Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

writer thread #1104

Merged
merged 10 commits into from
Nov 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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):
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
"""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:
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
UPDATE_PERIOD: Final[float] = 1 / 120


@rich.repr.auto
Expand Down