From 0ff8c7e47c147b1c49f116b0fa46b141e409f411 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 8 Jun 2022 11:46:53 +0100 Subject: [PATCH] compositor and cache optimizations --- examples/pride.py | 21 +++++++++++++++++++++ poetry.lock | 32 +++++++++++++++----------------- pyproject.toml | 4 ++-- sandbox/basic.css | 18 +++++++++--------- sandbox/basic.py | 17 +++++++++++++++++ src/textual/_compositor.py | 15 +++++++-------- src/textual/_segment_tools.py | 13 +++++++++++-- src/textual/color.py | 3 ++- src/textual/css/scalar.py | 8 ++++++++ src/textual/geometry.py | 17 ++++++++++++++--- src/textual/layouts/vertical.py | 2 +- 11 files changed, 107 insertions(+), 43 deletions(-) create mode 100644 examples/pride.py diff --git a/examples/pride.py b/examples/pride.py new file mode 100644 index 000000000..d6da93797 --- /dev/null +++ b/examples/pride.py @@ -0,0 +1,21 @@ +from textual.app import App +from textual.widgets import Static + + +class PrideApp(App): + """Displays a pride flag.""" + + COLORS = ["red", "orange", "yellow", "green", "blue", "purple"] + + def compose(self): + for color in self.COLORS: + stripe = Static() + stripe.styles.height = "1fr" + stripe.styles.background = color + yield stripe + + +app = PrideApp() + +if __name__ == "__main__": + app.run() diff --git a/poetry.lock b/poetry.lock index 63c2789fd..71de43dae 100644 --- a/poetry.lock +++ b/poetry.lock @@ -345,7 +345,7 @@ mkdocs = ">=1.1" [[package]] name = "mkdocs-material" -version = "8.3.2" +version = "8.3.3" description = "Documentation that simply works" category = "dev" optional = false @@ -533,7 +533,7 @@ python-versions = ">=3.6" [[package]] name = "pymdown-extensions" -version = "9.4" +version = "9.5" description = "Extension pack for Python Markdown." category = "dev" optional = false @@ -658,21 +658,16 @@ version = "12.4.4" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" category = "main" optional = false -python-versions = "^3.6.3" -develop = true +python-versions = ">=3.6.3,<4.0.0" [package.dependencies] -commonmark = "^0.9.0" -pygments = "^2.6.0" -typing-extensions = {version = ">=4.0.0, <5.0", markers = "python_version < \"3.9\""} +commonmark = ">=0.9.0,<0.10.0" +pygments = ">=2.6.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] -[package.source] -type = "directory" -url = "../rich" - [[package]] name = "six" version = "1.16.0" @@ -782,7 +777,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "4aeef009c7c1f6a34d0dd1c3c16647b53a339f5963dcbe31eef43bb9dac270ab" +content-hash = "378a60041202d505cba26ea7084886fe1b01090a0035253b100593b559aed090" [metadata.files] aiohttp = [ @@ -1127,8 +1122,8 @@ mkdocs-autorefs = [ {file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"}, ] mkdocs-material = [ - {file = "mkdocs-material-8.3.2.tar.gz", hash = "sha256:d203ba166063ccd0ff83893a54f46d98e2fb3961babd7c0176cb3f95ac62df20"}, - {file = "mkdocs_material-8.3.2-py2.py3-none-any.whl", hash = "sha256:57d910ccb6d98d0d1281cbb34a9c8ae6d820b2f5caad1d1246d8ed49eec10a50"}, + {file = "mkdocs-material-8.3.3.tar.gz", hash = "sha256:3dd30af894f6d5da3d8a2f8ffc04c90c4d0f1be013e654ec45f608373c131542"}, + {file = "mkdocs_material-8.3.3-py2.py3-none-any.whl", hash = "sha256:4f9564af58f9c96f25c263cb705a40a82c833cb10c2626d6db6ddadedaa5b6c3"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"}, @@ -1319,8 +1314,8 @@ pygments = [ {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"}, ] pymdown-extensions = [ - {file = "pymdown_extensions-9.4-py3-none-any.whl", hash = "sha256:5b7432456bf555ce2b0ab3c2439401084cda8110f24f6b3ecef952b8313dfa1b"}, - {file = "pymdown_extensions-9.4.tar.gz", hash = "sha256:1baa22a60550f731630474cad28feb0405c8101f1a7ddc3ec0ed86ee510bcc43"}, + {file = "pymdown_extensions-9.5-py3-none-any.whl", hash = "sha256:ec141c0f4983755349f0c8710416348d1a13753976c028186ed14f190c8061c4"}, + {file = "pymdown_extensions-9.5.tar.gz", hash = "sha256:3ef2d998c0d5fa7eb09291926d90d69391283561cf6306f85cd588a5eb5befa0"}, ] pyparsing = [ {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, @@ -1386,7 +1381,10 @@ pyyaml-env-tag = [ {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, ] -rich = [] +rich = [ + {file = "rich-12.4.4-py3-none-any.whl", hash = "sha256:d2bbd99c320a2532ac71ff6a3164867884357da3e3301f0240090c5d2fdac7ec"}, + {file = "rich-12.4.4.tar.gz", hash = "sha256:4c586de507202505346f3e32d1363eb9ed6932f0c2f63184dea88983ff4971e2"}, +] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, diff --git a/pyproject.toml b/pyproject.toml index edcb9e10c..ff2941829 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,8 @@ textual = "textual.cli.cli:run" [tool.poetry.dependencies] python = "^3.7" -#rich = "^12.4.3" -rich = {path="../rich", develop=true} +rich = "^12.4.3" +#rich = {path="../rich", develop=true} click = "8.1.2" importlib-metadata = "^4.11.3" typing-extensions = { version = "^4.0.0", python = "<3.8" } diff --git a/sandbox/basic.css b/sandbox/basic.css index 9405dbfc6..439778337 100644 --- a/sandbox/basic.css +++ b/sandbox/basic.css @@ -76,8 +76,8 @@ App > Screen { Tweet { - height: 12; - width: 80; + height:12; + width: 100%; margin: 1 3; background: $panel; @@ -94,18 +94,18 @@ Tweet { .scrollable { - width: 80; + overflow-y: scroll; - max-width:80; + margin: 1 2; height: 20; align-horizontal: center; layout: vertical; } .code { - + height: 34; - width: 100%; + } @@ -189,7 +189,7 @@ OptionItem:hover { } Error { - width: 80; + width: 100%; height:3; background: $error; color: $text-error; @@ -202,7 +202,7 @@ Error { } Warning { - width: 80; + width: 100%; height:3; background: $warning; color: $text-warning-fade-1; @@ -214,7 +214,7 @@ Warning { } Success { - width: 80; + width: 100%; height:3; box-sizing: border-box; background: $success-lighten-3; diff --git a/sandbox/basic.py b/sandbox/basic.py index 2518ce1d1..65a829fda 100644 --- a/sandbox/basic.py +++ b/sandbox/basic.py @@ -117,6 +117,11 @@ class BasicApp(App, css_path="basic.css"): Warning(), Tweet(TweetBody(), classes="scroll-horizontal"), Success(), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), ), footer=Widget(), sidebar=Widget( @@ -154,3 +159,15 @@ app = BasicApp() if __name__ == "__main__": app.run() + + from textual.geometry import Region + from textual.color import Color + + print(Region.intersection.cache_info()) + print(Region.overlaps.cache_info()) + print(Region.union.cache_info()) + print(Region.split_vertical.cache_info()) + print(Region.__contains__.cache_info()) + from textual.css.scalar import Scalar + + print(Scalar.resolve_dimension.cache_info()) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 733c78b37..0aac433a5 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -68,7 +68,7 @@ class MapGeometry(NamedTuple): CompositorMap: TypeAlias = "dict[Widget, MapGeometry]" -@rich.repr.auto +@rich.repr.auto(angular=True) class LayoutUpdate: """A renderable containing the result of a render for a given region.""" @@ -89,11 +89,7 @@ class LayoutUpdate: yield new_line def __rich_repr__(self) -> rich.repr.Result: - x, y, width, height = self.region - yield "x", x - yield "y", y - yield "width", width - yield "height", height + yield self.region @rich.repr.auto(angular=True) @@ -558,7 +554,10 @@ class Compositor: delta_y = new_y - region.y crop_x = delta_x + new_width lines = widget.get_render_lines(delta_y, delta_y + new_height) - lines = [line_crop(line, delta_x, crop_x) for line in lines] + if (delta_x, crop_x) != (0, region.width): + lines = [ + line_crop(line, delta_x, crop_x, region.width) for line in lines + ] yield region, clip, lines @classmethod @@ -600,7 +599,7 @@ class Compositor: is_rendered_line = lambda y: True elif update_regions: # Create a crop regions that surrounds all updates - crop = Region.from_union(list(update_regions)).intersection(screen_region) + crop = Region.from_union(update_regions).intersection(screen_region) spans = list(self._regions_to_spans(update_regions)) is_rendered_line = {y for y, _, _ in spans}.__contains__ else: diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index 15a9f25fd..cd1b91f2b 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -7,14 +7,16 @@ from __future__ import annotations from rich.segment import Segment -def line_crop(segments: list[Segment], start: int, end: int) -> list[Segment]: +def line_crop( + segments: list[Segment], start: int, end: int, total: int +) -> list[Segment]: """Crops a list of segments between two cell offsets. Args: segments (list[Segment]): A list of Segments for a line. start (int): Start offset end (int): End offset - + total (int): Total cell length of segments. Returns: list[Segment]: A new shorter list of segments """ @@ -35,6 +37,13 @@ def line_crop(segments: list[Segment], start: int, end: int) -> list[Segment]: else: return [] + if end >= total: + # The end crop is the end of the segments, so we can collect all remaining segments + if segment: + add_segment(segment) + output_segments.extend(iter_segments) + return output_segments + pos = start while segment is not None: end_pos = pos + segment.cell_length diff --git a/src/textual/color.py b/src/textual/color.py index fd7fd0bd8..b6d906048 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -231,7 +231,6 @@ class Color(NamedTuple): r, g, b, _ = self return Color(r, g, b, alpha) - @lru_cache(maxsize=2048) def blend(self, destination: Color, factor: float) -> Color: """Generate a new color between two colors. @@ -418,6 +417,7 @@ class ColorPair(NamedTuple): ) +@lru_cache(maxsize=1024) def rgb_to_lab(rgb: Color) -> Lab: """Convert an RGB color to the CIE-L*ab format. @@ -444,6 +444,7 @@ def rgb_to_lab(rgb: Color) -> Lab: return Lab(116 * y - 16, 500 * (x - y), 200 * (y - z)) +@lru_cache(maxsize=1024) def lab_to_rgb(lab: Lab) -> Color: """Convert a CIE-L*ab color to RGB. diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index 7e349e785..f0a4083d9 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -223,6 +223,14 @@ class Scalar(NamedTuple): @classmethod def from_number(cls, value: float) -> Scalar: + """Create a scalar with cells unit. + + Args: + value (float): A number of cells. + + Returns: + Scalar: New Scalar. + """ return cls(float(value), Unit.CELLS, Unit.WIDTH) @classmethod diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 7d933c7e6..32495cfaa 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -6,9 +6,13 @@ Functions and classes to manage terminal geometry (anything involving coordinate from __future__ import annotations -from typing import Any, cast, NamedTuple, Sequence, Tuple, Union, TypeVar +from functools import lru_cache -SpacingDimensions = Union[int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]] +from typing import Any, cast, Collection, NamedTuple, Tuple, TypeAlias, Union, TypeVar + +SpacingDimensions: TypeAlias = Union[ + int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int] +] T = TypeVar("T", int, float) @@ -182,7 +186,7 @@ class Region(NamedTuple): height: int = 0 @classmethod - def from_union(cls, regions: Sequence[Region]) -> Region: + def from_union(cls, regions: Collection[Region]) -> Region: """Create a Region from the union of other regions. Args: @@ -356,6 +360,7 @@ class Region(NamedTuple): height + expand_height * 2, ) + @lru_cache(maxsize=1024) def overlaps(self, other: Region) -> bool: """Check if another region overlaps this region. @@ -433,6 +438,7 @@ class Region(NamedTuple): self_x, self_y, width, height = self return Region(self_x + x, self_y + y, width, height) + @lru_cache(maxsize=4096) def __contains__(self, other: Any) -> bool: """Check if a point is in this region.""" if isinstance(other, Region): @@ -483,6 +489,7 @@ class Region(NamedTuple): height=max(0, height - top - bottom), ) + @lru_cache(maxsize=4096) def intersection(self, region: Region) -> Region: """Get the overlapping portion of the two regions. @@ -507,6 +514,7 @@ class Region(NamedTuple): return Region(rx1, ry1, rx2 - rx1, ry2 - ry1) + @lru_cache(maxsize=4096) def union(self, region: Region) -> Region: """Get a new region that contains both regions. @@ -524,6 +532,7 @@ class Region(NamedTuple): ) return union_region + @lru_cache(maxsize=1024) def split(self, cut_x: int, cut_y: int) -> tuple[Region, Region, Region, Region]: """Split a region in to 4 from given x and y offsets (cuts). @@ -563,6 +572,7 @@ class Region(NamedTuple): _Region(x + cut_x, y + cut_y, width - cut_x, height - cut_y), ) + @lru_cache(maxsize=1024) def split_vertical(self, cut: int) -> tuple[Region, Region]: """Split a region in to two, from a given x offset. @@ -591,6 +601,7 @@ class Region(NamedTuple): Region(x + cut, y, width - cut, height), ) + @lru_cache(maxsize=1024) def split_horizontal(self, cut: int) -> tuple[Region, Region]: """Split a region in to two, from a given x offset. diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 1580f439d..023995a6a 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -31,7 +31,7 @@ class VerticalLayout(Layout): box_models = [ widget.get_box_model(size, parent_size, fraction_unit) - for widget in cast("list[Widget]", parent.children) + for widget in parent.children ] margins = [