mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
69
src/textual/drivers/_writer_thread.py
Normal file
69
src/textual/drivers/_writer_thread.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user