# Urwid utility functions # Copyright (C) 2004-2011 Ian Ward # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 2.1 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # Urwid web site: https://urwid.org/ from __future__ import annotations import codecs import contextlib import sys import typing import warnings from contextlib import suppress from urwid import str_util if typing.TYPE_CHECKING: from collections.abc import Generator, Iterable from types import TracebackType from typing_extensions import Literal, Protocol, Self class CanBeStopped(Protocol): def stop(self) -> None: ... def __getattr__(name: str) -> typing.Any: if hasattr(str_util, name): warnings.warn( f"Do not import {name!r} from {__package__}.{__name__}, import it from 'urwid'.", DeprecationWarning, stacklevel=3, ) return getattr(str_util, name) raise AttributeError(f"{name} is not defined in {__package__}.{__name__}") def detect_encoding() -> str: # Windows is a special case: # CMD is Unicode and non-unicode at the same time: # Unicode display support depends on C API usage and partially limited by font settings. # By default, python is distributed in "Unicode" version, and since Python version 3.8 only Unicode. # In case of curses, "windows-curses" is distributed in unicode-only. # Since Windows 10 default console font is already unicode, # in older versions maybe need to set TTF font manually to support more symbols # (this does not affect unicode IO). if sys.platform == "win32" and sys.getdefaultencoding() == "utf-8": return "utf-8" # Try to determine if using a supported double-byte encoding import locale no_set_locale = locale.getpreferredencoding(False) if no_set_locale != "ascii": # ascii is fallback locale in case of detect failed return no_set_locale # Use actual `getpreferredencoding` with public API only old_loc = locale.setlocale(locale.LC_CTYPE) # == getlocale, but not mangle data try: with suppress(locale.Error): locale.setlocale(locale.LC_CTYPE, "") # internally call private `_get_locale_encoding` return locale.getpreferredencoding(False) finally: with suppress(locale.Error): locale.setlocale(locale.LC_CTYPE, old_loc) if "detected_encoding" not in locals(): detected_encoding = detect_encoding() else: raise RuntimeError("Encoding detection broken") _target_encoding = "ascii" _use_dec_special = True def set_encoding(encoding: str) -> None: """ Set the byte encoding to assume when processing strings and the encoding to use when converting unicode strings. """ encoding = encoding.lower() global _target_encoding, _use_dec_special # noqa: PLW0603 # noqa: PLW0603 # pylint: disable=global-statement if encoding in {"utf-8", "utf8", "utf"}: str_util.set_byte_encoding("utf8") _use_dec_special = False elif encoding in { "euc-jp", # JISX 0208 only "euc-kr", "euc-cn", "euc-tw", # CNS 11643 plain 1 only "gb2312", "gbk", "big5", "cn-gb", "uhc", # these shouldn't happen, should they? "eucjp", "euckr", "euccn", "euctw", "cncb", }: str_util.set_byte_encoding("wide") _use_dec_special = True else: str_util.set_byte_encoding("narrow") _use_dec_special = True # if encoding is valid for conversion from unicode, remember it _target_encoding = "ascii" with contextlib.suppress(LookupError): if encoding: "".encode(encoding) _target_encoding = encoding def get_encoding() -> str: """Get target encoding.""" return _target_encoding @contextlib.contextmanager def set_temporary_encoding(encoding_name: str) -> Generator[None]: """Internal helper for encoding specific validation in unittests/doctests. Not exported globally. """ old_encoding = _target_encoding try: set_encoding(encoding_name) yield finally: set_encoding(old_encoding) def get_encoding_mode() -> Literal["wide", "narrow", "utf8"]: """ Get the mode Urwid is using when processing text strings. Returns 'narrow' for 8-bit encodings, 'wide' for CJK encodings or 'utf8' for UTF-8 encodings. """ return str_util.get_byte_encoding() def apply_target_encoding(s: str | bytes) -> tuple[bytes, list[tuple[Literal["U", "0"] | None, int]]]: """ Return (encoded byte string, character set rle). """ # Import locally to warranty no circular imports from urwid.display import escape if _use_dec_special and isinstance(s, str): # first convert drawing characters s = s.translate(escape.DEC_SPECIAL_CHARMAP) if isinstance(s, str): s = s.replace(escape.SI + escape.SO, "") # remove redundant shifts s = codecs.encode(s, _target_encoding, "replace") if not isinstance(s, bytes): raise TypeError(s) SO = escape.SO.encode("ascii") SI = escape.SI.encode("ascii") sis = s.split(SO) sis0 = sis[0].replace(SI, b"") sout = [] cout = [] if sis0: sout.append(sis0) cout.append((None, len(sis0))) if len(sis) == 1: return sis0, cout for sn in sis[1:]: sl = sn.split(SI, 1) if len(sl) == 1: sin = sl[0] sout.append(sin) rle_append_modify(cout, (escape.DEC_TAG, len(sin))) continue sin, son = sl son = son.replace(SI, b"") if sin: sout.append(sin) rle_append_modify(cout, (escape.DEC_TAG, len(sin))) if son: sout.append(son) rle_append_modify(cout, (None, len(son))) outstr = b"".join(sout) return outstr, cout ###################################################################### # Try to set the encoding using the one detected by the locale module set_encoding(detected_encoding) ###################################################################### def supports_unicode() -> bool: """ Return True if python is able to convert non-ascii unicode strings to the current encoding. """ return _target_encoding and _target_encoding != "ascii" def calc_trim_text( text: str | bytes, start_offs: int, end_offs: int, start_col: int, end_col: int, ) -> tuple[int, int, int, int]: """ Calculate the result of trimming text. start_offs -- offset into text to treat as screen column 0 end_offs -- offset into text to treat as the end of the line start_col -- screen column to trim at the left end_col -- screen column to trim at the right Returns (start, end, pad_left, pad_right), where: start -- resulting start offset end -- resulting end offset pad_left -- 0 for no pad or 1 for one space to be added pad_right -- 0 for no pad or 1 for one space to be added """ spos = start_offs pad_left = pad_right = 0 if start_col > 0: spos, sc = str_util.calc_text_pos(text, spos, end_offs, start_col) if sc < start_col: pad_left = 1 spos, sc = str_util.calc_text_pos(text, start_offs, end_offs, start_col + 1) run = end_col - start_col - pad_left pos, sc = str_util.calc_text_pos(text, spos, end_offs, run) if sc < run: pad_right = 1 return (spos, pos, pad_left, pad_right) def trim_text_attr_cs(text: bytes, attr, cs, start_col: int, end_col: int): """ Return ( trimmed text, trimmed attr, trimmed cs ). """ spos, epos, pad_left, pad_right = calc_trim_text(text, 0, len(text), start_col, end_col) attrtr = rle_subseg(attr, spos, epos) cstr = rle_subseg(cs, spos, epos) if pad_left: al = rle_get_at(attr, spos - 1) rle_prepend_modify(attrtr, (al, 1)) rle_prepend_modify(cstr, (None, 1)) if pad_right: al = rle_get_at(attr, epos) rle_append_modify(attrtr, (al, 1)) rle_append_modify(cstr, (None, 1)) return (b"".rjust(pad_left) + text[spos:epos] + b"".rjust(pad_right), attrtr, cstr) def rle_get_at(rle, pos: int): """ Return the attribute at offset pos. """ x = 0 if pos < 0: return None for a, run in rle: if x + run > pos: return a x += run return None def rle_subseg(rle, start: int, end: int): """Return a sub segment of a rle list.""" sub_segment = [] x = 0 for a, run in rle: if start: if start >= run: start -= run x += run continue x += start run -= start # noqa: PLW2901 start = 0 if x >= end: break if x + run > end: run = end - x # noqa: PLW2901 x += run sub_segment.append((a, run)) return sub_segment def rle_len(rle: Iterable[tuple[typing.Any, int]]) -> int: """ Return the number of characters covered by a run length encoded attribute list. """ run = 0 for v in rle: if not isinstance(v, tuple): raise TypeError(rle) _a, r = v run += r return run def rle_prepend_modify(rle, a_r) -> None: """ Append (a, r) (unpacked from *a_r*) to BEGINNING of rle. Merge with first run when possible MODIFIES rle parameter contents. Returns None. """ a, r = a_r if not rle: rle[:] = [(a, r)] else: al, run = rle[0] if a == al: rle[0] = (a, run + r) else: rle[0:0] = [(a, r)] def rle_append_modify(rle, a_r) -> None: """ Append (a, r) (unpacked from *a_r*) to the rle list rle. Merge with last run when possible. MODIFIES rle parameter contents. Returns None. """ a, r = a_r if not rle or rle[-1][0] != a: rle.append((a, r)) return _la, lr = rle[-1] rle[-1] = (a, lr + r) def rle_join_modify(rle, rle2) -> None: """ Append attribute list rle2 to rle. Merge last run of rle with first run of rle2 when possible. MODIFIES attr parameter contents. Returns None. """ if not rle2: return rle_append_modify(rle, rle2[0]) rle += rle2[1:] def rle_product(rle1, rle2): """ Merge the runs of rle1 and rle2 like this: eg. rle1 = [ ("a", 10), ("b", 5) ] rle2 = [ ("Q", 5), ("P", 10) ] rle_product: [ (("a","Q"), 5), (("a","P"), 5), (("b","P"), 5) ] rle1 and rle2 are assumed to cover the same total run. """ i1 = i2 = 1 # rle1, rle2 indexes if not rle1 or not rle2: return [] a1, r1 = rle1[0] a2, r2 = rle2[0] result = [] while r1 and r2: r = min(r1, r2) rle_append_modify(result, ((a1, a2), r)) r1 -= r if r1 == 0 and i1 < len(rle1): a1, r1 = rle1[i1] i1 += 1 r2 -= r if r2 == 0 and i2 < len(rle2): a2, r2 = rle2[i2] i2 += 1 return result def rle_factor(rle): """ Inverse of rle_product. """ rle1 = [] rle2 = [] for (a1, a2), r in rle: rle_append_modify(rle1, (a1, r)) rle_append_modify(rle2, (a2, r)) return rle1, rle2 class TagMarkupException(Exception): pass def decompose_tagmarkup(tm): """Return (text string, attribute list) for tagmarkup passed.""" tl, al = _tagmarkup_recurse(tm, None) # join as unicode or bytes based on type of first element if tl: text = tl[0][:0].join(tl) else: text = "" if al and al[-1][0] is None: del al[-1] return text, al def _tagmarkup_recurse(tm, attr): """Return (text list, attribute list) for tagmarkup passed. tm -- tagmarkup attr -- current attribute or None""" if isinstance(tm, list): # for lists recurse to process each subelement rtl = [] ral = [] for element in tm: tl, al = _tagmarkup_recurse(element, attr) if ral: # merge attributes when possible last_attr, last_run = ral[-1] top_attr, top_run = al[0] if last_attr == top_attr: ral[-1] = (top_attr, last_run + top_run) del al[0] rtl += tl ral += al return rtl, ral if isinstance(tm, tuple): # tuples mark a new attribute boundary if len(tm) != 2: raise TagMarkupException(f"Tuples must be in the form (attribute, tagmarkup): {tm!r}") attr, element = tm return _tagmarkup_recurse(element, attr) if not isinstance(tm, (str, bytes)): raise TagMarkupException(f"Invalid markup element: {tm!r}") # text return [tm], [(attr, len(tm))] def is_mouse_event(ev: tuple[str, int, int, int] | typing.Any) -> bool: return isinstance(ev, tuple) and len(ev) == 4 and "mouse" in ev[0] def is_mouse_press(ev: str) -> bool: return "press" in ev class MetaSuper(type): """adding .__super""" def __init__(cls, name: str, bases, d): super().__init__(name, bases, d) if hasattr(cls, f"_{name}__super"): raise AttributeError("Class has same name as one of its super classes") @property def _super(self): warnings.warn( f"`{name}.__super` was a deprecated feature for old python versions." f"Please use `super()` call instead.", DeprecationWarning, stacklevel=3, ) return super(cls, self) setattr(cls, f"_{name}__super", _super) def int_scale(val: int, val_range: int, out_range: int) -> int: """ Scale val in the range [0, val_range-1] to an integer in the range [0, out_range-1]. This implementation uses the "round-half-up" rounding method. >>> "%x" % int_scale(0x7, 0x10, 0x10000) '7777' >>> "%x" % int_scale(0x5f, 0x100, 0x10) '6' >>> int_scale(2, 6, 101) 40 >>> int_scale(1, 3, 4) 2 """ num = int(val * (out_range - 1) * 2 + (val_range - 1)) dem = (val_range - 1) * 2 # if num % dem == 0 then we are exactly half-way and have rounded up. return num // dem class StoppingContext(typing.ContextManager["StoppingContext"]): """Context manager that calls ``stop`` on a given object on exit. Used to make the ``start`` method on `MainLoop` and `BaseScreen` optionally act as context managers. """ __slots__ = ("_wrapped",) def __init__(self, wrapped: CanBeStopped) -> None: self._wrapped = wrapped def __enter__(self) -> Self: return self def __exit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: self._wrapped.stop()