From c249548c43f6831db76d59aba5cc10c600335afa Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 12 Apr 2023 17:10:21 +0100 Subject: [PATCH] Move responsibility for writing in to driver (#2273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Move responsibility for writing in to driver * remove driver property * optimization for segments * force terminal * Update src/textual/drivers/_writer_thread.py Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> * no safe box * safe box false * force null file --------- Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/_compositor.py | 16 +++- src/textual/app.py | 123 ++++++++------------------ src/textual/driver.py | 3 + src/textual/drivers/_writer_thread.py | 69 +++++++++++++++ src/textual/drivers/linux_driver.py | 15 +++- src/textual/drivers/windows_driver.py | 14 ++- 6 files changed, 145 insertions(+), 95 deletions(-) create mode 100644 src/textual/drivers/_writer_thread.py diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 4aa65f227..985046021 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -94,11 +94,15 @@ class LayoutUpdate: def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: + return self.iter_segments() + + def iter_segments(self) -> Iterable[Segment]: + """Get an iterable of segments.""" x = self.region.x new_line = Segment.line() move_to = Control.move_to for last, (y, line) in loop_last(enumerate(self.strips, self.region.y)): - yield move_to(x, y) + yield move_to(x, y).segment yield from line if not last: yield new_line @@ -131,6 +135,10 @@ class ChopsUpdate: def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: + return self.iter_segments() + + def iter_segments(self) -> Iterable[Segment]: + """Get an iterable of segments.""" move_to = Control.move_to new_line = Segment.line() chops = self.chops @@ -150,7 +158,7 @@ class ChopsUpdate: continue if x2 > x >= x1 and end <= x2: - yield move_to(x, y) + yield move_to(x, y).segment yield from strip continue @@ -159,12 +167,12 @@ class ChopsUpdate: for segment in iter_segments: next_x = x + _cell_len(segment.text) if next_x > x1: - yield move_to(x, y) + yield move_to(x, y).segment yield segment break x = next_x else: - yield move_to(x, y) + yield move_to(x, y).segment if end <= x2: yield from iter_segments else: diff --git a/src/textual/app.py b/src/textual/app.py index e37a8dc5d..c4ee96ade 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -95,7 +95,7 @@ from .screen import Screen from .widget import AwaitMount, Widget if TYPE_CHECKING: - from typing_extensions import Coroutine, Final, TypeAlias + from typing_extensions import Coroutine, TypeAlias from .devtools.client import DevtoolsClient from .pilot import Pilot @@ -162,6 +162,15 @@ class CssPathError(Exception): ReturnType = TypeVar("ReturnType") +CSSPathType = Union[ + str, + PurePath, + List[Union[str, PurePath]], +] + +CallThreadReturnType = TypeVar("CallThreadReturnType") + + class _NullFile: """A file-like where writes go nowhere.""" @@ -171,77 +180,9 @@ class _NullFile: 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: Text to write to the file. - """ - self._queue.put(text) - def isatty(self) -> bool: - """Pretend to be a terminal. - - Returns: - True if this is a tty. - """ return True - def fileno(self) -> int: - """Get file handle number. - - Returns: - 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() - if text is None: - break - write(text) - if qsize() == 0: - flush() - 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]], -] - -CallThreadReturnType = TypeVar("CallThreadReturnType") - @rich.repr.auto class App(Generic[ReturnType], DOMNode): @@ -324,21 +265,15 @@ class App(Generic[ReturnType], DOMNode): 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=file, + file=_NullFile(), markup=True, highlight=False, emoji=False, legacy_windows=False, _environ=environ, + force_terminal=True, + safe_box=False, ) self._workers = WorkerManager(self) self.error_console = Console(markup=False, stderr=True) @@ -900,6 +835,7 @@ class App(Generic[ReturnType], DOMNode): """ assert self._driver is not None, "App must be running" width, height = self.size + console = Console( width=width, height=height, @@ -908,6 +844,7 @@ class App(Generic[ReturnType], DOMNode): color_system="truecolor", record=True, legacy_windows=False, + safe_box=False, ) screen_render = self.screen._compositor.render_update( full=True, screen_stack=self.app._background_screens @@ -2011,8 +1948,8 @@ class App(Generic[ReturnType], DOMNode): 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() + if self._driver is not None: + self._driver.close() self._print_error_renderables() @@ -2049,17 +1986,29 @@ class App(Generic[ReturnType], DOMNode): if screen is not self.screen or renderable is None: return - if self._running and not self._closed and not self.is_headless: + if ( + self._running + and not self._closed + and not self.is_headless + and self._driver is not None + ): console = self.console self._begin_update() try: try: - console.print(renderable) + segments = ( + renderable.iter_segments() + if hasattr(renderable, "iter_segments") + else console.render(renderable) + ) + terminal_sequence = console._render_buffer(segments) except Exception as error: self._handle_exception(error) + else: + self._driver.write(terminal_sequence) finally: self._end_update() - console.file.flush() + self._driver.flush() finally: self.post_display_hook() @@ -2552,9 +2501,9 @@ class App(Generic[ReturnType], DOMNode): self._sync_available = True def _begin_update(self) -> None: - if self._sync_available: - self.console.file.write(SYNC_START) + if self._sync_available and self._driver is not None: + self._driver.write(SYNC_START) def _end_update(self) -> None: - if self._sync_available: - self.console.file.write(SYNC_END) + if self._sync_available and self._driver is not None: + self._driver.write(SYNC_END) diff --git a/src/textual/driver.py b/src/textual/driver.py index 8dc2a3e5c..62d8a9cfe 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -128,3 +128,6 @@ class Driver(ABC): @abstractmethod def stop_application_mode(self) -> None: """Stop application mode, restore state.""" + + def close(self) -> None: + """Perform any final cleanup.""" diff --git a/src/textual/drivers/_writer_thread.py b/src/textual/drivers/_writer_thread.py new file mode 100644 index 000000000..a31e63e5a --- /dev/null +++ b/src/textual/drivers/_writer_thread.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import sys +import threading +from queue import Queue +from typing import IO + +from typing_extensions import Final + +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, file: IO[str]) -> None: + super().__init__(daemon=True) + self._queue: Queue[str | None] = Queue(MAX_QUEUED_WRITES) + self._file = file + + def write(self, text: str) -> None: + """Write text. Text will be enqueued for writing. + + Args: + text: Text to write to the file. + """ + self._queue.put(text) + + def isatty(self) -> bool: + """Pretend to be a terminal. + + Returns: + True. + """ + return True + + def fileno(self) -> int: + """Get file handle number. + + Returns: + 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() + if text is None: + break + write(text) + if qsize() == 0: + flush() + flush() + + def stop(self) -> None: + """Stop the thread, and block until it finished.""" + self._queue.put(None) + self.join() diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index fcdaae2a0..d77a12f64 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -17,6 +17,7 @@ from .. import events, log from .._xterm_parser import XTermParser from ..driver import Driver from ..geometry import Size +from ._writer_thread import WriterThread if TYPE_CHECKING: from ..app import App @@ -41,11 +42,12 @@ class LinuxDriver(Driver): size: Initial size of the terminal or `None` to detect. """ super().__init__(app, debug=debug, size=size) - self._file = app.console.file + self._file = sys.__stdout__ self.fileno = sys.stdin.fileno() self.attrs_before: list[Any] | None = None self.exit_event = Event() self._key_thread: Thread | None = None + self._writer_thread: WriterThread | None = None def __rich_repr__(self) -> rich.repr.Result: yield self._app @@ -108,7 +110,8 @@ class LinuxDriver(Driver): Args: data: Raw data. """ - self._file.write(data) + assert self._writer_thread is not None, "Driver must be in application mode" + self._writer_thread.write(data) def start_application_mode(self): """Start application mode.""" @@ -124,6 +127,9 @@ class LinuxDriver(Driver): loop=loop, ) + self._writer_thread = WriterThread(self._file) + self._writer_thread.start() + def on_terminal_resize(signum, stack) -> None: send_size_event() @@ -222,6 +228,11 @@ class LinuxDriver(Driver): self.write("\x1b[?1049l" + "\x1b[?25h") self.flush() + def close(self) -> None: + """Perform cleanup.""" + if self._writer_thread is not None: + self._writer_thread.stop() + def run_input_thread(self) -> None: """Wait for input and dispatch events.""" selector = selectors.DefaultSelector() diff --git a/src/textual/drivers/windows_driver.py b/src/textual/drivers/windows_driver.py index 369de8245..9fbfa70a8 100644 --- a/src/textual/drivers/windows_driver.py +++ b/src/textual/drivers/windows_driver.py @@ -4,9 +4,9 @@ import asyncio from threading import Event, Thread from typing import TYPE_CHECKING, Callable -from .._context import active_app from ..driver import Driver from . import win32 +from ._writer_thread import WriterThread if TYPE_CHECKING: from ..app import App @@ -34,6 +34,7 @@ class WindowsDriver(Driver): self.exit_event = Event() self._event_thread: Thread | None = None self._restore_console: Callable[[], None] | None = None + self._writer_thread: WriterThread | None = None def write(self, data: str) -> None: """Write data to the output device. @@ -41,7 +42,8 @@ class WindowsDriver(Driver): Args: data: Raw data. """ - self._file.write(data) + assert self._writer_thread is not None, "Driver must be in application mode" + self._writer_thread.write(data) def _enable_mouse_support(self) -> None: """Enable reporting of mouse events.""" @@ -73,6 +75,9 @@ class WindowsDriver(Driver): """Start application mode.""" loop = asyncio.get_running_loop() + self._writer_thread = WriterThread(self._file) + self._writer_thread.start() + self._restore_console = win32.enable_application_mode() self.write("\x1b[?1049h") # Enable alt screen @@ -110,3 +115,8 @@ class WindowsDriver(Driver): # Disable alt screen, show cursor self.write("\x1b[?1049l" + "\x1b[?25h") self.flush() + + def close(self) -> None: + """Perform cleanup.""" + if self._writer_thread is not None: + self._writer_thread.stop()