Move responsibility for writing in to driver (#2273)

* 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>
This commit is contained in:
Will McGugan
2023-04-12 17:10:21 +01:00
committed by GitHub
parent 6369c37907
commit c249548c43
6 changed files with 145 additions and 95 deletions

View File

@@ -94,11 +94,15 @@ class LayoutUpdate:
def __rich_console__( def __rich_console__(
self, console: Console, options: ConsoleOptions self, console: Console, options: ConsoleOptions
) -> RenderResult: ) -> RenderResult:
return self.iter_segments()
def iter_segments(self) -> Iterable[Segment]:
"""Get an iterable of segments."""
x = self.region.x x = self.region.x
new_line = Segment.line() new_line = Segment.line()
move_to = Control.move_to move_to = Control.move_to
for last, (y, line) in loop_last(enumerate(self.strips, self.region.y)): 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 yield from line
if not last: if not last:
yield new_line yield new_line
@@ -131,6 +135,10 @@ class ChopsUpdate:
def __rich_console__( def __rich_console__(
self, console: Console, options: ConsoleOptions self, console: Console, options: ConsoleOptions
) -> RenderResult: ) -> RenderResult:
return self.iter_segments()
def iter_segments(self) -> Iterable[Segment]:
"""Get an iterable of segments."""
move_to = Control.move_to move_to = Control.move_to
new_line = Segment.line() new_line = Segment.line()
chops = self.chops chops = self.chops
@@ -150,7 +158,7 @@ class ChopsUpdate:
continue continue
if x2 > x >= x1 and end <= x2: if x2 > x >= x1 and end <= x2:
yield move_to(x, y) yield move_to(x, y).segment
yield from strip yield from strip
continue continue
@@ -159,12 +167,12 @@ class ChopsUpdate:
for segment in iter_segments: for segment in iter_segments:
next_x = x + _cell_len(segment.text) next_x = x + _cell_len(segment.text)
if next_x > x1: if next_x > x1:
yield move_to(x, y) yield move_to(x, y).segment
yield segment yield segment
break break
x = next_x x = next_x
else: else:
yield move_to(x, y) yield move_to(x, y).segment
if end <= x2: if end <= x2:
yield from iter_segments yield from iter_segments
else: else:

View File

@@ -95,7 +95,7 @@ from .screen import Screen
from .widget import AwaitMount, Widget from .widget import AwaitMount, Widget
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Coroutine, Final, TypeAlias from typing_extensions import Coroutine, TypeAlias
from .devtools.client import DevtoolsClient from .devtools.client import DevtoolsClient
from .pilot import Pilot from .pilot import Pilot
@@ -162,6 +162,15 @@ class CssPathError(Exception):
ReturnType = TypeVar("ReturnType") ReturnType = TypeVar("ReturnType")
CSSPathType = Union[
str,
PurePath,
List[Union[str, PurePath]],
]
CallThreadReturnType = TypeVar("CallThreadReturnType")
class _NullFile: class _NullFile:
"""A file-like where writes go nowhere.""" """A file-like where writes go nowhere."""
@@ -171,77 +180,9 @@ class _NullFile:
def flush(self) -> None: def flush(self) -> None:
pass 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: def isatty(self) -> bool:
"""Pretend to be a terminal.
Returns:
True if this is a tty.
"""
return 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()
CSSPathType = Union[
str,
PurePath,
List[Union[str, PurePath]],
]
CallThreadReturnType = TypeVar("CallThreadReturnType")
@rich.repr.auto @rich.repr.auto
class App(Generic[ReturnType], DOMNode): class App(Generic[ReturnType], DOMNode):
@@ -324,21 +265,15 @@ class App(Generic[ReturnType], DOMNode):
if no_color is not None: if no_color is not None:
self._filter = Monochrome() 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( self.console = Console(
file=file, file=_NullFile(),
markup=True, markup=True,
highlight=False, highlight=False,
emoji=False, emoji=False,
legacy_windows=False, legacy_windows=False,
_environ=environ, _environ=environ,
force_terminal=True,
safe_box=False,
) )
self._workers = WorkerManager(self) self._workers = WorkerManager(self)
self.error_console = Console(markup=False, stderr=True) 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" assert self._driver is not None, "App must be running"
width, height = self.size width, height = self.size
console = Console( console = Console(
width=width, width=width,
height=height, height=height,
@@ -908,6 +844,7 @@ class App(Generic[ReturnType], DOMNode):
color_system="truecolor", color_system="truecolor",
record=True, record=True,
legacy_windows=False, legacy_windows=False,
safe_box=False,
) )
screen_render = self.screen._compositor.render_update( screen_render = self.screen._compositor.render_update(
full=True, screen_stack=self.app._background_screens 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: if self.devtools is not None and self.devtools.is_connected:
await self._disconnect_devtools() await self._disconnect_devtools()
if self._writer_thread is not None: if self._driver is not None:
self._writer_thread.stop() self._driver.close()
self._print_error_renderables() self._print_error_renderables()
@@ -2049,17 +1986,29 @@ class App(Generic[ReturnType], DOMNode):
if screen is not self.screen or renderable is None: if screen is not self.screen or renderable is None:
return 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 console = self.console
self._begin_update() self._begin_update()
try: try:
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: except Exception as error:
self._handle_exception(error) self._handle_exception(error)
else:
self._driver.write(terminal_sequence)
finally: finally:
self._end_update() self._end_update()
console.file.flush() self._driver.flush()
finally: finally:
self.post_display_hook() self.post_display_hook()
@@ -2552,9 +2501,9 @@ class App(Generic[ReturnType], DOMNode):
self._sync_available = True self._sync_available = True
def _begin_update(self) -> None: def _begin_update(self) -> None:
if self._sync_available: if self._sync_available and self._driver is not None:
self.console.file.write(SYNC_START) self._driver.write(SYNC_START)
def _end_update(self) -> None: def _end_update(self) -> None:
if self._sync_available: if self._sync_available and self._driver is not None:
self.console.file.write(SYNC_END) self._driver.write(SYNC_END)

View File

@@ -128,3 +128,6 @@ class Driver(ABC):
@abstractmethod @abstractmethod
def stop_application_mode(self) -> None: def stop_application_mode(self) -> None:
"""Stop application mode, restore state.""" """Stop application mode, restore state."""
def close(self) -> None:
"""Perform any final cleanup."""

View File

@@ -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()

View File

@@ -17,6 +17,7 @@ from .. import events, log
from .._xterm_parser import XTermParser from .._xterm_parser import XTermParser
from ..driver import Driver from ..driver import Driver
from ..geometry import Size from ..geometry import Size
from ._writer_thread import WriterThread
if TYPE_CHECKING: if TYPE_CHECKING:
from ..app import App from ..app import App
@@ -41,11 +42,12 @@ class LinuxDriver(Driver):
size: Initial size of the terminal or `None` to detect. size: Initial size of the terminal or `None` to detect.
""" """
super().__init__(app, debug=debug, size=size) super().__init__(app, debug=debug, size=size)
self._file = app.console.file self._file = sys.__stdout__
self.fileno = sys.stdin.fileno() self.fileno = sys.stdin.fileno()
self.attrs_before: list[Any] | None = None self.attrs_before: list[Any] | None = None
self.exit_event = Event() self.exit_event = Event()
self._key_thread: Thread | None = None self._key_thread: Thread | None = None
self._writer_thread: WriterThread | None = None
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:
yield self._app yield self._app
@@ -108,7 +110,8 @@ class LinuxDriver(Driver):
Args: Args:
data: Raw data. 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): def start_application_mode(self):
"""Start application mode.""" """Start application mode."""
@@ -124,6 +127,9 @@ class LinuxDriver(Driver):
loop=loop, loop=loop,
) )
self._writer_thread = WriterThread(self._file)
self._writer_thread.start()
def on_terminal_resize(signum, stack) -> None: def on_terminal_resize(signum, stack) -> None:
send_size_event() send_size_event()
@@ -222,6 +228,11 @@ class LinuxDriver(Driver):
self.write("\x1b[?1049l" + "\x1b[?25h") self.write("\x1b[?1049l" + "\x1b[?25h")
self.flush() 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: def run_input_thread(self) -> None:
"""Wait for input and dispatch events.""" """Wait for input and dispatch events."""
selector = selectors.DefaultSelector() selector = selectors.DefaultSelector()

