codekingpro/portable-devtools
115k
1# Urwid Text Layout classes2# 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 functools24import typing25 26import wcwidth27 28from urwid.str_util import calc_text_pos, calc_width, is_wide_char, move_next_char, move_prev_char29from urwid.util import calc_trim_text, get_encoding30 31if typing.TYPE_CHECKING:32 from typing_extensions import Literal33 34 from urwid.widget import Align, WrapMode35 36 37@functools.lru_cache(maxsize=4)38def get_ellipsis_string(encoding: str) -> str:39 """Get ellipsis character for given encoding."""40 try:41 return "…".encode(encoding).decode(encoding)42 except UnicodeEncodeError:43 return "..."44 45 46@functools.lru_cache(maxsize=4)47def _get_width(string) -> int:48 """Get ellipsis character width for given encoding."""49 return wcwidth.width(string, control_codes="ignore")50 51 52class TextLayout:53 def supports_align_mode(self, align: Literal["left", "center", "right"] | Align) -> bool:54 """Return True if align is a supported align mode."""55 return True56 57 def supports_wrap_mode(self, wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode) -> bool:58 """Return True if wrap is a supported wrap mode."""59 return True60 61 def layout(62 self,63 text: str | bytes,64 width: int,65 align: Literal["left", "center", "right"] | Align,66 wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,67 ) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:68 """69 Return a layout structure for text.70 71 :param text: string in current encoding or unicode string72 :param width: number of screen columns available73 :param align: align mode for text74 :param wrap: wrap mode for text75 76 Layout structure is a list of line layouts, one per output line.77 Line layouts are lists than may contain the following tuples:78 79 * (column width of text segment, start offset, end offset)80 * (number of space characters to insert, offset or None)81 * (column width of insert text, offset, "insert text")82 83 The offset in the last two tuples is used to determine the84 attribute used for the inserted spaces or text respectively.85 The attribute used will be the same as the attribute at that86 text offset. If the offset is None when inserting spaces87 then no attribute will be used.88 """89 raise NotImplementedError(90 "This function must be overridden by a real text layout class. (see StandardTextLayout)"91 )92 93 94class CanNotDisplayText(Exception):95 pass96 97 98class StandardTextLayout(TextLayout):99 def __init__(self) -> None: # , tab_stops=(), tab_stop_every=8):100 pass101 # """102 # tab_stops -- list of screen column indexes for tab stops103 # tab_stop_every -- repeated interval for following tab stops104 # """105 # assert tab_stop_every is None or type(tab_stop_every)==int106 # if not tab_stops and tab_stop_every:107 # self.tab_stops = (tab_stop_every,)108 # self.tab_stops = tab_stops109 # self.tab_stop_every = tab_stop_every110 111 def supports_align_mode(self, align: Literal["left", "center", "right"] | Align) -> bool:112 """Return True if align is 'left', 'center' or 'right'."""113 return align in {"left", "center", "right"}114 115 def supports_wrap_mode(self, wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode) -> bool:116 """Return True if wrap is 'any', 'space', 'clip' or 'ellipsis'."""117 return wrap in {"any", "space", "clip", "ellipsis"}118 119 def layout(120 self,121 text: str | bytes,122 width: int,123 align: Literal["left", "center", "right"] | Align,124 wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,125 ) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:126 """Return a layout structure for text."""127 try:128 segs = self.calculate_text_segments(text, width, wrap)129 return self.align_layout(text, width, segs, wrap, align)130 except CanNotDisplayText:131 return [[]]132 133 def pack(134 self,135 maxcol: int,136 layout: list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]],137 ) -> int:138 """Return a minimal maxcol value that would result in the same number of lines for layout.139 140 layout must be a layout structure returned by self.layout().141 """142 maxwidth = 0143 if not layout:144 raise ValueError(f"huh? empty layout?: {layout!r}")145 for lines in layout:146 lw = line_width(lines)147 if lw >= maxcol:148 return maxcol149 maxwidth = max(maxwidth, lw)150 return maxwidth151 152 def align_layout(153 self,154 text: str | bytes,155 width: int,156 segs: list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]],157 wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,158 align: Literal["left", "center", "right"] | Align,159 ) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:160 """Convert the layout segments to an aligned layout."""161 out = []162 for lines in segs:163 sc = line_width(lines)164 if sc == width or align == "left":165 out.append(lines)166 continue167 168 if align == "right":169 out.append([(width - sc, None), *lines])170 continue171 if align != "center":172 raise ValueError(align)173 pad_trim_left = (width - sc + 1) // 2174 out.append([(pad_trim_left, None), *lines] if pad_trim_left else lines)175 return out176 177 def _calculate_trimmed_segments(178 self,179 text: str | bytes,180 width: int,181 wrap: Literal["clip", "ellipsis", WrapMode.CLIP, WrapMode.ELLIPSIS],182 ) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:183 """Calculate text segments for cases of a text trimmed (wrap is clip or ellipsis)."""184 segments = []185 186 nl: str | bytes = "\n" if isinstance(text, str) else b"\n"187 encoding = get_encoding()188 ellipsis_string = get_ellipsis_string(encoding)189 ellipsis_width = _get_width(ellipsis_string)190 if (extra := width - ellipsis_width - 1) < 0:191 ellipsis_string = ellipsis_string[:extra]192 ellipsis_width = _get_width(ellipsis_string)193 194 ellipsis_char = ellipsis_string.encode(encoding)195 196 idx = 0197 198 while idx <= len(text):199 nl_pos = text.find(nl, idx)200 if nl_pos == -1:201 nl_pos = len(text)202 screen_columns = calc_width(text, idx, nl_pos)203 204 # trim line to max width if needed, add ellipsis if trimmed205 if wrap == "ellipsis" and screen_columns > width and ellipsis_width:206 trimmed = True207 208 start_off, end_off, pad_left, pad_right = calc_trim_text(text, idx, nl_pos, 0, width - ellipsis_width)209 # pad_left should be 0, because the start_col parameter was 0 (no trimming on the left)210 # similarly spos should not be changed from p211 if pad_left != 0:212 raise ValueError(f"Invalid padding for start column==0: {pad_left!r}")213 if start_off != idx:214 raise ValueError(f"Invalid start offset for start column==0 and position={idx!r}: {start_off!r}")215 screen_columns = width - 1 - pad_right216 217 else:218 trimmed = False219 end_off = nl_pos220 pad_right = 0221 222 line = []223 if idx != end_off:224 line += [(screen_columns, idx, end_off)]225 if trimmed:226 line += [(ellipsis_width, end_off, ellipsis_char)]227 line += [(pad_right, end_off)]228 segments.append(line)229 idx = nl_pos + 1230 return segments231 232 def calculate_text_segments(233 self,234 text: str | bytes,235 width: int,236 wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,237 ) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:238 """239 Calculate the segments of text to display given width screen columns to display them.240 241 text - Unicode text or byte string to display242 width - number of available screen columns243 wrap - wrapping mode used244 245 Returns a layout structure without an alignment applied.246 """247 if wrap in {"clip", "ellipsis"}:248 return self._calculate_trimmed_segments(text, width, wrap)249 250 nl, nl_o, sp_o = "\n", "\n", " "251 if isinstance(text, bytes):252 nl = b"\n" # can only find bytes in python3 bytestrings253 nl_o = ord(nl_o) # + an item of a bytestring is the ordinal value254 sp_o = ord(sp_o)255 segments = []256 idx = 0257 258 while idx <= len(text):259 # look for the next eligible line break260 nl_pos = text.find(nl, idx)261 if nl_pos == -1:262 nl_pos = len(text)263 264 screen_columns = calc_width(text, idx, nl_pos)265 if screen_columns == 0:266 # removed character hint267 segments.append([(0, nl_pos)])268 idx = nl_pos + 1269 continue270 271 if screen_columns <= width:272 # this segment fits273 segments.append([(screen_columns, idx, nl_pos), (0, nl_pos)])274 # removed character hint275 276 idx = nl_pos + 1277 continue278 279 pos, screen_columns = calc_text_pos(text, idx, nl_pos, width)280 if pos == idx: # pathological width=1 double-byte case281 raise CanNotDisplayText("Wide character will not fit in 1-column width")282 283 if wrap == "any":284 segments.append([(screen_columns, idx, pos)])285 idx = pos286 continue287 288 if wrap != "space":289 raise ValueError(wrap)290 291 if text[pos] == sp_o:292 # perfect space wrap293 segments.append([(screen_columns, idx, pos), (0, pos)])294 # removed character hint295 296 idx = pos + 1297 continue298 299 if is_wide_char(text, pos):300 # perfect next wide301 segments.append([(screen_columns, idx, pos)])302 idx = pos303 continue304 305 prev = pos306 while prev > idx:307 prev = move_prev_char(text, idx, prev)308 if text[prev] == sp_o:309 screen_columns = calc_width(text, idx, prev)310 line = [(0, prev)]311 if idx != prev:312 line = [(screen_columns, idx, prev), *line]313 segments.append(line)314 idx = prev + 1315 break316 317 if is_wide_char(text, prev):318 # wrap after wide char319 next_char = move_next_char(text, prev, pos)320 screen_columns = calc_width(text, idx, next_char)321 segments.append([(screen_columns, idx, next_char)])322 idx = next_char323 break324 325 else:326 # unwrap previous line space if possible to327 # fit more text (we're breaking a word anyway)328 if segments and (len(segments[-1]) == 2 or (len(segments[-1]) == 1 and len(segments[-1][0]) == 2)):329 # look for the removed space above330 if len(segments[-1]) == 1:331 [(h_sc, h_off)] = segments[-1]332 p_sc = 0333 p_off = _p_end = h_off334 335 else:336 [(p_sc, p_off, _p_end), (h_sc, h_off)] = segments[-1]337 338 if p_sc < width and h_sc == 0 and text[h_off] == sp_o:339 # combine with the previous line340 del segments[-1]341 idx = p_off342 pos, screen_columns = calc_text_pos(text, idx, nl_pos, width)343 segments.append([(screen_columns, idx, pos)])344 # check for trailing " " or "\n"345 idx = pos346 if idx < len(text) and (text[idx] in {sp_o, nl_o}):347 # removed character hint348 segments[-1].append((0, idx))349 idx += 1350 continue351 352 # force any char wrap353 segments.append([(screen_columns, idx, pos)])354 idx = pos355 return segments356 357 358######################################359# default layout object to use360default_layout = StandardTextLayout()361######################################362 363 364class LayoutSegment:365 def __init__(self, seg: tuple[int, int, int | bytes] | tuple[int, int | None]) -> None:366 """Create object from line layout segment structure"""367 368 if not isinstance(seg, tuple):369 raise TypeError(seg)370 if len(seg) not in {2, 3}:371 raise ValueError(seg)372 373 self.sc, self.offs = seg[:2]374 375 if not isinstance(self.sc, int):376 raise TypeError(self.sc)377 378 if len(seg) == 3:379 if not isinstance(self.offs, int):380 raise TypeError(self.offs)381 if self.sc <= 0:382 raise ValueError(seg)383 t = seg[2]384 if isinstance(t, bytes):385 self.text: bytes | None = t386 self.end = None387 else:388 if not isinstance(t, int):389 raise TypeError(t)390 self.text = None391 self.end = t392 else:393 if len(seg) != 2:394 raise ValueError(seg)395 if self.offs is not None:396 if self.sc < 0:397 raise ValueError(seg)398 if not isinstance(self.offs, int):399 raise TypeError(self.offs)400 self.text = self.end = None401 402 def subseg(self, text: str | bytes, start: int, end: int) -> list[tuple[int, int] | tuple[int, int, int | bytes]]:403 """404 Return a "sub-segment" list containing segment structures405 that make up a portion of this segment.406 407 A list is returned to handle cases where wide characters408 need to be replaced with a space character at either edge409 so two or three segments will be returned.410 """411 start = max(start, 0)412 end = min(end, self.sc)413 414 if start >= end:415 return [] # completely gone416 if self.text:417 # use text stored in segment (self.text)418 spos, epos, pad_left, pad_right = calc_trim_text(self.text, 0, len(self.text), start, end)419 return [(end - start, self.offs, b"".ljust(pad_left) + self.text[spos:epos] + b"".ljust(pad_right))]420 if self.end:421 # use text passed as parameter (text)422 spos, epos, pad_left, pad_right = calc_trim_text(text, self.offs, self.end, start, end)423 lines = []424 if pad_left:425 lines.append((1, spos - 1))426 lines.append((end - start - pad_left - pad_right, spos, epos))427 if pad_right:428 lines.append((1, epos))429 return lines430 431 return [(end - start, self.offs)]432 433 434def line_width(segs: list[tuple[int, int, int | bytes] | tuple[int, int | None]]) -> int:435 """436 Return the screen column width of one line of a text layout structure.437 438 This function ignores any existing shift applied to the line,439 represented by an (amount, None) tuple at the start of the line.440 """441 sc = 0442 seglist = segs443 if segs and len(segs[0]) == 2 and segs[0][1] is None:444 seglist = segs[1:]445 for s in seglist:446 sc += s[0]447 return sc448 449 450def shift_line(451 segs: list[tuple[int, int, int | bytes] | tuple[int, int | None]],452 amount: int,453) -> list[tuple[int, int, int | bytes] | tuple[int, int | None]]:454 """455 Return a shifted line from a layout structure to the left or right.456 segs -- line of a layout structure457 amount -- screen columns to shift right (+ve) or left (-ve)458 """459 if not isinstance(amount, int):460 raise TypeError(amount)461 462 if segs and len(segs[0]) == 2 and segs[0][1] is None:463 # existing shift464 amount += segs[0][0]465 if amount:466 return [(amount, None), *segs[1:]]467 return segs[1:]468 469 if amount:470 return [(amount, None), *segs]471 return segs472 473 474def trim_line(475 segs: list[tuple[int, int, int | bytes] | tuple[int, int | None]],476 text: str | bytes,477 start: int,478 end: int,479) -> list[tuple[int, int, int | bytes] | tuple[int, int | None]]:480 """481 Return a trimmed line of a text layout structure.482 text -- text to which this layout structure applies483 start -- starting screen column484 end -- ending screen column485 """486 result = []487 x = 0488 for seg in segs:489 sc = seg[0]490 if start or sc < 0:491 if start >= sc:492 start -= sc493 x += sc494 continue495 s = LayoutSegment(seg)496 if x + sc >= end:497 # can all be done at once498 return s.subseg(text, start, end - x)499 result += s.subseg(text, start, sc)500 start = 0501 x += sc502 continue503 if x >= end:504 break505 if x + sc > end:506 s = LayoutSegment(seg)507 result += s.subseg(text, 0, end - x)508 break509 result.append(seg)510 return result511 512 513def calc_line_pos(514 text: str | bytes,515 line_layout,516 pref_col: Literal["left", "right", Align.LEFT, Align.RIGHT] | int,517):518 """519 Calculate the closest linear position to pref_col given a520 line layout structure. Returns None if no position found.521 """522 closest_sc = None523 closest_pos = None524 current_sc = 0525 526 if pref_col == "left":527 for seg in line_layout:528 s = LayoutSegment(seg)529 if s.offs is not None:530 return s.offs531 return None532 if pref_col == "right":533 for seg in line_layout:534 s = LayoutSegment(seg)535 if s.offs is not None:536 closest_pos = s537 s = closest_pos538 if s is None:539 return None540 if s.end is None:541 return s.offs542 return calc_text_pos(text, s.offs, s.end, s.sc - 1)[0]543 544 for seg in line_layout:545 s = LayoutSegment(seg)546 if s.offs is not None:547 if s.end is not None:548 if current_sc <= pref_col < current_sc + s.sc:549 # exact match within this segment550 return calc_text_pos(text, s.offs, s.end, pref_col - current_sc)[0]551 if current_sc <= pref_col:552 closest_sc = current_sc + s.sc - 1553 closest_pos = s554 555 if closest_sc is None or (abs(pref_col - current_sc) < abs(pref_col - closest_sc)):556 # this screen column is closer557 closest_sc = current_sc558 closest_pos = s.offs559 if current_sc > closest_sc:560 # we're moving past561 break562 current_sc += s.sc563 564 if closest_pos is None or isinstance(closest_pos, int):565 return closest_pos566 567 # return the last positions in the segment "closest_pos"568 s = closest_pos569 return calc_text_pos(text, s.offs, s.end, s.sc - 1)[0]570 571 572def calc_pos(573 text: str | bytes,574 layout: list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]],575 pref_col: Literal["left", "right", Align.LEFT, Align.RIGHT] | int,576 row: int,577) -> int:578 """579 Calculate the closest linear position to pref_col and row given a580 layout structure.581 """582 583 if row < 0 or row >= len(layout):584 raise ValueError("calculate_pos: out of layout row range")585 586 if (pos := calc_line_pos(text, layout[row], pref_col)) is not None:587 return pos588 589 rows_above = list(range(row - 1, -1, -1))590 rows_below = list(range(row + 1, len(layout)))591 while rows_above and rows_below:592 if rows_above:593 r = rows_above.pop(0)594 if (pos := calc_line_pos(text, layout[r], pref_col)) is not None:595 return pos596 597 if rows_below:598 r = rows_below.pop(0)599 if (pos := calc_line_pos(text, layout[r], pref_col)) is not None:600 return pos601 602 return 0603 604 605def calc_coords(606 text: str | bytes,607 layout: list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]],608 pos: int,609 clamp: int = 1,610) -> tuple[int, int]:611 """612 Calculate the coordinates closest to position pos in text with layout.613 614 text -- raw string or unicode string615 layout -- layout structure applied to text616 pos -- integer position into text617 clamp -- ignored right now618 """619 closest: tuple[int, tuple[int, int]] | None = None620 y = 0621 for line_layout in layout:622 x = 0623 for seg in line_layout:624 s = LayoutSegment(seg)625 if s.offs is None:626 x += s.sc627 continue628 if s.offs == pos:629 return x, y630 if s.end is not None and s.offs <= pos < s.end:631 x += calc_width(text, s.offs, pos)632 return x, y633 distance = abs(s.offs - pos)634 if s.end is not None and s.end < pos:635 distance = pos - (s.end - 1)636 if closest is None or distance < closest[0]: # pylint: disable=unsubscriptable-object637 closest = distance, (x, y)638 x += s.sc639 y += 1640 641 if closest:642 return closest[1]643 return 0, 0644 