codekingpro/portable-devtools
114k
1from __future__ import annotations2 3import typing4import warnings5 6from urwid.canvas import CompositeCanvas7from urwid.split_repr import remove_defaults8from urwid.util import int_scale9 10from .constants import (11 RELATIVE_100,12 Sizing,13 VAlign,14 WHSettings,15 normalize_height,16 normalize_valign,17 simplify_height,18 simplify_valign,19)20from .widget_decoration import WidgetDecoration, WidgetError21 22if typing.TYPE_CHECKING:23 from typing_extensions import Literal24 25WrappedWidget = typing.TypeVar("WrappedWidget")26 27 28class FillerError(WidgetError):29 pass30 31 32class Filler(WidgetDecoration[WrappedWidget]):33 def __init__(34 self,35 body: WrappedWidget,36 valign: (37 Literal["top", "middle", "bottom"] | VAlign | tuple[Literal["relative", WHSettings.RELATIVE], int]38 ) = VAlign.MIDDLE,39 height: (40 int | Literal["pack", WHSettings.PACK] | tuple[Literal["relative", WHSettings.RELATIVE], int] | None41 ) = WHSettings.PACK,42 min_height: int | None = None,43 top: int = 0,44 bottom: int = 0,45 ) -> None:46 """47 :param body: a flow widget or box widget to be filled around (stored as self.original_widget)48 :type body: Widget49 50 :param valign: one of:51 ``'top'``, ``'middle'``, ``'bottom'``,52 (``'relative'``, *percentage* 0=top 100=bottom)53 54 :param height: one of:55 56 ``'pack'``57 if body is a flow widget58 59 *given height*60 integer number of rows for self.original_widget61 62 (``'relative'``, *percentage of total height*)63 make height depend on container's height64 65 :param min_height: one of:66 67 ``None``68 if no minimum or if body is a flow widget69 70 *minimum height*71 integer number of rows for the widget when height not fixed72 73 :param top: a fixed number of rows to fill at the top74 :type top: int75 :param bottom: a fixed number of rows to fill at the bottom76 :type bottom: int77 78 If body is a flow widget, then height must be ``'pack'`` and *min_height* will be ignored.79 Sizing of the filler will be BOX/FLOW in this case.80 81 If height is integer, *min_height* will be ignored and sizing of filler will be BOX/FLOW.82 83 Filler widgets will try to satisfy height argument first by reducing the valign amount when necessary.84 If height still cannot be satisfied it will also be reduced.85 """86 super().__init__(body)87 88 # convert old parameters to the new top/bottom values89 if isinstance(height, tuple):90 if height[0] == "fixed top":91 if not isinstance(valign, tuple) or valign[0] != "fixed bottom":92 raise FillerError("fixed top height may only be used with fixed bottom valign")93 top = height[1]94 height = RELATIVE_10095 elif height[0] == "fixed bottom":96 if not isinstance(valign, tuple) or valign[0] != "fixed top":97 raise FillerError("fixed bottom height may only be used with fixed top valign")98 bottom = height[1]99 height = RELATIVE_100100 101 if isinstance(valign, tuple):102 if valign[0] == "fixed top":103 top = valign[1]104 normalized_valign = VAlign.TOP105 elif valign[0] == "fixed bottom":106 bottom = valign[1]107 normalized_valign = VAlign.BOTTOM108 else:109 normalized_valign = valign110 111 elif not isinstance(valign, (VAlign, str)):112 raise FillerError(f"invalid valign: {valign!r}")113 114 else:115 normalized_valign = VAlign(valign)116 117 # convert old flow mode parameter height=None to height='flow'118 if height is None or height == Sizing.FLOW:119 height = WHSettings.PACK120 121 self.top = top122 self.bottom = bottom123 self.valign_type, self.valign_amount = normalize_valign(normalized_valign, FillerError)124 self.height_type, self.height_amount = normalize_height(height, FillerError)125 126 if self.height_type not in {WHSettings.GIVEN, WHSettings.PACK}:127 self.min_height = min_height128 else:129 self.min_height = None130 131 def sizing(self) -> frozenset[Sizing]:132 """Widget sizing.133 134 Sizing BOX is always supported.135 Sizing FLOW is supported if: FLOW widget (a height type is PACK) or BOX widget with height GIVEN136 """137 sizing: set[Sizing] = {Sizing.BOX}138 if self.height_type in {WHSettings.PACK, WHSettings.GIVEN}:139 sizing.add(Sizing.FLOW)140 return frozenset(sizing)141 142 def rows(self, size: tuple[int], focus: bool = False) -> int:143 """Flow pack support if FLOW sizing supported."""144 if self.height_type == WHSettings.PACK:145 return self.original_widget.rows(size, focus) + self.top + self.bottom146 if self.height_type == WHSettings.GIVEN:147 return self.height_amount + self.top + self.bottom148 raise FillerError("Method 'rows' not supported for BOX widgets") # pragma: no cover149 150 def _repr_attrs(self) -> dict[str, typing.Any]:151 attrs = {152 **super()._repr_attrs(),153 "valign": simplify_valign(self.valign_type, self.valign_amount),154 "height": simplify_height(self.height_type, self.height_amount),155 "top": self.top,156 "bottom": self.bottom,157 "min_height": self.min_height,158 }159 return remove_defaults(attrs, Filler.__init__)160 161 @property162 def body(self) -> WrappedWidget:163 """backwards compatibility, widget used to be stored as body"""164 warnings.warn(165 "backwards compatibility, widget used to be stored as body. API will be removed in version 5.0.",166 DeprecationWarning,167 stacklevel=2,168 )169 return self.original_widget170 171 @body.setter172 def body(self, new_body: WrappedWidget) -> None:173 warnings.warn(174 "backwards compatibility, widget used to be stored as body. API will be removed in version 5.0.",175 DeprecationWarning,176 stacklevel=2,177 )178 self.original_widget = new_body179 180 def get_body(self) -> WrappedWidget:181 """backwards compatibility, widget used to be stored as body"""182 warnings.warn(183 "backwards compatibility, widget used to be stored as body. API will be removed in version 4.0.",184 DeprecationWarning,185 stacklevel=2,186 )187 return self.original_widget188 189 def set_body(self, new_body: WrappedWidget) -> None:190 warnings.warn(191 "backwards compatibility, widget used to be stored as body. API will be removed in version 4.0.",192 DeprecationWarning,193 stacklevel=2,194 )195 self.original_widget = new_body196 197 def selectable(self) -> bool:198 """Return selectable from body."""199 return self._original_widget.selectable()200 201 def filler_values(self, size: tuple[int, int] | tuple[int], focus: bool) -> tuple[int, int]:202 """203 Return the number of rows to pad on the top and bottom.204 205 Override this method to define custom padding behaviour.206 """207 maxcol, maxrow = self.pack(size, focus)208 209 if self.height_type == WHSettings.PACK:210 height = self._original_widget.rows((maxcol,), focus=focus)211 return calculate_top_bottom_filler(212 maxrow,213 self.valign_type,214 self.valign_amount,215 WHSettings.GIVEN,216 height,217 None,218 self.top,219 self.bottom,220 )221 222 return calculate_top_bottom_filler(223 maxrow,224 self.valign_type,225 self.valign_amount,226 self.height_type,227 self.height_amount,228 self.min_height,229 self.top,230 self.bottom,231 )232 233 def render(234 self,235 size: tuple[int, int] | tuple[int], # type: ignore[override]236 focus: bool = False,237 ) -> CompositeCanvas:238 """Render self.original_widget with space above and/or below."""239 maxcol, maxrow = self.pack(size, focus)240 top, bottom = self.filler_values(size, focus)241 242 if self.height_type == WHSettings.PACK:243 canv = self._original_widget.render((maxcol,), focus)244 else:245 canv = self._original_widget.render((maxcol, maxrow - top - bottom), focus)246 canv = CompositeCanvas(canv)247 248 if maxrow and canv.rows() > maxrow and canv.cursor is not None:249 _cx, cy = canv.cursor250 if cy >= maxrow:251 canv.trim(cy - maxrow + 1, maxrow - top - bottom)252 if canv.rows() > maxrow:253 canv.trim(0, maxrow)254 return canv255 canv.pad_trim_top_bottom(top, bottom)256 return canv257 258 def keypress(259 self,260 size: tuple[int, int] | tuple[()], # type: ignore[override]261 key: str,262 ) -> str | None:263 """Pass keypress to self.original_widget."""264 maxcol, maxrow = self.pack(size, True)265 if self.height_type == WHSettings.PACK:266 return self._original_widget.keypress((maxcol,), key)267 268 top, bottom = self.filler_values((maxcol, maxrow), True)269 return self._original_widget.keypress((maxcol, maxrow - top - bottom), key)270 271 def get_cursor_coords(self, size: tuple[int, int] | tuple[int]) -> tuple[int, int] | None:272 """Return cursor coords from self.original_widget if any."""273 maxcol, maxrow = self.pack(size, True)274 if not hasattr(self._original_widget, "get_cursor_coords"):275 return None276 277 top, bottom = self.filler_values(size, True)278 if self.height_type == WHSettings.PACK:279 coords = self._original_widget.get_cursor_coords((maxcol,))280 else:281 coords = self._original_widget.get_cursor_coords((maxcol, maxrow - top - bottom))282 if not coords:283 return None284 x, y = coords285 if y >= maxrow:286 y = maxrow - 1287 return x, y + top288 289 def get_pref_col(self, size: tuple[int, int] | tuple[int]) -> int | None:290 """Return pref_col from self.original_widget if any."""291 maxcol, maxrow = self.pack(size, True)292 if not hasattr(self._original_widget, "get_pref_col"):293 return None294 295 if self.height_type == WHSettings.PACK:296 x = self._original_widget.get_pref_col((maxcol,))297 else:298 top, bottom = self.filler_values(size, True)299 x = self._original_widget.get_pref_col((maxcol, maxrow - top - bottom))300 301 return x302 303 def move_cursor_to_coords(self, size: tuple[int, int] | tuple[int], col: int, row: int) -> bool:304 """Pass to self.original_widget."""305 maxcol, maxrow = self.pack(size, True)306 if not hasattr(self._original_widget, "move_cursor_to_coords"):307 return True308 309 top, bottom = self.filler_values(size, True)310 if row < top or row >= maxcol - bottom:311 return False312 313 if self.height_type == WHSettings.PACK:314 return self._original_widget.move_cursor_to_coords((maxcol,), col, row - top)315 return self._original_widget.move_cursor_to_coords((maxcol, maxrow - top - bottom), col, row - top)316 317 def mouse_event(318 self,319 size: tuple[int, int] | tuple[int], # type: ignore[override]320 event,321 button: int,322 col: int,323 row: int,324 focus: bool,325 ) -> bool | None:326 """Pass to self.original_widget."""327 maxcol, maxrow = self.pack(size, focus)328 if not hasattr(self._original_widget, "mouse_event"):329 return False330 331 top, bottom = self.filler_values(size, True)332 if row < top or row >= maxrow - bottom:333 return False334 335 if self.height_type == WHSettings.PACK:336 return self._original_widget.mouse_event((maxcol,), event, button, col, row - top, focus)337 return self._original_widget.mouse_event((maxcol, maxrow - top - bottom), event, button, col, row - top, focus)338 339 340def calculate_top_bottom_filler(341 maxrow: int,342 valign_type: Literal["top", "middle", "bottom", "relative", WHSettings.RELATIVE] | VAlign,343 valign_amount: int,344 height_type: Literal["given", "relative", "clip", WHSettings.GIVEN, WHSettings.RELATIVE, WHSettings.CLIP],345 height_amount: int,346 min_height: int | None,347 top: int,348 bottom: int,349) -> tuple[int, int]:350 """351 Return the amount of filler (or clipping) on the top and352 bottom part of maxrow rows to satisfy the following:353 354 valign_type -- 'top', 'middle', 'bottom', 'relative'355 valign_amount -- a percentage when align_type=='relative'356 height_type -- 'given', 'relative', 'clip'357 height_amount -- a percentage when width_type=='relative'358 otherwise equal to the height of the widget359 min_height -- a desired minimum width for the widget or None360 top -- a fixed number of rows to fill on the top361 bottom -- a fixed number of rows to fill on the bottom362 363 >>> ctbf = calculate_top_bottom_filler364 >>> ctbf(15, "top", 0, "given", 10, None, 2, 0)365 (2, 3)366 >>> ctbf(15, "relative", 0, "given", 10, None, 2, 0)367 (2, 3)368 >>> ctbf(15, "relative", 100, "given", 10, None, 2, 0)369 (5, 0)370 >>> ctbf(15, "middle", 0, "given", 4, None, 2, 0)371 (6, 5)372 >>> ctbf(15, "middle", 0, "given", 18, None, 2, 0)373 (0, 0)374 >>> ctbf(20, "top", 0, "relative", 60, None, 0, 0)375 (0, 8)376 >>> ctbf(20, "relative", 30, "relative", 60, None, 0, 0)377 (2, 6)378 >>> ctbf(20, "relative", 30, "relative", 60, 14, 0, 0)379 (2, 4)380 """381 if height_type == WHSettings.RELATIVE:382 maxheight = max(maxrow - top - bottom, 0)383 height = int_scale(height_amount, 101, maxheight + 1)384 if min_height is not None:385 height = max(height, min_height)386 else:387 height = height_amount388 389 valign = {VAlign.TOP: 0, VAlign.MIDDLE: 50, VAlign.BOTTOM: 100}.get(valign_type, valign_amount)390 391 # add the remainder of top/bottom to the filler392 filler = maxrow - height - top - bottom393 bottom += int_scale(100 - valign, 101, filler + 1)394 top = maxrow - height - bottom395 396 # reduce filler if we are clipping an edge397 if bottom < 0 < top:398 shift = min(top, -bottom)399 top -= shift400 bottom += shift401 elif top < 0 < bottom:402 shift = min(bottom, -top)403 bottom -= shift404 top += shift405 406 # no negative values for filler at the moment407 top = max(top, 0)408 bottom = max(bottom, 0)409 410 return top, bottom411 