from __future__ import annotations import typing import warnings from urwid.canvas import CompositeCanvas from urwid.split_repr import remove_defaults from urwid.util import int_scale from .constants import ( RELATIVE_100, Sizing, VAlign, WHSettings, normalize_height, normalize_valign, simplify_height, simplify_valign, ) from .widget_decoration import WidgetDecoration if typing.TYPE_CHECKING: from typing_extensions import Literal from .widget import Widget class FillerError(Exception): pass class Filler(WidgetDecoration): def __init__( self, body: Widget, valign: ( Literal["top", "middle", "bottom"] | VAlign | tuple[Literal["relative", WHSettings.RELATIVE], int] ) = VAlign.MIDDLE, height: int | Literal["pack"] | tuple[Literal["relative"], int] | None = WHSettings.PACK, min_height: int | None = None, top: int = 0, bottom: int = 0, ) -> None: """ :param body: a flow widget or box widget to be filled around (stored as self.original_widget) :type body: Widget :param valign: one of: ``'top'``, ``'middle'``, ``'bottom'``, (``'relative'``, *percentage* 0=top 100=bottom) :param height: one of: ``'pack'`` if body is a flow widget *given height* integer number of rows for self.original_widget (``'relative'``, *percentage of total height*) make height depend on container's height :param min_height: one of: ``None`` if no minimum or if body is a flow widget *minimum height* integer number of rows for the widget when height not fixed :param top: a fixed number of rows to fill at the top :type top: int :param bottom: a fixed number of rows to fill at the bottom :type bottom: int If body is a flow widget then height must be ``'flow'`` and *min_height* will be ignored. Filler widgets will try to satisfy height argument first by reducing the valign amount when necessary. If height still cannot be satisfied it will also be reduced. """ super().__init__(body) # convert old parameters to the new top/bottom values if isinstance(height, tuple): if height[0] == "fixed top": if not isinstance(valign, tuple) or valign[0] != "fixed bottom": raise FillerError("fixed top height may only be used with fixed bottom valign") top = height[1] height = RELATIVE_100 elif height[0] == "fixed bottom": if not isinstance(valign, tuple) or valign[0] != "fixed top": raise FillerError("fixed bottom height may only be used with fixed top valign") bottom = height[1] height = RELATIVE_100 if isinstance(valign, tuple): if valign[0] == "fixed top": top = valign[1] valign = VAlign.TOP elif valign[0] == "fixed bottom": bottom = valign[1] valign = VAlign.BOTTOM # convert old flow mode parameter height=None to height='flow' if height is None or height == Sizing.FLOW: height = WHSettings.PACK self.top = top self.bottom = bottom self.valign_type, self.valign_amount = normalize_valign(valign, FillerError) self.height_type, self.height_amount = normalize_height(height, FillerError) if self.height_type not in (WHSettings.GIVEN, WHSettings.PACK): self.min_height = min_height else: self.min_height = None def sizing(self): return {Sizing.BOX} # always a box widget def _repr_attrs(self): attrs = dict( super()._repr_attrs(), valign=simplify_valign(self.valign_type, self.valign_amount), height=simplify_height(self.height_type, self.height_amount), top=self.top, bottom=self.bottom, min_height=self.min_height, ) return remove_defaults(attrs, Filler.__init__) @property def body(self): """backwards compatibility, widget used to be stored as body""" warnings.warn( "backwards compatibility, widget used to be stored as body", PendingDeprecationWarning, stacklevel=2, ) return self.original_widget @body.setter def body(self, new_body): warnings.warn( "backwards compatibility, widget used to be stored as body", PendingDeprecationWarning, stacklevel=2, ) self.original_widget = new_body def get_body(self): """backwards compatibility, widget used to be stored as body""" warnings.warn( "backwards compatibility, widget used to be stored as body", DeprecationWarning, stacklevel=2, ) return self.original_widget def set_body(self, new_body): warnings.warn( "backwards compatibility, widget used to be stored as body", DeprecationWarning, stacklevel=2, ) self.original_widget = new_body def selectable(self) -> bool: """Return selectable from body.""" return self._original_widget.selectable() def filler_values(self, size: tuple[int, int], focus: bool) -> tuple[int, int]: """ Return the number of rows to pad on the top and bottom. Override this method to define custom padding behaviour. """ (maxcol, maxrow) = size if self.height_type == WHSettings.PACK: height = self._original_widget.rows((maxcol,), focus=focus) return calculate_top_bottom_filler( maxrow, self.valign_type, self.valign_amount, WHSettings.GIVEN, height, None, self.top, self.bottom ) return calculate_top_bottom_filler( maxrow, self.valign_type, self.valign_amount, self.height_type, self.height_amount, self.min_height, self.top, self.bottom, ) def render(self, size: tuple[int, int], focus: bool = False) -> CompositeCanvas: """Render self.original_widget with space above and/or below.""" (maxcol, maxrow) = size top, bottom = self.filler_values(size, focus) if self.height_type == WHSettings.PACK: canv = self._original_widget.render((maxcol,), focus) else: canv = self._original_widget.render((maxcol, maxrow - top - bottom), focus) canv = CompositeCanvas(canv) if maxrow and canv.rows() > maxrow and canv.cursor is not None: cx, cy = canv.cursor if cy >= maxrow: canv.trim(cy - maxrow + 1, maxrow - top - bottom) if canv.rows() > maxrow: canv.trim(0, maxrow) return canv canv.pad_trim_top_bottom(top, bottom) return canv def keypress(self, size: tuple[int, int], key: str) -> str | None: """Pass keypress to self.original_widget.""" (maxcol, maxrow) = size if self.height_type == WHSettings.PACK: return self._original_widget.keypress((maxcol,), key) top, bottom = self.filler_values((maxcol, maxrow), True) return self._original_widget.keypress((maxcol, maxrow - top - bottom), key) def get_cursor_coords(self, size: tuple[int, int]) -> tuple[int, int] | None: """Return cursor coords from self.original_widget if any.""" (maxcol, maxrow) = size if not hasattr(self._original_widget, "get_cursor_coords"): return None top, bottom = self.filler_values(size, True) if self.height_type == WHSettings.PACK: coords = self._original_widget.get_cursor_coords((maxcol,)) else: coords = self._original_widget.get_cursor_coords((maxcol, maxrow - top - bottom)) if not coords: return None x, y = coords if y >= maxrow: y = maxrow - 1 return x, y + top def get_pref_col(self, size: tuple[int, int]) -> int: """Return pref_col from self.original_widget if any.""" (maxcol, maxrow) = size if not hasattr(self._original_widget, "get_pref_col"): return None if self.height_type == WHSettings.PACK: x = self._original_widget.get_pref_col((maxcol,)) else: top, bottom = self.filler_values(size, True) x = self._original_widget.get_pref_col((maxcol, maxrow - top - bottom)) return x def move_cursor_to_coords(self, size: tuple[int, int], col: int, row: int) -> bool: """Pass to self.original_widget.""" (maxcol, maxrow) = size if not hasattr(self._original_widget, "move_cursor_to_coords"): return True top, bottom = self.filler_values(size, True) if row < top or row >= maxcol - bottom: return False if self.height_type == WHSettings.PACK: return self._original_widget.move_cursor_to_coords((maxcol,), col, row - top) return self._original_widget.move_cursor_to_coords((maxcol, maxrow - top - bottom), col, row - top) def mouse_event( self, size: tuple[int, int], event, button: int, col: int, row: int, focus: bool, ) -> bool: """Pass to self.original_widget.""" (maxcol, maxrow) = size if not hasattr(self._original_widget, "mouse_event"): return False top, bottom = self.filler_values(size, True) if row < top or row >= maxrow - bottom: return False if self.height_type == WHSettings.PACK: return self._original_widget.mouse_event((maxcol,), event, button, col, row - top, focus) return self._original_widget.mouse_event((maxcol, maxrow - top - bottom), event, button, col, row - top, focus) def calculate_top_bottom_filler( maxrow: int, valign_type: Literal["top", "middle", "bottom", "relative", WHSettings.RELATIVE] | VAlign, valign_amount: int, height_type: Literal["given", "relative", "clip", WHSettings.GIVEN, WHSettings.RELATIVE, WHSettings.CLIP], height_amount: int, min_height: int | None, top: int, bottom: int, ) -> tuple[int, int]: """ Return the amount of filler (or clipping) on the top and bottom part of maxrow rows to satisfy the following: valign_type -- 'top', 'middle', 'bottom', 'relative' valign_amount -- a percentage when align_type=='relative' height_type -- 'given', 'relative', 'clip' height_amount -- a percentage when width_type=='relative' otherwise equal to the height of the widget min_height -- a desired minimum width for the widget or None top -- a fixed number of rows to fill on the top bottom -- a fixed number of rows to fill on the bottom >>> ctbf = calculate_top_bottom_filler >>> ctbf(15, 'top', 0, 'given', 10, None, 2, 0) (2, 3) >>> ctbf(15, 'relative', 0, 'given', 10, None, 2, 0) (2, 3) >>> ctbf(15, 'relative', 100, 'given', 10, None, 2, 0) (5, 0) >>> ctbf(15, 'middle', 0, 'given', 4, None, 2, 0) (6, 5) >>> ctbf(15, 'middle', 0, 'given', 18, None, 2, 0) (0, 0) >>> ctbf(20, 'top', 0, 'relative', 60, None, 0, 0) (0, 8) >>> ctbf(20, 'relative', 30, 'relative', 60, None, 0, 0) (2, 6) >>> ctbf(20, 'relative', 30, 'relative', 60, 14, 0, 0) (2, 4) """ if height_type == WHSettings.RELATIVE: maxheight = max(maxrow - top - bottom, 0) height = int_scale(height_amount, 101, maxheight + 1) if min_height is not None: height = max(height, min_height) else: height = height_amount standard_alignments = {VAlign.TOP: 0, VAlign.MIDDLE: 50, VAlign.BOTTOM: 100} valign = standard_alignments.get(valign_type, valign_amount) # add the remainder of top/bottom to the filler filler = maxrow - height - top - bottom bottom += int_scale(100 - valign, 101, filler + 1) top = maxrow - height - bottom # reduce filler if we are clipping an edge if bottom < 0 < top: shift = min(top, -bottom) top -= shift bottom += shift elif top < 0 < bottom: shift = min(bottom, -top) bottom -= shift top += shift # no negative values for filler at the moment top = max(top, 0) bottom = max(bottom, 0) return top, bottom