from __future__ import annotations import typing import warnings from urwid.canvas import CompositeCanvas, SolidCanvas from urwid.split_repr import remove_defaults from urwid.util import int_scale from .constants import ( RELATIVE_100, Align, Sizing, WHSettings, normalize_align, normalize_width, simplify_align, simplify_width, ) from .widget_decoration import WidgetDecoration, WidgetError, WidgetWarning if typing.TYPE_CHECKING: from collections.abc import Iterator from typing_extensions import Literal WrappedWidget = typing.TypeVar("WrappedWidget") class PaddingError(WidgetError): """Padding related errors.""" class PaddingWarning(WidgetWarning): """Padding related warnings.""" class Padding(WidgetDecoration[WrappedWidget], typing.Generic[WrappedWidget]): def __init__( self, w: WrappedWidget, align: ( Literal["left", "center", "right"] | Align | tuple[Literal["relative", WHSettings.RELATIVE, "fixed left", "fixed right"], int] ) = Align.LEFT, width: ( int | Literal["pack", "clip", WHSettings.PACK, WHSettings.CLIP] | tuple[Literal["relative", WHSettings.RELATIVE, "fixed left", "fixed right"], int] ) = RELATIVE_100, min_width: int | None = None, left: int = 0, right: int = 0, ) -> None: """ :param w: a box, flow or fixed widget to pad on the left and/or right this widget is stored as self.original_widget :type w: Widget :param align: one of: ``'left'``, ``'center'``, ``'right'`` (``'relative'``, *percentage* 0=left 100=right) :param width: one of: *given width* integer number of columns for self.original_widget ``'pack'`` try to pack self.original_widget to its ideal size (``'relative'``, *percentage of total width*) make width depend on the container's width ``'clip'`` to enable clipping mode for a fixed widget :param min_width: the minimum number of columns for self.original_widget or ``None`` :type min_width: int | None :param left: a fixed number of columns to pad on the left :type left: int :param right: a fixed number of columns to pad on the right :type right: int Clipping Mode: (width= ``'clip'``) In clipping mode this padding widget will behave as a flow widget and self.original_widget will be treated as a fixed widget. self.original_widget will be clipped to fit the available number of columns. For example if align is ``'left'`` then self.original_widget may be clipped on the right. Pack Mode: (width= ``'pack'``) In pack mode is supported FIXED operation if it is supported by the original widget. >>> from urwid import Divider, Text, BigText, FontRegistry >>> from urwid.util import set_temporary_encoding >>> size = (7,) >>> def pr(w): ... with set_temporary_encoding("utf-8"): ... for t in w.render(size).text: ... print(f"|{t.decode('utf-8')}|" ) >>> pr(Padding(Text(u"Head"), ('relative', 20), 'pack')) | Head | >>> pr(Padding(Divider(u"-"), left=2, right=1)) | ---- | >>> pr(Padding(Divider(u"*"), 'center', 3)) | *** | >>> p=Padding(Text(u"1234"), 'left', 2, None, 1, 1) >>> p left=1 right=1 width=2> >>> pr(p) # align against left | 12 | | 34 | >>> p.align = 'right' >>> pr(p) # align against right | 12 | | 34 | >>> pr(Padding(Text(u"hi\\nthere"), 'right', 'pack')) # pack text first | hi | | there| >>> pr(Padding(BigText("1,2,3", FontRegistry['Thin 3x3']()), width="clip")) | ┐ ┌─┐| | │ ┌─┘| | ┴ ,└─ | """ super().__init__(w) # convert obsolete parameters 'fixed left' and 'fixed right': if isinstance(align, tuple) and align[0] in {"fixed left", "fixed right"}: if align[0] == "fixed left": left = align[1] align = Align.LEFT else: right = align[1] align = Align.RIGHT if isinstance(width, tuple) and width[0] in {"fixed left", "fixed right"}: if width[0] == "fixed left": left = width[1] else: right = width[1] width = RELATIVE_100 # convert old clipping mode width=None to width='clip' if width is None: width = WHSettings.CLIP self.left = left self.right = right self._align_type, self._align_amount = normalize_align(align, PaddingError) self._width_type, self._width_amount = normalize_width(width, PaddingError) self.min_width = min_width def sizing(self) -> frozenset[Sizing]: """Widget sizing. Rules: * width == CLIP: only FLOW is supported, and wrapped widget should support FIXED * width == GIVEN: FIXED is supported, and wrapped widget should support FLOW * All other cases: use sizing of target widget """ if self._width_type == WHSettings.CLIP: return frozenset((Sizing.FLOW,)) sizing = set(self.original_widget.sizing()) if self._width_type == WHSettings.GIVEN: if Sizing.FLOW in sizing: sizing.add(Sizing.FIXED) elif Sizing.BOX not in sizing: warnings.warn( f"WHSettings.GIVEN expect BOX or FLOW widget to be used, but received {self.original_widget}", PaddingWarning, stacklevel=3, ) return frozenset(sizing) def _repr_attrs(self) -> dict[str, typing.Any]: attrs = { **super()._repr_attrs(), "align": self.align, "width": self.width, "left": self.left, "right": self.right, "min_width": self.min_width, } return remove_defaults(attrs, Padding.__init__) def __rich_repr__(self) -> Iterator[tuple[str | None, typing.Any] | typing.Any]: yield "w", self.original_widget yield "align", self.align yield "width", self.width yield "min_width", self.min_width yield "left", self.left yield "right", self.right @property def align( self, ) -> Literal["left", "center", "right"] | Align | tuple[Literal["relative", WHSettings.RELATIVE], int]: """ Return the padding alignment setting. """ return simplify_align(self._align_type, self._align_amount) @align.setter def align( self, align: Literal["left", "center", "right"] | Align | tuple[Literal["relative", WHSettings.RELATIVE], int] ) -> None: """ Set the padding alignment. """ self._align_type, self._align_amount = normalize_align(align, PaddingError) self._invalidate() def _get_align(self) -> Literal["left", "center", "right"] | tuple[Literal["relative"], int]: warnings.warn( f"Method `{self.__class__.__name__}._get_align` is deprecated, " f"please use property `{self.__class__.__name__}.align`", DeprecationWarning, stacklevel=2, ) return self.align def _set_align(self, align: Literal["left", "center", "right"] | tuple[Literal["relative"], int]) -> None: warnings.warn( f"Method `{self.__class__.__name__}._set_align` is deprecated, " f"please use property `{self.__class__.__name__}.align`", DeprecationWarning, stacklevel=2, ) self.align = align @property def width( self, ) -> ( Literal["clip", "pack", WHSettings.CLIP, WHSettings.PACK] | int | tuple[Literal["relative", WHSettings.RELATIVE], int] ): """ Return the padding width. """ return simplify_width(self._width_type, self._width_amount) @width.setter def width( self, width: ( Literal["clip", "pack", WHSettings.CLIP, WHSettings.PACK] | int | tuple[Literal["relative", WHSettings.RELATIVE], int] ), ) -> None: """ Set the padding width. """ self._width_type, self._width_amount = normalize_width(width, PaddingError) self._invalidate() def _get_width(self) -> Literal["clip", "pack"] | int | tuple[Literal["relative"], int]: warnings.warn( f"Method `{self.__class__.__name__}._get_width` is deprecated, " f"please use property `{self.__class__.__name__}.width`", DeprecationWarning, stacklevel=2, ) return self.width def _set_width(self, width: Literal["clip", "pack"] | int | tuple[Literal["relative"], int]) -> None: warnings.warn( f"Method `{self.__class__.__name__}._set_width` is deprecated, " f"please use property `{self.__class__.__name__}.width`", DeprecationWarning, stacklevel=2, ) self.width = width def pack( self, size: tuple[()] | tuple[int] | tuple[int, int] = (), focus: bool = False, ) -> tuple[int, int]: if size: return super().pack(size, focus) if self._width_type == WHSettings.CLIP: raise PaddingError("WHSettings.CLIP makes Padding FLOW-only widget") expand = self.left + self.right w_sizing = self.original_widget.sizing() if self._width_type == WHSettings.GIVEN: if Sizing.FLOW not in w_sizing: warnings.warn( f"WHSettings.GIVEN expect FLOW widget to be used for FIXED pack/render, " f"but received {self.original_widget}", PaddingWarning, stacklevel=3, ) return ( max(self._width_amount, self.min_width or 1) + expand, self.original_widget.rows((self._width_amount,), focus), ) if Sizing.FIXED not in w_sizing: warnings.warn( f"Padded widget should support FIXED sizing for FIXED render, but received {self.original_widget}", PaddingWarning, stacklevel=3, ) width, height = self.original_widget.pack(size, focus) if self._width_type == WHSettings.PACK: return max(width, self.min_width or 1) + expand, height if self._width_type == WHSettings.RELATIVE: return max(int(width * 100 / self._width_amount + 0.5), self.min_width or 1) + expand, height raise PaddingError(f"Unexpected width type: {self._width_type.upper()})") def render( self, size: tuple[()] | tuple[int] | tuple[int, int], focus: bool = False, ) -> CompositeCanvas: left, right = self.padding_values(size, focus) if self._width_type == WHSettings.CLIP: canv = self._original_widget.render((), focus) elif size: maxcol = size[0] - (left + right) if self._width_type == WHSettings.GIVEN and maxcol < self._width_amount: warnings.warn( f"{self}.render(size={size}, focus={focus}): too narrow size ({maxcol!r} < {self._width_amount!r})", PaddingWarning, stacklevel=3, ) canv = self._original_widget.render((maxcol,) + size[1:], focus) elif self._width_type == WHSettings.GIVEN: canv = self._original_widget.render((self._width_amount,) + size[1:], focus) else: canv = self._original_widget.render((), focus) if canv.cols() == 0: canv = SolidCanvas(" ", size[0], canv.rows()) canv = CompositeCanvas(canv) canv.set_depends([self._original_widget]) return canv canv = CompositeCanvas(canv) canv.set_depends([self._original_widget]) if left != 0 or right != 0: canv.pad_trim_left_right(left, right) return canv def padding_values( self, size: tuple[()] | tuple[int] | tuple[int, int], focus: bool, ) -> tuple[int, int]: """Return the number of columns to pad on the left and right. Override this method to define custom padding behaviour.""" if self._width_type == WHSettings.CLIP: width, _ignore = self._original_widget.pack((), focus=focus) if not size: raise PaddingError("WHSettings.CLIP makes Padding FLOW-only widget") return calculate_left_right_padding( size[0], self._align_type, self._align_amount, WHSettings.CLIP, width, None, self.left, self.right, ) if self._width_type == WHSettings.PACK: if size: maxcol = size[0] maxwidth = max(maxcol - self.left - self.right, self.min_width or 0) (width, _ignore) = self._original_widget.pack((maxwidth,), focus=focus) else: (width, _ignore) = self._original_widget.pack((), focus=focus) maxcol = width + self.left + self.right return calculate_left_right_padding( maxcol, self._align_type, self._align_amount, WHSettings.GIVEN, width, self.min_width, self.left, self.right, ) if size: maxcol = size[0] elif self._width_type == WHSettings.GIVEN: maxcol = self._width_amount + self.left + self.right else: maxcol = ( max(self._original_widget.pack((), focus=focus)[0] * 100 // self._width_amount, self.min_width or 1) + self.left + self.right ) return calculate_left_right_padding( maxcol, self._align_type, self._align_amount, self._width_type, self._width_amount, self.min_width, self.left, self.right, ) def rows(self, size: tuple[int], focus: bool = False) -> int: """Return the rows needed for self.original_widget.""" (maxcol,) = size left, right = self.padding_values(size, focus) if self._width_type == WHSettings.PACK: _pcols, prows = self._original_widget.pack((maxcol - left - right,), focus) return prows if self._width_type == WHSettings.CLIP: _fcols, frows = self._original_widget.pack((), focus) return frows return self._original_widget.rows((maxcol - left - right,), focus=focus) def keypress(self, size: tuple[()] | tuple[int] | tuple[int, int], key: str) -> str | None: """Pass keypress to self._original_widget.""" left, right = self.padding_values(size, True) if size: maxvals = (size[0] - left - right,) + size[1:] return self._original_widget.keypress(maxvals, key) return self._original_widget.keypress((), key) def get_cursor_coords(self, size: tuple[()] | tuple[int] | tuple[int, int]) -> tuple[int, int] | None: """Return the (x,y) coordinates of cursor within self._original_widget.""" if not hasattr(self._original_widget, "get_cursor_coords"): return None left, right = self.padding_values(size, True) if size: maxvals = (size[0] - left - right,) + size[1:] if maxvals[0] == 0: return None else: maxvals = () coords = self._original_widget.get_cursor_coords(maxvals) if coords is None: return None x, y = coords return x + left, y def move_cursor_to_coords( self, size: tuple[()] | tuple[int] | tuple[int, int], x: int, y: int, ) -> bool: """Set the cursor position with (x,y) coordinates of self._original_widget. Returns True if move succeeded, False otherwise. """ if not hasattr(self._original_widget, "move_cursor_to_coords"): return True left, right = self.padding_values(size, True) if size: maxcol = size[0] maxvals = (maxcol - left - right,) + size[1:] else: maxcol = self.pack((), True)[0] maxvals = () if isinstance(x, int): if x < left: x = left elif x >= maxcol - right: x = maxcol - right - 1 x -= left return self._original_widget.move_cursor_to_coords(maxvals, x, y) def mouse_event( self, size: tuple[()] | tuple[int] | tuple[int, int], event: str, button: int, col: int, row: int, focus: bool, ) -> bool | None: """Send mouse event if position is within self._original_widget.""" if not hasattr(self._original_widget, "mouse_event"): return False left, right = self.padding_values(size, focus) if size: maxcol = size[0] if col < left or col >= maxcol - right: return False maxvals = (maxcol - left - right,) + size[1:] else: maxvals = () return self._original_widget.mouse_event(maxvals, event, button, col - left, row, focus) def get_pref_col(self, size: tuple[()] | tuple[int] | tuple[int, int]) -> int | None: """Return the preferred column from self._original_widget, or None.""" if not hasattr(self._original_widget, "get_pref_col"): return None left, right = self.padding_values(size, True) if size: maxvals = (size[0] - left - right,) + size[1:] else: maxvals = () x = self._original_widget.get_pref_col(maxvals) if isinstance(x, int): return x + left return x def calculate_left_right_padding( maxcol: int, align_type: Literal["left", "center", "right"] | Align, align_amount: int, width_type: Literal["fixed", "relative", "clip", "given", WHSettings.RELATIVE, WHSettings.CLIP, WHSettings.GIVEN], width_amount: int, min_width: int | None, left: int, right: int, ) -> tuple[int, int]: """ Return the amount of padding (or clipping) on the left and right part of maxcol columns to satisfy the following: align_type -- 'left', 'center', 'right', 'relative' align_amount -- a percentage when align_type=='relative' width_type -- 'fixed', 'relative', 'clip' width_amount -- a percentage when width_type=='relative' otherwise equal to the width of the widget min_width -- a desired minimum width for the widget or None left -- a fixed number of columns to pad on the left right -- a fixed number of columns to pad on the right >>> clrp = calculate_left_right_padding >>> clrp(15, 'left', 0, 'given', 10, None, 2, 0) (2, 3) >>> clrp(15, 'relative', 0, 'given', 10, None, 2, 0) (2, 3) >>> clrp(15, 'relative', 100, 'given', 10, None, 2, 0) (5, 0) >>> clrp(15, 'center', 0, 'given', 4, None, 2, 0) (6, 5) >>> clrp(15, 'left', 0, 'clip', 18, None, 0, 0) (0, -3) >>> clrp(15, 'right', 0, 'clip', 18, None, 0, -1) (-2, -1) >>> clrp(15, 'center', 0, 'given', 18, None, 2, 0) (0, 0) >>> clrp(20, 'left', 0, 'relative', 60, None, 0, 0) (0, 8) >>> clrp(20, 'relative', 30, 'relative', 60, None, 0, 0) (2, 6) >>> clrp(20, 'relative', 30, 'relative', 60, 14, 0, 0) (2, 4) """ if width_type == WHSettings.RELATIVE: maxwidth = max(maxcol - left - right, 0) width = int(maxwidth * width_amount / 100 + 0.5) if min_width is not None: width = max(width, min_width) else: width = width_amount align = {Align.LEFT: 0, Align.CENTER: 50, Align.RIGHT: 100}.get(align_type, align_amount) # add the remainder of left/right the padding padding = maxcol - width - left - right right += int_scale(100 - align, 101, padding + 1) left = maxcol - width - right # reduce padding if we are clipping an edge if right < 0 < left: shift = min(left, -right) left -= shift right += shift elif left < 0 < right: shift = min(right, -left) right -= shift left += shift # only clip if width_type == 'clip' if width_type != WHSettings.CLIP and (left < 0 or right < 0): left = max(left, 0) right = max(right, 0) return left, right