Skip to content

Commit

Permalink
Move responsibility for writing in to driver (#2273)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
willmcgugan and rodrigogiraoserrao authored Apr 12, 2023
1 parent 6369c37 commit c249548
Show file tree
Hide file tree
Showing 6 changed files with 145 additions and 95 deletions.
16 changes: 12 additions & 4 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,15 @@ def __init__(self, strips: list[Strip], region: Region) -> None:
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
Expand Down Expand Up @@ -131,6 +135,10 @@ def __init__(
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
Expand All @@ -150,7 +158,7 @@ def __rich_console__(
continue

if x2 > x >= x1 and end <= x2:
yield move_to(x, y)
yield move_to(x, y).segment
yield from strip
continue

Expand All @@ -159,12 +167,12 @@ def __rich_console__(
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:
Expand Down
123 changes: 36 additions & 87 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
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
Expand Down Expand Up @@ -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."""

Expand All @@ -171,77 +180,9 @@ def write(self, text: str) -> None:
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):
Expand Down Expand Up @@ -324,21 +265,15 @@ def __init__(
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)
Expand Down Expand Up @@ -900,6 +835,7 @@ def export_screenshot(self, *, title: str | None = None) -> str:
"""
assert self._driver is not None, "App must be running"
width, height = self.size

console = Console(
width=width,
height=height,
Expand All @@ -908,6 +844,7 @@ def export_screenshot(self, *, title: str | None = None) -> str:
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
Expand Down Expand Up @@ -2011,8 +1948,8 @@ 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()
if self._driver is not None:
self._driver.close()

self._print_error_renderables()

Expand Down Expand Up @@ -2049,17 +1986,29 @@ def _display(self, screen: Screen, renderable: RenderableType | None) -> None:
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()

Expand Down Expand Up @@ -2552,9 +2501,9 @@ def _on_terminal_supports_synchronized_output(
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)
3 changes: 3 additions & 0 deletions src/textual/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,6 @@ def disable_input(self) -> None:
@abstractmethod
def stop_application_mode(self) -> None:
"""Stop application mode, restore state."""

def close(self) -> None:
"""Perform any final cleanup."""
69 changes: 69 additions & 0 deletions src/textual/drivers/_writer_thread.py
Original file line number Diff line number Diff line change
@@ -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()
15 changes: 13 additions & 2 deletions src/textual/drivers/linux_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
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
Expand All @@ -41,11 +42,12 @@ def __init__(
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
Expand Down Expand Up @@ -108,7 +110,8 @@ def write(self, data: str) -> None:
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."""
Expand All @@ -124,6 +127,9 @@ def send_size_event():
loop=loop,
)

self._writer_thread = WriterThread(self._file)
self._writer_thread.start()

def on_terminal_resize(signum, stack) -> None:
send_size_event()

Expand Down Expand Up @@ -222,6 +228,11 @@ def stop_application_mode(self) -> None:
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()
Expand Down
Loading

0 comments on commit c249548

Please sign in to comment.