summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlexey Stepanov <penguinolog@users.noreply.github.com>2023-04-21 15:05:01 +0200
committerGitHub <noreply@github.com>2023-04-21 15:05:01 +0200
commita53fd346d22e67d5e42c29a4d5c3498d598443c9 (patch)
tree1e2c9c226594dd0e8b6eaeb64c3cc3d0cd26047c
parent50e763194c56b1ef11d16d7c2cd24f66639fa049 (diff)
downloadurwid-a53fd346d22e67d5e42c29a4d5c3498d598443c9.tar.gz
Add type annotations and optimize `urwid.font` (#540)
* Add type annotations and optimize `urwid.font` * optimize `separate_glyphs`: do not mutate `gl`, less temporary variables * add `__repr__` and human-friendly exception reraise on render * add type annotations to satisfy mypy Partial: #406 * Add FontRegistry metaclass and use self-registration for fonts. --------- Co-authored-by: Aleksei Stepanov <alekseis@nvidia.com>
-rw-r--r--urwid/__init__.py1
-rwxr-xr-xurwid/font.py283
-rwxr-xr-xurwid/old_str_util.py2
3 files changed, 197 insertions, 89 deletions
diff --git a/urwid/__init__.py b/urwid/__init__.py
index 0960e58..6234d6d 100644
--- a/urwid/__init__.py
+++ b/urwid/__init__.py
@@ -78,6 +78,7 @@ from urwid.decoration import (
)
from urwid.font import (
Font,
+ FontRegistry,
HalfBlock5x4Font,
HalfBlock6x5Font,
HalfBlock7x7Font,
diff --git a/urwid/font.py b/urwid/font.py
index adb9db1..0269ce6 100755
--- a/urwid/font.py
+++ b/urwid/font.py
@@ -22,53 +22,66 @@
from __future__ import annotations
-from urwid.canvas import TextCanvas
+import typing
+import warnings
+from collections.abc import Iterator, Sequence
+from pprint import pformat
+
+from urwid.canvas import CanvasError, TextCanvas
from urwid.escape import SAFE_ASCII_DEC_SPECIAL_RE
from urwid.util import apply_target_encoding, str_util
+if typing.TYPE_CHECKING:
+ from typing_extensions import Literal
+
-def separate_glyphs(gdata, height):
+def separate_glyphs(gdata: str, height: int) -> tuple[dict[str, tuple[int, list[str]]], bool]:
"""return (dictionary of glyphs, utf8 required)"""
- gl = gdata.split("\n")
- del gl[0]
- del gl[-1]
- for g in gl:
- assert "\t" not in g
- assert len(gl) == height+1, repr(gdata)
- key_line = gl[0]
- del gl[0]
- c = None # current character
- key_index = 0 # index into character key line
- end_col = 0 # column position at end of glyph
- start_col = 0 # column position at start of glyph
- jl = [0]*height # indexes into lines of gdata (gl)
- dout = {}
+ gl: list[str] = gdata.split("\n")[1: -1]
+
+ if any("\t" in elem for elem in gl):
+ raise ValueError(f"Incorrect glyphs data:\n{gdata!r}")
+
+ if len(gl) != height + 1:
+ raise ValueError(f"Incorrect glyphs height (expected: {height}):\n{gdata}")
+
+ key_line: str = gl[0]
+
+ character: str | None = None # current character
+ key_index = 0 # index into character key line
+ end_col = 0 # column position at end of glyph
+ start_col = 0 # column position at start of glyph
+ jl: list[int] = [0] * height # indexes into lines of gdata (gl)
+ result: dict[str, tuple[int, list[str]]] = {}
utf8_required = False
while True:
- if c is None:
+ if character is None:
if key_index >= len(key_line):
break
- c = key_line[key_index]
- if key_index < len(key_line) and key_line[key_index] == c:
- end_col += str_util.get_width(ord(c))
+ character = key_line[key_index]
+
+ if key_index < len(key_line) and key_line[key_index] == character:
+ end_col += str_util.get_width(ord(character))
key_index += 1
continue
- out = []
- for k in range(height):
- l = gl[k]
- j = jl[k]
+
+ out: list[str] = []
+ y = 0
+ fill = 0
+
+ for k, line in enumerate(gl[1:]):
+ j: int = jl[k]
y = 0
fill = 0
while y < end_col - start_col:
- if j >= len(l):
+ if j >= len(line):
fill = end_col - start_col - y
break
- y += str_util.get_width(ord(l[j]))
+ y += str_util.get_width(ord(line[j]))
j += 1
- assert y + fill == end_col - start_col, \
- repr((y, fill, end_col))
+ assert y + fill == end_col - start_col, repr((y, fill, end_col))
- segment = l[jl[k]:j]
+ segment = line[jl[k]:j]
if not SAFE_ASCII_DEC_SPECIAL_RE.match(segment):
utf8_required = True
@@ -76,75 +89,164 @@ def separate_glyphs(gdata, height):
jl[k] = j
start_col = end_col
- dout[c] = (y + fill, out)
- c = None
- return dout, utf8_required
+ result[character] = (y + fill, out)
+ character = None
+ return result, utf8_required
-_all_fonts = []
+def add_font(name: str, cls: FontRegistry) -> None:
+ warnings.warn(
+ "`add_font` is deprecated, please set 'name' attribute to the font class,"
+ " use metaclass keyword argument 'font_name'"
+ " or use `Font.register(<name>)`",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ cls.register(name)
-def get_all_fonts():
- """
- Return a list of (font name, font class) tuples.
- """
- return _all_fonts[:]
+class FontRegistryWarning(UserWarning):
+ """FontRegistry warning."""
-def add_font(name, cls):
- _all_fonts.append((name, cls))
+class FontRegistry(type):
+ """Font registry.
-
-class Font:
- def __init__(self):
+ Store all registered fonts, register during class creation if possible.
+ """
+ __slots__ = ()
+
+ __registered: dict[str, FontRegistry] = {}
+
+ def __iter__(self) -> Iterator[str]:
+ """Iterate over registered font names."""
+ return iter(self.__registered)
+
+ def __getitem__(self, item: str) -> FontRegistry | None:
+ """Get font by name if registered."""
+ return self.__registered.get(item)
+
+ @property
+ def registered(cls) -> Sequence[str]:
+ """Registered font names in alphabetical order."""
+ return tuple(sorted(cls.__registered))
+
+ @classmethod
+ def as_list(mcs) -> list[tuple[str, FontRegistry]]:
+ """List of (font name, font class) tuples."""
+ return list(mcs.__registered.items())
+
+ def __new__(
+ cls: type[FontRegistry],
+ name: str,
+ bases: tuple[type, ...],
+ namespace: dict[str, typing.Any],
+ **kwds: typing.Any,
+ ) -> FontRegistry:
+ font_name: str = namespace.setdefault("name", kwds.get("font_name", ""))
+ font_class = super().__new__(cls, name, bases, namespace)
+ if font_name:
+ if font_name not in cls.__registered:
+ cls.__registered[font_name] = font_class
+ if cls.__registered[font_name] != font_class:
+ warnings.warn(
+ f"{font_name!r} is already registered, please override explicit if required or change name",
+ FontRegistryWarning,
+ stacklevel=2,
+ )
+ return font_class
+
+ def register(cls, font_name: str) -> None:
+ """Register font explicit.
+
+ :param font_name: Font name to use in registration.
+ """
+ if not font_name:
+ raise ValueError('"font_name" is not set.')
+ cls.__registered[font_name] = cls
+
+
+get_all_fonts = FontRegistry.as_list
+
+
+class Font(metaclass=FontRegistry):
+ """Font base class."""
+
+ __slots__ = ("char", "canvas", "utf8_required")
+
+ height: int
+ data: list[str]
+ name: str
+
+ def __init__(self) -> None:
assert self.height
assert self.data
- self.char = {}
- self.canvas = {}
+ self.char: dict[str, tuple[int, list[str]]] = {}
+ self.canvas: dict[str, TextCanvas] = {}
self.utf8_required = False
- data = [self._to_text(block) for block in self.data]
+ data: list[str] = [self._to_text(block) for block in self.data]
for gdata in data:
self.add_glyphs(gdata)
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}()"
+
+ def __str__(self) -> str:
+ """Font description."""
+ return f"{self.__class__.__name__}():\n {self.height!r}\n {pformat(self.data, indent=4)}"
+
@staticmethod
- def _to_text(obj, encoding='utf-8', errors='strict') -> str:
+ def _to_text(
+ obj: str | bytes,
+ encoding: str = 'utf-8',
+ errors: Literal['strict', 'ignore', 'replace'] | str = 'strict',
+ ) -> str:
if isinstance(obj, str):
return obj
elif isinstance(obj, bytes):
+ warnings.warn(
+ "Bytes based fonts are deprecated, please switch to the text one",
+ DeprecationWarning,
+ stacklevel=3,
+ )
return obj.decode(encoding, errors)
raise TypeError(f"{obj!r} is not str|bytes")
- def add_glyphs(self, gdata):
+ def add_glyphs(self, gdata: str) -> None:
d, utf8_required = separate_glyphs(gdata, self.height)
self.char.update(d)
self.utf8_required |= utf8_required
def characters(self) -> str:
- l = sorted(self.char)
-
- return "".join(l)
+ return "".join(sorted(self.char))
- def char_width(self, c) -> int:
- if c in self.char:
- return self.char[c][0]
+ def char_width(self, character: str) -> int:
+ if character in self.char:
+ return self.char[character][0]
return 0
- def char_data(self, c):
- return self.char[c][1]
+ def char_data(self, character: str) -> list[str]:
+ return self.char[character][1]
- def render(self, c):
- if c in self.canvas:
- return self.canvas[c]
- width, l = self.char[c]
- tl = []
- csl = []
- for d in l:
+ def render(self, character: str) -> TextCanvas:
+ if character in self.canvas:
+ return self.canvas[character]
+ width, line = self.char[character]
+ byte_lines = []
+ character_set_lines = []
+ for d in line:
t, cs = apply_target_encoding(d)
- tl.append(t)
- csl.append(cs)
- canv = TextCanvas(tl, None, csl, maxcol=width,
- check_width=False)
- self.canvas[c] = canv
+ byte_lines.append(t)
+ character_set_lines.append(cs)
+
+ try:
+ canv = TextCanvas(byte_lines, None, character_set_lines, maxcol=width, check_width=False)
+ except CanvasError as exc:
+ raise CanvasError(
+ f"Failed render of {character!r} from line {line!r}:\n{self}\n:{exc}"
+ ).with_traceback(exc.__traceback__) from exc
+
+ self.canvas[character] = canv
return canv
@@ -154,6 +256,7 @@ class Font:
class Thin3x3Font(Font):
+ name = "Thin 3x3"
height = 3
data = ["""
000111222333444555666777888999 !
@@ -166,9 +269,10 @@ class Thin3x3Font(Font):
┼┼└┼┐ / * ┼ ─ / ., _ ┌┘│ \ │
└┼┘/ O , ./ . └ \ ┘ ──
"""]
-add_font("Thin 3x3",Thin3x3Font)
+
class Thin4x3Font(Font):
+ name = "Thin 4x3"
height = 3
data = Thin3x3Font.data + ["""
0000111122223333444455556666777788889999 ####$$$$
@@ -176,9 +280,10 @@ class Thin4x3Font(Font):
│ │ │ ┌──┘ ─┤└──┼└──┐├──┐ ┼├──┤└──┤ ┼─┼└┼┼┐
└──┘ ┴ └── └──┘ ┴ ──┘└──┘ ┴└──┘ ──┘ └┼┼┘
"""]
-add_font("Thin 4x3",Thin4x3Font)
+
class Sextant3x3Font(Font):
+ name = "Sextant 3x3"
height = 3
data = [u"""
!!!###$$$%%%&&&'''((()))***+++,,,---...///
@@ -216,18 +321,20 @@ RRRSSSTTTUUUVVVWWWXXXYYYZZZ[[[]]]^^^___```
🬁🬢
"""]
-add_font("Sextant 3x3", Sextant3x3Font)
+
class Sextant2x2Font(Font):
+ name = "Sextant 2x2"
height = 2
data = [u"""
..,,%%00112233445566778899
🬁🬖▐🬨🬇▌🬁🬗🬠🬸🬦▐▐🬒▐🬭🬁🬙▐🬸▐🬸
🬇 🬇🬀🬁🬇🬉🬍 🬄🬉🬋🬇🬍🬁🬊🬇🬅🬉🬍 🬄🬉🬍 🬉
"""]
-add_font("Sextant 2x2", Sextant2x2Font)
+
class HalfBlock5x4Font(Font):
+ name = "Half Block 5x4"
height = 4
data = ["""
00000111112222233333444445555566666777778888899999 !!
@@ -284,9 +391,10 @@ uuuuuvvvvvwwwwwwxxxxxxyyyyyzzzzz
█ █ ▐▌▐▌ ▐▌█▐▌ ▄▀▄ ▀▄▄█ ▄▀
▀▀ ▀▀ ▀ ▀ ▀ ▀ ▄▄▀ ▀▀▀▀
''']
-add_font("Half Block 5x4",HalfBlock5x4Font)
+
class HalfBlock6x5Font(Font):
+ name = "Half Block 6x5"
height = 5
data = ["""
000000111111222222333333444444555555666666777777888888999999 ..::////
@@ -296,9 +404,10 @@ class HalfBlock6x5Font(Font):
█ █ █ ▄▀ ▄ █ █ █ █ █ ▐▌ █ █ █ ▐▌
▀▀▀ ▀▀▀ ▀▀▀▀▀ ▀▀▀ ▀ ▀▀▀▀ ▀▀▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀
"""]
-add_font("Half Block 6x5",HalfBlock6x5Font)
+
class HalfBlockHeavy6x5Font(Font):
+ name = "Half Block Heavy 6x5"
height = 5
data = ["""
000000111111222222333333444444555555666666777777888888999999 ..::////
@@ -308,9 +417,10 @@ class HalfBlockHeavy6x5Font(Font):
█▌ ▐█ █▌ ▄█▀ ▄ ▐█ █▌ ▐█ █▌ ▐█ █▌ █▌ ▐█ ▐█ █▌▐█
▀███▀ ███▌ █████ ▀███▀ █▌ ████▀ ▀███▀ ▐█ ▀███▀ ▀███▀ █▌ █▌
"""]
-add_font("Half Block Heavy 6x5",HalfBlockHeavy6x5Font)
+
class Thin6x6Font(Font):
+ name = "Thin 6x6"
height = 6
data = ["""
000000111111222222333333444444555555666666777777888888999999''
@@ -393,10 +503,10 @@ ttttuuuuuuvvvvvvwwwwwwxxxxxxyyyyyyzzzzzz
└─ └───┴ └─┘ └─┴─┘ ─┘ └─ └───┤ ┴────
└───┘
"""]
-add_font("Thin 6x6",Thin6x6Font)
class HalfBlock7x7Font(Font):
+ name = "Half Block 7x7"
height = 7
data = ["""
0000000111111122222223333333444444455555556666666777777788888889999999'''
@@ -491,24 +601,21 @@ tttttuuuuuuuvvvvvvvwwwwwwwwxxxxxxxyyyyyyyzzzzzzz
"""]
-add_font("Half Block 7x7", HalfBlock7x7Font)
-
-
if __name__ == "__main__":
- l = get_all_fonts()
- all_ascii = "".join([chr(x) for x in range(32, 127)])
+ all_ascii = frozenset(chr(x) for x in range(32, 127))
print("Available Fonts: (U) = UTF-8 required")
print("----------------")
- for n,cls in l:
- f = cls()
+ for n, font_cls in get_all_fonts():
+ f = font_cls()
u = ""
if f.utf8_required:
u = "(U)"
print(f"{n:<20} {u:>3} ", end=' ')
- c = f.characters()
- if c == all_ascii:
+ chars = f.characters()
+ font_chars = frozenset(chars)
+ if font_chars == all_ascii:
print("Full ASCII")
- elif c.startswith(all_ascii):
- print(f"Full ASCII + {c[len(all_ascii):]}")
+ elif font_chars & all_ascii == all_ascii:
+ print(f"Full ASCII + {''.join(font_chars^all_ascii)!r}")
else:
- print(f"Characters: {c}")
+ print(f"Characters: {chars!r}")
diff --git a/urwid/old_str_util.py b/urwid/old_str_util.py
index 28a712d..1a1c709 100755
--- a/urwid/old_str_util.py
+++ b/urwid/old_str_util.py
@@ -82,7 +82,7 @@ widths: list[tuple[int, Literal[0, 1, 2]]] = [
# ACCESSOR FUNCTIONS
-def get_width(o) -> Literal[0, 1, 2]:
+def get_width(o: int) -> Literal[0, 1, 2]:
"""Return the screen column width for unicode ordinal o."""
global widths
if o == 0xe or o == 0xf: