codekingpro/portable-devtools
115k
1# Urwid main loop code2# Copyright (C) 2004-2012 Ian Ward3# Copyright (C) 2008 Walter Mundt4# Copyright (C) 2009 Andrew Psaltis5#6# This library is free software; you can redistribute it and/or7# modify it under the terms of the GNU Lesser General Public8# License as published by the Free Software Foundation; either9# version 2.1 of the License, or (at your option) any later version.10#11# This library is distributed in the hope that it will be useful,12# but WITHOUT ANY WARRANTY; without even the implied warranty of13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU14# Lesser General Public License for more details.15#16# You should have received a copy of the GNU Lesser General Public17# License along with this library; if not, write to the Free Software18# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA19#20# Urwid web site: https://urwid.org/21 22 23from __future__ import annotations24 25import heapq26import logging27import os28import sys29import time30import typing31import warnings32from contextlib import suppress33 34from urwid import display, signals35from urwid.command_map import Command, command_map36from urwid.display.common import INPUT_DESCRIPTORS_CHANGED37from urwid.util import StoppingContext, is_mouse_event38from urwid.widget import PopUpTarget39 40from .abstract_loop import ExitMainLoop41from .select_loop import SelectEventLoop42 43if typing.TYPE_CHECKING:44 from collections.abc import Callable, Iterable45 46 from typing_extensions import Self47 48 from urwid.display import BaseScreen49 from urwid.widget import Widget50 51 from .abstract_loop import EventLoop52 53 _T = typing.TypeVar("_T")54 55 56IS_WINDOWS = sys.platform == "win32"57PIPE_BUFFER_READ_SIZE = 4096 # can expect this much on Linux, so try for that58 59__all__ = ("CantUseExternalLoop", "MainLoop")60 61 62class CantUseExternalLoop(Exception):63 pass64 65 66class MainLoop:67 """68 This is the standard main loop implementation for a single interactive69 session.70 71 :param widget: the topmost widget used for painting the screen, stored as72 :attr:`widget` and may be modified. Must be a box widget.73 :type widget: widget instance74 75 :param palette: initial palette for screen76 :type palette: iterable of palette entries77 78 :param screen: screen to use, default is a new :class:`raw_display.Screen`79 instance; stored as :attr:`screen`80 :type screen: display module screen instance81 82 :param handle_mouse: ``True`` to ask :attr:`.screen` to process mouse events83 :type handle_mouse: bool84 85 :param input_filter: a function to filter input before sending it to86 :attr:`.widget`, called from :meth:`.input_filter`87 :type input_filter: callable88 89 :param unhandled_input: a function called when input is not handled by90 :attr:`.widget`, called from :meth:`.unhandled_input`91 :type unhandled_input: callable92 93 :param event_loop: if :attr:`.screen` supports external an event loop it may be94 given here, default is a new :class:`SelectEventLoop` instance;95 stored as :attr:`.event_loop`96 :type event_loop: event loop instance97 98 :param pop_ups: `True` to wrap :attr:`.widget` with a :class:`PopUpTarget`99 instance to allow any widget to open a pop-up anywhere on the screen100 :type pop_ups: boolean101 102 103 .. attribute:: screen104 105 The screen object this main loop uses for screen updates and reading input106 107 .. attribute:: event_loop108 109 The event loop object this main loop uses for waiting on alarms and IO110 """111 112 def __init__(113 self,114 widget: Widget,115 palette: Iterable[116 tuple[str, str] | tuple[str, str, str] | tuple[str, str, str, str] | tuple[str, str, str, str, str, str]117 ] = (),118 screen: BaseScreen | None = None,119 handle_mouse: bool = True,120 input_filter: Callable[[list[str], list[int]], list[str]] | None = None,121 unhandled_input: Callable[[str | tuple[str, int, int, int]], bool | None] | None = None,122 event_loop: EventLoop | None = None,123 pop_ups: bool = False,124 ):125 self.logger = logging.getLogger(__name__).getChild(self.__class__.__name__)126 self._widget = widget127 self.handle_mouse = handle_mouse128 self._pop_ups = False # only initialize placeholder129 self.pop_ups = pop_ups # triggers property setting side-effect130 131 if not screen:132 screen = display.raw.Screen()133 134 if palette:135 screen.register_palette(palette)136 137 self.screen: BaseScreen = screen138 self.screen_size: tuple[int, int] | None = None139 140 self._unhandled_input = unhandled_input141 self._input_filter = input_filter142 143 if not hasattr(screen, "hook_event_loop") and event_loop is not None:144 raise NotImplementedError(f"screen object passed {screen!r} does not support external event loops")145 if event_loop is None:146 event_loop = SelectEventLoop()147 self.event_loop: EventLoop = event_loop148 149 if hasattr(self.screen, "signal_handler_setter"):150 # Tell the screen what function it must use to set151 # signal handlers152 self.screen.signal_handler_setter = self.event_loop.set_signal_handler153 154 self._watch_pipes: dict[int, tuple[Callable[[], typing.Any], int]] = {}155 156 @property157 def widget(self) -> Widget:158 """159 Property for the topmost widget used to draw the screen.160 This must be a box widget.161 """162 return self._widget163 164 @widget.setter165 def widget(self, widget: Widget) -> None:166 self._widget = widget167 if self.pop_ups:168 self._topmost_widget.original_widget = self._widget169 else:170 self._topmost_widget = self._widget171 172 def _set_widget(self, widget: Widget) -> None:173 warnings.warn(174 f"method `{self.__class__.__name__}._set_widget` is deprecated, "175 f"please use `{self.__class__.__name__}.widget` property."176 "API will be removed in version 4.0.",177 DeprecationWarning,178 stacklevel=2,179 )180 self.widget = widget181 182 @property183 def pop_ups(self) -> bool:184 return self._pop_ups185 186 @pop_ups.setter187 def pop_ups(self, pop_ups: bool) -> None:188 self._pop_ups = pop_ups189 if pop_ups:190 self._topmost_widget = PopUpTarget(self._widget)191 else:192 self._topmost_widget = self._widget193 194 def _set_pop_ups(self, pop_ups: bool) -> None:195 warnings.warn(196 f"method `{self.__class__.__name__}._set_pop_ups` is deprecated, "197 f"please use `{self.__class__.__name__}.pop_ups` property."198 "API will be removed in version 4.0.",199 DeprecationWarning,200 stacklevel=2,201 )202 self.pop_ups = pop_ups203 204 def set_alarm_in(self, sec: float, callback: Callable[[Self, _T], typing.Any], user_data: _T = None):205 """206 Schedule an alarm in *sec* seconds that will call *callback* from the207 within the :meth:`run` method.208 209 :param sec: seconds until alarm210 :type sec: float211 :param callback: function to call with two parameters: this main loop212 object and *user_data*213 :type callback: callable214 :param user_data: optional user data to pass to the callback215 :type user_data: object216 """217 self.logger.debug(f"Setting alarm in {sec!r} seconds with callback {callback!r}")218 219 def cb() -> None:220 callback(self, user_data)221 222 return self.event_loop.alarm(sec, cb)223 224 def set_alarm_at(self, tm: float, callback: Callable[[Self, _T], typing.Any], user_data: _T = None):225 """226 Schedule an alarm at *tm* time that will call *callback* from the227 within the :meth:`run` function. Returns a handle that may be passed to228 :meth:`remove_alarm`.229 230 :param tm: time to call callback e.g. ``time.time() + 5``231 :type tm: float232 :param callback: function to call with two parameters: this main loop233 object and *user_data*234 :type callback: callable235 :param user_data: optional user data to pass to the callback236 :type user_data: object237 """238 sec = tm - time.time()239 self.logger.debug(f"Setting alarm in {sec!r} seconds with callback {callback!r}")240 241 def cb() -> None:242 callback(self, user_data)243 244 return self.event_loop.alarm(sec, cb)245 246 def remove_alarm(self, handle) -> bool:247 """248 Remove an alarm. Return ``True`` if *handle* was found, ``False``249 otherwise.250 """251 return self.event_loop.remove_alarm(handle)252 253 if not IS_WINDOWS:254 255 def watch_pipe(self, callback: Callable[[bytes], bool | None]) -> int:256 """257 Create a pipe for use by a subprocess or thread to trigger a callback258 in the process/thread running the main loop.259 260 :param callback: function taking one parameter to call from within the process/thread running the main loop261 :type callback: callable262 263 This method returns a file descriptor attached to the write end of a pipe.264 The read end of the pipe is added to the list of files :attr:`event_loop` is watching.265 When data is written to the pipe the callback function will be called266 and passed a single value containing data read from the pipe.267 268 This method may be used any time you want to update widgets from another thread or subprocess.269 270 Data may be written to the returned file descriptor with ``os.write(fd, data)``.271 Ensure that data is less than 512 bytes (or 4K on Linux)272 so that the callback will be triggered just once with the complete value of data passed in.273 274 If the callback returns ``False`` then the watch will be removed from :attr:`event_loop`275 and the read end of the pipe will be closed.276 You are responsible for closing the write end of the pipe with ``os.close(fd)``.277 """278 import fcntl279 280 pipe_rd, pipe_wr = os.pipe()281 fcntl.fcntl(pipe_rd, fcntl.F_SETFL, os.O_NONBLOCK)282 watch_handle = None283 284 def cb() -> None:285 data = os.read(pipe_rd, PIPE_BUFFER_READ_SIZE)286 if callback(data) is False:287 self.event_loop.remove_watch_file(watch_handle)288 os.close(pipe_rd)289 290 watch_handle = self.event_loop.watch_file(pipe_rd, cb)291 self._watch_pipes[pipe_wr] = (watch_handle, pipe_rd)292 return pipe_wr293 294 def remove_watch_pipe(self, write_fd: int) -> bool:295 """296 Close the read end of the pipe and remove the watch created by :meth:`watch_pipe`.297 298 ..note:: You are responsible for closing the write end of the pipe.299 300 Returns ``True`` if the watch pipe exists, ``False`` otherwise301 """302 try:303 watch_handle, pipe_rd = self._watch_pipes.pop(write_fd)304 except KeyError:305 return False306 307 if not self.event_loop.remove_watch_file(watch_handle):308 return False309 os.close(pipe_rd)310 return True311 312 def watch_file(self, fd: int, callback: Callable[[], typing.Any]):313 """314 Call *callback* when *fd* has some data to read. No parameters are315 passed to callback.316 317 Returns a handle that may be passed to :meth:`remove_watch_file`.318 """319 self.logger.debug(f"Setting watch file descriptor {fd!r} with {callback!r}")320 return self.event_loop.watch_file(fd, callback)321 322 def remove_watch_file(self, handle):323 """324 Remove a watch file. Returns ``True`` if the watch file325 exists, ``False`` otherwise.326 """327 return self.event_loop.remove_watch_file(handle)328 329 def run(self) -> None:330 """331 Start the main loop handling input events and updating the screen. The332 loop will continue until an :exc:`ExitMainLoop` exception is raised.333 334 If you would prefer to manage the event loop yourself, don't use this335 method. Instead, call :meth:`start` before starting the event loop,336 and :meth:`stop` once it's finished.337 """338 with suppress(ExitMainLoop):339 self._run()340 341 def _test_run(self):342 """343 >>> w = _refl("widget") # _refl prints out function calls344 >>> w.render_rval = "fake canvas" # *_rval is used for return values345 >>> scr = _refl("screen")346 >>> scr.get_input_descriptors_rval = [42]347 >>> scr.get_cols_rows_rval = (20, 10)348 >>> scr.started = True349 >>> scr._urwid_signals = {}350 >>> evl = _refl("event_loop")351 >>> evl.enter_idle_rval = 1352 >>> evl.watch_file_rval = 2353 >>> ml = MainLoop(w, [], scr, event_loop=evl)354 >>> ml.run() # doctest:+ELLIPSIS355 screen.start()356 screen.set_mouse_tracking()357 screen.unhook_event_loop(...)358 screen.hook_event_loop(...)359 event_loop.enter_idle(<bound method MainLoop.entering_idle...>)360 event_loop.run()361 event_loop.remove_enter_idle(1)362 screen.unhook_event_loop(...)363 screen.stop()364 >>> ml.draw_screen() # doctest:+ELLIPSIS365 screen.get_cols_rows()366 widget.render((20, 10), focus=True)367 screen.draw_screen((20, 10), 'fake canvas')368 """369 370 def start(self) -> StoppingContext:371 """372 Sets up the main loop, hooking into the event loop where necessary.373 Starts the :attr:`screen` if it hasn't already been started.374 375 If you want to control starting and stopping the event loop yourself,376 you should call this method before starting, and call `stop` once the377 loop has finished. You may also use this method as a context manager,378 which will stop the loop automatically at the end of the block:379 380 with main_loop.start():381 ...382 383 Note that some event loop implementations don't handle exceptions384 specially if you manage the event loop yourself. In particular, the385 Twisted and asyncio loops won't stop automatically when386 :exc:`ExitMainLoop` (or anything else) is raised.387 """388 389 self.logger.debug(f"Starting event loop {self.event_loop.__class__.__name__!r} to manage display.")390 391 self.screen.start()392 393 if self.handle_mouse:394 self.screen.set_mouse_tracking()395 396 if not hasattr(self.screen, "hook_event_loop"):397 raise CantUseExternalLoop(f"Screen {self.screen!r} doesn't support external event loops")398 399 with suppress(NameError):400 signals.connect_signal(self.screen, INPUT_DESCRIPTORS_CHANGED, self._reset_input_descriptors)401 402 # watch our input descriptors403 self._reset_input_descriptors()404 self.idle_handle = self.event_loop.enter_idle(self.entering_idle)405 406 # the screen is redrawn automatically after input and alarms,407 # however, there can be none of those at the start,408 # so draw the initial screen here unconditionally409 self.event_loop.alarm(0, self.entering_idle)410 411 return StoppingContext(self)412 413 def stop(self) -> None:414 """415 Cleans up any hooks added to the event loop. Only call this if you're416 managing the event loop yourself, after the loop stops.417 """418 419 self.event_loop.remove_enter_idle(self.idle_handle)420 del self.idle_handle421 signals.disconnect_signal(self.screen, INPUT_DESCRIPTORS_CHANGED, self._reset_input_descriptors)422 self.screen.unhook_event_loop(self.event_loop)423 424 self.screen.stop()425 426 def _reset_input_descriptors(self) -> None:427 self.screen.unhook_event_loop(self.event_loop)428 self.screen.hook_event_loop(self.event_loop, self._update)429 430 def _run(self) -> None:431 try:432 self.start()433 except CantUseExternalLoop:434 try:435 self._run_screen_event_loop()436 return437 finally:438 self.screen.stop()439 440 try:441 self.event_loop.run()442 except:443 self.screen.stop() # clean up screen control444 raise445 self.stop()446 447 def _update(self, keys: list[str], raw: list[int]) -> None:448 """449 >>> w = _refl("widget")450 >>> w.selectable_rval = True451 >>> w.mouse_event_rval = True452 >>> scr = _refl("screen")453 >>> scr.get_cols_rows_rval = (15, 5)454 >>> evl = _refl("event_loop")455 >>> ml = MainLoop(w, [], scr, event_loop=evl)456 >>> ml._input_timeout = "old timeout"457 >>> ml._update(["y"], [121]) # doctest:+ELLIPSIS458 screen.get_cols_rows()459 widget.selectable()460 widget.keypress((15, 5), 'y')461 >>> ml._update([("mouse press", 1, 5, 4)], [])462 widget.mouse_event((15, 5), 'mouse press', 1, 5, 4, focus=True)463 >>> ml._update([], [])464 """465 if keys := self.input_filter(keys, raw):466 self.process_input(keys)467 if "window resize" in keys:468 self.screen_size = None469 470 def _run_screen_event_loop(self) -> None:471 """472 This method is used when the screen does not support using external event loops.473 474 The alarms stored in the SelectEventLoop in :attr:`event_loop` are modified by this method.475 """476 # pylint: disable=protected-access # special case for alarms handling477 self.logger.debug(f"Starting screen {self.screen!r} event loop")478 479 next_alarm = None480 481 while True:482 self.draw_screen()483 484 if not next_alarm and self.event_loop._alarms:485 next_alarm = heapq.heappop(self.event_loop._alarms)486 487 keys: list[str] = []488 raw: list[int] = []489 while not keys:490 if next_alarm:491 sec = max(0.0, next_alarm[0] - time.time())492 self.screen.set_input_timeouts(sec)493 else:494 self.screen.set_input_timeouts(None)495 keys, raw = self.screen.get_input(True)496 if not keys and next_alarm and next_alarm[0] - time.time() <= 0:497 break498 499 if keys := self.input_filter(keys, raw):500 self.process_input(keys)501 502 while next_alarm:503 if (next_alarm[0] - time.time()) > 0:504 break505 _tm, _tie_break, callback = next_alarm506 callback()507 508 if self.event_loop._alarms:509 next_alarm = heapq.heappop(self.event_loop._alarms)510 else:511 next_alarm = None512 513 if "window resize" in keys:514 self.screen_size = None515 516 def _test_run_screen_event_loop(self):517 """518 >>> w = _refl("widget")519 >>> scr = _refl("screen")520 >>> scr.get_cols_rows_rval = (10, 5)521 >>> scr.get_input_rval = [], []522 >>> ml = MainLoop(w, screen=scr)523 >>> def stop_now(loop, data):524 ... raise ExitMainLoop()525 >>> handle = ml.set_alarm_in(0, stop_now)526 >>> try:527 ... ml._run_screen_event_loop()528 ... except ExitMainLoop:529 ... pass530 screen.get_cols_rows()531 widget.render((10, 5), focus=True)532 screen.draw_screen((10, 5), None)533 screen.set_input_timeouts(0.0)534 screen.get_input(True)535 """536 537 def process_input(self, keys: Iterable[str | tuple[str, int, int, int]]) -> bool:538 """539 This method will pass keyboard input and mouse events to :attr:`widget`.540 This method is called automatically from the :meth:`run` method when541 there is input, but may also be called to simulate input from the user.542 543 *keys* is a list of input returned from :attr:`screen`'s get_input()544 or get_input_nonblocking() methods.545 546 Returns ``True`` if any key was handled by a widget or the547 :meth:`unhandled_input` method.548 """549 self.logger.debug(f"Processing input: keys={keys!r}")550 if not self.screen_size:551 self.screen_size = self.screen.get_cols_rows()552 553 something_handled = False554 555 for key in keys:556 if key == "window resize":557 continue558 559 if isinstance(key, str):560 if self._topmost_widget.selectable():561 if handled_key := self._topmost_widget.keypress(self.screen_size, key):562 key = handled_key # noqa: PLW2901563 564 else:565 something_handled = True566 continue567 568 elif is_mouse_event(key):569 event, button, col, row = key570 if hasattr(self._topmost_widget, "mouse_event") and self._topmost_widget.mouse_event(571 self.screen_size,572 event,573 button,574 col,575 row,576 focus=True,577 ):578 something_handled = True579 continue580 581 else:582 raise TypeError(f"{key!r} is not str | tuple[str, int, int, int]")583 584 if key:585 if command_map[key] == Command.REDRAW_SCREEN:586 self.screen.clear()587 something_handled = True588 else:589 something_handled |= bool(self.unhandled_input(key))590 else:591 something_handled = True592 593 return something_handled594 595 def _test_process_input(self):596 """597 >>> w = _refl("widget")598 >>> w.selectable_rval = True599 >>> scr = _refl("screen")600 >>> scr.get_cols_rows_rval = (10, 5)601 >>> ml = MainLoop(w, [], scr)602 >>> ml.process_input(["enter", ("mouse drag", 1, 14, 20)])603 screen.get_cols_rows()604 widget.selectable()605 widget.keypress((10, 5), 'enter')606 widget.mouse_event((10, 5), 'mouse drag', 1, 14, 20, focus=True)607 True608 """609 610 def input_filter(self, keys: list[str], raw: list[int]) -> list[str]:611 """612 This function is passed each all the input events and raw keystroke613 values. These values are passed to the *input_filter* function614 passed to the constructor. That function must return a list of keys to615 be passed to the widgets to handle. If no *input_filter* was616 defined this implementation will return all the input events.617 """618 if self._input_filter:619 return self._input_filter(keys, raw)620 return keys621 622 def unhandled_input(self, data: str | tuple[str, int, int, int]) -> bool | None:623 """624 This function is called with any input that was not handled by the625 widgets, and calls the *unhandled_input* function passed to the626 constructor. If no *unhandled_input* was defined then the input627 will be ignored.628 629 *input* is the keyboard or mouse input.630 631 The *unhandled_input* function should return ``True`` if it handled632 the input.633 """634 if self._unhandled_input:635 return self._unhandled_input(data)636 return False637 638 def entering_idle(self) -> None:639 """640 This method is called whenever the event loop is about to enter the641 idle state. :meth:`draw_screen` is called here to update the642 screen when anything has changed.643 """644 if self.screen.started:645 self.draw_screen()646 else:647 self.logger.debug(f"No redrawing screen: {self.screen!r} is not started.")648 649 def draw_screen(self) -> None:650 """651 Render the widgets and paint the screen. This method is called652 automatically from :meth:`entering_idle`.653 654 If you modify the widgets displayed outside of handling input or655 responding to an alarm you will need to call this method yourself656 to repaint the screen.657 """658 if not self.screen_size:659 self.screen_size = self.screen.get_cols_rows()660 self.logger.debug(f"Screen size recalculated: {self.screen_size!r}")661 662 canvas = self._topmost_widget.render(self.screen_size, focus=True)663 self.screen.draw_screen(self.screen_size, canvas)664 665 666def _refl(name: str, rval=None, loop_exit=False):667 """668 This function is used to test the main loop classes.669 670 >>> scr = _refl("screen")671 >>> scr.function("argument")672 screen.function('argument')673 >>> scr.callme(when="now")674 screen.callme(when='now')675 >>> scr.want_something_rval = 42676 >>> x = scr.want_something()677 screen.want_something()678 >>> x679 42680 681 """682 683 class Reflect:684 def __init__(self, name: str, rval=None):685 self._name = name686 self._rval = rval687 688 def __call__(self, *argl, **argd):689 args = ", ".join([repr(a) for a in argl])690 if args and argd:691 args = f"{args}, "692 args += ", ".join([f"{k}={v!r}" for k, v in argd.items()])693 print(f"{self._name}({args})")694 if loop_exit:695 raise ExitMainLoop()696 return self._rval697 698 def __getattr__(self, attr):699 if attr.endswith("_rval"):700 raise AttributeError()701 # print(self._name+"."+attr)702 if hasattr(self, f"{attr}_rval"):703 return Reflect(f"{self._name}.{attr}", getattr(self, f"{attr}_rval"))704 return Reflect(f"{self._name}.{attr}")705 706 return Reflect(name)707 708 709def _test():710 import doctest711 712 doctest.testmod()713 714 715if __name__ == "__main__":716 _test()717 