From 7de6ceea9c13b9b1b2555fe99456468a99709c06 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 3 Nov 2022 20:45:19 +0000 Subject: [PATCH 01/10] writer thread --- src/textual/app.py | 56 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index a823c79f35..abc40179c4 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -13,6 +13,9 @@ from contextlib import redirect_stderr, redirect_stdout from datetime import datetime from pathlib import Path, PurePath +from queue import Queue +from ._profile import timer +import threading from time import perf_counter from typing import ( Any, @@ -135,6 +138,45 @@ def flush(self) -> None: pass +class _WriterThread(threading.Thread): + """A thread / file-like to do writes to stdout in the background.""" + + def __init__(self) -> None: + super().__init__(daemon=True) + self._queue: Queue[str | None | threading.Event] = Queue(10) + self._file = sys.__stdout__ + + def write(self, text: str) -> None: + self._queue.put(text) + + def isatty(self) -> bool: + return True + + def fileno(self) -> int: + return self._file.fileno() + + def flush(self) -> None: + event = threading.Event() + self._queue.put(event) + event.wait() + self._file.flush() + + def run(self) -> None: + write = self._file.write + while True: + text: str | threading.Event | None = self._queue.get() + if isinstance(text, threading.Event): + text.set() + continue + if text is None: + break + write(text) + + def stop(self) -> None: + self._queue.put(None) + self.join() + + CSSPathType = Union[str, PurePath, List[Union[str, PurePath]], None] @@ -192,8 +234,17 @@ def __init__( no_color = environ.pop("NO_COLOR", None) if no_color is not None: self._filter = Monochrome() + + self._writer_thread: _WriterThread | None = None + if sys.__stdout__ is None: + file = _NullFile() + else: + self._writer_thread = _WriterThread() + self._writer_thread.start() + file = self._writer_thread + self.console = Console( - file=sys.__stdout__ if sys.__stdout__ is not None else _NullFile(), + file=file, markup=False, highlight=False, emoji=False, @@ -1502,6 +1553,9 @@ async def _shutdown(self) -> None: if self.devtools is not None and self.devtools.is_connected: await self._disconnect_devtools() + if self._writer_thread is not None: + self._writer_thread.stop() + async def _on_exit_app(self) -> None: await self._message_queue.put(None) From 4be39235f2655a5e01e6092eb11f2942b8d6d5ae Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 3 Nov 2022 21:35:00 +0000 Subject: [PATCH 02/10] bump update period --- src/textual/app.py | 2 +- src/textual/screen.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index abc40179c4..9bd8f89f6a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -143,7 +143,7 @@ class _WriterThread(threading.Thread): def __init__(self) -> None: super().__init__(daemon=True) - self._queue: Queue[str | None | threading.Event] = Queue(10) + self._queue: Queue[str | None | threading.Event] = Queue(16) self._file = sys.__stdout__ def write(self, text: str) -> None: diff --git a/src/textual/screen.py b/src/textual/screen.py index c7ff3a2e5e..9ee50ba031 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -19,7 +19,7 @@ # Screen updates will be batched so that they don't happen more often than 60 times per second: -UPDATE_PERIOD: Final = 1 / 60 +UPDATE_PERIOD: Final[float] = 1 / 120 @rich.repr.auto From 24ec9ac84a6a2729b69e61c8a9c37e20456cabbc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 4 Nov 2022 09:13:48 +0000 Subject: [PATCH 03/10] don't block on flush --- src/textual/app.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 9bd8f89f6a..05fed8b1a6 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -143,7 +143,7 @@ class _WriterThread(threading.Thread): def __init__(self) -> None: super().__init__(daemon=True) - self._queue: Queue[str | None | threading.Event] = Queue(16) + self._queue: Queue[str | None] = Queue(32) self._file = sys.__stdout__ def write(self, text: str) -> None: @@ -156,21 +156,18 @@ def fileno(self) -> int: return self._file.fileno() def flush(self) -> None: - event = threading.Event() - self._queue.put(event) - event.wait() - self._file.flush() + return def run(self) -> None: write = self._file.write + flush = self._file.flush + get = self._queue.get while True: - text: str | threading.Event | None = self._queue.get() - if isinstance(text, threading.Event): - text.set() - continue + text: str | None = get() if text is None: break write(text) + flush() def stop(self) -> None: self._queue.put(None) From ec2a057c707a05e6e5bef2810b53bc451b432e55 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 4 Nov 2022 09:44:20 +0000 Subject: [PATCH 04/10] reduce flushes --- src/textual/app.py | 4 +++- src/textual/demo.css | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 05fed8b1a6..8b3601c142 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -162,12 +162,14 @@ def run(self) -> None: write = self._file.write flush = self._file.flush get = self._queue.get + qsize = self._queue.qsize while True: + if not qsize(): + flush() text: str | None = get() if text is None: break write(text) - flush() def stop(self) -> None: self._queue.put(None) diff --git a/src/textual/demo.css b/src/textual/demo.css index fd968f51ef..c6b7fecad8 100644 --- a/src/textual/demo.css +++ b/src/textual/demo.css @@ -1,5 +1,5 @@ * { - transition: background 250ms linear, color 250ms linear; + transition: background 500ms in_out_cubic, color 500ms in_out_cubic; } Screen { From f30814b31a69ef8b4e433533595561f0155dfdc7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 4 Nov 2022 09:52:46 +0000 Subject: [PATCH 05/10] tweak animation --- src/textual/demo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/demo.py b/src/textual/demo.py index f3401ba7cc..ac364eaae8 100644 --- a/src/textual/demo.py +++ b/src/textual/demo.py @@ -219,7 +219,7 @@ def compose(self) -> ComposeResult: def on_button_pressed(self, event: Button.Pressed) -> None: self.app.add_note("[b magenta]Start!") - self.app.query_one(".location-first").scroll_visible(speed=50, top=True) + self.app.query_one(".location-first").scroll_visible(duration=0.5, top=True) class OptionGroup(Container): @@ -272,7 +272,7 @@ def __init__(self, label: str, reveal: str) -> None: self.reveal = reveal def on_click(self) -> None: - self.app.query_one(self.reveal).scroll_visible(top=True) + self.app.query_one(self.reveal).scroll_visible(top=True, duration=0.5) self.app.add_note(f"Scrolling to [b]{self.reveal}[/b]") From f10906fc13691a61fa258e0e67b3f106ddbab4b9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 4 Nov 2022 09:55:08 +0000 Subject: [PATCH 06/10] comment --- src/textual/screen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 9ee50ba031..97881e668c 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -18,7 +18,7 @@ from .widget import Widget -# Screen updates will be batched so that they don't happen more often than 60 times per second: +# Screen updates will be batched so that they don't happen more often than 120 times per second: UPDATE_PERIOD: Final[float] = 1 / 120 From 219a4f93270168e38cbb6465d4991314c612b5e1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 4 Nov 2022 09:57:01 +0000 Subject: [PATCH 07/10] imports --- src/textual/app.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 8b3601c142..fdfca8b478 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -6,27 +6,25 @@ import os import platform import sys +import threading import unicodedata import warnings from asyncio import Task -from contextlib import asynccontextmanager -from contextlib import redirect_stderr, redirect_stdout +from contextlib import asynccontextmanager, redirect_stderr, redirect_stdout from datetime import datetime from pathlib import Path, PurePath from queue import Queue -from ._profile import timer -import threading from time import perf_counter from typing import ( + TYPE_CHECKING, Any, Generic, Iterable, + List, Type, - TYPE_CHECKING, TypeVar, - cast, Union, - List, + cast, ) from weakref import WeakSet, WeakValueDictionary @@ -39,7 +37,7 @@ from rich.traceback import Traceback from . import Logger, LogGroup, LogVerbosity, actions, events, log, messages -from ._animator import Animator, DEFAULT_EASING, Animatable, EasingFunction +from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction from ._ansi_sequences import SYNC_END, SYNC_START from ._callback import invoke from ._context import active_app From d465d933fd1d86227ac79ac8a40decbb83afad54 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 4 Nov 2022 09:59:20 +0000 Subject: [PATCH 08/10] flush earlier --- src/textual/app.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index fdfca8b478..ba304203de 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -162,12 +162,13 @@ def run(self) -> None: get = self._queue.get qsize = self._queue.qsize while True: - if not qsize(): - flush() text: str | None = get() + empty = qsize() == 0 if text is None: break write(text) + if empty: + flush() def stop(self) -> None: self._queue.put(None) From de9a150484b9a2ddfcccd2802f418be22643bc5f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 5 Nov 2022 13:17:46 +0000 Subject: [PATCH 09/10] changelog --- CHANGELOG.md | 2 ++ src/textual/app.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71350ccefd..440cb162c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `DOMNode.query_one` now raises a `TooManyMatches` exception if there is more than one matching node. https://github.com/Textualize/textual/issues/1096 +- `App.mount` and `Widget.mount` have new `before` and `after` parameters https://github.com/Textualize/textual/issues/778 ### Added - Added `init` param to reactive.watch - `CSS_PATH` can now be a list of CSS files https://github.com/Textualize/textual/pull/1079 - Added `DOMQuery.only_one` https://github.com/Textualize/textual/issues/1096 +- Writes to stdout are now done in a thread, for smoother animation. https://github.com/Textualize/textual/pull/1104 ## [0.3.0] - 2022-10-31 diff --git a/src/textual/app.py b/src/textual/app.py index ba304203de..cf04d8e7cc 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -129,6 +129,8 @@ class CssPathError(Exception): class _NullFile: + """A file-like where writes go nowhere.""" + def write(self, text: str) -> None: pass From 0af9fed65969894d604e32a177120f0a03857265 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 7 Nov 2022 11:31:09 +0000 Subject: [PATCH 10/10] added constant, and docstrings --- src/textual/app.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index cf04d8e7cc..dba40a9353 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -44,7 +44,7 @@ from ._event_broker import NoHandler, extract_handler_actions from ._filter import LineFilter, Monochrome from ._path import _make_path_object_relative -from ._typing import TypeAlias +from ._typing import TypeAlias, Final from .binding import Binding, Bindings from .css.query import NoMatches from .css.stylesheet import Stylesheet @@ -138,31 +138,53 @@ def flush(self) -> None: pass +MAX_QUEUED_WRITES: Final[int] = 30 + + class _WriterThread(threading.Thread): """A thread / file-like to do writes to stdout in the background.""" def __init__(self) -> None: super().__init__(daemon=True) - self._queue: Queue[str | None] = Queue(32) + self._queue: Queue[str | None] = Queue(MAX_QUEUED_WRITES) self._file = sys.__stdout__ def write(self, text: str) -> None: + """Write text. Text will be enqueued for writing. + + Args: + text (str): Text to write to the file. + """ self._queue.put(text) def isatty(self) -> bool: + """Pretend to be a terminal. + + Returns: + bool: True if this is a tty. + """ return True def fileno(self) -> int: + """Get file handle number. + + Returns: + int: File number of proxied file. + """ return self._file.fileno() def flush(self) -> None: + """Flush the file (a no-op, because flush is done in the thread).""" return def run(self) -> None: + """Run the thread.""" write = self._file.write flush = self._file.flush get = self._queue.get qsize = self._queue.qsize + # Read from the queue, write to the file. + # Flush when there is a break. while True: text: str | None = get() empty = qsize() == 0 @@ -173,6 +195,7 @@ def run(self) -> None: flush() def stop(self) -> None: + """Stop the thread, and block until it finished.""" self._queue.put(None) self.join()