From cdad0ff020f5a6b88c5158f74d47a388358a555d Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Thu, 12 May 2022 16:42:42 +0100 Subject: [PATCH 01/28] [App] Remove the focus timer: we now focus from a widget to the next/prev one instantly --- src/textual/app.py | 18 +----------------- tests/test_focus.py | 20 -------------------- 2 files changed, 1 insertion(+), 37 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 05c743418..068be5714 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -192,7 +192,6 @@ class App(Generic[ReturnType], DOMNode): self.registry: set[MessagePump] = set() self.devtools = DevtoolsClient() self._return_value: ReturnType | None = None - self._focus_timer: Timer | None = None self.css_monitor = ( FileMonitor(css_path, self._on_css_change) @@ -263,10 +262,6 @@ class App(Generic[ReturnType], DOMNode): Returns: Widget | None: Newly focused widget, or None for no focus. """ - if self._focus_timer: - # Cancel the timer that clears the show focus class - # We will be creating a new timer to extend the time until the focus is hidden - self._focus_timer.stop_no_wait() focusable_widgets = self.focus_chain if not focusable_widgets: @@ -284,12 +279,10 @@ class App(Generic[ReturnType], DOMNode): self.set_focus(focusable_widgets[0]) else: # Only move the focus if we are currently showing the focus - if direction and self.has_class("-show-focus"): + if direction: current_index = (current_index + direction) % len(focusable_widgets) self.set_focus(focusable_widgets[current_index]) - self._focus_timer = self.set_timer(2, self.hide_focus) - self.add_class("-show-focus") return self.focused def show_focus(self) -> Widget | None: @@ -316,15 +309,6 @@ class App(Generic[ReturnType], DOMNode): """ return self._move_focus(-1) - def hide_focus(self) -> None: - """Hide the focus. - - Returns: - Widget | None: Newly focused widget, or None for no focus. - - """ - self.remove_class("-show-focus") - def compose(self) -> ComposeResult: """Yield child widgets for a container.""" return diff --git a/tests/test_focus.py b/tests/test_focus.py index c9fa8bcd7..dfe8dad8a 100644 --- a/tests/test_focus.py +++ b/tests/test_focus.py @@ -31,26 +31,6 @@ async def test_focus_chain(): assert focused == ["foo", "Paul", "baz"] -async def test_show_focus(): - app = App() - app.push_screen(Screen()) - app.screen.add_children( - Focusable(id="foo"), - NonFocusable(id="bar"), - Focusable(Focusable(id="Paul"), id="container1"), - NonFocusable(Focusable(id="Jessica"), id="container2"), - Focusable(id="baz"), - ) - - focused = [widget.id for widget in app.focus_chain] - assert focused == ["foo", "Paul", "baz"] - - assert app.focused is None - assert not app.has_class("-show-focus") - app.show_focus() - assert app.has_class("-show-focus") - - async def test_focus_next_and_previous(): app = App() From 2dfda6e4dcc8aebd324ccbc2e6f1c3c6bea9e351 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 13 May 2022 15:56:58 +0100 Subject: [PATCH 02/28] prefer msgpack over json --- poetry.lock | 46 ++++++++++++++++++++++++++++- pyproject.toml | 1 + src/textual/devtools/client.py | 26 +++++++++------- src/textual/devtools/renderables.py | 12 ++------ src/textual/devtools/service.py | 20 ++++++++----- 5 files changed, 77 insertions(+), 28 deletions(-) diff --git a/poetry.lock b/poetry.lock index 1d289ffbe..7142087f0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -381,6 +381,14 @@ mkdocs-autorefs = ">=0.1" pymdown-extensions = ">=6.3" pytkdocs = ">=0.14.0" +[[package]] +name = "msgpack" +version = "1.0.3" +description = "MessagePack (de)serializer." +category = "main" +optional = false +python-versions = "*" + [[package]] name = "multidict" version = "6.0.2" @@ -765,7 +773,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "d801e69bdd847115e92104a8cdd51ba1f207a1b7c25c4f6c9fb88434594be975" +content-hash = "05e80f2e4709cbc33327e1ddfaf7ae19a7f708fae251834d631317dc4cf4cd2f" [metadata.files] aiohttp = [ @@ -1121,6 +1129,42 @@ mkdocstrings = [ {file = "mkdocstrings-0.17.0-py3-none-any.whl", hash = "sha256:103fc1dd58cb23b7e0a6da5292435f01b29dc6fa0ba829132537f3f556f985de"}, {file = "mkdocstrings-0.17.0.tar.gz", hash = "sha256:75b5cfa2039aeaf3a5f5cf0aa438507b0330ce76c8478da149d692daa7213a98"}, ] +msgpack = [ + {file = "msgpack-1.0.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:96acc674bb9c9be63fa8b6dabc3248fdc575c4adc005c440ad02f87ca7edd079"}, + {file = "msgpack-1.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c3ca57c96c8e69c1a0d2926a6acf2d9a522b41dc4253a8945c4c6cd4981a4e3"}, + {file = "msgpack-1.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0a792c091bac433dfe0a70ac17fc2087d4595ab835b47b89defc8bbabcf5c73"}, + {file = "msgpack-1.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c58cdec1cb5fcea8c2f1771d7b5fec79307d056874f746690bd2bdd609ab147"}, + {file = "msgpack-1.0.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f97c0f35b3b096a330bb4a1a9247d0bd7e1f3a2eba7ab69795501504b1c2c39"}, + {file = "msgpack-1.0.3-cp310-cp310-win32.whl", hash = "sha256:36a64a10b16c2ab31dcd5f32d9787ed41fe68ab23dd66957ca2826c7f10d0b85"}, + {file = "msgpack-1.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c1ba333b4024c17c7591f0f372e2daa3c31db495a9b2af3cf664aef3c14354f7"}, + {file = "msgpack-1.0.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c2140cf7a3ec475ef0938edb6eb363fa704159e0bf71dde15d953bacc1cf9d7d"}, + {file = "msgpack-1.0.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f4c22717c74d44bcd7af353024ce71c6b55346dad5e2cc1ddc17ce8c4507c6b"}, + {file = "msgpack-1.0.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d733a15ade190540c703de209ffbc42a3367600421b62ac0c09fde594da6ec"}, + {file = "msgpack-1.0.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e03b06f2982aa98d4ddd082a210c3db200471da523f9ac197f2828e80e7770"}, + {file = "msgpack-1.0.3-cp36-cp36m-win32.whl", hash = "sha256:3d875631ecab42f65f9dce6f55ce6d736696ced240f2634633188de2f5f21af9"}, + {file = "msgpack-1.0.3-cp36-cp36m-win_amd64.whl", hash = "sha256:40fb89b4625d12d6027a19f4df18a4de5c64f6f3314325049f219683e07e678a"}, + {file = "msgpack-1.0.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6eef0cf8db3857b2b556213d97dd82de76e28a6524853a9beb3264983391dc1a"}, + {file = "msgpack-1.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d8c332f53ffff01953ad25131272506500b14750c1d0ce8614b17d098252fbc"}, + {file = "msgpack-1.0.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0903bd93cbd34653dd63bbfcb99d7539c372795201f39d16fdfde4418de43a"}, + {file = "msgpack-1.0.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bf1e6bfed4860d72106f4e0a1ab519546982b45689937b40257cfd820650b920"}, + {file = "msgpack-1.0.3-cp37-cp37m-win32.whl", hash = "sha256:d02cea2252abc3756b2ac31f781f7a98e89ff9759b2e7450a1c7a0d13302ff50"}, + {file = "msgpack-1.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:2f30dd0dc4dfe6231ad253b6f9f7128ac3202ae49edd3f10d311adc358772dba"}, + {file = "msgpack-1.0.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f201d34dc89342fabb2a10ed7c9a9aaaed9b7af0f16a5923f1ae562b31258dea"}, + {file = "msgpack-1.0.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bb87f23ae7d14b7b3c21009c4b1705ec107cb21ee71975992f6aca571fb4a42a"}, + {file = "msgpack-1.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a3a5c4b16e9d0edb823fe54b59b5660cc8d4782d7bf2c214cb4b91a1940a8ef"}, + {file = "msgpack-1.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f74da1e5fcf20ade12c6bf1baa17a2dc3604958922de8dc83cbe3eff22e8b611"}, + {file = "msgpack-1.0.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:73a80bd6eb6bcb338c1ec0da273f87420829c266379c8c82fa14c23fb586cfa1"}, + {file = "msgpack-1.0.3-cp38-cp38-win32.whl", hash = "sha256:9fce00156e79af37bb6db4e7587b30d11e7ac6a02cb5bac387f023808cd7d7f4"}, + {file = "msgpack-1.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:9b6f2d714c506e79cbead331de9aae6837c8dd36190d02da74cb409b36162e8a"}, + {file = "msgpack-1.0.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:89908aea5f46ee1474cc37fbc146677f8529ac99201bc2faf4ef8edc023c2bf3"}, + {file = "msgpack-1.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:973ad69fd7e31159eae8f580f3f707b718b61141838321c6fa4d891c4a2cca52"}, + {file = "msgpack-1.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da24375ab4c50e5b7486c115a3198d207954fe10aaa5708f7b65105df09109b2"}, + {file = "msgpack-1.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a598d0685e4ae07a0672b59792d2cc767d09d7a7f39fd9bd37ff84e060b1a996"}, + {file = "msgpack-1.0.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4c309a68cb5d6bbd0c50d5c71a25ae81f268c2dc675c6f4ea8ab2feec2ac4e2"}, + {file = "msgpack-1.0.3-cp39-cp39-win32.whl", hash = "sha256:494471d65b25a8751d19c83f1a482fd411d7ca7a3b9e17d25980a74075ba0e88"}, + {file = "msgpack-1.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:f01b26c2290cbd74316990ba84a14ac3d599af9cebefc543d241a66e785cf17d"}, + {file = "msgpack-1.0.3.tar.gz", hash = "sha256:51fdc7fb93615286428ee7758cecc2f374d5ff363bdd884c7ea622a7a327a81e"}, +] multidict = [ {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b9e95a740109c6047602f4db4da9949e6c5945cefbad34a1299775ddc9a62e2"}, {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac0e27844758d7177989ce406acc6a83c16ed4524ebc363c1f748cba184d89d3"}, diff --git a/pyproject.toml b/pyproject.toml index 7fa77d670..8c13d28a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ rich = "^12.3.0" click = "8.1.2" importlib-metadata = "^4.11.3" typing-extensions = { version = "^4.0.0", python = "<3.8" } +msgpack = "^1.0.3" [tool.poetry.dev-dependencies] pytest = "^6.2.3" diff --git a/src/textual/devtools/client.py b/src/textual/devtools/client.py index 689abb96b..7a13c2bdb 100644 --- a/src/textual/devtools/client.py +++ b/src/textual/devtools/client.py @@ -1,11 +1,12 @@ from __future__ import annotations import asyncio -import base64 import datetime import inspect import json +import msgpack import pickle +from time import time from asyncio import Queue, Task, QueueFull from io import StringIO from typing import Type, Any, NamedTuple @@ -97,7 +98,7 @@ class DevtoolsClient: self.update_console_task: Task | None = None self.console: DevtoolsConsole = DevtoolsConsole(file=StringIO()) self.websocket: ClientWebSocketResponse | None = None - self.log_queue: Queue[str | Type[ClientShutdown]] | None = None + self.log_queue: Queue[str | bytes | Type[ClientShutdown]] | None = None self.spillover: int = 0 async def connect(self) -> None: @@ -144,7 +145,10 @@ class DevtoolsClient: if log is ClientShutdown: log_queue.task_done() break - await websocket.send_str(log) + if isinstance(log, str): + await websocket.send_str(log) + else: + await websocket.send_bytes(log) log_queue.task_done() self.log_queue_task = asyncio.create_task(send_queued_logs()) @@ -203,17 +207,18 @@ class DevtoolsClient: segments = self.console.export_segments() encoded_segments = self._encode_segments(segments) - message = json.dumps( + message: bytes | None = msgpack.packb( { "type": "client_log", "payload": { - "timestamp": int(datetime.datetime.utcnow().timestamp()), + "timestamp": int(time()), "path": getattr(log.caller, "filename", ""), "line_number": getattr(log.caller, "lineno", 0), - "encoded_segments": encoded_segments, + "segments": encoded_segments, }, } ) + assert message is not None try: if self.log_queue: self.log_queue.put_nowait(message) @@ -233,8 +238,8 @@ class DevtoolsClient: except QueueFull: self.spillover += 1 - def _encode_segments(self, segments: list[Segment]) -> str: - """Pickle and Base64 encode the list of Segments + def _encode_segments(self, segments: list[Segment]) -> bytes: + """Pickle a list of Segments Args: segments (list[Segment]): A list of Segments to encode @@ -242,6 +247,5 @@ class DevtoolsClient: Returns: str: The Segment list pickled with pickle protocol v3, then base64 encoded """ - pickled = pickle.dumps(segments, protocol=3) - encoded = base64.b64encode(pickled) - return str(encoded, encoding="utf-8") + pickled = pickle.dumps(segments, protocol=pickle.HIGHEST_PROTOCOL) + return pickled diff --git a/src/textual/devtools/renderables.py b/src/textual/devtools/renderables.py index 8cba84d39..378a5a758 100644 --- a/src/textual/devtools/renderables.py +++ b/src/textual/devtools/renderables.py @@ -72,19 +72,13 @@ class DevConsoleLog: def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: - local_time = ( - datetime.fromtimestamp(self.unix_timestamp) - .replace(tzinfo=timezone.utc) - .astimezone(tz=datetime.now().astimezone().tzinfo) - ) - timezone_name = local_time.tzname() + local_time = datetime.fromtimestamp(self.unix_timestamp) table = Table.grid(expand=True) - table.add_column() - table.add_column() + file_link = escape(f"file://{Path(self.path).absolute()}") file_and_line = escape(f"{Path(self.path).name}:{self.line_number}") table.add_row( - f"[dim]{local_time.time()} {timezone_name}", + f"[dim]{local_time.time()}", Align.right( Text(f"{file_and_line}", style=Style(dim=True, link=file_link)) ), diff --git a/src/textual/devtools/service.py b/src/textual/devtools/service.py index e70fb06a9..83acb702c 100644 --- a/src/textual/devtools/service.py +++ b/src/textual/devtools/service.py @@ -14,6 +14,7 @@ from aiohttp.abc import Request from aiohttp.web_ws import WebSocketResponse from rich.console import Console from rich.markup import escape +import msgpack from textual.devtools.renderables import ( DevConsoleLog, @@ -170,9 +171,9 @@ class ClientHandler: path = message_json["payload"]["path"] line_number = message_json["payload"]["line_number"] timestamp = message_json["payload"]["timestamp"] - encoded_segments = message_json["payload"]["encoded_segments"] - decoded_segments = base64.b64decode(encoded_segments) - segments = pickle.loads(decoded_segments) + encoded_segments = message_json["payload"]["segments"] + # decoded_segments = base64.b64decode(encoded_segments) + segments = pickle.loads(encoded_segments) message_time = time() if ( last_message_time is not None @@ -219,21 +220,26 @@ class ClientHandler: await self.service.send_server_info(client_handler=self) async for message in self.websocket: message = cast(WSMessage, message) - if message.type == WSMsgType.TEXT: + + if message.type in (WSMsgType.TEXT, WSMsgType.BINARY): + try: - message_json = json.loads(message.data) + if isinstance(message.data, bytes): + message = msgpack.unpackb(message.data) + else: + message = json.loads(message.data) except JSONDecodeError: self.service.console.print(escape(str(message.data))) continue - type = message_json.get("type") + type = message.get("type") if not type: continue if ( type in QUEUEABLE_TYPES and not self.service.shutdown_event.is_set() ): - await self.incoming_queue.put(message_json) + await self.incoming_queue.put(message) elif message.type == WSMsgType.ERROR: self.service.console.print( DevConsoleNotice("Websocket error occurred", level="error") From ffcf0afc9235b256a55987e70623201ad19b46e8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 13 May 2022 16:11:44 +0100 Subject: [PATCH 03/28] docstring --- src/textual/devtools/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/devtools/client.py b/src/textual/devtools/client.py index 7a13c2bdb..91319a3f0 100644 --- a/src/textual/devtools/client.py +++ b/src/textual/devtools/client.py @@ -245,7 +245,7 @@ class DevtoolsClient: segments (list[Segment]): A list of Segments to encode Returns: - str: The Segment list pickled with pickle protocol v3, then base64 encoded + bytes: The Segment list pickled with the latest protocol. """ pickled = pickle.dumps(segments, protocol=pickle.HIGHEST_PROTOCOL) return pickled From 673cadaf859163de20ca6fcdf32fc64252783d8a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 13 May 2022 16:19:43 +0100 Subject: [PATCH 04/28] tidy --- src/textual/devtools/client.py | 5 +++-- src/textual/devtools/service.py | 19 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/textual/devtools/client.py b/src/textual/devtools/client.py index 91319a3f0..86298472c 100644 --- a/src/textual/devtools/client.py +++ b/src/textual/devtools/client.py @@ -238,14 +238,15 @@ class DevtoolsClient: except QueueFull: self.spillover += 1 - def _encode_segments(self, segments: list[Segment]) -> bytes: + @classmethod + def _encode_segments(cls, segments: list[Segment]) -> bytes: """Pickle a list of Segments Args: segments (list[Segment]): A list of Segments to encode Returns: - bytes: The Segment list pickled with the latest protocol. + bytes: The Segment list pickled with the latest protocol. """ pickled = pickle.dumps(segments, protocol=pickle.HIGHEST_PROTOCOL) return pickled diff --git a/src/textual/devtools/service.py b/src/textual/devtools/service.py index 83acb702c..6bc79db61 100644 --- a/src/textual/devtools/service.py +++ b/src/textual/devtools/service.py @@ -161,18 +161,17 @@ class ClientHandler: """ last_message_time: float | None = None while True: - message_json = await self.incoming_queue.get() - if message_json is None: + message = await self.incoming_queue.get() + if message is None: self.incoming_queue.task_done() break - type = message_json["type"] + type = message["type"] if type == "client_log": - path = message_json["payload"]["path"] - line_number = message_json["payload"]["line_number"] - timestamp = message_json["payload"]["timestamp"] - encoded_segments = message_json["payload"]["segments"] - # decoded_segments = base64.b64decode(encoded_segments) + path = message["payload"]["path"] + line_number = message["payload"]["line_number"] + timestamp = message["payload"]["timestamp"] + encoded_segments = message["payload"]["segments"] segments = pickle.loads(encoded_segments) message_time = time() if ( @@ -180,7 +179,7 @@ class ClientHandler: and message_time - last_message_time > 1 ): # Print a rule if it has been longer than a second since the last message - self.service.console.rule("") + self.service.console.rule() self.service.console.print( DevConsoleLog( segments=segments, @@ -191,7 +190,7 @@ class ClientHandler: ) last_message_time = message_time elif type == "client_spillover": - spillover = int(message_json["payload"]["spillover"]) + spillover = int(message["payload"]["spillover"]) info_renderable = DevConsoleNotice( f"Discarded {spillover} messages", level="warning" ) From f7fb6f613a9515703ca2f7945cc31a0746cdd7d0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 13 May 2022 16:38:09 +0100 Subject: [PATCH 05/28] test fix --- tests/devtools/test_devtools.py | 20 ++++++++----------- tests/devtools/test_devtools_client.py | 27 ++++++++++++++------------ tests/devtools/test_redirect_output.py | 20 +++++++++++-------- 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/tests/devtools/test_devtools.py b/tests/devtools/test_devtools.py index 833c3daf2..f66766445 100644 --- a/tests/devtools/test_devtools.py +++ b/tests/devtools/test_devtools.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import datetime import pytest import time_machine @@ -6,22 +6,23 @@ from rich.align import Align from rich.console import Console from rich.segment import Segment +import msgpack from tests.utilities.render import wait_for_predicate from textual.devtools.renderables import DevConsoleLog, DevConsoleNotice TIMESTAMP = 1649166819 WIDTH = 40 # The string "Hello, world!" is encoded in the payload below -EXAMPLE_LOG = { +_EXAMPLE_LOG = { "type": "client_log", "payload": { - "encoded_segments": "gASVQgAAAAAAAABdlCiMDHJpY2guc2VnbWVudJSMB1NlZ" - "21lbnSUk5SMDUhlbGxvLCB3b3JsZCGUTk6HlIGUaAOMAQqUTk6HlIGUZS4=", + "segments": b"\x80\x04\x955\x00\x00\x00\x00\x00\x00\x00]\x94\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\rHello, world!\x94NN\x87\x94\x81\x94a.", "line_number": 123, "path": "abc/hello.py", "timestamp": TIMESTAMP, }, } +EXAMPLE_LOG = msgpack.packb(_EXAMPLE_LOG) @pytest.fixture(scope="module") @@ -48,15 +49,10 @@ def test_log_message_render(console): right: Align = right_cells[0] # Since we can't guarantee the timezone the tests will run in... - local_time = ( - datetime.fromtimestamp(TIMESTAMP) - .replace(tzinfo=timezone.utc) - .astimezone(tz=datetime.now().astimezone().tzinfo) - ) - timezone_name = local_time.tzname() + local_time = datetime.fromtimestamp(TIMESTAMP) string_timestamp = local_time.time() - assert left == f"[dim]{string_timestamp} {timezone_name}" + assert left == f"[dim]{string_timestamp}" assert right.align == "right" assert "hello.py:123" in right.renderable @@ -69,7 +65,7 @@ def test_internal_message_render(console): async def test_devtools_valid_client_log(devtools): - await devtools.websocket.send_json(EXAMPLE_LOG) + await devtools.websocket.send_bytes(EXAMPLE_LOG) assert devtools.is_connected diff --git a/tests/devtools/test_devtools_client.py b/tests/devtools/test_devtools_client.py index 008ee8f28..177549b42 100644 --- a/tests/devtools/test_devtools_client.py +++ b/tests/devtools/test_devtools_client.py @@ -7,6 +7,7 @@ import time_machine from aiohttp.web_ws import WebSocketResponse from rich.console import ConsoleDimensions from rich.panel import Panel +import msgpack from tests.utilities.render import wait_for_predicate from textual.devtools.client import DevtoolsClient @@ -32,14 +33,15 @@ async def test_devtools_log_places_encodes_and_queues_message(devtools): await devtools._stop_log_queue_processing() devtools.log(DevtoolsLog("Hello, world!", CALLER)) queued_log = await devtools.log_queue.get() - queued_log_json = json.loads(queued_log) - assert queued_log_json == { + queued_log_data = msgpack.unpackb(queued_log) + print(repr(queued_log_data)) + assert queued_log_data == { "type": "client_log", "payload": { - "timestamp": TIMESTAMP, - "path": CALLER_PATH, - "line_number": CALLER_LINENO, - "encoded_segments": "gANdcQAoY3JpY2guc2VnbWVudApTZWdtZW50CnEBWA0AAABIZWxsbywgd29ybGQhcQJOTodxA4FxBGgBWAEAAAAKcQVOTodxBoFxB2Uu", + "timestamp": 1649170419, + "path": "a/b/c.py", + "line_number": 123, + "segments": b"\x80\x05\x95B\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\rHello, world!\x94NN\x87\x94\x81\x94h\x03\x8c\x01\n\x94NN\x87\x94\x81\x94e.", }, } @@ -49,14 +51,15 @@ async def test_devtools_log_places_encodes_and_queues_many_logs_as_string(devtoo await devtools._stop_log_queue_processing() devtools.log(DevtoolsLog(("hello", "world"), CALLER)) queued_log = await devtools.log_queue.get() - queued_log_json = json.loads(queued_log) - assert queued_log_json == { + queued_log_data = msgpack.unpackb(queued_log) + print(repr(queued_log_data)) + assert queued_log_data == { "type": "client_log", "payload": { - "timestamp": TIMESTAMP, - "path": CALLER_PATH, - "line_number": CALLER_LINENO, - "encoded_segments": "gANdcQAoY3JpY2guc2VnbWVudApTZWdtZW50CnEBWAsAAABoZWxsbyB3b3JsZHECTk6HcQOBcQRoAVgBAAAACnEFTk6HcQaBcQdlLg==", + "timestamp": 1649170419, + "path": "a/b/c.py", + "line_number": 123, + "segments": b"\x80\x05\x95@\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\x0bhello world\x94NN\x87\x94\x81\x94h\x03\x8c\x01\n\x94NN\x87\x94\x81\x94e.", }, } diff --git a/tests/devtools/test_redirect_output.py b/tests/devtools/test_redirect_output.py index 6974e0d6a..f1fc69515 100644 --- a/tests/devtools/test_redirect_output.py +++ b/tests/devtools/test_redirect_output.py @@ -4,12 +4,13 @@ from datetime import datetime import time_machine +import msgpack from textual.devtools.redirect_output import StdoutRedirector TIMESTAMP = 1649166819 -@time_machine.travel(datetime.fromtimestamp(TIMESTAMP)) +@time_machine.travel(datetime.utcfromtimestamp(TIMESTAMP)) async def test_print_redirect_to_devtools_only(devtools): await devtools._stop_log_queue_processing() @@ -19,14 +20,15 @@ async def test_print_redirect_to_devtools_only(devtools): assert devtools.log_queue.qsize() == 1 queued_log = await devtools.log_queue.get() - queued_log_json = json.loads(queued_log) - payload = queued_log_json["payload"] + queued_log_data = msgpack.unpackb(queued_log) + print(repr(queued_log_data)) + payload = queued_log_data["payload"] - assert queued_log_json["type"] == "client_log" + assert queued_log_data["type"] == "client_log" assert payload["timestamp"] == TIMESTAMP assert ( - payload["encoded_segments"] - == "gANdcQAoY3JpY2guc2VnbWVudApTZWdtZW50CnEBWA0AAABIZWxsbywgd29ybGQhcQJOTodxA4FxBGgBWAEAAAAKcQVOTodxBoFxB2Uu" + payload["segments"] + == b"\x80\x05\x95B\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\rHello, world!\x94NN\x87\x94\x81\x94h\x03\x8c\x01\n\x94NN\x87\x94\x81\x94e." ) @@ -86,8 +88,10 @@ async def test_print_multiple_args_batched_as_one_log(devtools, in_memory_logfil assert queued_log_json["type"] == "client_log" assert payload["timestamp"] == TIMESTAMP - assert payload[ - "encoded_segments"] == "gANdcQAoY3JpY2guc2VnbWVudApTZWdtZW50CnEBWBQAAABIZWxsbyB3b3JsZCBtdWx0aXBsZXECTk6HcQOBcQRoAVgBAAAACnEFTk6HcQaBcQdlLg==" + assert ( + payload["encoded_segments"] + == "gANdcQAoY3JpY2guc2VnbWVudApTZWdtZW50CnEBWBQAAABIZWxsbyB3b3JsZCBtdWx0aXBsZXECTk6HcQOBcQRoAVgBAAAACnEFTk6HcQaBcQdlLg==" + ) assert len(payload["path"]) > 0 assert payload["line_number"] != 0 From f26d917a72a828e500d9eb6030da909e4be64c8c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 13 May 2022 16:45:50 +0100 Subject: [PATCH 06/28] fix tests for 3.8 --- src/textual/devtools/client.py | 2 +- tests/devtools/test_devtools_client.py | 4 ++-- tests/devtools/test_redirect_output.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/textual/devtools/client.py b/src/textual/devtools/client.py index 86298472c..08a5e50b3 100644 --- a/src/textual/devtools/client.py +++ b/src/textual/devtools/client.py @@ -248,5 +248,5 @@ class DevtoolsClient: Returns: bytes: The Segment list pickled with the latest protocol. """ - pickled = pickle.dumps(segments, protocol=pickle.HIGHEST_PROTOCOL) + pickled = pickle.dumps(segments, protocol=3) return pickled diff --git a/tests/devtools/test_devtools_client.py b/tests/devtools/test_devtools_client.py index 177549b42..efa1a38ab 100644 --- a/tests/devtools/test_devtools_client.py +++ b/tests/devtools/test_devtools_client.py @@ -41,7 +41,7 @@ async def test_devtools_log_places_encodes_and_queues_message(devtools): "timestamp": 1649170419, "path": "a/b/c.py", "line_number": 123, - "segments": b"\x80\x05\x95B\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\rHello, world!\x94NN\x87\x94\x81\x94h\x03\x8c\x01\n\x94NN\x87\x94\x81\x94e.", + "segments": b"\x80\x03]q\x00(crich.segment\nSegment\nq\x01X\r\x00\x00\x00Hello, world!q\x02NN\x87q\x03\x81q\x04h\x01X\x01\x00\x00\x00\nq\x05NN\x87q\x06\x81q\x07e.", }, } @@ -59,7 +59,7 @@ async def test_devtools_log_places_encodes_and_queues_many_logs_as_string(devtoo "timestamp": 1649170419, "path": "a/b/c.py", "line_number": 123, - "segments": b"\x80\x05\x95@\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\x0bhello world\x94NN\x87\x94\x81\x94h\x03\x8c\x01\n\x94NN\x87\x94\x81\x94e.", + "segments": b"\x80\x03]q\x00(crich.segment\nSegment\nq\x01X\x0b\x00\x00\x00hello worldq\x02NN\x87q\x03\x81q\x04h\x01X\x01\x00\x00\x00\nq\x05NN\x87q\x06\x81q\x07e.", }, } diff --git a/tests/devtools/test_redirect_output.py b/tests/devtools/test_redirect_output.py index f1fc69515..442be0825 100644 --- a/tests/devtools/test_redirect_output.py +++ b/tests/devtools/test_redirect_output.py @@ -28,7 +28,7 @@ async def test_print_redirect_to_devtools_only(devtools): assert payload["timestamp"] == TIMESTAMP assert ( payload["segments"] - == b"\x80\x05\x95B\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\rHello, world!\x94NN\x87\x94\x81\x94h\x03\x8c\x01\n\x94NN\x87\x94\x81\x94e." + == b"\x80\x03]q\x00(crich.segment\nSegment\nq\x01X\r\x00\x00\x00Hello, world!q\x02NN\x87q\x03\x81q\x04h\x01X\x01\x00\x00\x00\nq\x05NN\x87q\x06\x81q\x07e." ) From 53837c1a81371f5c5f69eefe47327271afcdce39 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 13 May 2022 16:52:15 +0100 Subject: [PATCH 07/28] force highest protocol --- src/textual/devtools/client.py | 2 +- tests/devtools/test_devtools_client.py | 4 ++-- tests/devtools/test_redirect_output.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/textual/devtools/client.py b/src/textual/devtools/client.py index 08a5e50b3..5c7ea51fc 100644 --- a/src/textual/devtools/client.py +++ b/src/textual/devtools/client.py @@ -248,5 +248,5 @@ class DevtoolsClient: Returns: bytes: The Segment list pickled with the latest protocol. """ - pickled = pickle.dumps(segments, protocol=3) + pickled = pickle.dumps(segments, protocol=4) return pickled diff --git a/tests/devtools/test_devtools_client.py b/tests/devtools/test_devtools_client.py index efa1a38ab..7dac85929 100644 --- a/tests/devtools/test_devtools_client.py +++ b/tests/devtools/test_devtools_client.py @@ -41,7 +41,7 @@ async def test_devtools_log_places_encodes_and_queues_message(devtools): "timestamp": 1649170419, "path": "a/b/c.py", "line_number": 123, - "segments": b"\x80\x03]q\x00(crich.segment\nSegment\nq\x01X\r\x00\x00\x00Hello, world!q\x02NN\x87q\x03\x81q\x04h\x01X\x01\x00\x00\x00\nq\x05NN\x87q\x06\x81q\x07e.", + "segments": b"\x80\x04\x95B\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\rHello, world!\x94NN\x87\x94\x81\x94h\x03\x8c\x01\n\x94NN\x87\x94\x81\x94e.", }, } @@ -59,7 +59,7 @@ async def test_devtools_log_places_encodes_and_queues_many_logs_as_string(devtoo "timestamp": 1649170419, "path": "a/b/c.py", "line_number": 123, - "segments": b"\x80\x03]q\x00(crich.segment\nSegment\nq\x01X\x0b\x00\x00\x00hello worldq\x02NN\x87q\x03\x81q\x04h\x01X\x01\x00\x00\x00\nq\x05NN\x87q\x06\x81q\x07e.", + "segments": b"\x80\x04\x95@\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\x0bhello world\x94NN\x87\x94\x81\x94h\x03\x8c\x01\n\x94NN\x87\x94\x81\x94e.", }, } diff --git a/tests/devtools/test_redirect_output.py b/tests/devtools/test_redirect_output.py index 442be0825..349c2aa69 100644 --- a/tests/devtools/test_redirect_output.py +++ b/tests/devtools/test_redirect_output.py @@ -28,7 +28,7 @@ async def test_print_redirect_to_devtools_only(devtools): assert payload["timestamp"] == TIMESTAMP assert ( payload["segments"] - == b"\x80\x03]q\x00(crich.segment\nSegment\nq\x01X\r\x00\x00\x00Hello, world!q\x02NN\x87q\x03\x81q\x04h\x01X\x01\x00\x00\x00\nq\x05NN\x87q\x06\x81q\x07e." + == b"\x80\x04\x95B\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\rHello, world!\x94NN\x87\x94\x81\x94h\x03\x8c\x01\n\x94NN\x87\x94\x81\x94e." ) From d20f1299f6388c874be23bc79490b06ea79ca8bc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 13 May 2022 16:59:47 +0100 Subject: [PATCH 08/28] timezone fix --- src/textual/devtools/renderables.py | 2 +- tests/devtools/test_devtools_client.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/textual/devtools/renderables.py b/src/textual/devtools/renderables.py index 378a5a758..1cf0b6320 100644 --- a/src/textual/devtools/renderables.py +++ b/src/textual/devtools/renderables.py @@ -1,7 +1,7 @@ from __future__ import annotations import sys -from datetime import datetime, timezone +from datetime import datetime from pathlib import Path from typing import Iterable diff --git a/tests/devtools/test_devtools_client.py b/tests/devtools/test_devtools_client.py index 7dac85929..84a57b3c6 100644 --- a/tests/devtools/test_devtools_client.py +++ b/tests/devtools/test_devtools_client.py @@ -28,8 +28,9 @@ async def test_devtools_client_is_connected(devtools): assert devtools.is_connected -@time_machine.travel(datetime.fromtimestamp(TIMESTAMP)) +@time_machine.travel(datetime.utcfromtimestamp(TIMESTAMP)) async def test_devtools_log_places_encodes_and_queues_message(devtools): + await devtools._stop_log_queue_processing() devtools.log(DevtoolsLog("Hello, world!", CALLER)) queued_log = await devtools.log_queue.get() @@ -38,7 +39,7 @@ async def test_devtools_log_places_encodes_and_queues_message(devtools): assert queued_log_data == { "type": "client_log", "payload": { - "timestamp": 1649170419, + "timestamp": 1649166819, "path": "a/b/c.py", "line_number": 123, "segments": b"\x80\x04\x95B\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\rHello, world!\x94NN\x87\x94\x81\x94h\x03\x8c\x01\n\x94NN\x87\x94\x81\x94e.", @@ -46,7 +47,7 @@ async def test_devtools_log_places_encodes_and_queues_message(devtools): } -@time_machine.travel(datetime.fromtimestamp(TIMESTAMP)) +@time_machine.travel(datetime.utcfromtimestamp(TIMESTAMP)) async def test_devtools_log_places_encodes_and_queues_many_logs_as_string(devtools): await devtools._stop_log_queue_processing() devtools.log(DevtoolsLog(("hello", "world"), CALLER)) @@ -56,7 +57,7 @@ async def test_devtools_log_places_encodes_and_queues_many_logs_as_string(devtoo assert queued_log_data == { "type": "client_log", "payload": { - "timestamp": 1649170419, + "timestamp": 1649166819, "path": "a/b/c.py", "line_number": 123, "segments": b"\x80\x04\x95@\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\x0bhello world\x94NN\x87\x94\x81\x94h\x03\x8c\x01\n\x94NN\x87\x94\x81\x94e.", From 3f90b951588121e932b0c873c6fa42c73c341cee Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 12:12:01 +0100 Subject: [PATCH 09/28] added save_screenshot method and action --- src/textual/app.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index 068be5714..d7f0939c8 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1,7 +1,9 @@ from __future__ import annotations import asyncio +from datetime import datetime import inspect +import io import os import platform import sys @@ -411,6 +413,33 @@ class App(Generic[ReturnType], DOMNode): except Exception: pass + def action_screenshot(self, path: str | None = None) -> None: + """Action to save a screenshot.""" + self.save_screenshot(path) + + def save_screenshot(self, path: str | None = None) -> None: + """Save an SVG screenshot of the current screen. + + Args: + path (str | None, optional): Path of the SVG to save, or None to + generate a path automatically. Defaults to None. + """ + if path is None: + svg_path = f"{self.title.lower()}_{datetime.now().isoformat()}.svg" + svg_path = svg_path.replace("/", "_").replace("\\", "_") + else: + svg_path = path + console = Console( + width=self.console.width, + height=self.console.height, + file=io.StringIO(), + force_terminal=True, + color_system="truecolor", + record=True, + ) + console.print(self.screen._compositor) + console.save_svg(svg_path, title=self.title) + def bind( self, keys: str, From acefea31c24d5f7570c16a933de96b227201f048 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 12:22:57 +0100 Subject: [PATCH 10/28] factored save and export screenshot --- src/textual/app.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index d7f0939c8..c59999aed 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -417,18 +417,14 @@ class App(Generic[ReturnType], DOMNode): """Action to save a screenshot.""" self.save_screenshot(path) - def save_screenshot(self, path: str | None = None) -> None: - """Save an SVG screenshot of the current screen. + def export_screenshot(self) -> str: + """Export a SVG screenshot of the current screen. Args: path (str | None, optional): Path of the SVG to save, or None to generate a path automatically. Defaults to None. """ - if path is None: - svg_path = f"{self.title.lower()}_{datetime.now().isoformat()}.svg" - svg_path = svg_path.replace("/", "_").replace("\\", "_") - else: - svg_path = path + console = Console( width=self.console.width, height=self.console.height, @@ -438,7 +434,27 @@ class App(Generic[ReturnType], DOMNode): record=True, ) console.print(self.screen._compositor) - console.save_svg(svg_path, title=self.title) + return console.export_svg(title=self.title) + + def save_screenshot(self, path: str | None = None) -> str: + """Save a screenshot of the current screen. + + Args: + path (str | None, optional): Path to SVG to save or None to pick + a filename automatically. Defaults to None. + + Returns: + str: Filename of screenshot. + """ + if path is None: + svg_path = f"{self.title.lower()}_{datetime.now().isoformat()}.svg" + svg_path = svg_path.replace("/", "_").replace("\\", "_") + else: + svg_path = path + screenshot_svg = self.export_screenshot() + with open(svg_path, "w") as svg_file: + svg_file.write(screenshot_svg) + return svg_path def bind( self, From 8c9263be548cd071994b47611061e780ac3e3337 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 18:04:06 +0100 Subject: [PATCH 11/28] compare animation frames --- src/textual/_animator.py | 1 + src/textual/_compositor.py | 87 ++++++++++++++++++++++++++------------ src/textual/app.py | 5 +++ src/textual/geometry.py | 4 +- src/textual/screen.py | 16 ++++--- 5 files changed, 78 insertions(+), 35 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 6af8c912c..c6d228bbc 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -249,5 +249,6 @@ class Animator: self.on_animation_frame() def on_animation_frame(self) -> None: + return # TODO: We should be able to do animation without refreshing everything self.target.screen.refresh_layout() diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 80d1d14ed..052eda535 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -26,7 +26,7 @@ from rich.style import Style from . import errors from .geometry import Region, Offset, Size - +from ._profile import timer from ._loop import loop_last from ._segment_tools import line_crop from ._types import Lines @@ -59,6 +59,11 @@ class MapGeometry(NamedTuple): virtual_size: Size # The virtual size (scrollable region) of a widget if it is a container container_size: Size # The container size (area not occupied by scrollbars) + @property + def visible_region(self) -> Region: + """The Widget region after clipping.""" + return self.clip.intersection(self.region) + CompositorMap: TypeAlias = "dict[Widget, MapGeometry]" @@ -77,12 +82,13 @@ class LayoutUpdate: x = self.region.x new_line = Segment.line() move_to = Control.move_to + yield Control.home() for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)): - yield Control.home() yield move_to(x, y) yield from line if not last: yield new_line + yield Control.home() def __rich_repr__(self) -> rich.repr.Result: x, y, width, height = self.region @@ -109,11 +115,13 @@ class SpansUpdate: ) -> RenderResult: move_to = Control.move_to new_line = Segment.line() + yield Control.home() for last, (y, x, segments) in loop_last(self.spans): yield move_to(x, y) yield from segments if not last: yield new_line + yield Control.home() def __rich_repr__(self) -> rich.repr.Result: yield [(y, x, "...") for y, x, _segments in self.spans] @@ -144,6 +152,11 @@ class Compositor: # The points in each line where the line bisects the left and right edges of the widget self._cuts: list[list[int]] | None = None + self._dirty_regions: set[Region] = set() + + def add_dirty_regions(self, regions: Iterable[Region]) -> None: + self._dirty_regions.update(regions) + @classmethod def _regions_to_spans( cls, regions: Iterable[Region] @@ -198,11 +211,12 @@ class Compositor: self.root = parent self.size = size + old_map = self.map.copy() # TODO: Handle virtual size map, widgets = self._arrange_root(parent) - old_widgets = set(self.map.keys()) - new_widgets = set(map.keys()) + old_widgets = self.map.keys() + new_widgets = map.keys() # Newly visible widgets shown_widgets = new_widgets - old_widgets # Newly hidden widgets @@ -225,10 +239,23 @@ class Compositor: if widget in old_widgets and widget.size != region.size } + screen_region = size.region + with timer("delta"): + updates: set[Region] = { + map[widget].visible_region + for widget in (shown_widgets | hidden_widgets) + } + add_region = updates.add + for widget in old_widgets & new_widgets: + if map[widget] != old_map[widget]: + add_region(map[widget].visible_region) + add_region(old_map[widget].visible_region) + self._dirty_regions.update( + [screen_region.intersection(update) for update in updates] + ) + return ReflowResult( - hidden=hidden_widgets, - shown=shown_widgets, - resized=resized_widgets, + hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets ) def _arrange_root(self, root: Widget) -> tuple[CompositorMap, set[Widget]]: @@ -516,27 +543,39 @@ class Compositor: ] return segment_lines - def render(self, regions: list[Region] | None = None) -> RenderableType: + def render(self, full: bool = False) -> RenderableType: """Render a layout. Args: - clip (Optional[Region]): Region to clip to. + full (bool): Perform a full render (ignore dirty regions) Returns: SegmentLines: A renderable """ width, height = self.size screen_region = Region(0, 0, width, height) - if regions: - # Create a crop regions that surrounds all updates - crop = Region.from_union(regions).intersection(screen_region) - spans = list(self._regions_to_spans(regions)) - is_rendered_line = {y for y, _, _ in spans}.__contains__ - else: - crop = screen_region - spans = [] - is_rendered_line = lambda y: True + if full: + update_regions = set() + crop = screen_region + is_rendered_line = lambda y: True + else: + update_regions = self._dirty_regions.copy() + self._dirty_regions.clear() + + if update_regions: + # Create a crop regions that surrounds all updates + crop = Region.from_union(list(update_regions)).intersection( + screen_region + ) + spans = list(self._regions_to_spans(update_regions)) + is_rendered_line = {y for y, _, _ in spans}.__contains__ + else: + crop = screen_region + spans = [] + is_rendered_line = lambda y: True + + print("CROP", crop) _Segment = Segment divide = _Segment.divide @@ -569,7 +608,6 @@ class Compositor: else: render_x = render_region.x relative_cuts = [cut - render_x for cut in final_cuts] - # print(relative_cuts) _, *cut_segments = divide(line, relative_cuts) # Since we are painting front to back, the first segments for a cut "wins" @@ -578,7 +616,7 @@ class Compositor: if chops_line[cut] is None: chops_line[cut] = segments - if regions: + if update_regions: crop_y, crop_y2 = crop.y_extents render_lines = self._assemble_chops(chops[crop_y:crop_y2]) render_spans = [ @@ -594,17 +632,15 @@ class Compositor: def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: - yield self.render() + yield self.render(full=True) - def update_widgets(self, widgets: set[Widget]) -> RenderableType | None: + def update_widgets(self, widgets: set[Widget]): """Update a given widget in the composition. Args: console (Console): Console instance. widget (Widget): Widget to update. - Returns: - LayoutUpdate | None: A renderable or None if nothing to render. """ regions: list[Region] = [] add_region = regions.append @@ -613,5 +649,4 @@ class Compositor: update_region = region.intersection(clip) if update_region: add_region(update_region) - update = self.render(regions or None) - return update + self.add_dirty_regions(regions) diff --git a/src/textual/app.py b/src/textual/app.py index c59999aed..6da4d454e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -875,10 +875,15 @@ class App(Generic[ReturnType], DOMNode): return if not self._closed: console = self.console + if self._sync_available: + console.file.write("\x1bP=1s\x1b\\") try: console.print(renderable) except Exception as error: self.on_exception(error) + if self._sync_available: + console.file.write("\x1bP=2s\x1b\\") + console.file.flush() def measure(self, renderable: RenderableType, max_width=100_000) -> int: """Get the optimal width for a widget or renderable. diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 3973e3eac..9e4480306 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -6,7 +6,7 @@ Functions and classes to manage terminal geometry (anything involving coordinate from __future__ import annotations -from typing import Any, cast, Iterable, NamedTuple, Tuple, Union, TypeVar +from typing import Any, cast, NamedTuple, Sequence, Tuple, Union, TypeVar SpacingDimensions = Union[int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]] @@ -182,7 +182,7 @@ class Region(NamedTuple): height: int = 0 @classmethod - def from_union(cls, regions: list[Region]) -> Region: + def from_union(cls, regions: Sequence[Region]) -> Region: """Create a Region from the union of other regions. Args: diff --git a/src/textual/screen.py b/src/textual/screen.py index 51de850ff..11f9694a4 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -94,13 +94,11 @@ class Screen(Widget): def _on_update(self) -> None: """Called by the _update_timer.""" - # Render widgets together - if self._dirty_widgets: + if self._dirty_widgets or self._dirty_regions: self.log(dirty=self._dirty_widgets) - display_update = self._compositor.update_widgets(self._dirty_widgets) - if display_update is not None: - self.app.display(display_update) + self._compositor.update_widgets(self._dirty_widgets) + self.app.display(self._compositor.render()) self._dirty_widgets.clear() self._update_timer.pause() @@ -109,8 +107,9 @@ class Screen(Widget): if not self.size: return # This paint the entire screen, so replaces the batched dirty widgets + # self._on_update() + self._compositor.update_widgets(self._dirty_widgets) self._update_timer.pause() - self._dirty_widgets.clear() try: hidden, shown, resized = self._compositor.reflow(self, self.size) @@ -140,7 +139,10 @@ class Screen(Widget): except Exception as error: self.app.on_exception(error) return - self.app.refresh() + + display_update = self._compositor.render() + if display_update is not None: + self.app.display(display_update) async def handle_update(self, message: messages.Update) -> None: message.stop() From 37fb5884a802a3c66e888930e784d1185629b855 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 18:07:03 +0100 Subject: [PATCH 12/28] docstring --- src/textual/_compositor.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 052eda535..4b3a763e5 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -155,6 +155,11 @@ class Compositor: self._dirty_regions: set[Region] = set() def add_dirty_regions(self, regions: Iterable[Region]) -> None: + """Add dirty regions to be repainted next call to render. + + Args: + regions (Iterable[Region]): Regions that are "dirty" (changed since last render). + """ self._dirty_regions.update(regions) @classmethod From 46b94a38f88e8ff5ea315ea8d72870428c11b742 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 18:18:58 +0100 Subject: [PATCH 13/28] comments --- src/textual/_compositor.py | 29 ++++++++++++++--------------- src/textual/screen.py | 1 - 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 4b3a763e5..61ee2fbba 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -244,20 +244,20 @@ class Compositor: if widget in old_widgets and widget.size != region.size } - screen_region = size.region - with timer("delta"): - updates: set[Region] = { - map[widget].visible_region - for widget in (shown_widgets | hidden_widgets) - } - add_region = updates.add - for widget in old_widgets & new_widgets: - if map[widget] != old_map[widget]: - add_region(map[widget].visible_region) - add_region(old_map[widget].visible_region) - self._dirty_regions.update( - [screen_region.intersection(update) for update in updates] - ) + # Updates (regions) that need repainting + # Hidden widgets and shown widgets will need repainting + updates: set[Region] = { + map[widget].visible_region for widget in (shown_widgets | hidden_widgets) + } + add_region = updates.add + # Widgets that have moved in any way (position, ordering, etc) + for widget in old_widgets & new_widgets: + if map[widget] != old_map[widget]: + add_region(map[widget].visible_region) + add_region(old_map[widget].visible_region) + # Crop region to the screen + crop_screen = size.region.intersection + self._dirty_regions.update([crop_screen(update) for update in updates]) return ReflowResult( hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets @@ -580,7 +580,6 @@ class Compositor: spans = [] is_rendered_line = lambda y: True - print("CROP", crop) _Segment = Segment divide = _Segment.divide diff --git a/src/textual/screen.py b/src/textual/screen.py index 11f9694a4..fc44f4718 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -96,7 +96,6 @@ class Screen(Widget): """Called by the _update_timer.""" # Render widgets together if self._dirty_widgets or self._dirty_regions: - self.log(dirty=self._dirty_widgets) self._compositor.update_widgets(self._dirty_widgets) self.app.display(self._compositor.render()) self._dirty_widgets.clear() From c15db0613d7e1aa400ba687cf9a02d535e78d4d3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 18:28:20 +0100 Subject: [PATCH 14/28] optimization --- src/textual/_compositor.py | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 61ee2fbba..ed80083c6 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -246,18 +246,31 @@ class Compositor: # Updates (regions) that need repainting # Hidden widgets and shown widgets will need repainting - updates: set[Region] = { - map[widget].visible_region for widget in (shown_widgets | hidden_widgets) - } - add_region = updates.add - # Widgets that have moved in any way (position, ordering, etc) - for widget in old_widgets & new_widgets: - if map[widget] != old_map[widget]: - add_region(map[widget].visible_region) - add_region(old_map[widget].visible_region) - # Crop region to the screen crop_screen = size.region.intersection - self._dirty_regions.update([crop_screen(update) for update in updates]) + + updates = self._dirty_regions + updates.update( + [ + crop_screen(map[widget].visible_region) + for widget in (shown_widgets | hidden_widgets) + ] + ) + # Widgets that have moved in any way (position, ordering, etc) + changed_widgets = [ + widget + for widget in old_widgets & new_widgets + if map[widget] != old_map[widget] + ] + if changed_widgets: + updates.update( + [crop_screen(map[widget].visible_region) for widget in changed_widgets] + ) + updates.update( + [ + crop_screen(old_map[widget].visible_region) + for widget in changed_widgets + ] + ) return ReflowResult( hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets From 4208c237786d3f477236c96f74af88afd31f8535 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 18:31:24 +0100 Subject: [PATCH 15/28] comments --- src/textual/_compositor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index ed80083c6..152926c09 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -244,10 +244,9 @@ class Compositor: if widget in old_widgets and widget.size != region.size } - # Updates (regions) that need repainting + # Calculate regions that need repainting # Hidden widgets and shown widgets will need repainting crop_screen = size.region.intersection - updates = self._dirty_regions updates.update( [ @@ -255,13 +254,14 @@ class Compositor: for widget in (shown_widgets | hidden_widgets) ] ) - # Widgets that have moved in any way (position, ordering, etc) + # Widgets that have moved in any way (position, ordering, etc.) changed_widgets = [ widget for widget in old_widgets & new_widgets if map[widget] != old_map[widget] ] if changed_widgets: + # Paint the old position and the new position updates.update( [crop_screen(map[widget].visible_region) for widget in changed_widgets] ) From 1882bd608caa3a01cad3051937e97187b3b97396 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 21:57:51 +0100 Subject: [PATCH 16/28] remove import --- src/textual/_compositor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 152926c09..1e4857b49 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -26,7 +26,6 @@ from rich.style import Style from . import errors from .geometry import Region, Offset, Size -from ._profile import timer from ._loop import loop_last from ._segment_tools import line_crop from ._types import Lines From e17e82698f9dcafe36f8592640a75a56e598bd01 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 22:17:34 +0100 Subject: [PATCH 17/28] simplify --- src/textual/_compositor.py | 8 ++------ src/textual/screen.py | 2 +- src/textual/widget.py | 4 ++-- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 1e4857b49..3cbc15441 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -81,13 +81,11 @@ class LayoutUpdate: x = self.region.x new_line = Segment.line() move_to = Control.move_to - yield Control.home() for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)): yield move_to(x, y) yield from line if not last: yield new_line - yield Control.home() def __rich_repr__(self) -> rich.repr.Result: x, y, width, height = self.region @@ -114,13 +112,11 @@ class SpansUpdate: ) -> RenderResult: move_to = Control.move_to new_line = Segment.line() - yield Control.home() for last, (y, x, segments) in loop_last(self.spans): yield move_to(x, y) yield from segments if not last: yield new_line - yield Control.home() def __rich_repr__(self) -> rich.repr.Result: yield [(y, x, "...") for y, x, _segments in self.spans] @@ -151,6 +147,7 @@ class Compositor: # The points in each line where the line bisects the left and right edges of the widget self._cuts: list[list[int]] | None = None + # Regions that require an update self._dirty_regions: set[Region] = set() def add_dirty_regions(self, regions: Iterable[Region]) -> None: @@ -592,8 +589,7 @@ class Compositor: spans = [] is_rendered_line = lambda y: True - _Segment = Segment - divide = _Segment.divide + divide = Segment.divide # Maps each cut on to a list of segments cuts = self.cuts diff --git a/src/textual/screen.py b/src/textual/screen.py index fc44f4718..fc1a7145b 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -95,7 +95,7 @@ class Screen(Widget): def _on_update(self) -> None: """Called by the _update_timer.""" # Render widgets together - if self._dirty_widgets or self._dirty_regions: + if self._dirty_widgets: self._compositor.update_widgets(self._dirty_widgets) self.app.display(self._compositor.render()) self._dirty_widgets.clear() diff --git a/src/textual/widget.py b/src/textual/widget.py index 09b315eb0..ff5a90933 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -104,8 +104,8 @@ class Widget(DOMNode): has_focus = Reactive(False) descendant_has_focus = Reactive(False) mouse_over = Reactive(False) - scroll_x = Reactive(0.0, repaint=False) - scroll_y = Reactive(0.0, repaint=False) + scroll_x = Reactive(0.0, repaint=False, layout=True) + scroll_y = Reactive(0.0, repaint=False, layout=True) scroll_target_x = Reactive(0.0, repaint=False) scroll_target_y = Reactive(0.0, repaint=False) show_vertical_scrollbar = Reactive(False, layout=True) From 220b8bcccc6ee0884b39ee034bce708d57326806 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 22:26:10 +0100 Subject: [PATCH 18/28] comment --- src/textual/_animator.py | 6 ------ src/textual/_compositor.py | 1 + 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index c6d228bbc..c03378953 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -246,9 +246,3 @@ class Animator: animation = self._animations[animation_key] if animation(animation_time): del self._animations[animation_key] - self.on_animation_frame() - - def on_animation_frame(self) -> None: - return - # TODO: We should be able to do animation without refreshing everything - self.target.screen.refresh_layout() diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 3cbc15441..141451591 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -212,6 +212,7 @@ class Compositor: self.root = parent self.size = size + # Keep a copy of the old map because we're going to compare it with the update old_map = self.map.copy() # TODO: Handle virtual size map, widgets = self._arrange_root(parent) From da3f8f98189291dca5c130719ce9dd430b6af341 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 22:31:56 +0100 Subject: [PATCH 19/28] simplify --- src/textual/_compositor.py | 2 +- src/textual/app.py | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 141451591..d7b17ff2d 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -645,7 +645,7 @@ class Compositor: def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: - yield self.render(full=True) + yield self.render() def update_widgets(self, widgets: set[Widget]): """Update a given widget in the composition. diff --git a/src/textual/app.py b/src/textual/app.py index 6da4d454e..b875e7aa1 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -30,10 +30,8 @@ else: import rich import rich.repr from rich.console import Console, RenderableType -from rich.control import Control from rich.measure import Measurement from rich.protocol import is_renderable -from rich.screen import Screen as ScreenRenderable from rich.segment import Segments from rich.style import Style from rich.traceback import Traceback @@ -845,13 +843,7 @@ class App(Generic[ReturnType], DOMNode): try: if self._sync_available: console.file.write("\x1bP=1s\x1b\\") - console.print( - ScreenRenderable( - Control.home(), - self.screen._compositor, - Control.home(), - ) - ) + console.print(self.screen._compositor) if self._sync_available: console.file.write("\x1bP=2s\x1b\\") console.file.flush() From 1b9a2704117230ee869af662266341ae62ab73d1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 22:35:01 +0100 Subject: [PATCH 20/28] simplify --- src/textual/_compositor.py | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index d7b17ff2d..1cb3f1f73 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -558,37 +558,27 @@ class Compositor: ] return segment_lines - def render(self, full: bool = False) -> RenderableType: + def render(self) -> RenderableType: """Render a layout. - Args: - full (bool): Perform a full render (ignore dirty regions) - Returns: SegmentLines: A renderable """ width, height = self.size screen_region = Region(0, 0, width, height) - if full: - update_regions = set() - crop = screen_region - is_rendered_line = lambda y: True - else: - update_regions = self._dirty_regions.copy() - self._dirty_regions.clear() + update_regions = self._dirty_regions.copy() + self._dirty_regions.clear() - if update_regions: - # Create a crop regions that surrounds all updates - crop = Region.from_union(list(update_regions)).intersection( - screen_region - ) - spans = list(self._regions_to_spans(update_regions)) - is_rendered_line = {y for y, _, _ in spans}.__contains__ - else: - crop = screen_region - spans = [] - is_rendered_line = lambda y: True + if update_regions: + # Create a crop regions that surrounds all updates + crop = Region.from_union(list(update_regions)).intersection(screen_region) + spans = list(self._regions_to_spans(update_regions)) + is_rendered_line = {y for y, _, _ in spans}.__contains__ + else: + crop = screen_region + spans = [] + is_rendered_line = lambda y: True divide = Segment.divide From 5d50fa2a9052c339d23ec55196311fda702b3d29 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 22:42:03 +0100 Subject: [PATCH 21/28] tidy and typing --- src/textual/_compositor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 1cb3f1f73..38fd2c3ab 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -270,7 +270,9 @@ class Compositor: ) return ReflowResult( - hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets + hidden=hidden_widgets, + shown=shown_widgets, + resized=resized_widgets, ) def _arrange_root(self, root: Widget) -> tuple[CompositorMap, set[Widget]]: @@ -637,7 +639,7 @@ class Compositor: ) -> RenderResult: yield self.render() - def update_widgets(self, widgets: set[Widget]): + def update_widgets(self, widgets: set[Widget]) -> None: """Update a given widget in the composition. Args: From 2d4d30c312be0a7e04e005a90e6c292448275709 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 22:42:33 +0100 Subject: [PATCH 22/28] comment --- src/textual/screen.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index fc1a7145b..9375cd17f 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -106,7 +106,6 @@ class Screen(Widget): if not self.size: return # This paint the entire screen, so replaces the batched dirty widgets - # self._on_update() self._compositor.update_widgets(self._dirty_widgets) self._update_timer.pause() try: From 97942faf3602e0ad8bcc53764cecea471c59aa2e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 22:49:34 +0100 Subject: [PATCH 23/28] remove refresh --- src/textual/app.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index b875e7aa1..38d49e3b4 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -552,7 +552,6 @@ class App(Generic[ReturnType], DOMNode): def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: self.register(self.screen, *anon_widgets, **widgets) - self.screen.refresh() def push_screen(self, screen: Screen | None = None) -> Screen: """Push a new screen on the screen stack. @@ -757,7 +756,6 @@ class App(Generic[ReturnType], DOMNode): widgets = list(self.compose()) if widgets: self.mount(*widgets) - self.screen.refresh() async def on_idle(self) -> None: """Perform actions when there are no messages in the queue.""" From 77fcef7a540de66b3d632a5d151e507c8822eca0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 16 May 2022 08:44:43 +0100 Subject: [PATCH 24/28] optimized deltas --- src/textual/_compositor.py | 35 +++++++++-------------------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 38fd2c3ab..50f2e7c3d 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -26,6 +26,7 @@ from rich.style import Style from . import errors from .geometry import Region, Offset, Size +from ._profile import timer from ._loop import loop_last from ._segment_tools import line_crop from ._types import Lines @@ -228,7 +229,7 @@ class Compositor: self.map = map self.widgets = widgets - # Copy renders if the size hasn't changed + # Get a map of regions self.regions = { widget: (region, clip) for widget, (region, _order, clip, _, _) in map.items() @@ -241,33 +242,13 @@ class Compositor: if widget in old_widgets and widget.size != region.size } - # Calculate regions that need repainting - # Hidden widgets and shown widgets will need repainting + # Gets pairs of tuples of (Widget, MapGeometry) which have changed + # i.e. if something is moved / deleted / added crop_screen = size.region.intersection - updates = self._dirty_regions - updates.update( - [ - crop_screen(map[widget].visible_region) - for widget in (shown_widgets | hidden_widgets) - ] + changes: set[tuple[Widget, MapGeometry]] = self.map.items() ^ old_map.items() + self._dirty_regions.update( + [crop_screen(map_geometry.visible_region) for _, map_geometry in changes] ) - # Widgets that have moved in any way (position, ordering, etc.) - changed_widgets = [ - widget - for widget in old_widgets & new_widgets - if map[widget] != old_map[widget] - ] - if changed_widgets: - # Paint the old position and the new position - updates.update( - [crop_screen(map[widget].visible_region) for widget in changed_widgets] - ) - updates.update( - [ - crop_screen(old_map[widget].visible_region) - for widget in changed_widgets - ] - ) return ReflowResult( hidden=hidden_widgets, @@ -584,6 +565,8 @@ class Compositor: divide = Segment.divide + print("CROP", crop) + # Maps each cut on to a list of segments cuts = self.cuts # dict.fromkeys is a callable which takes a list of ints returns a dict which maps ints on to a list of Segments or None. From e0db9914345aa46a61847fe06c182b427a56eb62 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 16 May 2022 09:15:14 +0100 Subject: [PATCH 25/28] optimize, fix scroll_to_region --- src/textual/_compositor.py | 22 +++++++++++++++------- src/textual/widget.py | 15 +++++---------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 50f2e7c3d..42f5ad3fa 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -244,11 +244,18 @@ class Compositor: # Gets pairs of tuples of (Widget, MapGeometry) which have changed # i.e. if something is moved / deleted / added - crop_screen = size.region.intersection - changes: set[tuple[Widget, MapGeometry]] = self.map.items() ^ old_map.items() - self._dirty_regions.update( - [crop_screen(map_geometry.visible_region) for _, map_geometry in changes] - ) + screen = size.region + if screen not in self._dirty_regions: + crop_screen = screen.intersection + changes: set[tuple[Widget, MapGeometry]] = ( + self.map.items() ^ old_map.items() + ) + self._dirty_regions.update( + [ + crop_screen(map_geometry.visible_region) + for _, map_geometry in changes + ] + ) return ReflowResult( hidden=hidden_widgets, @@ -551,6 +558,9 @@ class Compositor: screen_region = Region(0, 0, width, height) update_regions = self._dirty_regions.copy() + if screen_region in update_regions: + # If one of the updates is the entire screen, then we only need one update + update_regions.clear() self._dirty_regions.clear() if update_regions: @@ -565,8 +575,6 @@ class Compositor: divide = Segment.divide - print("CROP", crop) - # Maps each cut on to a list of segments cuts = self.cuts # dict.fromkeys is a callable which takes a list of ints returns a dict which maps ints on to a list of Segments or None. diff --git a/src/textual/widget.py b/src/textual/widget.py index ff5a90933..16de19f07 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -431,16 +431,13 @@ class Widget(DOMNode): Returns: bool: True if the scroll position changed, otherwise False. """ - screen = self.screen + try: - widget_geometry = screen.find_widget(widget) - container_geometry = screen.find_widget(self) + widget_region = widget.content_region + container_region = self.content_region except errors.NoWidget: return False - widget_region = widget.content_region + widget_geometry.region.origin - container_region = self.content_region + container_geometry.region.origin - if widget_region in container_region: # Widget is visible, nothing to do return False @@ -610,10 +607,8 @@ class Widget(DOMNode): @property def content_region(self) -> Region: - """A region relative to the Widget origin that contains the content.""" - x, y = self.styles.content_gutter.top_left - width, height = self._container_size - return Region(x, y, width, height) + """Gets an absolute region containing the content (minus padding and border).""" + return self.region.shrink(self.styles.content_gutter) @property def content_offset(self) -> Offset: From 36dd8b67e822f8fc032e743bc0c1fcd74a631833 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 16 May 2022 09:19:37 +0100 Subject: [PATCH 26/28] import --- src/textual/_compositor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 42f5ad3fa..8f74c13a4 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -26,7 +26,6 @@ from rich.style import Style from . import errors from .geometry import Region, Offset, Size -from ._profile import timer from ._loop import loop_last from ._segment_tools import line_crop from ._types import Lines From 3e45949a5f1985d4c5ab9de9ed0a3e3e02a1a734 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 16 May 2022 11:47:40 +0100 Subject: [PATCH 27/28] tody --- sandbox/basic.css | 1 - src/textual/_compositor.py | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/sandbox/basic.css b/sandbox/basic.css index 3934f521c..e474a0d5e 100644 --- a/sandbox/basic.css +++ b/sandbox/basic.css @@ -6,7 +6,6 @@ transition: color 300ms linear, background 300ms linear; } - * { scrollbar-background: $panel-darken-2; scrollbar-background-hover: $panel-darken-3; diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 8f74c13a4..a49843808 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -37,7 +37,6 @@ else: # pragma: no cover if TYPE_CHECKING: - from .screen import Screen from .widget import Widget @@ -214,11 +213,10 @@ class Compositor: # Keep a copy of the old map because we're going to compare it with the update old_map = self.map.copy() - # TODO: Handle virtual size + old_widgets = old_map.keys() map, widgets = self._arrange_root(parent) - - old_widgets = self.map.keys() new_widgets = map.keys() + # Newly visible widgets shown_widgets = new_widgets - old_widgets # Newly hidden widgets From ec8eda7c6de0b6c043882bc4faa9f2c1c95a59dc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 16 May 2022 15:17:10 +0100 Subject: [PATCH 28/28] fix test --- tests/test_animator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_animator.py b/tests/test_animator.py index 6f9e500df..76caf31dd 100644 --- a/tests/test_animator.py +++ b/tests/test_animator.py @@ -208,7 +208,6 @@ def test_animator(): animator() assert animate_test.foo == 0 - assert animator._on_animation_frame_called animator._time = 5 animator()