codekingpro/portable-devtools
115k
1from __future__ import annotations2 3import typing4import warnings5 6from urwid.canvas import CompositeCanvas, SolidCanvas7from urwid.split_repr import remove_defaults8from urwid.util import int_scale9 10from .constants import (11 RELATIVE_100,12 Align,13 Sizing,14 WHSettings,15 normalize_align,16 normalize_width,17 simplify_align,18 simplify_width,19)20from .widget_decoration import WidgetDecoration, WidgetError, WidgetWarning21 22if typing.TYPE_CHECKING:23 from collections.abc import Iterator24 25 from typing_extensions import Literal26 27WrappedWidget = typing.TypeVar("WrappedWidget")28 29 30class PaddingError(WidgetError):31 """Padding related errors."""32 33 34class PaddingWarning(WidgetWarning):35 """Padding related warnings."""36 37 38class Padding(WidgetDecoration[WrappedWidget], typing.Generic[WrappedWidget]):39 def __init__(40 self,41 w: WrappedWidget,42 align: (43 Literal["left", "center", "right"]44 | Align45 | tuple[Literal["relative", WHSettings.RELATIVE, "fixed left", "fixed right"], int]46 ) = Align.LEFT,47 width: (48 int49 | Literal["pack", "clip", WHSettings.PACK, WHSettings.CLIP]50 | tuple[Literal["relative", WHSettings.RELATIVE, "fixed left", "fixed right"], int]51 ) = RELATIVE_100,52 min_width: int | None = None,53 left: int = 0,54 right: int = 0,55 ) -> None:56 """57 :param w: a box, flow or fixed widget to pad on the left and/or right58 this widget is stored as self.original_widget59 :type w: Widget60 61 :param align: one of: ``'left'``, ``'center'``, ``'right'``62 (``'relative'``, *percentage* 0=left 100=right)63 64 :param width: one of:65 66 *given width*67 integer number of columns for self.original_widget68 69 ``'pack'``70 try to pack self.original_widget to its ideal size71 72 (``'relative'``, *percentage of total width*)73 make width depend on the container's width74 75 ``'clip'``76 to enable clipping mode for a fixed widget77 78 :param min_width: the minimum number of columns for79 self.original_widget or ``None``80 :type min_width: int | None81 82 :param left: a fixed number of columns to pad on the left83 :type left: int84 85 :param right: a fixed number of columns to pad on the right86 :type right: int87 88 Clipping Mode: (width= ``'clip'``)89 In clipping mode this padding widget will behave as a flow90 widget and self.original_widget will be treated as a fixed widget.91 self.original_widget will be clipped to fit the available number of columns.92 For example if align is ``'left'`` then self.original_widget may be clipped on the right.93 94 Pack Mode: (width= ``'pack'``)95 In pack mode is supported FIXED operation if it is supported by the original widget.96 97 >>> from urwid import Divider, Text, BigText, FontRegistry98 >>> from urwid.util import set_temporary_encoding99 >>> size = (7,)100 >>> def pr(w):101 ... with set_temporary_encoding("utf-8"):102 ... for t in w.render(size).text:103 ... print(f"|{t.decode('utf-8')}|")104 >>> pr(Padding(Text("Head"), ("relative", 20), "pack"))105 | Head |106 >>> pr(Padding(Divider("-"), left=2, right=1))107 | ---- |108 >>> pr(Padding(Divider("*"), "center", 3))109 | *** |110 >>> p = Padding(Text("1234"), "left", 2, None, 1, 1)111 >>> p112 <Padding fixed/flow widget <Text fixed/flow widget '1234'> left=1 right=1 width=2>113 >>> pr(p) # align against left114 | 12 |115 | 34 |116 >>> p.align = "right"117 >>> pr(p) # align against right118 | 12 |119 | 34 |120 >>> pr(Padding(Text("hi\\nthere"), "right", "pack")) # pack text first121 | hi |122 | there|123 >>> pr(Padding(BigText("1,2,3", FontRegistry["Thin 3x3"]()), width="clip"))124 | ┐ ┌─┐|125 | │ ┌─┘|126 | ┴ ,└─ |127 """128 super().__init__(w)129 130 # convert obsolete parameters 'fixed left' and 'fixed right':131 if isinstance(align, tuple) and align[0] in {"fixed left", "fixed right"}:132 if align[0] == "fixed left":133 left = align[1]134 align = Align.LEFT135 else:136 right = align[1]137 align = Align.RIGHT138 if isinstance(width, tuple) and width[0] in {"fixed left", "fixed right"}:139 if width[0] == "fixed left":140 left = width[1]141 else:142 right = width[1]143 width = RELATIVE_100144 145 # convert old clipping mode width=None to width='clip'146 if width is None:147 width = WHSettings.CLIP148 149 self.left = left150 self.right = right151 self._align_type, self._align_amount = normalize_align(align, PaddingError)152 self._width_type, self._width_amount = normalize_width(width, PaddingError)153 self.min_width = min_width154 155 def sizing(self) -> frozenset[Sizing]:156 """Widget sizing.157 158 Rules:159 * width == CLIP: only FLOW is supported, and wrapped widget should support FIXED160 * width == GIVEN: FIXED is supported, and wrapped widget should support FLOW161 * All other cases: use sizing of target widget162 """163 if self._width_type == WHSettings.CLIP:164 return frozenset((Sizing.FLOW,))165 166 sizing = set(self.original_widget.sizing())167 if self._width_type == WHSettings.GIVEN:168 if Sizing.FLOW in sizing:169 sizing.add(Sizing.FIXED)170 171 elif Sizing.BOX not in sizing:172 warnings.warn(173 f"WHSettings.GIVEN expect BOX or FLOW widget to be used, but received {self.original_widget}",174 PaddingWarning,175 stacklevel=3,176 )177 178 return frozenset(sizing)179 180 def _repr_attrs(self) -> dict[str, typing.Any]:181 attrs = {182 **super()._repr_attrs(),183 "align": self.align,184 "width": self.width,185 "left": self.left,186 "right": self.right,187 "min_width": self.min_width,188 }189 return remove_defaults(attrs, Padding.__init__)190 191 def __rich_repr__(self) -> Iterator[tuple[str | None, typing.Any] | typing.Any]:192 yield "w", self.original_widget193 yield "align", self.align194 yield "width", self.width195 yield "min_width", self.min_width196 yield "left", self.left197 yield "right", self.right198 199 @property200 def align(201 self,202 ) -> Literal["left", "center", "right"] | Align | tuple[Literal["relative", WHSettings.RELATIVE], int]:203 """204 Return the padding alignment setting.205 """206 return simplify_align(self._align_type, self._align_amount)207 208 @align.setter209 def align(210 self, align: Literal["left", "center", "right"] | Align | tuple[Literal["relative", WHSettings.RELATIVE], int]211 ) -> None:212 """213 Set the padding alignment.214 """215 self._align_type, self._align_amount = normalize_align(align, PaddingError)216 self._invalidate()217 218 @property219 def width(220 self,221 ) -> (222 Literal["clip", "pack", WHSettings.CLIP, WHSettings.PACK]223 | int224 | tuple[Literal["relative", WHSettings.RELATIVE], int]225 ):226 """227 Return the padding width.228 """229 return simplify_width(self._width_type, self._width_amount)230 231 @width.setter232 def width(233 self,234 width: (235 Literal["clip", "pack", WHSettings.CLIP, WHSettings.PACK]236 | int237 | tuple[Literal["relative", WHSettings.RELATIVE], int]238 ),239 ) -> None:240 """241 Set the padding width.242 """243 self._width_type, self._width_amount = normalize_width(width, PaddingError)244 self._invalidate()245 246 def pack(247 self,248 size: tuple[()] | tuple[int] | tuple[int, int] = (),249 focus: bool = False,250 ) -> tuple[int, int]:251 if size:252 return super().pack(size, focus)253 if self._width_type == WHSettings.CLIP:254 raise PaddingError("WHSettings.CLIP makes Padding FLOW-only widget")255 256 expand = self.left + self.right257 w_sizing = self.original_widget.sizing()258 259 if self._width_type == WHSettings.GIVEN:260 if Sizing.FLOW not in w_sizing:261 warnings.warn(262 f"WHSettings.GIVEN expect FLOW widget to be used for FIXED pack/render, "263 f"but received {self.original_widget}",264 PaddingWarning,265 stacklevel=3,266 )267 268 return (269 max(self._width_amount, self.min_width or 1) + expand,270 self.original_widget.rows((self._width_amount,), focus),271 )272 273 if Sizing.FIXED not in w_sizing:274 warnings.warn(275 f"Padded widget should support FIXED sizing for FIXED render, but received {self.original_widget}",276 PaddingWarning,277 stacklevel=3,278 )279 width, height = self.original_widget.pack(size, focus)280 281 if self._width_type == WHSettings.PACK:282 return max(width, self.min_width or 1) + expand, height283 284 if self._width_type == WHSettings.RELATIVE:285 return max(int(width * 100 / self._width_amount + 0.5), self.min_width or 1) + expand, height286 287 raise PaddingError(f"Unexpected width type: {self._width_type.upper()})")288 289 def render(290 self,291 size: tuple[()] | tuple[int] | tuple[int, int],292 focus: bool = False,293 ) -> CompositeCanvas:294 left, right = self.padding_values(size, focus)295 296 if self._width_type == WHSettings.CLIP:297 canv = self._original_widget.render((), focus)298 elif size:299 maxcol = size[0] - (left + right)300 if self._width_type == WHSettings.GIVEN and maxcol < self._width_amount:301 warnings.warn(302 f"{self}.render(size={size}, focus={focus}): too narrow size ({maxcol!r} < {self._width_amount!r})",303 PaddingWarning,304 stacklevel=3,305 )306 canv = self._original_widget.render((maxcol, *size[1:]), focus)307 elif self._width_type == WHSettings.GIVEN:308 canv = self._original_widget.render((self._width_amount, *size[1:]), focus)309 else:310 canv = self._original_widget.render((), focus)311 312 if canv.cols() == 0:313 canv = SolidCanvas(" ", size[0], canv.rows())314 canv = CompositeCanvas(canv)315 canv.set_depends([self._original_widget])316 return canv317 318 canv = CompositeCanvas(canv)319 canv.set_depends([self._original_widget])320 if left != 0 or right != 0:321 canv.pad_trim_left_right(left, right)322 323 return canv324 325 def padding_values(326 self,327 size: tuple[()] | tuple[int] | tuple[int, int],328 focus: bool,329 ) -> tuple[int, int]:330 """Return the number of columns to pad on the left and right.331 332 Override this method to define custom padding behaviour."""333 if self._width_type == WHSettings.CLIP:334 width, _ignore = self._original_widget.pack((), focus=focus)335 if not size:336 raise PaddingError("WHSettings.CLIP makes Padding FLOW-only widget")337 return calculate_left_right_padding(338 size[0],339 self._align_type,340 self._align_amount,341 WHSettings.CLIP,342 width,343 None,344 self.left,345 self.right,346 )347 348 if self._width_type == WHSettings.PACK:349 if size:350 maxcol = size[0]351 maxwidth = max(maxcol - self.left - self.right, self.min_width or 0)352 (width, _ignore) = self._original_widget.pack((maxwidth,), focus=focus)353 else:354 (width, _ignore) = self._original_widget.pack((), focus=focus)355 maxcol = width + self.left + self.right356 357 return calculate_left_right_padding(358 maxcol,359 self._align_type,360 self._align_amount,361 WHSettings.GIVEN,362 width,363 self.min_width,364 self.left,365 self.right,366 )367 368 if size:369 maxcol = size[0]370 elif self._width_type == WHSettings.GIVEN:371 maxcol = self._width_amount + self.left + self.right372 else:373 maxcol = (374 max(self._original_widget.pack((), focus=focus)[0] * 100 // self._width_amount, self.min_width or 1)375 + self.left376 + self.right377 )378 379 return calculate_left_right_padding(380 maxcol,381 self._align_type,382 self._align_amount,383 self._width_type,384 self._width_amount,385 self.min_width,386 self.left,387 self.right,388 )389 390 def rows(self, size: tuple[int], focus: bool = False) -> int:391 """Return the rows needed for self.original_widget."""392 (maxcol,) = size393 left, right = self.padding_values(size, focus)394 if self._width_type == WHSettings.PACK:395 _pcols, prows = self._original_widget.pack((maxcol - left - right,), focus)396 return prows397 if self._width_type == WHSettings.CLIP:398 _fcols, frows = self._original_widget.pack((), focus)399 return frows400 return self._original_widget.rows((maxcol - left - right,), focus=focus)401 402 def keypress(self, size: tuple[()] | tuple[int] | tuple[int, int], key: str) -> str | None:403 """Pass keypress to self._original_widget."""404 left, right = self.padding_values(size, True)405 if size:406 maxvals = (size[0] - left - right, *size[1:])407 return self._original_widget.keypress(maxvals, key)408 return self._original_widget.keypress((), key)409 410 def get_cursor_coords(self, size: tuple[()] | tuple[int] | tuple[int, int]) -> tuple[int, int] | None:411 """Return the (x,y) coordinates of cursor within self._original_widget."""412 if not hasattr(self._original_widget, "get_cursor_coords"):413 return None414 415 left, right = self.padding_values(size, True)416 if size:417 maxvals = (size[0] - left - right, *size[1:])418 if maxvals[0] == 0:419 return None420 else:421 maxvals = ()422 423 if (coords := self._original_widget.get_cursor_coords(maxvals)) is not None:424 x, y = coords425 return x + left, y426 427 return None428 429 def move_cursor_to_coords(430 self,431 size: tuple[()] | tuple[int] | tuple[int, int],432 x: int,433 y: int,434 ) -> bool:435 """Set the cursor position with (x,y) coordinates of self._original_widget.436 437 Returns True if move succeeded, False otherwise.438 """439 if not hasattr(self._original_widget, "move_cursor_to_coords"):440 return True441 442 left, right = self.padding_values(size, True)443 if size:444 maxcol = size[0]445 maxvals = (maxcol - left - right, *size[1:])446 else:447 maxcol = self.pack((), True)[0]448 maxvals = ()449 450 if isinstance(x, int):451 if x < left:452 x = left453 elif x >= maxcol - right:454 x = maxcol - right - 1455 x -= left456 457 return self._original_widget.move_cursor_to_coords(maxvals, x, y)458 459 def mouse_event(460 self,461 size: tuple[()] | tuple[int] | tuple[int, int],462 event: str,463 button: int,464 col: int,465 row: int,466 focus: bool,467 ) -> bool | None:468 """Send mouse event if position is within self._original_widget."""469 if not hasattr(self._original_widget, "mouse_event"):470 return False471 472 left, right = self.padding_values(size, focus)473 if size:474 maxcol = size[0]475 if col < left or col >= maxcol - right:476 return False477 maxvals = (maxcol - left - right, *size[1:])478 else:479 maxvals = ()480 481 return self._original_widget.mouse_event(maxvals, event, button, col - left, row, focus)482 483 def get_pref_col(self, size: tuple[()] | tuple[int] | tuple[int, int]) -> int | None:484 """Return the preferred column from self._original_widget, or None."""485 if not hasattr(self._original_widget, "get_pref_col"):486 return None487 488 left, right = self.padding_values(size, True)489 if size:490 maxvals = (size[0] - left - right, *size[1:])491 else:492 maxvals = ()493 494 x = self._original_widget.get_pref_col(maxvals)495 if isinstance(x, int):496 return x + left497 return x498 499 500def calculate_left_right_padding(501 maxcol: int,502 align_type: Literal["left", "center", "right"] | Align,503 align_amount: int,504 width_type: Literal["fixed", "relative", "clip", "given", WHSettings.RELATIVE, WHSettings.CLIP, WHSettings.GIVEN],505 width_amount: int,506 min_width: int | None,507 left: int,508 right: int,509) -> tuple[int, int]:510 """511 Return the amount of padding (or clipping) on the left and512 right part of maxcol columns to satisfy the following:513 514 align_type -- 'left', 'center', 'right', 'relative'515 align_amount -- a percentage when align_type=='relative'516 width_type -- 'fixed', 'relative', 'clip'517 width_amount -- a percentage when width_type=='relative'518 otherwise equal to the width of the widget519 min_width -- a desired minimum width for the widget or None520 left -- a fixed number of columns to pad on the left521 right -- a fixed number of columns to pad on the right522 523 >>> clrp = calculate_left_right_padding524 >>> clrp(15, "left", 0, "given", 10, None, 2, 0)525 (2, 3)526 >>> clrp(15, "relative", 0, "given", 10, None, 2, 0)527 (2, 3)528 >>> clrp(15, "relative", 100, "given", 10, None, 2, 0)529 (5, 0)530 >>> clrp(15, "center", 0, "given", 4, None, 2, 0)531 (6, 5)532 >>> clrp(15, "left", 0, "clip", 18, None, 0, 0)533 (0, -3)534 >>> clrp(15, "right", 0, "clip", 18, None, 0, -1)535 (-2, -1)536 >>> clrp(15, "center", 0, "given", 18, None, 2, 0)537 (0, 0)538 >>> clrp(20, "left", 0, "relative", 60, None, 0, 0)539 (0, 8)540 >>> clrp(20, "relative", 30, "relative", 60, None, 0, 0)541 (2, 6)542 >>> clrp(20, "relative", 30, "relative", 60, 14, 0, 0)543 (2, 4)544 """545 if width_type == WHSettings.RELATIVE:546 maxwidth = max(maxcol - left - right, 0)547 width = int(maxwidth * width_amount / 100 + 0.5)548 if min_width is not None:549 width = max(width, min_width)550 else:551 width = width_amount552 553 align = {Align.LEFT: 0, Align.CENTER: 50, Align.RIGHT: 100}.get(align_type, align_amount)554 555 # add the remainder of left/right the padding556 padding = maxcol - width - left - right557 right += int_scale(100 - align, 101, padding + 1)558 left = maxcol - width - right559 560 # reduce padding if we are clipping an edge561 if right < 0 < left:562 shift = min(left, -right)563 left -= shift564 right += shift565 elif left < 0 < right:566 shift = min(right, -left)567 right -= shift568 left += shift569 570 # only clip if width_type == 'clip'571 if width_type != WHSettings.CLIP and (left < 0 or right < 0):572 left = max(left, 0)573 right = max(right, 0)574 575 return left, right576 