codekingpro/portable-devtools
114k
1# Urwid basic widget classes2# Copyright (C) 2004-2012 Ian Ward3#4# This library is free software; you can redistribute it and/or5# modify it under the terms of the GNU Lesser General Public6# License as published by the Free Software Foundation; either7# version 2.1 of the License, or (at your option) any later version.8#9# This library is distributed in the hope that it will be useful,10# but WITHOUT ANY WARRANTY; without even the implied warranty of11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU12# Lesser General Public License for more details.13#14# You should have received a copy of the GNU Lesser General Public15# License along with this library; if not, write to the Free Software16# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA17#18# Urwid web site: https://urwid.org/19 20 21from __future__ import annotations22 23import functools24import logging25import typing26import warnings27from operator import attrgetter28 29from urwid import signals30from urwid.canvas import Canvas, CanvasCache, CompositeCanvas31from urwid.command_map import command_map32from urwid.split_repr import split_repr33from urwid.util import MetaSuper34 35from .constants import Sizing36 37if typing.TYPE_CHECKING:38 from collections.abc import Callable, Hashable39 40WrappedWidget = typing.TypeVar("WrappedWidget")41LOGGER = logging.getLogger(__name__)42 43 44class WidgetMeta(signals.MetaSignals, MetaSuper):45 """46 Bases: :class:`MetaSuper`, :class:`MetaSignals`47 48 Automatic caching of render and rows methods.49 50 Class variable *no_cache* is a list of names of methods to not cache51 automatically. Valid method names for *no_cache* are ``'render'`` and52 ``'rows'``.53 54 Class variable *ignore_focus* if defined and set to ``True`` indicates55 that the canvas this widget renders is not affected by the focus56 parameter, so it may be ignored when caching.57 """58 59 def __init__(cls, name, bases, d):60 no_cache = d.get("no_cache", [])61 62 super().__init__(name, bases, d)63 64 if "render" in d:65 if "render" not in no_cache:66 render_fn = cache_widget_render(cls)67 else:68 render_fn = nocache_widget_render(cls)69 cls.render = render_fn70 71 if "rows" in d and "rows" not in no_cache:72 cls.rows = cache_widget_rows(cls)73 if "no_cache" in d:74 del cls.no_cache75 if "ignore_focus" in d:76 del cls.ignore_focus77 78 79class WidgetError(Exception):80 """Widget specific errors."""81 82 83class WidgetWarning(Warning):84 """Widget specific warnings."""85 86 87def validate_size(widget, size, canv):88 """89 Raise a WidgetError if a canv does not match size.90 """91 if (size and size[1:] != (0,) and size[0] != canv.cols()) or (len(size) > 1 and size[1] != canv.rows()):92 raise WidgetError(93 f"Widget {widget!r} rendered ({canv.cols():d} x {canv.rows():d}) canvas when passed size {size!r}!"94 )95 96 97def cache_widget_render(cls):98 """99 Return a function that wraps the cls.render() method100 and fetches and stores canvases with CanvasCache.101 """102 ignore_focus = bool(getattr(cls, "ignore_focus", False))103 fn = cls.render104 105 @functools.wraps(fn)106 def cached_render(self, size, focus=False):107 focus = focus and not ignore_focus108 109 if canv := CanvasCache.fetch(self, cls, size, focus):110 return canv111 112 canv = fn(self, size, focus=focus)113 validate_size(self, size, canv)114 if canv.widget_info:115 canv = CompositeCanvas(canv)116 canv.finalize(self, size, focus)117 CanvasCache.store(cls, canv)118 return canv119 120 cached_render.original_fn = fn121 return cached_render122 123 124def nocache_widget_render(cls):125 """126 Return a function that wraps the cls.render() method127 and finalizes the canvas that it returns.128 """129 fn = cls.render130 if hasattr(fn, "original_fn"):131 fn = fn.original_fn132 133 @functools.wraps(fn)134 def finalize_render(self, size, focus=False):135 canv = fn(self, size, focus=focus)136 if canv.widget_info:137 canv = CompositeCanvas(canv)138 validate_size(self, size, canv)139 canv.finalize(self, size, focus)140 return canv141 142 finalize_render.original_fn = fn143 return finalize_render144 145 146def nocache_widget_render_instance(self):147 """148 Return a function that wraps the cls.render() method149 and finalizes the canvas that it returns, but does not150 cache the canvas.151 """152 fn = self.render.original_fn153 154 @functools.wraps(fn)155 def finalize_render(size, focus=False):156 canv = fn(self, size, focus=focus)157 if canv.widget_info:158 canv = CompositeCanvas(canv)159 canv.finalize(self, size, focus)160 return canv161 162 finalize_render.original_fn = fn163 return finalize_render164 165 166def cache_widget_rows(cls):167 """168 Return a function that wraps the cls.rows() method169 and returns rows from the CanvasCache if available.170 """171 ignore_focus = bool(getattr(cls, "ignore_focus", False))172 fn = cls.rows173 174 @functools.wraps(fn)175 def cached_rows(self, size: tuple[int], focus: bool = False) -> int:176 focus = focus and not ignore_focus177 178 if canv := CanvasCache.fetch(self, cls, size, focus):179 return canv.rows()180 181 return fn(self, size, focus)182 183 return cached_rows184 185 186class Widget(metaclass=WidgetMeta):187 """188 Widget base class189 190 .. attribute:: _selectable191 :annotation: = False192 193 The default :meth:`.selectable` method returns this value.194 195 .. attribute:: _sizing196 :annotation: = frozenset(['flow', 'box', 'fixed'])197 198 The default :meth:`.sizing` method returns this value.199 200 .. attribute:: _command_map201 :annotation: = urwid.command_map202 203 A shared :class:`CommandMap` instance. May be redefined in subclasses or widget instances.204 205 206 .. method:: rows(size, focus=False)207 208 .. note::209 210 This method is not implemented in :class:`.Widget` but211 must be implemented by any flow widget. See :meth:`.sizing`.212 213 See :meth:`Widget.render` for parameter details.214 215 :returns: The number of rows required for this widget given a number of columns in *size*216 217 This is the method flow widgets use to communicate their size to other218 widgets without having to render a canvas. This should be a quick219 calculation as this function may be called a number of times in normal220 operation. If your implementation may take a long time you should add221 your own caching here.222 223 There is some metaclass magic defined in the :class:`Widget`224 metaclass :class:`WidgetMeta` that causes the225 result of this function to be retrieved from any226 canvas cached by :class:`CanvasCache`, so if your widget227 has been rendered you may not receive calls to this function. The class228 variable :attr:`ignore_focus` may be defined and set to ``True`` if this229 widget renders the same size regardless of the value of the *focus*230 parameter.231 232 .. method:: get_cursor_coords(size)233 234 .. note::235 236 This method is not implemented in :class:`.Widget` but237 must be implemented by any widget that may return cursor238 coordinates as part of the canvas that :meth:`render` returns.239 240 :param size: See :meth:`Widget.render` for details.241 :type size: widget size242 243 :returns: (*col*, *row*) if this widget has a cursor, ``None`` otherwise244 245 Return the cursor coordinates (*col*, *row*) of a cursor that will appear246 as part of the canvas rendered by this widget when in focus, or ``None``247 if no cursor is displayed.248 249 The :class:`ListBox` widget250 uses this method to make sure a cursor in the focus widget is not scrolled out of view.251 It is a separate method to avoid having to render the whole widget while calculating layout.252 253 Container widgets will typically call the :meth:`.get_cursor_coords` method on their focus widget.254 255 256 .. method:: get_pref_col(size)257 258 .. note::259 260 This method is not implemented in :class:`.Widget` but may be implemented by a subclass.261 262 :param size: See :meth:`Widget.render` for details.263 :type size: widget size264 265 :returns: a column number or ``'left'`` for the leftmost available266 column or ``'right'`` for the rightmost available column267 268 Return the preferred column for the cursor to be displayed in this269 widget. This value might not be the same as the column returned from270 :meth:`get_cursor_coords`.271 272 The :class:`ListBox` and :class:`Pile`273 widgets call this method on a widget losing focus and use the value274 returned to call :meth:`.move_cursor_to_coords` on the widget becoming275 the focus. This allows the focus to move up and down through widgets276 while keeping the cursor in approximately the same column on screen.277 278 279 .. method:: move_cursor_to_coords(size, col, row)280 281 .. note::282 283 This method is not implemented in :class:`.Widget` but may be implemented by a subclass.284 Not implementing this method is equivalent to having a method that always returns285 ``False``.286 287 :param size: See :meth:`Widget.render` for details.288 :type size: widget size289 :param col: new column for the cursor, 0 is the left edge of this widget290 :type col: int291 :param row: new row for the cursor, 0 it the top row of this widget292 :type row: int293 294 :returns: ``True`` if the position was set successfully anywhere on *row*, ``False`` otherwise295 """296 297 _selectable = False298 _sizing = frozenset([Sizing.FLOW, Sizing.BOX, Sizing.FIXED])299 _command_map = command_map300 301 def __init__(self) -> None:302 self.logger = logging.getLogger(f"{self.__class__.__module__}.{self.__class__.__name__}")303 304 def _invalidate(self) -> None:305 """Mark cached canvases rendered by this widget as dirty so that they will not be used again."""306 CanvasCache.invalidate(self)307 308 def _emit(self, name: Hashable, *args) -> None:309 """Convenience function to emit signals with self as first argument."""310 signals.emit_signal(self, name, self, *args)311 312 def selectable(self) -> bool:313 """314 :returns: ``True`` if this is a widget that is designed to take the315 focus, i.e. it contains something the user might want to316 interact with, ``False`` otherwise,317 318 This default implementation returns :attr:`._selectable`.319 Subclasses may leave these is if the are not selectable,320 or if they are always selectable they may321 set the :attr:`_selectable` class variable to ``True``.322 323 If this method returns ``True`` then the :meth:`.keypress` method324 must be implemented.325 326 Returning ``False`` does not guarantee that this widget will never be in327 focus, only that this widget will usually be skipped over when changing328 focus. It is still possible for non selectable widgets to have the focus329 (typically when there are no other selectable widgets visible).330 """331 return self._selectable332 333 def sizing(self) -> frozenset[Sizing]:334 """335 :returns: A frozenset including one or more of ``'box'``, ``'flow'`` and336 ``'fixed'``. Default implementation returns the value of337 :attr:`._sizing`, which for this class includes all three.338 339 The sizing modes returned indicate the modes that may be340 supported by this widget, but is not sufficient to know341 that using that sizing mode will work. Subclasses should342 make an effort to remove sizing modes they know will not343 work given the state of the widget, but many do not yet344 do this.345 346 If a sizing mode is missing from the set then the widget347 should fail when used in that mode.348 349 If ``'flow'`` is among the values returned then the other350 methods in this widget must be able to accept a351 single-element tuple (*maxcol*,) to their ``size``352 parameter, and the :meth:`rows` method must be defined.353 354 If ``'box'`` is among the values returned then the other355 methods must be able to accept a two-element tuple356 (*maxcol*, *maxrow*) to their size parameter.357 358 If ``'fixed'`` is among the values returned then the other359 methods must be able to accept an empty tuple () to360 their size parameter, and the :meth:`pack` method must361 be defined.362 """363 return self._sizing364 365 def pack(self, size: tuple[()] | tuple[int] | tuple[int, int], focus: bool = False) -> tuple[int, int]:366 """367 See :meth:`Widget.render` for parameter details.368 369 :returns: A "packed" size (*maxcol*, *maxrow*) for this widget370 371 Calculate and return a minimum372 size where all content could still be displayed. Fixed widgets must373 implement this method and return their size when ``()`` is passed as the374 *size* parameter.375 376 This default implementation returns the *size* passed, or the *maxcol*377 passed and the value of :meth:`rows` as the *maxrow* when (*maxcol*,)378 is passed as the *size* parameter.379 380 .. note::381 382 This is a new method that hasn't been fully implemented across the383 standard widget types. In particular it has not yet been384 implemented for container widgets.385 386 :class:`Text` widgets have implemented this method.387 You can use :meth:`Text.pack` to calculate the minimum388 columns and rows required to display a text widget without wrapping,389 or call it iteratively to calculate the minimum number of columns390 required to display the text wrapped into a target number of rows.391 """392 if not size:393 if Sizing.FIXED in self.sizing():394 raise NotImplementedError(f"{self!r} must override Widget.pack()")395 raise WidgetError(f"Cannot pack () size, this is not a fixed widget: {self!r}")396 397 if len(size) == 1:398 if Sizing.FLOW in self.sizing():399 return (*size, self.rows(size, focus)) # pylint: disable=no-member # can not announce abstract400 401 raise WidgetError(f"Cannot pack (maxcol,) size, this is not a flow widget: {self!r}")402 403 return size404 405 @property406 def base_widget(self) -> Widget:407 """Read-only property that steps through decoration widgets and returns the one at the base.408 409 This default implementation returns self.410 """411 return self412 413 @property414 def focus(self) -> Widget | None:415 """416 Read-only property returning the child widget in focus for container widgets.417 418 This default implementation always returns ``None``, indicating that this widget has no children.419 """420 return None421 422 def _not_a_container(self, val=None):423 raise IndexError(f"No focus_position, {self!r} is not a container widget")424 425 focus_position = property(426 _not_a_container,427 _not_a_container,428 doc="""429 Property for reading and setting the focus position for430 container widgets. This default implementation raises431 :exc:`IndexError`, making normal widgets fail the same way432 accessing :attr:`.focus_position` on an empty container widget would.433 """,434 )435 436 def __repr__(self):437 """A friendly __repr__ for widgets.438 439 Designed to be extended by subclasses with _repr_words and _repr_attr methods.440 """441 return split_repr(self)442 443 def _repr_words(self) -> list[str]:444 words = []445 if self.selectable():446 words = ["selectable", *words]447 if self.sizing() and self.sizing() != frozenset([Sizing.FLOW, Sizing.BOX, Sizing.FIXED]):448 words.append("/".join(sorted(self.sizing())))449 return [*words, "widget"]450 451 def _repr_attrs(self) -> dict[str, typing.Any]:452 return {}453 454 def keypress(455 self,456 size: tuple[()] | tuple[int] | tuple[int, int],457 key: str,458 ) -> str | None:459 """Keyboard input handler.460 461 :param size: See :meth:`Widget.render` for details462 :type size: tuple[()] | tuple[int] | tuple[int, int]463 :param key: a single keystroke value; see :ref:`keyboard-input`464 :type key: str465 :return: ``None`` if *key* was handled by *key* (the same value passed) if *key* was not handled466 :rtype: str | None467 """468 if not self.selectable():469 if hasattr(self, "logger"):470 self.logger.debug(f"keypress sent to non selectable widget {self!r}")471 else:472 warnings.warn(473 f"Widget {self.__class__.__name__} did not call 'super().__init__()",474 WidgetWarning,475 stacklevel=3,476 )477 LOGGER.debug(f"Widget {self!r} is not selectable")478 return key479 480 def mouse_event(481 self,482 size: tuple[()] | tuple[int] | tuple[int, int],483 event: str,484 button: int,485 col: int,486 row: int,487 focus: bool,488 ) -> bool | None:489 """Mouse event handler.490 491 :param size: See :meth:`Widget.render` for details.492 :type size: tuple[()] | tuple[int] | tuple[int, int]493 :param event: Values such as ``'mouse press'``, ``'ctrl mouse press'``,494 ``'mouse release'``, ``'meta mouse release'``,495 ``'mouse drag'``; see :ref:`mouse-input`496 :type event: str497 :param button: 1 through 5 for press events, often 0 for release events498 (which button was released is often not known)499 :type button: int500 :param col: Column of the event, 0 is the left edge of this widget501 :type col: int502 :param row: Row of the event, 0 it the top row of this widget503 :type row: int504 :param focus: Set to ``True`` if this widget or one of its children is in focus505 :type focus: bool506 :return: ``True`` if the event was handled by this widget, ``False`` otherwise507 :rtype: bool | None508 """509 if not self.selectable():510 if hasattr(self, "logger"):511 self.logger.debug(f"Widget {self!r} is not selectable")512 else:513 warnings.warn(514 f"Widget {self.__class__.__name__} not called 'super().__init__()",515 WidgetWarning,516 stacklevel=3,517 )518 LOGGER.debug(f"Widget {self!r} is not selectable")519 return False520 521 def render(522 self,523 size: tuple[()] | tuple[int] | tuple[int, int],524 focus: bool = False,525 ) -> Canvas:526 """Render widget and produce canvas527 528 :param size: One of the following, *maxcol* and *maxrow* are integers > 0:529 530 (*maxcol*, *maxrow*)531 for box sizing -- the parent chooses the exact532 size of this widget533 534 (*maxcol*,)535 for flow sizing -- the parent chooses only the536 number of columns for this widget537 538 ()539 for fixed sizing -- this widget is a fixed size540 which can't be adjusted by the parent541 :type size: widget size542 :param focus: set to ``True`` if this widget or one of its children is in focus543 :type focus: bool544 545 :returns: A :class:`Canvas` subclass instance containing the rendered content of this widget546 547 :class:`Text` widgets return a :class:`TextCanvas` (arbitrary text and display attributes),548 :class:`SolidFill` widgets return a :class:`SolidCanvas` (a single character repeated across the whole surface)549 and container widgets return a :class:`CompositeCanvas` (one or more other canvases arranged arbitrarily).550 551 If *focus* is ``False``, the returned canvas may not have a cursor position set.552 553 There is some metaclass magic defined in the :class:`Widget` metaclass :class:`WidgetMeta`554 that causes the result of this method to be cached by :class:`CanvasCache`.555 Later calls will automatically look up the value in the cache first.556 557 As a small optimization the class variable :attr:`ignore_focus`558 may be defined and set to ``True`` if this widget renders the same559 canvas regardless of the value of the *focus* parameter.560 561 Any time the content of a widget changes it should call562 :meth:`_invalidate` to remove any cached canvases, or the widget563 may render the cached canvas instead of creating a new one.564 """565 raise NotImplementedError566 567 568def fixed_size(size: tuple[()]) -> None:569 """570 raise ValueError if size != ().571 572 Used by FixedWidgets to test size parameter.573 """574 if size:575 raise ValueError(f"FixedWidget takes only () for size.passed: {size!r}")576 577 578def delegate_to_widget_mixin(attribute_name: str) -> type[Widget]:579 """580 Return a mixin class that delegates all standard widget methods581 to an attribute given by attribute_name.582 583 This mixin is designed to be used as a superclass of another widget.584 """585 # FIXME: this is so common, let's add proper support for it586 # when layout and rendering are separated587 588 get_delegate = attrgetter(attribute_name)589 590 class DelegateToWidgetMixin(Widget):591 no_cache: typing.ClassVar[list[str]] = ["rows"] # crufty metaclass work-around592 593 def render(self, size, focus: bool = False) -> CompositeCanvas:594 canv = get_delegate(self).render(size, focus=focus)595 return CompositeCanvas(canv)596 597 @property598 def selectable(self) -> Callable[[], bool]:599 return get_delegate(self).selectable600 601 @property602 def get_cursor_coords(self) -> Callable[[tuple[()] | tuple[int] | tuple[int, int]], tuple[int, int] | None]:603 # TODO(Aleksei): Get rid of property usage after getting rid of "if getattr"604 return get_delegate(self).get_cursor_coords605 606 @property607 def get_pref_col(self) -> Callable[[tuple[()] | tuple[int] | tuple[int, int]], int | None]:608 # TODO(Aleksei): Get rid of property usage after getting rid of "if getattr"609 return get_delegate(self).get_pref_col610 611 def keypress(self, size: tuple[()] | tuple[int] | tuple[int, int], key: str) -> str | None:612 return get_delegate(self).keypress(size, key)613 614 @property615 def move_cursor_to_coords(self) -> Callable[[[tuple[()] | tuple[int] | tuple[int, int], int, int]], bool]:616 # TODO(Aleksei): Get rid of property usage after getting rid of "if getattr"617 return get_delegate(self).move_cursor_to_coords618 619 @property620 def rows(self) -> Callable[[tuple[int], bool], int]:621 return get_delegate(self).rows622 623 @property624 def mouse_event(625 self,626 ) -> Callable[[tuple[()] | tuple[int] | tuple[int, int], str, int, int, int, bool], bool | None]:627 # TODO(Aleksei): Get rid of property usage after getting rid of "if getattr"628 return get_delegate(self).mouse_event629 630 @property631 def sizing(self) -> Callable[[], frozenset[Sizing]]:632 return get_delegate(self).sizing633 634 @property635 def pack(self) -> Callable[[tuple[()] | tuple[int] | tuple[int, int], bool], tuple[int, int]]:636 return get_delegate(self).pack637 638 return DelegateToWidgetMixin639 640 641class WidgetWrapError(Exception):642 pass643 644 645class WidgetWrap(delegate_to_widget_mixin("_wrapped_widget"), typing.Generic[WrappedWidget]):646 def __init__(self, w: WrappedWidget) -> None:647 """648 w -- widget to wrap, stored as self._w649 650 This object will pass the functions defined in Widget interface651 definition to self._w.652 653 The purpose of this widget is to provide a base class for654 widgets that compose other widgets for their display and655 behaviour. The details of that composition should not affect656 users of the subclass. The subclass may decide to expose some657 of the wrapped widgets by behaving like a ContainerWidget or658 WidgetDecoration, or it may hide them from outside access.659 """660 super().__init__()661 if not isinstance(w, Widget):662 obj_class_path = f"{w.__class__.__module__}.{w.__class__.__name__}"663 warnings.warn(664 f"{obj_class_path} is not subclass of Widget",665 DeprecationWarning,666 stacklevel=2,667 )668 self._wrapped_widget = w669 670 @property671 def _w(self) -> WrappedWidget:672 return self._wrapped_widget673 674 @_w.setter675 def _w(self, new_widget: WrappedWidget) -> None:676 """677 Change the wrapped widget. This is meant to be called678 only by subclasses.679 680 >>> size = (10,)681 >>> ww = WidgetWrap(Edit("hello? ", "hi"))682 >>> ww.render(size).text # ... = b in Python 3683 [...'hello? hi ']684 >>> ww.selectable()685 True686 >>> ww._w = Text("goodbye") # calls _set_w()687 >>> ww.render(size).text688 [...'goodbye ']689 >>> ww.selectable()690 False691 """692 self._wrapped_widget = new_widget693 self._invalidate()694 695 def _set_w(self, w: WrappedWidget) -> None:696 """697 Change the wrapped widget. This is meant to be called698 only by subclasses.699 >>> from urwid import Edit, Text700 >>> size = (10,)701 >>> ww = WidgetWrap(Edit("hello? ", "hi"))702 >>> ww.render(size).text # ... = b in Python 3703 [...'hello? hi ']704 >>> ww.selectable()705 True706 >>> ww._w = Text("goodbye") # calls _set_w()707 >>> ww.render(size).text708 [...'goodbye ']709 >>> ww.selectable()710 False711 """712 warnings.warn(713 "_set_w is deprecated. Please use 'WidgetWrap._w' property directly. API will be removed in version 5.0.",714 DeprecationWarning,715 stacklevel=2,716 )717 self._wrapped_widget = w718 self._invalidate()719 720 721def _test():722 import doctest723 724 doctest.testmod()725 726 727if __name__ == "__main__":728 _test()729 