Skip to content

Commit 5cd1263

Browse files
Simplify driver (#2091)
* simplify driver * fix headless driver * docstrings and simplify * tidy * docstrings * docstring * docstring * more docstrings * import * Update src/textual/app.py Co-authored-by: Rodrigo Girão Serrão <[email protected]> * Update src/textual/driver.py Co-authored-by: Rodrigo Girão Serrão <[email protected]> * docstring * Update src/textual/drivers/linux_driver.py Co-authored-by: Rodrigo Girão Serrão <[email protected]> * Update src/textual/drivers/linux_driver.py Co-authored-by: Rodrigo Girão Serrão <[email protected]> * Update src/textual/drivers/linux_driver.py Co-authored-by: Rodrigo Girão Serrão <[email protected]> * docstring --------- Co-authored-by: Rodrigo Girão Serrão <[email protected]>
1 parent a08d8e4 commit 5cd1263

File tree

5 files changed

+145
-68
lines changed

5 files changed

+145
-68
lines changed

src/textual/app.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -507,12 +507,12 @@ def animate(
507507

508508
@property
509509
def debug(self) -> bool:
510-
"""bool: Is debug mode is enabled?"""
510+
"""Is debug mode enabled?"""
511511
return "debug" in self.features
512512

513513
@property
514514
def is_headless(self) -> bool:
515-
"""bool: Is the app running in 'headless' mode?"""
515+
"""Is the driver running in 'headless' mode?"""
516516
return False if self._driver is None else self._driver.is_headless
517517

518518
@property
@@ -1619,7 +1619,9 @@ async def invoke_ready_callback() -> None:
16191619
"type[Driver]",
16201620
HeadlessDriver if headless else self.driver_class,
16211621
)
1622-
driver = self._driver = driver_class(self.console, self, size=terminal_size)
1622+
driver = self._driver = driver_class(
1623+
self.console.file, self, size=terminal_size
1624+
)
16231625

16241626
if not self._exit:
16251627
driver.start_application_mode()

src/textual/driver.py

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,33 @@
22

33
import asyncio
44
from abc import ABC, abstractmethod
5-
from typing import TYPE_CHECKING
5+
from typing import IO
66

77
from . import _time, events
88
from ._types import MessageTarget
99
from .events import MouseUp
1010

11-
if TYPE_CHECKING:
12-
from rich.console import Console
13-
1411

1512
class Driver(ABC):
13+
"""A base class for drivers."""
14+
1615
def __init__(
1716
self,
18-
console: "Console",
17+
file: IO[str],
1918
target: "MessageTarget",
2019
*,
2120
debug: bool = False,
2221
size: tuple[int, int] | None = None,
2322
) -> None:
24-
self.console = console
23+
"""Initialize a driver.
24+
25+
Args:
26+
file: A file-like object open for writing unicode.
27+
target: The message target (expected to be the app).
28+
debug: Enabled debug mode.
29+
size: Initial size of the terminal or `None` to detect.
30+
"""
31+
self._file = file
2532
self._target = target
2633
self._debug = debug
2734
self._size = size
@@ -32,16 +39,25 @@ def __init__(
3239

3340
@property
3441
def is_headless(self) -> bool:
35-
"""Check if the driver is 'headless'"""
42+
"""Is the driver 'headless' (no output)?"""
3643
return False
3744

3845
def send_event(self, event: events.Event) -> None:
46+
"""Send an event to the target app.
47+
48+
Args:
49+
event: An event.
50+
"""
3951
asyncio.run_coroutine_threadsafe(
4052
self._target._post_message(event), loop=self._loop
4153
)
4254

4355
def process_event(self, event: events.Event) -> None:
44-
"""Performs some additional processing of events."""
56+
"""Perform additional processing on an event, prior to sending.
57+
58+
Args:
59+
event: An event to send.
60+
"""
4561
event._set_sender(self._target)
4662
if isinstance(event, events.MouseDown):
4763
self._mouse_down_time = event.time
@@ -87,14 +103,25 @@ def process_event(self, event: events.Event) -> None:
87103
click_event = events.Click.from_event(event)
88104
self.send_event(click_event)
89105

106+
def flush(self) -> None:
107+
"""Flush any buffered data."""
108+
109+
@abstractmethod
110+
def write(self, data: str) -> None:
111+
"""Write data to the output device.
112+
113+
Args:
114+
data: Raw data.
115+
"""
116+
90117
@abstractmethod
91118
def start_application_mode(self) -> None:
92-
...
119+
"""Start application mode."""
93120

94121
@abstractmethod
95122
def disable_input(self) -> None:
96-
...
123+
"""Disable further input."""
97124

98125
@abstractmethod
99126
def stop_application_mode(self) -> None:
100-
...
127+
"""Stop application mode, restore state."""

src/textual/drivers/headless_driver.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class HeadlessDriver(Driver):
1212

1313
@property
1414
def is_headless(self) -> bool:
15+
"""Is the driver running in 'headless' mode?"""
1516
return True
1617

1718
def _get_terminal_size(self) -> tuple[int, int]:
@@ -32,10 +33,20 @@ def _get_terminal_size(self) -> tuple[int, int]:
3233
height = height or 25
3334
return width, height
3435

36+
def write(self, data: str) -> None:
37+
"""Write data to the output device.
38+
39+
Args:
40+
data: Raw data.
41+
"""
42+
# Nothing to write as this is a headless driver.
43+
3544
def start_application_mode(self) -> None:
45+
"""Start application mode."""
3646
loop = asyncio.get_running_loop()
3747

38-
def send_size_event():
48+
def send_size_event() -> None:
49+
"""Send first resize event."""
3950
terminal_size = self._get_terminal_size()
4051
width, height = terminal_size
4152
textual_size = Size(width, height)
@@ -48,7 +59,8 @@ def send_size_event():
4859
send_size_event()
4960

5061
def disable_input(self) -> None:
51-
pass
62+
"""Disable further input."""
5263

5364
def stop_application_mode(self) -> None:
54-
pass
65+
"""Stop application mode, restore state."""
66+
# Nothing to do

src/textual/drivers/linux_driver.py

Lines changed: 50 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,7 @@
99
import tty
1010
from codecs import getincrementaldecoder
1111
from threading import Event, Thread
12-
from typing import TYPE_CHECKING, Any
13-
14-
if TYPE_CHECKING:
15-
from rich.console import Console
12+
from typing import IO, Any
1613

1714
import rich.repr
1815

@@ -29,13 +26,22 @@ class LinuxDriver(Driver):
2926

3027
def __init__(
3128
self,
32-
console: "Console",
29+
file: IO[str],
3330
target: "MessageTarget",
3431
*,
3532
debug: bool = False,
3633
size: tuple[int, int] | None = None,
3734
) -> None:
38-
super().__init__(console, target, debug=debug, size=size)
35+
"""Initialize a driver.
36+
37+
Args:
38+
file: A file-like object open for writing unicode.
39+
target: The message target (expected to be the app).
40+
debug: Enabled debug mode.
41+
size: Initial size of the terminal or `None` to detect.
42+
"""
43+
super().__init__(file, target, debug=debug, size=size)
44+
self._file = file
3945
self.fileno = sys.stdin.fileno()
4046
self.attrs_before: list[Any] | None = None
4147
self.exit_event = Event()
@@ -45,6 +51,11 @@ def __rich_repr__(self) -> rich.repr.Result:
4551
yield "debug", self._debug
4652

4753
def _get_terminal_size(self) -> tuple[int, int]:
54+
"""Detect the terminal size.
55+
56+
Returns:
57+
The size of the terminal as a tuple of (WIDTH, HEIGHT).
58+
"""
4859
width: int | None = 80
4960
height: int | None = 25
5061
import shutil
@@ -61,35 +72,46 @@ def _get_terminal_size(self) -> tuple[int, int]:
6172
return width, height
6273

6374
def _enable_mouse_support(self) -> None:
64-
write = self.console.file.write
75+
"""Enable reporting of mouse events."""
76+
write = self.write
6577
write("\x1b[?1000h") # SET_VT200_MOUSE
6678
write("\x1b[?1003h") # SET_ANY_EVENT_MOUSE
6779
write("\x1b[?1015h") # SET_VT200_HIGHLIGHT_MOUSE
6880
write("\x1b[?1006h") # SET_SGR_EXT_MODE_MOUSE
6981

7082
# write("\x1b[?1007h")
71-
self.console.file.flush()
83+
self.flush()
7284

7385
# Note: E.g. lxterminal understands 1000h, but not the urxvt or sgr
7486
# extensions.
7587

7688
def _enable_bracketed_paste(self) -> None:
7789
"""Enable bracketed paste mode."""
78-
self.console.file.write("\x1b[?2004h")
90+
self.write("\x1b[?2004h")
7991

8092
def _disable_bracketed_paste(self) -> None:
8193
"""Disable bracketed paste mode."""
82-
self.console.file.write("\x1b[?2004l")
94+
self.write("\x1b[?2004l")
8395

8496
def _disable_mouse_support(self) -> None:
85-
write = self.console.file.write
97+
"""Disable reporting of mouse events."""
98+
write = self.write
8699
write("\x1b[?1000l") #
87100
write("\x1b[?1003l") #
88101
write("\x1b[?1015l")
89102
write("\x1b[?1006l")
90-
self.console.file.flush()
103+
self.flush()
104+
105+
def write(self, data: str) -> None:
106+
"""Write data to the output device.
107+
108+
Args:
109+
data: Raw data.
110+
"""
111+
self._file.write(data)
91112

92113
def start_application_mode(self):
114+
"""Start application mode."""
93115
loop = asyncio.get_running_loop()
94116

95117
def send_size_event():
@@ -107,7 +129,8 @@ def on_terminal_resize(signum, stack) -> None:
107129

108130
signal.signal(signal.SIGWINCH, on_terminal_resize)
109131

110-
self.console.set_alt_screen(True)
132+
self.write("\x1b[?1049h") # Alt screen
133+
111134
self._enable_mouse_support()
112135
try:
113136
self.attrs_before = termios.tcgetattr(self.fileno)
@@ -132,10 +155,10 @@ def on_terminal_resize(signum, stack) -> None:
132155

133156
termios.tcsetattr(self.fileno, termios.TCSANOW, newattr)
134157

135-
self.console.show_cursor(False)
136-
self.console.file.write("\033[?1003h\n")
137-
self.console.file.flush()
138-
self._key_thread = Thread(target=self.run_input_thread, args=(loop,))
158+
self.write("\x1b[?25l") # Hide cursor
159+
self.write("\033[?1003h\n")
160+
self.flush()
161+
self._key_thread = Thread(target=self.run_input_thread)
139162
send_size_event()
140163
self._key_thread.start()
141164
self._request_terminal_sync_mode_support()
@@ -146,9 +169,9 @@ def _request_terminal_sync_mode_support(self) -> None:
146169
# Terminals should ignore this sequence if not supported.
147170
# Apple terminal doesn't, and writes a single 'p' in to the terminal,
148171
# so we will make a special case for Apple terminal (which doesn't support sync anyway).
149-
if self.console._environ.get("TERM_PROGRAM", "") != "Apple_Terminal":
150-
self.console.file.write("\033[?2026$p")
151-
self.console.file.flush()
172+
if os.environ.get("TERM_PROGRAM", "") != "Apple_Terminal":
173+
self.write("\033[?2026$p")
174+
self.flush()
152175

153176
@classmethod
154177
def _patch_lflag(cls, attrs: int) -> int:
@@ -170,6 +193,7 @@ def _patch_iflag(cls, attrs: int) -> int:
170193
)
171194

172195
def disable_input(self) -> None:
196+
"""Disable further input."""
173197
try:
174198
if not self.exit_event.is_set():
175199
signal.signal(signal.SIGWINCH, signal.SIG_DFL)
@@ -184,6 +208,7 @@ def disable_input(self) -> None:
184208
pass
185209

186210
def stop_application_mode(self) -> None:
211+
"""Stop application mode, restore state."""
187212
self._disable_bracketed_paste()
188213
self.disable_input()
189214

@@ -193,17 +218,12 @@ def stop_application_mode(self) -> None:
193218
except termios.error:
194219
pass
195220

196-
with self.console:
197-
self.console.set_alt_screen(False)
198-
self.console.show_cursor(True)
199-
200-
def run_input_thread(self, loop) -> None:
201-
try:
202-
self._run_input_thread(loop)
203-
except Exception:
204-
pass # TODO: log
221+
# Alt screen false, show cursor
222+
self.write("\x1b[?1049l" + "\x1b[?25h")
223+
self.flush()
205224

206-
def _run_input_thread(self, loop) -> None:
225+
def run_input_thread(self) -> None:
226+
"""Wait for input and dispatch events."""
207227
selector = selectors.DefaultSelector()
208228
selector.register(self.fileno, selectors.EVENT_READ)
209229

0 commit comments

Comments
 (0)