codekingpro/portable-devtools
114k
1# Urwid canvas class and functions2# Copyright (C) 2004-2011 Ian Ward3#4# This library is free software; you can redistribute it and/or5# modify it under the terms of the GNU Lesser General Public6# License as published by the Free Software Foundation; either7# version 2.1 of the License, or (at your option) any later version.8#9# This library is distributed in the hope that it will be useful,10# but WITHOUT ANY WARRANTY; without even the implied warranty of11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU12# Lesser General Public License for more details.13#14# You should have received a copy of the GNU Lesser General Public15# License along with this library; if not, write to the Free Software16# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA17#18# Urwid web site: https://urwid.org/19 20 21from __future__ import annotations22 23import contextlib24import dataclasses25import typing26import weakref27from contextlib import suppress28 29from urwid.str_util import calc_text_pos, calc_width30from urwid.text_layout import LayoutSegment, trim_line31from urwid.util import (32 apply_target_encoding,33 get_encoding,34 rle_append_modify,35 rle_join_modify,36 rle_len,37 rle_product,38 trim_text_attr_cs,39)40 41if typing.TYPE_CHECKING:42 from collections.abc import Hashable, Iterable, Iterator, Sequence43 44 from typing_extensions import Literal45 46 from .widget import Widget47 48 49class CanvasCache:50 """51 Cache for rendered canvases. Automatically populated and52 accessed by Widget render() MetaClass magic, cleared by53 Widget._invalidate().54 55 Stores weakrefs to the canvas objects, so an external class56 must maintain a reference for this cache to be effective.57 At present the Screen classes store the last topmost canvas58 after redrawing the screen, keeping the canvases from being59 garbage collected.60 61 _widgets[widget] = {(wcls, size, focus): weakref.ref(canvas), ...}62 _refs[weakref.ref(canvas)] = (widget, wcls, size, focus)63 _deps[widget} = [dependent_widget, ...]64 """65 66 _widgets: typing.ClassVar[67 dict[68 Widget,69 dict[70 tuple[type[Widget], tuple[int, int] | tuple[int] | tuple[()], bool],71 weakref.ReferenceType,72 ],73 ]74 ] = {}75 _refs: typing.ClassVar[76 dict[77 weakref.ReferenceType,78 tuple[Widget, type[Widget], tuple[int, int] | tuple[int] | tuple[()], bool],79 ]80 ] = {}81 _deps: typing.ClassVar[dict[Widget, list[Widget]]] = {}82 hits = 083 fetches = 084 cleanups = 085 86 @classmethod87 def store(cls, wcls: type[Widget], canvas: Canvas) -> None:88 """89 Store a weakref to canvas in the cache.90 91 wcls -- widget class that contains render() function92 canvas -- rendered canvas with widget_info (widget, size, focus)93 """94 if not canvas.cacheable:95 return96 97 if not canvas.widget_info:98 raise TypeError("Can't store canvas without widget_info")99 widget, size, focus = canvas.widget_info100 101 def walk_depends(canv: Canvas) -> list[Widget]:102 """103 Collect all child widgets for determining who we104 depend on.105 """106 # FIXME: is this recursion necessary? The cache invalidating might work with only one level.107 depends = []108 for _x, _y, c, _pos in canv.children:109 if c.widget_info:110 depends.append(c.widget_info[0])111 elif hasattr(c, "children"):112 depends.extend(walk_depends(c))113 return depends114 115 # use explicit depends_on if available from the canvas116 depends_on = getattr(canvas, "depends_on", None)117 if depends_on is None and hasattr(canvas, "children"):118 depends_on = walk_depends(canvas)119 if depends_on:120 for w in depends_on:121 if w not in cls._widgets:122 return123 for w in depends_on:124 cls._deps.setdefault(w, []).append(widget)125 126 ref = weakref.ref(canvas, cls.cleanup)127 cls._refs[ref] = (widget, wcls, size, focus)128 cls._widgets.setdefault(widget, {})[wcls, size, focus] = ref129 130 @classmethod131 def fetch(cls, widget: Widget, wcls: type[Widget], size, focus: bool) -> Canvas | None:132 """133 Return the cached canvas or None.134 135 widget -- widget object requested136 wcls -- widget class that contains render() function137 size, focus -- render() parameters138 """139 cls.fetches += 1 # collect stats140 141 sizes = cls._widgets.get(widget, None)142 if not sizes:143 return None144 ref = sizes.get((wcls, size, focus), None)145 if not ref:146 return None147 canv = ref()148 if canv:149 cls.hits += 1 # more stats150 return canv151 152 @classmethod153 def invalidate(cls, widget: Widget) -> None:154 """155 Remove all canvases cached for widget.156 """157 with contextlib.suppress(KeyError):158 for ref in cls._widgets[widget].values():159 with suppress(KeyError):160 del cls._refs[ref]161 del cls._widgets[widget]162 163 if widget not in cls._deps:164 return165 dependants = cls._deps.get(widget, [])166 with suppress(KeyError):167 del cls._deps[widget]168 for w in dependants:169 cls.invalidate(w)170 171 @classmethod172 def cleanup(cls, ref: weakref.ReferenceType) -> None:173 cls.cleanups += 1 # collect stats174 175 w = cls._refs.get(ref, None)176 del cls._refs[ref]177 if not w:178 return179 widget, wcls, size, focus = w180 sizes = cls._widgets.get(widget, None)181 if not sizes:182 return183 with suppress(KeyError):184 del sizes[wcls, size, focus]185 if not sizes:186 with contextlib.suppress(KeyError):187 del cls._widgets[widget]188 del cls._deps[widget]189 190 @classmethod191 def clear(cls) -> None:192 """193 Empty the cache.194 """195 cls._widgets = {}196 cls._refs = {}197 cls._deps = {}198 199 200class CanvasError(Exception):201 pass202 203 204class Canvas:205 """206 base class for canvases207 """208 209 cacheable = True210 211 _finalized_error = CanvasError(212 "This canvas has been finalized. Use CompositeCanvas to wrap this canvas if you need to make changes."213 )214 215 def __init__(self) -> None:216 """Base Canvas class"""217 self._widget_info: tuple[Widget, tuple[[]] | tuple[int] | tuple[int, int], bool] | None = None218 self.coords: dict[str, tuple[int, int, tuple[Widget, int, int]] | tuple[int, int, None]] = {}219 self.shortcuts: dict[str, str] = {}220 221 def finalize(222 self,223 widget: Widget,224 size: tuple[()] | tuple[int] | tuple[int, int],225 focus: bool,226 ) -> None:227 """228 Mark this canvas as finalized (should not be any future229 changes to its content). This is required before caching230 the canvas. This happens automatically after a widget's231 'render call returns the canvas thanks to some metaclass232 magic.233 234 widget -- widget that rendered this canvas235 size -- size parameter passed to widget's render method236 focus -- focus parameter passed to widget's render method237 """238 if self.widget_info:239 raise self._finalized_error240 self._widget_info = widget, size, focus241 242 @property243 def widget_info(self) -> tuple[Widget, tuple[[]] | tuple[int] | tuple[int, int], bool] | None:244 return self._widget_info245 246 @property247 def text(self) -> list[bytes]:248 """249 Return the text content of the canvas as a list of strings, one for each row.250 """251 return [b"".join([text for (attr, cs, text) in row]) for row in self.content()]252 253 @property254 def decoded_text(self) -> Sequence[str]:255 """Decoded text content of the canvas as a sequence of strings, one for each row."""256 encoding = get_encoding()257 return tuple(line.decode(encoding) for line in self.text)258 259 def content(260 self,261 trim_left: int = 0,262 trim_top: int = 0,263 cols: int | None = None,264 rows: int | None = None,265 attr=None,266 ) -> Iterator[list[tuple[object, Literal["0", "U"] | None, bytes]]]:267 raise NotImplementedError()268 269 def cols(self) -> int:270 raise NotImplementedError()271 272 def rows(self) -> int:273 raise NotImplementedError()274 275 def content_delta(self, other: Canvas):276 raise NotImplementedError()277 278 def get_cursor(self) -> tuple[int, int] | None:279 if c := self.coords.get("cursor", None):280 return c[:2] # trim off data part281 282 return None283 284 def set_cursor(self, c: tuple[int, int] | None) -> None:285 if self.widget_info and self.cacheable:286 raise self._finalized_error287 if c is None:288 with suppress(KeyError):289 del self.coords["cursor"]290 return291 self.coords["cursor"] = (*c, None) # data part292 293 cursor = property(get_cursor, set_cursor)294 295 def get_pop_up(self) -> tuple[int, int, tuple[Widget, int, int]] | None:296 if c := self.coords.get("pop up", None):297 return c298 299 return None300 301 def set_pop_up(self, w: Widget, left: int, top: int, overlay_width: int, overlay_height: int) -> None:302 """303 This method adds pop-up information to the canvas. This information304 is intercepted by a PopUpTarget widget higher in the chain to305 display a pop-up at the given (left, top) position relative to the306 current canvas.307 308 :param w: widget to use for the pop-up309 :type w: widget310 :param left: x position for left edge of pop-up >= 0311 :type left: int312 :param top: y position for top edge of pop-up >= 0313 :type top: int314 :param overlay_width: width of overlay in screen columns > 0315 :type overlay_width: int316 :param overlay_height: height of overlay in screen rows > 0317 :type overlay_height: int318 """319 if self.widget_info and self.cacheable:320 raise self._finalized_error321 322 self.coords["pop up"] = (left, top, (w, overlay_width, overlay_height))323 324 def translate_coords(self, dx: int, dy: int) -> dict[str, tuple[int, int, tuple[Widget, int, int]]]:325 """326 Return coords shifted by (dx, dy).327 """328 d = {}329 for name, (x, y, data) in self.coords.items():330 d[name] = (x + dx, y + dy, data)331 return d332 333 def __repr__(self) -> str:334 extra = [""]335 with contextlib.suppress(BaseException):336 extra.append(f"cols={self.cols()}")337 338 with contextlib.suppress(BaseException):339 extra.append(f"rows={self.rows()}")340 341 if self.cursor:342 extra.append(f"cursor={self.cursor}")343 344 return f"<{self.__class__.__name__} finalized={bool(self.widget_info)}{' '.join(extra)} at 0x{id(self):X}>"345 346 def __str__(self) -> str:347 with contextlib.suppress(BaseException):348 return "\n".join(self.decoded_text)349 350 return repr(self)351 352 353class TextCanvas(Canvas):354 """355 class for storing rendered text and attributes356 """357 358 def __init__(359 self,360 text: list[bytes] | None = None,361 attr: list[list[tuple[Hashable | None, int]]] | None = None,362 cs: list[list[tuple[Literal["0", "U"] | None, int]]] | None = None,363 cursor: tuple[int, int] | None = None,364 maxcol: int | None = None,365 check_width: bool = True,366 ) -> None:367 """368 text -- list of strings, one for each line369 attr -- list of run length encoded attributes for text370 cs -- list of run length encoded character set for text371 cursor -- (x,y) of cursor or None372 maxcol -- screen columns taken by this canvas373 check_width -- check and fix width of all lines in text374 """375 super().__init__()376 if text is None:377 text = []378 379 if check_width:380 widths = []381 for t in text:382 if not isinstance(t, bytes):383 raise CanvasError(384 "Canvas text must be plain strings encoded in the screen's encoding",385 repr(text),386 )387 widths.append(calc_width(t, 0, len(t)))388 else:389 if not isinstance(maxcol, int):390 raise TypeError(maxcol)391 widths = [maxcol] * len(text)392 393 if maxcol is None:394 if widths:395 # find maxcol ourselves396 maxcol = max(widths)397 else:398 maxcol = 0399 400 if attr is None:401 attr = [[] for _ in range(len(text))]402 if cs is None:403 cs = [[] for _ in range(len(text))]404 405 # pad text and attr to maxcol406 for i in range(len(text)):407 w = widths[i]408 if w > maxcol:409 raise CanvasError(410 f"Canvas text is wider than the maxcol specified:\n"411 f"maxcol={maxcol!r}\n"412 f"widths={widths!r}\n"413 f"text={text!r}\n"414 f"urwid target encoding={get_encoding()}"415 )416 if w < maxcol:417 text[i] += b"".rjust(maxcol - w)418 a_gap = len(text[i]) - rle_len(attr[i])419 if a_gap < 0:420 raise CanvasError(f"Attribute extends beyond text \n{text[i]!r}\n{attr[i]!r}")421 if a_gap:422 rle_append_modify(attr[i], (None, a_gap))423 424 cs_gap = len(text[i]) - rle_len(cs[i])425 if cs_gap < 0:426 raise CanvasError(f"Character Set extends beyond text \n{text[i]!r}\n{cs[i]!r}")427 if cs_gap:428 rle_append_modify(cs[i], (None, cs_gap))429 430 self._attr = attr431 self._cs = cs432 self.cursor = cursor433 self._text = text434 self._maxcol = maxcol435 436 def rows(self) -> int:437 """Return the number of rows in this canvas."""438 return len(self._text)439 440 def cols(self) -> int:441 """Return the screen column width of this canvas."""442 return self._maxcol443 444 def translated_coords(self, dx: int, dy: int) -> tuple[int, int] | None:445 """446 Return cursor coords shifted by (dx, dy), or None if there447 is no cursor.448 """449 if self.cursor:450 x, y = self.cursor451 return x + dx, y + dy452 return None453 454 def content(455 self,456 trim_left: int = 0,457 trim_top: int = 0,458 cols: int | None = 0,459 rows: int | None = 0,460 attr=None,461 ) -> Iterator[tuple[object, Literal["0", "U"] | None, bytes]]:462 """463 Return the canvas content as a list of rows where each row464 is a list of (attr, cs, text) tuples.465 466 trim_left, trim_top, cols, rows may be set by467 CompositeCanvas when rendering a partially obscured468 canvas.469 """470 maxcol, maxrow = self.cols(), self.rows()471 if not cols:472 cols = maxcol - trim_left473 if not rows:474 rows = maxrow - trim_top475 476 if not ((0 <= trim_left < maxcol) and (cols > 0 and trim_left + cols <= maxcol)):477 raise ValueError(trim_left)478 if not ((0 <= trim_top < maxrow) and (rows > 0 and trim_top + rows <= maxrow)):479 raise ValueError(trim_top)480 481 if trim_top or rows < maxrow:482 text_attr_cs = zip(483 self._text[trim_top : trim_top + rows],484 self._attr[trim_top : trim_top + rows],485 self._cs[trim_top : trim_top + rows],486 )487 else:488 text_attr_cs = zip(self._text, self._attr, self._cs)489 490 for text, a_row, cs_row in text_attr_cs:491 if trim_left or cols < self._maxcol:492 text, a_row, cs_row = trim_text_attr_cs( # noqa: PLW2901493 text,494 a_row,495 cs_row,496 trim_left,497 trim_left + cols,498 )499 attr_cs = rle_product(a_row, cs_row)500 i = 0501 row = []502 for (a, cs), run in attr_cs:503 if attr and a in attr:504 a = attr[a] # noqa: PLW2901505 row.append((a, cs, text[i : i + run]))506 i += run507 yield row508 509 def content_delta(self, other: Canvas):510 """511 Return the differences between other and this canvas.512 513 If other is the same object as self this will return no514 differences, otherwise this is the same as calling515 content().516 """517 if other is self:518 return [self.cols()] * self.rows()519 return self.content()520 521 522class BlankCanvas(Canvas):523 """524 a canvas with nothing on it, only works as part of a composite canvas525 since it doesn't know its own size526 """527 528 def content(529 self,530 trim_left: int = 0,531 trim_top: int = 0,532 cols: int | None = 0,533 rows: int | None = 0,534 attr=None,535 ) -> Iterator[list[tuple[object, Literal["0", "U"] | None, bytes]]]:536 """537 return (cols, rows) of spaces with default attributes.538 """539 def_attr = None540 if attr and None in attr:541 def_attr = attr[None]542 line = [(def_attr, None, b"".rjust(cols))]543 for _ in range(rows):544 yield line545 546 def cols(self) -> typing.NoReturn:547 raise NotImplementedError("BlankCanvas doesn't know its own size!")548 549 def rows(self) -> typing.NoReturn:550 raise NotImplementedError("BlankCanvas doesn't know its own size!")551 552 def content_delta(self, other: Canvas) -> typing.NoReturn:553 raise NotImplementedError("BlankCanvas doesn't know its own size!")554 555 556blank_canvas = BlankCanvas()557 558 559class SolidCanvas(Canvas):560 """561 A canvas filled completely with a single character.562 """563 564 def __init__(self, fill_char: str | bytes, cols: int, rows: int) -> None:565 super().__init__()566 end, col = calc_text_pos(fill_char, 0, len(fill_char), 1)567 if col != 1:568 raise ValueError(f"Invalid fill_char: {fill_char!r}")569 self._text, cs = apply_target_encoding(fill_char[:end])570 self._cs = cs[0][0]571 self.size = cols, rows572 self.cursor = None573 574 def cols(self) -> int:575 return self.size[0]576 577 def rows(self) -> int:578 return self.size[1]579 580 def content(581 self,582 trim_left: int = 0,583 trim_top: int = 0,584 cols: int | None = None,585 rows: int | None = None,586 attr=None,587 ) -> Iterator[list[tuple[object, Literal["0", "U"] | None, bytes]]]:588 if cols is None:589 cols = self.size[0]590 if rows is None:591 rows = self.size[1]592 def_attr = None593 if attr and None in attr:594 def_attr = attr[None]595 596 line = [(def_attr, self._cs, self._text * cols)]597 for _ in range(rows):598 yield line599 600 def content_delta(self, other):601 """602 Return the differences between other and this canvas.603 """604 if other is self:605 return [self.cols()] * self.rows()606 return self.content()607 608 609class CompositeCanvas(Canvas):610 """611 class for storing a combination of canvases612 """613 614 def __init__(self, canv: Canvas = None) -> None:615 """616 canv -- a Canvas object to wrap this CompositeCanvas around.617 618 if canv is a CompositeCanvas, make a copy of its contents619 """620 # a "shard" is a (num_rows, list of cviews) tuple, one for621 # each cview starting in this shard622 623 # a "cview" is a tuple that defines a view of a canvas:624 # (trim_left, trim_top, cols, rows, attr_map, canv)625 626 # a "shard tail" is a list of tuples:627 # (col_gap, done_rows, content_iter, cview)628 629 # tuples that define the unfinished cviews that are part of630 # shards following the first shard.631 super().__init__()632 633 if canv is None:634 self.shards: list[635 tuple[636 int,637 list[tuple[int, int, int, int, dict[Hashable | None, Hashable] | None, Canvas]],638 ]639 ] = []640 self.children: list[tuple[int, int, Canvas, typing.Any]] = []641 else:642 if hasattr(canv, "shards"):643 self.shards = canv.shards644 else:645 self.shards = [(canv.rows(), [(0, 0, canv.cols(), canv.rows(), None, canv)])]646 self.children = [(0, 0, canv, None)]647 self.coords.update(canv.coords)648 for shortcut in canv.shortcuts:649 self.shortcuts[shortcut] = "wrap"650 651 def __repr__(self) -> str:652 extra = [""]653 with contextlib.suppress(BaseException):654 extra.append(f"cols={self.cols()}")655 656 with contextlib.suppress(BaseException):657 extra.append(f"rows={self.rows()}")658 659 if self.cursor:660 extra.append(f"cursor={self.cursor}")661 if self.children:662 extra.append(f"children=({', '.join(repr(canv) for _, _, canv, _ in self.children)})")663 664 return f"<{self.__class__.__name__} finalized={bool(self.widget_info)}{' '.join(extra)} at 0x{id(self):X}>"665 666 def rows(self) -> int:667 for r, cv in self.shards:668 if not isinstance(r, int):669 raise TypeError(r, cv)670 671 return sum(r for r, cv in self.shards)672 673 def cols(self) -> int:674 if not self.shards:675 return 0676 cols = sum(cv[2] for cv in self.shards[0][1])677 if not isinstance(cols, int):678 raise TypeError(cols)679 return cols680 681 def content(682 self,683 trim_left: int = 0,684 trim_top: int = 0,685 cols: int | None = None,686 rows: int | None = None,687 attr=None,688 ) -> Iterator[list[tuple[object, Literal["0", "U"] | None, bytes]]]:689 """690 Return the canvas content as a list of rows where each row691 is a list of (attr, cs, text) tuples.692 """693 shard_tail = []694 for num_rows, cviews in self.shards:695 # combine shard and shard tail696 sbody = shard_body(cviews, shard_tail)697 698 # output rows699 for _ in range(num_rows):700 yield shard_body_row(sbody)701 702 # prepare next shard tail703 shard_tail = shard_body_tail(num_rows, sbody)704 705 def content_delta(self, other: Canvas):706 """707 Return the differences between other and this canvas.708 """709 if not hasattr(other, "shards"):710 yield from self.content()711 return712 713 shard_tail = []714 for num_rows, cviews in shards_delta(self.shards, other.shards):715 # combine shard and shard tail716 sbody = shard_body(cviews, shard_tail)717 718 # output rows719 row = []720 for _ in range(num_rows):721 # if whole shard is unchanged, don't keep722 # calling shard_body_row723 if len(row) != 1 or not isinstance(row[0], int):724 row = shard_body_row(sbody)725 yield row726 727 # prepare next shard tail728 shard_tail = shard_body_tail(num_rows, sbody)729 730 def trim(self, top: int, count: int | None = None) -> None:731 """Trim lines from the top and/or bottom of canvas.732 733 top -- number of lines to remove from top734 count -- number of lines to keep, or None for all the rest735 """736 if top < 0:737 raise ValueError(f"invalid trim amount {top:d}!")738 if top >= self.rows():739 raise ValueError(f"cannot trim {top:d} lines from {self.rows():d}!")740 if self.widget_info:741 raise self._finalized_error742 743 if top:744 self.shards = shards_trim_top(self.shards, top)745 746 if count == 0:747 self.shards = []748 elif count is not None:749 self.shards = shards_trim_rows(self.shards, count)750 751 self.coords = self.translate_coords(0, -top)752 753 def trim_end(self, end: int) -> None:754 """Trim lines from the bottom of the canvas.755 756 end -- number of lines to remove from the end757 """758 if end <= 0:759 raise ValueError(f"invalid trim amount {end:d}!")760 if end > self.rows():761 raise ValueError(f"cannot trim {end:d} lines from {self.rows():d}!")762 if self.widget_info:763 raise self._finalized_error764 765 self.shards = shards_trim_rows(self.shards, self.rows() - end)766 767 def pad_trim_left_right(self, left: int, right: int) -> None:768 """769 Pad or trim this canvas on the left and right770 771 values > 0 indicate screen columns to pad772 values < 0 indicate screen columns to trim773 """774 if self.widget_info:775 raise self._finalized_error776 shards = self.shards777 if left < 0 or right < 0:778 trim_left = max(0, -left)779 cols = self.cols() - trim_left - max(0, -right)780 shards = shards_trim_sides(shards, trim_left, cols)781 782 rows = self.rows()783 if left > 0 or right > 0:784 top_rows, top_cviews = shards[0]785 if left > 0:786 new_top_cviews = [(0, 0, left, rows, None, blank_canvas), *top_cviews]787 else:788 new_top_cviews = top_cviews.copy()789 790 if right > 0:791 new_top_cviews.append((0, 0, right, rows, None, blank_canvas))792 shards = [(top_rows, new_top_cviews), *shards[1:]]793 794 self.coords = self.translate_coords(left, 0)795 self.shards = shards796 797 def pad_trim_top_bottom(self, top: int, bottom: int) -> None:798 """799 Pad or trim this canvas on the top and bottom.800 """801 if self.widget_info:802 raise self._finalized_error803 orig_shards = self.shards804 805 if top < 0 or bottom < 0:806 trim_top = max(0, -top)807 rows = self.rows() - trim_top - max(0, -bottom)808 self.trim(trim_top, rows)809 810 cols = self.cols()811 if top > 0:812 self.shards = [(top, [(0, 0, cols, top, None, blank_canvas)]), *self.shards]813 self.coords = self.translate_coords(0, top)814 815 if bottom > 0:816 if orig_shards is self.shards:817 self.shards = self.shards.copy()818 self.shards.append((bottom, [(0, 0, cols, bottom, None, blank_canvas)]))819 820 def overlay(self, other: CompositeCanvas, left: int, top: int) -> None:821 """Overlay other onto this canvas."""822 if self.widget_info:823 raise self._finalized_error824 825 width = other.cols()826 height = other.rows()827 right = self.cols() - left - width828 bottom = self.rows() - top - height829 830 if right < 0:831 raise ValueError(f"top canvas of overlay not the size expected!{(other.cols(), left, right, width)!r}")832 if bottom < 0:833 raise ValueError(f"top canvas of overlay not the size expected!{(other.rows(), top, bottom, height)!r}")834 835 shards = self.shards836 top_shards = []837 side_shards = self.shards838 bottom_shards = []839 if top:840 side_shards = shards_trim_top(shards, top)841 top_shards = shards_trim_rows(shards, top)842 if bottom:843 bottom_shards = shards_trim_top(side_shards, height)844 side_shards = shards_trim_rows(side_shards, height)845 846 left_shards = []847 right_shards = []848 if left > 0:849 left_shards = [shards_trim_sides(side_shards, 0, left)]850 if right > 0:851 right_shards = [shards_trim_sides(side_shards, max(0, left + width), right)]852 853 if not self.rows():854 middle_shards = []855 elif left or right:856 middle_shards = shards_join((*left_shards, other.shards, *right_shards))857 else:858 middle_shards = other.shards859 860 self.shards = top_shards + middle_shards + bottom_shards861 862 self.coords.update(other.translate_coords(left, top))863 864 def fill_attr(self, a: Hashable) -> None:865 """866 Apply attribute a to all areas of this canvas with default attribute currently set to None,867 leaving other attributes intact.868 """869 self.fill_attr_apply({None: a})870 871 def fill_attr_apply(self, mapping: dict[Hashable | None, Hashable]) -> None:872 """873 Apply an attribute-mapping dictionary to the canvas.874 875 mapping -- dictionary of original-attribute:new-attribute items876 """877 if self.widget_info:878 raise self._finalized_error879 880 shards = []881 for num_rows, original_cviews in self.shards:882 new_cviews = []883 for cv in original_cviews:884 # cv[4] == attr_map885 if cv[4] is None:886 new_cviews.append((*cv[:4], mapping, *cv[5:]))887 else:888 combined = mapping.copy()889 combined.update([(k, mapping.get(v, v)) for k, v in cv[4].items()])890 new_cviews.append((*cv[:4], combined, *cv[5:]))891 shards.append((num_rows, new_cviews))892 self.shards = shards893 894 def set_depends(self, widget_list: Sequence[Widget]) -> None:895 """896 Explicitly specify the list of widgets that this canvas897 depends on. If any of these widgets change this canvas898 will have to be updated.899 """900 if self.widget_info:901 raise self._finalized_error902 903 self.depends_on = widget_list904 905 906def shard_body_row(sbody):907 """908 Return one row, advancing the iterators in sbody.909 910 ** MODIFIES sbody by calling next() on its iterators **911 """912 row = []913 for _done_rows, content_iter, cview in sbody:914 if content_iter:915 row.extend(next(content_iter))916 else: # noqa: PLR5501 # pylint: disable=else-if-used # readability917 # need to skip this unchanged canvas918 if row and isinstance(row[-1], int):919 row[-1] += cview[2]920 else:921 row.append(cview[2])922 923 return row924 925 926def shard_body_tail(num_rows: int, sbody):927 """928 Return a new shard tail that follows this shard body.929 """930 shard_tail = []931 col_gap = 0932 933 for done_rows, content_iter, cview in sbody:934 cols, rows = cview[2:4]935 done_rows += num_rows # noqa: PLW2901936 if done_rows == rows:937 col_gap += cols938 continue939 shard_tail.append((col_gap, done_rows, content_iter, cview))940 col_gap = 0941 return shard_tail942 943 944def shards_delta(shards, other_shards):945 """946 Yield shards1 with cviews that are the same as shards2 having canv = None.947 """948 # pylint: disable=stop-iteration-return949 other_shards_iter = iter(other_shards)950 other_num_rows = other_cviews = None951 done = other_done = 0952 for num_rows, cviews in shards:953 if other_num_rows is None:954 other_num_rows, other_cviews = next(other_shards_iter)955 while other_done < done:956 other_done += other_num_rows957 other_num_rows, other_cviews = next(other_shards_iter)958 if other_done > done:959 yield (num_rows, cviews)960 done += num_rows961 continue962 # top-aligned shards, compare each cview963 yield (num_rows, shard_cviews_delta(cviews, other_cviews))964 other_done += other_num_rows965 other_num_rows = None966 done += num_rows967 968 969def shard_cviews_delta(cviews, other_cviews):970 # pylint: disable=stop-iteration-return971 other_cviews_iter = iter(other_cviews)972 other_cv = None973 cols = other_cols = 0974 for cv in cviews:975 if other_cv is None:976 other_cv = next(other_cviews_iter)977 while other_cols < cols:978 other_cols += other_cv[2]979 other_cv = next(other_cviews_iter)980 if other_cols > cols:981 yield cv982 cols += cv[2]983 continue984 # top-left-aligned cviews, compare them985 if cv[5] is other_cv[5] and cv[:5] == other_cv[:5]:986 yield (*cv[:5], None, *cv[6:])987 else:988 yield cv989 other_cols += other_cv[2]990 other_cv = None991 cols += cv[2]992 993 994def shard_body(cviews, shard_tail, create_iter: bool = True, iter_default=None):995 """996 Return a list of (done_rows, content_iter, cview) tuples for997 this shard and shard tail.998 999 If a canvas in cviews is None (eg. when unchanged from1000 shard_cviews_delta()) or if create_iter is False then no1001 iterator is created for content_iter.1002 1003 iter_default is the value used for content_iter when no iterator1004 is created.1005 """1006 col = 01007 body = [] # build the next shard tail1008 cviews_iter = iter(cviews)1009 for col_gap, done_rows, content_iter, tail_cview in shard_tail:1010 while col_gap:1011 try:1012 cview = next(cviews_iter)1013 except StopIteration:1014 break1015 (trim_left, trim_top, cols, rows, attr_map, canv) = cview[:6]1016 col += cols1017 col_gap -= cols # noqa: PLW29011018 if col_gap < 0:1019 raise CanvasError("cviews overflow gaps in shard_tail!")1020 if create_iter and canv:1021 new_iter = canv.content(trim_left, trim_top, cols, rows, attr_map)1022 else:1023 new_iter = iter_default1024 body.append((0, new_iter, cview))1025 body.append((done_rows, content_iter, tail_cview))1026 for cview in cviews_iter:1027 (trim_left, trim_top, cols, rows, attr_map, canv) = cview[:6]1028 if create_iter and canv:1029 new_iter = canv.content(trim_left, trim_top, cols, rows, attr_map)1030 else:1031 new_iter = iter_default1032 body.append((0, new_iter, cview))1033 return body1034 1035 1036def shards_trim_top(shards, top: int):1037 """1038 Return shards with top rows removed.1039 """1040 if top <= 0:1041 raise ValueError(top)1042 1043 shard_iter = iter(shards)1044 shard_tail = []1045 # skip over shards that are completely removed1046 for num_rows, cviews in shard_iter:1047 if top < num_rows:1048 break1049 sbody = shard_body(cviews, shard_tail, False)1050 shard_tail = shard_body_tail(num_rows, sbody)1051 top -= num_rows1052 else:1053 raise CanvasError("tried to trim shards out of existence")1054 1055 sbody = shard_body(cviews, shard_tail, False)1056 shard_tail = shard_body_tail(num_rows, sbody)1057 # trim the top of this shard1058 new_sbody = [(0, content_iter, cview_trim_top(cv, done_rows + top)) for done_rows, content_iter, cv in sbody]1059 1060 sbody = new_sbody1061 1062 new_shards = [(num_rows - top, [cv for done_rows, content_iter, cv in sbody])]1063 1064 # write out the rest of the shards1065 new_shards.extend(shard_iter)1066 1067 return new_shards1068 1069 1070def shards_trim_rows(shards, keep_rows: int):1071 """1072 Return the topmost keep_rows rows from shards.1073 """1074 if keep_rows < 0:1075 raise ValueError(keep_rows)1076 1077 new_shards = []1078 done_rows = 01079 for num_rows, cviews in shards:1080 if done_rows >= keep_rows:1081 break1082 new_cviews = []1083 for cv in cviews:1084 if cv[3] + done_rows > keep_rows:1085 new_cviews.append(cview_trim_rows(cv, keep_rows - done_rows))1086 else:1087 new_cviews.append(cv)1088 1089 if num_rows + done_rows > keep_rows:1090 new_shards.append((keep_rows - done_rows, new_cviews))1091 else:1092 new_shards.append((num_rows, new_cviews))1093 done_rows += num_rows1094 1095 return new_shards1096 1097 1098def shards_trim_sides(shards, left: int, cols: int):1099 """1100 Return shards with starting from column left and cols total width.1101 """1102 if left < 0:1103 raise ValueError(left)1104 if cols <= 0:1105 raise ValueError(cols)1106 shard_tail = []1107 new_shards = []1108 right = left + cols1109 for num_rows, cviews in shards:1110 sbody = shard_body(cviews, shard_tail, False)1111 shard_tail = shard_body_tail(num_rows, sbody)1112 new_cviews = []1113 col = 01114 for done_rows, _content_iter, cv in sbody:1115 cv_cols = cv[2]1116 next_col = col + cv_cols1117 if done_rows or next_col <= left or col >= right:1118 col = next_col1119 continue1120 if col < left:1121 cv = cview_trim_left(cv, left - col) # noqa: PLW29011122 col = left1123 if next_col > right:1124 cv = cview_trim_cols(cv, right - col) # noqa: PLW29011125 new_cviews.append(cv)1126 col = next_col1127 if not new_cviews:1128 prev_num_rows, prev_cviews = new_shards[-1]1129 new_shards[-1] = (prev_num_rows + num_rows, prev_cviews)1130 else:1131 new_shards.append((num_rows, new_cviews))1132 return new_shards1133 1134 1135def shards_join(shard_lists):1136 """1137 Return the result of joining shard lists horizontally.1138 All shards lists must have the same number of rows.1139 """1140 shards_iters = [iter(sl) for sl in shard_lists]1141 shards_current = [next(i) for i in shards_iters]1142 1143 new_shards = []1144 while True:1145 new_cviews = []1146 num_rows = min(r for r, cv in shards_current)1147 1148 shards_next = []1149 for rows, cviews in shards_current:1150 if cviews:1151 new_cviews.extend(cviews)1152 shards_next.append((rows - num_rows, None))1153 1154 shards_current = shards_next1155 new_shards.append((num_rows, new_cviews))1156 1157 # advance to next shards1158 try:1159 for i in range(len(shards_current)):1160 if shards_current[i][0] > 0:1161 continue1162 shards_current[i] = next(shards_iters[i])1163 except StopIteration:1164 break1165 return new_shards1166 1167 1168def cview_trim_rows(cv, rows: int):1169 return (*cv[:3], rows, *cv[4:])1170 1171 1172def cview_trim_top(cv, trim: int):1173 return (cv[0], trim + cv[1], cv[2], cv[3] - trim, *cv[4:])1174 1175 1176def cview_trim_left(cv, trim: int):1177 return (cv[0] + trim, cv[1], cv[2] - trim, *cv[3:])1178 1179 1180def cview_trim_cols(cv, cols: int):1181 return (*cv[:2], cols, *cv[3:])1182 1183 1184def CanvasCombine(canvas_info: Iterable[tuple[Canvas, typing.Any, bool]]) -> CompositeCanvas:1185 """Stack canvases in l vertically and return resulting canvas.1186 1187 :param canvas_info: list of (canvas, position, focus) tuples:1188 1189 position1190 a value that widget.set_focus will accept or None if not allowed1191 focus1192 True if this canvas is the one that would be in focus if the whole widget is in focus1193 """1194 clist = [(CompositeCanvas(c), p, f) for c, p, f in canvas_info]1195 1196 combined_canvas = CompositeCanvas()1197 shards = []1198 children = []1199 row = 01200 focus_index = 0