Skip to content

Commit 821a60f

Browse files
authored
Win wait (#3151)
* input waiter * waiter objects * try signal handler for windows * selectors * fix win wait * log meta * log * meta loop * loop * correct wait * Waiter tweak * timeout change * restore loop * change constant * quit * tweak * loops * debug * debug * exit on no data * change wait * loop tweak * log * change wait * experiement * wrap with handle * experiment * Debug * handle * DWORD * another attempt * test * log * reading * stream * tweak * Restore * input reader * reader * Remove debug * input reader * shutdown devtools after waiter * flush * fileno * exit meta * windows reader * remove logging * formatting * docstring
1 parent 9ce1840 commit 821a60f

File tree

10 files changed

+174
-68
lines changed

10 files changed

+174
-68
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "textual"
3-
version = "0.34.0"
3+
version = "0.35.0a1"
44
homepage = "https://github.com/Textualize/textual"
55
description = "Modern Text User Interface framework"
66
authors = ["Will McGugan <[email protected]>"]

src/textual/__init__.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,6 @@ def __rich_repr__(self) -> rich.repr.Result:
6464
yield self._verbosity, LogVerbosity.NORMAL
6565

6666
def __call__(self, *args: object, **kwargs) -> None:
67-
try:
68-
app = active_app.get()
69-
except LookupError:
70-
print_args = (*args, *[f"{key}={value!r}" for key, value in kwargs.items()])
71-
print(*print_args)
72-
return
7367
if constants.LOG_FILE:
7468
output = " ".join(str(arg) for arg in args)
7569
if kwargs:
@@ -80,6 +74,12 @@ def __call__(self, *args: object, **kwargs) -> None:
8074

8175
with open(constants.LOG_FILE, "a") as log_file:
8276
print(output, file=log_file)
77+
try:
78+
app = active_app.get()
79+
except LookupError:
80+
print_args = (*args, *[f"{key}={value!r}" for key, value in kwargs.items()])
81+
print(*print_args)
82+
return
8383
if app.devtools is None or not app.devtools.is_connected:
8484
return
8585

src/textual/app.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1983,6 +1983,9 @@ async def _process_messages(
19831983
self.log.system(driver=self.driver_class)
19841984
self.log.system(loop=asyncio.get_running_loop())
19851985
self.log.system(features=self.features)
1986+
if constants.LOG_FILE is not None:
1987+
_log_path = os.path.abspath(constants.LOG_FILE)
1988+
self.log.system(f"Writing logs to {_log_path!r}")
19861989

19871990
try:
19881991
if self.css_path:
@@ -2282,12 +2285,12 @@ async def _shutdown(self) -> None:
22822285

22832286
await self._dispatch_message(events.Unmount())
22842287

2285-
if self.devtools is not None and self.devtools.is_connected:
2286-
await self._disconnect_devtools()
2287-
22882288
if self._driver is not None:
22892289
self._driver.close()
22902290

2291+
if self.devtools is not None and self.devtools.is_connected:
2292+
await self._disconnect_devtools()
2293+
22912294
self._print_error_renderables()
22922295

22932296
if constants.SHOW_RETURN:

src/textual/drivers/_byte_stream.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ def parse(
151151
read = self.read
152152
from_bytes = int.from_bytes
153153
while not self.is_eof:
154-
packet_type = (yield read1()).decode("utf-8")
154+
packet_type = (yield read1()).decode("utf-8", "ignore")
155155
size = from_bytes((yield read(4)), "big")
156156
payload = (yield read(size)) if size else b""
157157
on_token(BytePacket(packet_type, payload))
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import platform
2+
3+
__all__ = ["InputReader"]
4+
5+
WINDOWS = platform.system() == "Windows"
6+
7+
if WINDOWS:
8+
from ._input_reader_windows import InputReader
9+
else:
10+
from ._input_reader_linux import InputReader
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import os
2+
import selectors
3+
import sys
4+
from threading import Event
5+
from typing import Iterator
6+
7+
from textual import log
8+
9+
10+
class InputReader:
11+
"""Read input from stdin."""
12+
13+
def __init__(self, timeout: float = 0.1) -> None:
14+
"""
15+
16+
Args:
17+
timeout: Seconds to block for input.
18+
"""
19+
self._fileno = sys.__stdin__.fileno()
20+
self.timeout = timeout
21+
self._selector = selectors.DefaultSelector()
22+
self._selector.register(self._fileno, selectors.EVENT_READ)
23+
self._exit_event = Event()
24+
25+
def more_data(self) -> bool:
26+
"""Check if there is data pending."""
27+
EVENT_READ = selectors.EVENT_READ
28+
for _key, events in self._selector.select(0.01):
29+
if events & EVENT_READ:
30+
return True
31+
return False
32+
33+
def close(self) -> None:
34+
"""Close the reader (will exit the iterator)."""
35+
self._exit_event.set()
36+
37+
def __iter__(self) -> Iterator[bytes]:
38+
"""Read input, yield bytes."""
39+
fileno = self._fileno
40+
read = os.read
41+
exit_set = self._exit_event.is_set
42+
EVENT_READ = selectors.EVENT_READ
43+
while not exit_set():
44+
for _key, events in self._selector.select(self.timeout):
45+
if events & EVENT_READ:
46+
data = read(fileno, 1024)
47+
if not data:
48+
return
49+
yield data
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import os
2+
import sys
3+
from threading import Event
4+
from typing import Iterator
5+
6+
7+
class InputReader:
8+
"""Read input from stdin."""
9+
10+
def __init__(self, timeout: float = 0.1) -> None:
11+
"""
12+
13+
Args:
14+
timeout: Seconds to block for input.
15+
"""
16+
self._fileno = sys.__stdin__.fileno()
17+
self.timeout = timeout
18+
self._exit_event = Event()
19+
20+
def more_data(self) -> bool:
21+
"""Check if there is data pending."""
22+
return True
23+
24+
def close(self) -> None:
25+
"""Close the reader (will exit the iterator)."""
26+
self._exit_event.set()
27+
28+
def __iter__(self) -> Iterator[bytes]:
29+
"""Read input, yield bytes."""
30+
while not self._exit_event.is_set():
31+
try:
32+
data = os.read(self._fileno, 1024) or None
33+
except Exception:
34+
break
35+
if not data:
36+
break
37+
yield data

src/textual/drivers/linux_driver.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,9 @@ def run_input_thread(self) -> None:
242242

243243
def more_data() -> bool:
244244
"""Check if there is more data to parse."""
245+
EVENT_READ = selectors.EVENT_READ
245246
for key, events in selector.select(0.01):
246-
if events:
247+
if events & EVENT_READ:
247248
return True
248249
return False
249250

@@ -259,7 +260,7 @@ def more_data() -> bool:
259260
while not self.exit_event.is_set():
260261
selector_events = selector.select(0.1)
261262
for _selector_key, mask in selector_events:
262-
if mask | EVENT_READ:
263+
if mask & EVENT_READ:
263264
unicode_data = decode(read(fileno, 1024))
264265
for event in feed(unicode_data):
265266
self.process_event(event)

src/textual/drivers/web_driver.py

Lines changed: 51 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
import json
1616
import os
1717
import platform
18-
import selectors
1918
import signal
2019
import sys
2120
from codecs import getincrementaldecoder
@@ -28,10 +27,15 @@
2827
from ..driver import Driver
2928
from ..geometry import Size
3029
from ._byte_stream import ByteStream
30+
from ._input_reader import InputReader
3131

3232
WINDOWS = platform.system() == "Windows"
3333

3434

35+
class _ExitInput(Exception):
36+
"""Internal exception to force exit of input loop."""
37+
38+
3539
class WebDriver(Driver):
3640
"""A headless driver that may be run remotely."""
3741

@@ -41,10 +45,10 @@ def __init__(
4145
super().__init__(app, debug=debug, size=size)
4246
self.stdout = sys.__stdout__
4347
self.fileno = sys.__stdout__.fileno()
44-
self.in_fileno = sys.__stdin__.fileno()
4548
self._write = partial(os.write, self.fileno)
4649
self.exit_event = Event()
4750
self._key_thread: Thread = Thread(target=self.run_input_thread)
51+
self._input_reader = InputReader()
4852

4953
def write(self, data: str) -> None:
5054
"""Write data to the output device.
@@ -56,6 +60,15 @@ def write(self, data: str) -> None:
5660
data_bytes = data.encode("utf-8")
5761
self._write(b"D%s%s" % (len(data_bytes).to_bytes(4, "big"), data_bytes))
5862

63+
def write_meta(self, data: dict[str, object]) -> None:
64+
"""Write meta to the controlling process (i.e. textual-web)
65+
66+
Args:
67+
data: Meta dict.
68+
"""
69+
meta_bytes = json.dumps(data).encode("utf-8", errors="ignore")
70+
self._write(b"M%s%s" % (len(meta_bytes).to_bytes(4, "big"), meta_bytes))
71+
5972
def flush(self) -> None:
6073
pass
6174

@@ -128,68 +141,61 @@ def disable_input(self) -> None:
128141
def stop_application_mode(self) -> None:
129142
"""Stop application mode, restore state."""
130143
self.exit_event.set()
131-
self._key_thread.join()
144+
self._input_reader.close()
145+
self.write_meta({"type": "exit"})
132146

133147
def run_input_thread(self) -> None:
134148
"""Wait for input and dispatch events."""
135-
selector = selectors.DefaultSelector()
136-
fileno = self.in_fileno
137-
selector.register(fileno, selectors.EVENT_READ)
138-
139-
def more_data() -> bool:
140-
"""Check if there is more data to parse."""
141-
for key, events in selector.select(0.01):
142-
if events:
143-
return True
144-
return False
145-
146-
parser = XTermParser(more_data, debug=self._debug)
147-
feed = parser.feed
148-
149+
input_reader = self._input_reader
150+
parser = XTermParser(input_reader.more_data, debug=self._debug)
149151
utf8_decoder = getincrementaldecoder("utf-8")().decode
150152
decode = utf8_decoder
151-
read = os.read
152-
EVENT_READ = selectors.EVENT_READ
153-
154153
# The server sends us a stream of bytes, which contains the equivalent of stdin, plus
155154
# in band data packets.
156155
byte_stream = ByteStream()
157156
try:
158-
while not self.exit_event.is_set():
159-
selector_events = selector.select(0.1)
160-
for _selector_key, mask in selector_events:
161-
if mask | EVENT_READ:
162-
data = read(fileno, 1024) # raw data
163-
164-
for packet_type, payload in byte_stream.feed(data):
165-
if packet_type == "D":
166-
# Treat as stdin
167-
for event in feed(decode(payload)):
168-
self.process_event(event)
169-
else:
170-
# Process meta information separately
171-
self._on_meta(packet_type, payload)
172-
except Exception as error:
173-
log(error)
157+
for data in input_reader:
158+
for packet_type, payload in byte_stream.feed(data):
159+
if packet_type == "D":
160+
# Treat as stdin
161+
for event in parser.feed(decode(payload)):
162+
self.process_event(event)
163+
else:
164+
# Process meta information separately
165+
self._on_meta(packet_type, payload)
166+
except _ExitInput:
167+
pass
168+
except Exception:
169+
from traceback import format_exc
170+
171+
log(format_exc())
174172
finally:
175-
selector.close()
173+
input_reader.close()
176174

177175
def _on_meta(self, packet_type: str, payload: bytes) -> None:
176+
"""Private method to dispatch meta.
177+
178+
Args:
179+
packet_type: Packet type (currently always "M")
180+
payload: Meta payload (JSON encoded as bytes).
181+
"""
178182
payload_map = json.loads(payload)
179183
_type = payload_map.get("type")
180184
if isinstance(payload_map, dict):
181185
self.on_meta(_type, payload_map)
182186

183187
def on_meta(self, packet_type: str, payload: dict) -> None:
188+
"""Process meta information.
189+
190+
Args:
191+
packet_type: The type of the packet.
192+
payload: meta dict.
193+
"""
184194
if packet_type == "resize":
185195
self._size = (payload["width"], payload["height"])
186196
size = Size(*self._size)
187-
event = events.Resize(size, size)
188-
asyncio.run_coroutine_threadsafe(
189-
self._app._post_message(event),
190-
loop=self._loop,
191-
)
197+
self._app.post_message(events.Resize(size, size))
192198
elif packet_type == "quit":
193-
asyncio.run_coroutine_threadsafe(
194-
self._app._post_message(messages.ExitApp()), loop=self._loop
195-
)
199+
self._app.post_message(messages.ExitApp())
200+
elif packet_type == "exit":
201+
raise _ExitInput()

0 commit comments

Comments
 (0)