diff options
author | Alexey Stepanov <penguinolog@users.noreply.github.com> | 2023-04-04 13:31:23 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-04-04 13:31:23 +0200 |
commit | 3cfa240252ab1efc74772ae45d8c6efe0b4acb39 (patch) | |
tree | 057956da0f02c903685e75efc2d294e67a9f748a | |
parent | 2d27ffc2bebc6436fc38ee3f1b3828635dc976a3 (diff) | |
download | urwid-3cfa240252ab1efc74772ae45d8c6efe0b4acb39.tar.gz |
Annotate types in simple cases and use isinstance (& protocol) based type checking (#529)
* Use `super()` where possible instead of direct base class
Related #525
Partial #406
Fix #510
Co-authored-by: Aleksei Stepanov <alekseis@nvidia.com>
-rw-r--r-- | urwid/__init__.py | 53 | ||||
-rw-r--r-- | urwid/canvas.py | 308 | ||||
-rwxr-xr-x | urwid/container.py | 420 | ||||
-rwxr-xr-x | urwid/curses_display.py | 84 | ||||
-rwxr-xr-x | urwid/decoration.py | 404 | ||||
-rwxr-xr-x | urwid/display_common.py | 3 | ||||
-rw-r--r-- | urwid/escape.py | 109 | ||||
-rwxr-xr-x | urwid/font.py | 17 | ||||
-rwxr-xr-x | urwid/graphics.py | 96 | ||||
-rwxr-xr-x | urwid/html_fragment.py | 8 | ||||
-rw-r--r-- | urwid/listbox.py | 314 | ||||
-rwxr-xr-x | urwid/main_loop.py | 5 | ||||
-rwxr-xr-x | urwid/monitored_list.py | 55 | ||||
-rw-r--r-- | urwid/numedit.py | 53 | ||||
-rwxr-xr-x | urwid/old_str_util.py | 67 | ||||
-rw-r--r-- | urwid/raw_display.py | 1 | ||||
-rw-r--r-- | urwid/signals.py | 17 | ||||
-rw-r--r-- | urwid/text_layout.py | 232 | ||||
-rw-r--r-- | urwid/treetools.py | 2 | ||||
-rw-r--r-- | urwid/util.py | 60 | ||||
-rw-r--r-- | urwid/vterm.py | 4 | ||||
-rw-r--r-- | urwid/widget.py | 198 |
22 files changed, 1392 insertions, 1118 deletions
diff --git a/urwid/__init__.py b/urwid/__init__.py index 99eaab5..e28b9a8 100644 --- a/urwid/__init__.py +++ b/urwid/__init__.py @@ -97,31 +97,10 @@ from urwid.graphics import ( ProgressBar, scale_bar_values, ) -from urwid.listbox import ( - ListBox, - ListBoxError, - ListWalker, - ListWalkerError, - SimpleFocusListWalker, - SimpleListWalker, -) -from urwid.main_loop import ( - AsyncioEventLoop, - ExitMainLoop, - GLibEventLoop, - MainLoop, - SelectEventLoop, - TornadoEventLoop, -) +from urwid.listbox import ListBox, ListBoxError, ListWalker, ListWalkerError, SimpleFocusListWalker, SimpleListWalker +from urwid.main_loop import AsyncioEventLoop, ExitMainLoop, GLibEventLoop, MainLoop, SelectEventLoop, TornadoEventLoop from urwid.monitored_list import MonitoredFocusList, MonitoredList -from urwid.signals import ( - MetaSignals, - Signals, - connect_signal, - disconnect_signal, - emit_signal, - register_signal, -) +from urwid.signals import MetaSignals, Signals, connect_signal, disconnect_signal, emit_signal, register_signal from urwid.version import VERSION, __version__ from urwid.widget import ( ANY, @@ -160,15 +139,7 @@ from urwid.widget import ( delegate_to_widget_mixin, fixed_size, ) -from urwid.wimp import ( - Button, - CheckBox, - CheckBoxError, - PopUpLauncher, - PopUpTarget, - RadioButton, - SelectableIcon, -) +from urwid.wimp import Button, CheckBox, CheckBoxError, PopUpLauncher, PopUpTarget, RadioButton, SelectableIcon try: from urwid.main_loop import TwistedEventLoop @@ -204,20 +175,8 @@ from urwid.display_common import ( RealTerminal, ScreenError, ) -from urwid.text_layout import ( - LayoutSegment, - StandardTextLayout, - TextLayout, - default_layout, -) -from urwid.treetools import ( - ParentNode, - TreeListBox, - TreeNode, - TreeWalker, - TreeWidget, - TreeWidgetError, -) +from urwid.text_layout import LayoutSegment, StandardTextLayout, TextLayout, default_layout +from urwid.treetools import ParentNode, TreeListBox, TreeNode, TreeWalker, TreeWidget, TreeWidgetError from urwid.util import ( MetaSuper, TagMarkupException, diff --git a/urwid/canvas.py b/urwid/canvas.py index 1f4789e..b1f7c9c 100644 --- a/urwid/canvas.py +++ b/urwid/canvas.py @@ -22,7 +22,9 @@ from __future__ import annotations +import typing import weakref +from collections.abc import Sequence from urwid.text_layout import LayoutSegment, trim_line from urwid.util import ( @@ -36,6 +38,9 @@ from urwid.util import ( trim_text_attr_cs, ) +if typing.TYPE_CHECKING: + from .widget import Widget + class CanvasCache: """ @@ -185,6 +190,7 @@ class CanvasCache: class CanvasError(Exception): pass + class Canvas: """ base class for canvases @@ -202,7 +208,12 @@ class Canvas: "lists, please see the TextCanvas class for the new " "representation of canvas content.") - def __init__(self, value1=None, value2=None, value3=None): + def __init__( + self, + value1: typing.Any = None, + value2: typing.Any = None, + value3: typing.Any = None, + ) -> None: """ value1, value2, value3 -- if not None, raise a helpful error: the old Canvas class is now called TextCanvas. @@ -233,7 +244,7 @@ class Canvas: return self._widget_info widget_info = property(_get_widget_info) - def _raise_old_repr_error(self, val=None): + def _raise_old_repr_error(self, val=None) -> typing.NoReturn: raise self._old_repr_error def _text_content(self): @@ -248,8 +259,14 @@ class Canvas: attr = property(_raise_old_repr_error, _raise_old_repr_error) cs = property(_raise_old_repr_error, _raise_old_repr_error) - def content(self, trim_left=0, trim_top=0, cols=None, rows=None, - attr=None): + def content( + self, + trim_left: int = 0, + trim_top: int = 0, + cols: int | None = None, + rows: int | None = None, + attr=None, + ): raise NotImplementedError() def cols(self): @@ -261,11 +278,12 @@ class Canvas: def content_delta(self): raise NotImplementedError() - def get_cursor(self): + def get_cursor(self) -> tuple[int, int] | None: c = self.coords.get("cursor", None) if not c: - return - return c[:2] # trim off data part + return None + return c[:2] # trim off data part + def set_cursor(self, c): if self.widget_info and self.cacheable: raise self._finalized_error @@ -275,7 +293,7 @@ class Canvas: except KeyError: pass return - self.coords["cursor"] = c + (None,) # data part + self.coords["cursor"] = c + (None,) # data part cursor = property(get_cursor, set_cursor) def get_pop_up(self): @@ -283,7 +301,8 @@ class Canvas: if not c: return return c - def set_pop_up(self, w, left, top, overlay_width, overlay_height): + + def set_pop_up(self, w: Widget, left: int, top: int, overlay_width: int, overlay_height: int): """ This method adds pop-up information to the canvas. This information is intercepted by a PopUpTarget widget higher in the chain to @@ -304,10 +323,9 @@ class Canvas: if self.widget_info and self.cacheable: raise self._finalized_error - self.coords["pop up"] = (left, top, ( - w, overlay_width, overlay_height)) + self.coords["pop up"] = (left, top, (w, overlay_width, overlay_height)) - def translate_coords(self, dx, dy): + def translate_coords(self, dx: int, dy: int): """ Return coords shifted by (dx, dy). """ @@ -317,13 +335,19 @@ class Canvas: return d - class TextCanvas(Canvas): """ class for storing rendered text and attributes """ - def __init__(self, text=None, attr=None, cs=None, - cursor=None, maxcol=None, check_width=True): + def __init__( + self, + text: Sequence[bytes] | None = None, + attr=None, + cs=None, + cursor: tuple[int, int] | None = None, + maxcol: int | None = None, + check_width: bool = True, + ) -> None: """ text -- list of strings, one for each line attr -- list of run length encoded attributes for text @@ -332,18 +356,18 @@ class TextCanvas(Canvas): maxcol -- screen columns taken by this canvas check_width -- check and fix width of all lines in text """ - Canvas.__init__(self) + super().__init__() if text is None: text = [] if check_width: widths = [] for t in text: - if type(t) != bytes: + if not isinstance(t, bytes): raise CanvasError("Canvas text must be plain strings encoded in the screen's encoding", repr(text)) - widths.append( calc_width( t, 0, len(t)) ) + widths.append(calc_width(t, 0, len(t))) else: - assert type(maxcol) == int + assert isinstance(maxcol, int) widths = [maxcol] * len(text) if maxcol is None: @@ -354,9 +378,9 @@ class TextCanvas(Canvas): maxcol = 0 if attr is None: - attr = [[] for x in range(len(text))] + attr = [[]] * len(text) if cs is None: - cs = [[] for x in range(len(text))] + cs = [[]] * len(text) # pad text and attr to maxcol for i in range(len(text)): @@ -364,7 +388,7 @@ class TextCanvas(Canvas): if w > maxcol: raise CanvasError(f"Canvas text is wider than the maxcol specified \n{maxcol!r}\n{widths!r}\n{text!r}") if w < maxcol: - text[i] = text[i] + b''.rjust(maxcol-w) + text[i] += b''.rjust(maxcol - w) a_gap = len(text[i]) - rle_len( attr[i] ) if a_gap < 0: raise CanvasError(f"Attribute extends beyond text \n{text[i]!r}\n{attr[i]!r}" ) @@ -383,28 +407,34 @@ class TextCanvas(Canvas): self._text = text self._maxcol = maxcol - def rows(self): + def rows(self) -> int: """Return the number of rows in this canvas.""" rows = len(self._text) assert isinstance(rows, int) return rows - def cols(self): + def cols(self) -> int: """Return the screen column width of this canvas.""" return self._maxcol - def translated_coords(self,dx,dy): + def translated_coords(self, dx: int, dy: int) -> tuple[int, int] | None: """ Return cursor coords shifted by (dx, dy), or None if there is no cursor. """ if self.cursor: x, y = self.cursor - return x+dx, y+dy + return x + dx, y + dy return None - def content(self, trim_left=0, trim_top=0, cols=None, rows=None, - attr_map=None): + def content( + self, + trim_left: int = 0, + trim_top: int = 0, + cols: int = 0, + rows: int = 0, + attr_map=None, + ): """ Return the canvas content as a list of rows where each row is a list of (attr, cs, text) tuples. @@ -419,9 +449,9 @@ class TextCanvas(Canvas): if not rows: rows = maxrow - trim_top - assert trim_left >= 0 and trim_left < maxcol + assert 0 <= trim_left < maxcol assert cols > 0 and trim_left + cols <= maxcol - assert trim_top >=0 and trim_top < maxrow + assert 0 <= trim_top < maxrow assert rows > 0 and trim_top + rows <= maxrow if trim_top or rows < maxrow: @@ -447,7 +477,6 @@ class TextCanvas(Canvas): i += run yield row - def content_delta(self, other): """ Return the differences between other and this canvas. @@ -457,20 +486,26 @@ class TextCanvas(Canvas): content(). """ if other is self: - return [self.cols()]*self.rows() + return [self.cols()] * self.rows() return self.content() - class BlankCanvas(Canvas): """ a canvas with nothing on it, only works as part of a composite canvas since it doesn't know its own size """ - def __init__(self): - Canvas.__init__(self, None) - - def content(self, trim_left, trim_top, cols, rows, attr): + def __init__(self) -> None: + super().__init__(None) + + def content( + self, + trim_left: int = 0, + trim_top: int = 0, + cols: int = 0, + rows: int = 0, + attr=None, + ): """ return (cols, rows) of spaces with default attributes. """ @@ -478,18 +513,19 @@ class BlankCanvas(Canvas): if attr and None in attr: def_attr = attr[None] line = [(def_attr, None, b''.rjust(cols))] - for i in range(rows): + for _ in range(rows): yield line - def cols(self): + def cols(self) -> typing.NoReturn: raise NotImplementedError("BlankCanvas doesn't know its own size!") - def rows(self): + def rows(self) -> typing.NoReturn: raise NotImplementedError("BlankCanvas doesn't know its own size!") - def content_delta(self): + def content_delta(self) -> typing.NoReturn: raise NotImplementedError("BlankCanvas doesn't know its own size!") + blank_canvas = BlankCanvas() @@ -497,8 +533,8 @@ class SolidCanvas(Canvas): """ A canvas filled completely with a single character. """ - def __init__(self, fill_char, cols, rows): - Canvas.__init__(self) + def __init__(self, fill_char, cols: int, rows: int) -> None: + super().__init__() end, col = calc_text_pos(fill_char, 0, len(fill_char), 1) assert col == 1, f"Invalid fill_char: {fill_char!r}" self._text, cs = apply_target_encoding(fill_char[:end]) @@ -506,14 +542,20 @@ class SolidCanvas(Canvas): self.size = cols, rows self.cursor = None - def cols(self): + def cols(self) -> int: return self.size[0] - def rows(self): + def rows(self) -> int: return self.size[1] - def content(self, trim_left=0, trim_top=0, cols=None, rows=None, - attr=None): + def content( + self, + trim_left: int = 0, + trim_top: int = 0, + cols: int | None = None, + rows: int | None = None, + attr=None, + ): if cols is None: cols = self.size[0] if rows is None: @@ -523,7 +565,7 @@ class SolidCanvas(Canvas): def_attr = attr[None] line = [(def_attr, self._cs, self._text*cols)] - for i in range(rows): + for _ in range(rows): yield line def content_delta(self, other): @@ -535,13 +577,11 @@ class SolidCanvas(Canvas): return self.content() - - class CompositeCanvas(Canvas): """ class for storing a combination of canvases """ - def __init__(self, canv=None): + def __init__(self, canv: Canvas = None) -> None: """ canv -- a Canvas object to wrap this CompositeCanvas around. @@ -558,7 +598,7 @@ class CompositeCanvas(Canvas): # tuples that define the unfinished cviews that are part of # shards following the first shard. - Canvas.__init__(self) + super().__init__() if canv is None: self.shards = [] @@ -567,32 +607,29 @@ class CompositeCanvas(Canvas): if hasattr(canv, "shards"): self.shards = canv.shards else: - self.shards = [(canv.rows(), [ - (0, 0, canv.cols(), canv.rows(), - None, canv)])] + self.shards = [(canv.rows(), [(0, 0, canv.cols(), canv.rows(), None, canv)])] self.children = [(0, 0, canv, None)] self.coords.update(canv.coords) for shortcut in canv.shortcuts: self.shortcuts[shortcut] = "wrap" - def rows(self): - for r,cv in self.shards: + def rows(self) -> int: + for r, cv in self.shards: try: assert isinstance(r, int) except AssertionError: raise AssertionError(r, cv) - rows = sum([r for r,cv in self.shards]) + rows = sum([r for r, cv in self.shards]) assert isinstance(rows, int) return rows - def cols(self): + def cols(self) -> int: if not self.shards: return 0 cols = sum([cv[2] for cv in self.shards[0][1]]) assert isinstance(cols, int) return cols - def content(self): """ Return the canvas content as a list of rows where each row @@ -604,14 +641,12 @@ class CompositeCanvas(Canvas): sbody = shard_body(cviews, shard_tail) # output rows - for i in range(num_rows): + for _ in range(num_rows): yield shard_body_row(sbody) # prepare next shard tail shard_tail = shard_body_tail(num_rows, sbody) - - def content_delta(self, other): """ Return the differences between other and this canvas. @@ -622,14 +657,13 @@ class CompositeCanvas(Canvas): return shard_tail = [] - for num_rows, cviews in shards_delta( - self.shards, other.shards): + for num_rows, cviews in shards_delta(self.shards, other.shards): # combine shard and shard tail sbody = shard_body(cviews, shard_tail) # output rows row = [] - for i in range(num_rows): + for _ in range(num_rows): # if whole shard is unchanged, don't keep # calling shard_body_row if len(row) != 1 or type(row[0]) != int: @@ -639,16 +673,14 @@ class CompositeCanvas(Canvas): # prepare next shard tail shard_tail = shard_body_tail(num_rows, sbody) - - def trim(self, top, count=None): + def trim(self, top: int, count: int | None = None) -> None: """Trim lines from the top and/or bottom of canvas. top -- number of lines to remove from top count -- number of lines to keep, or None for all the rest """ - assert top >= 0, "invalid trim amount %d!"%top - assert top < self.rows(), "cannot trim %d lines from %d!"%( - top, self.rows()) + assert top >= 0, f"invalid trim amount {top:d}!" + assert top < self.rows(), f"cannot trim {top:d} lines from {self.rows():d}!" if self.widget_info: raise self._finalized_error @@ -662,22 +694,19 @@ class CompositeCanvas(Canvas): self.coords = self.translate_coords(0, -top) - - def trim_end(self, end): + def trim_end(self, end: int) -> None: """Trim lines from the bottom of the canvas. end -- number of lines to remove from the end """ - assert end > 0, "invalid trim amount %d!"%end - assert end <= self.rows(), "cannot trim %d lines from %d!"%( - end, self.rows()) + assert end > 0, f"invalid trim amount {end:d}!" + assert end <= self.rows(), f"cannot trim {end:d} lines from {self.rows():d}!" if self.widget_info: raise self._finalized_error self.shards = shards_trim_rows(self.shards, self.rows() - end) - - def pad_trim_left_right(self, left, right): + def pad_trim_left_right(self, left: int, right: int) -> None: """ Pad or trim this canvas on the left and right @@ -696,22 +725,18 @@ class CompositeCanvas(Canvas): if left > 0 or right > 0: top_rows, top_cviews = shards[0] if left > 0: - new_top_cviews = ( - [(0,0,left,rows,None,blank_canvas)] + - top_cviews) + new_top_cviews = ([(0, 0, left, rows, None, blank_canvas)] + top_cviews) else: - new_top_cviews = top_cviews[:] #copy + new_top_cviews = top_cviews[:] #copy if right > 0: - new_top_cviews.append( - (0,0,right,rows,None,blank_canvas)) + new_top_cviews.append((0, 0, right, rows, None, blank_canvas)) shards = [(top_rows, new_top_cviews)] + shards[1:] self.coords = self.translate_coords(left, 0) self.shards = shards - - def pad_trim_top_bottom(self, top, bottom): + def pad_trim_top_bottom(self, top: int, bottom: int) -> None: """ Pad or trim this canvas on the top and bottom. """ @@ -726,19 +751,15 @@ class CompositeCanvas(Canvas): cols = self.cols() if top > 0: - self.shards = [(top, - [(0,0,cols,top,None,blank_canvas)])] + \ - self.shards + self.shards = [(top, [(0, 0, cols, top, None, blank_canvas)])] + self.shards self.coords = self.translate_coords(0, top) if bottom > 0: if orig_shards is self.shards: self.shards = self.shards[:] - self.shards.append((bottom, - [(0,0,cols,bottom,None,blank_canvas)])) - + self.shards.append((bottom, [(0, 0, cols, bottom, None, blank_canvas)])) - def overlay(self, other, left, top ): + def overlay(self, other, left: int, top: int) -> None: """Overlay other onto this canvas.""" if self.widget_info: raise self._finalized_error @@ -767,14 +788,12 @@ class CompositeCanvas(Canvas): if left > 0: left_shards = [shards_trim_sides(side_shards, 0, left)] if right > 0: - right_shards = [shards_trim_sides(side_shards, - max(0, left + width), right)] + right_shards = [shards_trim_sides(side_shards, max(0, left + width), right)] if not self.rows(): middle_shards = [] elif left or right: - middle_shards = shards_join(left_shards + - [other.shards] + right_shards) + middle_shards = shards_join(left_shards + [other.shards] + right_shards) else: middle_shards = other.shards @@ -782,15 +801,14 @@ class CompositeCanvas(Canvas): self.coords.update(other.translate_coords(left, top)) - - def fill_attr(self, a): + def fill_attr(self, a) -> None: """ Apply attribute a to all areas of this canvas with default attribute currently set to None, leaving other attributes intact.""" self.fill_attr_apply({None:a}) - def fill_attr_apply(self, mapping): + def fill_attr_apply(self, mapping) -> None: """ Apply an attribute-mapping dictionary to the canvas. @@ -805,14 +823,11 @@ class CompositeCanvas(Canvas): for cv in original_cviews: # cv[4] == attr_map if cv[4] is None: - new_cviews.append(cv[:4] + - (mapping,) + cv[5:]) + new_cviews.append(cv[:4] + (mapping,) + cv[5:]) else: combined = dict(mapping) - combined.update([ - (k, mapping.get(v, v)) for k,v in cv[4].items()]) - new_cviews.append(cv[:4] + - (combined,) + cv[5:]) + combined.update([(k, mapping.get(v, v)) for k,v in cv[4].items()]) + new_cviews.append(cv[:4] + (combined,) + cv[5:]) shards.append((num_rows, new_cviews)) self.shards = shards @@ -848,7 +863,7 @@ def shard_body_row(sbody): return row -def shard_body_tail(num_rows, sbody): +def shard_body_tail(num_rows: int, sbody): """ Return a new shard tail that follows this shard body. """ @@ -890,6 +905,7 @@ def shards_delta(shards, other_shards): other_num_rows = None done += num_rows + def shard_cviews_delta(cviews, other_cviews): """ """ @@ -916,8 +932,7 @@ def shard_cviews_delta(cviews, other_cviews): cols += cv[2] - -def shard_body(cviews, shard_tail, create_iter=True, iter_default=None): +def shard_body(cviews, shard_tail, create_iter: bool = True, iter_default=None): """ Return a list of (done_rows, content_iter, cview) tuples for this shard and shard tail. @@ -930,7 +945,7 @@ def shard_body(cviews, shard_tail, create_iter=True, iter_default=None): is created. """ col = 0 - body = [] # build the next shard tail + body = [] # build the next shard tail cviews_iter = iter(cviews) for col_gap, done_rows, content_iter, tail_cview in shard_tail: while col_gap: @@ -938,13 +953,11 @@ def shard_body(cviews, shard_tail, create_iter=True, iter_default=None): cview = next(cviews_iter) except StopIteration: break - (trim_left, trim_top, cols, rows, attr_map, canv) = \ - cview[:6] + (trim_left, trim_top, cols, rows, attr_map, canv) = cview[:6] col += cols col_gap -= cols if col_gap < 0: - raise CanvasError("cviews overflow gaps in" - " shard_tail!") + raise CanvasError("cviews overflow gaps in shard_tail!") if create_iter and canv: new_iter = canv.content(trim_left, trim_top, cols, rows, attr_map) @@ -953,18 +966,16 @@ def shard_body(cviews, shard_tail, create_iter=True, iter_default=None): body.append((0, new_iter, cview)) body.append((done_rows, content_iter, tail_cview)) for cview in cviews_iter: - (trim_left, trim_top, cols, rows, attr_map, canv) = \ - cview[:6] + (trim_left, trim_top, cols, rows, attr_map, canv) = cview[:6] if create_iter and canv: - new_iter = canv.content(trim_left, trim_top, cols, rows, - attr_map) + new_iter = canv.content(trim_left, trim_top, cols, rows, attr_map) else: new_iter = iter_default body.append((0, new_iter, cview)) return body -def shards_trim_top(shards, top): +def shards_trim_top(shards, top: int): """ Return shards with top rows removed. """ @@ -987,19 +998,18 @@ def shards_trim_top(shards, top): # trim the top of this shard new_sbody = [] for done_rows, content_iter, cv in sbody: - new_sbody.append((0, content_iter, - cview_trim_top(cv, done_rows+top))) + new_sbody.append((0, content_iter, cview_trim_top(cv, done_rows + top))) sbody = new_sbody - new_shards = [(num_rows-top, - [cv for done_rows, content_iter, cv in sbody])] + new_shards = [(num_rows - top, [cv for done_rows, content_iter, cv in sbody])] # write out the rest of the shards new_shards.extend(shard_iter) return new_shards -def shards_trim_rows(shards, keep_rows): + +def shards_trim_rows(shards, keep_rows: int): """ Return the topmost keep_rows rows from shards. """ @@ -1026,7 +1036,8 @@ def shards_trim_rows(shards, keep_rows): return new_shards -def shards_trim_sides(shards, left, cols): + +def shards_trim_sides(shards, left: int, cols: int): """ Return shards with starting from column left and cols total width. """ @@ -1059,6 +1070,7 @@ def shards_trim_sides(shards, left, cols): new_shards.append((num_rows, new_cviews)) return new_shards + def shards_join(shard_lists): """ Return the result of joining shard lists horizontally. @@ -1092,19 +1104,20 @@ def shards_join(shard_lists): return new_shards -def cview_trim_rows(cv, rows): +def cview_trim_rows(cv, rows: int): return cv[:3] + (rows,) + cv[4:] -def cview_trim_top(cv, trim): + +def cview_trim_top(cv, trim: int): return (cv[0], trim + cv[1], cv[2], cv[3] - trim) + cv[4:] -def cview_trim_left(cv, trim): - return (cv[0] + trim, cv[1], cv[2] - trim,) + cv[3:] -def cview_trim_cols(cv, cols): - return cv[:2] + (cols,) + cv[3:] +def cview_trim_left(cv, trim: int): + return (cv[0] + trim, cv[1], cv[2] - trim,) + cv[3:] +def cview_trim_cols(cv, cols: int): + return cv[:2] + (cols,) + cv[3:] def CanvasCombine(l): @@ -1119,7 +1132,7 @@ def CanvasCombine(l): True if this canvas is the one that would be in focus if the whole widget is in focus """ - clist = [(CompositeCanvas(c),p,f) for c,p,f in l] + clist = [(CompositeCanvas(c), p, f) for c, p, f in l] combined_canvas = CompositeCanvas() shards = [] @@ -1139,25 +1152,23 @@ def CanvasCombine(l): n += 1 if focus_index: - children = [children[focus_index]] + children[:focus_index] + \ - children[focus_index+1:] + children = [children[focus_index]] + children[:focus_index] + children[focus_index+1:] combined_canvas.shards = shards combined_canvas.children = children return combined_canvas -def CanvasOverlay(top_c, bottom_c, left, top): +def CanvasOverlay(top_c, bottom_c, left: int, top: int): """ Overlay canvas top_c onto bottom_c at position (left, top). """ overlayed_canvas = CompositeCanvas(bottom_c) overlayed_canvas.overlay(top_c, left, top) - overlayed_canvas.children = [(left, top, top_c, None), - (0, 0, bottom_c, None)] + overlayed_canvas.children = [(left, top, top_c, None), (0, 0, bottom_c, None)] overlayed_canvas.shortcuts = {} # disable background shortcuts for shortcut in top_c.shortcuts.keys(): - overlayed_canvas.shortcuts[shortcut]="fg" + overlayed_canvas.shortcuts[shortcut] = "fg" return overlayed_canvas @@ -1218,18 +1229,19 @@ def CanvasJoin(l): return joined_canvas -def apply_text_layout(text, attr, ls, maxcol): +def apply_text_layout(text, attr, ls, maxcol: int): t = [] a = [] c = [] class AttrWalk: pass + aw = AttrWalk - aw.k = 0 # counter for moving through elements of a - aw.off = 0 # current offset into text of attr[ak] + aw.k = 0 # counter for moving through elements of a + aw.off = 0 # current offset into text of attr[ak] - def arange( start_offs, end_offs ): + def arange(start_offs: int, end_offs: int): """Return an attribute list for the range of text specified.""" if start_offs < aw.off: aw.k = 0 @@ -1237,7 +1249,7 @@ def apply_text_layout(text, attr, ls, maxcol): o = [] # the loop should run at least once, the '=' part ensures that while aw.off <= end_offs: - if len(attr)<=aw.k: + if len(attr) <= aw.k: # run out of attributes o.append((None,end_offs-max(start_offs,aw.off))) break @@ -1255,26 +1267,25 @@ def apply_text_layout(text, attr, ls, maxcol): aw.off += run return o - for line_layout in ls: # trim the line to fit within maxcol - line_layout = trim_line( line_layout, text, 0, maxcol ) + line_layout = trim_line(line_layout, text, 0, maxcol) line = [] linea = [] linec = [] - def attrrange( start_offs, end_offs, destw ): + def attrrange(start_offs: int, end_offs: int, destw: int) -> None: """ Add attributes based on attributes between start_offs and end_offs. """ if start_offs == end_offs: - [(at,run)] = arange(start_offs,end_offs) + [(at,run)] = arange(start_offs, end_offs) rle_append_modify( linea, ( at, destw )) return if destw == end_offs-start_offs: - for at, run in arange(start_offs,end_offs): + for at, run in arange(start_offs, end_offs): rle_append_modify( linea, ( at, run )) return # encoded version has different width @@ -1291,7 +1302,6 @@ def apply_text_layout(text, attr, ls, maxcol): o += run destw -= segw - for seg in line_layout: #if seg is None: assert 0, ls s = LayoutSegment(seg) diff --git a/urwid/container.py b/urwid/container.py index 889c92e..376e8e5 100755 --- a/urwid/container.py +++ b/urwid/container.py @@ -22,15 +22,11 @@ from __future__ import annotations +import typing +from collections.abc import Iterable, Sequence from itertools import chain, repeat -from urwid.canvas import ( - CanvasCombine, - CanvasJoin, - CanvasOverlay, - CompositeCanvas, - SolidCanvas, -) +from urwid.canvas import CanvasCombine, CanvasJoin, CanvasOverlay, CompositeCanvas, SolidCanvas from urwid.decoration import ( Filler, Padding, @@ -66,6 +62,9 @@ from urwid.widget import ( WidgetWrap, ) +if typing.TYPE_CHECKING: + from typing_extensions import Literal + class WidgetContainerMixin: """ @@ -119,7 +118,7 @@ class WidgetContainerMixin: w.focus_position = p # modifies w.focus w = w.focus.base_widget - def get_focus_widgets(self): + def get_focus_widgets(self) -> list[Widget]: """ Return the .focus values starting from this container and proceeding along each child widget until reaching a leaf @@ -137,6 +136,7 @@ class WidgetContainerMixin: return out out.append(w) + class WidgetContainerListContentsMixin: """ Mixin class for widget containers whose positions are indexes into @@ -160,6 +160,7 @@ class WidgetContainerListContentsMixin: class GridFlowError(Exception): pass + class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixin): """ The GridFlow widget is a flow widget that renders all the widgets it @@ -169,7 +170,14 @@ class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixi def sizing(self): return frozenset([FLOW]) - def __init__(self, cells, cell_width, h_sep, v_sep, align): + def __init__( + self, + cells: Sequence[Widget], + cell_width: int, + h_sep: int, + v_sep: int, + align: Literal['left', 'center', 'right'] | tuple[Literal['relative'], int], + ): """ :param cells: list of flow widgets to display :param cell_width: column width for each cell @@ -179,8 +187,7 @@ class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixi :param align: horizontal alignment of cells, one of: 'left', 'center', 'right', ('relative', percentage 0=left 100=right) """ - self._contents = MonitoredFocusList([ - (w, (GIVEN, cell_width)) for w in cells]) + self._contents = MonitoredFocusList([(w, (GIVEN, cell_width)) for w in cells]) self._contents.set_modified_callback(self._invalidate) self._contents.set_focus_changed_callback(lambda f: self._invalidate()) self._contents.set_validate_contents_modified(self._contents_modified) @@ -193,7 +200,7 @@ class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixi # set self._w to something other than None self.get_display_widget(((h_sep+cell_width)*len(cells),)) - def _invalidate(self): + def _invalidate(self) -> None: self._cache_maxcol = None super()._invalidate() @@ -212,6 +219,7 @@ class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixi self._set_cells(ml) ml.set_modified_callback(user_modified) return ml + def _set_cells(self, widgets): focus_position = self.focus_position self.contents = [ @@ -226,9 +234,10 @@ class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixi contents. """) - def _get_cell_width(self): + def _get_cell_width(self) -> int: return self._cell_width - def _set_cell_width(self, width): + + def _set_cell_width(self, width: int) -> None: focus_position = self.focus_position self.contents = [ (w, (GIVEN, width)) for (w, options) in self.contents] @@ -241,6 +250,7 @@ class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixi def _get_contents(self): return self._contents + def _set_contents(self, c): self._contents[:] = c contents = property(_get_contents, _set_contents, doc=""" @@ -257,7 +267,11 @@ class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixi .. seealso:: Create new options tuples with the :meth:`options` method. """) - def options(self, width_type=GIVEN, width_amount=None): + def options( + self, + width_type: Literal['given'] = GIVEN, + width_amount: int | None = None, + ) -> tuple[Literal['given'], int]: """ Return a new options tuple for use in a GridFlow's .contents list. @@ -270,7 +284,7 @@ class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixi width_amount = self._cell_width return (width_type, width_amount) - def set_focus(self, cell): + def set_focus(self, cell: Widget | int): """ Set the cell in focus, for backwards compatibility. @@ -297,7 +311,7 @@ class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixi focus = property(get_focus, doc="the child widget in focus or None when GridFlow is empty") - def _set_focus_cell(self, cell): + def _set_focus_cell(self, cell: Widget) -> None: for i, (w, options) in enumerate(self.contents): if cell == w: self.focus_position = i @@ -312,7 +326,7 @@ class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixi index. """) - def _get_focus_position(self): + def _get_focus_position(self) -> int | None: """ Return the index of the widget in focus or None if this GridFlow is empty. @@ -320,7 +334,8 @@ class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixi if not self.contents: raise IndexError("No focus_position, GridFlow is empty") return self.contents.focus - def _set_focus_position(self, position): + + def _set_focus_position(self, position: int) -> None: """ Set the widget in focus. @@ -337,7 +352,7 @@ class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixi GridFlow is empty, or when set to an invalid index. """) - def get_display_widget(self, size): + def get_display_widget(self, size: tuple[int]) -> Divider | Pile: """ Arrange the cells into columns (and possibly a pile) for display, input or to calculate rows, and update the display @@ -353,7 +368,7 @@ class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixi return self._w - def generate_display_widget(self, size): + def generate_display_widget(self, size: tuple[int]) -> Divider | Pile: """ Actually generate display widget (ignoring cache) """ @@ -407,7 +422,7 @@ class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixi return p - def _set_focus_from_display_widget(self): + def _set_focus_from_display_widget(self) -> None: """ Set the focus to the item in focus in the display widget. """ @@ -431,8 +446,7 @@ class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixi # pad.first_position was set by generate_display_widget() above self.focus_position = pile_focus.first_position + col_focus_position - - def keypress(self, size, key): + def keypress(self, size: tuple[int], key: str) -> str | None: """ Pass keypress to display widget for handling. Captures focus changes. @@ -443,42 +457,42 @@ class GridFlow(WidgetWrap, WidgetContainerMixin, WidgetContainerListContentsMixi self._set_focus_from_display_widget() return key - def rows(self, size, focus=False): + def rows(self, size: tuple[int], focus: bool = False) -> int: self.get_display_widget(size) return super().rows(size, focus=focus) - def render(self, size, focus=False ): + def render(self, size: tuple[int], focus: bool = False): self.get_display_widget(size) return super().render(size, focus) - def get_cursor_coords(self, size): + def get_cursor_coords(self, size: tuple[int]) -> tuple[int, int]: """Get cursor from display widget.""" self.get_display_widget(size) return super().get_cursor_coords(size) - def move_cursor_to_coords(self, size, col, row): + def move_cursor_to_coords(self, size: tuple[int], col: int, row: int): """Set the widget in focus based on the col + row.""" self.get_display_widget(size) rval = super().move_cursor_to_coords(size, col, row) self._set_focus_from_display_widget() return rval - def mouse_event(self, size, event, button, col, row, focus): + def mouse_event(self, size: tuple[int], event, button: int, col: int, row: int, focus: bool) -> Literal[True]: self.get_display_widget(size) super().mouse_event(size, event, button, col, row, focus) self._set_focus_from_display_widget() - return True # at a minimum we adjusted our focus + return True # at a minimum we adjusted our focus - def get_pref_col(self, size): + def get_pref_col(self, size: tuple[int]): """Return pref col from display widget.""" self.get_display_widget(size) return super().get_pref_col(size) - class OverlayError(Exception): pass + class Overlay(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): """ Overlay contains two box widgets and renders one on top of the other @@ -490,8 +504,21 @@ class Overlay(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): LEFT, None, RELATIVE, 100, None, 0, 0, TOP, None, RELATIVE, 100, None, 0, 0) - def __init__(self, top_w, bottom_w, align, width, valign, height, - min_width=None, min_height=None, left=0, right=0, top=0, bottom=0): + def __init__( + self, + top_w: Widget, + bottom_w: Widget, + align: Literal['left', 'center', 'right'] | tuple[Literal['relative'], int], + width: Literal['pack'] | int | tuple[Literal['relative'], int], + valign: Literal['top', 'middle', 'bottom'] | tuple[Literal['relative'], int], + height: Literal['pack'] | int | tuple[Literal['relative'], int], + min_width: int | None = None, + min_height: int | None = None, + left: int = 0, + right: int = 0, + top: int = 0, + bottom: int = 0, + ) -> None: """ :param top_w: a flow, box or fixed widget to overlay "on top" :type top_w: Widget @@ -547,9 +574,22 @@ class Overlay(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): min_width, min_height, left, right, top, bottom) @staticmethod - def options(align_type, align_amount, width_type, width_amount, - valign_type, valign_amount, height_type, height_amount, - min_width=None, min_height=None, left=0, right=0, top=0, bottom=0): + def options( + align_type: Literal['left', 'center', 'right', 'relative'], + align_amount: int | None, + width_type, + width_amount, + valign_type: Literal['top', 'middle', 'bottom', 'relative'], + valign_amount: int | None, + height_type, + height_amount, + min_width: int | None = None, + min_height: int | None = None, + left: int = 0, + right: int = 0, + top: int = 0, + bottom: int = 0, + ): """ Return a new options tuple for use in this Overlay's .contents mapping. @@ -563,8 +603,19 @@ class Overlay(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): min_width, left, right, valign_type, valign_amount, height_type, height_amount, min_height, top, bottom) - def set_overlay_parameters(self, align, width, valign, height, - min_width=None, min_height=None, left=0, right=0, top=0, bottom=0): + def set_overlay_parameters( + self, + align: Literal['left', 'center', 'right'] | tuple[Literal['relative'], int], + width: int | None, + valign: Literal['top', 'middle', 'bottom'] | tuple[Literal['relative'], int], + height: int | None, + min_width: int | None = None, + min_height: int | None = None, + left: int = 0, + right: int = 0, + top: int = 0, + bottom: int = 0, + ): """ Adjust the overlay size and position parameters. @@ -601,7 +652,7 @@ class Overlay(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): top = height[1] height = RELATIVE_100 - if width is None: # more obsolete values accepted + if width is None: # more obsolete values accepted width = PACK if height is None: height = PACK @@ -620,16 +671,15 @@ class Overlay(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): valign_type, valign_amount, height_type, height_amount, min_width, min_height, left, right, top, bottom)) - def selectable(self): + def selectable(self) -> bool: """Return selectable from top_w.""" return self.top_w.selectable() - def keypress(self, size, key): + def keypress(self, size: tuple[int, int], key: str) -> str | None: """Pass keypress to top_w.""" - return self.top_w.keypress(self.top_w_size(size, - *self.calculate_padding_filler(size, True)), key) + return self.top_w.keypress(self.top_w_size(size, *self.calculate_padding_filler(size, True)), key) - def _get_focus(self): + def _get_focus(self) -> Widget: """ Currently self.top_w is always the focus of an Overlay """ @@ -637,11 +687,12 @@ class Overlay(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): focus = property(_get_focus, doc="the top widget in this overlay is always in focus") - def _get_focus_position(self): + def _get_focus_position(self) -> Literal[1]: """ Return the top widget position (currently always 1). """ return 1 + def _set_focus_position(self, position): """ Set the widget in focus. Currently only position 0 is accepted. @@ -649,8 +700,7 @@ class Overlay(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): position -- index of child widget to be made focus """ if position != 1: - raise IndexError("Overlay widget focus_position currently " - "must always be set to 1, not %s" % (position,)) + raise IndexError(f"Overlay widget focus_position currently must always be set to 1, not {position}") focus_position = property(_get_focus_position, _set_focus_position, doc="index of child widget in focus, currently always 1") @@ -661,7 +711,8 @@ class Overlay(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): __getitem__ = self._contents__getitem__ __setitem__ = self._contents__setitem__ return OverlayContents() - def _contents__getitem__(self, index): + + def _contents__getitem__(self, index: Literal[0, 1]): if index == 0: return (self.bottom_w, self._DEFAULT_BOTTOM_OPTIONS) if index == 1: @@ -673,15 +724,15 @@ class Overlay(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): self.height_type, self.height_amount, self.min_height, self.top, self.bottom)) raise IndexError(f"Overlay.contents has no position {index!r}") - def _contents__setitem__(self, index, value): + + def _contents__setitem__(self, index: Literal[0, 1], value): try: value_w, value_options = value except (ValueError, TypeError): raise OverlayError(f"added content invalid: {value!r}") if index == 0: if value_options != self._DEFAULT_BOTTOM_OPTIONS: - raise OverlayError("bottom_options must be set to " - "%r" % (self._DEFAULT_BOTTOM_OPTIONS,)) + raise OverlayError(f"bottom_options must be set to {self._DEFAULT_BOTTOM_OPTIONS!r}") self.bottom_w = value_w elif index == 1: try: @@ -739,20 +790,19 @@ class Overlay(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): :exc:`OverlayError`. """) - def get_cursor_coords(self, size): + def get_cursor_coords(self, size: tuple[int, int]) -> tuple[int, int] | None: """Return cursor coords from top_w, if any.""" if not hasattr(self.top_w, 'get_cursor_coords'): return None (maxcol, maxrow) = size - left, right, top, bottom = self.calculate_padding_filler(size, - True) + left, right, top, bottom = self.calculate_padding_filler(size, True) x, y = self.top_w.get_cursor_coords( (maxcol-left-right, maxrow-top-bottom) ) if y >= maxrow: # required?? y = maxrow-1 return x+left, y+top - def calculate_padding_filler(self, size, focus): + def calculate_padding_filler(self, size: tuple[int, int], focus: bool) -> tuple[int, int, int, int]: """Return (padding left, right, filler top, bottom).""" (maxcol, maxrow) = size height = None @@ -779,7 +829,7 @@ class Overlay(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): elif self.height_type == PACK: # top_w is a flow widget height = self.top_w.rows((maxcol,),focus=focus) - top, bottom = calculate_top_bottom_filler(maxrow, + top, bottom = calculate_top_bottom_filler(maxrow, self.valign_type, self.valign_amount, GIVEN, height, None, self.top, self.bottom) if height > maxrow: # flow widget rendered too large @@ -802,11 +852,9 @@ class Overlay(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): return (maxcol-left-right,) return (maxcol-left-right, maxrow-top-bottom) - - def render(self, size, focus=False): + def render(self, size: tuple[int, int], focus: bool = False) -> CompositeCanvas: """Render top_w overlayed on bottom_w.""" - left, right, top, bottom = self.calculate_padding_filler(size, - focus) + left, right, top, bottom = self.calculate_padding_filler(size, focus) bottom_c = self.bottom_w.render(size) if not bottom_c.cols() or not bottom_c.rows(): return CompositeCanvas(bottom_c) @@ -821,17 +869,14 @@ class Overlay(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): return CanvasOverlay(top_c, bottom_c, left, top) - - def mouse_event(self, size, event, button, col, row, focus): + def mouse_event(self, size: tuple[int, int], event, button: int, col: int, row: int, focus: bool) -> bool | None: """Pass event to top_w, ignore if outside of top_w.""" if not hasattr(self.top_w, 'mouse_event'): return False - left, right, top, bottom = self.calculate_padding_filler(size, - focus) + left, right, top, bottom = self.calculate_padding_filler(size, focus) maxcol, maxrow = size - if ( col<left or col>=maxcol-right or - row<top or row>=maxrow-bottom ): + if col<left or col>=maxcol-right or row<top or row>=maxrow-bottom: return False return self.top_w.mouse_event( @@ -842,6 +887,7 @@ class Overlay(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): class FrameError(Exception): pass + class Frame(Widget, WidgetContainerMixin): """ Frame widget is a box widget with optional header and footer @@ -856,7 +902,13 @@ class Frame(Widget, WidgetContainerMixin): _selectable = True _sizing = frozenset([BOX]) - def __init__(self, body, header=None, footer=None, focus_part='body'): + def __init__( + self, + body: Widget, + header: Widget | None = None, + footer: Widget | None = None, + focus_part: Literal['header', 'footer', 'body'] = 'body', + ): """ :param body: a box widget for the body of the frame :type body: Widget @@ -874,32 +926,34 @@ class Frame(Widget, WidgetContainerMixin): self._footer = footer self.focus_part = focus_part - def get_header(self): + def get_header(self) -> Widget | None: return self._header - def set_header(self, header): + + def set_header(self, header: Widget | None): self._header = header if header is None and self.focus_part == 'header': self.focus_part = 'body' self._invalidate() header = property(get_header, set_header) - def get_body(self): + def get_body(self) -> Widget: return self._body - def set_body(self, body): + def set_body(self, body: Widget) -> None: self._body = body self._invalidate() body = property(get_body, set_body) - def get_footer(self): + def get_footer(self) -> Widget | None: return self._footer - def set_footer(self, footer): + + def set_footer(self, footer: Widget | None) -> None: self._footer = footer if footer is None and self.focus_part == 'footer': self.focus_part = 'body' self._invalidate() footer = property(get_footer, set_footer) - def set_focus(self, part): + def set_focus(self, part: Literal['header', 'footer', 'body']) -> None: """ Determine which part of the frame is in focus. @@ -917,7 +971,7 @@ class Frame(Widget, WidgetContainerMixin): self.focus_part = part self._invalidate() - def get_focus(self): + def get_focus(self) -> Literal['header', 'footer', 'body']: """ Return an indicator which part of the frame is in focus @@ -929,7 +983,7 @@ class Frame(Widget, WidgetContainerMixin): """ return self.focus_part - def _get_focus(self): + def _get_focus(self) -> Widget: return { 'header': self._header, 'footer': self._footer, @@ -948,10 +1002,13 @@ class Frame(Widget, WidgetContainerMixin): class FrameContents: def __len__(inner_self): return len(inner_self.keys()) + def items(inner_self): return [(k, inner_self[k]) for k in inner_self.keys()] + def values(inner_self): return [inner_self[k] for k in inner_self.keys()] + def update(inner_self, E=None, **F): if E: keys = getattr(E, 'keys', None) @@ -968,14 +1025,16 @@ class Frame(Widget, WidgetContainerMixin): __setitem__ = self._contents__setitem__ __delitem__ = self._contents__delitem__ return FrameContents() - def _contents_keys(self): + + def _contents_keys(self) -> list[Literal['header', 'footer', 'body']]: keys = ['body'] if self._header: keys.append('header') if self._footer: keys.append('footer') return keys - def _contents__getitem__(self, key): + + def _contents__getitem__(self, key: Literal['header', 'footer', 'body']): if key == 'body': return (self._body, None) if key == 'header' and self._header: @@ -983,7 +1042,8 @@ class Frame(Widget, WidgetContainerMixin): if key == 'footer' and self._footer: return (self._footer, None) raise KeyError(f"Frame.contents has no key: {key!r}") - def _contents__setitem__(self, key, value): + + def _contents__setitem__(self, key: Literal['header', 'footer', 'body'], value): if key not in ('body', 'header', 'footer'): raise KeyError(f"Frame.contents has no key: {key!r}") try: @@ -998,7 +1058,8 @@ class Frame(Widget, WidgetContainerMixin): self.footer = value_w else: self.header = value_w - def _contents__delitem__(self, key): + + def _contents__delitem__(self, key: Literal['header', 'footer', 'body']): if key not in ('header', 'footer'): raise KeyError(f"Frame.contents can't remove key: {key!r}") if (key == 'header' and self._header is None @@ -1029,7 +1090,7 @@ class Frame(Widget, WidgetContainerMixin): compatibility. """) - def options(self): + def options(self) -> None: """ There are currently no options for Frame contents. @@ -1037,7 +1098,7 @@ class Frame(Widget, WidgetContainerMixin): """ return None - def frame_top_bottom(self, size, focus): + def frame_top_bottom(self, size: tuple[int, int], focus: bool) -> tuple[tuple[int, int], tuple[int, int]]: """ Calculate the number of rows for the header and footer. @@ -1090,11 +1151,9 @@ class Frame(Widget, WidgetContainerMixin): return (hrows, frows),(hrows, frows) - - def render(self, size, focus=False): + def render(self, size: tuple[int, int], focus: bool = False) -> CompositeCanvas: (maxcol, maxrow) = size - (htrim, ftrim),(hrows, frows) = self.frame_top_bottom( - (maxcol, maxrow), focus) + (htrim, ftrim),(hrows, frows) = self.frame_top_bottom((maxcol, maxrow), focus) combinelist = [] depends_on = [] @@ -1136,8 +1195,7 @@ class Frame(Widget, WidgetContainerMixin): return CanvasCombine(combinelist) - - def keypress(self, size, key): + def keypress(self, size: tuple[int, int], key: str) -> str | None: """Pass keypress to widget in focus.""" (maxcol, maxrow) = size @@ -1162,15 +1220,13 @@ class Frame(Widget, WidgetContainerMixin): return key return self.body.keypress( (maxcol, remaining), key ) - - def mouse_event(self, size, event, button, col, row, focus): + def mouse_event(self, size: tuple[str, str], event, button: int, col: int, row: int, focus: bool) -> bool | None: """ Pass mouse event to appropriate part of frame. Focus may be changed on button 1 press. """ (maxcol, maxrow) = size - (htrim, ftrim),(hrows, frows) = self.frame_top_bottom( - (maxcol, maxrow), focus) + (htrim, ftrim),(hrows, frows) = self.frame_top_bottom((maxcol, maxrow), focus) if row < htrim: # within header focus = focus and self.focus_part == 'header' @@ -1203,7 +1259,7 @@ class Frame(Widget, WidgetContainerMixin): return self.body.mouse_event( (maxcol, maxrow-htrim-ftrim), event, button, col, row-htrim, focus ) - def get_cursor_coords(self, size): + def get_cursor_coords(self, size: tuple[int, int]) -> tuple[int, int] | None: """Return the cursor coordinates of the focus widget.""" if not self.focus.selectable(): return None @@ -1254,13 +1310,14 @@ class Frame(Widget, WidgetContainerMixin): class PileError(Exception): pass + class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): """ A pile of widgets stacked vertically from top to bottom """ _sizing = frozenset([FLOW, BOX]) - def __init__(self, widget_list, focus_item=None): + def __init__(self, widget_list: Iterable[Widget], focus_item: Widget | int | None = None) -> None: """ :param widget_list: child widgets :type widget_list: iterable @@ -1293,7 +1350,6 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): self._contents.set_focus_changed_callback(lambda f: self._invalidate()) self._contents.set_validate_contents_modified(self._validate_contents_modified) - focus_item = focus_item for i, original in enumerate(widget_list): w = original if not isinstance(w, tuple): @@ -1321,7 +1377,7 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): self.pref_col = 0 - def _contents_modified(self): + def _contents_modified(self) -> None: """ Recalculate whether this widget should be selectable whenever the contents has been changed. @@ -1340,10 +1396,13 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): def _get_widget_list(self): ml = MonitoredList(w for w, t in self.contents) + def user_modified(): self._set_widget_list(ml) + ml.set_modified_callback(user_modified) return ml + def _set_widget_list(self, widgets): focus_position = self.focus_position self.contents = [ @@ -1364,10 +1423,12 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): # return the old item type names ({GIVEN: FIXED, PACK: FLOW}.get(f, f), height) for w, (f, height) in self.contents) + def user_modified(): self._set_item_types(ml) ml.set_modified_callback(user_modified) return ml + def _set_item_types(self, item_types): focus_position = self.focus_position self.contents = [ @@ -1415,7 +1476,10 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): """) @staticmethod - def options(height_type=WEIGHT, height_amount=1): + def options( + height_type: Literal['pack', 'given', 'weight'] = WEIGHT, + height_amount: int | None = 1, + ) -> tuple[Literal['pack'], None] | tuple[Literal['given', 'weight'], int]: """ Return a new options tuple for use in a Pile's :attr:`contents` list. @@ -1430,7 +1494,7 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): raise PileError(f'invalid height_type: {height_type!r}') return (height_type, height_amount) - def set_focus(self, item): + def set_focus(self, item: Widget | int) -> None: """ Set the item in focus, for backwards compatibility. @@ -1449,7 +1513,7 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): return raise ValueError(f"Widget not found in Pile contents: {item!r}") - def get_focus(self): + def get_focus(self) -> Widget | None: """ Return the widget in focus, for backwards compatibility. You may also use the new standard container property .focus to get the @@ -1472,7 +1536,7 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): focus position. """) - def _get_focus_position(self): + def _get_focus_position(self) -> int: """ Return the index of the widget in focus or None if this Pile is empty. @@ -1480,7 +1544,7 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): if not self.contents: raise IndexError("No focus_position, Pile is empty") return self.contents.focus - def _set_focus_position(self, position): + def _set_focus_position(self, position: int) -> None: """ Set the widget in focus. @@ -1504,7 +1568,13 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): self._update_pref_col_from_focus(size) return self.pref_col - def get_item_size(self, size, i, focus, item_rows=None): + def get_item_size( + self, + size: tuple[int] | tuple[int, int], + i: int, + focus: bool, + item_rows: list[int] | None = None, + ) -> tuple[int] | tuple[int, int]: """ Return a size appropriate for passing to self.contents[i][0].render """ @@ -1519,7 +1589,7 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): else: return (maxcol,) - def get_item_rows(self, size, focus): + def get_item_rows(self, size: tuple[int] | tuple[int, int], focus: bool) -> list[int]: """ Return a list of the number of rows used by each widget in self.contents @@ -1537,8 +1607,7 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): if f == GIVEN: l.append(height) else: - l.append(w.rows((maxcol,), - focus=focus and self.focus_item == w)) + l.append(w.rows((maxcol,), focus=focus and self.focus_item == w)) return l # pile is a box widget @@ -1556,7 +1625,7 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): l.append(None) wtotal += height else: - l.append(0) # zero-weighted items treated as ('given', 0) + l.append(0) # zero-weighted items treated as ('given', 0) if wtotal == 0: raise PileError("No weighted widgets found for Pile treated as a box widget") @@ -1603,7 +1672,7 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): out.pad_trim_top_bottom(0, size[1] - out.rows()) return out - def get_cursor_coords(self, size): + def get_cursor_coords(self, size: tuple[int] | tuple[int, int]) -> tuple[int, int] | None: """Return the cursor coordinates of the focus widget.""" if not self.selectable(): return None @@ -1635,10 +1704,10 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): y += r return x, y - def rows(self, size, focus=False ): + def rows(self, size: tuple[int] | tuple[int, int], focus: bool = False) -> int: return sum(self.get_item_rows(size, focus)) - def keypress(self, size, key ): + def keypress(self, size: tuple[int] | tuple[int, int], key: str) -> str | None: """Pass the keypress to the widget in focus. Unhandled 'up' and 'down' keys may cause a focus change.""" if not self.contents: @@ -1687,7 +1756,7 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): # nothing to select return key - def _update_pref_col_from_focus(self, size): + def _update_pref_col_from_focus(self, size: tuple[int] | tuple[int, int]) -> None: """Update self.pref_col from the focus widget.""" if not hasattr(self.focus, 'get_pref_col'): @@ -1698,16 +1767,15 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): if pref_col is not None: self.pref_col = pref_col - def move_cursor_to_coords(self, size, col, row): + def move_cursor_to_coords(self, size: tuple[int] | tuple[int, int], col: int, row: int) -> bool: """Capture pref col and set new focus.""" self.pref_col = col #FIXME guessing focus==True - focus=True + focus = True wrow = 0 item_rows = self.get_item_rows(size, focus) - for i, (r, w) in enumerate(zip(item_rows, - (w for (w, options) in self.contents))): + for i, (r, w) in enumerate(zip(item_rows, (w for (w, options) in self.contents))): if wrow + r > row: break wrow += r @@ -1726,7 +1794,15 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): self.focus_position = i return True - def mouse_event(self, size, event, button, col, row, focus): + def mouse_event( + self, + size: tuple[int] | tuple[int, int], + event, + button: int, + col: int, + row: int, + focus: bool, + ) -> bool | None: """ Pass the event to the contained widget. May change focus on button 1 press. @@ -1750,9 +1826,7 @@ class Pile(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): return False tsize = self.get_item_size(size, i, focus, item_rows) - return w.mouse_event(tsize, event, button, col, row-wrow, - focus) - + return w.mouse_event(tsize, event, button, col, row-wrow, focus) class ColumnsError(Exception): @@ -1765,8 +1839,14 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): """ _sizing = frozenset([FLOW, BOX]) - def __init__(self, widget_list, dividechars=0, focus_column=None, - min_width=1, box_columns=None): + def __init__( + self, + widget_list: Iterable[Widget], + dividechars: int = 0, + focus_column: int | None = None, + min_width: int = 1, + box_columns: Iterable[int] | None = None, + ): """ :param widget_list: iterable of flow or box widgets :param dividechars: number of blank characters between columns @@ -1841,7 +1921,7 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): self.min_width = min_width self._cache_maxcol = None - def _contents_modified(self): + def _contents_modified(self) -> None: """ Recalculate whether this widget should be selectable whenever the contents has been changed. @@ -1849,7 +1929,7 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): self._selectable = any(w.selectable() for w, o in self.contents) self._invalidate() - def _validate_contents_modified(self, slc, new_items): + def _validate_contents_modified(self, slc, new_items) -> None: for item in new_items: try: w, (t, n, b) = item @@ -1858,18 +1938,22 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): except (TypeError, ValueError): raise ColumnsError(f"added content invalid {item!r}") - def _get_widget_list(self): + def _get_widget_list(self) -> MonitoredList: ml = MonitoredList(w for w, t in self.contents) def user_modified(): self._set_widget_list(ml) ml.set_modified_callback(user_modified) return ml + def _set_widget_list(self, widgets): focus_position = self.focus_position self.contents = [ - (new, options) for (new, (w, options)) in zip(widgets, # need to grow contents list if widgets is longer - chain(self.contents, repeat((None, (WEIGHT, 1, False)))))] + (new, options) for (new, (w, options)) in zip( + widgets, + chain(self.contents, repeat((None, (WEIGHT, 1, False)))) + ) + ] if focus_position < len(widgets): self.focus_position = focus_position widget_list = property(_get_widget_list, _set_widget_list, doc=""" @@ -1879,7 +1963,7 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): standard container property :attr:`contents`. """) - def _get_column_types(self): + def _get_column_types(self) -> MonitoredList: ml = MonitoredList( # return the old column type names ({GIVEN: FIXED, PACK: FLOW}.get(t, t), n) @@ -1888,12 +1972,14 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): self._set_column_types(ml) ml.set_modified_callback(user_modified) return ml + def _set_column_types(self, column_types): focus_position = self.focus_position self.contents = [ (w, ({FIXED: GIVEN, FLOW: PACK}.get(new_t, new_t), new_n, b)) for ((new_t, new_n), (w, (t, n, b))) - in zip(column_types, self.contents)] + in zip(column_types, self.contents) + ] if focus_position < len(column_types): self.focus_position = focus_position column_types = property(_get_column_types, _set_column_types, doc=""" @@ -1902,13 +1988,14 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): container property .contents to modify Pile contents. """) - def _get_box_columns(self): + def _get_box_columns(self) -> MonitoredList: ml = MonitoredList( i for i, (w, (t, n, b)) in enumerate(self.contents) if b) def user_modified(): self._set_box_columns(ml) ml.set_modified_callback(user_modified) return ml + def _set_box_columns(self, box_columns): box_columns = set(box_columns) self.contents = [ @@ -1922,21 +2009,21 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): standard container property :attr:`contents`. """) - def _get_has_pack_type(self): + def _get_has_pack_type(self) -> bool: import warnings - warnings.warn(".has_flow_type is deprecated, " - "read values from .contents instead.", DeprecationWarning) + warnings.warn(".has_flow_type is deprecated, read values from .contents instead.", DeprecationWarning) return PACK in self.column_types + def _set_has_pack_type(self, value): import warnings - warnings.warn(".has_flow_type is deprecated, " - "read values from .contents instead.", DeprecationWarning) + warnings.warn(".has_flow_type is deprecated, read values from .contents instead.", DeprecationWarning) has_flow_type = property(_get_has_pack_type, _set_has_pack_type, doc=""" .. deprecated:: 1.0 Read values from :attr:`contents` instead. """) def _get_contents(self): return self._contents + def _set_contents(self, c): self._contents[:] = c contents = property(_get_contents, _set_contents, doc=""" @@ -1948,7 +2035,11 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): """) @staticmethod - def options(width_type=WEIGHT, width_amount=1, box_widget=False): + def options( + width_type: Literal['pack', 'given', 'weight'] = WEIGHT, + width_amount: int | None = 1, + box_widget: bool = False, + ) -> tuple[Literal['pack'], None, bool] | tuple[Literal['given', 'weight'], int, bool]: """ Return a new options tuple for use in a Pile's .contents list. @@ -1976,11 +2067,11 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): raise ColumnsError(f'invalid width_type: {width_type!r}') return (width_type, width_amount, box_widget) - def _invalidate(self): + def _invalidate(self) -> None: self._cache_maxcol = None super()._invalidate() - def set_focus_column(self, num): + def set_focus_column(self, num: int) -> None: """ Set the column in focus by its index in :attr:`widget_list`. @@ -1992,7 +2083,7 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): """ self._set_focus_position(num) - def get_focus_column(self): + def get_focus_column(self) -> int: """ Return the focus column index. @@ -2001,7 +2092,7 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): """ return self.focus_position - def set_focus(self, item): + def set_focus(self, item: Widget | int) -> None: """ Set the item in focus @@ -2017,7 +2108,7 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): return raise ValueError(f"Widget not found in Columns contents: {item!r}") - def get_focus(self): + def get_focus(self) -> Widget | None: """ Return the widget in focus, for backwards compatibility. You may also use the new standard container property .focus to get the @@ -2029,7 +2120,7 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): focus = property(get_focus, doc="the child widget in focus or None when Columns is empty") - def _get_focus_position(self): + def _get_focus_position(self) -> int | None: """ Return the index of the widget in focus or None if this Columns is empty. @@ -2037,7 +2128,8 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): if not self.widget_list: raise IndexError("No focus_position, Columns is empty") return self.contents.focus - def _set_focus_position(self, position): + + def _set_focus_position(self, position: int) -> None: """ Set the widget in focus. @@ -2062,7 +2154,7 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): standard container property :attr:`focus_position` to get the focus. """) - def column_widths(self, size, focus=False): + def column_widths(self, size: tuple[int] | tuple[int, int], focus: bool = False) -> list[int]: """ Return a list of column widths. @@ -2123,7 +2215,7 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): self._cache_column_widths = widths return widths - def render(self, size, focus=False): + def render(self, size: tuple[int] | tuple[int, int], focus: bool = False) -> SolidCanvas | CompositeCanvas: """ Render columns and return canvas. @@ -2155,8 +2247,7 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): else: sub_size = (mc,) + size[1:] - canv = w.render(sub_size, - focus = focus and self.focus_position == i) + canv = w.render(sub_size, focus=focus and self.focus_position == i) if i < len(widths) - 1: mc += self.dividechars @@ -2195,7 +2286,7 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): for wc in widths[:self.focus_position] if wc > 0]) return x, y - def move_cursor_to_coords(self, size, col, row): + def move_cursor_to_coords(self, size: tuple[int] | tuple[int, int], col: int, row: int) -> bool: """ Choose a selectable column to focus based on the coords. @@ -2242,7 +2333,15 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): self.pref_col = col return True - def mouse_event(self, size, event, button, col, row, focus): + def mouse_event( + self, + size: tuple[int] | tuple[int, int], + event, + button: int, + col: int, + row: int, + focus: bool, + ) -> bool | None: """ Send event to appropriate column. May change focus on button 1 press. @@ -2261,21 +2360,18 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): continue focus = focus and self.focus_col == i - if is_mouse_press(event) and button == 1: - if w.selectable(): - self.focus_position = i + if is_mouse_press(event) and button == 1 and w.selectable(): + self.focus_position = i if not hasattr(w, 'mouse_event'): return False if len(size) == 1 and b: - return w.mouse_event((end - x, self.rows(size)), event, button, - col - x, row, focus) - return w.mouse_event((end - x,) + size[1:], event, button, - col - x, row, focus) + return w.mouse_event((end - x, self.rows(size)), event, button, col - x, row, focus) + return w.mouse_event((end - x,) + size[1:], event, button, col - x, row, focus) return False - def get_pref_col(self, size): + def get_pref_col(self, size: tuple[int] | tuple[int, int]) -> int: """Return the pref col from the column in focus.""" widths = self.column_widths(size) @@ -2300,7 +2396,7 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): col += sum(widths[:self.focus_position] ) return col - def rows(self, size, focus=False): + def rows(self, size: tuple[int] | tuple[int, int], focus: bool = False) -> int: """ Return the number of rows required by the columns. This only makes sense if :attr:`widget_list` contains flow widgets. @@ -2313,11 +2409,10 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): for i, (mc, (w, (t, n, b))) in enumerate(zip(widths, self.contents)): if b: continue - rows = max(rows, - w.rows((mc,), focus=focus and self.focus_position == i)) + rows = max(rows, w.rows((mc,), focus=focus and self.focus_position == i)) return rows - def keypress(self, size, key): + def keypress(self, size: tuple[int] | tuple[int, int], key: str) -> str | None: """ Pass keypress to the focus column. @@ -2360,13 +2455,10 @@ class Columns(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin): return key - - - - def _test(): import doctest doctest.testmod() + if __name__=='__main__': _test() diff --git a/urwid/curses_display.py b/urwid/curses_display.py index 3a79ec7..bc13125 100755 --- a/urwid/curses_display.py +++ b/urwid/curses_display.py @@ -31,12 +31,7 @@ import curses import _curses from urwid import escape -from urwid.display_common import ( - UNPRINTABLE_TRANS_TABLE, - AttrSpec, - BaseScreen, - RealTerminal, -) +from urwid.display_common import UNPRINTABLE_TRANS_TABLE, AttrSpec, BaseScreen, RealTerminal KEY_RESIZE = 410 # curses.KEY_RESIZE (sometimes not defined) KEY_MOUSE = 409 # curses.KEY_MOUSE @@ -80,7 +75,7 @@ class Screen(BaseScreen, RealTerminal): self.register_palette_entry(None, 'default','default') - def set_mouse_tracking(self, enable=True): + def set_mouse_tracking(self, enable: bool = True) -> None: """ Enable mouse tracking. @@ -108,7 +103,7 @@ class Screen(BaseScreen, RealTerminal): self._mouse_tracking_enabled = enable - def _start(self): + def _start(self) -> None: """ Initialize the screen and input mode. """ @@ -136,7 +131,7 @@ class Screen(BaseScreen, RealTerminal): super()._start() - def _stop(self): + def _stop(self) -> None: """ Restore the screen. """ @@ -152,8 +147,7 @@ class Screen(BaseScreen, RealTerminal): super()._stop() - - def _setup_colour_pairs(self): + def _setup_colour_pairs(self) -> None: """ Initialize all 63 color pairs based on the term: bg * 8 + 7 - fg @@ -172,8 +166,8 @@ class Screen(BaseScreen, RealTerminal): curses.init_pair(bg * 8 + 7 - fg, fg, bg) - def _curs_set(self,x): - if self.cursor_state== "fixed" or x == self.cursor_state: + def _curs_set(self, x): + if self.cursor_state == "fixed" or x == self.cursor_state: return try: curses.curs_set(x) @@ -181,14 +175,12 @@ class Screen(BaseScreen, RealTerminal): except _curses.error: self.cursor_state = "fixed" - - def _clear(self): + def _clear(self) -> None: self.s.clear() self.s.refresh() - - def _getch(self, wait_tenths): - if wait_tenths==0: + def _getch(self, wait_tenths: int | None): + if wait_tenths == 0: return self._getch_nodelay() if wait_tenths is None: curses.cbreak() @@ -209,8 +201,12 @@ class Screen(BaseScreen, RealTerminal): return self.s.getch() - def set_input_timeouts(self, max_wait=None, complete_wait=0.1, - resize_wait=0.1): + def set_input_timeouts( + self, + max_wait: int | float | None = None, + complete_wait: int | float = 0.1, + resize_wait: int | float = 0.1, + ): """ Set the get_input timeout values. All values have a granularity of 0.1s, ie. any value between 0.15 and 0.05 will be treated as @@ -285,7 +281,7 @@ class Screen(BaseScreen, RealTerminal): # Avoid pegging CPU at 100% when slowly resizing, and work # around a bug with some braindead curses implementations that # return "no key" between "window resize" commands - if keys==['window resize'] and self.prev_input_resize: + if keys == ['window resize'] and self.prev_input_resize: while True: keys, raw2 = self._get_input(self.resize_tenths) raw += raw2 @@ -299,7 +295,7 @@ class Screen(BaseScreen, RealTerminal): keys.append('window resize') - if keys==['window resize']: + if keys == ['window resize']: self.prev_input_resize = 2 elif self.prev_input_resize == 2 and not keys: self.prev_input_resize = 1 @@ -310,7 +306,6 @@ class Screen(BaseScreen, RealTerminal): return keys, raw return keys - def _get_input(self, wait_tenths): # this works around a strange curses bug with window resizing # not being reported correctly with repeated calls to this @@ -358,7 +353,6 @@ class Screen(BaseScreen, RealTerminal): return processed, raw - def _encode_mouse_event(self): # convert to escape sequence last = next = self.last_bstate @@ -420,8 +414,7 @@ class Screen(BaseScreen, RealTerminal): self.last_bstate = next return l - - def _dbg_instr(self): # messy input string (intended for debugging) + def _dbg_instr(self): # messy input string (intended for debugging) curses.echo() self.s.nodelay(0) curses.halfdelay(100) @@ -429,26 +422,23 @@ class Screen(BaseScreen, RealTerminal): curses.noecho() return str - def _dbg_out(self,str): # messy output function (intended for debugging) + def _dbg_out(self, str) -> None: # messy output function (intended for debugging) self.s.clrtoeol() self.s.addstr(str) self.s.refresh() self._curs_set(1) - def _dbg_query(self,question): # messy query (intended for debugging) + def _dbg_query(self,question): # messy query (intended for debugging) self._dbg_out(question) return self._dbg_instr() - def _dbg_refresh(self): + def _dbg_refresh(self) -> None: self.s.refresh() - - - def get_cols_rows(self): + def get_cols_rows(self) -> tuple[int, int]: """Return the terminal dimensions (num columns, num rows).""" rows,cols = self.s.getmaxyx() - return cols,rows - + return cols, rows def _setattr(self, a): if a is None: @@ -487,7 +477,7 @@ class Screen(BaseScreen, RealTerminal): self.s.attrset(attr) - def draw_screen(self, size, r ): + def draw_screen(self, size: tuple[int, int], r): """Paint screen with rendered canvas.""" assert self._started @@ -548,8 +538,7 @@ class Screen(BaseScreen, RealTerminal): self.s.refresh() self.keep_cache_alive_link = r - - def clear(self): + def clear(self) -> None: """ Force the screen to be completely repainted on the next call to draw_screen(). @@ -557,8 +546,6 @@ class Screen(BaseScreen, RealTerminal): self.s.clear() - - class _test: def __init__(self): self.ui = Screen() @@ -570,13 +557,15 @@ class _test: (f"{c} on dark blue",c, 'dark blue', 'bold'), (f"{c} on light gray",c,'light gray', 'standout'), ]) - self.ui.run_wrapper(self.run) - def run(self): + with self.ui.start(): + self.run() + + def run(self) -> None: class FakeRender: pass r = FakeRender() text = [f" has_color = {self.ui.has_color!r}",""] - attr = [[],[]] + attr = [[], []] r.coords = {} r.cursor = None @@ -606,10 +595,11 @@ class _test: t = "" a = [] for k in keys: - if type(k) == str: k = k.encode("utf-8") + if isinstance(k, str): + k = k.encode("utf-8") + t += f"'{k}' " - a += [(None,1), ('yellow on dark blue',len(k)), - (None,2)] + a += [(None, 1), ('yellow on dark blue', len(k)), (None, 2)] text.append(f"{t}: {raw!r}") attr.append(a) @@ -617,7 +607,5 @@ class _test: attr = attr[-rows:] - - -if '__main__'==__name__: +if '__main__' == __name__: _test() diff --git a/urwid/decoration.py b/urwid/decoration.py index 28096ab..9722c5a 100755 --- a/urwid/decoration.py +++ b/urwid/decoration.py @@ -22,6 +22,9 @@ from __future__ import annotations +import typing +from collections.abc import Hashable, Mapping + from urwid.canvas import CompositeCanvas, SolidCanvas from urwid.split_repr import remove_defaults from urwid.util import int_scale @@ -48,6 +51,9 @@ from urwid.widget import ( # doctests delegate_to_widget_mixin, ) +if typing.TYPE_CHECKING: + from typing_extensions import Literal + class WidgetDecoration(Widget): # "decorator" was already taken """ @@ -65,13 +71,15 @@ class WidgetDecoration(Widget): # "decorator" was already taken >>> WidgetDecoration(Text(u"hi")) <WidgetDecoration flow widget <Text flow widget 'hi'>> """ - def __init__(self, original_widget): + def __init__(self, original_widget: Widget) -> None: self._original_widget = original_widget + def _repr_words(self): return super()._repr_words() + [repr(self._original_widget)] - def _get_original_widget(self): + def _get_original_widget(self) -> Widget: return self._original_widget + def _set_original_widget(self, original_widget): self._original_widget = original_widget self._invalidate() @@ -98,15 +106,14 @@ class WidgetDecoration(Widget): # "decorator" was already taken base_widget = property(_get_base_widget) - def selectable(self): + def selectable(self) -> bool: return self._original_widget.selectable() def sizing(self): return self._original_widget.sizing() -class WidgetPlaceholder(delegate_to_widget_mixin('_original_widget'), - WidgetDecoration): +class WidgetPlaceholder(delegate_to_widget_mixin('_original_widget'), WidgetDecoration): """ This is a do-nothing decoration widget that can be used for swapping between widgets without modifying the container of this widget. @@ -123,13 +130,14 @@ class WidgetPlaceholder(delegate_to_widget_mixin('_original_widget'), class AttrMapError(WidgetError): pass + class AttrMap(delegate_to_widget_mixin('_original_widget'), WidgetDecoration): """ AttrMap is a decoration that maps one set of attributes to another. This object will pass all function calls and variable references to the wrapped widget. """ - def __init__(self, w, attr_map, focus_map=None): + def __init__(self, w: Widget, attr_map, focus_map=None): """ :param w: widget to wrap (stored as self.original_widget) :type w: widget @@ -161,15 +169,17 @@ class AttrMap(delegate_to_widget_mixin('_original_widget'), WidgetDecoration): """ super().__init__(w) - if type(attr_map) != dict: - self.set_attr_map({None: attr_map}) + if isinstance(attr_map, Mapping): + self.attr_map = dict(attr_map) else: - self.set_attr_map(attr_map) + self.attr_map = {None: attr_map} - if focus_map is not None and type(focus_map) != dict: - self.set_focus_map({None: focus_map}) + if isinstance(focus_map, Mapping): + self.focus_map = dict(focus_map) + elif focus_map is None: + self.focus_map = focus_map else: - self.set_focus_map(focus_map) + self.focus_map = {None: focus_map} def _repr_attrs(self): # only include the focus_attr when it takes effect (not None) @@ -178,11 +188,12 @@ class AttrMap(delegate_to_widget_mixin('_original_widget'), WidgetDecoration): d['focus_map'] = self._focus_map return d - def get_attr_map(self): + def get_attr_map(self) -> dict[Hashable | None, Hashable]: # make a copy so ours is not accidentally modified # FIXME: a dictionary that detects modifications would be better return dict(self._attr_map) - def set_attr_map(self, attr_map): + + def set_attr_map(self, attr_map: dict[Hashable | None, Hashable]) -> None: """ Set the attribute mapping dictionary {from_attr: to_attr, ...} @@ -195,19 +206,22 @@ class AttrMap(delegate_to_widget_mixin('_original_widget'), WidgetDecoration): <AttrMap flow widget <Text flow widget 'hi'> attr_map={'a': 'b'}> """ for from_attr, to_attr in attr_map.items(): - if not from_attr.__hash__ or not to_attr.__hash__: - raise AttrMapError("%r:%r attribute mapping is invalid. " - "Attributes must be hashable" % (from_attr, to_attr)) + if not isinstance(from_attr, Hashable) or not isinstance(to_attr, Hashable): + raise AttrMapError( + f"{from_attr!r}:{to_attr!r} attribute mapping is invalid. Attributes must be hashable" + ) + self._attr_map = attr_map self._invalidate() attr_map = property(get_attr_map, set_attr_map) - def get_focus_map(self): + def get_focus_map(self) -> dict[Hashable | None, Hashable] | None: # make a copy so ours is not accidentally modified # FIXME: a dictionary that detects modifications would be better if self._focus_map: return dict(self._focus_map) - def set_focus_map(self, focus_map): + + def set_focus_map(self, focus_map: dict[Hashable | None, Hashable]) -> None: """ Set the focus attribute mapping dictionary {from_attr: to_attr, ...} @@ -228,14 +242,15 @@ class AttrMap(delegate_to_widget_mixin('_original_widget'), WidgetDecoration): """ if focus_map is not None: for from_attr, to_attr in focus_map.items(): - if not from_attr.__hash__ or not to_attr.__hash__: - raise AttrMapError("%r:%r attribute mapping is invalid. " - "Attributes must be hashable" % (from_attr, to_attr)) + if not isinstance(from_attr, Hashable) or not isinstance(to_attr, Hashable): + raise AttrMapError( + f"{from_attr!r}:{to_attr!r} attribute mapping is invalid. Attributes must be hashable" + ) self._focus_map = focus_map self._invalidate() focus_map = property(get_focus_map, set_focus_map) - def render(self, size, focus=False): + def render(self, size, focus: bool = False) -> CompositeCanvas: """ Render wrapped widget and apply attribute. Return canvas. """ @@ -248,9 +263,8 @@ class AttrMap(delegate_to_widget_mixin('_original_widget'), WidgetDecoration): return canv - class AttrWrap(AttrMap): - def __init__(self, w, attr, focus_attr=None): + def __init__(self, w: Widget, attr, focus_attr=None): """ w -- widget to wrap (stored as self.original_widget) attr -- attribute to apply to w @@ -291,6 +305,7 @@ class AttrWrap(AttrMap): def get_attr(self): return self.attr_map[None] + def set_attr(self, attr): """ Set the attribute to apply to the wrapped widget @@ -307,6 +322,7 @@ class AttrWrap(AttrMap): focus_map = self.focus_map if focus_map: return focus_map[None] + def set_focus_attr(self, focus_attr): """ Set the attribute to apply to the wapped widget when it is in @@ -326,7 +342,7 @@ class AttrWrap(AttrMap): self.set_focus_map({None: focus_attr}) focus_attr = property(get_focus_attr, set_focus_attr) - def __getattr__(self,name): + def __getattr__(self, name: str): """ Call getattr on wrapped widget. This has been the longstanding behaviour of AttrWrap, but is discouraged. New code should be @@ -334,7 +350,6 @@ class AttrWrap(AttrMap): """ return getattr(self._original_widget, name) - def sizing(self): return self._original_widget.sizing() @@ -342,6 +357,7 @@ class AttrWrap(AttrMap): class BoxAdapterError(Exception): pass + class BoxAdapter(WidgetDecoration): """ Adapter for using a box widget where a flow widget would usually go @@ -362,7 +378,7 @@ class BoxAdapter(WidgetDecoration): """ if hasattr(box_widget, 'sizing') and BOX not in box_widget.sizing(): raise BoxAdapterError(f"{box_widget!r} is not a box widget") - WidgetDecoration.__init__(self,box_widget) + super().__init__(box_widget) self.height = height @@ -370,13 +386,12 @@ class BoxAdapter(WidgetDecoration): return dict(super()._repr_attrs(), height=self.height) # originally stored as box_widget, keep for compatibility - box_widget = property(WidgetDecoration._get_original_widget, - WidgetDecoration._set_original_widget) + box_widget = property(WidgetDecoration._get_original_widget, WidgetDecoration._set_original_widget) def sizing(self): return {FLOW} - def rows(self, size, focus=False): + def rows(self, size: tuple[int], focus: bool = False) -> int: """ Return the predetermined height (behave like a flow widget) @@ -387,43 +402,49 @@ class BoxAdapter(WidgetDecoration): # The next few functions simply tack-on our height and pass through # to self._original_widget - def get_cursor_coords(self, size): + def get_cursor_coords(self, size: tuple[int]) -> int | None: (maxcol,) = size if not hasattr(self._original_widget,'get_cursor_coords'): return None return self._original_widget.get_cursor_coords((maxcol, self.height)) - def get_pref_col(self, size): + def get_pref_col(self, size: tuple[int]) -> int | None: (maxcol,) = size if not hasattr(self._original_widget,'get_pref_col'): return None return self._original_widget.get_pref_col((maxcol, self.height)) - def keypress(self, size, key): + def keypress(self, size: tuple[int], key): (maxcol,) = size return self._original_widget.keypress((maxcol, self.height), key) - def move_cursor_to_coords(self, size, col, row): + def move_cursor_to_coords(self, size: tuple[int], col: int, row: int): (maxcol,) = size if not hasattr(self._original_widget,'move_cursor_to_coords'): return True - return self._original_widget.move_cursor_to_coords((maxcol, - self.height), col, row ) - - def mouse_event(self, size, event, button, col, row, focus): + return self._original_widget.move_cursor_to_coords((maxcol, self.height), col, row ) + + def mouse_event( + self, + size: tuple[int], + event, + button: int, + col: int, + row: int, + focus: bool, + ) -> bool: (maxcol,) = size if not hasattr(self._original_widget,'mouse_event'): return False - return self._original_widget.mouse_event((maxcol, self.height), - event, button, col, row, focus) + return self._original_widget.mouse_event((maxcol, self.height), event, button, col, row, focus) - def render(self, size, focus=False): + def render(self, size: tuple[int], focus: bool = False) -> CompositeCanvas: (maxcol,) = size canv = self._original_widget.render((maxcol, self.height), focus) canv = CompositeCanvas(canv) return canv - def __getattr__(self, name): + def __getattr__(self, name: str): """ Pass calls to box widget. """ @@ -434,9 +455,17 @@ class BoxAdapter(WidgetDecoration): class PaddingError(Exception): pass + class Padding(WidgetDecoration): - def __init__(self, w, align=LEFT, width=RELATIVE_100, min_width=None, - left=0, right=0): + def __init__( + self, + w: Widget, + align: Literal["left", "center", "right"] = LEFT, + width: int | Literal['pack', 'clip'] | tuple[Literal['relative'], int] = RELATIVE_100, + min_width: int | None = None, + left: int = 0, + right: int = 0, + ): """ :param w: a box, flow or fixed widget to pad on the left and/or right this widget is stored as self.original_widget @@ -479,7 +508,7 @@ class Padding(WidgetDecoration): >>> size = (7,) >>> def pr(w): ... for t in w.render(size).text: - ... print("|%s|" % (t.decode('ascii'),)) + ... print(f"|{t.decode('ascii')}|" ) >>> pr(Padding(Text(u"Head"), ('relative', 20), 'pack')) | Head | >>> pr(Padding(Divider(u"-"), left=2, right=1)) @@ -503,17 +532,15 @@ class Padding(WidgetDecoration): super().__init__(w) # convert obsolete parameters 'fixed left' and 'fixed right': - if type(align) == tuple and align[0] in ('fixed left', - 'fixed right'): - if align[0]=='fixed left': + if isinstance(align, tuple) and align[0] in ('fixed left', 'fixed right'): + if align[0] == 'fixed left': left = align[1] align = LEFT else: right = align[1] align = RIGHT - if type(width) == tuple and width[0] in ('fixed left', - 'fixed right'): - if width[0]=='fixed left': + if isinstance(width, tuple) and width[0] in ('fixed left', 'fixed right'): + if width[0] == 'fixed left': left = width[1] else: right = width[1] @@ -525,10 +552,8 @@ class Padding(WidgetDecoration): self.left = left self.right = right - self._align_type, self._align_amount = normalize_align(align, - PaddingError) - self._width_type, self._width_amount = normalize_width(width, - PaddingError) + self._align_type, self._align_amount = normalize_align(align, PaddingError) + self._width_type, self._width_amount = normalize_width(width, PaddingError) self.min_width = min_width def sizing(self): @@ -545,35 +570,39 @@ class Padding(WidgetDecoration): min_width=self.min_width) return remove_defaults(attrs, Padding.__init__) - def _get_align(self): + def _get_align(self) -> Literal['left', 'center', 'right'] | tuple[Literal['relative'], int]: """ Return the padding alignment setting. """ return simplify_align(self._align_type, self._align_amount) - def _set_align(self, align): + + def _set_align(self, align: Literal['left', 'center', 'right'] | tuple[Literal['relative'], int]) -> None: """ Set the padding alignment. """ - self._align_type, self._align_amount = normalize_align(align, - PaddingError) + self._align_type, self._align_amount = normalize_align(align, PaddingError) self._invalidate() align = property(_get_align, _set_align) - def _get_width(self): + def _get_width(self) -> Literal['clip', 'pack'] | int | tuple[Literal['relative'], int]: """ Return the padding width. """ return simplify_width(self._width_type, self._width_amount) - def _set_width(self, width): + + def _set_width(self, width: Literal['clip', 'pack'] | int | tuple[Literal['relative'], int]) -> None: """ Set the padding width. """ - self._width_type, self._width_amount = normalize_width(width, - PaddingError) + self._width_type, self._width_amount = normalize_width(width, PaddingError) self._invalidate() width = property(_get_width, _set_width) - def render(self, size, focus=False): + def render( + self, + size: tuple[int] | tuple[int, int], + focus: bool = False, + ) -> CompositeCanvas: left, right = self.padding_values(size, focus) maxcol = size[0] @@ -595,21 +624,27 @@ class Padding(WidgetDecoration): return canv - def padding_values(self, size, focus): + def padding_values(self, size: tuple[int] | tuple[int, int], focus: bool) -> tuple[int, int]: """Return the number of columns to pad on the left and right. Override this method to define custom padding behaviour.""" maxcol = size[0] if self._width_type == CLIP: width, ignore = self._original_widget.pack((), focus=focus) - return calculate_left_right_padding(maxcol, - self._align_type, self._align_amount, - CLIP, width, None, self.left, self.right) + return calculate_left_right_padding( + maxcol, + self._align_type, + self._align_amount, + CLIP, + width, + None, + self.left, + self.right, + ) if self._width_type == PACK: maxwidth = max(maxcol - self.left - self.right, self.min_width or 0) - (width, ignore) = self._original_widget.pack((maxwidth,), - focus=focus) + (width, ignore) = self._original_widget.pack((maxwidth,), focus=focus) return calculate_left_right_padding(maxcol, self._align_type, self._align_amount, GIVEN, width, self.min_width, @@ -632,16 +667,16 @@ class Padding(WidgetDecoration): return frows return self._original_widget.rows((maxcol-left-right,), focus=focus) - def keypress(self, size, key): + def keypress(self, size: tuple[int] | tuple[int, int], key): """Pass keypress to self._original_widget.""" maxcol = size[0] left, right = self.padding_values(size, True) maxvals = (maxcol-left-right,)+size[1:] return self._original_widget.keypress(maxvals, key) - def get_cursor_coords(self,size): + def get_cursor_coords(self, size: tuple[int] | tuple[int, int]) -> tuple[int, int] | None: """Return the (x,y) coordinates of cursor within self._original_widget.""" - if not hasattr(self._original_widget,'get_cursor_coords'): + if not hasattr(self._original_widget, 'get_cursor_coords'): return None left, right = self.padding_values(size, True) maxcol = size[0] @@ -654,17 +689,20 @@ class Padding(WidgetDecoration): x, y = coords return x+left, y - def move_cursor_to_coords(self, size, x, y): + def move_cursor_to_coords( + self, + size: tuple[int] | tuple[int, int], x: int, y: int, + ) -> bool: """Set the cursor position with (x,y) coordinates of self._original_widget. Returns True if move succeeded, False otherwise. """ - if not hasattr(self._original_widget,'move_cursor_to_coords'): + if not hasattr(self._original_widget, 'move_cursor_to_coords'): return True left, right = self.padding_values(size, True) maxcol = size[0] - maxvals = (maxcol-left-right,)+size[1:] - if type(x)==int: + maxvals = (maxcol - left - right,) + size[1:] + if isinstance(x, int): if x < left: x = left elif x >= maxcol-right: @@ -672,38 +710,54 @@ class Padding(WidgetDecoration): x -= left return self._original_widget.move_cursor_to_coords(maxvals, x, y) - def mouse_event(self, size, event, button, x, y, focus): + def mouse_event( + self, + size: tuple[int] | tuple[int, int], + event, + button: int, + x: int, + y: int, + focus: bool, + ): """Send mouse event if position is within self._original_widget.""" - if not hasattr(self._original_widget,'mouse_event'): + if not hasattr(self._original_widget, 'mouse_event'): return False + left, right = self.padding_values(size, focus) maxcol = size[0] if x < left or x >= maxcol-right: return False maxvals = (maxcol-left-right,)+size[1:] - return self._original_widget.mouse_event(maxvals, event, button, x-left, y, - focus) - + return self._original_widget.mouse_event(maxvals, event, button, x-left, y, focus) - def get_pref_col(self, size): + def get_pref_col(self, size: tuple[int] | tuple[int, int]) -> int | None: """Return the preferred column from self._original_widget, or None.""" - if not hasattr(self._original_widget,'get_pref_col'): + if not hasattr(self._original_widget, 'get_pref_col'): return None + left, right = self.padding_values(size, True) maxcol = size[0] maxvals = (maxcol-left-right,)+size[1:] x = self._original_widget.get_pref_col(maxvals) - if type(x) == int: - return x+left + if isinstance(x, int): + return x + left return x class FillerError(Exception): pass + class Filler(WidgetDecoration): - def __init__(self, body, valign=MIDDLE, height=PACK, min_height=None, - top=0, bottom=0): + def __init__( + self, + body: Widget, + valign: Literal['top', 'middle', 'bottom'] | tuple[Literal['relative'], int] = MIDDLE, + height: int | Literal['pack'] | tuple[Literal['relative'], int] = PACK, + min_height: int | None = None, + top: int = 0, + bottom: int = 0, + ) -> None: """ :param body: a flow widget or box widget to be filled around (stored as self.original_widget) @@ -750,14 +804,12 @@ class Filler(WidgetDecoration): if isinstance(height, tuple): if height[0] == 'fixed top': if not isinstance(valign, tuple) or valign[0] != 'fixed bottom': - raise FillerError("fixed top height may only be used " - "with fixed bottom valign") + raise FillerError("fixed top height may only be used with fixed bottom valign") top = height[1] height = RELATIVE_100 elif height[0] == 'fixed bottom': if not isinstance(valign, tuple) or valign[0] != 'fixed top': - raise FillerError("fixed bottom height may only be used " - "with fixed top valign") + raise FillerError("fixed bottom height may only be used with fixed top valign") bottom = height[1] height = RELATIVE_100 if isinstance(valign, tuple): @@ -774,10 +826,8 @@ class Filler(WidgetDecoration): self.top = top self.bottom = bottom - self.valign_type, self.valign_amount = normalize_valign(valign, - FillerError) - self.height_type, self.height_amount = normalize_height(height, - FillerError) + self.valign_type, self.valign_amount = normalize_valign(valign, FillerError) + self.height_type, self.height_amount = normalize_height(height, FillerError) if self.height_type not in (GIVEN, PACK): self.min_height = min_height @@ -785,7 +835,7 @@ class Filler(WidgetDecoration): self.min_height = None def sizing(self): - return {BOX} # always a box widget + return {BOX} # always a box widget def _repr_attrs(self): attrs = dict(super()._repr_attrs(), @@ -801,11 +851,11 @@ class Filler(WidgetDecoration): set_body = WidgetDecoration._set_original_widget body = property(get_body, set_body) - def selectable(self): + def selectable(self) -> bool: """Return selectable from body.""" return self._original_widget.selectable() - def filler_values(self, size, focus): + def filler_values(self, size: tuple[int, int], focus: bool) -> tuple[int, int]: """ Return the number of rows to pad on the top and bottom. @@ -826,7 +876,7 @@ class Filler(WidgetDecoration): self.min_height, self.top, self.bottom) - def render(self, size, focus=False): + def render(self, size: tuple[int, int], focus: bool = False) -> CompositeCanvas: """Render self.original_widget with space above and/or below.""" (maxcol, maxrow) = size top, bottom = self.filler_values(size, focus) @@ -847,17 +897,16 @@ class Filler(WidgetDecoration): canv.pad_trim_top_bottom(top, bottom) return canv - - def keypress(self, size, key): + def keypress(self, size: tuple[int, int], key): """Pass keypress to self.original_widget.""" (maxcol, maxrow) = size if self.height_type == PACK: return self._original_widget.keypress((maxcol,), key) - top, bottom = self.filler_values((maxcol,maxrow), True) + top, bottom = self.filler_values((maxcol, maxrow), True) return self._original_widget.keypress((maxcol,maxrow-top-bottom), key) - def get_cursor_coords(self, size): + def get_cursor_coords(self, size: tuple[int, int]) -> tuple[int, int] | None: """Return cursor coords from self.original_widget if any.""" (maxcol, maxrow) = size if not hasattr(self._original_widget, 'get_cursor_coords'): @@ -876,7 +925,7 @@ class Filler(WidgetDecoration): y = maxrow-1 return x, y+top - def get_pref_col(self, size): + def get_pref_col(self, size: tuple[int, int]) -> int: """Return pref_col from self.original_widget if any.""" (maxcol, maxrow) = size if not hasattr(self._original_widget, 'get_pref_col'): @@ -891,7 +940,7 @@ class Filler(WidgetDecoration): return x - def move_cursor_to_coords(self, size, col, row): + def move_cursor_to_coords(self, size: tuple[int, int], col:int, row: int) -> bool: """Pass to self.original_widget.""" (maxcol, maxrow) = size if not hasattr(self._original_widget, 'move_cursor_to_coords'): @@ -907,7 +956,15 @@ class Filler(WidgetDecoration): return self._original_widget.move_cursor_to_coords( (maxcol, maxrow-top-bottom), col, row-top) - def mouse_event(self, size, event, button, col, row, focus): + def mouse_event( + self, + size: tuple[int, int], + event, + button: int, + col: int, + row: int, + focus: bool, + ) -> bool: """Pass to self.original_widget.""" (maxcol, maxrow) = size if not hasattr(self._original_widget, 'mouse_event'): @@ -923,6 +980,7 @@ class Filler(WidgetDecoration): return self._original_widget.mouse_event((maxcol, maxrow-top-bottom), event, button,col, row-top, focus) + class WidgetDisable(WidgetDecoration): """ A decoration widget that disables interaction with the widget it @@ -932,32 +990,44 @@ class WidgetDisable(WidgetDecoration): no_cache = ["rows"] ignore_focus = True - def selectable(self): + def selectable(self) -> Literal[False]: return False - def rows(self, size, focus=False): + + def rows(self, size, focus: bool = False) -> int: return self._original_widget.rows(size, False) + def sizing(self): return self._original_widget.sizing() - def pack(self, size, focus=False): + + def pack(self, size, focus: bool = False) -> tuple[int, int]: return self._original_widget.pack(size, False) - def render(self, size, focus=False): + + def render(self, size, focus: bool = False) -> CompositeCanvas: canv = self._original_widget.render(size, False) return CompositeCanvas(canv) -def normalize_align(align, err): + +def normalize_align( + align: Literal['left', 'center', 'right'] | tuple[Literal['relative'], int], + err: type[BaseException], +) -> tuple[Literal['left', 'center', 'right'], None] | tuple[Literal['relative'], int]: """ Split align into (align_type, align_amount). Raise exception err if align doesn't match a valid alignment. """ if align in (LEFT, CENTER, RIGHT): return (align, None) - elif type(align) == tuple and len(align) == 2 and align[0] == RELATIVE: + elif isinstance(align, tuple) and len(align) == 2 and align[0] == RELATIVE: return align - raise err("align value %r is not one of 'left', 'center', " - "'right', ('relative', percentage 0=left 100=right)" - % (align,)) + raise err( + f"align value {align!r} is not one of 'left', 'center', 'right', ('relative', percentage 0=left 100=right)" + ) -def simplify_align(align_type, align_amount): + +def simplify_align( + align_type: Literal['left', 'center', 'right', 'relative'], + align_amount: int | None, +) -> Literal['left', 'center', 'right'] | tuple[Literal['relative'], int]: """ Recombine (align_type, align_amount) into an align value. Inverse of normalize_align. @@ -966,22 +1036,31 @@ def simplify_align(align_type, align_amount): return (align_type, align_amount) return align_type -def normalize_width(width, err): + +def normalize_width( + width: Literal['clip', 'pack'] | int | tuple[Literal['relative'], int], + err: type[BaseException], +) -> tuple[Literal['clip', 'pack'], None] | tuple[Literal['given', 'relative'], int]: """ Split width into (width_type, width_amount). Raise exception err if width doesn't match a valid alignment. """ if width in (CLIP, PACK): return (width, None) - elif type(width) == int: + elif isinstance(width, int): return (GIVEN, width) - elif type(width) == tuple and len(width) == 2 and width[0] == RELATIVE: + elif isinstance(width, tuple) and len(width) == 2 and width[0] == RELATIVE: return width - raise err("width value %r is not one of fixed number of columns, " - "'pack', ('relative', percentage of total width), 'clip'" - % (width,)) + raise err( + f"width value {width!r} is not one of" + f"fixed number of columns, 'pack', ('relative', percentage of total width), 'clip'" + ) + -def simplify_width(width_type, width_amount): +def simplify_width( + width_type: Literal['clip', 'pack', 'given', 'relative'], + width_amount: int | None, +) -> Literal['clip', 'pack'] | int | tuple[Literal['relative'], int]: """ Recombine (width_type, width_amount) into an width value. Inverse of normalize_width. @@ -992,21 +1071,28 @@ def simplify_width(width_type, width_amount): return width_amount return (width_type, width_amount) -def normalize_valign(valign, err): + +def normalize_valign( + valign: Literal["top", "middle", "bottom"] | tuple[Literal["relative"], int], + err: type[BaseException], +) -> tuple[Literal["top", "middle", "bottom"], None] | tuple[Literal["relative"], int]: """ Split align into (valign_type, valign_amount). Raise exception err if align doesn't match a valid alignment. """ if valign in (TOP, MIDDLE, BOTTOM): return (valign, None) - elif (isinstance(valign, tuple) and len(valign) == 2 and - valign[0] == RELATIVE): + elif isinstance(valign, tuple) and len(valign) == 2 and valign[0] == RELATIVE: return valign - raise err("valign value %r is not one of 'top', 'middle', " - "'bottom', ('relative', percentage 0=left 100=right)" - % (valign,)) + raise err( + f"valign value {valign!r} is not one of 'top', 'middle', 'bottom', ('relative', percentage 0=left 100=right)" + ) + -def simplify_valign(valign_type, valign_amount): +def simplify_valign( + valign_type: Literal["top", "middle", "bottom", "relative"], + valign_amount: int | None, +) -> Literal["top", "middle", "bottom"] | tuple[Literal["relative"], int]: """ Recombine (valign_type, valign_amount) into an valign value. Inverse of normalize_valign. @@ -1015,23 +1101,31 @@ def simplify_valign(valign_type, valign_amount): return (valign_type, valign_amount) return valign_type -def normalize_height(height, err): + +def normalize_height( + height: int | Literal['flow', 'pack'] | tuple[Literal['relative'], int], + err: type[BaseException], +) -> tuple[Literal['flow', 'pack'], None] | tuple[Literal['relative', 'given'], int]: """ Split height into (height_type, height_amount). Raise exception err if height isn't valid. """ if height in (FLOW, PACK): return (height, None) - elif (isinstance(height, tuple) and len(height) == 2 and - height[0] == RELATIVE): + elif (isinstance(height, tuple) and len(height) == 2 and height[0] == RELATIVE): return height elif isinstance(height, int): return (GIVEN, height) - raise err("height value %r is not one of fixed number of columns, " - "'pack', ('relative', percentage of total height)" - % (height,)) + raise err( + f"height value {height!r} is not one of " + f"fixed number of columns, 'pack', ('relative', percentage of total height)" + ) -def simplify_height(height_type, height_amount): + +def simplify_height( + height_type: Literal['flow', 'pack', 'relative', 'given'], + height_amount: int | None, +) -> int | Literal['flow', 'pack'] | tuple[Literal['relative'], int]: """ Recombine (height_type, height_amount) into an height value. Inverse of normalize_height. @@ -1043,8 +1137,16 @@ def simplify_height(height_type, height_amount): return (height_type, height_amount) -def calculate_top_bottom_filler(maxrow, valign_type, valign_amount, height_type, - height_amount, min_height, top, bottom): +def calculate_top_bottom_filler( + maxrow: int, + valign_type: Literal['top', 'middle', 'bottom', 'relative'], + valign_amount: int, + height_type: Literal['given', 'relative', 'clip'], + height_amount: int, + min_height: int | None, + top: int, + bottom: int, +) -> tuple[int, int]: """ Return the amount of filler (or clipping) on the top and bottom part of maxrow rows to satisfy the following: @@ -1109,8 +1211,16 @@ def calculate_top_bottom_filler(maxrow, valign_type, valign_amount, height_type, return top, bottom -def calculate_left_right_padding(maxcol, align_type, align_amount, - width_type, width_amount, min_width, left, right): +def calculate_left_right_padding( + maxcol: int, + align_type: Literal['left', 'center', 'right'], + align_amount: int, + width_type: Literal['fixed', 'relative', 'clip'], + width_amount: int, + min_width: int | None, + left: int, + right: int, +) -> tuple[int, int]: """ Return the amount of padding (or clipping) on the left and right part of maxcol columns to satisfy the following: @@ -1154,7 +1264,7 @@ def calculate_left_right_padding(maxcol, align_type, align_amount, else: width = width_amount - standard_alignments = {LEFT:0, CENTER:50, RIGHT:100} + standard_alignments = {LEFT: 0, CENTER: 50, RIGHT: 100} align = standard_alignments.get(align_type, align_amount) # add the remainder of left/right the padding @@ -1163,11 +1273,11 @@ def calculate_left_right_padding(maxcol, align_type, align_amount, left = maxcol - width - right # reduce padding if we are clipping an edge - if right < 0 and left > 0: + if right < 0 < left: shift = min(left, -right) left -= shift right += shift - elif left < 0 and right > 0: + elif left < 0 < right: shift = min(right, -left) right -= shift left += shift @@ -1180,10 +1290,10 @@ def calculate_left_right_padding(maxcol, align_type, align_amount, return left, right - def _test(): import doctest doctest.testmod() + if __name__=='__main__': _test() diff --git a/urwid/display_common.py b/urwid/display_common.py index b135a5f..cceaf07 100755 --- a/urwid/display_common.py +++ b/urwid/display_common.py @@ -964,7 +964,7 @@ class BaseScreen(metaclass=signals.MetaSignals): """ basic = AttrSpec(foreground, background, 16) - if type(mono) == tuple: + if isinstance(mono, tuple): # old style of specifying mono attributes was to put them # in a tuple. convert to comma-separated string mono = ",".join(mono) @@ -1004,5 +1004,6 @@ def _test(): import doctest doctest.testmod() + if __name__=='__main__': _test() diff --git a/urwid/escape.py b/urwid/escape.py index ec8da4e..c475da1 100644 --- a/urwid/escape.py +++ b/urwid/escape.py @@ -27,6 +27,7 @@ Terminal Escape Sequences for input and display from __future__ import annotations import re +from collections.abc import MutableMapping, Sequence try: from urwid import str_util @@ -137,36 +138,36 @@ input_sequences = [ ('[0n', 'status ok') ] + class KeyqueueTrie: - def __init__( self, sequences ): + def __init__(self, sequences) -> None: self.data = {} for s, result in sequences: - assert type(result) != dict + assert not isinstance(result, dict) self.add(self.data, s, result) def add(self, root, s, result): - assert type(root) == dict, "trie conflict detected" + assert isinstance(root, MutableMapping), "trie conflict detected" assert len(s) > 0, "trie conflict detected" if ord(s[0]) in root: return self.add(root[ord(s[0])], s[1:], result) - if len(s)>1: + if len(s) > 1: d = {} root[ord(s[0])] = d return self.add(d, s[1:], result) root[ord(s)] = result - def get(self, keys, more_available): + def get(self, keys, more_available: bool): result = self.get_recurse(self.data, keys, more_available) if not result: result = self.read_cursor_position(keys, more_available) return result - def get_recurse(self, root, keys, more_available): - if type(root) != dict: + def get_recurse(self, root, keys, more_available: bool): + if not isinstance(root, MutableMapping): if root == "mouse": - return self.read_mouse_info(keys, - more_available) + return self.read_mouse_info(keys, more_available) elif root == "sgrmouse": return self.read_sgrmouse_info (keys, more_available) return (root, keys) @@ -179,7 +180,7 @@ class KeyqueueTrie: return None return self.get_recurse(root[keys[0]], keys[1:], more_available) - def read_mouse_info(self, keys, more_available): + def read_mouse_info(self, keys, more_available: bool): if len(keys) < 3: if more_available: raise MoreInputRequired() @@ -212,7 +213,7 @@ class KeyqueueTrie: return ( (f"{prefix}mouse {action}", button, x, y), keys[3:] ) - def read_sgrmouse_info(self, keys, more_available): + def read_sgrmouse_info(self, keys, more_available: bool): # Helpful links: # https://stackoverflow.com/questions/5966903/how-to-get-mousemove-and-mouseclick-in-bash # http://invisible-island.net/xterm/ctlseqs/ctlseqs.pdf @@ -227,7 +228,7 @@ class KeyqueueTrie: found_m = False for k in keys: value = value + chr(k) - if ((k is ord('M')) or (k is ord('m'))): + if (k is ord('M')) or (k is ord('m')): found_m = True break pos_m += 1 @@ -261,8 +262,7 @@ class KeyqueueTrie: return ( (f"mouse {action}", button, x, y), keys[pos_m + 1:] ) - - def read_cursor_position(self, keys, more_available): + def read_cursor_position(self, keys, more_available: bool): """ Interpret cursor position information being sent by the user's terminal. Returned as ('cursor position', x, y) @@ -305,14 +305,11 @@ class KeyqueueTrie: if not x and k == ord('0'): return None x = x * 10 + k - ord('0') - if not keys[i:]: - if more_available: - raise MoreInputRequired() + if not keys[i:] and more_available: + raise MoreInputRequired() return None - - # This is added to button value to signal mouse release by curses_display # and raw_display when we know which button was released. NON-STANDARD MOUSE_RELEASE_FLAG = 2048 @@ -366,8 +363,7 @@ _keyconv = { } - -def process_keyqueue(codes, more_available): +def process_keyqueue(codes: Sequence[int], more_available: bool): """ codes -- list of key codes more_available -- if True then raise MoreInputRequired when in the @@ -377,46 +373,44 @@ def process_keyqueue(codes, more_available): returns (list of input, list of remaining key codes). """ code = codes[0] - if code >= 32 and code <= 126: + if 32 <= code <= 126: key = chr(code) return [key], codes[1:] if code in _keyconv: return [_keyconv[code]], codes[1:] - if code >0 and code <27: + if 0 < code < 27: return [f"ctrl {chr(ord('a') + code - 1)}"], codes[1:] - if code >27 and code <32: + if 27 < code < 32: return [f"ctrl {chr(ord('A') + code - 1)}"], codes[1:] em = str_util.get_byte_encoding() - if (em == 'wide' and code < 256 and - within_double_byte(chr(code),0,0)): - if not codes[1:]: - if more_available: - raise MoreInputRequired() + if em == 'wide' and code < 256 and within_double_byte(chr(code), 0, 0): + if not codes[1:] and more_available: + raise MoreInputRequired() if codes[1:] and codes[1] < 256: db = chr(code)+chr(codes[1]) if within_double_byte(db, 0, 1): return [db], codes[2:] - if em == 'utf8' and code>127 and code<256: - if code & 0xe0 == 0xc0: # 2-byte form + if em == 'utf8' and 127 < code < 256: + if code & 0xe0 == 0xc0: # 2-byte form need_more = 1 - elif code & 0xf0 == 0xe0: # 3-byte form + elif code & 0xf0 == 0xe0: # 3-byte form need_more = 2 - elif code & 0xf8 == 0xf0: # 4-byte form + elif code & 0xf8 == 0xf0: # 4-byte form need_more = 3 else: - return ["<%d>"%code], codes[1:] + return [f"<{code:d}>"], codes[1:] for i in range(need_more): if len(codes)-1 <= i: if more_available: raise MoreInputRequired() else: - return ["<%d>"%code], codes[1:] + return [f"<{code:d}>"], codes[1:] k = codes[i+1] if k>256 or k&0xc0 != 0x80: - return ["<%d>"%code], codes[1:] + return [f"<{code:d}>"], codes[1:] s = bytes(codes[:need_more+1]) @@ -424,13 +418,13 @@ def process_keyqueue(codes, more_available): try: return [s.decode("utf-8")], codes[need_more+1:] except UnicodeDecodeError: - return ["<%d>"%code], codes[1:] + return [f"<{code:d}>"], codes[1:] - if code >127 and code <256: + if 127 < code < 256: key = chr(code) return [key], codes[1:] if code != 27: - return ["<%d>"%code], codes[1:] + return [f"<{code:d}>"], codes[1:] result = input_trie.get(codes[1:], more_available) @@ -440,8 +434,7 @@ def process_keyqueue(codes, more_available): if codes[1:]: # Meta keys -- ESC+Key form - run, remaining_codes = process_keyqueue(codes[1:], - more_available) + run, remaining_codes = process_keyqueue(codes[1:], more_available) if urwid.util.is_mouse_event(run[0]): return ['esc'] + run, remaining_codes if run[0] == "esc" or run[0].find("meta ") >= 0: @@ -475,22 +468,30 @@ REPORT_CURSOR_POSITION = f"{ESC}[6n" INSERT_ON = f"{ESC}[4h" INSERT_OFF = f"{ESC}[4l" -def set_cursor_position( x, y ): - assert type(x) == int - assert type(y) == int - return ESC+"[%d;%dH" %(y+1, x+1) -def move_cursor_right(x): - if x < 1: return "" - return ESC+"[%dC" % x +def set_cursor_position(x: int, y: int) -> str: + assert isinstance(x, int) + assert isinstance(y, int) + return ESC + f"[{y + 1:d};{x + 1:d}H" + + +def move_cursor_right(x: int) -> str: + if x < 1: + return "" + return ESC + f"[{x:d}C" + + +def move_cursor_up(x: int) -> str: + if x < 1: + return "" + return ESC + f"[{x:d}A" + -def move_cursor_up(x): - if x < 1: return "" - return ESC+"[%dA" % x +def move_cursor_down(x: int) -> str: + if x < 1: + return "" + return ESC + f"[{x:d}B" -def move_cursor_down(x): - if x < 1: return "" - return ESC+"[%dB" % x HIDE_CURSOR = f"{ESC}[?25l" SHOW_CURSOR = f"{ESC}[?25h" diff --git a/urwid/font.py b/urwid/font.py index 42dc647..295bcf1 100755 --- a/urwid/font.py +++ b/urwid/font.py @@ -80,13 +80,17 @@ def separate_glyphs(gdata, height): c = None return dout, utf8_required + _all_fonts = [] + + def get_all_fonts(): """ Return a list of (font name, font class) tuples. """ return _all_fonts[:] + def add_font(name, cls): _all_fonts.append((name, cls)) @@ -103,7 +107,7 @@ class Font: self.add_glyphs(gdata) @staticmethod - def _to_text(obj, encoding='utf-8', errors='strict'): + def _to_text(obj, encoding='utf-8', errors='strict') -> str: if isinstance(obj, str): return obj elif isinstance(obj, bytes): @@ -114,12 +118,12 @@ class Font: self.char.update(d) self.utf8_required |= utf8_required - def characters(self): + def characters(self) -> str: l = sorted(self.char) return "".join(l) - def char_width(self, c): + def char_width(self, c) -> int: if c in self.char: return self.char[c][0] return 0 @@ -143,7 +147,6 @@ class Font: return canv - #safe_palette = u"┘┐┌└┼─├┤┴┬│" #more_palette = u"═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡╢╣╤╥╦╧╨╩╪╫╬○" #block_palette = u"▄#█#▀#▌#▐#▖#▗#▘#▙#▚#▛#▜#▝#▞#▟" @@ -436,7 +439,9 @@ tttttuuuuuuuvvvvvvvwwwwwwwwxxxxxxxyyyyyyyzzzzzzz █▌ ▀███▀ ▐█▌ ▀█▌▐█▀ ▐█ █▌ ▀▀▀█▌▐█████▌ ▀███▀ """] -add_font("Half Block 7x7",HalfBlock7x7Font) + + +add_font("Half Block 7x7", HalfBlock7x7Font) if __name__ == "__main__": @@ -449,7 +454,7 @@ if __name__ == "__main__": u = "" if f.utf8_required: u = "(U)" - print(("%-20s %3s " % (n,u)), end=' ') + print(f"{n:<20} {u:>3} ", end=' ') c = f.characters() if c == all_ascii: print("Full ASCII") diff --git a/urwid/graphics.py b/urwid/graphics.py index 71c0791..f671c81 100755 --- a/urwid/graphics.py +++ b/urwid/graphics.py @@ -22,13 +22,9 @@ from __future__ import annotations -from urwid.canvas import ( - CanvasCombine, - CanvasJoin, - CompositeCanvas, - SolidCanvas, - TextCanvas, -) +import typing + +from urwid.canvas import CanvasCombine, CanvasJoin, CompositeCanvas, SolidCanvas, TextCanvas from urwid.container import Columns, Pile from urwid.decoration import WidgetDecoration from urwid.display_common import AttrSpec @@ -50,6 +46,9 @@ from urwid.widget import ( nocache_widget_render_instance, ) +if typing.TYPE_CHECKING: + from typing_extensions import Literal + class BigText(Widget): _sizing = frozenset([FIXED]) @@ -116,11 +115,21 @@ class BigText(Widget): class LineBox(WidgetDecoration, WidgetWrap): - def __init__(self, original_widget, title="", - title_align="center", title_attr=None, - tlcorner='┌', tline='─', lline='│', - trcorner='┐', blcorner='└', rline='│', - bline='─', brcorner='┘'): + def __init__( + self, + original_widget: Widget, + title: str = "", + title_align: Literal['left', 'center', 'right'] = "center", + title_attr=None, + tlcorner: str = '┌', + tline: str = '─', + lline: str = '│', + trcorner: str = '┐', + blcorner: str = '└', + rline: str = '│', + bline: str = '─', + brcorner: str = '┘', + ) -> None: """ Draw a line around original_widget. @@ -232,7 +241,7 @@ class LineBox(WidgetDecoration, WidgetWrap): self.title_widget.set_text(self.format_title(text)) self.tline_widget._invalidate() - def pack(self, size=None, focus=False): + def pack(self, size=None, focus: bool = False): """ Return the number of screen columns and rows required for this Linebox widget to be displayed without wrapping or @@ -275,9 +284,11 @@ def nocache_bargraph_get_data(self, get_data_fn): self.render = nocache_widget_render_instance(self) self._get_data = get_data_fn + class BarGraphError(Exception): pass + class BarGraph(Widget, metaclass=BarGraphMeta): _sizing = frozenset([BOX]) @@ -337,7 +348,7 @@ class BarGraph(Widget, metaclass=BarGraphMeta): raise BarGraphError(f"attlist must include at least background and seg1: {attlist!r}") assert len(attlist) >= 2, 'must at least specify bg and fg!' for a in attlist: - if type(a) != tuple: + if not isinstance(a, tuple): self.attr.append(a) self.char.append(' ') else: @@ -348,7 +359,7 @@ class BarGraph(Widget, metaclass=BarGraphMeta): self.hatt = [] if hatt is None: hatt = [self.attr[0]] - elif type(hatt) != list: + elif not isinstance(hatt, list): hatt = [hatt] self.hatt = hatt @@ -359,9 +370,9 @@ class BarGraph(Widget, metaclass=BarGraphMeta): (fg, bg), attr = i except ValueError: raise BarGraphError(f"satt not in (fg,bg:attr) form: {i!r}") - if type(fg) != int or fg >= len(attlist): + if not isinstance(fg, int) or fg >= len(attlist): raise BarGraphError(f"fg not valid integer: {fg!r}") - if type(bg) != int or bg >= len(attlist): + if not isinstance(bg, int) or bg >= len(attlist): raise BarGraphError(f"bg not valid integer: {fg!r}") if fg <= bg: raise BarGraphError(f"fg ({fg}) not > bg ({bg})") @@ -391,7 +402,7 @@ class BarGraph(Widget, metaclass=BarGraphMeta): self.data = bardata, top, hlines self._invalidate() - def _get_data(self, size): + def _get_data(self, size: tuple[int, int]): """ Return (bardata, top, hlines) @@ -410,7 +421,7 @@ class BarGraph(Widget, metaclass=BarGraphMeta): return bardata, top, hlines - def set_bar_width(self, width): + def set_bar_width(self, width: int | None): """ Set a preferred bar width for calculate_bar_widths to use. @@ -420,7 +431,7 @@ class BarGraph(Widget, metaclass=BarGraphMeta): self.bar_width = width self._invalidate() - def calculate_bar_widths(self, size, bardata): + def calculate_bar_widths(self, size: tuple[int, int], bardata): """ Return a list of bar widths, one for each bar in data. @@ -446,16 +457,16 @@ class BarGraph(Widget, metaclass=BarGraphMeta): remain -= 1 return widths - def selectable(self): + def selectable(self) -> Literal[False]: """ Return False. """ return False - def use_smoothed(self): + def use_smoothed(self) -> bool: return self.satt and get_encoding_mode() == "utf8" - def calculate_display(self, size): + def calculate_display(self, size: tuple[int, int]): """ Calculate display data. """ @@ -477,7 +488,7 @@ class BarGraph(Widget, metaclass=BarGraphMeta): return disp - def hlines_display(self, disp, top, hlines, maxrow): + def hlines_display(self, disp, top: int, hlines, maxrow: int): """ Add hlines to display structure represented as bar_type tuple values: @@ -523,8 +534,7 @@ class BarGraph(Widget, metaclass=BarGraphMeta): def fill_row(row, chnum): rout = [] for bar_type, width in row: - if (type(bar_type) == int and - len(self.hatt) > bar_type): + if (isinstance(bar_type, int) and len(self.hatt) > bar_type): rout.append(((bar_type, chnum), width)) continue rout.append((bar_type, width)) @@ -574,7 +584,7 @@ class BarGraph(Widget, metaclass=BarGraphMeta): l1 = (bt1, w1 - w2) elif w2 > w1: l2 = (bt2, w2 - w1) - if type(bt1) == tuple: + if isinstance(bt1, tuple): return (bt1, wmin), l1, l2 if (bt2, bt1) not in self.satt: if r < 4: @@ -582,7 +592,7 @@ class BarGraph(Widget, metaclass=BarGraphMeta): return (bt1, wmin), l1, l2 return ((bt2, bt1, 8 - r), wmin), l1, l2 - def row_combine_last(count, row): + def row_combine_last(count: int, row): o_count, o_row = o[-1] row = row[:] # shallow copy, so we don't destroy orig. o_row = o_row[:] @@ -623,7 +633,7 @@ class BarGraph(Widget, metaclass=BarGraphMeta): r = y_count return [(y // 8, row) for (y, row) in o] - def render(self, size, focus=False): + def render(self, size: tuple[int, int], focus: bool = False) -> CompositeCanvas: """ Render BarGraph. """ @@ -634,7 +644,7 @@ class BarGraph(Widget, metaclass=BarGraphMeta): for y_count, row in disp: l = [] for bar_type, width in row: - if type(bar_type) == tuple: + if isinstance(bar_type, tuple): if len(bar_type) == 3: # vertical eighths fg, bg, k = bar_type @@ -657,7 +667,7 @@ class BarGraph(Widget, metaclass=BarGraphMeta): return canv -def calculate_bargraph_display(bardata, top, bar_widths, maxrow): +def calculate_bargraph_display(bardata, top, bar_widths, maxrow: int): """ Calculate a rendering of the bar graph described by data, bar_widths and height. @@ -689,7 +699,7 @@ def calculate_bargraph_display(bardata, top, bar_widths, maxrow): # build intermediate data structure rows = [None] * maxrow - def add_segment(seg_num, col, row, width, rows=rows): + def add_segment(seg_num: int, col: int, row: int, width: int, rows=rows) -> None: if rows[row]: last_seg, last_col, last_end = rows[row][-1] if last_end > col: @@ -832,13 +842,13 @@ class GraphVScale(Widget): self.txt.append(Text(markup)) self.top = top - def selectable(self): + def selectable(self) -> Literal[False]: """ Return False. """ return False - def render(self, size, focus=False): + def render(self, size: tuple[int, int], focus: bool = False): """ Render GraphVScale. """ @@ -870,7 +880,7 @@ class GraphVScale(Widget): -def scale_bar_values( bar, top, maxrow ): +def scale_bar_values(bar, top, maxrow: int): """ Return a list of bar values aliased to integer values of maxrow. """ @@ -884,7 +894,7 @@ class ProgressBar(Widget): text_align = CENTER - def __init__(self, normal, complete, current=0, done=100, satt=None): + def __init__(self, normal, complete, current: int = 0, done: int = 100, satt=None): """ :param normal: display attribute for incomplete part of progress bar :param complete: display attribute for complete part of progress bar @@ -939,10 +949,10 @@ class ProgressBar(Widget): self._invalidate() done = property(lambda self: self._done, _set_done) - def rows(self, size, focus=False): + def rows(self, size, focus: bool = False) -> int: return 1 - def get_text(self): + def get_text(self) -> str: """ Return the progress bar percentage text. You can override this method to display custom text. @@ -950,7 +960,7 @@ class ProgressBar(Widget): percent = min(100, max(0, int(self.current * 100 / self.done))) return f"{str(percent)} %" - def render(self, size, focus=False): + def render(self, size: tuple[int], focus: bool = False): """ Render the progress bar. """ @@ -993,7 +1003,7 @@ class ProgressBar(Widget): class PythonLogo(Widget): _sizing = frozenset([FIXED]) - def __init__(self): + def __init__(self) -> None: """ Create canvas containing an ASCII version of the Python Logo and store it. @@ -1008,22 +1018,24 @@ class PythonLogo(Widget): (blu, " |__| "), (yel, "______|\n"), (yel, " |____o_|")]).render((width,)) - def pack(self, size=None, focus=False): + def pack(self, size=None, focus: bool = False): """ Return the size from our pre-rendered canvas. """ return self._canvas.cols(), self._canvas.rows() - def render(self, size, focus=False): + def render(self, size, focus: bool = False): """ Return the pre-rendered canvas. """ fixed_size(size) return self._canvas + def _test(): import doctest doctest.testmod() + if __name__=='__main__': _test() diff --git a/urwid/html_fragment.py b/urwid/html_fragment.py index b14c57f..c5ce1f2 100755 --- a/urwid/html_fragment.py +++ b/urwid/html_fragment.py @@ -220,16 +220,16 @@ def screenshot_init( sizes, keys ): """ try: for (row,col) in sizes: - assert type(row) == int - assert row>0 and col>0 + assert isinstance(row, int) + assert row > 0 and col > 0 except (AssertionError, ValueError): raise Exception("sizes must be in the form [ (col1,row1), (col2,row2), ...]") try: for l in keys: - assert type(l) == list + assert isinstance(l, list) for k in l: - assert type(k) == str + assert isinstance(k, str) except (AssertionError, ValueError): raise Exception("keys must be in the form [ [keyA1, keyA2, ..], [keyB1, ..], ...]") diff --git a/urwid/listbox.py b/urwid/listbox.py index 4a5b5ae..d4dddc4 100644 --- a/urwid/listbox.py +++ b/urwid/listbox.py @@ -22,6 +22,9 @@ from __future__ import annotations +import typing +from collections.abc import MutableSequence + from urwid import signals from urwid.canvas import CanvasCombine, SolidCanvas from urwid.command_map import ( @@ -39,10 +42,16 @@ from urwid.signals import connect_signal, disconnect_signal from urwid.util import is_mouse_press from urwid.widget import BOX, GIVEN, Widget, nocache_widget_render_instance +if typing.TYPE_CHECKING: + from typing_extensions import Literal + + from urwid.canvas import CompositeCanvas + class ListWalkerError(Exception): pass + class ListWalker(metaclass=signals.MetaSignals): signals = ["modified"] @@ -88,8 +97,9 @@ class ListWalker(metaclass=signals.MetaSignals): except (IndexError, KeyError): return None, None + class SimpleListWalker(MonitoredList, ListWalker): - def __init__(self, contents, wrap_around=False): + def __init__(self, contents, wrap_around: bool = False): """ contents -- list to copy into this object @@ -102,7 +112,7 @@ class SimpleListWalker(MonitoredList, ListWalker): detected automatically and will cause ListBox objects using this list walker to be updated. """ - if not getattr(contents, '__getitem__', None): + if not isinstance(contents, MutableSequence): raise ListWalkerError(f"SimpleListWalker expecting list like object, got: {contents!r}") MonitoredList.__init__(self, contents) self.focus = 0 @@ -122,17 +132,16 @@ class SimpleListWalker(MonitoredList, ListWalker): self.focus = max(0, len(self)-1) ListWalker._modified(self) - def set_modified_callback(self, callback): + def set_modified_callback(self, callback) -> typing.NoReturn: """ This function inherited from MonitoredList is not implemented in SimpleListWalker. Use connect_signal(list_walker, "modified", ...) instead. """ - raise NotImplementedError('Use connect_signal(' - 'list_walker, "modified", ...) instead.') + raise NotImplementedError('Use connect_signal(list_walker, "modified", ...) instead.') - def set_focus(self, position): + def set_focus(self, position: int) -> None: """Set focus position.""" try: if position < 0 or position >= len(self): @@ -142,7 +151,7 @@ class SimpleListWalker(MonitoredList, ListWalker): self.focus = position self._modified() - def next_position(self, position): + def next_position(self, position: int) -> int: """ Return position after start_from. """ @@ -152,7 +161,7 @@ class SimpleListWalker(MonitoredList, ListWalker): raise IndexError return position + 1 - def prev_position(self, position): + def prev_position(self, position: int) -> int: """ Return position before start_from. """ @@ -162,7 +171,7 @@ class SimpleListWalker(MonitoredList, ListWalker): raise IndexError return position - 1 - def positions(self, reverse=False): + def positions(self, reverse: bool = False): """ Optional method for returning an iterable of positions. """ @@ -189,28 +198,26 @@ class SimpleFocusListWalker(ListWalker, MonitoredFocusList): normal list methods will cause the focus to be updated intelligently. """ - if not getattr(contents, '__getitem__', None): - raise ListWalkerError("SimpleFocusListWalker expecting list like " - "object, got: %r"%(contents,)) + if not isinstance(contents, MutableSequence): + raise ListWalkerError(f"SimpleFocusListWalker expecting list like object, got: {contents!r}") MonitoredFocusList.__init__(self, contents) self.wrap_around = wrap_around - def set_modified_callback(self, callback): + def set_modified_callback(self, callback) -> typing.NoReturn: """ This function inherited from MonitoredList is not implemented in SimpleFocusListWalker. Use connect_signal(list_walker, "modified", ...) instead. """ - raise NotImplementedError('Use connect_signal(' - 'list_walker, "modified", ...) instead.') + raise NotImplementedError('Use connect_signal(list_walker, "modified", ...) instead.') - def set_focus(self, position): + def set_focus(self, position: int) -> None: """Set focus position.""" self.focus = position self._modified() - def next_position(self, position): + def next_position(self, position: int) -> int: """ Return position after start_from. """ @@ -220,7 +227,7 @@ class SimpleFocusListWalker(ListWalker, MonitoredFocusList): raise IndexError return position + 1 - def prev_position(self, position): + def prev_position(self, position: int) -> int: """ Return position before start_from. """ @@ -230,7 +237,7 @@ class SimpleFocusListWalker(ListWalker, MonitoredFocusList): raise IndexError return position - 1 - def positions(self, reverse=False): + def positions(self, reverse: bool = False): """ Optional method for returning an iterable of positions. """ @@ -242,6 +249,7 @@ class SimpleFocusListWalker(ListWalker, MonitoredFocusList): class ListBoxError(Exception): pass + class ListBox(Widget, WidgetContainerMixin): """ a horizontally stacked list of widgets @@ -276,7 +284,6 @@ class ListBox(Widget, WidgetContainerMixin): # variable for delayed valign change used by set_focus_valign self.set_focus_valign_pending = None - def _get_body(self): return self._body @@ -304,8 +311,7 @@ class ListBox(Widget, WidgetContainerMixin): widgets to be displayed inside the list box """) - - def calculate_visible(self, size, focus=False ): + def calculate_visible(self, size: tuple[int, int], focus: bool = False): """ Returns the widgets that would be displayed in the ListBox given the current *size* and *focus*. @@ -439,8 +445,7 @@ class ListBox(Widget, WidgetContainerMixin): focus_pos, focus_rows, cursor ), (trim_top, fill_above), (trim_bottom, fill_below)) - - def render(self, size, focus=False ): + def render(self, size: tuple[int, int], focus: bool = False) -> CompositeCanvas | SolidCanvas: """ Render ListBox and return canvas. @@ -459,21 +464,32 @@ class ListBox(Widget, WidgetContainerMixin): combinelist = [] rows = 0 - fill_above.reverse() # fill_above is in bottom-up order + fill_above.reverse() # fill_above is in bottom-up order for widget,w_pos,w_rows in fill_above: canvas = widget.render((maxcol,)) if w_rows != canvas.rows(): - raise ListBoxError("Widget %r at position %r within listbox calculated %d rows but rendered %d!"% (widget,w_pos,w_rows, canvas.rows())) + raise ListBoxError( + f"Widget {widget!r} at position {w_pos!r} " + f"within listbox calculated {w_rows:d} rows " + f"but rendered {canvas.rows():d}!" + ) rows += w_rows combinelist.append((canvas, w_pos, False)) focus_canvas = focus_widget.render((maxcol,), focus=focus) if focus_canvas.rows() != focus_rows: - raise ListBoxError("Focus Widget %r at position %r within listbox calculated %d rows but rendered %d!"% (focus_widget,focus_pos,focus_rows, focus_canvas.rows())) + raise ListBoxError( + f"Focus Widget {focus_widget!r} at position {focus_pos!r} " + f"within listbox calculated {focus_rows:d} rows " + f"but rendered {focus_canvas.rows():d}!" + ) c_cursor = focus_canvas.cursor if cursor is not None and cursor != c_cursor: - raise ListBoxError(f"Focus Widget {focus_widget!r} at position {focus_pos!r} within listbox calculated cursor coords {cursor!r} but rendered cursor coords {c_cursor!r}!") + raise ListBoxError( + f"Focus Widget {focus_widget!r} at position {focus_pos!r} " + f"within listbox calculated cursor coords {cursor!r} " + f"but rendered cursor coords {c_cursor!r}!") rows += focus_rows combinelist.append((focus_canvas, focus_pos, True)) @@ -481,7 +497,10 @@ class ListBox(Widget, WidgetContainerMixin): for widget,w_pos,w_rows in fill_below: canvas = widget.render((maxcol,)) if w_rows != canvas.rows(): - raise ListBoxError("Widget %r at position %r within listbox calculated %d rows but rendered %d!"% (widget,w_pos,w_rows, canvas.rows())) + raise ListBoxError( + f"Widget {widget!r} at position {w_pos!r} " + f"within listbox calculated {w_rows:d} " + f"rows but rendered {canvas.rows():d}!") rows += w_rows combinelist.append((canvas, w_pos, False)) @@ -495,19 +514,22 @@ class ListBox(Widget, WidgetContainerMixin): rows -= trim_bottom if rows > maxrow: - raise ListBoxError(f"Listbox contents too long! Probably urwid's fault (please report): {top, middle, bottom!r}") + raise ListBoxError( + f"Listbox contents too long! Probably urwid's fault (please report): {top, middle, bottom!r}" + ) if rows < maxrow: bottom_pos = focus_pos if fill_below: bottom_pos = fill_below[-1][1] if trim_bottom != 0 or self._body.get_next(bottom_pos) != (None,None): - raise ListBoxError(f"Listbox contents too short! Probably urwid's fault (please report): {top, middle, bottom!r}") + raise ListBoxError( + f"Listbox contents too short! Probably urwid's fault (please report): {top, middle, bottom!r}" + ) final_canvas.pad_trim_top_bottom(0, maxrow - rows) return final_canvas - - def get_cursor_coords(self, size): + def get_cursor_coords(self, size: tuple[int, int]) -> tuple[int, int] | None: """ See :meth:`Widget.get_cursor_coords` for details """ @@ -528,8 +550,10 @@ class ListBox(Widget, WidgetContainerMixin): return None return (x, y) - - def set_focus_valign(self, valign): + def set_focus_valign( + self, + valign: Literal['top', 'middle', 'bottom'] | tuple[Literal['fixed top', 'fixed bottom', 'relative'], int], + ): """Set the focus widget's display offset and inset. :param valign: one of: @@ -538,11 +562,10 @@ class ListBox(Widget, WidgetContainerMixin): ('fixed bottom', rows) ('relative', percentage 0=top 100=bottom) """ - vt, va = normalize_valign(valign,ListBoxError) + vt, va = normalize_valign(valign, ListBoxError) self.set_focus_valign_pending = vt, va - - def set_focus(self, position, coming_from=None): + def set_focus(self, position, coming_from: Literal['above', 'below'] | None = None) -> None: """ Set the focus position and try to keep the old focus in view. @@ -597,6 +620,7 @@ class ListBox(Widget, WidgetContainerMixin): class ListBoxContents: __getitem__ = self._contents__getitem__ return ListBoxContents() + def _contents__getitem__(self, key): # try list walker protocol v2 first getitem = getattr(self._body, '__getitem__', None) @@ -640,13 +664,13 @@ class ListBox(Widget, WidgetContainerMixin): """ return None - def _set_focus_valign_complete(self, size, focus): + def _set_focus_valign_complete(self, size: tuple[int, int], focus: bool) -> None: """ Finish setting the offset and inset now that we have have a maxcol & maxrow. """ (maxcol, maxrow) = size - vt,va = self.set_focus_valign_pending + vt, va = self.set_focus_valign_pending self.set_focus_valign_pending = None self.set_focus_pending = None @@ -655,12 +679,11 @@ class ListBox(Widget, WidgetContainerMixin): return rows = focus_widget.rows((maxcol,), focus) - rtop, rbot = calculate_top_bottom_filler(maxrow, - vt, va, GIVEN, rows, None, 0, 0) + rtop, rbot = calculate_top_bottom_filler(maxrow, vt, va, GIVEN, rows, None, 0, 0) self.shift_focus((maxcol, maxrow), rtop) - def _set_focus_first_selectable(self, size, focus): + def _set_focus_first_selectable(self, size: tuple[int, int], focus: bool) -> None: """ Choose the first visible, selectable widget below the current focus as the focus widget. @@ -668,8 +691,7 @@ class ListBox(Widget, WidgetContainerMixin): (maxcol, maxrow) = size self.set_focus_valign_pending = None self.set_focus_pending = None - middle, top, bottom = self.calculate_visible( - (maxcol, maxrow), focus=focus) + middle, top, bottom = self.calculate_visible((maxcol, maxrow), focus=focus) if middle is None: return @@ -691,18 +713,16 @@ class ListBox(Widget, WidgetContainerMixin): return new_row_offset += rows - def _set_focus_complete(self, size, focus): + def _set_focus_complete(self, size: tuple[int, int], focus: bool) -> None: """ Finish setting the position now that we have maxcol & maxrow. """ (maxcol, maxrow) = size self._invalidate() if self.set_focus_pending == "first selectable": - return self._set_focus_first_selectable( - (maxcol,maxrow), focus) + return self._set_focus_first_selectable((maxcol, maxrow), focus) if self.set_focus_valign_pending is not None: - return self._set_focus_valign_complete( - (maxcol,maxrow), focus) + return self._set_focus_valign_complete((maxcol,maxrow), focus) coming_from, focus_widget, focus_pos = self.set_focus_pending self.set_focus_pending = None @@ -715,8 +735,8 @@ class ListBox(Widget, WidgetContainerMixin): # restore old focus temporarily self._body.set_focus(focus_pos) - middle,top,bottom=self.calculate_visible((maxcol,maxrow),focus) - focus_offset, focus_widget, focus_pos, focus_rows, cursor=middle + middle, top, bottom=self.calculate_visible((maxcol,maxrow), focus) + focus_offset, focus_widget, focus_pos, focus_rows, cursor = middle trim_top, fill_above = top trim_bottom, fill_below = bottom @@ -724,15 +744,13 @@ class ListBox(Widget, WidgetContainerMixin): for widget, pos, rows in fill_above: offset -= rows if pos == position: - self.change_focus((maxcol, maxrow), pos, - offset, 'below' ) + self.change_focus((maxcol, maxrow), pos, offset, 'below') return offset = focus_offset + focus_rows for widget, pos, rows in fill_below: if pos == position: - self.change_focus((maxcol, maxrow), pos, - offset, 'above' ) + self.change_focus((maxcol, maxrow), pos, offset, 'above') return offset += rows @@ -741,16 +759,15 @@ class ListBox(Widget, WidgetContainerMixin): widget, position = self._body.get_focus() rows = widget.rows((maxcol,), focus) - if coming_from=='below': + if coming_from == 'below': offset = 0 - elif coming_from=='above': + elif coming_from == 'above': offset = maxrow-rows else: offset = (maxrow-rows) // 2 self.shift_focus((maxcol, maxrow), offset) - - def shift_focus(self, size, offset_inset): + def shift_focus(self, size: tuple[int, int], offset_inset: int) -> None: """ Move the location of the current focus relative to the top. This is used internally by methods that know the widget's *size*. @@ -782,7 +799,7 @@ class ListBox(Widget, WidgetContainerMixin): self.inset_fraction = (-offset_inset,tgt_rows) self._invalidate() - def update_pref_col_from_focus(self, size): + def update_pref_col_from_focus(self, size: tuple[int, int]): """Update self.pref_col from the focus widget.""" # TODO: should this not be private? (maxcol, maxrow) = size @@ -795,15 +812,20 @@ class ListBox(Widget, WidgetContainerMixin): pref_col = widget.get_pref_col((maxcol,)) if pref_col is None and hasattr(widget,'get_cursor_coords'): coords = widget.get_cursor_coords((maxcol,)) - if type(coords) == tuple: - pref_col,y = coords + if isinstance(coords, tuple): + pref_col, y = coords if pref_col is not None: self.pref_col = pref_col - - def change_focus(self, size, position, - offset_inset = 0, coming_from = None, - cursor_coords = None, snap_rows = None): + def change_focus( + self, + size: tuple[int, int], + position, + offset_inset: int = 0, + coming_from: Literal['above', 'below'] | None = None, + cursor_coords: tuple[int, int] | None = None, + snap_rows: int | None = None, + ) -> None: """ Change the current focus widget. This is used internally by methods that know the widget's *size*. @@ -915,7 +937,7 @@ class ListBox(Widget, WidgetContainerMixin): if target.move_cursor_to_coords((maxcol,),pref_col,row): break - def get_focus_offset_inset(self, size): + def get_focus_offset_inset(self, size: tuple[int, int]) -> tuple[int, int]: """Return (offset rows, inset rows) for focus widget.""" (maxcol, maxrow) = size focus_widget, pos = self._body.get_focus() @@ -931,8 +953,7 @@ class ListBox(Widget, WidgetContainerMixin): raise ListBoxError("urwid inset_fraction error (please report)") return offset_rows, inset_rows - - def make_cursor_visible(self, size): + def make_cursor_visible(self, size: tuple[int, int]) -> None: """Shift the focus widget so that its cursor is visible.""" (maxcol, maxrow) = size @@ -941,14 +962,13 @@ class ListBox(Widget, WidgetContainerMixin): return if not focus_widget.selectable(): return - if not hasattr(focus_widget,'get_cursor_coords'): + if not hasattr(focus_widget, 'get_cursor_coords'): return cursor = focus_widget.get_cursor_coords((maxcol,)) if cursor is None: return cx, cy = cursor - offset_rows, inset_rows = self.get_focus_offset_inset( - (maxcol, maxrow)) + offset_rows, inset_rows = self.get_focus_offset_inset((maxcol, maxrow)) if cy < inset_rows: self.shift_focus( (maxcol,maxrow), - (cy) ) @@ -958,8 +978,7 @@ class ListBox(Widget, WidgetContainerMixin): self.shift_focus( (maxcol,maxrow), maxrow-cy-1 ) return - - def keypress(self, size, key): + def keypress(self, size: tuple[int, int], key: str) -> str | None: """Move selection through the list elements scrolling when necessary. Keystrokes are first passed to widget in focus in case that widget can handle them. @@ -973,7 +992,7 @@ class ListBox(Widget, WidgetContainerMixin): (maxcol, maxrow) = size if self.set_focus_pending or self.set_focus_valign_pending: - self._set_focus_complete( (maxcol,maxrow), focus=True ) + self._set_focus_complete( (maxcol,maxrow), focus=True) focus_widget, pos = self._body.get_focus() if focus_widget is None: # empty listbox, can't do anything @@ -1010,22 +1029,22 @@ class ListBox(Widget, WidgetContainerMixin): return key - def _keypress_max_left(self, size): + def _keypress_max_left(self, size: tuple[int, int]) -> bool: self.focus_position = next(iter(self.body.positions())) self.set_focus_valign('top') return True - def _keypress_max_right(self, size): + def _keypress_max_right(self, size: tuple[int, int]) -> bool: self.focus_position = next(iter(self.body.positions(reverse=True))) self.set_focus_valign('bottom') return True - def _keypress_up(self, size): + def _keypress_up(self, size: tuple[int, int]) -> bool | None: (maxcol, maxrow) = size - middle, top, bottom = self.calculate_visible( - (maxcol,maxrow), True) - if middle is None: return True + middle, top, bottom = self.calculate_visible((maxcol,maxrow), True) + if middle is None: + return True focus_row_offset,focus_widget,focus_pos,_ignore,cursor = middle trim_top, fill_above = top @@ -1039,8 +1058,7 @@ class ListBox(Widget, WidgetContainerMixin): row_offset -= rows if rows and widget.selectable(): # this one will do - self.change_focus((maxcol,maxrow), pos, - row_offset, 'below') + self.change_focus((maxcol,maxrow), pos, row_offset, 'below') return # at this point we must scroll @@ -1067,8 +1085,7 @@ class ListBox(Widget, WidgetContainerMixin): if widget is None: self.shift_focus((maxcol,maxrow), row_offset) return - self.change_focus((maxcol,maxrow), pos, - row_offset, 'below') + self.change_focus((maxcol,maxrow), pos, row_offset, 'below') return # check if cursor will stop scroll from taking effect @@ -1089,20 +1106,18 @@ class ListBox(Widget, WidgetContainerMixin): # must scroll further than 1 line row_offset = - (rows-1) - self.change_focus((maxcol,maxrow),pos, - row_offset, 'below') + self.change_focus((maxcol,maxrow),pos,row_offset, 'below') return # if all else fails, just shift the current focus. self.shift_focus((maxcol,maxrow), focus_row_offset+1) - - def _keypress_down(self, size): + def _keypress_down(self, size: tuple[int, int]) -> bool | None: (maxcol, maxrow) = size - middle, top, bottom = self.calculate_visible( - (maxcol,maxrow), True) - if middle is None: return True + middle, top, bottom = self.calculate_visible((maxcol,maxrow), True) + if middle is None: + return True focus_row_offset,focus_widget,focus_pos,focus_rows,cursor=middle trim_bottom, fill_below = bottom @@ -1116,8 +1131,7 @@ class ListBox(Widget, WidgetContainerMixin): for widget, pos, rows in fill_below: if rows and widget.selectable(): # this one will do - self.change_focus((maxcol,maxrow), pos, - row_offset, 'above') + self.change_focus((maxcol,maxrow), pos, row_offset, 'above') return row_offset += rows @@ -1134,8 +1148,7 @@ class ListBox(Widget, WidgetContainerMixin): rows = widget.rows((maxcol,)) if rows and widget.selectable(): # this one will do - self.change_focus((maxcol,maxrow), pos, - row_offset, 'above') + self.change_focus((maxcol,maxrow), pos, row_offset, 'above') return row_offset += rows @@ -1143,14 +1156,12 @@ class ListBox(Widget, WidgetContainerMixin): # just take bottom one if current is not selectable # or if focus has moved out of view if widget is None: - self.shift_focus((maxcol,maxrow), - row_offset-rows) + self.shift_focus((maxcol,maxrow),row_offset-rows) return # FIXME: catch this bug in testcase #self.change_focus((maxcol,maxrow), pos, # row_offset+rows, 'above') - self.change_focus((maxcol,maxrow), pos, - row_offset-rows, 'above') + self.change_focus((maxcol,maxrow), pos,row_offset-rows, 'above') return # check if cursor will stop scroll from taking effect @@ -1178,13 +1189,13 @@ class ListBox(Widget, WidgetContainerMixin): # if all else fails, keep the current focus. self.shift_focus((maxcol,maxrow), focus_row_offset-1) - - def _keypress_page_up(self, size): + def _keypress_page_up(self, size: tuple[int, int]) -> bool | None: (maxcol, maxrow) = size middle, top, bottom = self.calculate_visible( (maxcol,maxrow), True) - if middle is None: return True + if middle is None: + return True row_offset, focus_widget, focus_pos, focus_rows, cursor = middle trim_top, fill_above = top @@ -1218,7 +1229,6 @@ class ListBox(Widget, WidgetContainerMixin): # not used below: scroll_from_row = topmost_visible = None - # gather potential target widgets t = [] # add current focus @@ -1257,8 +1267,7 @@ class ListBox(Widget, WidgetContainerMixin): # choose the topmost selectable and (newly) visible widget # search within snap_rows then visible region - search_order = (list(range(snap_region_start, len(t))) - + list(range(snap_region_start-1, -1, -1))) + search_order = (list(range(snap_region_start, len(t))) + list(range(snap_region_start-1, -1, -1))) #assert 0, repr((t, search_order)) bad_choices = [] cut_off_selectable_chosen = 0 @@ -1275,23 +1284,30 @@ class ListBox(Widget, WidgetContainerMixin): # if completely within snap region, adjust row_offset if rows + row_offset <= 0: - self.change_focus( (maxcol,maxrow), pos, - -(rows-1), 'below', + self.change_focus( + (maxcol,maxrow), + pos, + -(rows-1), + 'below', (self.pref_col, rows-1), - snap_rows-((-row_offset)-(rows-1))) + snap_rows-((-row_offset)-(rows-1)), + ) else: - self.change_focus( (maxcol,maxrow), pos, - row_offset, 'below', - (self.pref_col, pref_row), snap_rows ) + self.change_focus( + (maxcol,maxrow), + pos, + row_offset, + 'below', + (self.pref_col, pref_row), + snap_rows, + ) # if we're as far up as we can scroll, take this one - if (fill_above and self._body.get_prev(fill_above[-1][1]) - == (None,None) ): + if fill_above and self._body.get_prev(fill_above[-1][1]) == (None, None): pass #return # find out where that actually puts us - middle, top, bottom = self.calculate_visible( - (maxcol,maxrow), True) + middle, top, bottom = self.calculate_visible((maxcol,maxrow), True) act_row_offset, _ign1, _ign2, _ign3, _ign4 = middle # discard chosen widget if it will reduce scroll amount @@ -1360,16 +1376,14 @@ class ListBox(Widget, WidgetContainerMixin): return # bring in only one row if possible rows = widget.rows((maxcol,), True) - self.change_focus((maxcol,maxrow), pos, -(rows-1), - 'below', (self.pref_col, rows-1), 0 ) - + self.change_focus((maxcol,maxrow), pos, -(rows-1), 'below', (self.pref_col, rows-1), 0 ) - def _keypress_page_down(self, size): + def _keypress_page_down(self, size: tuple[int, int]) -> bool | None: (maxcol, maxrow) = size - middle, top, bottom = self.calculate_visible( - (maxcol,maxrow), True) - if middle is None: return True + middle, top, bottom = self.calculate_visible((maxcol,maxrow), True) + if middle is None: + return True row_offset, focus_widget, focus_pos, focus_rows, cursor = middle trim_bottom, fill_below = bottom @@ -1402,7 +1416,6 @@ class ListBox(Widget, WidgetContainerMixin): # not used below: scroll_from_row = bottom_edge = None - # gather potential target widgets t = [] # add current focus @@ -1442,8 +1455,7 @@ class ListBox(Widget, WidgetContainerMixin): # choose the bottommost selectable and (newly) visible widget # search within snap_rows then visible region - search_order = (list(range(snap_region_start, len(t))) - + list(range(snap_region_start-1, -1, -1))) + search_order = (list(range(snap_region_start, len(t))) + list(range(snap_region_start-1, -1, -1))) #assert 0, repr((t, search_order)) bad_choices = [] cut_off_selectable_chosen = 0 @@ -1460,18 +1472,26 @@ class ListBox(Widget, WidgetContainerMixin): # if completely within snap region, adjust row_offset if row_offset >= maxrow: - self.change_focus( (maxcol,maxrow), pos, - maxrow-1, 'above', + self.change_focus( + (maxcol,maxrow), + pos, + maxrow-1, + 'above', (self.pref_col, 0), - snap_rows+maxrow-row_offset-1 ) + snap_rows+maxrow-row_offset-1, + ) else: - self.change_focus( (maxcol,maxrow), pos, - row_offset, 'above', - (self.pref_col, pref_row), snap_rows ) + self.change_focus( + (maxcol,maxrow), + pos, + row_offset, + 'above', + (self.pref_col, pref_row), + snap_rows, + ) # find out where that actually puts us - middle, top, bottom = self.calculate_visible( - (maxcol,maxrow), True) + middle, top, bottom = self.calculate_visible((maxcol,maxrow), True) act_row_offset, _ign1, _ign2, _ign3, _ign4 = middle # discard chosen widget if it will reduce scroll amount @@ -1499,7 +1519,8 @@ class ListBox(Widget, WidgetContainerMixin): good_choices = [j for j in search_order if j not in bad_choices] for i in good_choices + search_order: row_offset, widget, pos, rows = t[i] - if pos == focus_pos: continue + if pos == focus_pos: + continue if not rows: # never focus a 0-height widget continue @@ -1514,7 +1535,6 @@ class ListBox(Widget, WidgetContainerMixin): snap_rows ) return - # no choices available, just shift current one self.shift_focus((maxcol, maxrow), max(1-focus_rows,row_offset)) @@ -1536,10 +1556,9 @@ class ListBox(Widget, WidgetContainerMixin): return # bring in only one row if possible rows = widget.rows((maxcol,), True) - self.change_focus((maxcol,maxrow), pos, maxrow-1, - 'above', (self.pref_col, 0), 0 ) + self.change_focus((maxcol,maxrow), pos, maxrow-1, 'above', (self.pref_col, 0), 0 ) - def mouse_event(self, size, event, button, col, row, focus): + def mouse_event(self, size: tuple[int, int], event, button: int, col: int, row: int, focus: bool) -> bool | None: """ Pass the event to the contained widgets. May change focus on button 1 press. @@ -1554,10 +1573,8 @@ class ListBox(Widget, WidgetContainerMixin): trim_top, fill_above = top _ignore, fill_below = bottom - fill_above.reverse() # fill_above is in bottom-up order - w_list = ( fill_above + - [ (focus_widget, focus_pos, focus_rows) ] + - fill_below ) + fill_above.reverse() # fill_above is in bottom-up order + w_list = ( fill_above + [(focus_widget, focus_pos, focus_rows)] + fill_below) wrow = -trim_top for w, w_pos, w_rows in w_list: @@ -1575,11 +1592,9 @@ class ListBox(Widget, WidgetContainerMixin): if not hasattr(w,'mouse_event'): return False - return w.mouse_event((maxcol,), event, button, col, row-wrow, - focus) + return w.mouse_event((maxcol,), event, button, col, row-wrow,focus) - - def ends_visible(self, size, focus=False): + def ends_visible(self, size: tuple[int, int], focus: bool = False): """ Return a list that may contain ``'top'`` and/or ``'bottom'``. @@ -1674,4 +1689,5 @@ class ListBox(Widget, WidgetContainerMixin): while True: yield pos w, pos = self._body.get_next(pos) - if not w: break + if not w: + break diff --git a/urwid/main_loop.py b/urwid/main_loop.py index 0d91a60..8cee637 100755 --- a/urwid/main_loop.py +++ b/urwid/main_loop.py @@ -507,8 +507,7 @@ class MainLoop: if is_mouse_event(k): event, button, col, row = k if hasattr(self._topmost_widget, "mouse_event"): - if self._topmost_widget.mouse_event(self.screen_size, - event, button, col, row, focus=True): + if self._topmost_widget.mouse_event(self.screen_size, event, button, col, row, focus=True): k = None elif self._topmost_widget.selectable(): k = self._topmost_widget.keypress(self.screen_size, k) @@ -1185,7 +1184,7 @@ class TwistedInputDescriptor(FileDescriptor): def __init__(self, reactor, fd, cb): self._fileno = fd self.cb = cb - FileDescriptor.__init__(self, reactor) + super().__init__(reactor) def fileno(self): return self._fileno diff --git a/urwid/monitored_list.py b/urwid/monitored_list.py index 8a04615..13700c8 100755 --- a/urwid/monitored_list.py +++ b/urwid/monitored_list.py @@ -19,19 +19,32 @@ # # Urwid web site: https://urwid.org/ +from __future__ import annotations -def _call_modified(fn): - def call_modified_wrapper(self, *args, **kwargs): +import typing +from collections.abc import Callable + +if typing.TYPE_CHECKING: + from typing_extensions import ParamSpec + + ArgSpec = ParamSpec("ArgSpec") + Ret = typing.TypeVar("Ret") + + +def _call_modified(fn: Callable[ArgSpec, Ret]) -> Callable[ArgSpec, Ret]: + def call_modified_wrapper(self: MonitoredList, *args, **kwargs): rval = fn(self, *args, **kwargs) self._modified() return rval return call_modified_wrapper + class MonitoredList(list): """ This class can trigger a callback any time its contents are changed with the usual list operations append, extend, etc. """ + def _modified(self): pass @@ -116,7 +129,7 @@ class MonitoredFocusList(MonitoredList): def __repr__(self): return f"{self.__class__.__name__}({list(self)!r}, focus={self.focus!r})" - def _get_focus(self): + def _get_focus(self) -> int | None: """ Return the index of the item "in focus" or None if the list is empty. @@ -129,7 +142,7 @@ class MonitoredFocusList(MonitoredList): return None return self._focus - def _set_focus(self, index): + def _set_focus(self, index: int) -> None: """ index -- index into this list, any index out of range will raise an IndexError, except when the list is empty and @@ -169,10 +182,10 @@ class MonitoredFocusList(MonitoredList): or an IndexError will be raised. """) - def _focus_changed(self, new_focus): + def _focus_changed(self, new_focus: int): pass - def set_focus_changed_callback(self, callback): + def set_focus_changed_callback(self, callback: Callable[[int], typing.Any]) -> None: """ Assign a callback to be called when the focus index changes for any reason. The callback is in the form: @@ -222,7 +235,7 @@ class MonitoredFocusList(MonitoredList): """ self._validate_contents_modified = callback - def _adjust_focus_on_contents_modified(self, slc, new_items=()): + def _adjust_focus_on_contents_modified(self, slc: slice, new_items=()) -> int: """ Default behaviour is to move the focus to the item following any removed items, unless that item was simply replaced. @@ -260,7 +273,7 @@ class MonitoredFocusList(MonitoredList): # override all the list methods that modify the list - def __delitem__(self, y): + def __delitem__(self, y: int | slice): """ >>> ml = MonitoredFocusList([0,1,2,3,4], focus=2) >>> del ml[3]; ml @@ -289,13 +302,12 @@ class MonitoredFocusList(MonitoredList): if isinstance(y, slice): focus = self._adjust_focus_on_contents_modified(y) else: - focus = self._adjust_focus_on_contents_modified(slice(y, - y+1 or None)) + focus = self._adjust_focus_on_contents_modified(slice(y, y+1 or None)) rval = super().__delitem__(y) self._set_focus(focus) return rval - def __setitem__(self, i, y): + def __setitem__(self, i: int | slice, y): """ >>> def modified(indices, new_items): ... print("range%r <- %r" % (indices, new_items)) @@ -332,7 +344,7 @@ class MonitoredFocusList(MonitoredList): self._set_focus(focus) return rval - def __imul__(self, n): + def __imul__(self, n: int): """ >>> def modified(indices, new_items): ... print("range%r <- %r" % (indices, list(new_items))) @@ -348,9 +360,8 @@ class MonitoredFocusList(MonitoredList): None """ if n > 0: - focus = self._adjust_focus_on_contents_modified( - slice(len(self), len(self)), list(self)*(n-1)) - else: # all contents are being removed + focus = self._adjust_focus_on_contents_modified(slice(len(self), len(self)), list(self) * (n-1)) + else: # all contents are being removed focus = self._adjust_focus_on_contents_modified(slice(0, len(self))) rval = super().__imul__(n) self._set_focus(focus) @@ -386,7 +397,7 @@ class MonitoredFocusList(MonitoredList): self._set_focus(focus) return rval - def insert(self, index, item): + def insert(self, index: int, item): """ >>> ml = MonitoredFocusList([0,1,2,3], focus=2) >>> ml.insert(-1, -1); ml @@ -396,13 +407,12 @@ class MonitoredFocusList(MonitoredList): >>> ml.insert(3, -3); ml MonitoredFocusList([-2, 0, 1, -3, 2, -1, 3], focus=4) """ - focus = self._adjust_focus_on_contents_modified(slice(index, index), - [item]) + focus = self._adjust_focus_on_contents_modified(slice(index, index), [item]) rval = super().insert(index, item) self._set_focus(focus) return rval - def pop(self, index=-1): + def pop(self, index: int = -1): """ >>> ml = MonitoredFocusList([-2,0,1,-3,2,3], focus=4) >>> ml.pop(3); ml @@ -418,8 +428,7 @@ class MonitoredFocusList(MonitoredList): 2 MonitoredFocusList([0, 1], focus=1) """ - focus = self._adjust_focus_on_contents_modified(slice(index, - index+1 or None)) + focus = self._adjust_focus_on_contents_modified(slice(index, index + 1 or None)) rval = super().pop(index) self._set_focus(focus) return rval @@ -472,13 +481,11 @@ class MonitoredFocusList(MonitoredList): return rval - - - def _test(): import doctest doctest.testmod() + if __name__=='__main__': _test() diff --git a/urwid/numedit.py b/urwid/numedit.py index ff372d5..a2cf32d 100644 --- a/urwid/numedit.py +++ b/urwid/numedit.py @@ -22,6 +22,7 @@ from __future__ import annotations import re +from collections.abc import Container from decimal import Decimal from urwid import Edit @@ -39,18 +40,24 @@ class NumEdit(Edit): """ ALLOWED = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" - def __init__(self, allowed, caption, default, trimLeadingZeros=True): + def __init__( + self, + allowed: Container[str], + caption, + default: str | bytes, + trimLeadingZeros: bool = True, + ): super().__init__(caption, default) self._allowed = allowed self.trimLeadingZeros = trimLeadingZeros - def valid_char(self, ch): + def valid_char(self, ch: str) -> bool: """ Return true for allowed characters. """ return len(ch) == 1 and ch.upper() in self._allowed - def keypress(self, size, key): + def keypress(self, size: tuple[int], key: str) -> str | None: """ Handle editing keystrokes. Remove leading zeros. @@ -71,12 +78,11 @@ class NumEdit(Edit): (maxcol,) = size unhandled = Edit.keypress(self, (maxcol,), key) - if not unhandled: - if self.trimLeadingZeros: - # trim leading zeros - while self.edit_pos > 0 and self.edit_text[:1] == "0": - self.set_edit_pos(self.edit_pos - 1) - self.set_edit_text(self.edit_text[1:]) + if not unhandled and self.trimLeadingZeros: + # trim leading zeros + while self.edit_pos > 0 and self.edit_text[:1] == "0": + self.set_edit_pos(self.edit_pos - 1) + self.set_edit_text(self.edit_text[1:]) return unhandled @@ -84,7 +90,12 @@ class NumEdit(Edit): class IntegerEdit(NumEdit): """Edit widget for integer values""" - def __init__(self, caption="", default=None, base=10): + def __init__( + self, + caption="", + default: int | str | Decimal | None = None, + base: int = 10, + ) -> None: """ caption -- caption markup default -- default edit value @@ -144,8 +155,7 @@ class IntegerEdit(NumEdit): allowed_chars = self.ALLOWED[:self.base] if default is not None: if not isinstance(default, (int, str, Decimal)): - raise ValueError("default: Only 'str', 'int', " - "'long' or Decimal input allowed") + raise ValueError("default: Only 'str', 'int' or Decimal input allowed") # convert to a long first, this will raise a ValueError # in case a float is passed or some other error @@ -163,10 +173,9 @@ class IntegerEdit(NumEdit): # convert possible int, long or Decimal to str val = str(default) - super().__init__(allowed_chars, caption, val, - trimLeadingZeros=(self.base == 10)) + super().__init__(allowed_chars, caption, val, trimLeadingZeros=(self.base == 10)) - def value(self): + def value(self) -> Decimal | None: """ Return the numeric value of self.edit_text. @@ -184,8 +193,13 @@ class IntegerEdit(NumEdit): class FloatEdit(NumEdit): """Edit widget for float values.""" - def __init__(self, caption="", default=None, - preserveSignificance=True, decimalSeparator='.'): + def __init__( + self, + caption="", + default: str | int | Decimal | None = None, + preserveSignificance: bool = True, + decimalSeparator: str = '.', + ) -> None: """ caption -- caption markup default -- default edit value @@ -244,8 +258,7 @@ class FloatEdit(NumEdit): val = "" if default is not None and default != "": if not isinstance(default, (int, str, Decimal)): - raise ValueError("default: Only 'str', 'int', " - "'long' or Decimal input allowed") + raise ValueError("default: Only 'str', 'int' or Decimal input allowed") if isinstance(default, str) and default: # check if it is a float, raises a ValueError otherwise @@ -260,7 +273,7 @@ class FloatEdit(NumEdit): super().__init__(self.ALLOWED[0:10] + decimalSeparator, caption, val) - def value(self): + def value(self) -> Decimal | None: """ Return the numeric value of self.edit_text. """ diff --git a/urwid/old_str_util.py b/urwid/old_str_util.py index 920b4a8..3a9bd84 100755 --- a/urwid/old_str_util.py +++ b/urwid/old_str_util.py @@ -23,6 +23,10 @@ from __future__ import annotations import re +import typing + +if typing.TYPE_CHECKING: + from typing_extensions import Literal SAFE_ASCII_RE = re.compile("^[ -~]*$") SAFE_ASCII_BYTES_RE = re.compile(b"^[ -~]*$") @@ -33,7 +37,7 @@ _byte_encoding = None # generated from # http://www.unicode.org/Public/4.0-Update/EastAsianWidth-4.0.0.txt -widths = [ +widths: list[tuple[int, Literal[0, 1, 2]]] = [ (126, 1), (159, 0), (687, 1), @@ -76,7 +80,8 @@ widths = [ # ACCESSOR FUNCTIONS -def get_width( o ): + +def get_width(o) -> Literal[0, 1, 2]: """Return the screen column width for unicode ordinal o.""" global widths if o == 0xe or o == 0xf: @@ -86,7 +91,8 @@ def get_width( o ): return wid return 1 -def decode_one( text, pos ): + +def decode_one(text: bytes, pos: int) -> tuple[int, int]: """ Return (ordinal at pos, next position) for UTF-8 encoded text. """ @@ -138,13 +144,15 @@ def decode_one( text, pos ): return o, pos+4 return error -def decode_one_uni(text, i): + +def decode_one_uni(text: str, i: int) -> tuple[int, int]: """ decode_one implementation for unicode strings """ return ord(text[i]), i+1 -def decode_one_right(text, pos): + +def decode_one_right(text: bytes, pos: int) -> tuple[int, int]: """ Return (ordinal at pos, next position) for UTF-8 encoded text. pos is assumed to be on the trailing byte of a utf-8 sequence. @@ -160,15 +168,18 @@ def decode_one_right(text, pos): if p == p-4: return error -def set_byte_encoding(enc): + +def set_byte_encoding(enc: Literal['utf8', 'narrow', 'wide']): assert enc in ('utf8', 'narrow', 'wide') global _byte_encoding _byte_encoding = enc + def get_byte_encoding(): return _byte_encoding -def calc_text_pos(text, start_offs, end_offs, pref_col): + +def calc_text_pos(text: str | bytes, start_offs: int, end_offs: int, pref_col: int) -> tuple[int, int]: """ Calculate the closest position to the screen column pref_col in text where start_offs is the offset into text assumed to be screen column 0 @@ -180,12 +191,12 @@ def calc_text_pos(text, start_offs, end_offs, pref_col): """ assert start_offs <= end_offs, repr((start_offs, end_offs)) utfs = isinstance(text, bytes) and _byte_encoding == "utf8" - unis = not isinstance(text, bytes) + unis = isinstance(text, str) if unis or utfs: decode = [decode_one, decode_one_uni][unis] i = start_offs sc = 0 - n = 1 # number to advance by + n = 1 # number to advance by while i < end_offs: o, n = decode(text, i) w = get_width(o) @@ -194,7 +205,8 @@ def calc_text_pos(text, start_offs, end_offs, pref_col): i = n sc += w return i, sc - assert type(text) == bytes, repr(text) + + assert isinstance(text, bytes), repr(text) # "wide" and "narrow" i = start_offs+pref_col if i >= end_offs: @@ -204,7 +216,8 @@ def calc_text_pos(text, start_offs, end_offs, pref_col): i -= 1 return i, i-start_offs -def calc_width(text, start_offs, end_offs): + +def calc_width(text: str | bytes, start_offs: int, end_offs: int) -> int: """ Return the screen column width of text between start_offs and end_offs. @@ -219,8 +232,7 @@ def calc_width(text, start_offs, end_offs): utfs = isinstance(text, bytes) and _byte_encoding == "utf8" unis = not isinstance(text, bytes) - if (unis and not SAFE_ASCII_RE.match(text) - ) or (utfs and not SAFE_ASCII_BYTES_RE.match(text)): + if (unis and not SAFE_ASCII_RE.match(text)) or (utfs and not SAFE_ASCII_BYTES_RE.match(text)): decode = [decode_one, decode_one_uni][unis] i = start_offs sc = 0 @@ -234,7 +246,8 @@ def calc_width(text, start_offs, end_offs): # "wide", "narrow" or all printable ASCII, just return the character count return end_offs - start_offs -def is_wide_char(text, offs): + +def is_wide_char(text: str | bytes, offs: int) -> bool: """ Test if the character at offs within text is wide. @@ -251,7 +264,8 @@ def is_wide_char(text, offs): return within_double_byte(text, offs, offs) == 1 return False -def move_prev_char(text, start_offs, end_offs): + +def move_prev_char(text: str | bytes, start_offs: int, end_offs: int): """ Return the position of the character before end_offs. """ @@ -269,7 +283,8 @@ def move_prev_char(text, start_offs, end_offs): return end_offs-2 return end_offs-1 -def move_next_char(text, start_offs, end_offs): + +def move_next_char(text: str | bytes, start_offs: int, end_offs: int) -> int: """ Return the position of the character after start_offs. """ @@ -279,15 +294,15 @@ def move_next_char(text, start_offs, end_offs): assert isinstance(text, bytes) if _byte_encoding == "utf8": o = start_offs+1 - while o<end_offs and text[o]&0xc0 == 0x80: + while o < end_offs and text[o] & 0xc0 == 0x80: o += 1 return o - if _byte_encoding == "wide" and within_double_byte(text, - start_offs, start_offs) == 1: + if _byte_encoding == "wide" and within_double_byte(text, start_offs, start_offs) == 1: return start_offs +2 return start_offs+1 -def within_double_byte(text, line_start, pos): + +def within_double_byte(text: bytes, line_start: int, pos: int) -> Literal[0, 1, 2]: """Return whether pos is within a double-byte encoded character. text -- byte string in question @@ -304,16 +319,18 @@ def within_double_byte(text, line_start, pos): if v >= 0x40 and v < 0x7f: # might be second half of big5, uhc or gbk encoding - if pos == line_start: return 0 + if pos == line_start: + return 0 if text[pos-1] >= 0x81: if within_double_byte(text, line_start, pos-1) == 1: return 2 return 0 - if v < 0x80: return 0 + if v < 0x80: + return 0 - i = pos -1 + i = pos - 1 while i >= line_start: if text[i] < 0x80: break @@ -323,9 +340,10 @@ def within_double_byte(text, line_start, pos): return 1 return 2 + # TABLE GENERATION CODE -def process_east_asian_width(): +def process_east_asian_width() -> None: import sys out = [] last = None @@ -362,6 +380,7 @@ def process_east_asian_width(): print(f"\t{o!r},") print("]") + if __name__ == "__main__": process_east_asian_width() diff --git a/urwid/raw_display.py b/urwid/raw_display.py index 7d90f3b..bbdf758 100644 --- a/urwid/raw_display.py +++ b/urwid/raw_display.py @@ -26,6 +26,7 @@ Direct terminal UI implementation from __future__ import annotations +import io import os import select import signal diff --git a/urwid/signals.py b/urwid/signals.py index ea40131..dfbf47f 100644 --- a/urwid/signals.py +++ b/urwid/signals.py @@ -32,7 +32,7 @@ class MetaSignals(type): register the list of signals in the class variable signals, including signals in superclasses. """ - def __init__(cls, name, bases, d): + def __init__(cls, name: str, bases, d): signals = d.get("signals", []) for superclass in cls.__bases__: signals.extend(getattr(superclass, 'signals', [])) @@ -48,15 +48,17 @@ def setdefaultattr(obj, name, value): setattr(obj, name, value) return value + class Key: """ Minimal class, whose only purpose is to produce objects with a unique hash """ - __slots__ = [] + __slots__ = () + class Signals: - _signal_attr = '_urwid_signals' # attribute to attach to signal senders + _signal_attr = '_urwid_signals' # attribute to attach to signal senders def __init__(self): self._supported = {} @@ -74,7 +76,7 @@ class Signals: """ self._supported[sig_cls] = signals - def connect(self, obj, name, callback, user_arg=None, weak_args=None, user_args=None): + def connect(self, obj, name: str, callback, user_arg=None, weak_args=None, user_args=None) -> Key: """ :param obj: the object sending a signal :type obj: object @@ -191,8 +193,7 @@ class Signals: # Turn weak_args into weakrefs and prepend them to user_args return [weakref.ref(a, callback) for a in (weak_args or [])] + (user_args or []) - - def disconnect(self, obj, name, callback, user_arg=None, weak_args=None, user_args=None): + def disconnect(self, obj, name: str, callback, user_arg=None, weak_args=None, user_args=None): """ :param obj: the object to disconnect the signal from :type obj: object @@ -226,7 +227,7 @@ class Signals: if h[1:] == (callback, user_arg, user_args): return self.disconnect_by_key(obj, name, h[0]) - def disconnect_by_key(self, obj, name, key): + def disconnect_by_key(self, obj, name: str, key: Key): """ :param obj: the object to disconnect the signal from :type obj: object @@ -247,7 +248,7 @@ class Signals: handlers = signals.get(name, []) handlers[:] = [h for h in handlers if h[0] is not key] - def emit(self, obj, name, *args): + def emit(self, obj, name: str, *args): """ :param obj: the object sending a signal :type obj: object diff --git a/urwid/text_layout.py b/urwid/text_layout.py index 5fcb407..aacb36d 100644 --- a/urwid/text_layout.py +++ b/urwid/text_layout.py @@ -22,23 +22,22 @@ from __future__ import annotations -from urwid.util import ( - calc_text_pos, - calc_trim_text, - calc_width, - is_wide_char, - move_next_char, - move_prev_char, -) +import typing +from urwid.util import calc_text_pos, calc_trim_text, calc_width, is_wide_char, move_next_char, move_prev_char + +if typing.TYPE_CHECKING: + from typing_extensions import Literal class TextLayout: def supports_align_mode(self, align): """Return True if align is a supported align mode.""" return True + def supports_wrap_mode(self, wrap): """Return True if wrap is a supported wrap mode.""" return True + def layout(self, text, width, align, wrap ): """ Return a layout structure for text. @@ -61,12 +60,15 @@ class TextLayout: text offset. If the offset is None when inserting spaces then no attribute will be used. """ - raise NotImplementedError("This function must be overridden by a real" - " text layout class. (see StandardTextLayout)") + raise NotImplementedError( + "This function must be overridden by a real text layout class. (see StandardTextLayout)" + ) + class CanNotDisplayText(Exception): pass + class StandardTextLayout(TextLayout): def __init__(self):#, tab_stops=(), tab_stop_every=8): pass @@ -79,13 +81,22 @@ class StandardTextLayout(TextLayout): # self.tab_stops = (tab_stop_every,) #self.tab_stops = tab_stops #self.tab_stop_every = tab_stop_every - def supports_align_mode(self, align): + + def supports_align_mode(self, align: str) -> bool: """Return True if align is 'left', 'center' or 'right'.""" return align in ('left', 'center', 'right') - def supports_wrap_mode(self, wrap): + + def supports_wrap_mode(self, wrap: str) -> bool: """Return True if wrap is 'any', 'space', 'clip' or 'ellipsis'.""" return wrap in ('any', 'space', 'clip', 'ellipsis') - def layout(self, text, width, align, wrap ): + + def layout( + self, + text, + width: int, + align: Literal['left', 'center', 'right'], + wrap: Literal['any', 'space', 'clip', 'ellipsis'], + ): """Return a layout structure for text.""" try: segs = self.calculate_text_segments( text, width, wrap ) @@ -93,7 +104,7 @@ class StandardTextLayout(TextLayout): except CanNotDisplayText: return [[]] - def pack(self, maxcol, layout): + def pack(self, maxcol: int, layout) -> int: """ Return a minimal maxcol value that would result in the same number of lines for layout. layout must be a layout structure @@ -108,7 +119,14 @@ class StandardTextLayout(TextLayout): maxwidth = max(maxwidth, lw) return maxwidth - def align_layout( self, text, width, segs, wrap, align ): + def align_layout( + self, + text, + width: int, + segs, + wrap: Literal['any', 'space', 'clip', 'ellipsis'], + align: Literal['left', 'center', 'right'], + ): """Convert the layout segs to an aligned layout.""" out = [] for l in segs: @@ -124,8 +142,12 @@ class StandardTextLayout(TextLayout): out.append([((width-sc+1) // 2, None)] + l) return out - - def calculate_text_segments(self, text, width, wrap): + def calculate_text_segments( + self, + text, + width: int, + wrap: Literal['any', 'space', 'clip', 'ellipsis'], + ): """ Calculate the segments of text to display given width screen columns to display them. @@ -138,14 +160,14 @@ class StandardTextLayout(TextLayout): """ nl, nl_o, sp_o = "\n", "\n", " " if isinstance(text, bytes): - nl = nl.encode('iso8859-1') # can only find bytes in python3 bytestrings - nl_o = ord(nl_o) # + an item of a bytestring is the ordinal value + nl = nl.encode('iso8859-1') # can only find bytes in python3 bytestrings + nl_o = ord(nl_o) # + an item of a bytestring is the ordinal value sp_o = ord(sp_o) b = [] p = 0 if wrap in ('clip', 'ellipsis'): # no wrapping to calculate, so it's easy. - while p<=len(text): + while p <= len(text): n_cr = text.find(nl, p) if n_cr == -1: n_cr = len(text) @@ -175,8 +197,7 @@ class StandardTextLayout(TextLayout): p = n_cr+1 return b - - while p<=len(text): + while p <= len(text): # look for next eligible line break n_cr = text.find(nl, p) if n_cr == -1: @@ -184,89 +205,82 @@ class StandardTextLayout(TextLayout): sc = calc_width(text, p, n_cr) if sc == 0: # removed character hint - b.append([(0,n_cr)]) - p = n_cr+1 + b.append([(0, n_cr)]) + p = n_cr + 1 continue if sc <= width: # this segment fits - b.append([(sc,p,n_cr), - # removed character hint - (0,n_cr)]) + b.append([(sc, p, n_cr), (0, n_cr)]) + # removed character hint - p = n_cr+1 + p = n_cr + 1 continue pos, sc = calc_text_pos( text, p, n_cr, width ) - if pos == p: # pathological width=1 double-byte case - raise CanNotDisplayText( - "Wide character will not fit in 1-column width") + if pos == p: # pathological width=1 double-byte case + raise CanNotDisplayText("Wide character will not fit in 1-column width") if wrap == 'any': - b.append([(sc,p,pos)]) + b.append([(sc, p, pos)]) p = pos continue assert wrap == 'space' if text[pos] == sp_o: # perfect space wrap - b.append([(sc,p,pos), - # removed character hint - (0,pos)]) - p = pos+1 + b.append([(sc, p, pos), (0,pos)]) + # removed character hint + + p = pos + 1 continue if is_wide_char(text, pos): # perfect next wide - b.append([(sc,p,pos)]) + b.append([(sc, p, pos)]) p = pos continue prev = pos while prev > p: prev = move_prev_char(text, p, prev) if text[prev] == sp_o: - sc = calc_width(text,p,prev) - l = [(0,prev)] - if p!=prev: - l = [(sc,p,prev)] + l + sc = calc_width(text, p, prev) + l = [(0, prev)] + if p != prev: + l = [(sc, p, prev)] + l b.append(l) p = prev+1 break - if is_wide_char(text,prev): + if is_wide_char(text, prev): # wrap after wide char next = move_next_char(text, prev, pos) - sc = calc_width(text,p,next) - b.append([(sc,p,next)]) + sc = calc_width(text, p, next) + b.append([(sc, p, next)]) p = next break else: # unwrap previous line space if possible to # fit more text (we're breaking a word anyway) - if b and (len(b[-1]) == 2 or ( len(b[-1])==1 - and len(b[-1][0])==2 )): + if b and (len(b[-1]) == 2 or (len(b[-1]) == 1 and len(b[-1][0]) == 2)): # look for removed space above if len(b[-1]) == 1: [(h_sc, h_off)] = b[-1] p_sc = 0 p_off = p_end = h_off else: - [(p_sc, p_off, p_end), - (h_sc, h_off)] = b[-1] - if (p_sc < width and h_sc==0 and - text[h_off] == sp_o): + [(p_sc, p_off, p_end), (h_sc, h_off)] = b[-1] + if p_sc < width and h_sc == 0 and text[h_off] == sp_o: # combine with previous line del b[-1] p = p_off pos, sc = calc_text_pos( text, p, n_cr, width ) - b.append([(sc,p,pos)]) + b.append([(sc, p, pos)]) # check for trailing " " or "\n" p = pos - if p < len(text) and ( - text[p] in (sp_o, nl_o)): + if p < len(text) and (text[p] in (sp_o, nl_o)): # removed character hint - b[-1].append((0,p)) + b[-1].append((0, p)) p += 1 continue - # force any char wrap - b.append([(sc,p,pos)]) + b.append([(sc, p, pos)]) p = pos return b @@ -279,35 +293,35 @@ default_layout = StandardTextLayout() class LayoutSegment: - def __init__(self, seg): + def __init__(self, seg: tuple[int, int, bytes | int] | tuple[int, int | None]) -> None: """Create object from line layout segment structure""" - assert type(seg) == tuple, repr(seg) - assert len(seg) in (2,3), repr(seg) + assert isinstance(seg, tuple), repr(seg) + assert len(seg) in (2, 3), repr(seg) self.sc, self.offs = seg[:2] - assert type(self.sc) == int, repr(self.sc) + assert isinstance(self.sc, int), repr(self.sc) - if len(seg)==3: - assert type(self.offs) == int, repr(self.offs) + if len(seg) == 3: + assert isinstance(self.offs, int), repr(self.offs) assert self.sc > 0, repr(seg) t = seg[2] - if type(t) == bytes: - self.text = t + if isinstance(t, bytes): + self.text: bytes | None = t self.end = None else: - assert type(t) == int, repr(t) + assert isinstance(t, int), repr(t) self.text = None self.end = t else: assert len(seg) == 2, repr(seg) if self.offs is not None: assert self.sc >= 0, repr(seg) - assert type(self.offs)==int + assert isinstance(self.offs, int) self.text = self.end = None - def subseg(self, text, start, end): + def subseg(self, text, start: int, end: int): """ Return a "sub-segment" list containing segment structures that make up a portion of this segment. @@ -316,33 +330,32 @@ class LayoutSegment: need to be replaced with a space character at either edge so two or three segments will be returned. """ - if start < 0: start = 0 - if end > self.sc: end = self.sc + if start < 0: + start = 0 + if end > self.sc: + end = self.sc if start >= end: - return [] # completely gone + return [] # completely gone if self.text: # use text stored in segment (self.text) - spos, epos, pad_left, pad_right = calc_trim_text( - self.text, 0, len(self.text), start, end ) - return [ (end-start, self.offs, b''.ljust(pad_left) + - self.text[spos:epos] + b''.ljust(pad_right)) ] + spos, epos, pad_left, pad_right = calc_trim_text(self.text, 0, len(self.text), start, end) + return [(end-start, self.offs, b''.ljust(pad_left) + self.text[spos:epos] + b''.ljust(pad_right))] elif self.end: # use text passed as parameter (text) - spos, epos, pad_left, pad_right = calc_trim_text( - text, self.offs, self.end, start, end ) + spos, epos, pad_left, pad_right = calc_trim_text(text, self.offs, self.end, start, end) l = [] if pad_left: - l.append((1,spos-1)) + l.append((1, spos-1)) l.append((end-start-pad_left-pad_right, spos, epos)) if pad_right: - l.append((1,epos)) + l.append((1, epos)) return l else: # simple padding adjustment - return [(end-start,self.offs)] + return [(end-start ,self.offs)] -def line_width( segs ): +def line_width(segs): """ Return the screen column width of one line of a text layout structure. @@ -351,21 +364,22 @@ def line_width( segs ): """ sc = 0 seglist = segs - if segs and len(segs[0])==2 and segs[0][1] is None: + if segs and len(segs[0]) == 2 and segs[0][1] is None: seglist = segs[1:] for s in seglist: sc += s[0] return sc -def shift_line( segs, amount ): + +def shift_line(segs, amount: int): """ Return a shifted line from a layout structure to the left or right. segs -- line of a layout structure amount -- screen columns to shift right (+ve) or left (-ve) """ - assert type(amount)==int, repr(amount) + assert isinstance(amount, int), repr(amount) - if segs and len(segs[0])==2 and segs[0][1] is None: + if segs and len(segs[0]) == 2 and segs[0][1] is None: # existing shift amount += segs[0][0] if amount: @@ -377,7 +391,7 @@ def shift_line( segs, amount ): return segs -def trim_line( segs, text, start, end ): +def trim_line(segs, text, start: int, end: int): """ Return a trimmed line of a text layout structure. text -- text to which this layout structure applies @@ -411,8 +425,7 @@ def trim_line( segs, text, start, end ): return l - -def calc_line_pos( text, line_layout, pref_col ): +def calc_line_pos(text, line_layout, pref_col: Literal['left', 'right'] | int): """ Calculate the closest linear position to pref_col given a line layout structure. Returns None if no position found. @@ -437,24 +450,20 @@ def calc_line_pos( text, line_layout, pref_col ): return if s.end is None: return s.offs - return calc_text_pos( text, s.offs, s.end, s.sc-1)[0] + return calc_text_pos(text, s.offs, s.end, s.sc-1)[0] for seg in line_layout: s = LayoutSegment(seg) if s.offs is not None: if s.end is not None: - if (current_sc <= pref_col and - pref_col < current_sc + s.sc): + if current_sc <= pref_col < current_sc + s.sc: # exact match within this segment - return calc_text_pos( text, - s.offs, s.end, - pref_col - current_sc )[0] + return calc_text_pos(text, s.offs, s.end, pref_col - current_sc)[0] elif current_sc <= pref_col: closest_sc = current_sc + s.sc - 1 closest_pos = s - if closest_sc is None or ( abs(pref_col-current_sc) - < abs(pref_col-closest_sc) ): + if closest_sc is None or (abs(pref_col-current_sc) < abs(pref_col-closest_sc)): # this screen column is closer closest_sc = current_sc closest_pos = s.offs @@ -463,41 +472,44 @@ def calc_line_pos( text, line_layout, pref_col ): break current_sc += s.sc - if closest_pos is None or type(closest_pos) == int: + if closest_pos is None or isinstance(closest_pos, int): return closest_pos # return the last positions in the segment "closest_pos" s = closest_pos return calc_text_pos( text, s.offs, s.end, s.sc-1)[0] -def calc_pos( text, layout, pref_col, row ): + +def calc_pos(text, layout, pref_col: Literal['left', 'right'] | int, row: int) -> int: """ Calculate the closest linear position to pref_col and row given a layout structure. """ if row < 0 or row >= len(layout): - raise Exception("calculate_pos: out of layout row range") + raise ValueError("calculate_pos: out of layout row range") - pos = calc_line_pos( text, layout[row], pref_col ) + pos = calc_line_pos(text, layout[row], pref_col) if pos is not None: return pos - rows_above = list(range(row-1,-1,-1)) - rows_below = list(range(row+1,len(layout))) + rows_above = list(range(row-1, -1, -1)) + rows_below = list(range(row+1, len(layout))) while rows_above and rows_below: if rows_above: r = rows_above.pop(0) pos = calc_line_pos(text, layout[r], pref_col) - if pos is not None: return pos + if pos is not None: + return pos if rows_below: r = rows_below.pop(0) pos = calc_line_pos(text, layout[r], pref_col) - if pos is not None: return pos + if pos is not None: + return pos return 0 -def calc_coords( text, layout, pos, clamp=1 ): +def calc_coords(text, layout, pos, clamp: int = 1): """ Calculate the coordinates closest to position pos in text with layout. @@ -516,18 +528,18 @@ def calc_coords( text, layout, pos, clamp=1 ): x += s.sc continue if s.offs == pos: - return x,y - if s.end is not None and s.offs<=pos and s.end>pos: - x += calc_width( text, s.offs, pos ) - return x,y + return x, y + if s.end is not None and s.offs <= pos < s.end: + x += calc_width(text, s.offs, pos) + return x, y distance = abs(s.offs - pos) if s.end is not None and s.end<pos: distance = pos - (s.end-1) if closest is None or distance < closest[0]: - closest = distance, (x,y) + closest = distance, (x, y) x += s.sc y += 1 if closest: return closest[1] - return 0,0 + return 0, 0 diff --git a/urwid/treetools.py b/urwid/treetools.py index 769da49..94d7c69 100644 --- a/urwid/treetools.py +++ b/urwid/treetools.py @@ -281,7 +281,7 @@ class TreeNode: class ParentNode(TreeNode): """Maintain sort order for TreeNodes.""" def __init__(self, value, parent=None, key=None, depth=None): - TreeNode.__init__(self, value, parent=parent, key=key, depth=depth) + super().__init__(value, parent=parent, key=key, depth=depth) self._child_keys = None self._children = {} diff --git a/urwid/util.py b/urwid/util.py index 9def762..de5bae2 100644 --- a/urwid/util.py +++ b/urwid/util.py @@ -23,9 +23,13 @@ from __future__ import annotations import codecs +import typing from urwid import escape +if typing.TYPE_CHECKING: + from typing_extensions import Self + str_util = escape.str_util # bring str_util functions into our namespace @@ -116,15 +120,15 @@ def get_encoding_mode(): return str_util.get_byte_encoding() -def apply_target_encoding( s ): +def apply_target_encoding(s: str | bytes): """ Return (encoded byte string, character set rle). """ - if _use_dec_special and type(s) == str: + if _use_dec_special and isinstance(s, str): # first convert drawing characters s = s.translate(escape.DEC_SPECIAL_CHARMAP) - if type(s) == str: + if isinstance(s, str): s = s.replace(escape.SI+escape.SO, "") # remove redundant shifts s = codecs.encode(s, _target_encoding, 'replace') @@ -183,10 +187,7 @@ def supports_unicode(): return _target_encoding and _target_encoding != 'ascii' - - - -def calc_trim_text( text, start_offs, end_offs, start_col, end_col ): +def calc_trim_text( text, start_offs: int, end_offs: int, start_col: int, end_col: int): """ Calculate the result of trimming text. start_offs -- offset into text to treat as screen column 0 @@ -215,14 +216,11 @@ def calc_trim_text( text, start_offs, end_offs, start_col, end_col ): return ( spos, pos, pad_left, pad_right ) - - -def trim_text_attr_cs( text, attr, cs, start_col, end_col ): +def trim_text_attr_cs(text, 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 ) + 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: @@ -238,7 +236,7 @@ def trim_text_attr_cs( text, attr, cs, start_col, end_col ): b''.rjust(pad_right), attrtr, cstr) -def rle_get_at( rle, pos ): +def rle_get_at(rle, pos: int): """ Return the attribute at offset pos. """ @@ -252,7 +250,7 @@ def rle_get_at( rle, pos ): return None -def rle_subseg( rle, start, end ): +def rle_subseg(rle, start: int, end: int): """Return a sub segment of an rle list.""" l = [] x = 0 @@ -274,7 +272,7 @@ def rle_subseg( rle, start, end ): return l -def rle_len( rle ): +def rle_len(rle) -> int: """ Return the number of characters covered by a run length encoded attribute list. @@ -282,12 +280,13 @@ def rle_len( rle ): run = 0 for v in rle: - assert type(v) == tuple, repr(rle) + assert isinstance(v, tuple), repr(rle) a, r = v run += r return run -def rle_prepend_modify(rle, a_r): + +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 @@ -319,6 +318,7 @@ def rle_append_modify(rle, a_r): la,lr = rle[-1] rle[-1] = (a, lr+r) + def rle_join_modify( rle, rle2 ): """ Append attribute list rle2 to rle. @@ -331,6 +331,7 @@ def rle_join_modify( rle, rle2 ): rle_append_modify(rle, rle2[0]) rle += rle2[1:] + def rle_product( rle1, rle2 ): """ Merge the runs of rle1 and rle2 like this: @@ -375,6 +376,7 @@ def rle_factor( rle ): class TagMarkupException(Exception): pass + def decompose_tagmarkup(tm): """Return (text string, attribute list) for tagmarkup passed.""" @@ -387,18 +389,19 @@ def decompose_tagmarkup(tm): return text, al -def _tagmarkup_recurse( tm, attr ): + +def _tagmarkup_recurse(tm, attr): """Return (text list, attribute list) for tagmarkup passed. tm -- tagmarkup attr -- current attribute or None""" - if type(tm) == list: + if isinstance(tm, list): # for lists recurse to process each subelement rtl = [] ral = [] for element in tm: - tl, al = _tagmarkup_recurse( element, attr ) + tl, al = _tagmarkup_recurse(element, attr) if ral: # merge attributes when possible last_attr, last_run = ral[-1] @@ -410,7 +413,7 @@ def _tagmarkup_recurse( tm, attr ): ral += al return rtl, ral - if type(tm) == tuple: + 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}") @@ -425,25 +428,24 @@ def _tagmarkup_recurse( tm, attr ): return [tm], [(attr, len(tm))] +def is_mouse_event(ev: str) -> bool: + return isinstance(ev, tuple) and len(ev) == 4 and "mouse" in ev[0] -def is_mouse_event( ev ): - return type(ev) == tuple and len(ev)==4 and ev[0].find("mouse")>=0 -def is_mouse_press( ev ): - return ev.find("press")>=0 +def is_mouse_press(ev: str) -> bool: + return "press" in ev class MetaSuper(type): """adding .__super""" - def __init__(cls, name, bases, d): + 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") setattr(cls, f"_{name}__super", super(cls)) - -def int_scale(val, val_range, out_range): +def int_scale(val: int, val_range: int, out_range: 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 @@ -472,7 +474,7 @@ class StoppingContext: def __init__(self, wrapped): self._wrapped = wrapped - def __enter__(self): + def __enter__(self) -> Self: return self def __exit__(self, *exc_info): diff --git a/urwid/vterm.py b/urwid/vterm.py index aee0bae..34c29c6 100644 --- a/urwid/vterm.py +++ b/urwid/vterm.py @@ -242,7 +242,7 @@ class TermCanvas(Canvas): cacheable = False def __init__(self, width, height, widget): - Canvas.__init__(self) + super().__init__() self.width, self.height = width, height self.widget = widget @@ -1360,7 +1360,7 @@ class Terminal(Widget): ``utf8`` with ``urwid.set_encoding("utf8")``. See :ref:`text-encodings` for more details. """ - Widget.__init__(self) + super().__init__() self.escape_sequence = escape_sequence or "ctrl a" diff --git a/urwid/widget.py b/urwid/widget.py index d0ae1fb..9de17e0 100644 --- a/urwid/widget.py +++ b/urwid/widget.py @@ -23,6 +23,7 @@ from __future__ import annotations import functools +import typing import warnings from operator import attrgetter @@ -39,14 +40,13 @@ from urwid.command_map import ( ) from urwid.split_repr import remove_defaults, split_repr from urwid.text_layout import calc_coords, calc_pos, shift_line -from urwid.util import ( - MetaSuper, - calc_width, - decompose_tagmarkup, - is_wide_char, - move_next_char, - move_prev_char, -) +from urwid.util import MetaSuper, calc_width, decompose_tagmarkup, is_wide_char, move_next_char, move_prev_char + +if typing.TYPE_CHECKING: + from typing_extensions import Literal + + from urwid.canvas import TextCanvas + # define some names for these constants to avoid misspellings in the source # and to document the constant strings we are using @@ -454,14 +454,14 @@ class Widget(metaclass=WidgetMeta): _sizing = frozenset([FLOW, BOX, FIXED]) _command_map = command_map - def _invalidate(self): + def _invalidate(self) -> None: """ Mark cached canvases rendered by this widget as dirty so that they will not be used again. """ CanvasCache.invalidate(self) - def _emit(self, name, *args): + def _emit(self, name: str, *args): """ Convenience function to emit signals with self as first argument. @@ -686,6 +686,7 @@ def fixed_size(size): if size != (): raise ValueError(f"FixedWidget takes only () for size.passed: {size!r}") + class FixedWidget(Widget): """ Deprecated. Inherit from Widget and add: @@ -731,7 +732,12 @@ class Divider(Widget): ignore_focus = True - def __init__(self,div_char=" ",top=0,bottom=0): + def __init__( + self, + div_char: str | bytes = " ", + top: int = 0, + bottom: int = 0, + ) -> None: """ :param div_char: character to repeat across line :type div_char: bytes or unicode @@ -764,7 +770,7 @@ class Divider(Widget): if self.bottom: attrs['bottom'] = self.bottom return attrs - def rows(self, size, focus=False): + def rows(self, size: tuple[int], focus: bool = False) -> int: """ Return the number of lines that will be rendered. @@ -776,7 +782,7 @@ class Divider(Widget): (maxcol,) = size return self.top + 1 + self.bottom - def render(self, size, focus=False): + def render(self, size: tuple[int], focus: bool = False): """ Render the divider as a canvas and return it. @@ -802,7 +808,7 @@ class SolidFill(BoxWidget): _selectable = False ignore_focus = True - def __init__(self, fill_char=" "): + def __init__(self, fill_char: str = " ") -> None: """ :param fill_char: character to fill area with :type fill_char: bytes or unicode @@ -816,7 +822,7 @@ class SolidFill(BoxWidget): def _repr_words(self): return super()._repr_words() + [repr(self.fill_char)] - def render(self, size, focus=False ): + def render(self, size: tuple[int, int], focus: bool = False) -> SolidCanvas: """ Render the Fill as a canvas and return it. @@ -828,9 +834,11 @@ class SolidFill(BoxWidget): maxcol, maxrow = size return SolidCanvas(self.fill_char, maxcol, maxrow) + class TextError(Exception): pass + class Text(Widget): """ a horizontally resizeable text widget @@ -840,7 +848,13 @@ class Text(Widget): ignore_focus = True _repr_content_length_max = 140 - def __init__(self, markup, align=LEFT, wrap=SPACE, layout=None): + def __init__( + self, + markup, + align: Literal['left', 'center', 'right'] = LEFT, + wrap: Literal['space', 'any', 'clip', 'ellipsis'] = SPACE, + layout=None, + ) -> None: """ :param markup: content of text widget, one of: @@ -896,11 +910,11 @@ class Text(Widget): wrap=self._wrap_mode) return remove_defaults(attrs, Text.__init__) - def _invalidate(self): + def _invalidate(self) -> None: self._cache_maxcol = None super()._invalidate() - def set_text(self,markup): + def set_text(self, markup): """ Set content of text widget. @@ -941,7 +955,7 @@ class Text(Widget): return self._text, self._attrib @property - def text(self): + def text(self) -> str: """ Read-only property returning the complete bytes/unicode content of this widget @@ -956,7 +970,7 @@ class Text(Widget): """ return self.get_text()[1] - def set_align_mode(self, mode): + def set_align_mode(self, mode: Literal['left', 'center', 'right']): """ Set text alignment mode. Supported modes depend on text layout object in use but defaults to a :class:`StandardTextLayout` instance @@ -982,7 +996,7 @@ class Text(Widget): self._align_mode = mode self._invalidate() - def set_wrap_mode(self, mode): + def set_wrap_mode(self, mode: Literal['space', 'any', 'clip', 'ellipsis']): """ Set text wrapping mode. Supported modes depend on text layout object in use but defaults to a :class:`StandardTextLayout` instance @@ -1010,7 +1024,12 @@ class Text(Widget): self._wrap_mode = mode self._invalidate() - def set_layout(self, align, wrap, layout=None): + def set_layout( + self, + align: Literal['left', 'center', 'right'], + wrap: Literal['space', 'any', 'clip', 'ellipsis'], + layout=None, + ): """ Set the text layout object, alignment and wrapping modes at the same time. @@ -1032,14 +1051,14 @@ class Text(Widget): self.set_align_mode(align) self.set_wrap_mode(wrap) - align = property(lambda self:self._align_mode, set_align_mode) - wrap = property(lambda self:self._wrap_mode, set_wrap_mode) + align = property(lambda self: self._align_mode, set_align_mode) + wrap = property(lambda self: self._wrap_mode, set_wrap_mode) @property def layout(self): return self._layout - def render(self, size, focus=False): + def render(self, size: tuple[int], focus: bool = False) -> TextCanvas: """ Render contents with wrapping and alignment. Return canvas. @@ -1053,10 +1072,10 @@ class Text(Widget): (maxcol,) = size text, attr = self.get_text() #assert isinstance(text, unicode) - trans = self.get_line_translation( maxcol, (text,attr) ) + trans = self.get_line_translation(maxcol, (text, attr)) return apply_text_layout(text, attr, trans, maxcol) - def rows(self, size, focus=False): + def rows(self, size: tuple[int], focus: bool = False) -> int: """ Return the number of rows the rendered text requires. @@ -1070,7 +1089,7 @@ class Text(Widget): (maxcol,) = size return len(self.get_line_translation(maxcol)) - def get_line_translation(self, maxcol, ta=None): + def get_line_translation(self, maxcol: int, ta=None): """ Return layout structure used to map self.text to a canvas. This method is used internally, but may be useful for @@ -1086,7 +1105,7 @@ class Text(Widget): self._update_cache_translation(maxcol, ta) return self._cache_translation - def _update_cache_translation(self,maxcol, ta): + def _update_cache_translation(self,maxcol: int, ta): if ta: text, attr = ta else: @@ -1095,7 +1114,7 @@ class Text(Widget): self._cache_translation = self.layout.layout( text, maxcol, self._align_mode, self._wrap_mode) - def pack(self, size=None, focus=False): + def pack(self, size: tuple[int] | None = None, focus: bool = False) -> tuple[int, int]: """ Return the number of screen columns and rows required for this Text widget to be displayed without wrapping or @@ -1175,9 +1194,18 @@ class Edit(Text): """ return is_wide_char(ch,0) or (len(ch)==1 and ord(ch) >= 32) - def __init__(self, caption="", edit_text="", multiline=False, - align=LEFT, wrap=SPACE, allow_tab=False, - edit_pos=None, layout=None, mask=None): + def __init__( + self, + caption="", + edit_text: str | bytes = "", + multiline: bool = False, + align: Literal['left', 'center', 'right'] = LEFT, + wrap: Literal['space', 'any', 'clip', 'ellipsis'] = SPACE, + allow_tab: bool = False, + edit_pos: int | None = None, + layout=None, + mask: str | bytes | None = None, + ) -> None: """ :param caption: markup for caption preceding edit_text, see :class:`Text` for description of text markup. @@ -1273,7 +1301,7 @@ class Edit(Text): raise EditError("set_text() not supported. Use set_caption()" " or set_edit_text() instead.") - def get_pref_col(self, size): + def get_pref_col(self, size: tuple[int]) -> int: """ Return the preferred column for the cursor, or the current cursor x value. May also return ``'left'`` or ``'right'`` @@ -1311,7 +1339,7 @@ class Edit(Text): else: return pref_col - def update_text(self): + def update_text(self) -> typing.NoReturn: """ No longer supported. @@ -1346,13 +1374,13 @@ class Edit(Text): self._invalidate() @property - def caption(self): + def caption(self) -> str: """ Read-only property returning the caption for this widget. """ return self._caption - def set_edit_pos(self, pos): + def set_edit_pos(self, pos: int) -> None: """ Set the cursor position with a self.edit_text offset. Clips pos to [0, len(edit_text)]. @@ -1386,7 +1414,7 @@ class Edit(Text): Property controlling the edit position for this widget. """) - def set_mask(self, mask): + def set_mask(self, mask: str | bytes | None): """ Set the character for masking text away. @@ -1397,7 +1425,7 @@ class Edit(Text): self._mask = mask self._invalidate() - def set_edit_text(self, text): + def set_edit_text(self, text: str | bytes) -> None: """ Set the edit text for this widget. @@ -1425,7 +1453,7 @@ class Edit(Text): self._emit("postchange", old_text) self._invalidate() - def get_edit_text(self): + def get_edit_text(self) -> str: """ Return the edit text for this widget. @@ -1441,7 +1469,7 @@ class Edit(Text): Property controlling the edit text for this widget. """) - def insert_text(self, text): + def insert_text(self, text: str | bytes) -> None: """ Insert text at the cursor position and update cursor. This method is used by the keypress() method when inserting @@ -1466,7 +1494,7 @@ class Edit(Text): self.set_edit_pos(result_pos) self.highlight = None - def _normalize_to_caption(self, text): + def _normalize_to_caption(self, text: str | bytes) -> str | bytes: """ Return text converted to the same type as self.caption (bytes or unicode) @@ -1476,10 +1504,10 @@ class Edit(Text): if tu == cu: return text if tu: - return text.encode('ascii') # follow python2's implicit conversion + return text.encode('ascii') # follow python2's implicit conversion return text.decode('ascii') - def insert_text_result(self, text): + def insert_text_result(self, text: str | bytes) -> tuple[str | bytes, int]: """ Return result of insert_text(text) without actually performing the insertion. Handy for pre-validation. @@ -1501,14 +1529,13 @@ class Edit(Text): result_pos = self.edit_pos try: - result_text = (result_text[:result_pos] + text + - result_text[result_pos:]) + result_text = (result_text[:result_pos] + text + result_text[result_pos:]) except: assert 0, repr((self.edit_text, result_text, text)) result_pos += len(text) return (result_text, result_pos) - def keypress(self, size, key): + def keypress(self, size: tuple[int], key: str | bytes): """ Handle editing keystrokes, return others. @@ -1530,24 +1557,23 @@ class Edit(Text): p = self.edit_pos if self.valid_char(key): - if (isinstance(key, str) and not - isinstance(self._caption, str)): + if isinstance(key, str) and not isinstance(self._caption, str): # screen is sending us unicode input, must be using utf-8 # encoding because that's all we support, so convert it # to bytes to match our caption's type key = key.encode('utf-8') self.insert_text(key) - elif key=="tab" and self.allow_tab: + elif key == "tab" and self.allow_tab: key = " "*(8-(self.edit_pos%8)) self.insert_text(key) - elif key=="enter" and self.multiline: + elif key == "enter" and self.multiline: key = "\n" self.insert_text(key) elif self._command_map[key] == CURSOR_LEFT: - if p==0: return key + if p == 0: return key p = move_prev_char(self.edit_text,0,p) self.set_edit_pos(p) @@ -1559,41 +1585,42 @@ class Edit(Text): elif self._command_map[key] in (CURSOR_UP, CURSOR_DOWN): self.highlight = None - x,y = self.get_cursor_coords((maxcol,)) + x, y = self.get_cursor_coords((maxcol,)) pref_col = self.get_pref_col((maxcol,)) assert pref_col is not None #if pref_col is None: # pref_col = x - if self._command_map[key] == CURSOR_UP: y -= 1 - else: y += 1 + if self._command_map[key] == CURSOR_UP: + y -= 1 + else: + y += 1 - if not self.move_cursor_to_coords((maxcol,),pref_col,y): + if not self.move_cursor_to_coords((maxcol,), pref_col, y): return key - elif key=="backspace": + elif key == "backspace": self.pref_col_maxcol = None, None if not self._delete_highlighted(): - if p == 0: return key - p = move_prev_char(self.edit_text,0,p) - self.set_edit_text( self.edit_text[:p] + - self.edit_text[self.edit_pos:] ) - self.set_edit_pos( p ) + if p == 0: + return key + p = move_prev_char(self.edit_text, 0, p) + self.set_edit_text(self.edit_text[:p] + self.edit_text[self.edit_pos:]) + self.set_edit_pos(p) - elif key=="delete": + elif key == "delete": self.pref_col_maxcol = None, None if not self._delete_highlighted(): if p >= len(self.edit_text): return key p = move_next_char(self.edit_text,p,len(self.edit_text)) - self.set_edit_text( self.edit_text[:self.edit_pos] + - self.edit_text[p:] ) + self.set_edit_text( self.edit_text[:self.edit_pos] + self.edit_text[p:] ) elif self._command_map[key] in (CURSOR_MAX_LEFT, CURSOR_MAX_RIGHT): self.highlight = None self.pref_col_maxcol = None, None - x,y = self.get_cursor_coords((maxcol,)) + x, y = self.get_cursor_coords((maxcol,)) if self._command_map[key] == CURSOR_MAX_LEFT: self.move_cursor_to_coords((maxcol,), LEFT, y) @@ -1605,7 +1632,7 @@ class Edit(Text): # key wasn't handled return key - def move_cursor_to_coords(self, size, x, y): + def move_cursor_to_coords(self, size: tuple[int], x: int, y: int) -> bool: """ Set the cursor position with (x,y) coordinates. Returns True if move succeeded, False otherwise. @@ -1631,14 +1658,16 @@ class Edit(Text): pos = calc_pos( self.get_text()[0], trans, x, y ) e_pos = pos - len(self.caption) - if e_pos < 0: e_pos = 0 - if e_pos > len(self.edit_text): e_pos = len(self.edit_text) + if e_pos < 0: + e_pos = 0 + if e_pos > len(self.edit_text): + e_pos = len(self.edit_text) self.edit_pos = e_pos self.pref_col_maxcol = x, maxcol self._invalidate() return True - def mouse_event(self, size, event, button, x, y, focus): + def mouse_event(self, size: tuple[int], event, button: int, x: int, y: int, focus: bool) -> bool: """ Move the cursor to the location clicked for button 1. @@ -1650,25 +1679,25 @@ class Edit(Text): 2 """ (maxcol,) = size - if button==1: - return self.move_cursor_to_coords( (maxcol,), x, y ) - + if button == 1: + return self.move_cursor_to_coords((maxcol,), x, y) + return False - def _delete_highlighted(self): + def _delete_highlighted(self) -> bool: """ Delete all highlighted text and update cursor position, if any text is highlighted. """ - if not self.highlight: return + if not self.highlight: + return False start, stop = self.highlight btext, etext = self.edit_text[:start], self.edit_text[stop:] - self.set_edit_text( btext + etext ) + self.set_edit_text(btext + etext) self.edit_pos = start self.highlight = None return True - - def render(self, size, focus=False): + def render(self, size: tuple[int], focus: bool = False) -> TextCanvas | CompositeCanvas: """ Render edit widget and return canvas. Include cursor when in focus. @@ -1682,7 +1711,7 @@ class Edit(Text): (maxcol,) = size self._shift_view_to_cursor = bool(focus) - canv = Text.render(self,(maxcol,)) + canv: TextCanvas | CompositeCanvas = Text.render(self,(maxcol,)) if focus: canv = CompositeCanvas(canv) canv.cursor = self.get_cursor_coords((maxcol,)) @@ -1693,14 +1722,13 @@ class Edit(Text): # d.coords['highlight'] = [ hstart, hstop ] return canv - - def get_line_translation(self, maxcol, ta=None ): + def get_line_translation(self, maxcol: int, ta=None): trans = Text.get_line_translation(self, maxcol, ta) if not self._shift_view_to_cursor: return trans text, ignore = self.get_text() - x,y = calc_coords( text, trans, + x, y = calc_coords( text, trans, self.edit_pos + len(self.caption) ) if x < 0: return ( trans[:y] @@ -1712,8 +1740,7 @@ class Edit(Text): + trans[y+1:] ) return trans - - def get_cursor_coords(self, size): + def get_cursor_coords(self, size: tuple[int]) -> tuple[int, int]: """ Return the (*x*, *y*) coordinates of cursor within widget. @@ -1725,8 +1752,7 @@ class Edit(Text): self._shift_view_to_cursor = True return self.position_coords(maxcol,self.edit_pos) - - def position_coords(self,maxcol,pos): + def position_coords(self, maxcol: int, pos) -> tuple[int, int]: """ Return (*x*, *y*) coordinates for an offset into self.edit_text. """ |