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__(
|
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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
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 .._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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user