codekingpro/portable-devtools
114k
1from __future__ import annotations2 3import typing4import warnings5 6from urwid.split_repr import remove_defaults7 8from .columns import Columns9from .constants import Align, Sizing, WHSettings10from .container import WidgetContainerListContentsMixin, WidgetContainerMixin11from .divider import Divider12from .monitored_list import MonitoredFocusList, MonitoredList13from .padding import Padding14from .pile import Pile15from .widget import Widget, WidgetError, WidgetWarning, WidgetWrap16 17if typing.TYPE_CHECKING:18 from collections.abc import Iterable, Iterator, Sequence19 20 from typing_extensions import Literal21 22 23class GridFlowError(WidgetError):24 """GridFlow specific error."""25 26 27class GridFlowWarning(WidgetWarning):28 """GridFlow specific warning."""29 30 31class GridFlow(WidgetWrap[Pile], WidgetContainerMixin, WidgetContainerListContentsMixin):32 """33 The GridFlow widget is a flow widget that renders all the widgets it contains the same width,34 and it arranges them from left to right and top to bottom.35 """36 37 def sizing(self) -> frozenset[Sizing]:38 """Widget sizing.39 40 ..note:: Empty widget sizing is limited to the FLOW due to no data for width.41 """42 if self:43 return frozenset((Sizing.FLOW, Sizing.FIXED))44 return frozenset((Sizing.FLOW,))45 46 def __init__(47 self,48 cells: Iterable[Widget],49 cell_width: int,50 h_sep: int,51 v_sep: int,52 align: Literal["left", "center", "right"] | Align | tuple[Literal["relative", WHSettings.RELATIVE], int],53 focus: int | Widget | None = None,54 ) -> None:55 """56 :param cells: iterable of flow widgets to display57 :param cell_width: column width for each cell58 :param h_sep: blank columns between each cell horizontally59 :param v_sep: blank rows between cells vertically60 (if more than one row is required to display all the cells)61 :param align: horizontal alignment of cells, one of:62 'left', 'center', 'right', ('relative', percentage 0=left 100=right)63 :param focus: widget index or widget instance to focus on64 """65 prepared_contents: list[tuple[Widget, tuple[Literal[WHSettings.GIVEN], int]]] = []66 focus_position: int = -167 68 for idx, widget in enumerate(cells):69 prepared_contents.append((widget, (WHSettings.GIVEN, cell_width)))70 if focus_position < 0 and (focus in {widget, idx} or (focus is None and widget.selectable())):71 focus_position = idx72 73 focus_position = max(focus_position, 0)74 75 self._contents: MonitoredFocusList[tuple[Widget, tuple[Literal[WHSettings.GIVEN], int]]] = MonitoredFocusList(76 prepared_contents, focus=focus_position77 )78 self._contents.set_modified_callback(self._invalidate)79 self._contents.set_focus_changed_callback(lambda f: self._invalidate())80 self._contents.set_validate_contents_modified(self._contents_modified)81 self._cell_width = cell_width82 self.h_sep = h_sep83 self.v_sep = v_sep84 self.align = align85 self._cache_maxcol = self._get_maxcol(())86 super().__init__(self.generate_display_widget((self._cache_maxcol,)))87 88 def _repr_words(self) -> list[str]:89 if len(self.contents) > 1:90 contents_string = f"({len(self.contents)} items)"91 elif self.contents:92 contents_string = "(1 item)"93 else:94 contents_string = "()"95 return [*super()._repr_words(), contents_string]96 97 def _repr_attrs(self) -> dict[str, typing.Any]:98 attrs = {99 **super()._repr_attrs(),100 "cell_width": self.cell_width,101 "h_sep": self.h_sep,102 "v_sep": self.v_sep,103 "align": self.align,104 "focus": self.focus_position if len(self._contents) > 1 else None,105 }106 return remove_defaults(attrs, GridFlow.__init__)107 108 def __rich_repr__(self) -> Iterator[tuple[str | None, typing.Any] | typing.Any]:109 yield "cells", [widget for widget, _ in self.contents]110 yield "cell_width", self.cell_width111 yield "h_sep", self.h_sep112 yield "v_sep", self.v_sep113 yield "align", self.align114 yield "focus", self.focus_position115 116 def __len__(self) -> int:117 return len(self._contents)118 119 def _invalidate(self) -> None:120 self._cache_maxcol = None121 super()._invalidate()122 123 def _contents_modified(124 self,125 _slc: tuple[int, int, int],126 new_items: Iterable[tuple[Widget, tuple[Literal["given", WHSettings.GIVEN], int]]],127 ) -> None:128 for item in new_items:129 try:130 _w, (t, _n) = item131 if t != WHSettings.GIVEN:132 raise GridFlowError(f"added content invalid {item!r}")133 except (TypeError, ValueError) as exc: # noqa: PERF203134 raise GridFlowError(f"added content invalid {item!r}").with_traceback(exc.__traceback__) from exc135 136 @property137 def cells(self):138 """139 A list of the widgets in this GridFlow140 141 .. note:: only for backwards compatibility. You should use the new142 standard container property :attr:`contents` to modify GridFlow143 contents.144 """145 warnings.warn(146 "only for backwards compatibility."147 "You should use the new standard container property `contents` to modify GridFlow."148 "API will be removed in version 5.0.",149 DeprecationWarning,150 stacklevel=2,151 )152 ml = MonitoredList(w for w, t in self.contents)153 154 def user_modified():155 self.cells = ml156 157 ml.set_modified_callback(user_modified)158 return ml159 160 @cells.setter161 def cells(self, widgets: Sequence[Widget]):162 warnings.warn(163 "only for backwards compatibility."164 "You should use the new standard container property `contents` to modify GridFlow."165 "API will be removed in version 5.0.",166 DeprecationWarning,167 stacklevel=2,168 )169 focus_position = self.focus_position170 self.contents = [(new, (WHSettings.GIVEN, self._cell_width)) for new in widgets]171 if focus_position < len(widgets):172 self.focus_position = focus_position173 174 @property175 def cell_width(self) -> int:176 """177 The width of each cell in the GridFlow. Setting this value affects178 all cells.179 """180 return self._cell_width181 182 @cell_width.setter183 def cell_width(self, width: int) -> None:184 focus_position = self.focus_position185 self.contents = [(w, (WHSettings.GIVEN, width)) for (w, options) in self.contents]186 self.focus_position = focus_position187 self._cell_width = width188 189 @property190 def contents(self) -> MonitoredFocusList[tuple[Widget, tuple[Literal[WHSettings.GIVEN], int]]]:191 """192 The contents of this GridFlow as a list of (widget, options)193 tuples.194 195 options is currently a tuple in the form `('fixed', number)`.196 number is the number of screen columns to allocate to this cell.197 'fixed' is the only type accepted at this time.198 199 This list may be modified like a normal list and the GridFlow200 widget will update automatically.201 202 .. seealso:: Create new options tuples with the :meth:`options` method.203 """204 return self._contents205 206 @contents.setter207 def contents(self, c):208 self._contents[:] = c209 210 def options(211 self,212 width_type: Literal["given", WHSettings.GIVEN] = WHSettings.GIVEN,213 width_amount: int | None = None,214 ) -> tuple[Literal[WHSettings.GIVEN], int]:215 """216 Return a new options tuple for use in a GridFlow's .contents list.217 218 width_type -- 'given' is the only value accepted219 width_amount -- None to use the default cell_width for this GridFlow220 """221 if width_type != WHSettings.GIVEN:222 raise GridFlowError(f"invalid width_type: {width_type!r}")223 if width_amount is None:224 width_amount = self._cell_width225 return (WHSettings(width_type), width_amount)226 227 def set_focus(self, cell: Widget | int) -> None:228 """229 Set the cell in focus, for backwards compatibility.230 231 .. note:: only for backwards compatibility. You may also use the new232 standard container property :attr:`focus_position` to get the focus.233 234 :param cell: contained element to focus235 :type cell: Widget or int236 """237 warnings.warn(238 "only for backwards compatibility."239 "You may also use the new standard container property `focus_position` to set the focus."240 "API will be removed in version 5.0.",241 DeprecationWarning,242 stacklevel=2,243 )244 if isinstance(cell, int):245 try:246 if cell < 0 or cell >= len(self.contents):247 raise IndexError(f"No GridFlow child widget at position {cell}")248 except TypeError as exc:249 raise IndexError(f"No GridFlow child widget at position {cell}").with_traceback(250 exc.__traceback__251 ) from exc252 self.contents.focus = cell253 return254 255 for i, (w, _options) in enumerate(self.contents):256 if cell == w:257 self.focus_position = i258 return259 raise ValueError(f"Widget not found in GridFlow contents: {cell!r}")260 261 @property262 def focus(self) -> Widget | None:263 """the child widget in focus or None when GridFlow is empty"""264 if not self.contents:265 return None266 return self.contents[self.focus_position][0]267 268 def get_focus(self):269 """270 Return the widget in focus, for backwards compatibility.271 272 .. note:: only for backwards compatibility. You may also use the new273 standard container property :attr:`focus` to get the focus.274 """275 warnings.warn(276 "only for backwards compatibility."277 "You may also use the new standard container property `focus` to get the focus."278 "API will be removed in version 5.0.",279 DeprecationWarning,280 stacklevel=2,281 )282 if not self.contents:283 return None284 return self.contents[self.focus_position][0]285 286 @property287 def focus_cell(self):288 warnings.warn(289 "only for backwards compatibility."290 "You may also use the new standard container property"291 "`focus` to get the focus and `focus_position` to get/set the cell in focus by index."292 "API will be removed in version 5.0.",293 DeprecationWarning,294 stacklevel=2,295 )296 return self.focus297 298 @focus_cell.setter299 def focus_cell(self, cell: Widget) -> None:300 warnings.warn(301 "only for backwards compatibility."302 "You may also use the new standard container property"303 "`focus` to get the focus and `focus_position` to get/set the cell in focus by index."304 "API will be removed in version 5.0.",305 DeprecationWarning,306 stacklevel=2,307 )308 for i, (w, _options) in enumerate(self.contents):309 if cell == w:310 self.focus_position = i311 return312 raise ValueError(f"Widget not found in GridFlow contents: {cell!r}")313 314 @property315 def focus_position(self) -> int | None:316 """317 index of child widget in focus.318 Raises :exc:`IndexError` if read when GridFlow is empty, or when set to an invalid index.319 """320 if not self.contents:321 raise IndexError("No focus_position, GridFlow is empty")322 return self.contents.focus323 324 @focus_position.setter325 def focus_position(self, position: int) -> None:326 """327 Set the widget in focus.328 329 position -- index of child widget to be made focus330 """331 try:332 if position < 0 or position >= len(self.contents):333 raise IndexError(f"No GridFlow child widget at position {position}")334 except TypeError as exc:335 raise IndexError(f"No GridFlow child widget at position {position}").with_traceback(336 exc.__traceback__337 ) from exc338 self.contents.focus = position339 340 def _get_maxcol(self, size: tuple[int] | tuple[()]) -> int:341 if size:342 (maxcol,) = size343 if self and maxcol < self.cell_width:344 warnings.warn(345 f"Size is smaller than cell width ({maxcol!r} < {self.cell_width!r})",346 GridFlowWarning,347 stacklevel=3,348 )349 elif self:350 maxcol = len(self) * self.cell_width + (len(self) - 1) * self.h_sep351 else:352 maxcol = 0353 return maxcol354 355 def get_display_widget(self, size: tuple[int] | tuple[()]) -> Divider | Pile:356 """357 Arrange the cells into columns (and possibly a pile) for358 display, input or to calculate rows, and update the display359 widget.360 """361 maxcol = self._get_maxcol(size)362 363 # use cache if possible364 if self._cache_maxcol == maxcol:365 return self._w366 367 self._cache_maxcol = maxcol368 self._w = self.generate_display_widget((maxcol,))369 370 return self._w371 372 def generate_display_widget(self, size: tuple[int] | tuple[()]) -> Divider | Pile:373 """374 Actually generate display widget (ignoring cache)375 """376 maxcol = self._get_maxcol(size)377 378 divider = Divider()379 if not self.contents:380 return divider381 382 if self.v_sep > 1:383 # increase size of divider384 divider.top = self.v_sep - 1385 386 c = None387 p = Pile([])388 used_space = 0389 390 for i, (w, (_width_type, width_amount)) in enumerate(self.contents):391 if c is None or maxcol - used_space < width_amount:392 # starting a new row393 if self.v_sep:394 p.contents.append((divider, p.options()))395 c = Columns([], self.h_sep)396 column_focused = False397 pad = Padding(c, self.align)398 # extra attribute to reference contents position399 pad.first_position = i400 p.contents.append((pad, p.options()))401 402 # Use width == maxcol in case of maxcol < width amount403 # Columns will use empty widget in case of GIVEN width > maxcol404 c.contents.append((w, c.options(WHSettings.GIVEN, min(width_amount, maxcol))))405 if (i == self.focus_position) or (not column_focused and w.selectable()):406 c.focus_position = len(c.contents) - 1407 column_focused = True408 if i == self.focus_position:409 p.focus_position = len(p.contents) - 1410 used_space = sum(x[1][1] for x in c.contents) + self.h_sep * len(c.contents)411 pad.width = used_space - self.h_sep412 413 if self.v_sep:414 # remove first divider415 del p.contents[:1]416 else:417 # Ensure p __selectable is updated418 p._contents_modified() # pylint: disable=protected-access419 420 return p421 422 def _set_focus_from_display_widget(self) -> None:423 """424 Set the focus to the item in focus in the display widget.425 """426 # display widget (self._w) is always built as:427 #428 # Pile([429 # Padding(430 # Columns([ # possibly431 # cell, ...])),432 # Divider(), # possibly433 # ...])434 435 pile_focus = self._w.focus436 if not pile_focus:437 return438 c = pile_focus.base_widget439 if c.focus:440 col_focus_position = c.focus_position441 else:442 col_focus_position = 0443 # pad.first_position was set by generate_display_widget() above444 self.focus_position = pile_focus.first_position + col_focus_position445 446 def keypress(447 self,448 size: tuple[int] | tuple[()], # type: ignore[override]449 key: str,450 ) -> str | None:451 """452 Pass keypress to display widget for handling.453 Captures focus changes.454 """455 self.get_display_widget(size)456 457 if (key := super().keypress(size, key)) is not None:458 return key459 460 self._set_focus_from_display_widget()461 return None462 463 def pack(464 self,465 size: tuple[int] | tuple[()] = (), # type: ignore[override]466 focus: bool = False,467 ) -> tuple[int, int]:468 if size:469 return super().pack(size, focus)470 if self:471 cols = len(self) * self.cell_width + (len(self) - 1) * self.h_sep472 else:473 cols = 0474 return cols, self.rows((cols,), focus)475 476 def rows(self, size: tuple[int], focus: bool = False) -> int:477 self.get_display_widget(size)478 return super().rows(size, focus=focus)479 480 def render(481 self,482 size: tuple[int] | tuple[()], # type: ignore[override]483 focus: bool = False,484 ):485 self.get_display_widget(size)486 return super().render(size, focus)487 488 def get_cursor_coords(self, size: tuple[int] | tuple[()]) -> tuple[int, int]:489 """Get cursor from display widget."""490 self.get_display_widget(size)491 return super().get_cursor_coords(size)492 493 def move_cursor_to_coords(self, size: tuple[int] | tuple[()], col: int, row: int):494 """Set the widget in focus based on the col + row."""495 self.get_display_widget(size)496 rval = super().move_cursor_to_coords(size, col, row)497 self._set_focus_from_display_widget()498 return rval499 500 def mouse_event(501 self,502 size: tuple[int] | tuple[()], # type: ignore[override]503 event: str,504 button: int,505 col: int,506 row: int,507 focus: bool,508 ) -> Literal[True]:509 self.get_display_widget(size)510 super().mouse_event(size, event, button, col, row, focus)511 self._set_focus_from_display_widget()512 return True # at a minimum we adjusted our focus513 514 def get_pref_col(self, size: tuple[int] | tuple[()]):515 """Return pref col from display widget."""516 self.get_display_widget(size)517 return super().get_pref_col(size)518 