Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
widget.py729 linesDownload Raw Back to widget
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 
codekingpro/portable-devtools · Team Ai