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 a823c79f35..dba40a9353 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -6,24 +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 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 @@ -36,14 +37,14 @@ 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 ._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 @@ -128,6 +129,8 @@ class CssPathError(Exception): class _NullFile: + """A file-like where writes go nowhere.""" + def write(self, text: str) -> None: pass @@ -135,6 +138,68 @@ 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(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 + if text is None: + break + write(text) + if empty: + flush() + + def stop(self) -> None: + """Stop the thread, and block until it finished.""" + self._queue.put(None) + self.join() + + CSSPathType = Union[str, PurePath, List[Union[str, PurePath]], None] @@ -192,8 +257,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 +1576,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) 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 { 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]") diff --git a/src/textual/screen.py b/src/textual/screen.py index c7ff3a2e5e..97881e668c 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -18,8 +18,8 @@ from .widget import Widget -# Screen updates will be batched so that they don't happen more often than 60 times per second: -UPDATE_PERIOD: Final = 1 / 60 +# Screen updates will be batched so that they don't happen more often than 120 times per second: +UPDATE_PERIOD: Final[float] = 1 / 120 @rich.repr.auto