Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes15kdownloads
text_layout.py644 linesDownload Raw Back to urwid
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 
codekingpro/portable-devtools · Team Ai