Skip to content

Commit

Permalink
Merge pull request #1104 from Textualize/thread-writes
Browse files Browse the repository at this point in the history
writer thread
  • Loading branch information
willmcgugan authored Nov 7, 2022
2 parents efbcbfd + 0af9fed commit 7c9e7d6
Show file tree
Hide file tree
Showing 5 changed files with 92 additions and 13 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
93 changes: 85 additions & 8 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -128,13 +129,77 @@ class CssPathError(Exception):


class _NullFile:
"""A file-like where writes go nowhere."""

def write(self, text: str) -> None:
pass

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]


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion src/textual/demo.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
* {
transition: background 250ms linear, color 250ms linear;
transition: background 500ms in_out_cubic, color 500ms in_out_cubic;
}

Screen {
Expand Down
4 changes: 2 additions & 2 deletions src/textual/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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]")


Expand Down
4 changes: 2 additions & 2 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 7c9e7d6

Please sign in to comment.