View File

@@ -4,9 +4,9 @@ import asyncio
from threading import Event, Thread from threading import Event, Thread
from typing import TYPE_CHECKING, Callable from typing import TYPE_CHECKING, Callable
from .._context import active_app
from ..driver import Driver from ..driver import Driver
from . import win32 from . import win32
from ._writer_thread import WriterThread
if TYPE_CHECKING: if TYPE_CHECKING:
from ..app import App from ..app import App
@@ -34,6 +34,7 @@ class WindowsDriver(Driver):
self.exit_event = Event() self.exit_event = Event()
self._event_thread: Thread | None = None self._event_thread: Thread | None = None
self._restore_console: Callable[[], None] | None = None self._restore_console: Callable[[], None] | None = None
self._writer_thread: WriterThread | None = None
def write(self, data: str) -> None: def write(self, data: str) -> None:
"""Write data to the output device. """Write data to the output device.
@@ -41,7 +42,8 @@ class WindowsDriver(Driver):
Args: Args:
data: Raw data. 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: def _enable_mouse_support(self) -> None:
"""Enable reporting of mouse events.""" """Enable reporting of mouse events."""
@@ -73,6 +75,9 @@ class WindowsDriver(Driver):
"""Start application mode.""" """Start application mode."""
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
self._writer_thread = WriterThread(self._file)
self._writer_thread.start()
self._restore_console = win32.enable_application_mode() self._restore_console = win32.enable_application_mode()
self.write("\x1b[?1049h") # Enable alt screen self.write("\x1b[?1049h") # Enable alt screen
@@ -110,3 +115,8 @@ class WindowsDriver(Driver):
# Disable alt screen, show cursor # Disable alt screen, show cursor
self.write("\x1b[?1049l" + "\x1b[?25h") self.write("\x1b[?1049l" + "\x1b[?25h")
self.flush() self.flush()
def close(self) -> None:
"""Perform cleanup."""
if self._writer_thread is not None:
self._writer_thread.stop()