Automated update

This commit is contained in:
klein panic
2025-02-21 22:00:16 -05:00
parent 3b6cc2dc0e
commit a573a508ac
2351 changed files with 522265 additions and 91 deletions

View File

@@ -0,0 +1,199 @@
from __future__ import annotations
from .attr_map import AttrMap, AttrMapError
from .attr_wrap import AttrWrap
from .bar_graph import BarGraph, BarGraphError, BarGraphMeta, GraphVScale, scale_bar_values
from .big_text import BigText
from .box_adapter import BoxAdapter, BoxAdapterError
from .columns import Columns, ColumnsError, ColumnsWarning
from .constants import (
RELATIVE_100,
Align,
Sizing,
VAlign,
WHSettings,
WrapMode,
normalize_align,
normalize_height,
normalize_valign,
normalize_width,
simplify_align,
simplify_height,
simplify_valign,
simplify_width,
)
from .container import WidgetContainerListContentsMixin, WidgetContainerMixin
from .divider import Divider
from .edit import Edit, EditError, IntEdit
from .filler import Filler, FillerError, calculate_top_bottom_filler
from .frame import Frame, FrameError
from .grid_flow import GridFlow, GridFlowError, GridFlowWarning
from .line_box import LineBox
from .listbox import ListBox, ListBoxError, ListWalker, ListWalkerError, SimpleFocusListWalker, SimpleListWalker
from .monitored_list import MonitoredFocusList, MonitoredList
from .overlay import Overlay, OverlayError, OverlayWarning
from .padding import Padding, PaddingError, PaddingWarning, calculate_left_right_padding
from .pile import Pile, PileError, PileWarning
from .popup import PopUpLauncher, PopUpTarget
from .progress_bar import ProgressBar
from .scrollable import Scrollable, ScrollableError, ScrollBar
from .solid_fill import SolidFill
from .text import Text, TextError
from .treetools import ParentNode, TreeListBox, TreeNode, TreeWalker, TreeWidget, TreeWidgetError
from .widget import (
BoxWidget,
FixedWidget,
FlowWidget,
Widget,
WidgetError,
WidgetMeta,
WidgetWarning,
WidgetWrap,
WidgetWrapError,
delegate_to_widget_mixin,
fixed_size,
nocache_widget_render,
nocache_widget_render_instance,
)
from .widget_decoration import WidgetDecoration, WidgetDisable, WidgetPlaceholder
from .wimp import Button, CheckBox, CheckBoxError, RadioButton, SelectableIcon
__all__ = (
"ANY",
"BOTTOM",
"BOX",
"CENTER",
"CLIP",
"ELLIPSIS",
"FIXED",
"FLOW",
"GIVEN",
"LEFT",
"MIDDLE",
"PACK",
"RELATIVE",
"RELATIVE_100",
"RIGHT",
"SPACE",
"TOP",
"WEIGHT",
"Align",
"AttrMap",
"AttrMapError",
"AttrWrap",
"BarGraph",
"BarGraphError",
"BarGraphMeta",
"BigText",
"BoxAdapter",
"BoxAdapterError",
"BoxWidget",
"Button",
"CheckBox",
"CheckBoxError",
"Columns",
"ColumnsError",
"ColumnsWarning",
"Divider",
"Edit",
"EditError",
"Filler",
"FillerError",
"FixedWidget",
"FlowWidget",
"Frame",
"FrameError",
"GraphVScale",
"GridFlow",
"GridFlowError",
"GridFlowWarning",
"IntEdit",
"LineBox",
"ListBox",
"ListBoxError",
"ListWalker",
"ListWalkerError",
"MonitoredFocusList",
"MonitoredList",
"Overlay",
"OverlayError",
"OverlayWarning",
"Padding",
"PaddingError",
"PaddingWarning",
"ParentNode",
"Pile",
"PileError",
"PileWarning",
"PopUpLauncher",
"PopUpTarget",
"ProgressBar",
"RadioButton",
"ScrollBar",
"Scrollable",
"ScrollableError",
"SelectableIcon",
"SimpleFocusListWalker",
"SimpleListWalker",
"Sizing",
"SolidFill",
"Text",
"TextError",
"TreeListBox",
"TreeNode",
"TreeWalker",
"TreeWidget",
"TreeWidgetError",
"VAlign",
"WHSettings",
"Widget",
"WidgetContainerListContentsMixin",
"WidgetContainerMixin",
"WidgetDecoration",
"WidgetDisable",
"WidgetError",
"WidgetMeta",
"WidgetPlaceholder",
"WidgetWarning",
"WidgetWrap",
"WidgetWrapError",
"WrapMode",
"calculate_left_right_padding",
"calculate_top_bottom_filler",
"delegate_to_widget_mixin",
"fixed_size",
"nocache_widget_render",
"nocache_widget_render_instance",
"normalize_align",
"normalize_height",
"normalize_valign",
"normalize_width",
"scale_bar_values",
"simplify_align",
"simplify_height",
"simplify_valign",
"simplify_width",
)
# Backward compatibility
FLOW = Sizing.FLOW
BOX = Sizing.BOX
FIXED = Sizing.FIXED
LEFT = Align.LEFT
RIGHT = Align.RIGHT
CENTER = Align.CENTER
TOP = VAlign.TOP
MIDDLE = VAlign.MIDDLE
BOTTOM = VAlign.BOTTOM
SPACE = WrapMode.SPACE
ANY = WrapMode.ANY
CLIP = WrapMode.CLIP
ELLIPSIS = WrapMode.ELLIPSIS
PACK = WHSettings.PACK
GIVEN = WHSettings.GIVEN
RELATIVE = WHSettings.RELATIVE
WEIGHT = WHSettings.WEIGHT

View File

@@ -0,0 +1,161 @@
from __future__ import annotations
import typing
from collections.abc import Hashable, Mapping
from urwid.canvas import CompositeCanvas
from .widget import WidgetError, delegate_to_widget_mixin
from .widget_decoration import WidgetDecoration
WrappedWidget = typing.TypeVar("WrappedWidget")
class AttrMapError(WidgetError):
pass
class AttrMap(delegate_to_widget_mixin("_original_widget"), WidgetDecoration[WrappedWidget]):
"""
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: WrappedWidget,
attr_map: Hashable | Mapping[Hashable | None, Hashable] | None,
focus_map: Hashable | Mapping[Hashable | None, Hashable] | None = None,
) -> None:
"""
:param w: widget to wrap (stored as self.original_widget)
:type w: widget
:param attr_map: attribute to apply to *w*, or dict of old display
attribute: new display attribute mappings
:type attr_map: display attribute or dict
:param focus_map: attribute to apply when in focus or dict of
old display attribute: new display attribute mappings;
if ``None`` use *attr*
:type focus_map: display attribute or dict
>>> from urwid import Divider, Edit, Text
>>> AttrMap(Divider(u"!"), 'bright')
<AttrMap flow widget <Divider flow widget '!'> attr_map={None: 'bright'}>
>>> AttrMap(Edit(), 'notfocus', 'focus').attr_map
{None: 'notfocus'}
>>> AttrMap(Edit(), 'notfocus', 'focus').focus_map
{None: 'focus'}
>>> size = (5,)
>>> am = AttrMap(Text(u"hi"), 'greeting', 'fgreet')
>>> next(am.render(size, focus=False).content()) # ... = b in Python 3
[('greeting', None, ...'hi ')]
>>> next(am.render(size, focus=True).content())
[('fgreet', None, ...'hi ')]
>>> am2 = AttrMap(Text(('word', u"hi")), {'word':'greeting', None:'bg'})
>>> am2
<AttrMap fixed/flow widget <Text fixed/flow widget 'hi'> attr_map={'word': 'greeting', None: 'bg'}>
>>> next(am2.render(size).content())
[('greeting', None, ...'hi'), ('bg', None, ...' ')]
"""
super().__init__(w)
if isinstance(attr_map, Mapping):
self.attr_map = dict(attr_map)
else:
self.attr_map = {None: attr_map}
if isinstance(focus_map, Mapping):
self.focus_map = dict(focus_map)
elif focus_map is None:
self.focus_map = focus_map
else:
self.focus_map = {None: focus_map}
def _repr_attrs(self) -> dict[str, typing.Any]:
# only include the focus_attr when it takes effect (not None)
d = {**super()._repr_attrs(), "attr_map": self._attr_map}
if self._focus_map is not None:
d["focus_map"] = self._focus_map
return d
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: dict[Hashable | None, Hashable] | None) -> None:
"""
Set the attribute mapping dictionary {from_attr: to_attr, ...}
Note this function does not accept a single attribute the way the
constructor does. You must specify {None: attribute} instead.
>>> from urwid import Text
>>> w = AttrMap(Text(u"hi"), None)
>>> w.set_attr_map({'a':'b'})
>>> w
<AttrMap fixed/flow widget <Text fixed/flow widget 'hi'> attr_map={'a': 'b'}>
"""
for from_attr, to_attr in attr_map.items():
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) -> 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)
return None
def set_focus_map(self, focus_map: dict[Hashable | None, Hashable] | None) -> None:
"""
Set the focus attribute mapping dictionary
{from_attr: to_attr, ...}
If None this widget will use the attr mapping instead (no change
when in focus).
Note this function does not accept a single attribute the way the
constructor does. You must specify {None: attribute} instead.
>>> from urwid import Text
>>> w = AttrMap(Text(u"hi"), {})
>>> w.set_focus_map({'a':'b'})
>>> w
<AttrMap fixed/flow widget <Text fixed/flow widget 'hi'> attr_map={} focus_map={'a': 'b'}>
>>> w.set_focus_map(None)
>>> w
<AttrMap fixed/flow widget <Text fixed/flow widget 'hi'> attr_map={}>
"""
if focus_map is not None:
for from_attr, to_attr in focus_map.items():
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: bool = False) -> CompositeCanvas:
"""
Render wrapped widget and apply attribute. Return canvas.
"""
attr_map = self._attr_map
if focus and self._focus_map is not None:
attr_map = self._focus_map
canv = self._original_widget.render(size, focus=focus)
canv = CompositeCanvas(canv)
canv.fill_attr_apply(attr_map)
return canv

View File

@@ -0,0 +1,142 @@
from __future__ import annotations
import typing
import warnings
from .attr_map import AttrMap
if typing.TYPE_CHECKING:
from collections.abc import Hashable
from .constants import Sizing
from .widget import Widget
class AttrWrap(AttrMap):
def __init__(self, w: Widget, attr, focus_attr=None):
"""
w -- widget to wrap (stored as self.original_widget)
attr -- attribute to apply to w
focus_attr -- attribute to apply when in focus, if None use attr
This widget is a special case of the new AttrMap widget, and it
will pass all function calls and variable references to the wrapped
widget. This class is maintained for backwards compatibility only,
new code should use AttrMap instead.
>>> from urwid import Divider, Edit, Text
>>> AttrWrap(Divider(u"!"), 'bright')
<AttrWrap flow widget <Divider flow widget '!'> attr='bright'>
>>> AttrWrap(Edit(), 'notfocus', 'focus')
<AttrWrap selectable flow widget <Edit selectable flow widget '' edit_pos=0> attr='notfocus' focus_attr='focus'>
>>> size = (5,)
>>> aw = AttrWrap(Text(u"hi"), 'greeting', 'fgreet')
>>> next(aw.render(size, focus=False).content())
[('greeting', None, ...'hi ')]
>>> next(aw.render(size, focus=True).content())
[('fgreet', None, ...'hi ')]
"""
warnings.warn(
"AttrWrap is maintained for backwards compatibility only, new code should use AttrMap instead.",
PendingDeprecationWarning,
stacklevel=2,
)
super().__init__(w, attr, focus_attr)
def _repr_attrs(self) -> dict[str, typing.Any]:
# only include the focus_attr when it takes effect (not None)
d = {**super()._repr_attrs(), "attr": self.attr}
del d["attr_map"]
if "focus_map" in d:
del d["focus_map"]
if self.focus_attr is not None:
d["focus_attr"] = self.focus_attr
return d
@property
def w(self) -> Widget:
"""backwards compatibility, widget used to be stored as w"""
warnings.warn(
"backwards compatibility, widget used to be stored as original_widget",
PendingDeprecationWarning,
stacklevel=2,
)
return self.original_widget
@w.setter
def w(self, new_widget: Widget) -> None:
warnings.warn(
"backwards compatibility, widget used to be stored as original_widget",
PendingDeprecationWarning,
stacklevel=2,
)
self.original_widget = new_widget
def get_w(self):
warnings.warn(
"backwards compatibility, widget used to be stored as original_widget",
DeprecationWarning,
stacklevel=2,
)
return self.original_widget
def set_w(self, new_widget: Widget) -> None:
warnings.warn(
"backwards compatibility, widget used to be stored as original_widget",
DeprecationWarning,
stacklevel=2,
)
self.original_widget = new_widget
def get_attr(self) -> Hashable:
return self.attr_map[None]
def set_attr(self, attr: Hashable) -> None:
"""
Set the attribute to apply to the wrapped widget
>> w = AttrWrap(Divider("-"), None)
>> w.set_attr('new_attr')
>> w
<AttrWrap flow widget <Divider flow widget '-'> attr='new_attr'>
"""
self.set_attr_map({None: attr})
attr = property(get_attr, set_attr)
def get_focus_attr(self) -> Hashable | None:
focus_map = self.focus_map
if focus_map:
return focus_map[None]
return None
def set_focus_attr(self, focus_attr: Hashable) -> None:
"""
Set the attribute to apply to the wapped widget when it is in
focus
If None this widget will use the attr instead (no change when in
focus).
>> w = AttrWrap(Divider("-"), 'old')
>> w.set_focus_attr('new_attr')
>> w
<AttrWrap flow widget <Divider flow widget '-'> attr='old' focus_attr='new_attr'>
>> w.set_focus_attr(None)
>> w
<AttrWrap flow widget <Divider flow widget '-'> attr='old'>
"""
self.set_focus_map({None: focus_attr})
focus_attr = property(get_focus_attr, set_focus_attr)
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
using AttrMap and .base_widget or .original_widget instead.
"""
return getattr(self._original_widget, name)
def sizing(self) -> frozenset[Sizing]:
return self._original_widget.sizing()

View File

@@ -0,0 +1,653 @@
from __future__ import annotations
import typing
from urwid.canvas import CanvasCombine, CompositeCanvas, SolidCanvas
from urwid.util import get_encoding_mode
from .constants import BAR_SYMBOLS, Sizing
from .text import Text
from .widget import Widget, WidgetError, WidgetMeta, nocache_widget_render, nocache_widget_render_instance
if typing.TYPE_CHECKING:
from typing_extensions import Literal
class BarGraphMeta(WidgetMeta):
"""
Detect subclass get_data() method and dynamic change to
get_data() method and disable caching in these cases.
This is for backwards compatibility only, new programs
should use set_data() instead of overriding get_data().
"""
def __init__(cls, name, bases, d):
# pylint: disable=protected-access
super().__init__(name, bases, d)
if "get_data" in d:
cls.render = nocache_widget_render(cls)
cls._get_data = cls.get_data
cls.get_data = property(lambda self: self._get_data, nocache_bargraph_get_data)
def nocache_bargraph_get_data(self, get_data_fn):
"""
Disable caching on this bargraph because get_data_fn needs
to be polled to get the latest data.
"""
self.render = nocache_widget_render_instance(self)
self._get_data = get_data_fn # pylint: disable=protected-access
class BarGraphError(WidgetError):
pass
class BarGraph(Widget, metaclass=BarGraphMeta):
_sizing = frozenset([Sizing.BOX])
ignore_focus = True
eighths = BAR_SYMBOLS.VERTICAL[:8] # Full height is done by style
hlines = "_⎺⎻─⎼⎽"
def __init__(self, attlist, hatt=None, satt=None) -> None:
"""
Create a bar graph with the passed display characteristics.
see set_segment_attributes for a description of the parameters.
"""
super().__init__()
self.set_segment_attributes(attlist, hatt, satt)
self.set_data([], 1, None)
self.set_bar_width(None)
def set_segment_attributes(self, attlist, hatt=None, satt=None):
"""
:param attlist: list containing display attribute or
(display attribute, character) tuple for background,
first segment, and optionally following segments.
ie. len(attlist) == num segments+1
character defaults to ' ' if not specified.
:param hatt: list containing attributes for horizontal lines. First
element is for lines on background, second is for lines
on first segment, third is for lines on second segment
etc.
:param satt: dictionary containing attributes for smoothed
transitions of bars in UTF-8 display mode. The values
are in the form:
(fg,bg) : attr
fg and bg are integers where 0 is the graph background,
1 is the first segment, 2 is the second, ...
fg > bg in all values. attr is an attribute with a
foreground corresponding to fg and a background
corresponding to bg.
If satt is not None and the bar graph is being displayed in
a terminal using the UTF-8 encoding then the character cell
that is shared between the segments specified will be smoothed
with using the UTF-8 vertical eighth characters.
eg: set_segment_attributes( ['no', ('unsure',"?"), 'yes'] )
will use the attribute 'no' for the background (the area from
the top of the graph to the top of the bar), question marks
with the attribute 'unsure' will be used for the topmost
segment of the bar, and the attribute 'yes' will be used for
the bottom segment of the bar.
"""
self.attr = []
self.char = []
if len(attlist) < 2:
raise BarGraphError(f"attlist must include at least background and seg1: {attlist!r}")
if len(attlist) < 2:
raise BarGraphError("must at least specify bg and fg!")
for a in attlist:
if not isinstance(a, tuple):
self.attr.append(a)
self.char.append(" ")
else:
attr, ch = a
self.attr.append(attr)
self.char.append(ch)
self.hatt = []
if hatt is None:
hatt = [self.attr[0]]
elif not isinstance(hatt, list):
hatt = [hatt]
self.hatt = hatt
if satt is None:
satt = {}
for i in satt.items():
try:
(fg, bg), attr = i
except ValueError as exc:
raise BarGraphError(f"satt not in (fg,bg:attr) form: {i!r}").with_traceback(exc.__traceback__) from exc
if not isinstance(fg, int) or fg >= len(attlist):
raise BarGraphError(f"fg not valid integer: {fg!r}")
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})")
self.satt = satt
def set_data(self, bardata, top: float, hlines=None) -> None:
"""
Store bar data, bargraph top and horizontal line positions.
bardata -- a list of bar values.
top -- maximum value for segments within bardata
hlines -- None or a bar value marking horizontal line positions
bar values are [ segment1, segment2, ... ] lists where top is
the maximal value corresponding to the top of the bar graph and
segment1, segment2, ... are the values for the top of each
segment of this bar. Simple bar graphs will only have one
segment in each bar value.
Eg: if top is 100 and there is a bar value of [ 80, 30 ] then
the top of this bar will be at 80% of full height of the graph
and it will have a second segment that starts at 30%.
"""
if hlines is not None:
hlines = sorted(hlines[:], reverse=True) # shallow copy
self.data = bardata, top, hlines
self._invalidate()
def _get_data(self, size: tuple[int, int]):
"""
Return (bardata, top, hlines)
This function is called by render to retrieve the data for
the graph. It may be overloaded to create a dynamic bar graph.
This implementation will truncate the bardata list returned
if not all bars will fit within maxcol.
"""
(maxcol, maxrow) = size
bardata, top, hlines = self.data
widths = self.calculate_bar_widths((maxcol, maxrow), bardata)
if len(bardata) > len(widths):
return bardata[: len(widths)], top, hlines
return bardata, top, hlines
def set_bar_width(self, width: int | None):
"""
Set a preferred bar width for calculate_bar_widths to use.
width -- width of bar or None for automatic width adjustment
"""
if width is not None and width <= 0:
raise ValueError(width)
self.bar_width = width
self._invalidate()
def calculate_bar_widths(self, size: tuple[int, int], bardata):
"""
Return a list of bar widths, one for each bar in data.
If self.bar_width is None this implementation will stretch
the bars across the available space specified by maxcol.
"""
(maxcol, _maxrow) = size
if self.bar_width is not None:
return [self.bar_width] * min(len(bardata), maxcol // self.bar_width)
if len(bardata) >= maxcol:
return [1] * maxcol
widths = []
grow = maxcol
remain = len(bardata)
for _row in bardata:
w = int(float(grow) / remain + 0.5)
widths.append(w)
grow -= w
remain -= 1
return widths
def selectable(self) -> Literal[False]:
"""
Return False.
"""
return False
def use_smoothed(self) -> bool:
return self.satt and get_encoding_mode() == "utf8"
def calculate_display(self, size: tuple[int, int]):
"""
Calculate display data.
"""
(maxcol, maxrow) = size
bardata, top, hlines = self.get_data((maxcol, maxrow)) # pylint: disable=no-member # metaclass defined
widths = self.calculate_bar_widths((maxcol, maxrow), bardata)
if self.use_smoothed():
disp = calculate_bargraph_display(bardata, top, widths, maxrow * 8)
disp = self.smooth_display(disp)
else:
disp = calculate_bargraph_display(bardata, top, widths, maxrow)
if hlines:
disp = self.hlines_display(disp, top, hlines, maxrow)
return disp
def hlines_display(self, disp, top: int, hlines, maxrow: int):
"""
Add hlines to display structure represented as bar_type tuple
values:
(bg, 0-5)
bg is the segment that has the hline on it
0-5 is the hline graphic to use where 0 is a regular underscore
and 1-5 are the UTF-8 horizontal scan line characters.
"""
if self.use_smoothed():
shiftr = 0
r = [
(0.2, 1),
(0.4, 2),
(0.6, 3),
(0.8, 4),
(1.0, 5),
]
else:
shiftr = 0.5
r = [
(1.0, 0),
]
# reverse the hlines to match screen ordering
rhl = []
for h in hlines:
rh = float(top - h) * maxrow / top - shiftr
if rh < 0:
continue
rhl.append(rh)
# build a list of rows that will have hlines
hrows = []
last_i = -1
for rh in rhl:
i = int(rh)
if i == last_i:
continue
f = rh - i
for spl, chnum in r:
if f < spl:
hrows.append((i, chnum))
break
last_i = i
# fill hlines into disp data
def fill_row(row, chnum):
rout = []
for bar_type, width in row:
if isinstance(bar_type, int) and len(self.hatt) > bar_type:
rout.append(((bar_type, chnum), width))
continue
rout.append((bar_type, width))
return rout
o = []
k = 0
rnum = 0
for y_count, row in disp:
if k >= len(hrows):
o.append((y_count, row))
continue
end_block = rnum + y_count
while k < len(hrows) and hrows[k][0] < end_block:
i, chnum = hrows[k]
if i - rnum > 0:
o.append((i - rnum, row))
o.append((1, fill_row(row, chnum)))
rnum = i + 1
k += 1
if rnum < end_block:
o.append((end_block - rnum, row))
rnum = end_block
# assert 0, o
return o
def smooth_display(self, disp):
"""
smooth (col, row*8) display into (col, row) display using
UTF vertical eighth characters represented as bar_type
tuple values:
( fg, bg, 1-7 )
where fg is the lower segment, bg is the upper segment and
1-7 is the vertical eighth character to use.
"""
o = []
r = 0 # row remainder
def seg_combine(a, b):
(bt1, w1), (bt2, w2) = a, b
if (bt1, w1) == (bt2, w2):
return (bt1, w1), None, None
wmin = min(w1, w2)
l1 = l2 = None
if w1 > w2:
l1 = (bt1, w1 - w2)
elif w2 > w1:
l2 = (bt2, w2 - w1)
if isinstance(bt1, tuple):
return (bt1, wmin), l1, l2
if (bt2, bt1) not in self.satt:
if r < 4:
return (bt2, wmin), l1, l2
return (bt1, wmin), l1, l2
return ((bt2, bt1, 8 - r), wmin), l1, l2
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[:]
widget_list = []
while row:
(bt, w), l1, l2 = seg_combine(o_row.pop(0), row.pop(0))
if widget_list and widget_list[-1][0] == bt:
widget_list[-1] = (bt, widget_list[-1][1] + w)
else:
widget_list.append((bt, w))
if l1:
o_row = [l1, *o_row]
if l2:
row = [l2, *row]
if o_row:
raise BarGraphError(o_row)
o[-1] = (o_count + count, widget_list)
# regroup into actual rows (8 disp rows == 1 actual row)
for y_count, row in disp:
if r:
count = min(8 - r, y_count)
row_combine_last(count, row)
y_count -= count # noqa: PLW2901
r += count
r %= 8
if not y_count:
continue
if r != 0:
raise BarGraphError
# copy whole blocks
if y_count > 7:
o.append((y_count // 8 * 8, row))
y_count %= 8 # noqa: PLW2901
if not y_count:
continue
o.append((y_count, row))
r = y_count
return [(y // 8, row) for (y, row) in o]
def render(self, size: tuple[int, int], focus: bool = False) -> CompositeCanvas:
"""
Render BarGraph.
"""
(maxcol, maxrow) = size
disp = self.calculate_display((maxcol, maxrow))
combinelist = []
for y_count, row in disp:
widget_list = []
for bar_type, width in row:
if isinstance(bar_type, tuple):
if len(bar_type) == 3:
# vertical eighths
fg, bg, k = bar_type
a = self.satt[fg, bg]
t = self.eighths[k] * width
else:
# horizontal lines
bg, k = bar_type
a = self.hatt[bg]
t = self.hlines[k] * width
else:
a = self.attr[bar_type]
t = self.char[bar_type] * width
widget_list.append((a, t))
c = Text(widget_list).render((maxcol,))
if c.rows() != 1:
raise BarGraphError("Invalid characters in BarGraph!")
combinelist += [(c, None, False)] * y_count
canv = CanvasCombine(combinelist)
return canv
def calculate_bargraph_display(bardata, top: float, bar_widths: list[int], maxrow: int):
"""
Calculate a rendering of the bar graph described by data, bar_widths
and height.
bardata -- bar information with same structure as BarGraph.data
top -- maximal value for bardata segments
bar_widths -- list of integer column widths for each bar
maxrow -- rows for display of bargraph
Returns a structure as follows:
[ ( y_count, [ ( bar_type, width), ... ] ), ... ]
The outer tuples represent a set of identical rows. y_count is
the number of rows in this set, the list contains the data to be
displayed in the row repeated through the set.
The inner tuple describes a run of width characters of bar_type.
bar_type is an integer starting from 0 for the background, 1 for
the 1st segment, 2 for the 2nd segment etc..
This function should complete in approximately O(n+m) time, where
n is the number of bars displayed and m is the number of rows.
"""
if len(bardata) != len(bar_widths):
raise BarGraphError
maxcol = sum(bar_widths)
# build intermediate data structure
rows = [None] * maxrow
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:
if last_col >= col:
del rows[row][-1]
else:
rows[row][-1] = (last_seg, last_col, col)
elif last_seg == seg_num and last_end == col:
rows[row][-1] = (last_seg, last_col, last_end + width)
return
elif rows[row] is None:
rows[row] = []
rows[row].append((seg_num, col, col + width))
col = 0
barnum = 0
for bar in bardata:
width = bar_widths[barnum]
if width < 1:
continue
# loop through in reverse order
tallest = maxrow
segments = scale_bar_values(bar, top, maxrow)
for k in range(len(bar) - 1, -1, -1):
s = segments[k]
if s >= maxrow:
continue
s = max(s, 0)
if s < tallest:
# add only properly-overlapped bars
tallest = s
add_segment(k + 1, col, s, width)
col += width
barnum += 1
# print(repr(rows))
# build rowsets data structure
rowsets = []
y_count = 0
last = [(0, maxcol)]
for r in rows:
if r is None:
y_count += 1
continue
if y_count:
rowsets.append((y_count, last))
y_count = 0
i = 0 # index into "last"
la, ln = last[i] # last attribute, last run length
c = 0 # current column
o = [] # output list to be added to rowsets
for seg_num, start, end in r:
while start > c + ln:
o.append((la, ln))
i += 1
c += ln
la, ln = last[i]
if la == seg_num:
# same attribute, can combine
o.append((la, end - c))
else:
if start - c > 0:
o.append((la, start - c))
o.append((seg_num, end - start))
if end == maxcol:
i = len(last)
break
# skip past old segments covered by new one
while end >= c + ln:
i += 1
c += ln
la, ln = last[i]
if la != seg_num:
ln = c + ln - end
c = end
continue
# same attribute, can extend
oa, on = o[-1]
on += c + ln - end
o[-1] = oa, on
i += 1
c += ln
if c == maxcol:
break
if i >= len(last):
raise ValueError(repr((on, maxcol)))
la, ln = last[i]
if i < len(last):
o += [(la, ln)] + last[i + 1 :]
last = o
y_count += 1
if y_count:
rowsets.append((y_count, last))
return rowsets
class GraphVScale(Widget):
_sizing = frozenset([Sizing.BOX])
def __init__(self, labels, top: float) -> None:
"""
GraphVScale( [(label1 position, label1 markup),...], top )
label position -- 0 < position < top for the y position
label markup -- text markup for this label
top -- top y position
This widget is a vertical scale for the BarGraph widget that
can correspond to the BarGraph's horizontal lines
"""
super().__init__()
self.set_scale(labels, top)
def set_scale(self, labels, top: float) -> None:
"""
set_scale( [(label1 position, label1 markup),...], top )
label position -- 0 < position < top for the y position
label markup -- text markup for this label
top -- top y position
"""
labels = sorted(labels[:], reverse=True) # shallow copy
self.pos = []
self.txt = []
for y, markup in labels:
self.pos.append(y)
self.txt.append(Text(markup))
self.top = top
def selectable(self) -> Literal[False]:
"""
Return False.
"""
return False
def render(
self,
size: tuple[int, int],
focus: bool = False,
) -> SolidCanvas | CompositeCanvas:
"""
Render GraphVScale.
"""
(maxcol, maxrow) = size
pl = scale_bar_values(self.pos, self.top, maxrow)
combinelist = []
rows = 0
for p, t in zip(pl, self.txt):
p -= 1 # noqa: PLW2901
if p >= maxrow:
break
if p < rows:
continue
c = t.render((maxcol,))
if p > rows:
run = p - rows
c = CompositeCanvas(c)
c.pad_trim_top_bottom(run, 0)
rows += c.rows()
combinelist.append((c, None, False))
if not combinelist:
return SolidCanvas(" ", size[0], size[1])
canvas = CanvasCombine(combinelist)
if maxrow - rows:
canvas.pad_trim_top_bottom(0, maxrow - rows)
return canvas
def scale_bar_values(bar, top: float, maxrow: int) -> list[int]:
"""
Return a list of bar values aliased to integer values of maxrow.
"""
return [maxrow - int(float(v) * maxrow / top + 0.5) for v in bar]

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
import typing
from urwid.canvas import CanvasJoin, CompositeCanvas, TextCanvas
from urwid.util import decompose_tagmarkup
from .constants import Sizing
from .widget import Widget, fixed_size
if typing.TYPE_CHECKING:
from collections.abc import Hashable
from urwid import Font
class BigText(Widget):
_sizing = frozenset([Sizing.FIXED])
def __init__(self, markup, font: Font) -> None:
"""
markup -- same as Text widget markup
font -- instance of a Font class
"""
super().__init__()
self.text: str = ""
self.attrib = []
self.font: Font = font
self.set_font(font)
self.set_text(markup)
def set_text(self, markup):
self.text, self.attrib = decompose_tagmarkup(markup)
self._invalidate()
def get_text(self):
"""
Returns (text, attributes).
"""
return self.text, self.attrib
def set_font(self, font: Font) -> None:
self.font = font
self._invalidate()
def pack(
self,
size: tuple[()] | None = (), # type: ignore[override]
focus: bool = False,
) -> tuple[int, int]:
rows = self.font.height
cols = 0
for c in self.text:
cols += self.font.char_width(c)
return cols, rows
def render(
self,
size: tuple[()], # type: ignore[override]
focus: bool = False,
) -> CompositeCanvas:
fixed_size(size) # complain if parameter is wrong
a: Hashable | None = None
ai = ak = 0
o = []
rows = self.font.height
attrib = [*self.attrib, (None, len(self.text))]
for ch in self.text:
if not ak:
a, ak = attrib[ai]
ai += 1
ak -= 1
width = self.font.char_width(ch)
if not width:
# ignore invalid characters
continue
c: TextCanvas | CompositeCanvas = self.font.render(ch)
if a is not None:
c = CompositeCanvas(c)
c.fill_attr(a)
o.append((c, None, False, width))
if o:
canv = CanvasJoin(o)
else:
canv = CompositeCanvas(TextCanvas([b""] * rows, maxcol=0, check_width=False))
canv.set_depends([])
return canv

View File

@@ -0,0 +1,134 @@
from __future__ import annotations
import typing
import warnings
from urwid.canvas import CompositeCanvas
from .constants import Sizing
from .widget_decoration import WidgetDecoration, WidgetError
WrappedWidget = typing.TypeVar("WrappedWidget")
class BoxAdapterError(WidgetError):
pass
class BoxAdapter(WidgetDecoration[WrappedWidget]):
"""
Adapter for using a box widget where a flow widget would usually go
"""
no_cache: typing.ClassVar[list[str]] = ["rows"]
def __init__(self, box_widget: WrappedWidget, height: int) -> None:
"""
Create a flow widget that contains a box widget
:param box_widget: box widget to wrap
:type box_widget: Widget
:param height: number of rows for box widget
:type height: int
>>> from urwid import SolidFill
>>> BoxAdapter(SolidFill(u"x"), 5) # 5-rows of x's
<BoxAdapter flow widget <SolidFill box widget 'x'> height=5>
"""
if hasattr(box_widget, "sizing") and Sizing.BOX not in box_widget.sizing():
raise BoxAdapterError(f"{box_widget!r} is not a box widget")
super().__init__(box_widget)
self.height = height
def _repr_attrs(self) -> dict[str, typing.Any]:
return {**super()._repr_attrs(), "height": self.height}
# originally stored as box_widget, keep for compatibility
@property
def box_widget(self) -> WrappedWidget:
warnings.warn(
"original stored as original_widget, keep for compatibility",
PendingDeprecationWarning,
stacklevel=2,
)
return self.original_widget
@box_widget.setter
def box_widget(self, widget: WrappedWidget) -> None:
warnings.warn(
"original stored as original_widget, keep for compatibility",
PendingDeprecationWarning,
stacklevel=2,
)
self.original_widget = widget
def sizing(self) -> frozenset[Sizing]:
return frozenset((Sizing.FLOW,))
def rows(self, size: tuple[int], focus: bool = False) -> int:
"""
Return the predetermined height (behave like a flow widget)
>>> from urwid import SolidFill
>>> BoxAdapter(SolidFill(u"x"), 5).rows((20,))
5
"""
return self.height
# The next few functions simply tack-on our height and pass through
# to self._original_widget
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: 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: tuple[int], # type: ignore[override]
key: str,
) -> str | None:
(maxcol,) = size
return self._original_widget.keypress((maxcol, self.height), key)
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: tuple[int], # type: ignore[override]
event: str,
button: int,
col: int,
row: int,
focus: bool,
) -> bool | None:
(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)
def render(
self,
size: tuple[int], # type: ignore[override]
focus: bool = False,
) -> CompositeCanvas:
(maxcol,) = size
canv = CompositeCanvas(self._original_widget.render((maxcol, self.height), focus))
return canv
def __getattr__(self, name: str):
"""
Pass calls to box widget.
"""
return getattr(self.original_widget, name)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,555 @@
from __future__ import annotations
import dataclasses
import enum
import typing
if typing.TYPE_CHECKING:
from typing_extensions import Literal
# define some names for these constants to avoid misspellings in the source
# and to document the constant strings we are using
class Sizing(str, enum.Enum):
"""Widget sizing methods."""
FLOW = "flow"
BOX = "box"
FIXED = "fixed"
class Align(str, enum.Enum):
"""Text alignment modes"""
LEFT = "left"
RIGHT = "right"
CENTER = "center"
class VAlign(str, enum.Enum):
"""Filler alignment"""
TOP = "top"
MIDDLE = "middle"
BOTTOM = "bottom"
class WrapMode(str, enum.Enum):
"""Text wrapping modes"""
SPACE = "space"
ANY = "any"
CLIP = "clip"
ELLIPSIS = "ellipsis"
class WHSettings(str, enum.Enum):
"""Width and Height settings"""
PACK = "pack"
GIVEN = "given"
RELATIVE = "relative"
WEIGHT = "weight"
CLIP = "clip" # Used as "given" for widgets with fixed width (with clipping part of it)
FLOW = "flow" # Used as pack for flow widgets
RELATIVE_100 = (WHSettings.RELATIVE, 100)
@typing.overload
def normalize_align(
align: Literal["left", "center", "right"] | Align,
err: type[BaseException],
) -> tuple[Align, None]: ...
@typing.overload
def normalize_align(
align: tuple[Literal["relative", WHSettings.RELATIVE], int],
err: type[BaseException],
) -> tuple[Literal[WHSettings.RELATIVE], int]: ...
def normalize_align(
align: Literal["left", "center", "right"] | Align | tuple[Literal["relative", WHSettings.RELATIVE], int],
err: type[BaseException],
) -> tuple[Align, None] | tuple[Literal[WHSettings.RELATIVE], int]:
"""
Split align into (align_type, align_amount). Raise exception err
if align doesn't match a valid alignment.
"""
if align in {Align.LEFT, Align.CENTER, Align.RIGHT}:
return (Align(align), None)
if isinstance(align, tuple) and len(align) == 2 and align[0] == WHSettings.RELATIVE:
_align_type, align_amount = align
return (WHSettings.RELATIVE, align_amount)
raise err(
f"align value {align!r} is not one of 'left', 'center', 'right', ('relative', percentage 0=left 100=right)"
)
@typing.overload
def simplify_align(
align_type: Literal["relative", WHSettings.RELATIVE],
align_amount: int,
) -> tuple[Literal[WHSettings.RELATIVE], int]: ...
@typing.overload
def simplify_align(
align_type: Literal["relative", WHSettings.RELATIVE],
align_amount: None,
) -> typing.NoReturn: ...
@typing.overload
def simplify_align(
align_type: Literal["left", "center", "right"] | Align,
align_amount: int | None,
) -> Align: ...
def simplify_align(
align_type: Literal["left", "center", "right", "relative", WHSettings.RELATIVE] | Align,
align_amount: int | None,
) -> Align | tuple[Literal[WHSettings.RELATIVE], int]:
"""
Recombine (align_type, align_amount) into an align value.
Inverse of normalize_align.
"""
if align_type == WHSettings.RELATIVE:
if not isinstance(align_amount, int):
raise TypeError(align_amount)
return (WHSettings.RELATIVE, align_amount)
return Align(align_type)
@typing.overload
def normalize_valign(
valign: Literal["top", "middle", "bottom"] | VAlign,
err: type[BaseException],
) -> tuple[VAlign, None]: ...
@typing.overload
def normalize_valign(
valign: tuple[Literal["relative", WHSettings.RELATIVE], int],
err: type[BaseException],
) -> tuple[Literal[WHSettings.RELATIVE], int]: ...
def normalize_valign(
valign: Literal["top", "middle", "bottom"] | VAlign | tuple[Literal["relative", WHSettings.RELATIVE], int],
err: type[BaseException],
) -> tuple[VAlign, None] | tuple[Literal[WHSettings.RELATIVE], int]:
"""
Split align into (valign_type, valign_amount). Raise exception err
if align doesn't match a valid alignment.
"""
if valign in {VAlign.TOP, VAlign.MIDDLE, VAlign.BOTTOM}:
return (VAlign(valign), None)
if isinstance(valign, tuple) and len(valign) == 2 and valign[0] == WHSettings.RELATIVE:
_valign_type, valign_amount = valign
return (WHSettings.RELATIVE, valign_amount)
raise err(
f"valign value {valign!r} is not one of 'top', 'middle', 'bottom', ('relative', percentage 0=left 100=right)"
)
@typing.overload
def simplify_valign(
valign_type: Literal["top", "middle", "bottom"] | VAlign,
valign_amount: int | None,
) -> VAlign: ...
@typing.overload
def simplify_valign(
valign_type: Literal["relative", WHSettings.RELATIVE],
valign_amount: int,
) -> tuple[Literal[WHSettings.RELATIVE], int]: ...
@typing.overload
def simplify_valign(
valign_type: Literal["relative", WHSettings.RELATIVE],
valign_amount: None,
) -> typing.NoReturn: ...
def simplify_valign(
valign_type: Literal["top", "middle", "bottom", "relative", WHSettings.RELATIVE] | VAlign,
valign_amount: int | None,
) -> VAlign | tuple[Literal[WHSettings.RELATIVE], int]:
"""
Recombine (valign_type, valign_amount) into an valign value.
Inverse of normalize_valign.
"""
if valign_type == WHSettings.RELATIVE:
if not isinstance(valign_amount, int):
raise TypeError(valign_amount)
return (WHSettings.RELATIVE, valign_amount)
return VAlign(valign_type)
@typing.overload
def normalize_width(
width: Literal["clip", "pack", WHSettings.CLIP, WHSettings.PACK],
err: type[BaseException],
) -> tuple[Literal[WHSettings.CLIP, WHSettings.PACK], None]: ...
@typing.overload
def normalize_width(
width: int,
err: type[BaseException],
) -> tuple[Literal[WHSettings.GIVEN], int]: ...
@typing.overload
def normalize_width(
width: tuple[Literal["relative", WHSettings.RELATIVE], int],
err: type[BaseException],
) -> tuple[Literal[WHSettings.RELATIVE], int]: ...
@typing.overload
def normalize_width(
width: tuple[Literal["weight", WHSettings.WEIGHT], int | float],
err: type[BaseException],
) -> tuple[Literal[WHSettings.WEIGHT], int | float]: ...
def normalize_width(
width: (
Literal["clip", "pack", WHSettings.CLIP, WHSettings.PACK]
| int
| tuple[Literal["relative", WHSettings.RELATIVE], int]
| tuple[Literal["weight", WHSettings.WEIGHT], int | float]
),
err: type[BaseException],
) -> (
tuple[Literal[WHSettings.CLIP, WHSettings.PACK], None]
| tuple[Literal[WHSettings.GIVEN, WHSettings.RELATIVE], int]
| tuple[Literal[WHSettings.WEIGHT], int | float]
):
"""
Split width into (width_type, width_amount). Raise exception err
if width doesn't match a valid alignment.
"""
if width in {WHSettings.CLIP, WHSettings.PACK}:
return (WHSettings(width), None)
if isinstance(width, int):
return (WHSettings.GIVEN, width)
if isinstance(width, tuple) and len(width) == 2 and width[0] in {WHSettings.RELATIVE, WHSettings.WEIGHT}:
width_type, width_amount = width
return (WHSettings(width_type), width_amount)
raise err(
f"width value {width!r} is not one of"
f"fixed number of columns, 'pack', ('relative', percentage of total width), 'clip'"
)
@typing.overload
def simplify_width(
width_type: Literal["clip", "pack", WHSettings.CLIP, WHSettings.PACK],
width_amount: int | None,
) -> Literal[WHSettings.CLIP, WHSettings.PACK]: ...
@typing.overload
def simplify_width(
width_type: Literal["given", WHSettings.GIVEN],
width_amount: int,
) -> int: ...
@typing.overload
def simplify_width(
width_type: Literal["relative", WHSettings.RELATIVE],
width_amount: int,
) -> tuple[Literal[WHSettings.RELATIVE], int]: ...
@typing.overload
def simplify_width(
width_type: Literal["weight", WHSettings.WEIGHT],
width_amount: int | float, # noqa: PYI041 # provide explicit for IDEs
) -> tuple[Literal[WHSettings.WEIGHT], int | float]: ...
@typing.overload
def simplify_width(
width_type: Literal["given", "relative", "weight", WHSettings.GIVEN, WHSettings.RELATIVE, WHSettings.WEIGHT],
width_amount: None,
) -> typing.NoReturn: ...
def simplify_width(
width_type: Literal["clip", "pack", "given", "relative", "weight"] | WHSettings,
width_amount: int | float | None, # noqa: PYI041 # provide explicit for IDEs
) -> (
Literal[WHSettings.CLIP, WHSettings.PACK]
| int
| tuple[Literal[WHSettings.RELATIVE], int]
| tuple[Literal[WHSettings.WEIGHT], int | float]
):
"""
Recombine (width_type, width_amount) into an width value.
Inverse of normalize_width.
"""
if width_type in {WHSettings.CLIP, WHSettings.PACK}:
return WHSettings(width_type)
if not isinstance(width_amount, int):
raise TypeError(width_amount)
if width_type == WHSettings.GIVEN:
return width_amount
return (WHSettings(width_type), width_amount)
@typing.overload
def normalize_height(
height: int,
err: type[BaseException],
) -> tuple[Literal[WHSettings.GIVEN], int]: ...
@typing.overload
def normalize_height(
height: Literal["flow", "pack", Sizing.FLOW, WHSettings.PACK],
err: type[BaseException],
) -> tuple[Literal[Sizing.FLOW, WHSettings.PACK], None]: ...
@typing.overload
def normalize_height(
height: tuple[Literal["relative", WHSettings.RELATIVE], int],
err: type[BaseException],
) -> tuple[Literal[WHSettings.RELATIVE], int]: ...
@typing.overload
def normalize_height(
height: tuple[Literal["weight", WHSettings.WEIGHT], int | float],
err: type[BaseException],
) -> tuple[Literal[WHSettings.WEIGHT], int | float]: ...
def normalize_height(
height: (
int
| Literal["flow", "pack", Sizing.FLOW, WHSettings.PACK]
| tuple[Literal["relative", WHSettings.RELATIVE], int]
| tuple[Literal["weight", WHSettings.WEIGHT], int | float]
),
err: type[BaseException],
) -> (
tuple[Literal[Sizing.FLOW, WHSettings.PACK], None]
| tuple[Literal[WHSettings.RELATIVE, WHSettings.GIVEN], int]
| tuple[Literal[WHSettings.WEIGHT], int | float]
):
"""
Split height into (height_type, height_amount). Raise exception err
if height isn't valid.
"""
if height == Sizing.FLOW:
return (Sizing.FLOW, None)
if height == WHSettings.PACK:
return (WHSettings.PACK, None)
if isinstance(height, tuple) and len(height) == 2 and height[0] in {WHSettings.RELATIVE, WHSettings.WEIGHT}:
return (WHSettings(height[0]), height[1])
if isinstance(height, int):
return (WHSettings.GIVEN, height)
raise err(
f"height value {height!r} is not one of "
f"fixed number of columns, 'pack', ('relative', percentage of total height)"
)
@typing.overload
def simplify_height(
height_type: Literal["flow", "pack", WHSettings.FLOW, WHSettings.PACK],
height_amount: int | None,
) -> Literal[WHSettings.FLOW, WHSettings.PACK]: ...
@typing.overload
def simplify_height(
height_type: Literal["given", WHSettings.GIVEN],
height_amount: int,
) -> int: ...
@typing.overload
def simplify_height(
height_type: Literal["relative", WHSettings.RELATIVE],
height_amount: int | None,
) -> tuple[Literal[WHSettings.RELATIVE], int]: ...
@typing.overload
def simplify_height(
height_type: Literal["weight", WHSettings.WEIGHT],
height_amount: int | float | None, # noqa: PYI041 # provide explicit for IDEs
) -> tuple[Literal[WHSettings.WEIGHT], int | float]: ...
@typing.overload
def simplify_height(
height_type: Literal["relative", "given", "weight", WHSettings.RELATIVE, WHSettings.GIVEN, WHSettings.WEIGHT],
height_amount: None,
) -> typing.NoReturn: ...
def simplify_height(
height_type: Literal[
"flow",
"pack",
"relative",
"given",
"weight",
WHSettings.FLOW,
WHSettings.PACK,
WHSettings.RELATIVE,
WHSettings.GIVEN,
WHSettings.WEIGHT,
],
height_amount: int | float | None, # noqa: PYI041 # provide explicit for IDEs
) -> (
int
| Literal[WHSettings.FLOW, WHSettings.PACK]
| tuple[Literal[WHSettings.RELATIVE], int]
| tuple[Literal[WHSettings.WEIGHT], int | float]
):
"""
Recombine (height_type, height_amount) into a height value.
Inverse of normalize_height.
"""
if height_type in {WHSettings.FLOW, WHSettings.PACK}:
return WHSettings(height_type)
if not isinstance(height_amount, int):
raise TypeError(height_amount)
if height_type == WHSettings.GIVEN:
return height_amount
return (WHSettings(height_type), height_amount)
@dataclasses.dataclass(frozen=True)
class _BoxSymbols:
"""Box symbols for drawing."""
HORIZONTAL: str
VERTICAL: str
TOP_LEFT: str
TOP_RIGHT: str
BOTTOM_LEFT: str
BOTTOM_RIGHT: str
# Joints for tables making
LEFT_T: str
RIGHT_T: str
TOP_T: str
BOTTOM_T: str
CROSS: str
@dataclasses.dataclass(frozen=True)
class _BoxSymbolsWithDashes(_BoxSymbols):
"""Box symbols for drawing.
Extra dashes symbols.
"""
HORIZONTAL_4_DASHES: str
HORIZONTAL_3_DASHES: str
HORIZONTAL_2_DASHES: str
VERTICAL_2_DASH: str
VERTICAL_3_DASH: str
VERTICAL_4_DASH: str
@dataclasses.dataclass(frozen=True)
class _LightBoxSymbols(_BoxSymbolsWithDashes):
"""Box symbols for drawing.
The Thin version includes extra symbols.
Symbols are ordered as in Unicode except dashes.
"""
TOP_LEFT_ROUNDED: str
TOP_RIGHT_ROUNDED: str
BOTTOM_LEFT_ROUNDED: str
BOTTOM_RIGHT_ROUNDED: str
class _BoxSymbolsCollection(typing.NamedTuple):
"""Standard Unicode box symbols for basic tables drawing.
.. note::
Transitions are not included: depends on line types, different kinds of transitions are available.
Please check Unicode table for transitions symbols if required.
"""
# fmt: off
LIGHT: _LightBoxSymbols = _LightBoxSymbols(
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""
)
HEAVY: _BoxSymbolsWithDashes = _BoxSymbolsWithDashes(
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""
)
DOUBLE: _BoxSymbols = _BoxSymbols(
"", "", "", "", "", "", "", "", "", "", ""
)
BOX_SYMBOLS = _BoxSymbolsCollection()
class BAR_SYMBOLS(str, enum.Enum):
"""Standard Unicode bar symbols excluding empty space.
Start from space (0), then 1/8 till full block (1/1).
Typically used only 8 from this symbol collection depends on use-case:
* empty - 7/8 and styles for BG different on both sides (like standard `ProgressBar` and `BarGraph`)
* 1/8 - full block and single style for BG on the right side
"""
# fmt: off
HORISONTAL = " ▏▎▍▌▋▊▉█"
VERTICAL = " ▁▂▃▄▅▆▇█"
class _SHADE_SYMBOLS(typing.NamedTuple):
"""Standard shade symbols excluding empty space."""
# fmt: off
FULL_BLOCK: str = ""
DARK_SHADE: str = ""
MEDIUM_SHADE: str = ""
LITE_SHADE: str = ""
SHADE_SYMBOLS = _SHADE_SYMBOLS()

View File

@@ -0,0 +1,227 @@
from __future__ import annotations
import abc
import enum
import typing
import warnings
from .constants import Sizing, WHSettings
if typing.TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from .widget import Widget
class _ContainerElementSizingFlag(enum.IntFlag):
NONE = 0
BOX = enum.auto()
FLOW = enum.auto()
FIXED = enum.auto()
WH_WEIGHT = enum.auto()
WH_PACK = enum.auto()
WH_GIVEN = enum.auto()
@property
def reverse_flag(self) -> tuple[frozenset[Sizing], WHSettings | None]:
"""Get flag in public API format."""
sizing: set[Sizing] = set()
if self & self.BOX:
sizing.add(Sizing.BOX)
if self & self.FLOW:
sizing.add(Sizing.FLOW)
if self & self.FIXED:
sizing.add(Sizing.FIXED)
if self & self.WH_WEIGHT:
return frozenset(sizing), WHSettings.WEIGHT
if self & self.WH_PACK:
return frozenset(sizing), WHSettings.PACK
if self & self.WH_GIVEN:
return frozenset(sizing), WHSettings.GIVEN
return frozenset(sizing), None
@property
def log_string(self) -> str:
"""Get desctiprion in public API format."""
sizing, render = self.reverse_flag
render_string = f" {render.upper()}" if render else ""
return "|".join(sorted(mode.upper() for mode in sizing)) + render_string
class WidgetContainerMixin:
"""
Mixin class for widget containers implementing common container methods
"""
def __getitem__(self, position) -> Widget:
"""
Container short-cut for self.contents[position][0].base_widget
which means "give me the child widget at position without any
widget decorations".
This allows for concise traversal of nested container widgets
such as:
my_widget[position0][position1][position2] ...
"""
return self.contents[position][0].base_widget
def get_focus_path(self) -> list[int | str]:
"""
Return the .focus_position values starting from this container
and proceeding along each child widget until reaching a leaf
(non-container) widget.
"""
out = []
w = self
while True:
try:
p = w.focus_position
except IndexError:
return out
out.append(p)
w = w.focus.base_widget
def set_focus_path(self, positions: Iterable[int | str]) -> None:
"""
Set the .focus_position property starting from this container
widget and proceeding along newly focused child widgets. Any
failed assignment due do incompatible position types or invalid
positions will raise an IndexError.
This method may be used to restore a particular widget to the
focus by passing in the value returned from an earlier call to
get_focus_path().
positions -- sequence of positions
"""
w: Widget = self
for p in positions:
if p != w.focus_position:
w.focus_position = p # modifies w.focus
w = w.focus.base_widget # type: ignore[assignment]
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
(non-container) widget.
Note that the list does not contain the topmost container widget
(i.e., on which this method is called), but does include the
lowest leaf widget.
"""
out = []
w = self
while True:
w = w.base_widget.focus
if w is None:
return out
out.append(w)
@property
@abc.abstractmethod
def focus(self) -> Widget:
"""
Read-only property returning the child widget in focus for
container widgets. This default implementation
always returns ``None``, indicating that this widget has no children.
"""
def _get_focus(self) -> Widget:
warnings.warn(
f"method `{self.__class__.__name__}._get_focus` is deprecated, "
f"please use `{self.__class__.__name__}.focus` property",
DeprecationWarning,
stacklevel=3,
)
return self.focus
class WidgetContainerListContentsMixin:
"""
Mixin class for widget containers whose positions are indexes into
a list available as self.contents.
"""
def __iter__(self) -> Iterator[int]:
"""
Return an iterable of positions for this container from first
to last.
"""
return iter(range(len(self.contents)))
def __reversed__(self) -> Iterator[int]:
"""
Return an iterable of positions for this container from last
to first.
"""
return iter(range(len(self.contents) - 1, -1, -1))
def __len__(self) -> int:
return len(self.contents)
@property
@abc.abstractmethod
def contents(self) -> list[tuple[Widget, typing.Any]]:
"""The contents of container as a list of (widget, options)"""
@contents.setter
def contents(self, new_contents: list[tuple[Widget, typing.Any]]) -> None:
"""The contents of container as a list of (widget, options)"""
def _get_contents(self) -> list[tuple[Widget, typing.Any]]:
warnings.warn(
f"method `{self.__class__.__name__}._get_contents` is deprecated, "
f"please use `{self.__class__.__name__}.contents` property",
DeprecationWarning,
stacklevel=2,
)
return self.contents
def _set_contents(self, c: list[tuple[Widget, typing.Any]]) -> None:
warnings.warn(
f"method `{self.__class__.__name__}._set_contents` is deprecated, "
f"please use `{self.__class__.__name__}.contents` property",
DeprecationWarning,
stacklevel=2,
)
self.contents = c
@property
@abc.abstractmethod
def focus_position(self) -> int | None:
"""
index of child widget in focus.
"""
@focus_position.setter
def focus_position(self, position: int) -> None:
"""
index of child widget in focus.
"""
def _get_focus_position(self) -> int | None:
warnings.warn(
f"method `{self.__class__.__name__}._get_focus_position` is deprecated, "
f"please use `{self.__class__.__name__}.focus_position` property",
DeprecationWarning,
stacklevel=3,
)
return self.focus_position
def _set_focus_position(self, position: int) -> None:
"""
Set the widget in focus.
position -- index of child widget to be made focus
"""
warnings.warn(
f"method `{self.__class__.__name__}._set_focus_position` is deprecated, "
f"please use `{self.__class__.__name__}.focus_position` property",
DeprecationWarning,
stacklevel=3,
)
self.focus_position = position

View File

@@ -0,0 +1,114 @@
from __future__ import annotations
import enum
import typing
from urwid.canvas import CompositeCanvas, SolidCanvas
from .constants import BOX_SYMBOLS, SHADE_SYMBOLS, Sizing
from .widget import Widget
class DividerSymbols(str, enum.Enum):
"""Common symbols for divider widgets."""
# Lines
LIGHT_HL = BOX_SYMBOLS.LIGHT.HORIZONTAL
LIGHT_4_DASHES = BOX_SYMBOLS.LIGHT.HORIZONTAL_4_DASHES
LIGHT_3_DASHES = BOX_SYMBOLS.LIGHT.HORIZONTAL_3_DASHES
LIGHT_2_DASHES = BOX_SYMBOLS.LIGHT.HORIZONTAL_2_DASHES
HEAVY_HL = BOX_SYMBOLS.HEAVY.HORIZONTAL
HEAVY_4_DASHES = BOX_SYMBOLS.HEAVY.HORIZONTAL_4_DASHES
HEAVY_3_DASHES = BOX_SYMBOLS.HEAVY.HORIZONTAL_3_DASHES
HEAVY_2_DASHES = BOX_SYMBOLS.HEAVY.HORIZONTAL_2_DASHES
DOUBLE_HL = BOX_SYMBOLS.DOUBLE.HORIZONTAL
# Full block
FULL_BLOCK = SHADE_SYMBOLS.FULL_BLOCK
DARK_SHADE = SHADE_SYMBOLS.DARK_SHADE
MEDIUM_SHADE = SHADE_SYMBOLS.MEDIUM_SHADE
LITE_SHADE = SHADE_SYMBOLS.LITE_SHADE
class Divider(Widget):
"""
Horizontal divider widget
"""
Symbols = DividerSymbols
_sizing = frozenset([Sizing.FLOW])
ignore_focus = True
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
:param top: number of blank lines above
:type top: int
:param bottom: number of blank lines below
:type bottom: int
>>> Divider()
<Divider flow widget>
>>> Divider(u'-')
<Divider flow widget '-'>
>>> Divider(u'x', 1, 2)
<Divider flow widget 'x' bottom=2 top=1>
"""
super().__init__()
self.div_char = div_char
self.top = top
self.bottom = bottom
def _repr_words(self) -> list[str]:
return super()._repr_words() + [repr(self.div_char)] * (self.div_char != " ")
def _repr_attrs(self) -> dict[str, typing.Any]:
attrs = dict(super()._repr_attrs())
if self.top:
attrs["top"] = self.top
if self.bottom:
attrs["bottom"] = self.bottom
return attrs
def rows(self, size: tuple[int], focus: bool = False) -> int:
"""
Return the number of lines that will be rendered.
>>> Divider().rows((10,))
1
>>> Divider(u'x', 1, 2).rows((10,))
4
"""
(_maxcol,) = size
return self.top + 1 + self.bottom
def render(
self,
size: tuple[int], # type: ignore[override]
focus: bool = False,
) -> CompositeCanvas:
"""
Render the divider as a canvas and return it.
>>> Divider().render((10,)).text # ... = b in Python 3
[...' ']
>>> Divider(u'-', top=1).render((10,)).text
[...' ', ...'----------']
>>> Divider(u'x', bottom=2).render((5,)).text
[...'xxxxx', ...' ', ...' ']
"""
(maxcol,) = size
canv = CompositeCanvas(SolidCanvas(self.div_char, maxcol, 1))
if self.top or self.bottom:
canv.pad_trim_top_bottom(self.top, self.bottom)
return canv

View File

@@ -0,0 +1,724 @@
from __future__ import annotations
import string
import typing
from urwid import text_layout
from urwid.canvas import CompositeCanvas
from urwid.command_map import Command
from urwid.split_repr import remove_defaults
from urwid.str_util import is_wide_char, move_next_char, move_prev_char
from urwid.util import decompose_tagmarkup
from .constants import Align, Sizing, WrapMode
from .text import Text, TextError
if typing.TYPE_CHECKING:
from collections.abc import Hashable
from typing_extensions import Literal
from urwid.canvas import TextCanvas
class EditError(TextError):
pass
class Edit(Text):
"""
Text editing widget implements cursor movement, text insertion and
deletion. A caption may prefix the editing area. Uses text class
for text layout.
Users of this class may listen for ``"change"`` or ``"postchange"``
events. See :func:``connect_signal``.
* ``"change"`` is sent just before the value of edit_text changes.
It receives the new text as an argument. Note that ``"change"`` cannot
change the text in question as edit_text changes the text afterwards.
* ``"postchange"`` is sent after the value of edit_text changes.
It receives the old value of the text as an argument and thus is
appropriate for changing the text. It is possible for a ``"postchange"``
event handler to get into a loop of changing the text and then being
called when the event is re-emitted. It is up to the event
handler to guard against this case (for instance, by not changing the
text if it is signaled for text that it has already changed once).
"""
_sizing = frozenset([Sizing.FLOW])
_selectable = True
ignore_focus = False
# (this variable is picked up by the MetaSignals metaclass)
signals: typing.ClassVar[list[str]] = ["change", "postchange"]
def valid_char(self, ch: str) -> bool:
"""
Filter for text that may be entered into this widget by the user
:param ch: character to be inserted
:type ch: str
This implementation returns True for all printable characters.
"""
return is_wide_char(ch, 0) or (len(ch) == 1 and ord(ch) >= 32)
def __init__(
self,
caption: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]] = "",
edit_text: str = "",
multiline: bool = False,
align: Literal["left", "center", "right"] | Align = Align.LEFT,
wrap: Literal["space", "any", "clip", "ellipsis"] | WrapMode = WrapMode.SPACE,
allow_tab: bool = False,
edit_pos: int | None = None,
layout: text_layout.TextLayout = None,
mask: str | None = None,
) -> None:
"""
:param caption: markup for caption preceding edit_text, see
:class:`Text` for description of text markup.
:type caption: text markup
:param edit_text: initial text for editing, type (bytes or unicode)
must match the text in the caption
:type edit_text: bytes or unicode
:param multiline: True: 'enter' inserts newline False: return it
:type multiline: bool
:param align: typically 'left', 'center' or 'right'
:type align: text alignment mode
:param wrap: typically 'space', 'any' or 'clip'
:type wrap: text wrapping mode
:param allow_tab: True: 'tab' inserts 1-8 spaces False: return it
:type allow_tab: bool
:param edit_pos: initial position for cursor, None:end of edit_text
:type edit_pos: int
:param layout: defaults to a shared :class:`StandardTextLayout` instance
:type layout: text layout instance
:param mask: hide text entered with this character, None:disable mask
:type mask: bytes or unicode
>>> Edit()
<Edit selectable flow widget '' edit_pos=0>
>>> Edit(u"Y/n? ", u"yes")
<Edit selectable flow widget 'yes' caption='Y/n? ' edit_pos=3>
>>> Edit(u"Name ", u"Smith", edit_pos=1)
<Edit selectable flow widget 'Smith' caption='Name ' edit_pos=1>
>>> Edit(u"", u"3.14", align='right')
<Edit selectable flow widget '3.14' align='right' edit_pos=4>
"""
super().__init__("", align, wrap, layout)
self.multiline = multiline
self.allow_tab = allow_tab
self._edit_pos = 0
self._caption, self._attrib = decompose_tagmarkup(caption)
self._edit_text = ""
self.highlight: tuple[int, int] | None = None
self.set_edit_text(edit_text)
if edit_pos is None:
edit_pos = len(edit_text)
self.set_edit_pos(edit_pos)
self.set_mask(mask)
self._shift_view_to_cursor = False
def _repr_words(self) -> list[str]:
return (
super()._repr_words()[:-1]
+ [repr(self._edit_text)]
+ [f"caption={self._caption!r}"] * bool(self._caption)
+ ["multiline"] * (self.multiline is True)
)
def _repr_attrs(self) -> dict[str, typing.Any]:
attrs = {**super()._repr_attrs(), "edit_pos": self._edit_pos}
return remove_defaults(attrs, Edit.__init__)
def get_text(self) -> tuple[str | bytes, list[tuple[Hashable, int]]]:
"""
Returns ``(text, display attributes)``. See :meth:`Text.get_text`
for details.
Text returned includes the caption and edit_text, possibly masked.
>>> Edit(u"What? ","oh, nothing.").get_text()
('What? oh, nothing.', [])
>>> Edit(('bright',u"user@host:~$ "),"ls").get_text()
('user@host:~$ ls', [('bright', 13)])
>>> Edit(u"password:", u"seekrit", mask=u"*").get_text()
('password:*******', [])
"""
if self._mask is None:
return self._caption + self._edit_text, self._attrib
return self._caption + (self._mask * len(self._edit_text)), self._attrib
def set_text(self, markup: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]]) -> None:
"""
Not supported by Edit widget.
>>> Edit().set_text("test")
Traceback (most recent call last):
EditError: set_text() not supported. Use set_caption() or set_edit_text() instead.
"""
# FIXME: this smells. reimplement Edit as a WidgetWrap subclass to
# clean this up
# hack to let Text.__init__() work
if not hasattr(self, "_text") and markup == "": # noqa: PLC1901,RUF100
self._text = None
return
raise EditError("set_text() not supported. Use set_caption() or set_edit_text() instead.")
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'``
to indicate the leftmost or rightmost column available.
This method is used internally and by other widgets when
moving the cursor up or down between widgets so that the
column selected is one that the user would expect.
>>> size = (10,)
>>> Edit().get_pref_col(size)
0
>>> e = Edit(u"", u"word")
>>> e.get_pref_col(size)
4
>>> e.keypress(size, 'left')
>>> e.get_pref_col(size)
3
>>> e.keypress(size, 'end')
>>> e.get_pref_col(size)
<Align.RIGHT: 'right'>
>>> e = Edit(u"", u"2\\nwords")
>>> e.keypress(size, 'left')
>>> e.keypress(size, 'up')
>>> e.get_pref_col(size)
4
>>> e.keypress(size, 'left')
>>> e.get_pref_col(size)
0
"""
(maxcol,) = size
pref_col, then_maxcol = self.pref_col_maxcol
if then_maxcol != maxcol:
return self.get_cursor_coords((maxcol,))[0]
return pref_col
def set_caption(self, caption: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]]) -> None:
"""
Set the caption markup for this widget.
:param caption: markup for caption preceding edit_text, see
:meth:`Text.__init__` for description of text markup.
>>> e = Edit("")
>>> e.set_caption("cap1")
>>> print(e.caption)
cap1
>>> e.set_caption(('bold', "cap2"))
>>> print(e.caption)
cap2
>>> e.attrib
[('bold', 4)]
>>> e.caption = "cap3" # not supported because caption stores text but set_caption() takes markup
Traceback (most recent call last):
AttributeError: can't set attribute
"""
self._caption, self._attrib = decompose_tagmarkup(caption)
self._invalidate()
@property
def caption(self) -> str:
"""
Read-only property returning the caption for this widget.
"""
return self._caption
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)].
:param pos: cursor position
:type pos: int
>>> e = Edit(u"", u"word")
>>> e.edit_pos
4
>>> e.set_edit_pos(2)
>>> e.edit_pos
2
>>> e.edit_pos = -1 # Urwid 0.9.9 or later
>>> e.edit_pos
0
>>> e.edit_pos = 20
>>> e.edit_pos
4
"""
pos = min(max(pos, 0), len(self._edit_text))
self.highlight = None
self.pref_col_maxcol = None, None
self._edit_pos = pos
self._invalidate()
edit_pos = property(
lambda self: self._edit_pos,
set_edit_pos,
doc="""
Property controlling the edit position for this widget.
""",
)
def set_mask(self, mask: str | None) -> None:
"""
Set the character for masking text away.
:param mask: hide text entered with this character, None:disable mask
:type mask: bytes or unicode
"""
self._mask = mask
self._invalidate()
def set_edit_text(self, text: str) -> None:
"""
Set the edit text for this widget.
:param text: text for editing, type (bytes or unicode)
must match the text in the caption
:type text: bytes or unicode
>>> e = Edit()
>>> e.set_edit_text(u"yes")
>>> print(e.edit_text)
yes
>>> e
<Edit selectable flow widget 'yes' edit_pos=0>
>>> e.edit_text = u"no" # Urwid 0.9.9 or later
>>> print(e.edit_text)
no
"""
text = self._normalize_to_caption(text)
self.highlight = None
self._emit("change", text)
old_text = self._edit_text
self._edit_text = text
self.edit_pos = min(self.edit_pos, len(text))
self._emit("postchange", old_text)
self._invalidate()
def get_edit_text(self) -> str:
"""
Return the edit text for this widget.
>>> e = Edit(u"What? ", u"oh, nothing.")
>>> print(e.get_edit_text())
oh, nothing.
>>> print(e.edit_text)
oh, nothing.
"""
return self._edit_text
edit_text = property(
get_edit_text,
set_edit_text,
doc="""
Property controlling the edit text for this widget.
""",
)
def insert_text(self, text: str) -> None:
"""
Insert text at the cursor position and update cursor.
This method is used by the keypress() method when inserting
one or more characters into edit_text.
:param text: text for inserting, type (bytes or unicode)
must match the text in the caption
:type text: bytes or unicode
>>> e = Edit(u"", u"42")
>>> e.insert_text(u".5")
>>> e
<Edit selectable flow widget '42.5' edit_pos=4>
>>> e.set_edit_pos(2)
>>> e.insert_text(u"a")
>>> print(e.edit_text)
42a.5
"""
text = self._normalize_to_caption(text)
result_text, result_pos = self.insert_text_result(text)
self.set_edit_text(result_text)
self.set_edit_pos(result_pos)
self.highlight = None
def _normalize_to_caption(self, text: str | bytes) -> str | bytes:
"""Return text converted to the same type as self.caption (bytes or unicode)"""
tu = isinstance(text, str)
cu = isinstance(self._caption, str)
if tu == cu:
return text
if tu:
return text.encode("ascii") # follow python2's implicit conversion
return text.decode("ascii")
def insert_text_result(self, text: str) -> tuple[str | bytes, int]:
"""
Return result of insert_text(text) without actually performing the
insertion. Handy for pre-validation.
:param text: text for inserting, type (bytes or unicode)
must match the text in the caption
:type text: bytes or unicode
"""
# if there's highlighted text, it'll get replaced by the new text
text = self._normalize_to_caption(text)
if self.highlight:
start, stop = self.highlight # pylint: disable=unpacking-non-sequence # already checked
btext, etext = self.edit_text[:start], self.edit_text[stop:]
result_text = btext + etext
result_pos = start
else:
result_text = self.edit_text
result_pos = self.edit_pos
try:
result_text = result_text[:result_pos] + text + result_text[result_pos:]
except (IndexError, TypeError) as exc:
raise ValueError(repr((self.edit_text, result_text, text))).with_traceback(exc.__traceback__) from exc
result_pos += len(text)
return (result_text, result_pos)
def keypress(
self,
size: tuple[int], # type: ignore[override]
key: str,
) -> str | None:
"""
Handle editing keystrokes, return others.
>>> e, size = Edit(), (20,)
>>> e.keypress(size, 'x')
>>> e.keypress(size, 'left')
>>> e.keypress(size, '1')
>>> print(e.edit_text)
1x
>>> e.keypress(size, 'backspace')
>>> e.keypress(size, 'end')
>>> e.keypress(size, '2')
>>> print(e.edit_text)
x2
>>> e.keypress(size, 'shift f1')
'shift f1'
"""
pos = self.edit_pos
if self.valid_char(key):
if isinstance(key, str) and not isinstance(self._caption, str):
key = key.encode("utf-8")
self.insert_text(key)
return None
if key == "tab" and self.allow_tab:
key = " " * (8 - (self.edit_pos % 8))
self.insert_text(key)
return None
if key == "enter" and self.multiline:
key = "\n"
self.insert_text(key)
return None
if self._command_map[key] == Command.LEFT:
if pos == 0:
return key
pos = move_prev_char(self.edit_text, 0, pos)
self.set_edit_pos(pos)
return None
if self._command_map[key] == Command.RIGHT:
if pos >= len(self.edit_text):
return key
pos = move_next_char(self.edit_text, pos, len(self.edit_text))
self.set_edit_pos(pos)
return None
if self._command_map[key] in {Command.UP, Command.DOWN}:
self.highlight = None
_x, y = self.get_cursor_coords(size)
pref_col = self.get_pref_col(size)
if pref_col is None:
raise ValueError(pref_col)
# if pref_col is None:
# pref_col = x
if self._command_map[key] == Command.UP:
y -= 1
else:
y += 1
if not self.move_cursor_to_coords(size, pref_col, y):
return key
return None
if key == "backspace":
self.pref_col_maxcol = None, None
if not self._delete_highlighted():
if pos == 0:
return key
pos = move_prev_char(self.edit_text, 0, pos)
self.set_edit_text(self.edit_text[:pos] + self.edit_text[self.edit_pos :])
self.set_edit_pos(pos)
return None
return None
if key == "delete":
self.pref_col_maxcol = None, None
if not self._delete_highlighted():
if pos >= len(self.edit_text):
return key
pos = move_next_char(self.edit_text, pos, len(self.edit_text))
self.set_edit_text(self.edit_text[: self.edit_pos] + self.edit_text[pos:])
return None
return None
if self._command_map[key] in {Command.MAX_LEFT, Command.MAX_RIGHT}:
self.highlight = None
self.pref_col_maxcol = None, None
_x, y = self.get_cursor_coords(size)
if self._command_map[key] == Command.MAX_LEFT:
self.move_cursor_to_coords(size, Align.LEFT, y)
else:
self.move_cursor_to_coords(size, Align.RIGHT, y)
return None
# key wasn't handled
return key
def move_cursor_to_coords(
self,
size: tuple[int],
x: int | Literal[Align.LEFT, Align.RIGHT],
y: int,
) -> bool:
"""
Set the cursor position with (x,y) coordinates.
Returns True if move succeeded, False otherwise.
>>> size = (10,)
>>> e = Edit("","edit\\ntext")
>>> e.move_cursor_to_coords(size, 5, 0)
True
>>> e.edit_pos
4
>>> e.move_cursor_to_coords(size, 5, 3)
False
>>> e.move_cursor_to_coords(size, 0, 1)
True
>>> e.edit_pos
5
"""
(maxcol,) = size
trans = self.get_line_translation(maxcol)
_top_x, top_y = self.position_coords(maxcol, 0)
if y < top_y or y >= len(trans):
return False
pos = text_layout.calc_pos(self.get_text()[0], trans, x, y)
e_pos = min(max(pos - len(self.caption), 0), len(self.edit_text))
self.edit_pos = e_pos
self.pref_col_maxcol = x, maxcol
self._invalidate()
return True
def mouse_event(
self,
size: tuple[int], # type: ignore[override]
event: str,
button: int,
col: int,
row: int,
focus: bool,
) -> bool | None:
"""
Move the cursor to the location clicked for button 1.
>>> size = (20,)
>>> e = Edit("","words here")
>>> e.mouse_event(size, 'mouse press', 1, 2, 0, True)
True
>>> e.edit_pos
2
"""
if button == 1:
return self.move_cursor_to_coords(size, col, row)
return False
def _delete_highlighted(self) -> bool:
"""
Delete all highlighted text and update cursor position, if any
text is highlighted.
"""
if not self.highlight:
return False
start, stop = self.highlight # pylint: disable=unpacking-non-sequence # already checked
btext, etext = self.edit_text[:start], self.edit_text[stop:]
self.set_edit_text(btext + etext)
self.edit_pos = start
self.highlight = None
return True
def render(
self,
size: tuple[int], # type: ignore[override]
focus: bool = False,
) -> TextCanvas | CompositeCanvas:
"""
Render edit widget and return canvas. Include cursor when in
focus.
>>> edit = Edit("? ","yes")
>>> c = edit.render((10,), focus=True)
>>> c.text
[b'? yes ']
>>> c.cursor
(5, 0)
"""
self._shift_view_to_cursor = bool(focus) # noqa: FURB123,RUF100
canv: TextCanvas | CompositeCanvas = super().render(size, focus)
if focus:
canv = CompositeCanvas(canv)
canv.cursor = self.get_cursor_coords(size)
# .. will need to FIXME if I want highlight to work again
# if self.highlight:
# hstart, hstop = self.highlight_coords()
# d.coords['highlight'] = [ hstart, hstop ]
return canv
def get_line_translation(
self,
maxcol: int,
ta: tuple[str | bytes, list[tuple[Hashable, int]]] | None = None,
) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:
trans = super().get_line_translation(maxcol, ta)
if not self._shift_view_to_cursor:
return trans
text, _ignore = self.get_text()
x, y = text_layout.calc_coords(text, trans, self.edit_pos + len(self.caption))
if x < 0:
return [
*trans[:y],
*[text_layout.shift_line(trans[y], -x)],
*trans[y + 1 :],
]
if x >= maxcol:
return [
*trans[:y],
*[text_layout.shift_line(trans[y], -(x - maxcol + 1))],
*trans[y + 1 :],
]
return trans
def get_cursor_coords(self, size: tuple[int]) -> tuple[int, int]:
"""
Return the (*x*, *y*) coordinates of cursor within widget.
>>> Edit("? ","yes").get_cursor_coords((10,))
(5, 0)
"""
(maxcol,) = size
self._shift_view_to_cursor = True
return self.position_coords(maxcol, self.edit_pos)
def position_coords(self, maxcol: int, pos: int) -> tuple[int, int]:
"""
Return (*x*, *y*) coordinates for an offset into self.edit_text.
"""
p = pos + len(self.caption)
trans = self.get_line_translation(maxcol)
x, y = text_layout.calc_coords(self.get_text()[0], trans, p)
return x, y
class IntEdit(Edit):
"""Edit widget for integer values"""
def valid_char(self, ch: str) -> bool:
"""
Return true for decimal digits.
"""
return len(ch) == 1 and ch in string.digits
def __init__(self, caption="", default: int | str | None = None) -> None:
"""
caption -- caption markup
default -- default edit value
>>> IntEdit(u"", 42)
<IntEdit selectable flow widget '42' edit_pos=2>
"""
if default is not None:
val = str(default)
else:
val = ""
super().__init__(caption, val)
def keypress(
self,
size: tuple[int], # type: ignore[override]
key: str,
) -> str | None:
"""
Handle editing keystrokes. Remove leading zeros.
>>> e, size = IntEdit(u"", 5002), (10,)
>>> e.keypress(size, 'home')
>>> e.keypress(size, 'delete')
>>> print(e.edit_text)
002
>>> e.keypress(size, 'end')
>>> print(e.edit_text)
2
"""
unhandled = super().keypress(size, key)
if not unhandled:
# 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
def value(self) -> int:
"""
Return the numeric value of self.edit_text.
>>> e, size = IntEdit(), (10,)
>>> e.keypress(size, '5')
>>> e.keypress(size, '1')
>>> e.value() == 51
True
"""
if self.edit_text:
return int(self.edit_text)
return 0

View File

@@ -0,0 +1,410 @@
from __future__ import annotations
import typing
import warnings
from urwid.canvas import CompositeCanvas
from urwid.split_repr import remove_defaults
from urwid.util import int_scale
from .constants import (
RELATIVE_100,
Sizing,
VAlign,
WHSettings,
normalize_height,
normalize_valign,
simplify_height,
simplify_valign,
)
from .widget_decoration import WidgetDecoration, WidgetError
if typing.TYPE_CHECKING:
from typing_extensions import Literal
WrappedWidget = typing.TypeVar("WrappedWidget")
class FillerError(WidgetError):
pass
class Filler(WidgetDecoration[WrappedWidget]):
def __init__(
self,
body: WrappedWidget,
valign: (
Literal["top", "middle", "bottom"] | VAlign | tuple[Literal["relative", WHSettings.RELATIVE], int]
) = VAlign.MIDDLE,
height: (
int | Literal["pack", WHSettings.PACK] | tuple[Literal["relative", WHSettings.RELATIVE], int] | None
) = WHSettings.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)
:type body: Widget
:param valign: one of:
``'top'``, ``'middle'``, ``'bottom'``,
(``'relative'``, *percentage* 0=top 100=bottom)
:param height: one of:
``'pack'``
if body is a flow widget
*given height*
integer number of rows for self.original_widget
(``'relative'``, *percentage of total height*)
make height depend on container's height
:param min_height: one of:
``None``
if no minimum or if body is a flow widget
*minimum height*
integer number of rows for the widget when height not fixed
:param top: a fixed number of rows to fill at the top
:type top: int
:param bottom: a fixed number of rows to fill at the bottom
:type bottom: int
If body is a flow widget, then height must be ``'pack'`` and *min_height* will be ignored.
Sizing of the filler will be BOX/FLOW in this case.
If height is integer, *min_height* will be ignored and sizing of filler will be BOX/FLOW.
Filler widgets will try to satisfy height argument first by reducing the valign amount when necessary.
If height still cannot be satisfied it will also be reduced.
"""
super().__init__(body)
# convert old parameters to the new top/bottom values
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")
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")
bottom = height[1]
height = RELATIVE_100
if isinstance(valign, tuple):
if valign[0] == "fixed top":
top = valign[1]
normalized_valign = VAlign.TOP
elif valign[0] == "fixed bottom":
bottom = valign[1]
normalized_valign = VAlign.BOTTOM
else:
normalized_valign = valign
elif not isinstance(valign, (VAlign, str)):
raise FillerError(f"invalid valign: {valign!r}")
else:
normalized_valign = VAlign(valign)
# convert old flow mode parameter height=None to height='flow'
if height is None or height == Sizing.FLOW:
height = WHSettings.PACK
self.top = top
self.bottom = bottom
self.valign_type, self.valign_amount = normalize_valign(normalized_valign, FillerError)
self.height_type, self.height_amount = normalize_height(height, FillerError)
if self.height_type not in {WHSettings.GIVEN, WHSettings.PACK}:
self.min_height = min_height
else:
self.min_height = None
def sizing(self) -> frozenset[Sizing]:
"""Widget sizing.
Sizing BOX is always supported.
Sizing FLOW is supported if: FLOW widget (a height type is PACK) or BOX widget with height GIVEN
"""
sizing: set[Sizing] = {Sizing.BOX}
if self.height_type in {WHSettings.PACK, WHSettings.GIVEN}:
sizing.add(Sizing.FLOW)
return frozenset(sizing)
def rows(self, size: tuple[int], focus: bool = False) -> int:
"""Flow pack support if FLOW sizing supported."""
if self.height_type == WHSettings.PACK:
return self.original_widget.rows(size, focus) + self.top + self.bottom
if self.height_type == WHSettings.GIVEN:
return self.height_amount + self.top + self.bottom
raise FillerError("Method 'rows' not supported for BOX widgets") # pragma: no cover
def _repr_attrs(self) -> dict[str, typing.Any]:
attrs = {
**super()._repr_attrs(),
"valign": simplify_valign(self.valign_type, self.valign_amount),
"height": simplify_height(self.height_type, self.height_amount),
"top": self.top,
"bottom": self.bottom,
"min_height": self.min_height,
}
return remove_defaults(attrs, Filler.__init__)
@property
def body(self) -> WrappedWidget:
"""backwards compatibility, widget used to be stored as body"""
warnings.warn(
"backwards compatibility, widget used to be stored as body",
PendingDeprecationWarning,
stacklevel=2,
)
return self.original_widget
@body.setter
def body(self, new_body: WrappedWidget) -> None:
warnings.warn(
"backwards compatibility, widget used to be stored as body",
PendingDeprecationWarning,
stacklevel=2,
)
self.original_widget = new_body
def get_body(self) -> WrappedWidget:
"""backwards compatibility, widget used to be stored as body"""
warnings.warn(
"backwards compatibility, widget used to be stored as body",
DeprecationWarning,
stacklevel=2,
)
return self.original_widget
def set_body(self, new_body: WrappedWidget) -> None:
warnings.warn(
"backwards compatibility, widget used to be stored as body",
DeprecationWarning,
stacklevel=2,
)
self.original_widget = new_body
def selectable(self) -> bool:
"""Return selectable from body."""
return self._original_widget.selectable()
def filler_values(self, size: tuple[int, int] | tuple[int], focus: bool) -> tuple[int, int]:
"""
Return the number of rows to pad on the top and bottom.
Override this method to define custom padding behaviour.
"""
maxcol, maxrow = self.pack(size, focus)
if self.height_type == WHSettings.PACK:
height = self._original_widget.rows((maxcol,), focus=focus)
return calculate_top_bottom_filler(
maxrow,
self.valign_type,
self.valign_amount,
WHSettings.GIVEN,
height,
None,
self.top,
self.bottom,
)
return calculate_top_bottom_filler(
maxrow,
self.valign_type,
self.valign_amount,
self.height_type,
self.height_amount,
self.min_height,
self.top,
self.bottom,
)
def render(
self,
size: tuple[int, int] | tuple[int], # type: ignore[override]
focus: bool = False,
) -> CompositeCanvas:
"""Render self.original_widget with space above and/or below."""
maxcol, maxrow = self.pack(size, focus)
top, bottom = self.filler_values(size, focus)
if self.height_type == WHSettings.PACK:
canv = self._original_widget.render((maxcol,), focus)
else:
canv = self._original_widget.render((maxcol, maxrow - top - bottom), focus)
canv = CompositeCanvas(canv)
if maxrow and canv.rows() > maxrow and canv.cursor is not None:
_cx, cy = canv.cursor
if cy >= maxrow:
canv.trim(cy - maxrow + 1, maxrow - top - bottom)
if canv.rows() > maxrow:
canv.trim(0, maxrow)
return canv
canv.pad_trim_top_bottom(top, bottom)
return canv
def keypress(
self,
size: tuple[int, int] | tuple[()], # type: ignore[override]
key: str,
) -> str | None:
"""Pass keypress to self.original_widget."""
maxcol, maxrow = self.pack(size, True)
if self.height_type == WHSettings.PACK:
return self._original_widget.keypress((maxcol,), key)
top, bottom = self.filler_values((maxcol, maxrow), True)
return self._original_widget.keypress((maxcol, maxrow - top - bottom), key)
def get_cursor_coords(self, size: tuple[int, int] | tuple[int]) -> tuple[int, int] | None:
"""Return cursor coords from self.original_widget if any."""
maxcol, maxrow = self.pack(size, True)
if not hasattr(self._original_widget, "get_cursor_coords"):
return None
top, bottom = self.filler_values(size, True)
if self.height_type == WHSettings.PACK:
coords = self._original_widget.get_cursor_coords((maxcol,))
else:
coords = self._original_widget.get_cursor_coords((maxcol, maxrow - top - bottom))
if not coords:
return None
x, y = coords
if y >= maxrow:
y = maxrow - 1
return x, y + top
def get_pref_col(self, size: tuple[int, int] | tuple[int]) -> int | None:
"""Return pref_col from self.original_widget if any."""
maxcol, maxrow = self.pack(size, True)
if not hasattr(self._original_widget, "get_pref_col"):
return None
if self.height_type == WHSettings.PACK:
x = self._original_widget.get_pref_col((maxcol,))
else:
top, bottom = self.filler_values(size, True)
x = self._original_widget.get_pref_col((maxcol, maxrow - top - bottom))
return x
def move_cursor_to_coords(self, size: tuple[int, int] | tuple[int], col: int, row: int) -> bool:
"""Pass to self.original_widget."""
maxcol, maxrow = self.pack(size, True)
if not hasattr(self._original_widget, "move_cursor_to_coords"):
return True
top, bottom = self.filler_values(size, True)
if row < top or row >= maxcol - bottom:
return False
if self.height_type == WHSettings.PACK:
return self._original_widget.move_cursor_to_coords((maxcol,), col, row - top)
return self._original_widget.move_cursor_to_coords((maxcol, maxrow - top - bottom), col, row - top)
def mouse_event(
self,
size: tuple[int, int] | tuple[int], # type: ignore[override]
event,
button: int,
col: int,
row: int,
focus: bool,
) -> bool | None:
"""Pass to self.original_widget."""
maxcol, maxrow = self.pack(size, focus)
if not hasattr(self._original_widget, "mouse_event"):
return False
top, bottom = self.filler_values(size, True)
if row < top or row >= maxrow - bottom:
return False
if self.height_type == WHSettings.PACK:
return self._original_widget.mouse_event((maxcol,), event, button, col, row - top, focus)
return self._original_widget.mouse_event((maxcol, maxrow - top - bottom), event, button, col, row - top, focus)
def calculate_top_bottom_filler(
maxrow: int,
valign_type: Literal["top", "middle", "bottom", "relative", WHSettings.RELATIVE] | VAlign,
valign_amount: int,
height_type: Literal["given", "relative", "clip", WHSettings.GIVEN, WHSettings.RELATIVE, WHSettings.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:
valign_type -- 'top', 'middle', 'bottom', 'relative'
valign_amount -- a percentage when align_type=='relative'
height_type -- 'given', 'relative', 'clip'
height_amount -- a percentage when width_type=='relative'
otherwise equal to the height of the widget
min_height -- a desired minimum width for the widget or None
top -- a fixed number of rows to fill on the top
bottom -- a fixed number of rows to fill on the bottom
>>> ctbf = calculate_top_bottom_filler
>>> ctbf(15, 'top', 0, 'given', 10, None, 2, 0)
(2, 3)
>>> ctbf(15, 'relative', 0, 'given', 10, None, 2, 0)
(2, 3)
>>> ctbf(15, 'relative', 100, 'given', 10, None, 2, 0)
(5, 0)
>>> ctbf(15, 'middle', 0, 'given', 4, None, 2, 0)
(6, 5)
>>> ctbf(15, 'middle', 0, 'given', 18, None, 2, 0)
(0, 0)
>>> ctbf(20, 'top', 0, 'relative', 60, None, 0, 0)
(0, 8)
>>> ctbf(20, 'relative', 30, 'relative', 60, None, 0, 0)
(2, 6)
>>> ctbf(20, 'relative', 30, 'relative', 60, 14, 0, 0)
(2, 4)
"""
if height_type == WHSettings.RELATIVE:
maxheight = max(maxrow - top - bottom, 0)
height = int_scale(height_amount, 101, maxheight + 1)
if min_height is not None:
height = max(height, min_height)
else:
height = height_amount
valign = {VAlign.TOP: 0, VAlign.MIDDLE: 50, VAlign.BOTTOM: 100}.get(valign_type, valign_amount)
# add the remainder of top/bottom to the filler
filler = maxrow - height - top - bottom
bottom += int_scale(100 - valign, 101, filler + 1)
top = maxrow - height - bottom
# reduce filler if we are clipping an edge
if bottom < 0 < top:
shift = min(top, -bottom)
top -= shift
bottom += shift
elif top < 0 < bottom:
shift = min(bottom, -top)
bottom -= shift
top += shift
# no negative values for filler at the moment
top = max(top, 0)
bottom = max(bottom, 0)
return top, bottom

View File

@@ -0,0 +1,614 @@
from __future__ import annotations
import typing
import warnings
from urwid.canvas import CanvasCombine, CompositeCanvas
from urwid.split_repr import remove_defaults
from urwid.util import is_mouse_press
from .constants import Sizing, VAlign
from .container import WidgetContainerMixin
from .filler import Filler
from .widget import Widget, WidgetError
if typing.TYPE_CHECKING:
from collections.abc import Iterator, MutableMapping
from typing_extensions import Literal
BodyWidget = typing.TypeVar("BodyWidget")
HeaderWidget = typing.TypeVar("HeaderWidget")
FooterWidget = typing.TypeVar("FooterWidget")
class FrameError(WidgetError):
pass
def _check_widget_subclass(widget: Widget | None) -> None:
if widget is None:
return
if not isinstance(widget, Widget):
obj_class_path = f"{widget.__class__.__module__}.{widget.__class__.__name__}"
warnings.warn(
f"{obj_class_path} is not subclass of Widget",
DeprecationWarning,
stacklevel=3,
)
class Frame(Widget, WidgetContainerMixin, typing.Generic[BodyWidget, HeaderWidget, FooterWidget]):
"""
Frame widget is a box widget with optional header and footer
flow widgets placed above and below the box widget.
.. note:: The main difference between a Frame and a :class:`Pile` widget
defined as: `Pile([('pack', header), body, ('pack', footer)])` is that
the Frame will not automatically change focus up and down in response to keystrokes.
"""
_selectable = True
_sizing = frozenset([Sizing.BOX])
def __init__(
self,
body: BodyWidget,
header: HeaderWidget = None,
footer: FooterWidget = None,
focus_part: Literal["header", "footer", "body"] | Widget = "body",
):
"""
:param body: a box widget for the body of the frame
:type body: Widget
:param header: a flow widget for above the body (or None)
:type header: Widget
:param footer: a flow widget for below the body (or None)
:type footer: Widget
:param focus_part: 'header', 'footer' or 'body'
:type focus_part: str | Widget
"""
super().__init__()
self._header = header
self._body = body
self._footer = footer
if focus_part in {"header", "footer", "body"}:
self.focus_part = focus_part
elif focus_part == header:
self.focus_part = "header"
elif focus_part == footer:
self.focus_part = "footer"
elif focus_part == body:
self.focus_part = "body"
else:
raise ValueError(f"Invalid focus part {focus_part!r}")
_check_widget_subclass(header)
_check_widget_subclass(body)
_check_widget_subclass(footer)
def _repr_attrs(self) -> dict[str, typing.Any]:
attrs = {
**super()._repr_attrs(),
"body": self._body,
"header": self._header,
"footer": self._footer,
"focus_part": self.focus_part,
}
return remove_defaults(attrs, Frame.__init__)
def __rich_repr__(self) -> Iterator[tuple[str | None, typing.Any] | typing.Any]:
yield "body", self._body
yield "header", self._header
yield "footer", self._footer
yield "focus_part", self.focus_part
@property
def header(self) -> HeaderWidget:
return self._header
@header.setter
def header(self, header: HeaderWidget) -> None:
_check_widget_subclass(header)
self._header = header
if header is None and self.focus_part == "header":
self.focus_part = "body"
self._invalidate()
def get_header(self) -> HeaderWidget:
warnings.warn(
f"method `{self.__class__.__name__}.get_header` is deprecated, "
f"standard property `{self.__class__.__name__}.header` should be used instead",
PendingDeprecationWarning,
stacklevel=2,
)
return self.header
def set_header(self, header: HeaderWidget) -> None:
warnings.warn(
f"method `{self.__class__.__name__}.set_header` is deprecated, "
f"standard property `{self.__class__.__name__}.header` should be used instead",
PendingDeprecationWarning,
stacklevel=2,
)
self.header = header
@property
def body(self) -> BodyWidget:
return self._body
@body.setter
def body(self, body: BodyWidget) -> None:
_check_widget_subclass(body)
self._body = body
self._invalidate()
def get_body(self) -> BodyWidget:
warnings.warn(
f"method `{self.__class__.__name__}.get_body` is deprecated, "
f"standard property {self.__class__.__name__}.body should be used instead",
PendingDeprecationWarning,
stacklevel=2,
)
return self.body
def set_body(self, body: BodyWidget) -> None:
warnings.warn(
f"method `{self.__class__.__name__}.set_body` is deprecated, "
f"standard property `{self.__class__.__name__}.body` should be used instead",
PendingDeprecationWarning,
stacklevel=2,
)
self.body = body
@property
def footer(self) -> FooterWidget:
return self._footer
@footer.setter
def footer(self, footer: FooterWidget) -> None:
_check_widget_subclass(footer)
self._footer = footer
if footer is None and self.focus_part == "footer":
self.focus_part = "body"
self._invalidate()
def get_footer(self) -> FooterWidget:
warnings.warn(
f"method `{self.__class__.__name__}.get_footer` is deprecated, "
f"standard property `{self.__class__.__name__}.footer` should be used instead",
PendingDeprecationWarning,
stacklevel=2,
)
return self.footer
def set_footer(self, footer: FooterWidget) -> None:
warnings.warn(
f"method `{self.__class__.__name__}.set_footer` is deprecated, "
f"standard property `{self.__class__.__name__}.footer` should be used instead",
PendingDeprecationWarning,
stacklevel=2,
)
self.footer = footer
@property
def focus_position(self) -> Literal["header", "footer", "body"]:
"""
writeable property containing an indicator which part of the frame
that is in focus: `'body', 'header'` or `'footer'`.
:returns: one of 'header', 'footer' or 'body'.
:rtype: str
"""
return self.focus_part
@focus_position.setter
def focus_position(self, part: Literal["header", "footer", "body"]) -> None:
"""
Determine which part of the frame is in focus.
:param part: 'header', 'footer' or 'body'
:type part: str
"""
if part not in {"header", "footer", "body"}:
raise IndexError(f"Invalid position for Frame: {part}")
if (part == "header" and self._header is None) or (part == "footer" and self._footer is None):
raise IndexError(f"This Frame has no {part}")
self.focus_part = part
self._invalidate()
def get_focus(self) -> Literal["header", "footer", "body"]:
"""
writeable property containing an indicator which part of the frame
that is in focus: `'body', 'header'` or `'footer'`.
.. note:: included for backwards compatibility. You should rather use
the container property :attr:`.focus_position` to get this value.
:returns: one of 'header', 'footer' or 'body'.
:rtype: str
"""
warnings.warn(
"included for backwards compatibility."
"You should rather use the container property `.focus_position` to get this value.",
PendingDeprecationWarning,
stacklevel=2,
)
return self.focus_position
def set_focus(self, part: Literal["header", "footer", "body"]) -> None:
warnings.warn(
"included for backwards compatibility."
"You should rather use the container property `.focus_position` to set this value.",
PendingDeprecationWarning,
stacklevel=2,
)
self.focus_position = part
@property
def focus(self) -> BodyWidget | HeaderWidget | FooterWidget:
"""
child :class:`Widget` in focus: the body, header or footer widget.
This is a read-only property."""
return {"header": self._header, "footer": self._footer, "body": self._body}[self.focus_part]
def _get_focus(self) -> BodyWidget | HeaderWidget | FooterWidget:
warnings.warn(
f"method `{self.__class__.__name__}._get_focus` is deprecated, "
f"please use `{self.__class__.__name__}.focus` property",
DeprecationWarning,
stacklevel=3,
)
return {"header": self._header, "footer": self._footer, "body": self._body}[self.focus_part]
@property
def contents(
self,
) -> MutableMapping[
Literal["header", "footer", "body"],
tuple[BodyWidget | HeaderWidget | FooterWidget, None],
]:
"""
a dict-like object similar to::
{
'body': (body_widget, None),
'header': (header_widget, None), # if frame has a header
'footer': (footer_widget, None) # if frame has a footer
}
This object may be used to read or update the contents of the Frame.
The values are similar to the list-like .contents objects used
in other containers with (:class:`Widget`, options) tuples, but are
constrained to keys for each of the three usual parts of a Frame.
When other keys are used a :exc:`KeyError` will be raised.
Currently, all options are `None`, but using the :meth:`options` method
to create the options value is recommended for forwards
compatibility.
"""
# noinspection PyMethodParameters
class FrameContents(
typing.MutableMapping[
str,
typing.Tuple[typing.Union[BodyWidget, HeaderWidget, FooterWidget], None],
]
):
# pylint: disable=no-self-argument
__slots__ = ()
def __len__(inner_self) -> int:
return len(inner_self.keys())
__getitem__ = self._contents__getitem__
__setitem__ = self._contents__setitem__
__delitem__ = self._contents__delitem__
def __iter__(inner_self) -> Iterator[str]:
yield from inner_self.keys()
def __repr__(inner_self) -> str:
return f"<{inner_self.__class__.__name__}({dict(inner_self)}) for {self}>"
def __rich_repr__(inner_self) -> Iterator[tuple[str | None, typing.Any] | typing.Any]:
yield from inner_self.items()
return FrameContents()
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
@typing.overload
def _contents__getitem__(self, key: Literal["body"]) -> tuple[BodyWidget, None]: ...
@typing.overload
def _contents__getitem__(self, key: Literal["header"]) -> tuple[HeaderWidget, None]: ...
@typing.overload
def _contents__getitem__(self, key: Literal["footer"]) -> tuple[FooterWidget, None]: ...
def _contents__getitem__(
self, key: Literal["body", "header", "footer"]
) -> tuple[BodyWidget | HeaderWidget | FooterWidget, None]:
if key == "body":
return (self._body, None)
if key == "header" and self._header:
return (self._header, None)
if key == "footer" and self._footer:
return (self._footer, None)
raise KeyError(f"Frame.contents has no key: {key!r}")
@typing.overload
def _contents__setitem__(self, key: Literal["body"], value: tuple[BodyWidget, None]) -> None: ...
@typing.overload
def _contents__setitem__(self, key: Literal["header"], value: tuple[HeaderWidget, None]) -> None: ...
@typing.overload
def _contents__setitem__(self, key: Literal["footer"], value: tuple[FooterWidget, None]) -> None: ...
def _contents__setitem__(
self,
key: Literal["body", "header", "footer"],
value: tuple[BodyWidget | HeaderWidget | FooterWidget, None],
) -> None:
if key not in {"body", "header", "footer"}:
raise KeyError(f"Frame.contents has no key: {key!r}")
try:
value_w, value_options = value
if value_options is not None:
raise FrameError(f"added content invalid: {value!r}")
except (ValueError, TypeError) as exc:
raise FrameError(f"added content invalid: {value!r}").with_traceback(exc.__traceback__) from exc
if key == "body":
self.body = value_w
elif key == "footer":
self.footer = value_w
else:
self.header = value_w
def _contents__delitem__(self, key: Literal["header", "footer"]) -> None:
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) or (key == "footer" and self._footer is None):
raise KeyError(f"Frame.contents has no key: {key!r}")
if key == "header":
self.header = None
else:
self.footer = None
def _contents(self):
warnings.warn(
f"method `{self.__class__.__name__}._contents` is deprecated, "
f"please use property `{self.__class__.__name__}.contents`",
DeprecationWarning,
stacklevel=3,
)
return self.contents
def options(self) -> None:
"""
There are currently no options for Frame contents.
Return None as a placeholder for future options.
"""
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.
:param size: See :meth:`Widget.render` for details
:type size: widget size
:param focus: ``True`` if this widget is in focus
:type focus: bool
:returns: `(head rows, foot rows),(orig head, orig foot)`
orig head/foot are from rows() calls.
:rtype: (int, int), (int, int)
"""
(maxcol, maxrow) = size
frows = hrows = 0
if self.header:
hrows = self.header.rows((maxcol,), self.focus_part == "header" and focus)
if self.footer:
frows = self.footer.rows((maxcol,), self.focus_part == "footer" and focus)
remaining = maxrow
if self.focus_part == "footer":
if frows >= remaining:
return (0, remaining), (hrows, frows)
remaining -= frows
if hrows >= remaining:
return (remaining, frows), (hrows, frows)
elif self.focus_part == "header":
if hrows >= maxrow:
return (remaining, 0), (hrows, frows)
remaining -= hrows
if frows >= remaining:
return (hrows, remaining), (hrows, frows)
elif hrows + frows >= remaining:
# self.focus_part == 'body'
rless1 = max(0, remaining - 1)
if frows >= remaining - 1:
return (0, rless1), (hrows, frows)
remaining -= frows
rless1 = max(0, remaining - 1)
return (rless1, frows), (hrows, frows)
return (hrows, frows), (hrows, frows)
def render(
self,
size: tuple[int, int], # type: ignore[override]
focus: bool = False,
) -> CompositeCanvas:
(maxcol, maxrow) = size
(htrim, ftrim), (hrows, frows) = self.frame_top_bottom((maxcol, maxrow), focus)
combinelist = []
depends_on = []
head = None
if htrim and htrim < hrows:
head = Filler(self.header, VAlign.TOP).render((maxcol, htrim), focus and self.focus_part == "header")
elif htrim:
head = self.header.render((maxcol,), focus and self.focus_part == "header")
if head.rows() != hrows:
raise RuntimeError("rows, render mismatch")
if head:
combinelist.append((head, "header", self.focus_part == "header"))
depends_on.append(self.header)
if ftrim + htrim < maxrow:
body = self.body.render((maxcol, maxrow - ftrim - htrim), focus and self.focus_part == "body")
combinelist.append((body, "body", self.focus_part == "body"))
depends_on.append(self.body)
foot = None
if ftrim and ftrim < frows:
foot = Filler(self.footer, VAlign.BOTTOM).render((maxcol, ftrim), focus and self.focus_part == "footer")
elif ftrim:
foot = self.footer.render((maxcol,), focus and self.focus_part == "footer")
if foot.rows() != frows:
raise RuntimeError("rows, render mismatch")
if foot:
combinelist.append((foot, "footer", self.focus_part == "footer"))
depends_on.append(self.footer)
return CanvasCombine(combinelist)
def keypress(
self,
size: tuple[int, int], # type: ignore[override]
key: str,
) -> str | None:
"""Pass keypress to widget in focus."""
(maxcol, maxrow) = size
if self.focus_part == "header" and self.header is not None:
if not self.header.selectable():
return key
return self.header.keypress((maxcol,), key)
if self.focus_part == "footer" and self.footer is not None:
if not self.footer.selectable():
return key
return self.footer.keypress((maxcol,), key)
if self.focus_part != "body":
return key
remaining = maxrow
if self.header is not None:
remaining -= self.header.rows((maxcol,))
if self.footer is not None:
remaining -= self.footer.rows((maxcol,))
if remaining <= 0:
return key
if not self.body.selectable():
return key
return self.body.keypress((maxcol, remaining), key)
def mouse_event(
self,
size: tuple[int, int], # type: ignore[override]
event: str,
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)
if row < htrim: # within header
focus = focus and self.focus_part == "header"
if is_mouse_press(event) and button == 1 and self.header.selectable():
self.focus_position = "header"
if not hasattr(self.header, "mouse_event"):
return False
return self.header.mouse_event((maxcol,), event, button, col, row, focus)
if row >= maxrow - ftrim: # within footer
focus = focus and self.focus_part == "footer"
if is_mouse_press(event) and button == 1 and self.footer.selectable():
self.focus_position = "footer"
if not hasattr(self.footer, "mouse_event"):
return False
return self.footer.mouse_event((maxcol,), event, button, col, row - maxrow + ftrim, focus)
# within body
focus = focus and self.focus_part == "body"
if is_mouse_press(event) and button == 1 and self.body.selectable():
self.focus_position = "body"
if not hasattr(self.body, "mouse_event"):
return False
return self.body.mouse_event((maxcol, maxrow - htrim - ftrim), event, button, col, row - htrim, focus)
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
if not hasattr(self.focus, "get_cursor_coords"):
return None
fp = self.focus_position
(maxcol, maxrow) = size
(hrows, frows), _ = self.frame_top_bottom(size, True)
if fp == "header":
row_adjust = 0
coords = self.header.get_cursor_coords((maxcol,))
elif fp == "body":
row_adjust = hrows
coords = self.body.get_cursor_coords((maxcol, maxrow - hrows - frows))
else:
row_adjust = maxrow - frows
coords = self.footer.get_cursor_coords((maxcol,))
if coords is None:
return None
x, y = coords
return x, y + row_adjust
def __iter__(self) -> Iterator[Literal["header", "body", "footer"]]:
"""
Return an iterator over the positions in this Frame top to bottom.
"""
if self._header:
yield "header"
yield "body"
if self._footer:
yield "footer"
def __reversed__(self) -> Iterator[Literal["footer", "body", "header"]]:
"""
Return an iterator over the positions in this Frame bottom to top.
"""
if self._footer:
yield "footer"
yield "body"
if self._header:
yield "header"

View File

@@ -0,0 +1,602 @@
from __future__ import annotations
import typing
import warnings
from urwid.split_repr import remove_defaults
from .columns import Columns
from .constants import Align, Sizing, WHSettings
from .container import WidgetContainerListContentsMixin, WidgetContainerMixin
from .divider import Divider
from .monitored_list import MonitoredFocusList, MonitoredList
from .padding import Padding
from .pile import Pile
from .widget import Widget, WidgetError, WidgetWarning, WidgetWrap
if typing.TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence
from typing_extensions import Literal
class GridFlowError(WidgetError):
"""GridFlow specific error."""
class GridFlowWarning(WidgetWarning):
"""GridFlow specific warning."""
class GridFlow(WidgetWrap[Pile], WidgetContainerMixin, WidgetContainerListContentsMixin):
"""
The GridFlow widget is a flow widget that renders all the widgets it contains the same width,
and it arranges them from left to right and top to bottom.
"""
def sizing(self) -> frozenset[Sizing]:
"""Widget sizing.
..note:: Empty widget sizing is limited to the FLOW due to no data for width.
"""
if self:
return frozenset((Sizing.FLOW, Sizing.FIXED))
return frozenset((Sizing.FLOW,))
def __init__(
self,
cells: Iterable[Widget],
cell_width: int,
h_sep: int,
v_sep: int,
align: Literal["left", "center", "right"] | Align | tuple[Literal["relative", WHSettings.RELATIVE], int],
focus: int | Widget | None = None,
) -> None:
"""
:param cells: iterable of flow widgets to display
:param cell_width: column width for each cell
:param h_sep: blank columns between each cell horizontally
:param v_sep: blank rows between cells vertically
(if more than one row is required to display all the cells)
:param align: horizontal alignment of cells, one of:
'left', 'center', 'right', ('relative', percentage 0=left 100=right)
:param focus: widget index or widget instance to focus on
"""
prepared_contents: list[tuple[Widget, tuple[Literal[WHSettings.GIVEN], int]]] = []
focus_position: int = -1
for idx, widget in enumerate(cells):
prepared_contents.append((widget, (WHSettings.GIVEN, cell_width)))
if focus_position < 0 and (focus in {widget, idx} or (focus is None and widget.selectable())):
focus_position = idx
focus_position = max(focus_position, 0)
self._contents: MonitoredFocusList[tuple[Widget, tuple[Literal[WHSettings.GIVEN], int]]] = MonitoredFocusList(
prepared_contents, focus=focus_position
)
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)
self._cell_width = cell_width
self.h_sep = h_sep
self.v_sep = v_sep
self.align = align
self._cache_maxcol = self._get_maxcol(())
super().__init__(self.generate_display_widget((self._cache_maxcol,)))
def _repr_words(self) -> list[str]:
if len(self.contents) > 1:
contents_string = f"({len(self.contents)} items)"
elif self.contents:
contents_string = "(1 item)"
else:
contents_string = "()"
return [*super()._repr_words(), contents_string]
def _repr_attrs(self) -> dict[str, typing.Any]:
attrs = {
**super()._repr_attrs(),
"cell_width": self.cell_width,
"h_sep": self.h_sep,
"v_sep": self.v_sep,
"align": self.align,
"focus": self.focus_position if len(self._contents) > 1 else None,
}
return remove_defaults(attrs, GridFlow.__init__)
def __rich_repr__(self) -> Iterator[tuple[str | None, typing.Any] | typing.Any]:
yield "cells", [widget for widget, _ in self.contents]
yield "cell_width", self.cell_width
yield "h_sep", self.h_sep
yield "v_sep", self.v_sep
yield "align", self.align
yield "focus", self.focus_position
def __len__(self) -> int:
return len(self._contents)
def _invalidate(self) -> None:
self._cache_maxcol = None
super()._invalidate()
def _contents_modified(
self,
_slc: tuple[int, int, int],
new_items: Iterable[tuple[Widget, tuple[Literal["given", WHSettings.GIVEN], int]]],
) -> None:
for item in new_items:
try:
_w, (t, _n) = item
if t != WHSettings.GIVEN:
raise GridFlowError(f"added content invalid {item!r}")
except (TypeError, ValueError) as exc: # noqa: PERF203
raise GridFlowError(f"added content invalid {item!r}").with_traceback(exc.__traceback__) from exc
@property
def cells(self):
"""
A list of the widgets in this GridFlow
.. note:: only for backwards compatibility. You should use the new
standard container property :attr:`contents` to modify GridFlow
contents.
"""
warnings.warn(
"only for backwards compatibility."
"You should use the new standard container property `contents` to modify GridFlow",
PendingDeprecationWarning,
stacklevel=2,
)
ml = MonitoredList(w for w, t in self.contents)
def user_modified():
self.cells = ml
ml.set_modified_callback(user_modified)
return ml
@cells.setter
def cells(self, widgets: Sequence[Widget]):
warnings.warn(
"only for backwards compatibility."
"You should use the new standard container property `contents` to modify GridFlow",
PendingDeprecationWarning,
stacklevel=2,
)
focus_position = self.focus_position
self.contents = [(new, (WHSettings.GIVEN, self._cell_width)) for new in widgets]
if focus_position < len(widgets):
self.focus_position = focus_position
def _get_cells(self):
warnings.warn(
"only for backwards compatibility."
"You should use the new standard container property `contents` to modify GridFlow",
DeprecationWarning,
stacklevel=3,
)
return self.cells
def _set_cells(self, widgets: Sequence[Widget]):
warnings.warn(
"only for backwards compatibility."
"You should use the new standard container property `contents` to modify GridFlow",
DeprecationWarning,
stacklevel=3,
)
self.cells = widgets
@property
def cell_width(self) -> int:
"""
The width of each cell in the GridFlow. Setting this value affects
all cells.
"""
return self._cell_width
@cell_width.setter
def cell_width(self, width: int) -> None:
focus_position = self.focus_position
self.contents = [(w, (WHSettings.GIVEN, width)) for (w, options) in self.contents]
self.focus_position = focus_position
self._cell_width = width
def _get_cell_width(self) -> int:
warnings.warn(
f"Method `{self.__class__.__name__}._get_cell_width` is deprecated, "
f"please use property `{self.__class__.__name__}.cell_width`",
DeprecationWarning,
stacklevel=3,
)
return self.cell_width
def _set_cell_width(self, width: int) -> None:
warnings.warn(
f"Method `{self.__class__.__name__}._set_cell_width` is deprecated, "
f"please use property `{self.__class__.__name__}.cell_width`",
DeprecationWarning,
stacklevel=3,
)
self.cell_width = width
@property
def contents(self) -> MonitoredFocusList[tuple[Widget, tuple[Literal[WHSettings.GIVEN], int]]]:
"""
The contents of this GridFlow as a list of (widget, options)
tuples.
options is currently a tuple in the form `('fixed', number)`.
number is the number of screen columns to allocate to this cell.
'fixed' is the only type accepted at this time.
This list may be modified like a normal list and the GridFlow
widget will update automatically.
.. seealso:: Create new options tuples with the :meth:`options` method.
"""
return self._contents
@contents.setter
def contents(self, c):
self._contents[:] = c
def options(
self,
width_type: Literal["given", WHSettings.GIVEN] = WHSettings.GIVEN,
width_amount: int | None = None,
) -> tuple[Literal[WHSettings.GIVEN], int]:
"""
Return a new options tuple for use in a GridFlow's .contents list.
width_type -- 'given' is the only value accepted
width_amount -- None to use the default cell_width for this GridFlow
"""
if width_type != WHSettings.GIVEN:
raise GridFlowError(f"invalid width_type: {width_type!r}")
if width_amount is None:
width_amount = self._cell_width
return (WHSettings(width_type), width_amount)
def set_focus(self, cell: Widget | int) -> None:
"""
Set the cell in focus, for backwards compatibility.
.. note:: only for backwards compatibility. You may also use the new
standard container property :attr:`focus_position` to get the focus.
:param cell: contained element to focus
:type cell: Widget or int
"""
warnings.warn(
"only for backwards compatibility."
"You may also use the new standard container property `focus_position` to set the focus.",
PendingDeprecationWarning,
stacklevel=2,
)
if isinstance(cell, int):
try:
if cell < 0 or cell >= len(self.contents):
raise IndexError(f"No GridFlow child widget at position {cell}")
except TypeError as exc:
raise IndexError(f"No GridFlow child widget at position {cell}").with_traceback(
exc.__traceback__
) from exc
self.contents.focus = cell
return
for i, (w, _options) in enumerate(self.contents):
if cell == w:
self.focus_position = i
return
raise ValueError(f"Widget not found in GridFlow contents: {cell!r}")
@property
def focus(self) -> Widget | None:
"""the child widget in focus or None when GridFlow is empty"""
if not self.contents:
return None
return self.contents[self.focus_position][0]
def _get_focus(self) -> Widget | None:
warnings.warn(
f"method `{self.__class__.__name__}._get_focus` is deprecated, "
f"please use `{self.__class__.__name__}.focus` property",
DeprecationWarning,
stacklevel=3,
)
if not self.contents:
return None
return self.contents[self.focus_position][0]
def get_focus(self):
"""
Return the widget in focus, for backwards compatibility.
.. note:: only for backwards compatibility. You may also use the new
standard container property :attr:`focus` to get the focus.
"""
warnings.warn(
"only for backwards compatibility."
"You may also use the new standard container property `focus` to get the focus.",
PendingDeprecationWarning,
stacklevel=2,
)
if not self.contents:
return None
return self.contents[self.focus_position][0]
@property
def focus_cell(self):
warnings.warn(
"only for backwards compatibility."
"You may also use the new standard container property"
"`focus` to get the focus and `focus_position` to get/set the cell in focus by index",
PendingDeprecationWarning,
stacklevel=2,
)
return self.focus
@focus_cell.setter
def focus_cell(self, cell: Widget) -> None:
warnings.warn(
"only for backwards compatibility."
"You may also use the new standard container property"
"`focus` to get the focus and `focus_position` to get/set the cell in focus by index",
PendingDeprecationWarning,
stacklevel=2,
)
for i, (w, _options) in enumerate(self.contents):
if cell == w:
self.focus_position = i
return
raise ValueError(f"Widget not found in GridFlow contents: {cell!r}")
def _set_focus_cell(self, cell: Widget) -> None:
warnings.warn(
"only for backwards compatibility."
"You may also use the new standard container property"
"`focus` to get the focus and `focus_position` to get/set the cell in focus by index",
DeprecationWarning,
stacklevel=3,
)
for i, (w, _options) in enumerate(self.contents):
if cell == w:
self.focus_position = i
return
raise ValueError(f"Widget not found in GridFlow contents: {cell!r}")
@property
def focus_position(self) -> int | None:
"""
index of child widget in focus.
Raises :exc:`IndexError` if read when GridFlow is empty, or when set to an invalid index.
"""
if not self.contents:
raise IndexError("No focus_position, GridFlow is empty")
return self.contents.focus
@focus_position.setter
def focus_position(self, position: int) -> None:
"""
Set the widget in focus.
position -- index of child widget to be made focus
"""
try:
if position < 0 or position >= len(self.contents):
raise IndexError(f"No GridFlow child widget at position {position}")
except TypeError as exc:
raise IndexError(f"No GridFlow child widget at position {position}").with_traceback(
exc.__traceback__
) from exc
self.contents.focus = position
def _get_focus_position(self) -> int | None:
warnings.warn(
f"method `{self.__class__.__name__}._get_focus_position` is deprecated, "
f"please use `{self.__class__.__name__}.focus_position` property",
DeprecationWarning,
stacklevel=3,
)
if not self.contents:
raise IndexError("No focus_position, GridFlow is empty")
return self.contents.focus
def _set_focus_position(self, position: int) -> None:
"""
Set the widget in focus.
position -- index of child widget to be made focus
"""
warnings.warn(
f"method `{self.__class__.__name__}._set_focus_position` is deprecated, "
f"please use `{self.__class__.__name__}.focus_position` property",
DeprecationWarning,
stacklevel=3,
)
try:
if position < 0 or position >= len(self.contents):
raise IndexError(f"No GridFlow child widget at position {position}")
except TypeError as exc:
raise IndexError(f"No GridFlow child widget at position {position}").with_traceback(
exc.__traceback__
) from exc
self.contents.focus = position
def _get_maxcol(self, size: tuple[int] | tuple[()]) -> int:
if size:
(maxcol,) = size
if self and maxcol < self.cell_width:
warnings.warn(
f"Size is smaller than cell width ({maxcol!r} < {self.cell_width!r})",
GridFlowWarning,
stacklevel=3,
)
elif self:
maxcol = len(self) * self.cell_width + (len(self) - 1) * self.h_sep
else:
maxcol = 0
return maxcol
def get_display_widget(self, size: tuple[int] | tuple[()]) -> Divider | Pile:
"""
Arrange the cells into columns (and possibly a pile) for
display, input or to calculate rows, and update the display
widget.
"""
maxcol = self._get_maxcol(size)
# use cache if possible
if self._cache_maxcol == maxcol:
return self._w
self._cache_maxcol = maxcol
self._w = self.generate_display_widget((maxcol,))
return self._w
def generate_display_widget(self, size: tuple[int] | tuple[()]) -> Divider | Pile:
"""
Actually generate display widget (ignoring cache)
"""
maxcol = self._get_maxcol(size)
divider = Divider()
if not self.contents:
return divider
if self.v_sep > 1:
# increase size of divider
divider.top = self.v_sep - 1
c = None
p = Pile([])
used_space = 0
for i, (w, (_width_type, width_amount)) in enumerate(self.contents):
if c is None or maxcol - used_space < width_amount:
# starting a new row
if self.v_sep:
p.contents.append((divider, p.options()))
c = Columns([], self.h_sep)
column_focused = False
pad = Padding(c, self.align)
# extra attribute to reference contents position
pad.first_position = i
p.contents.append((pad, p.options()))
# Use width == maxcol in case of maxcol < width amount
# Columns will use empty widget in case of GIVEN width > maxcol
c.contents.append((w, c.options(WHSettings.GIVEN, min(width_amount, maxcol))))
if (i == self.focus_position) or (not column_focused and w.selectable()):
c.focus_position = len(c.contents) - 1
column_focused = True
if i == self.focus_position:
p.focus_position = len(p.contents) - 1
used_space = sum(x[1][1] for x in c.contents) + self.h_sep * len(c.contents)
pad.width = used_space - self.h_sep
if self.v_sep:
# remove first divider
del p.contents[:1]
else:
# Ensure p __selectable is updated
p._contents_modified() # pylint: disable=protected-access
return p
def _set_focus_from_display_widget(self) -> None:
"""
Set the focus to the item in focus in the display widget.
"""
# display widget (self._w) is always built as:
#
# Pile([
# Padding(
# Columns([ # possibly
# cell, ...])),
# Divider(), # possibly
# ...])
pile_focus = self._w.focus
if not pile_focus:
return
c = pile_focus.base_widget
if c.focus:
col_focus_position = c.focus_position
else:
col_focus_position = 0
# pad.first_position was set by generate_display_widget() above
self.focus_position = pile_focus.first_position + col_focus_position
def keypress(
self,
size: tuple[int] | tuple[()], # type: ignore[override]
key: str,
) -> str | None:
"""
Pass keypress to display widget for handling.
Captures focus changes.
"""
self.get_display_widget(size)
key = super().keypress(size, key)
if key is None:
self._set_focus_from_display_widget()
return key
def pack(
self,
size: tuple[int] | tuple[()] = (), # type: ignore[override]
focus: bool = False,
) -> tuple[int, int]:
if size:
return super().pack(size, focus)
if self:
cols = len(self) * self.cell_width + (len(self) - 1) * self.h_sep
else:
cols = 0
return cols, self.rows((cols,), focus)
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: tuple[int] | tuple[()], # type: ignore[override]
focus: bool = False,
):
self.get_display_widget(size)
return super().render(size, focus)
def get_cursor_coords(self, size: tuple[int] | tuple[()]) -> 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: tuple[int] | tuple[()], 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: tuple[int] | tuple[()], # type: ignore[override]
event: str,
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
def get_pref_col(self, size: tuple[int] | tuple[()]):
"""Return pref col from display widget."""
self.get_display_widget(size)
return super().get_pref_col(size)

View File

@@ -0,0 +1,194 @@
from __future__ import annotations
import typing
from .columns import Columns
from .constants import BOX_SYMBOLS, Align, WHSettings
from .divider import Divider
from .pile import Pile
from .solid_fill import SolidFill
from .text import Text
from .widget_decoration import WidgetDecoration, delegate_to_widget_mixin
if typing.TYPE_CHECKING:
from typing_extensions import Literal
from .widget import Widget
WrappedWidget = typing.TypeVar("WrappedWidget")
class LineBox(WidgetDecoration[WrappedWidget], delegate_to_widget_mixin("_wrapped_widget")):
Symbols = BOX_SYMBOLS
def __init__(
self,
original_widget: WrappedWidget,
title: str = "",
title_align: Literal["left", "center", "right"] | Align = Align.CENTER,
title_attr=None,
tlcorner: str = BOX_SYMBOLS.LIGHT.TOP_LEFT,
tline: str = BOX_SYMBOLS.LIGHT.HORIZONTAL,
lline: str = BOX_SYMBOLS.LIGHT.VERTICAL,
trcorner: str = BOX_SYMBOLS.LIGHT.TOP_RIGHT,
blcorner: str = BOX_SYMBOLS.LIGHT.BOTTOM_LEFT,
rline: str = BOX_SYMBOLS.LIGHT.VERTICAL,
bline: str = BOX_SYMBOLS.LIGHT.HORIZONTAL,
brcorner: str = BOX_SYMBOLS.LIGHT.BOTTOM_RIGHT,
) -> None:
"""
Draw a line around original_widget.
Use 'title' to set an initial title text with will be centered
on top of the box.
Use `title_attr` to apply a specific attribute to the title text.
Use `title_align` to align the title to the 'left', 'right', or 'center'.
The default is 'center'.
You can also override the widgets used for the lines/corners:
tline: top line
bline: bottom line
lline: left line
rline: right line
tlcorner: top left corner
trcorner: top right corner
blcorner: bottom left corner
brcorner: bottom right corner
If empty string is specified for one of the lines/corners, then no character will be output there.
If no top/bottom/left/right lines - whole lines will be omitted.
This allows for seamless use of adjoining LineBoxes.
Class attribute `Symbols` can be used as source for standard lines:
>>> print(LineBox(Text("Some text")).render(()))
┌─────────┐
│Some text│
└─────────┘
>>> print(
... LineBox(
... Text("Some text"),
... tlcorner=LineBox.Symbols.LIGHT.TOP_LEFT_ROUNDED,
... trcorner=LineBox.Symbols.LIGHT.TOP_RIGHT_ROUNDED,
... blcorner=LineBox.Symbols.LIGHT.BOTTOM_LEFT_ROUNDED,
... brcorner=LineBox.Symbols.LIGHT.BOTTOM_RIGHT_ROUNDED,
... ).render(())
... )
╭─────────╮
│Some text│
╰─────────╯
>>> print(
... LineBox(
... Text("Some text"),
... tline=LineBox.Symbols.HEAVY.HORIZONTAL,
... bline=LineBox.Symbols.HEAVY.HORIZONTAL,
... lline=LineBox.Symbols.HEAVY.VERTICAL,
... rline=LineBox.Symbols.HEAVY.VERTICAL,
... tlcorner=LineBox.Symbols.HEAVY.TOP_LEFT,
... trcorner=LineBox.Symbols.HEAVY.TOP_RIGHT,
... blcorner=LineBox.Symbols.HEAVY.BOTTOM_LEFT,
... brcorner=LineBox.Symbols.HEAVY.BOTTOM_RIGHT,
... ).render(())
... )
┏━━━━━━━━━┓
┃Some text┃
┗━━━━━━━━━┛
To make Table constructions, some lineboxes need to be drawn without sides
and T or CROSS symbols used for corners of cells.
"""
w_lline = SolidFill(lline)
w_rline = SolidFill(rline)
w_tlcorner, w_tline, w_trcorner = Text(tlcorner), Divider(tline), Text(trcorner)
w_blcorner, w_bline, w_brcorner = Text(blcorner), Divider(bline), Text(brcorner)
if not tline and title:
raise ValueError("Cannot have a title when tline is empty string")
if title_attr:
self.title_widget = Text((title_attr, self.format_title(title)))
else:
self.title_widget = Text(self.format_title(title))
if tline:
if title_align not in {Align.LEFT, Align.CENTER, Align.RIGHT}:
raise ValueError('title_align must be one of "left", "right", or "center"')
if title_align == Align.LEFT:
tline_widgets = [(WHSettings.PACK, self.title_widget), w_tline]
else:
tline_widgets = [w_tline, (WHSettings.PACK, self.title_widget)]
if title_align == Align.CENTER:
tline_widgets.append(w_tline)
self.tline_widget = Columns(tline_widgets)
top = Columns(
(
(int(bool(tlcorner and lline)), w_tlcorner),
self.tline_widget,
(int(bool(trcorner and rline)), w_trcorner),
)
)
else:
self.tline_widget = None
top = None
# Note: We need to define a fixed first widget (even if it's 0 width) so that the other
# widgets have something to anchor onto
middle = Columns(
((int(bool(lline)), w_lline), original_widget, (int(bool(rline)), w_rline)),
box_columns=[0, 2],
focus_column=original_widget,
)
if bline:
bottom = Columns(
(
(int(bool(blcorner and lline)), w_blcorner),
w_bline,
(int(bool(brcorner and rline)), w_brcorner),
)
)
else:
bottom = None
pile_widgets = []
if top:
pile_widgets.append((WHSettings.PACK, top))
pile_widgets.append(middle)
if bottom:
pile_widgets.append((WHSettings.PACK, bottom))
self._wrapped_widget = Pile(pile_widgets, focus_item=middle)
super().__init__(original_widget)
@property
def _w(self) -> Pile:
return self._wrapped_widget
def format_title(self, text: str) -> str:
if text:
return f" {text} "
return ""
def set_title(self, text: str) -> None:
if not self.tline_widget:
raise ValueError("Cannot set title when tline is unset")
self.title_widget.set_text(self.format_title(text))
self.tline_widget._invalidate()
@property
def focus(self) -> Widget | None:
"""LineBox is partially container.
While focus position is a bit hacky
(formally it's not container and only position 0 available),
focus widget is always provided by original widget.
"""
return self._original_widget.focus

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,570 @@
# Urwid MonitoredList class
# Copyright (C) 2004-2011 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
import functools
import typing
import warnings
if typing.TYPE_CHECKING:
from collections.abc import Callable, Collection, Iterable, Iterator
from typing_extensions import Concatenate, ParamSpec, Self
ArgSpec = ParamSpec("ArgSpec")
Ret = typing.TypeVar("Ret")
__all__ = ("MonitoredFocusList", "MonitoredList")
_T = typing.TypeVar("_T")
def _call_modified(
fn: Callable[Concatenate[MonitoredList, ArgSpec], Ret]
) -> Callable[Concatenate[MonitoredList, ArgSpec], Ret]:
@functools.wraps(fn)
def call_modified_wrapper(self: MonitoredList, *args: ArgSpec.args, **kwargs: ArgSpec.kwargs) -> Ret:
rval = fn(self, *args, **kwargs)
self._modified() # pylint: disable=protected-access
return rval
return call_modified_wrapper
class MonitoredList(typing.List[_T], typing.Generic[_T]):
"""
This class can trigger a callback any time its contents are changed
with the usual list operations append, extend, etc.
"""
def _modified(self) -> None: # pylint: disable=method-hidden # monkeypatch used
pass
def set_modified_callback(self, callback: Callable[[], typing.Any]) -> None:
"""
Assign a callback function with no parameters that is called any
time the list is modified. Callback's return value is ignored.
>>> import sys
>>> ml = MonitoredList([1,2,3])
>>> ml.set_modified_callback(lambda: sys.stdout.write("modified\\n"))
>>> ml
MonitoredList([1, 2, 3])
>>> ml.append(10)
modified
>>> len(ml)
4
>>> ml += [11, 12, 13]
modified
>>> ml[:] = ml[:2] + ml[-2:]
modified
>>> ml
MonitoredList([1, 2, 12, 13])
"""
self._modified = callback # monkeypatch
def __repr__(self) -> str:
return f"{self.__class__.__name__}({list(self)!r})"
# noinspection PyMethodParameters
def __rich_repr__(self) -> Iterator[tuple[str | None, typing.Any] | typing.Any]:
for item in self:
yield None, item
@_call_modified
def __add__(self, __value: list[_T]) -> Self:
return super().__add__(__value)
@_call_modified
def __delitem__(self, __key: typing.SupportsIndex | slice) -> None:
super().__delitem__(__key)
@_call_modified
def __iadd__(self, __value: Iterable[_T]) -> Self:
return super().__iadd__(__value)
@_call_modified
def __rmul__(self, __value: typing.SupportsIndex) -> Self:
return super().__rmul__(__value)
@_call_modified
def __imul__(self, __value: typing.SupportsIndex) -> Self:
return super().__imul__(__value)
@typing.overload
@_call_modified
def __setitem__(self, __key: typing.SupportsIndex, __value: _T) -> None: ...
@typing.overload
@_call_modified
def __setitem__(self, __key: slice, __value: Iterable[_T]) -> None: ...
@_call_modified
def __setitem__(self, __key: typing.SupportsIndex | slice, __value: _T | Iterable[_T]) -> None:
super().__setitem__(__key, __value)
@_call_modified
def append(self, __object: _T) -> None:
super().append(__object)
@_call_modified
def extend(self, __iterable: Iterable[_T]) -> None:
super().extend(__iterable)
@_call_modified
def pop(self, __index: typing.SupportsIndex = -1) -> _T:
return super().pop(__index)
@_call_modified
def insert(self, __index: typing.SupportsIndex, __object: _T) -> None:
super().insert(__index, __object)
@_call_modified
def remove(self, __value: _T) -> None:
super().remove(__value)
@_call_modified
def reverse(self) -> None:
super().reverse()
@_call_modified
def sort(self, *, key: Callable[[_T], typing.Any] | None = None, reverse: bool = False) -> None:
super().sort(key=key, reverse=reverse)
@_call_modified
def clear(self) -> None:
super().clear()
class MonitoredFocusList(MonitoredList[_T], typing.Generic[_T]):
"""
This class can trigger a callback any time its contents are modified,
before and/or after modification, and any time the focus index is changed.
"""
def __init__(self, *args, focus: int = 0, **kwargs) -> None:
"""
This is a list that tracks one item as the focus item. If items
are inserted or removed it will update the focus.
>>> ml = MonitoredFocusList([10, 11, 12, 13, 14], focus=3)
>>> ml
MonitoredFocusList([10, 11, 12, 13, 14], focus=3)
>>> del(ml[1])
>>> ml
MonitoredFocusList([10, 12, 13, 14], focus=2)
>>> ml[:2] = [50, 51, 52, 53]
>>> ml
MonitoredFocusList([50, 51, 52, 53, 13, 14], focus=4)
>>> ml[4] = 99
>>> ml
MonitoredFocusList([50, 51, 52, 53, 99, 14], focus=4)
>>> ml[:] = []
>>> ml
MonitoredFocusList([], focus=None)
"""
super().__init__(*args, **kwargs)
self._focus = focus
self._focus_modified = lambda ml, indices, new_items: None
def __repr__(self) -> str:
return f"{self.__class__.__name__}({list(self)!r}, focus={self.focus!r})"
@property
def focus(self) -> int | None:
"""
Get/set the focus index. This value is read as None when the list
is empty, and may only be set to a value between 0 and len(self)-1
or an IndexError will be raised.
Return the index of the item "in focus" or None if
the list is empty.
>>> MonitoredFocusList([1,2,3], focus=2).focus
2
>>> MonitoredFocusList().focus
"""
if not self:
return None
return self._focus
@focus.setter
def 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
the index passed is ignored.
This function may call self._focus_changed when the focus
is modified, passing the new focus position to the
callback just before changing the old focus setting.
That method may be overridden on the
instance with set_focus_changed_callback().
>>> ml = MonitoredFocusList([9, 10, 11])
>>> ml.focus = 2; ml.focus
2
>>> ml.focus = 0; ml.focus
0
>>> ml.focus = -2
Traceback (most recent call last):
...
IndexError: focus index is out of range: -2
"""
if not self:
self._focus = 0
return
if not isinstance(index, int):
raise TypeError("index must be an integer")
if index < 0 or index >= len(self):
raise IndexError(f"focus index is out of range: {index}")
if index != self._focus:
self._focus_changed(index)
self._focus = index
def _get_focus(self) -> int | None:
warnings.warn(
f"method `{self.__class__.__name__}._get_focus` is deprecated, "
f"please use `{self.__class__.__name__}.focus` property",
DeprecationWarning,
stacklevel=3,
)
return self.focus
def _set_focus(self, index: int) -> None:
warnings.warn(
f"method `{self.__class__.__name__}._set_focus` is deprecated, "
f"please use `{self.__class__.__name__}.focus` property",
DeprecationWarning,
stacklevel=3,
)
self.focus = index
def _focus_changed(self, new_focus: int) -> None: # pylint: disable=method-hidden # monkeypatch used
pass
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:
callback(new_focus)
new_focus -- new focus index
>>> import sys
>>> ml = MonitoredFocusList([1,2,3], focus=1)
>>> ml.set_focus_changed_callback(lambda f: sys.stdout.write("focus: %d\\n" % (f,)))
>>> ml
MonitoredFocusList([1, 2, 3], focus=1)
>>> ml.append(10)
>>> ml.insert(1, 11)
focus: 2
>>> ml
MonitoredFocusList([1, 11, 2, 3, 10], focus=2)
>>> del ml[:2]
focus: 0
>>> ml[:0] = [12, 13, 14]
focus: 3
>>> ml.focus = 5
focus: 5
>>> ml
MonitoredFocusList([12, 13, 14, 2, 3, 10], focus=5)
"""
self._focus_changed = callback # Monkeypatch
def _validate_contents_modified( # pylint: disable=method-hidden # monkeypatch used
self,
indices: tuple[int, int, int],
new_items: Collection[_T],
) -> int | None:
return None
def set_validate_contents_modified(self, callback: Callable[[tuple[int, int, int], Collection[_T]], int | None]):
"""
Assign a callback function to handle validating changes to the list.
This may raise an exception if the change should not be performed.
It may also return an integer position to be the new focus after the
list is modified, or None to use the default behaviour.
The callback is in the form:
callback(indices, new_items)
indices -- a (start, stop, step) tuple whose range covers the
items being modified
new_items -- an iterable of items replacing those at range(*indices),
empty if items are being removed, if step==1 this list may
contain any number of items
"""
self._validate_contents_modified = callback # Monkeypatch
def _adjust_focus_on_contents_modified(self, slc: slice, new_items: Collection[_T] = ()) -> int:
"""
Default behaviour is to move the focus to the item following
any removed items, unless that item was simply replaced.
Failing that choose the last item in the list.
returns focus position for after change is applied
"""
num_new_items = len(new_items)
start, stop, step = indices = slc.indices(len(self))
num_removed = len(list(range(*indices)))
focus = self._validate_contents_modified(indices, new_items)
if focus is not None:
return focus
focus = self._focus
if step == 1:
if start + num_new_items <= focus < stop:
focus = stop
# adjust for added/removed items
if stop <= focus:
focus += num_new_items - (stop - start)
else: # noqa: PLR5501 # pylint: disable=else-if-used # readability
if not num_new_items:
# extended slice being removed
if focus in range(start, stop, step):
focus += 1
# adjust for removed items
focus -= len(list(range(start, min(focus, stop), step)))
return min(focus, len(self) + num_new_items - num_removed - 1)
# override all the list methods that modify the list
def __delitem__(self, y: int | slice) -> None:
"""
>>> ml = MonitoredFocusList([0,1,2,3,4], focus=2)
>>> del ml[3]; ml
MonitoredFocusList([0, 1, 2, 4], focus=2)
>>> del ml[-1]; ml
MonitoredFocusList([0, 1, 2], focus=2)
>>> del ml[0]; ml
MonitoredFocusList([1, 2], focus=1)
>>> del ml[1]; ml
MonitoredFocusList([1], focus=0)
>>> del ml[0]; ml
MonitoredFocusList([], focus=None)
>>> ml = MonitoredFocusList([5,4,6,4,5,4,6,4,5], focus=4)
>>> del ml[1::2]; ml
MonitoredFocusList([5, 6, 5, 6, 5], focus=2)
>>> del ml[::2]; ml
MonitoredFocusList([6, 6], focus=1)
>>> ml = MonitoredFocusList([0,1,2,3,4,6,7], focus=2)
>>> del ml[-2:]; ml
MonitoredFocusList([0, 1, 2, 3, 4], focus=2)
>>> del ml[-4:-2]; ml
MonitoredFocusList([0, 3, 4], focus=1)
>>> del ml[:]; ml
MonitoredFocusList([], focus=None)
"""
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))
super().__delitem__(y)
self.focus = focus
@typing.overload
def __setitem__(self, i: int, y: _T) -> None: ...
@typing.overload
def __setitem__(self, i: slice, y: Collection[_T]) -> None: ...
def __setitem__(self, i: int | slice, y: _T | Collection[_T]) -> None:
"""
>>> def modified(indices, new_items):
... print(f"range{indices!r} <- {new_items!r}" )
>>> ml = MonitoredFocusList([0,1,2,3], focus=2)
>>> ml.set_validate_contents_modified(modified)
>>> ml[0] = 9
range(0, 1, 1) <- [9]
>>> ml[2] = 6
range(2, 3, 1) <- [6]
>>> ml.focus
2
>>> ml[-1] = 8
range(3, 4, 1) <- [8]
>>> ml
MonitoredFocusList([9, 1, 6, 8], focus=2)
>>> ml[1::2] = [12, 13]
range(1, 4, 2) <- [12, 13]
>>> ml[::2] = [10, 11]
range(0, 4, 2) <- [10, 11]
>>> ml[-3:-1] = [21, 22, 23]
range(1, 3, 1) <- [21, 22, 23]
>>> ml
MonitoredFocusList([10, 21, 22, 23, 13], focus=2)
>>> ml[:] = []
range(0, 5, 1) <- []
>>> ml
MonitoredFocusList([], focus=None)
"""
if isinstance(i, slice):
focus = self._adjust_focus_on_contents_modified(i, y)
else:
focus = self._adjust_focus_on_contents_modified(slice(i, i + 1 or None), [y])
super().__setitem__(i, y)
self.focus = focus
def __imul__(self, n: int):
"""
>>> def modified(indices, new_items):
... print(f"range{indices!r} <- {list(new_items)!r}" )
>>> ml = MonitoredFocusList([0,1,2], focus=2)
>>> ml.set_validate_contents_modified(modified)
>>> ml *= 3
range(3, 3, 1) <- [0, 1, 2, 0, 1, 2]
>>> ml
MonitoredFocusList([0, 1, 2, 0, 1, 2, 0, 1, 2], focus=2)
>>> ml *= 0
range(0, 9, 1) <- []
>>> print(ml.focus)
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(0, len(self)))
rval = super().__imul__(n)
self.focus = focus
return rval
def append(self, item: _T) -> None:
"""
>>> def modified(indices, new_items):
... print(f"range{indices!r} <- {new_items!r}" )
>>> ml = MonitoredFocusList([0,1,2], focus=2)
>>> ml.set_validate_contents_modified(modified)
>>> ml.append(6)
range(3, 3, 1) <- [6]
"""
focus = self._adjust_focus_on_contents_modified(slice(len(self), len(self)), [item])
super().append(item)
self.focus = focus
def extend(self, items: Collection[_T]) -> None:
"""
>>> def modified(indices, new_items):
... print(f"range{indices!r} <- {list(new_items)!r}" )
>>> ml = MonitoredFocusList([0,1,2], focus=2)
>>> ml.set_validate_contents_modified(modified)
>>> ml.extend((6,7,8))
range(3, 3, 1) <- [6, 7, 8]
"""
focus = self._adjust_focus_on_contents_modified(slice(len(self), len(self)), items)
super().extend(items)
self.focus = focus
def insert(self, index: int, item: _T) -> None:
"""
>>> ml = MonitoredFocusList([0,1,2,3], focus=2)
>>> ml.insert(-1, -1); ml
MonitoredFocusList([0, 1, 2, -1, 3], focus=2)
>>> ml.insert(0, -2); ml
MonitoredFocusList([-2, 0, 1, 2, -1, 3], focus=3)
>>> 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])
super().insert(index, item)
self.focus = focus
def pop(self, index: int = -1) -> _T:
"""
>>> ml = MonitoredFocusList([-2,0,1,-3,2,3], focus=4)
>>> ml.pop(3); ml
-3
MonitoredFocusList([-2, 0, 1, 2, 3], focus=3)
>>> ml.pop(0); ml
-2
MonitoredFocusList([0, 1, 2, 3], focus=2)
>>> ml.pop(-1); ml
3
MonitoredFocusList([0, 1, 2], focus=2)
>>> ml.pop(2); ml
2
MonitoredFocusList([0, 1], focus=1)
"""
focus = self._adjust_focus_on_contents_modified(slice(index, index + 1 or None))
rval = super().pop(index)
self.focus = focus
return rval
def remove(self, value: _T) -> None:
"""
>>> ml = MonitoredFocusList([-2,0,1,-3,2,-1,3], focus=4)
>>> ml.remove(-3); ml
MonitoredFocusList([-2, 0, 1, 2, -1, 3], focus=3)
>>> ml.remove(-2); ml
MonitoredFocusList([0, 1, 2, -1, 3], focus=2)
>>> ml.remove(3); ml
MonitoredFocusList([0, 1, 2, -1], focus=2)
"""
index = self.index(value)
focus = self._adjust_focus_on_contents_modified(slice(index, index + 1 or None))
super().remove(value)
self.focus = focus
def reverse(self) -> None:
"""
>>> ml = MonitoredFocusList([0,1,2,3,4], focus=1)
>>> ml.reverse(); ml
MonitoredFocusList([4, 3, 2, 1, 0], focus=3)
"""
rval = super().reverse()
self.focus = max(0, len(self) - self._focus - 1)
return rval
def sort(self, **kwargs) -> None:
"""
>>> ml = MonitoredFocusList([-2,0,1,-3,2,-1,3], focus=4)
>>> ml.sort(); ml
MonitoredFocusList([-3, -2, -1, 0, 1, 2, 3], focus=5)
"""
if not self:
return None
value = self[self._focus]
rval = super().sort(**kwargs)
self.focus = self.index(value)
return rval
if hasattr(list, "clear"):
def clear(self) -> None:
focus = self._adjust_focus_on_contents_modified(slice(0, 0))
super().clear()
self.focus = focus
def _test():
import doctest
doctest.testmod()
if __name__ == "__main__":
_test()

View File

@@ -0,0 +1,943 @@
from __future__ import annotations
import typing
import warnings
from urwid.canvas import CanvasOverlay, CompositeCanvas
from urwid.split_repr import remove_defaults
from .constants import (
RELATIVE_100,
Align,
Sizing,
VAlign,
WHSettings,
WrapMode,
normalize_align,
normalize_height,
normalize_valign,
normalize_width,
simplify_align,
simplify_height,
simplify_valign,
simplify_width,
)
from .container import WidgetContainerListContentsMixin, WidgetContainerMixin
from .filler import calculate_top_bottom_filler
from .padding import calculate_left_right_padding
from .widget import Widget, WidgetError, WidgetWarning
if typing.TYPE_CHECKING:
from collections.abc import Iterator, MutableSequence, Sequence
from typing_extensions import Literal
TopWidget = typing.TypeVar("TopWidget")
BottomWidget = typing.TypeVar("BottomWidget")
class OverlayError(WidgetError):
"""Overlay specific errors."""
class OverlayWarning(WidgetWarning):
"""Overlay specific warnings."""
def _check_widget_subclass(widget: Widget) -> None:
if not isinstance(widget, Widget):
obj_class_path = f"{widget.__class__.__module__}.{widget.__class__.__name__}"
warnings.warn(
f"{obj_class_path} is not subclass of Widget",
DeprecationWarning,
stacklevel=3,
)
class OverlayOptions(typing.NamedTuple):
align: Align | Literal[WHSettings.RELATIVE]
align_amount: int | None
width_type: WHSettings
width_amount: int | None
min_width: int | None
left: int
right: int
valign_type: VAlign | Literal[WHSettings.RELATIVE]
valign_amount: int | None
height_type: WHSettings
height_amount: int | None
min_height: int | None
top: int
bottom: int
class Overlay(Widget, WidgetContainerMixin, WidgetContainerListContentsMixin, typing.Generic[TopWidget, BottomWidget]):
"""Overlay contains two widgets and renders one on top of the other.
Top widget can be Box, Flow or Fixed.
Bottom widget should be Box.
"""
_selectable = True
_DEFAULT_BOTTOM_OPTIONS = OverlayOptions(
align=Align.LEFT,
align_amount=None,
width_type=WHSettings.RELATIVE,
width_amount=100,
min_width=None,
left=0,
right=0,
valign_type=VAlign.TOP,
valign_amount=None,
height_type=WHSettings.RELATIVE,
height_amount=100,
min_height=None,
top=0,
bottom=0,
)
def __init__(
self,
top_w: TopWidget,
bottom_w: BottomWidget,
align: (
Literal["left", "center", "right"]
| Align
| tuple[Literal["relative", "fixed left", "fixed right", WHSettings.RELATIVE], int]
),
width: Literal["pack", WHSettings.PACK] | int | tuple[Literal["relative", WHSettings.RELATIVE], int] | None,
valign: (
Literal["top", "middle", "bottom"]
| VAlign
| tuple[Literal["relative", "fixed top", "fixed bottom", WHSettings.RELATIVE], int]
),
height: Literal["pack", WHSettings.PACK] | int | tuple[Literal["relative", WHSettings.RELATIVE], int] | None,
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
:param bottom_w: a box widget to appear "below" previous widget.
:type bottom_w: Widget
:param align: alignment, one of ``'left'``, ``'center'``, ``'right'`` or
(``'relative'``, *percentage* 0=left 100=right)
:type align: Literal["left", "center", "right"] | tuple[Literal["relative"], int]
:param width: width type, one of:
``'pack'``
if *top_w* is a fixed widget
*given width*
integer number of columns wide
(``'relative'``, *percentage of total width*)
make *top_w* width related to container width
:type width: Literal["pack"] | int | tuple[Literal["relative"], int]
:param valign: alignment mode, one of ``'top'``, ``'middle'``, ``'bottom'`` or
(``'relative'``, *percentage* 0=top 100=bottom)
:type valign: Literal["top", "middle", "bottom"] | tuple[Literal["relative"], int]
:param height: one of:
``'pack'``
if *top_w* is a flow or fixed widget
*given height*
integer number of rows high
(``'relative'``, *percentage of total height*)
make *top_w* height related to container height
:type height: Literal["pack"] | int | tuple[Literal["relative"], int]
:param min_width: the minimum number of columns for *top_w* when width is not fixed.
:type min_width: int
:param min_height: minimum number of rows for *top_w* when height is not fixed.
:type min_height: int
:param left: a fixed number of columns to add on the left.
:type left: int
:param right: a fixed number of columns to add on the right.
:type right: int
:param top: a fixed number of rows to add on the top.
:type top: int
:param bottom: a fixed number of rows to add on the bottom.
:type bottom: int
Overlay widgets behave similarly to :class:`Padding` and :class:`Filler`
widgets when determining the size and position of *top_w*. *bottom_w* is
always rendered the full size available "below" *top_w*.
"""
super().__init__()
self.top_w = top_w
self.bottom_w = bottom_w
self.set_overlay_parameters(align, width, valign, height, min_width, min_height, left, right, top, bottom)
_check_widget_subclass(top_w)
_check_widget_subclass(bottom_w)
def sizing(self) -> frozenset[Sizing]:
"""Actual widget sizing.
:returns: Sizing information depends on the top widget sizing and sizing parameters.
:rtype: frozenset[Sizing]
Rules:
* BOX sizing is always supported provided by the bottom widget
* FLOW sizing is supported if top widget has:
* * PACK height type and FLOW supported by the TOP widget
* * BOX supported by TOP widget AND height amount AND height type GIVEN of min_height
* FIXED sizing is supported if top widget has:
* * PACK width type and FIXED supported by the TOP widget
* * width amount and GIVEN width or min_width AND:
* * * FLOW supported by the TOP widget AND PACK height type
* * * BOX supported by the TOP widget AND height_amount and GIVEN height or min height
"""
sizing = {Sizing.BOX}
top_sizing = self.top_w.sizing()
if self.width_type == WHSettings.PACK:
if Sizing.FIXED in top_sizing:
sizing.add(Sizing.FIXED)
else:
warnings.warn(
f"Top widget {self.top_w} should support sizing {Sizing.FIXED.upper()} "
f"for width type {WHSettings.PACK.upper()}",
OverlayWarning,
stacklevel=3,
)
elif self.height_type == WHSettings.PACK:
if Sizing.FLOW in top_sizing:
sizing.add(Sizing.FLOW)
if self.width_amount and (self.width_type == WHSettings.GIVEN or self.min_width):
sizing.add(Sizing.FIXED)
else:
warnings.warn(
f"Top widget {self.top_w} should support sizing {Sizing.FLOW.upper()} "
f"for height type {self.height_type.upper()}",
OverlayWarning,
stacklevel=3,
)
elif self.height_amount and (self.height_type == WHSettings.GIVEN or self.min_height):
if Sizing.BOX in top_sizing:
sizing.add(Sizing.FLOW)
if self.width_amount and (self.width_type == WHSettings.GIVEN or self.min_width):
sizing.add(Sizing.FIXED)
else:
warnings.warn(
f"Top widget {self.top_w} should support sizing {Sizing.BOX.upper()} "
f"for height type {self.height_type.upper()}",
OverlayWarning,
stacklevel=3,
)
return frozenset(sizing)
def pack(
self,
size: tuple[()] | tuple[int] | tuple[int, int] = (),
focus: bool = False,
) -> tuple[int, int]:
if size:
return super().pack(size, focus)
extra_cols = (self.left or 0) + (self.right or 0)
extra_rows = (self.top or 0) + (self.bottom or 0)
if self.width_type == WHSettings.PACK:
cols, rows = self.top_w.pack((), focus)
return cols + extra_cols, rows + extra_rows
if not self.width_amount:
raise OverlayError(
f"Requested FIXED render for {self.top_w} with "
f"width_type={self.width_type.upper()}, "
f"width_amount={self.width_amount!r}, "
f"height_type={self.height_type.upper()}, "
f"height_amount={self.height_amount!r}"
f"min_width={self.min_width!r}, "
f"min_height={self.min_height!r}"
)
if self.width_type == WHSettings.GIVEN:
w_cols = self.width_amount
cols = w_cols + extra_cols
elif self.width_type == WHSettings.RELATIVE and self.min_width:
w_cols = self.min_width
cols = int(w_cols * 100 / self.width_amount + 0.5)
else:
raise OverlayError(
f"Requested FIXED render for {self.top_w} with "
f"width_type={self.width_type.upper()}, "
f"width_amount={self.width_amount!r}, "
f"height_type={self.height_type.upper()}, "
f"height_amount={self.height_amount!r}"
f"min_width={self.min_width!r}, "
f"min_height={self.min_height!r}"
)
if self.height_type == WHSettings.PACK:
return cols, self.top_w.rows((w_cols,), focus) + extra_rows
if not self.height_amount:
raise OverlayError(
f"Requested FIXED render for {self.top_w} with "
f"width_type={self.width_type.upper()}, "
f"width_amount={self.width_amount!r}, "
f"height_type={self.height_type.upper()}, "
f"height_amount={self.height_amount!r}"
f"min_width={self.min_width!r}, "
f"min_height={self.min_height!r}"
)
if self.height_type == WHSettings.GIVEN:
return cols, self.height_amount + extra_rows
if self.height_type == WHSettings.RELATIVE and self.min_height:
return cols, int(self.min_height * 100 / self.height_amount + 0.5)
raise OverlayError(
f"Requested FIXED render for {self.top_w} with "
f"width_type={self.width_type.upper()}, "
f"width_amount={self.width_amount!r}, "
f"height_type={self.height_type.upper()}, "
f"height_amount={self.height_amount!r}"
f"min_width={self.min_width!r}, "
f"min_height={self.min_height!r}"
)
def rows(self, size: tuple[int], focus: bool = False) -> int:
"""Widget rows amount for FLOW sizing."""
extra_height = (self.top or 0) + (self.bottom or 0)
if self.height_type == WHSettings.GIVEN:
return self.height_amount + extra_height
if self.height_type == WHSettings.RELATIVE and self.min_height:
return int(self.min_height * 100 / self.height_amount + 0.5)
if self.height_type == WHSettings.PACK:
extra_height = (self.top or 0) + (self.bottom or 0)
if self.width_type == WHSettings.GIVEN and self.width_amount:
return self.top_w.rows((self.width_amount,), focus) + extra_height
if self.width_type == WHSettings.RELATIVE:
width = max(int(size[0] * self.width_amount / 100 + 0.5), (self.min_width or 0))
return self.top_w.rows((width,), focus) + extra_height
raise OverlayError(
f"Requested rows for {self.top_w} with size {size!r}"
f"width_type={self.width_type.upper()}, "
f"width_amount={self.width_amount!r}, "
f"height_type={self.height_type.upper()}, "
f"height_amount={self.height_amount!r}"
f"min_width={self.min_width!r}, "
f"min_height={self.min_height!r}"
)
def _repr_attrs(self) -> dict[str, typing.Any]:
attrs = {
**super()._repr_attrs(),
"top_w": self.top_w,
"bottom_w": self.bottom_w,
"align": self.align,
"width": self.width,
"valign": self.valign,
"height": self.height,
"min_width": self.min_width,
"min_height": self.min_height,
"left": self.left,
"right": self.right,
"top": self.top,
"bottom": self.bottom,
}
return remove_defaults(attrs, Overlay.__init__)
def __rich_repr__(self) -> Iterator[tuple[str | None, typing.Any] | typing.Any]:
yield "top", self.top_w
yield "bottom", self.bottom_w
yield "align", self.align
yield "width", self.width
yield "valign", self.valign
yield "height", self.height
yield "min_width", self.min_width
yield "min_height", self.min_height
yield "left", self.left
yield "right", self.right
yield "top", self.top
yield "bottom", self.bottom
@property
def align(self) -> Align | tuple[Literal[WHSettings.RELATIVE], int]:
return simplify_align(self.align_type, self.align_amount)
@property
def width(
self,
) -> (
Literal[WHSettings.CLIP, WHSettings.PACK]
| int
| tuple[Literal[WHSettings.RELATIVE], int]
| tuple[Literal[WHSettings.WEIGHT], int | float]
):
return simplify_width(self.width_type, self.width_amount)
@property
def valign(self) -> VAlign | tuple[Literal[WHSettings.RELATIVE], int]:
return simplify_valign(self.valign_type, self.valign_amount)
@property
def height(
self,
) -> (
int
| Literal[WHSettings.FLOW, WHSettings.PACK]
| tuple[Literal[WHSettings.RELATIVE], int]
| tuple[Literal[WHSettings.WEIGHT], int | float]
):
return simplify_height(self.height_type, self.height_amount)
@staticmethod
def options(
align_type: Literal["left", "center", "right", "relative", WHSettings.RELATIVE] | Align,
align_amount: int | None,
width_type: Literal["clip", "pack", "relative", "given"] | WHSettings,
width_amount: int | None,
valign_type: Literal["top", "middle", "bottom", "relative", WHSettings.RELATIVE] | VAlign,
valign_amount: int | None,
height_type: Literal["flow", "pack", "relative", "given"] | WHSettings,
height_amount: int | None,
min_width: int | None = None,
min_height: int | None = None,
left: int = 0,
right: int = 0,
top: int = 0,
bottom: int = 0,
) -> OverlayOptions:
"""
Return a new options tuple for use in this Overlay's .contents mapping.
This is the common container API to create options for replacing the
top widget of this Overlay. It is provided for completeness
but is not necessarily the easiest way to change the overlay parameters.
See also :meth:`.set_overlay_parameters`
"""
if align_type in {Align.LEFT, Align.CENTER, Align.RIGHT}:
align = Align(align_type)
elif align_type == WHSettings.RELATIVE:
align = WHSettings.RELATIVE
else:
raise ValueError(f"Unknown alignment type {align_type!r}")
if valign_type in {VAlign.TOP, VAlign.MIDDLE, VAlign.BOTTOM}:
valign = VAlign(valign_type)
elif valign_type == WHSettings.RELATIVE:
valign = WHSettings.RELATIVE
else:
raise ValueError(f"Unknown vertical alignment type {valign_type!r}")
return OverlayOptions(
align,
align_amount,
WHSettings(width_type),
width_amount,
min_width,
left,
right,
valign,
valign_amount,
WHSettings(height_type),
height_amount,
min_height,
top,
bottom,
)
def set_overlay_parameters(
self,
align: (
Literal["left", "center", "right"]
| Align
| tuple[Literal["relative", "fixed left", "fixed right", WHSettings.RELATIVE], int]
),
width: Literal["pack", WHSettings.PACK] | int | tuple[Literal["relative", WHSettings.RELATIVE], int] | None,
valign: (
Literal["top", "middle", "bottom"]
| VAlign
| tuple[Literal["relative", "fixed top", "fixed bottom", WHSettings.RELATIVE], int]
),
height: Literal["pack", WHSettings.PACK] | int | tuple[Literal["relative", WHSettings.RELATIVE], int] | None,
min_width: int | None = None,
min_height: int | None = None,
left: int = 0,
right: int = 0,
top: int = 0,
bottom: int = 0,
) -> None:
"""
Adjust the overlay size and position parameters.
See :class:`__init__() <Overlay>` for a description of the parameters.
"""
# convert obsolete parameters 'fixed ...':
if isinstance(align, tuple):
if align[0] == "fixed left":
left = align[1]
normalized_align = Align.LEFT
elif align[0] == "fixed right":
right = align[1]
normalized_align = Align.RIGHT
else:
normalized_align = align
else:
normalized_align = Align(align)
if isinstance(width, tuple):
if width[0] == "fixed left":
left = width[1]
width = RELATIVE_100
elif width[0] == "fixed right":
right = width[1]
width = RELATIVE_100
if isinstance(valign, tuple):
if valign[0] == "fixed top":
top = valign[1]
normalized_valign = VAlign.TOP
elif valign[0] == "fixed bottom":
bottom = valign[1]
normalized_valign = VAlign.BOTTOM
else:
normalized_valign = valign
elif not isinstance(valign, (VAlign, str)):
raise OverlayError(f"invalid valign: {valign!r}")
else:
normalized_valign = VAlign(valign)
if isinstance(height, tuple):
if height[0] == "fixed bottom":
bottom = height[1]
height = RELATIVE_100
elif height[0] == "fixed top":
top = height[1]
height = RELATIVE_100
if width is None: # more obsolete values accepted
width = WHSettings.PACK
if height is None:
height = WHSettings.PACK
align_type, align_amount = normalize_align(normalized_align, OverlayError)
width_type, width_amount = normalize_width(width, OverlayError)
valign_type, valign_amount = normalize_valign(normalized_valign, OverlayError)
height_type, height_amount = normalize_height(height, OverlayError)
if height_type in {WHSettings.GIVEN, WHSettings.PACK}:
min_height = None
# use container API to set the parameters
self.contents[1] = (
self.top_w,
self.options(
align_type,
align_amount,
width_type,
width_amount,
valign_type,
valign_amount,
height_type,
height_amount,
min_width,
min_height,
left,
right,
top,
bottom,
),
)
def selectable(self) -> bool:
"""Return selectable from top_w."""
return self.top_w.selectable()
def keypress(
self,
size: tuple[()] | tuple[int] | tuple[int, int],
key: str,
) -> str | None:
"""Pass keypress to top_w."""
real_size = self.pack(size, True)
return self.top_w.keypress(
self.top_w_size(real_size, *self.calculate_padding_filler(real_size, True)),
key,
)
@property
def focus(self) -> TopWidget:
"""
Read-only property returning the child widget in focus for
container widgets. This default implementation
always returns ``None``, indicating that this widget has no children.
"""
return self.top_w
def _get_focus(self) -> TopWidget:
warnings.warn(
f"method `{self.__class__.__name__}._get_focus` is deprecated, "
f"please use `{self.__class__.__name__}.focus` property",
DeprecationWarning,
stacklevel=3,
)
return self.top_w
@property
def focus_position(self) -> Literal[1]:
"""
Return the top widget position (currently always 1).
"""
return 1
@focus_position.setter
def focus_position(self, position: int) -> None:
"""
Set the widget in focus. Currently only position 0 is accepted.
position -- index of child widget to be made focus
"""
if position != 1:
raise IndexError(f"Overlay widget focus_position currently must always be set to 1, not {position}")
def _get_focus_position(self) -> int | None:
warnings.warn(
f"method `{self.__class__.__name__}._get_focus_position` is deprecated, "
f"please use `{self.__class__.__name__}.focus_position` property",
DeprecationWarning,
stacklevel=3,
)
return 1
def _set_focus_position(self, position: int) -> None:
"""
Set the widget in focus.
position -- index of child widget to be made focus
"""
warnings.warn(
f"method `{self.__class__.__name__}._set_focus_position` is deprecated, "
f"please use `{self.__class__.__name__}.focus_position` property",
DeprecationWarning,
stacklevel=3,
)
if position != 1:
raise IndexError(f"Overlay widget focus_position currently must always be set to 1, not {position}")
@property
def contents(self) -> MutableSequence[tuple[TopWidget | BottomWidget, OverlayOptions]]:
"""
a list-like object similar to::
[(bottom_w, bottom_options)),
(top_w, top_options)]
This object may be used to read or update top and bottom widgets and
top widgets's options, but no widgets may be added or removed.
`top_options` takes the form
`(align_type, align_amount, width_type, width_amount, min_width, left,
right, valign_type, valign_amount, height_type, height_amount,
min_height, top, bottom)`
bottom_options is always
`('left', None, 'relative', 100, None, 0, 0,
'top', None, 'relative', 100, None, 0, 0)`
which means that bottom widget always covers the full area of the Overlay.
writing a different value for `bottom_options` raises an
:exc:`OverlayError`.
"""
# noinspection PyMethodParameters
class OverlayContents(
typing.MutableSequence[
typing.Tuple[
typing.Union[TopWidget, BottomWidget],
OverlayOptions,
]
]
):
# pylint: disable=no-self-argument
def __len__(inner_self) -> int:
return 2
__getitem__ = self._contents__getitem__
__setitem__ = self._contents__setitem__
def __delitem__(self, index: int | slice) -> typing.NoReturn:
raise TypeError("OverlayContents is fixed-sized sequence")
def insert(self, index: int | slice, value: typing.Any) -> typing.NoReturn:
raise TypeError("OverlayContents is fixed-sized sequence")
def __repr__(inner_self) -> str:
return repr(f"<{inner_self.__class__.__name__}({[inner_self[0], inner_self[1]]})> for {self}")
def __rich_repr__(inner_self) -> Iterator[tuple[str | None, typing.Any] | typing.Any]:
for val in inner_self:
yield None, val
def __iter__(inner_self) -> Iterator[tuple[Widget, OverlayOptions]]:
for idx in range(2):
yield inner_self[idx]
return OverlayContents()
@contents.setter
def contents(self, new_contents: Sequence[tuple[width, OverlayOptions]]) -> None:
if len(new_contents) != 2:
raise ValueError("Contents length for overlay should be only 2")
self.contents[0] = new_contents[0]
self.contents[1] = new_contents[1]
def _contents__getitem__(
self,
index: Literal[0, 1],
) -> tuple[TopWidget | BottomWidget, OverlayOptions]:
if index == 0:
return (self.bottom_w, self._DEFAULT_BOTTOM_OPTIONS)
if index == 1:
return (
self.top_w,
OverlayOptions(
self.align_type,
self.align_amount,
self.width_type,
self.width_amount,
self.min_width,
self.left,
self.right,
self.valign_type,
self.valign_amount,
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: Literal[0, 1],
value: tuple[TopWidget | BottomWidget, OverlayOptions],
) -> None:
try:
value_w, value_options = value
except (ValueError, TypeError) as exc:
raise OverlayError(f"added content invalid: {value!r}").with_traceback(exc.__traceback__) from exc
if index == 0:
if value_options != 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:
(
align_type,
align_amount,
width_type,
width_amount,
min_width,
left,
right,
valign_type,
valign_amount,
height_type,
height_amount,
min_height,
top,
bottom,
) = value_options
except (ValueError, TypeError) as exc:
raise OverlayError(f"top_options is invalid: {value_options!r}").with_traceback(
exc.__traceback__
) from exc
# normalize first, this is where errors are raised
align_type, align_amount = normalize_align(simplify_align(align_type, align_amount), OverlayError)
width_type, width_amount = normalize_width(simplify_width(width_type, width_amount), OverlayError)
valign_type, valign_amount = normalize_valign(simplify_valign(valign_type, valign_amount), OverlayError)
height_type, height_amount = normalize_height(simplify_height(height_type, height_amount), OverlayError)
self.align_type = align_type
self.align_amount = align_amount
self.width_type = width_type
self.width_amount = width_amount
self.valign_type = valign_type
self.valign_amount = valign_amount
self.height_type = height_type
self.height_amount = height_amount
self.left = left
self.right = right
self.top = top
self.bottom = bottom
self.min_width = min_width
self.min_height = min_height
else:
raise IndexError(f"Overlay.contents has no position {index!r}")
self._invalidate()
def get_cursor_coords(
self,
size: tuple[()] | tuple[int] | 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
real_size = self.pack(size, True)
(maxcol, maxrow) = real_size
left, right, top, bottom = self.calculate_padding_filler(real_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: tuple[int, int],
focus: bool,
) -> tuple[int, int, int, int]:
"""Return (padding left, right, filler top, bottom)."""
(maxcol, maxrow) = size
height = None
if self.width_type == WHSettings.PACK:
width, height = self.top_w.pack((), focus=focus)
if not height:
raise OverlayError("fixed widget must have a height")
left, right = calculate_left_right_padding(
maxcol,
self.align_type,
self.align_amount,
WrapMode.CLIP,
width,
None,
self.left,
self.right,
)
else:
left, right = calculate_left_right_padding(
maxcol,
self.align_type,
self.align_amount,
self.width_type,
self.width_amount,
self.min_width,
self.left,
self.right,
)
if height:
# top_w is a fixed widget
top, bottom = calculate_top_bottom_filler(
maxrow,
self.valign_type,
self.valign_amount,
WHSettings.GIVEN,
height,
None,
self.top,
self.bottom,
)
if maxrow - top - bottom < height:
bottom = maxrow - top - height
elif self.height_type == WHSettings.PACK:
# top_w is a flow widget
height = self.top_w.rows((maxcol,), focus=focus)
top, bottom = calculate_top_bottom_filler(
maxrow,
self.valign_type,
self.valign_amount,
WHSettings.GIVEN,
height,
None,
self.top,
self.bottom,
)
if height > maxrow: # flow widget rendered too large
bottom = maxrow - height
else:
top, bottom = calculate_top_bottom_filler(
maxrow,
self.valign_type,
self.valign_amount,
self.height_type,
self.height_amount,
self.min_height,
self.top,
self.bottom,
)
return left, right, top, bottom
def top_w_size(
self,
size: tuple[int, int],
left: int,
right: int,
top: int,
bottom: int,
) -> tuple[()] | tuple[int] | tuple[int, int]:
"""Return the size to pass to top_w."""
if self.width_type == WHSettings.PACK:
# top_w is a fixed widget
return ()
maxcol, maxrow = size
if self.width_type != WHSettings.PACK and self.height_type == WHSettings.PACK:
# top_w is a flow widget
return (maxcol - left - right,)
return (maxcol - left - right, maxrow - top - bottom)
def render(self, size: tuple[()] | tuple[int] | tuple[int, int], focus: bool = False) -> CompositeCanvas:
"""Render top_w overlayed on bottom_w."""
real_size = self.pack(size, focus)
left, right, top, bottom = self.calculate_padding_filler(real_size, focus)
bottom_c = self.bottom_w.render(real_size)
if not bottom_c.cols() or not bottom_c.rows():
return CompositeCanvas(bottom_c)
top_c = self.top_w.render(self.top_w_size(real_size, left, right, top, bottom), focus)
top_c = CompositeCanvas(top_c)
if left < 0 or right < 0:
top_c.pad_trim_left_right(min(0, left), min(0, right))
if top < 0 or bottom < 0:
top_c.pad_trim_top_bottom(min(0, top), min(0, bottom))
return CanvasOverlay(top_c, bottom_c, left, top)
def mouse_event(
self,
size: tuple[()] | tuple[int] | tuple[int, int],
event: str,
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
real_size = self.pack(size, focus)
left, right, top, bottom = self.calculate_padding_filler(real_size, focus)
maxcol, maxrow = real_size
if col < left or col >= maxcol - right or row < top or row >= maxrow - bottom:
return False
return self.top_w.mouse_event(
self.top_w_size(real_size, left, right, top, bottom),
event,
button,
col - left,
row - top,
focus,
)

View File

@@ -0,0 +1,612 @@
from __future__ import annotations
import typing
import warnings
from urwid.canvas import CompositeCanvas, SolidCanvas
from urwid.split_repr import remove_defaults
from urwid.util import int_scale
from .constants import (
RELATIVE_100,
Align,
Sizing,
WHSettings,
normalize_align,
normalize_width,
simplify_align,
simplify_width,
)
from .widget_decoration import WidgetDecoration, WidgetError, WidgetWarning
if typing.TYPE_CHECKING:
from collections.abc import Iterator
from typing_extensions import Literal
WrappedWidget = typing.TypeVar("WrappedWidget")
class PaddingError(WidgetError):
"""Padding related errors."""
class PaddingWarning(WidgetWarning):
"""Padding related warnings."""
class Padding(WidgetDecoration[WrappedWidget], typing.Generic[WrappedWidget]):
def __init__(
self,
w: WrappedWidget,
align: (
Literal["left", "center", "right"]
| Align
| tuple[Literal["relative", WHSettings.RELATIVE, "fixed left", "fixed right"], int]
) = Align.LEFT,
width: (
int
| Literal["pack", "clip", WHSettings.PACK, WHSettings.CLIP]
| tuple[Literal["relative", WHSettings.RELATIVE, "fixed left", "fixed right"], int]
) = RELATIVE_100,
min_width: int | None = None,
left: int = 0,
right: int = 0,
) -> None:
"""
:param w: a box, flow or fixed widget to pad on the left and/or right
this widget is stored as self.original_widget
:type w: Widget
:param align: one of: ``'left'``, ``'center'``, ``'right'``
(``'relative'``, *percentage* 0=left 100=right)
:param width: one of:
*given width*
integer number of columns for self.original_widget
``'pack'``
try to pack self.original_widget to its ideal size
(``'relative'``, *percentage of total width*)
make width depend on the container's width
``'clip'``
to enable clipping mode for a fixed widget
:param min_width: the minimum number of columns for
self.original_widget or ``None``
:type min_width: int | None
:param left: a fixed number of columns to pad on the left
:type left: int
:param right: a fixed number of columns to pad on the right
:type right: int
Clipping Mode: (width= ``'clip'``)
In clipping mode this padding widget will behave as a flow
widget and self.original_widget will be treated as a fixed widget.
self.original_widget will be clipped to fit the available number of columns.
For example if align is ``'left'`` then self.original_widget may be clipped on the right.
Pack Mode: (width= ``'pack'``)
In pack mode is supported FIXED operation if it is supported by the original widget.
>>> from urwid import Divider, Text, BigText, FontRegistry
>>> from urwid.util import set_temporary_encoding
>>> size = (7,)
>>> def pr(w):
... with set_temporary_encoding("utf-8"):
... for t in w.render(size).text:
... print(f"|{t.decode('utf-8')}|" )
>>> pr(Padding(Text(u"Head"), ('relative', 20), 'pack'))
| Head |
>>> pr(Padding(Divider(u"-"), left=2, right=1))
| ---- |
>>> pr(Padding(Divider(u"*"), 'center', 3))
| *** |
>>> p=Padding(Text(u"1234"), 'left', 2, None, 1, 1)
>>> p
<Padding fixed/flow widget <Text fixed/flow widget '1234'> left=1 right=1 width=2>
>>> pr(p) # align against left
| 12 |
| 34 |
>>> p.align = 'right'
>>> pr(p) # align against right
| 12 |
| 34 |
>>> pr(Padding(Text(u"hi\\nthere"), 'right', 'pack')) # pack text first
| hi |
| there|
>>> pr(Padding(BigText("1,2,3", FontRegistry['Thin 3x3']()), width="clip"))
| ┐ ┌─┐|
| │ ┌─┘|
| ┴ ,└─ |
"""
super().__init__(w)
# convert obsolete parameters 'fixed left' and 'fixed right':
if isinstance(align, tuple) and align[0] in {"fixed left", "fixed right"}:
if align[0] == "fixed left":
left = align[1]
align = Align.LEFT
else:
right = align[1]
align = Align.RIGHT
if isinstance(width, tuple) and width[0] in {"fixed left", "fixed right"}:
if width[0] == "fixed left":
left = width[1]
else:
right = width[1]
width = RELATIVE_100
# convert old clipping mode width=None to width='clip'
if width is None:
width = WHSettings.CLIP
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.min_width = min_width
def sizing(self) -> frozenset[Sizing]:
"""Widget sizing.
Rules:
* width == CLIP: only FLOW is supported, and wrapped widget should support FIXED
* width == GIVEN: FIXED is supported, and wrapped widget should support FLOW
* All other cases: use sizing of target widget
"""
if self._width_type == WHSettings.CLIP:
return frozenset((Sizing.FLOW,))
sizing = set(self.original_widget.sizing())
if self._width_type == WHSettings.GIVEN:
if Sizing.FLOW in sizing:
sizing.add(Sizing.FIXED)
elif Sizing.BOX not in sizing:
warnings.warn(
f"WHSettings.GIVEN expect BOX or FLOW widget to be used, but received {self.original_widget}",
PaddingWarning,
stacklevel=3,
)
return frozenset(sizing)
def _repr_attrs(self) -> dict[str, typing.Any]:
attrs = {
**super()._repr_attrs(),
"align": self.align,
"width": self.width,
"left": self.left,
"right": self.right,
"min_width": self.min_width,
}
return remove_defaults(attrs, Padding.__init__)
def __rich_repr__(self) -> Iterator[tuple[str | None, typing.Any] | typing.Any]:
yield "w", self.original_widget
yield "align", self.align
yield "width", self.width
yield "min_width", self.min_width
yield "left", self.left
yield "right", self.right
@property
def align(
self,
) -> Literal["left", "center", "right"] | Align | tuple[Literal["relative", WHSettings.RELATIVE], int]:
"""
Return the padding alignment setting.
"""
return simplify_align(self._align_type, self._align_amount)
@align.setter
def align(
self, align: Literal["left", "center", "right"] | Align | tuple[Literal["relative", WHSettings.RELATIVE], int]
) -> None:
"""
Set the padding alignment.
"""
self._align_type, self._align_amount = normalize_align(align, PaddingError)
self._invalidate()
def _get_align(self) -> Literal["left", "center", "right"] | tuple[Literal["relative"], int]:
warnings.warn(
f"Method `{self.__class__.__name__}._get_align` is deprecated, "
f"please use property `{self.__class__.__name__}.align`",
DeprecationWarning,
stacklevel=2,
)
return self.align
def _set_align(self, align: Literal["left", "center", "right"] | tuple[Literal["relative"], int]) -> None:
warnings.warn(
f"Method `{self.__class__.__name__}._set_align` is deprecated, "
f"please use property `{self.__class__.__name__}.align`",
DeprecationWarning,
stacklevel=2,
)
self.align = align
@property
def width(
self,
) -> (
Literal["clip", "pack", WHSettings.CLIP, WHSettings.PACK]
| int
| tuple[Literal["relative", WHSettings.RELATIVE], int]
):
"""
Return the padding width.
"""
return simplify_width(self._width_type, self._width_amount)
@width.setter
def width(
self,
width: (
Literal["clip", "pack", WHSettings.CLIP, WHSettings.PACK]
| int
| tuple[Literal["relative", WHSettings.RELATIVE], int]
),
) -> None:
"""
Set the padding width.
"""
self._width_type, self._width_amount = normalize_width(width, PaddingError)
self._invalidate()
def _get_width(self) -> Literal["clip", "pack"] | int | tuple[Literal["relative"], int]:
warnings.warn(
f"Method `{self.__class__.__name__}._get_width` is deprecated, "
f"please use property `{self.__class__.__name__}.width`",
DeprecationWarning,
stacklevel=2,
)
return self.width
def _set_width(self, width: Literal["clip", "pack"] | int | tuple[Literal["relative"], int]) -> None:
warnings.warn(
f"Method `{self.__class__.__name__}._set_width` is deprecated, "
f"please use property `{self.__class__.__name__}.width`",
DeprecationWarning,
stacklevel=2,
)
self.width = width
def pack(
self,
size: tuple[()] | tuple[int] | tuple[int, int] = (),
focus: bool = False,
) -> tuple[int, int]:
if size:
return super().pack(size, focus)
if self._width_type == WHSettings.CLIP:
raise PaddingError("WHSettings.CLIP makes Padding FLOW-only widget")
expand = self.left + self.right
w_sizing = self.original_widget.sizing()
if self._width_type == WHSettings.GIVEN:
if Sizing.FLOW not in w_sizing:
warnings.warn(
f"WHSettings.GIVEN expect FLOW widget to be used for FIXED pack/render, "
f"but received {self.original_widget}",
PaddingWarning,
stacklevel=3,
)
return (
max(self._width_amount, self.min_width or 1) + expand,
self.original_widget.rows((self._width_amount,), focus),
)
if Sizing.FIXED not in w_sizing:
warnings.warn(
f"Padded widget should support FIXED sizing for FIXED render, but received {self.original_widget}",
PaddingWarning,
stacklevel=3,
)
width, height = self.original_widget.pack(size, focus)
if self._width_type == WHSettings.PACK:
return max(width, self.min_width or 1) + expand, height
if self._width_type == WHSettings.RELATIVE:
return max(int(width * 100 / self._width_amount + 0.5), self.min_width or 1) + expand, height
raise PaddingError(f"Unexpected width type: {self._width_type.upper()})")
def render(
self,
size: tuple[()] | tuple[int] | tuple[int, int],
focus: bool = False,
) -> CompositeCanvas:
left, right = self.padding_values(size, focus)
if self._width_type == WHSettings.CLIP:
canv = self._original_widget.render((), focus)
elif size:
maxcol = size[0] - (left + right)
if self._width_type == WHSettings.GIVEN and maxcol < self._width_amount:
warnings.warn(
f"{self}.render(size={size}, focus={focus}): too narrow size ({maxcol!r} < {self._width_amount!r})",
PaddingWarning,
stacklevel=3,
)
canv = self._original_widget.render((maxcol,) + size[1:], focus)
elif self._width_type == WHSettings.GIVEN:
canv = self._original_widget.render((self._width_amount,) + size[1:], focus)
else:
canv = self._original_widget.render((), focus)
if canv.cols() == 0:
canv = SolidCanvas(" ", size[0], canv.rows())
canv = CompositeCanvas(canv)
canv.set_depends([self._original_widget])
return canv
canv = CompositeCanvas(canv)
canv.set_depends([self._original_widget])
if left != 0 or right != 0:
canv.pad_trim_left_right(left, right)
return canv
def padding_values(
self,
size: tuple[()] | 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."""
if self._width_type == WHSettings.CLIP:
width, _ignore = self._original_widget.pack((), focus=focus)
if not size:
raise PaddingError("WHSettings.CLIP makes Padding FLOW-only widget")
return calculate_left_right_padding(
size[0],
self._align_type,
self._align_amount,
WHSettings.CLIP,
width,
None,
self.left,
self.right,
)
if self._width_type == WHSettings.PACK:
if size:
maxcol = size[0]
maxwidth = max(maxcol - self.left - self.right, self.min_width or 0)
(width, _ignore) = self._original_widget.pack((maxwidth,), focus=focus)
else:
(width, _ignore) = self._original_widget.pack((), focus=focus)
maxcol = width + self.left + self.right
return calculate_left_right_padding(
maxcol,
self._align_type,
self._align_amount,
WHSettings.GIVEN,
width,
self.min_width,
self.left,
self.right,
)
if size:
maxcol = size[0]
elif self._width_type == WHSettings.GIVEN:
maxcol = self._width_amount + self.left + self.right
else:
maxcol = (
max(self._original_widget.pack((), focus=focus)[0] * 100 // self._width_amount, self.min_width or 1)
+ self.left
+ self.right
)
return calculate_left_right_padding(
maxcol,
self._align_type,
self._align_amount,
self._width_type,
self._width_amount,
self.min_width,
self.left,
self.right,
)
def rows(self, size: tuple[int], focus: bool = False) -> int:
"""Return the rows needed for self.original_widget."""
(maxcol,) = size
left, right = self.padding_values(size, focus)
if self._width_type == WHSettings.PACK:
_pcols, prows = self._original_widget.pack((maxcol - left - right,), focus)
return prows
if self._width_type == WHSettings.CLIP:
_fcols, frows = self._original_widget.pack((), focus)
return frows
return self._original_widget.rows((maxcol - left - right,), focus=focus)
def keypress(self, size: tuple[()] | tuple[int] | tuple[int, int], key: str) -> str | None:
"""Pass keypress to self._original_widget."""
left, right = self.padding_values(size, True)
if size:
maxvals = (size[0] - left - right,) + size[1:]
return self._original_widget.keypress(maxvals, key)
return self._original_widget.keypress((), key)
def get_cursor_coords(self, size: tuple[()] | 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"):
return None
left, right = self.padding_values(size, True)
if size:
maxvals = (size[0] - left - right,) + size[1:]
if maxvals[0] == 0:
return None
else:
maxvals = ()
coords = self._original_widget.get_cursor_coords(maxvals)
if coords is None:
return None
x, y = coords
return x + left, y
def move_cursor_to_coords(
self,
size: tuple[()] | 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"):
return True
left, right = self.padding_values(size, True)
if size:
maxcol = size[0]
maxvals = (maxcol - left - right,) + size[1:]
else:
maxcol = self.pack((), True)[0]
maxvals = ()
if isinstance(x, int):
if x < left:
x = left
elif x >= maxcol - right:
x = maxcol - right - 1
x -= left
return self._original_widget.move_cursor_to_coords(maxvals, x, y)
def mouse_event(
self,
size: tuple[()] | tuple[int] | tuple[int, int],
event: str,
button: int,
col: int,
row: int,
focus: bool,
) -> bool | None:
"""Send mouse event if position is within self._original_widget."""
if not hasattr(self._original_widget, "mouse_event"):
return False
left, right = self.padding_values(size, focus)
if size:
maxcol = size[0]
if col < left or col >= maxcol - right:
return False
maxvals = (maxcol - left - right,) + size[1:]
else:
maxvals = ()
return self._original_widget.mouse_event(maxvals, event, button, col - left, row, focus)
def get_pref_col(self, size: tuple[()] | 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"):
return None
left, right = self.padding_values(size, True)
if size:
maxvals = (size[0] - left - right,) + size[1:]
else:
maxvals = ()
x = self._original_widget.get_pref_col(maxvals)
if isinstance(x, int):
return x + left
return x
def calculate_left_right_padding(
maxcol: int,
align_type: Literal["left", "center", "right"] | Align,
align_amount: int,
width_type: Literal["fixed", "relative", "clip", "given", WHSettings.RELATIVE, WHSettings.CLIP, WHSettings.GIVEN],
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:
align_type -- 'left', 'center', 'right', 'relative'
align_amount -- a percentage when align_type=='relative'
width_type -- 'fixed', 'relative', 'clip'
width_amount -- a percentage when width_type=='relative'
otherwise equal to the width of the widget
min_width -- a desired minimum width for the widget or None
left -- a fixed number of columns to pad on the left
right -- a fixed number of columns to pad on the right
>>> clrp = calculate_left_right_padding
>>> clrp(15, 'left', 0, 'given', 10, None, 2, 0)
(2, 3)
>>> clrp(15, 'relative', 0, 'given', 10, None, 2, 0)
(2, 3)
>>> clrp(15, 'relative', 100, 'given', 10, None, 2, 0)
(5, 0)
>>> clrp(15, 'center', 0, 'given', 4, None, 2, 0)
(6, 5)
>>> clrp(15, 'left', 0, 'clip', 18, None, 0, 0)
(0, -3)
>>> clrp(15, 'right', 0, 'clip', 18, None, 0, -1)
(-2, -1)
>>> clrp(15, 'center', 0, 'given', 18, None, 2, 0)
(0, 0)
>>> clrp(20, 'left', 0, 'relative', 60, None, 0, 0)
(0, 8)
>>> clrp(20, 'relative', 30, 'relative', 60, None, 0, 0)
(2, 6)
>>> clrp(20, 'relative', 30, 'relative', 60, 14, 0, 0)
(2, 4)
"""
if width_type == WHSettings.RELATIVE:
maxwidth = max(maxcol - left - right, 0)
width = int(maxwidth * width_amount / 100 + 0.5)
if min_width is not None:
width = max(width, min_width)
else:
width = width_amount
align = {Align.LEFT: 0, Align.CENTER: 50, Align.RIGHT: 100}.get(align_type, align_amount)
# add the remainder of left/right the padding
padding = maxcol - width - left - right
right += int_scale(100 - align, 101, padding + 1)
left = maxcol - width - right
# reduce padding if we are clipping an edge
if right < 0 < left:
shift = min(left, -right)
left -= shift
right += shift
elif left < 0 < right:
shift = min(right, -left)
right -= shift
left += shift
# only clip if width_type == 'clip'
if width_type != WHSettings.CLIP and (left < 0 or right < 0):
left = max(left, 0)
right = max(right, 0)
return left, right

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
# Urwid Window-Icon-Menu-Pointer-style widget classes
# Copyright (C) 2004-2011 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
import typing
from urwid.canvas import CompositeCanvas
from .constants import Align, Sizing, VAlign
from .overlay import Overlay
from .widget import delegate_to_widget_mixin
from .widget_decoration import WidgetDecoration
if typing.TYPE_CHECKING:
from typing_extensions import TypedDict
from urwid.canvas import Canvas
from .widget import Widget
class PopUpParametersModel(TypedDict):
left: int
top: int
overlay_width: int
overlay_height: int
WrappedWidget = typing.TypeVar("WrappedWidget")
class PopUpLauncher(delegate_to_widget_mixin("_original_widget"), WidgetDecoration[WrappedWidget]):
def __init__(self, original_widget: [WrappedWidget]) -> None:
super().__init__(original_widget)
self._pop_up_widget = None
def create_pop_up(self) -> Widget:
"""
Subclass must override this method and return a widget
to be used for the pop-up. This method is called once each time
the pop-up is opened.
"""
raise NotImplementedError("Subclass must override this method")
def get_pop_up_parameters(self) -> PopUpParametersModel:
"""
Subclass must override this method and have it return a dict, eg:
{'left':0, 'top':1, 'overlay_width':30, 'overlay_height':4}
This method is called each time this widget is rendered.
"""
raise NotImplementedError("Subclass must override this method")
def open_pop_up(self) -> None:
self._pop_up_widget = self.create_pop_up()
self._invalidate()
def close_pop_up(self) -> None:
self._pop_up_widget = None
self._invalidate()
def render(self, size, focus: bool = False) -> CompositeCanvas | Canvas:
canv = super().render(size, focus)
if self._pop_up_widget:
canv = CompositeCanvas(canv)
canv.set_pop_up(self._pop_up_widget, **self.get_pop_up_parameters())
return canv
class PopUpTarget(WidgetDecoration[WrappedWidget]):
# FIXME: this whole class is a terrible hack and must be fixed when layout and rendering are separated
_sizing = frozenset((Sizing.BOX,))
_selectable = True
def __init__(self, original_widget: WrappedWidget) -> None:
super().__init__(original_widget)
self._pop_up = None
self._current_widget = self._original_widget
def _update_overlay(self, size: tuple[int, int], focus: bool) -> None:
canv = self._original_widget.render(size, focus=focus)
self._cache_original_canvas = canv # imperfect performance hack
pop_up = canv.get_pop_up()
if pop_up:
left, top, (w, overlay_width, overlay_height) = pop_up
if self._pop_up != w:
self._pop_up = w
self._current_widget = Overlay(
top_w=w,
bottom_w=self._original_widget,
align=Align.LEFT,
width=overlay_width,
valign=VAlign.TOP,
height=overlay_height,
left=left,
top=top,
)
else:
self._current_widget.set_overlay_parameters(
align=Align.LEFT,
width=overlay_width,
valign=VAlign.TOP,
height=overlay_height,
left=left,
top=top,
)
else:
self._pop_up = None
self._current_widget = self._original_widget
def render(self, size: tuple[int, int], focus: bool = False) -> Canvas:
self._update_overlay(size, focus)
return self._current_widget.render(size, focus=focus)
def get_cursor_coords(self, size: tuple[int, int]) -> tuple[int, int] | None:
self._update_overlay(size, True)
return self._current_widget.get_cursor_coords(size)
def get_pref_col(self, size: tuple[int, int]) -> int:
self._update_overlay(size, True)
return self._current_widget.get_pref_col(size)
def keypress(self, size: tuple[int, int], key: str) -> str | None:
self._update_overlay(size, True)
return self._current_widget.keypress(size, key)
def move_cursor_to_coords(self, size: tuple[int, int], x: int, y: int):
self._update_overlay(size, True)
return self._current_widget.move_cursor_to_coords(size, x, y)
def mouse_event(
self,
size: tuple[int, int],
event: str,
button: int,
col: int,
row: int,
focus: bool,
) -> bool | None:
self._update_overlay(size, focus)
return self._current_widget.mouse_event(size, event, button, col, row, focus)
def pack(self, size: tuple[int, int] | None = None, focus: bool = False) -> tuple[int, int]:
self._update_overlay(size, focus)
return self._current_widget.pack(size)
def _test():
import doctest
doctest.testmod()
if __name__ == "__main__":
_test()

View File

@@ -0,0 +1,154 @@
from __future__ import annotations
import typing
import warnings
from .constants import BAR_SYMBOLS, Align, Sizing, WrapMode
from .text import Text
from .widget import Widget
if typing.TYPE_CHECKING:
from collections.abc import Hashable
from urwid.canvas import TextCanvas
class ProgressBar(Widget):
_sizing = frozenset([Sizing.FLOW])
eighths = BAR_SYMBOLS.HORISONTAL[:8] # Full width line is made by style
text_align = Align.CENTER
def __init__(
self,
normal: Hashable | None,
complete: Hashable | None,
current: int = 0,
done: int = 100,
satt: Hashable | None = None,
) -> None:
"""
:param normal: display attribute for incomplete part of progress bar
:param complete: display attribute for complete part of progress bar
:param current: current progress
:param done: progress amount at 100%
:param satt: display attribute for smoothed part of bar where the
foreground of satt corresponds to the normal part and the
background corresponds to the complete part.
If satt is ``None`` then no smoothing will be done.
>>> from urwid import LineBox
>>> pb = ProgressBar('a', 'b')
>>> pb
<ProgressBar flow widget>
>>> print(pb.get_text())
0 %
>>> pb.set_completion(34.42)
>>> print(pb.get_text())
34 %
>>> class CustomProgressBar(ProgressBar):
... def get_text(self):
... return u'Foobar'
>>> cpb = CustomProgressBar('a', 'b')
>>> print(cpb.get_text())
Foobar
>>> for x in range(101):
... cpb.set_completion(x)
... s = cpb.render((10, ))
>>> cpb2 = CustomProgressBar('a', 'b', satt='c')
>>> for x in range(101):
... cpb2.set_completion(x)
... s = cpb2.render((10, ))
>>> pb = ProgressBar('a', 'b', satt='c')
>>> pb.set_completion(34.56)
>>> print(LineBox(pb).render((20,)))
┌──────────────────┐
│ ▏34 %
└──────────────────┘
"""
super().__init__()
self.normal = normal
self.complete = complete
self._current = current
self._done = done
self.satt = satt
def set_completion(self, current: int) -> None:
"""
current -- current progress
"""
self._current = current
self._invalidate()
current = property(lambda self: self._current, set_completion)
@property
def done(self):
return self._done
@done.setter
def done(self, done):
"""
done -- progress amount at 100%
"""
self._done = done
self._invalidate()
def _set_done(self, done):
warnings.warn(
f"Method `{self.__class__.__name__}._set_done` is deprecated, "
f"please use property `{self.__class__.__name__}.done`",
DeprecationWarning,
stacklevel=2,
)
self.done = done
def rows(self, size: tuple[int], focus: bool = False) -> int:
return 1
def get_text(self) -> str:
"""
Return the progress bar percentage text.
You can override this method to display custom text.
"""
percent = min(100, max(0, int(self.current * 100 / self.done)))
return f"{percent!s} %"
def render(
self,
size: tuple[int], # type: ignore[override]
focus: bool = False,
) -> TextCanvas:
"""
Render the progress bar.
"""
# pylint: disable=protected-access
(maxcol,) = size
c = Text(self.get_text(), self.text_align, WrapMode.CLIP).render((maxcol,))
cf = float(self.current) * maxcol / self.done
ccol_dirty = int(cf)
ccol = len(c._text[0][:ccol_dirty].decode("utf-8", "ignore").encode("utf-8"))
cs = 0
if self.satt is not None:
cs = int((cf - ccol) * 8)
if ccol < 0 or (ccol == cs == 0):
c._attr = [[(self.normal, maxcol)]]
elif ccol >= maxcol:
c._attr = [[(self.complete, maxcol)]]
elif cs and c._text[0][ccol] == 32:
t = c._text[0]
cenc = self.eighths[cs].encode("utf-8")
c._text[0] = t[:ccol] + cenc + t[ccol + 1 :]
a = []
if ccol > 0:
a.append((self.complete, ccol))
a.append((self.satt, len(cenc)))
if maxcol - ccol - 1 > 0:
a.append((self.normal, maxcol - ccol - 1))
c._attr = [a]
c._cs = [[(None, len(c._text[0]))]]
else:
c._attr = [[(self.complete, ccol), (self.normal, maxcol - ccol)]]
return c

View File

@@ -0,0 +1,671 @@
# Copyright (C) 2024 Urwid developers
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
#
# Copyright (C) 2017-2024 rndusr (https://github.com/rndusr)
# Re-licensed from gpl-3.0 with author permission.
# Permission comment link: https://github.com/markqvist/NomadNet/pull/46#issuecomment-1892712616
from __future__ import annotations
import contextlib
import enum
import typing
from typing_extensions import Protocol, runtime_checkable
from .constants import BOX_SYMBOLS, SHADE_SYMBOLS, Sizing
from .widget_decoration import WidgetDecoration, WidgetError
if typing.TYPE_CHECKING:
from collections.abc import Iterator
from typing_extensions import Literal
from urwid import Canvas, CompositeCanvas
from .widget import Widget
__all__ = ("ScrollBar", "Scrollable", "ScrollableError", "ScrollbarSymbols")
WrappedWidget = typing.TypeVar("WrappedWidget", bound="SupportsScroll")
class ScrollableError(WidgetError):
"""Scrollable specific widget errors."""
# Scroll actions
SCROLL_LINE_UP = "line up"
SCROLL_LINE_DOWN = "line down"
SCROLL_PAGE_UP = "page up"
SCROLL_PAGE_DOWN = "page down"
SCROLL_TO_TOP = "to top"
SCROLL_TO_END = "to end"
# Scrollbar positions
SCROLLBAR_LEFT = "left"
SCROLLBAR_RIGHT = "right"
class ScrollbarSymbols(str, enum.Enum):
"""Common symbols suitable for scrollbar."""
FULL_BLOCK = SHADE_SYMBOLS.FULL_BLOCK
DARK_SHADE = SHADE_SYMBOLS.DARK_SHADE
MEDIUM_SHADE = SHADE_SYMBOLS.MEDIUM_SHADE
LITE_SHADE = SHADE_SYMBOLS.LITE_SHADE
DRAWING_LIGHT = BOX_SYMBOLS.LIGHT.VERTICAL
DRAWING_LIGHT_2_DASH = BOX_SYMBOLS.LIGHT.VERTICAL_2_DASH
DRAWING_LIGHT_3_DASH = BOX_SYMBOLS.LIGHT.VERTICAL_3_DASH
DRAWING_LIGHT_4_DASH = BOX_SYMBOLS.LIGHT.VERTICAL_4_DASH
DRAWING_HEAVY = BOX_SYMBOLS.HEAVY.VERTICAL
DRAWING_HEAVY_2_DASH = BOX_SYMBOLS.HEAVY.VERTICAL_2_DASH
DRAWING_HEAVY_3_DASH = BOX_SYMBOLS.HEAVY.VERTICAL_3_DASH
DRAWING_HEAVY_4_DASH = BOX_SYMBOLS.HEAVY.VERTICAL_4_DASH
DRAWING_DOUBLE = BOX_SYMBOLS.DOUBLE.VERTICAL
@runtime_checkable
class WidgetProto(Protocol):
"""Protocol for widget.
Due to protocol cannot inherit non-protocol bases, define several obligatory Widget methods.
"""
# Base widget methods (from Widget)
def sizing(self) -> frozenset[Sizing]: ...
def selectable(self) -> bool: ...
def pack(self, size: tuple[int, int], focus: bool = False) -> tuple[int, int]: ...
@property
def base_widget(self) -> Widget:
raise NotImplementedError
def keypress(self, size: tuple[int, int], key: str) -> str | None: ...
def mouse_event(
self,
size: tuple[int, int],
event: str,
button: int,
col: int,
row: int,
focus: bool,
) -> bool | None: ...
def render(self, size: tuple[int, int], focus: bool = False) -> Canvas: ...
@runtime_checkable
class SupportsScroll(WidgetProto, Protocol):
"""Scroll specific methods."""
def get_scrollpos(self, size: tuple[int, int], focus: bool = False) -> int: ...
def rows_max(self, size: tuple[int, int] | None = None, focus: bool = False) -> int: ...
@runtime_checkable
class SupportsRelativeScroll(WidgetProto, Protocol):
"""Relative scroll-specific methods."""
def require_relative_scroll(self, size: tuple[int, int], focus: bool = False) -> bool: ...
def get_first_visible_pos(self, size: tuple[int, int], focus: bool = False) -> int: ...
def get_visible_amount(self, size: tuple[int, int], focus: bool = False) -> int: ...
def orig_iter(w: Widget) -> Iterator[Widget]:
visited = {w}
yield w
while hasattr(w, "original_widget"):
w = w.original_widget
if w in visited:
break
visited.add(w)
yield w
class Scrollable(WidgetDecoration[WrappedWidget]):
def sizing(self) -> frozenset[Sizing]:
return frozenset((Sizing.BOX,))
def selectable(self) -> bool:
return True
def __init__(self, widget: WrappedWidget, force_forward_keypress: bool = False) -> None:
"""Box widget that makes a fixed or flow widget vertically scrollable
.. note::
Focusable widgets are handled, including switching focus, but possibly not intuitively,
depending on the arrangement of widgets.
When switching focus to a widget that is ouside of the visible part of the original widget,
the canvas scrolls up/down to the focused widget.
It would be better to scroll until the next focusable widget is in sight first.
But for that to work we must somehow obtain a list of focusable rows in the original canvas.
"""
if not widget.sizing() & frozenset((Sizing.FIXED, Sizing.FLOW)):
raise ValueError(f"Not a fixed or flow widget: {widget!r}")
self._trim_top = 0
self._scroll_action = None
self._forward_keypress = None
self._old_cursor_coords = None
self._rows_max_cached = 0
self.force_forward_keypress = force_forward_keypress
super().__init__(widget)
def render(
self,
size: tuple[int, int], # type: ignore[override]
focus: bool = False,
) -> CompositeCanvas:
from urwid import canvas
maxcol, maxrow = size
def automove_cursor() -> None:
ch = 0
last_hidden = False
first_visible = False
for pwi, (w, _o) in enumerate(ow.contents):
wcanv = w.render((maxcol,))
wh = wcanv.rows()
if wh:
ch += wh
if not last_hidden and ch >= self._trim_top:
last_hidden = True
elif last_hidden:
if not first_visible:
first_visible = True
if not w.selectable():
continue
ow.focus_item = pwi
st = None
nf = ow.get_focus()
if hasattr(nf, "key_timeout"):
st = nf
elif hasattr(nf, "original_widget"):
no = nf.original_widget
if hasattr(no, "original_widget"):
st = no.original_widget
elif hasattr(no, "key_timeout"):
st = no
if st and hasattr(st, "key_timeout") and callable(getattr(st, "keypress", None)):
st.keypress(None, None)
break
# Render complete original widget
ow = self._original_widget
ow_size = self._get_original_widget_size(size)
canv_full = ow.render(ow_size, focus)
# Make full canvas editable
canv = canvas.CompositeCanvas(canv_full)
canv_cols, canv_rows = canv.cols(), canv.rows()
if canv_cols <= maxcol:
pad_width = maxcol - canv_cols
if pad_width > 0:
# Canvas is narrower than available horizontal space
canv.pad_trim_left_right(0, pad_width)
if canv_rows <= maxrow:
fill_height = maxrow - canv_rows
if fill_height > 0:
# Canvas is lower than available vertical space
canv.pad_trim_top_bottom(0, fill_height)
if canv_cols <= maxcol and canv_rows <= maxrow:
# Canvas is small enough to fit without trimming
return canv
self._adjust_trim_top(canv, size)
# Trim canvas if necessary
trim_top = self._trim_top
trim_end = canv_rows - maxrow - trim_top
trim_right = canv_cols - maxcol
if trim_top > 0:
canv.trim(trim_top)
if trim_end > 0:
canv.trim_end(trim_end)
if trim_right > 0:
canv.pad_trim_left_right(0, -trim_right)
# Disable cursor display if cursor is outside of visible canvas parts
if canv.cursor is not None:
# Pylint check acts here a bit weird.
_curscol, cursrow = canv.cursor # pylint: disable=unpacking-non-sequence,useless-suppression
if cursrow >= maxrow or cursrow < 0:
canv.cursor = None
# Figure out whether we should forward keypresses to original widget
if canv.cursor is not None:
# Trimmed canvas contains the cursor, e.g. in an Edit widget
self._forward_keypress = True
elif canv_full.cursor is not None:
# Full canvas contains the cursor, but scrolled out of view
self._forward_keypress = False
# Reset cursor position on page/up down scrolling
if getattr(ow, "automove_cursor_on_scroll", False):
with contextlib.suppress(Exception):
automove_cursor()
else:
# Original widget does not have a cursor, but may be selectable
# FIXME: Using ow.selectable() is bad because the original
# widget may be selectable because it's a container widget with
# a key-grabbing widget that is scrolled out of view.
# ow.selectable() returns True anyway because it doesn't know
# how we trimmed our canvas.
#
# To fix this, we need to resolve ow.focus and somehow
# ask canv whether it contains bits of the focused widget. I
# can't see a way to do that.
self._forward_keypress = ow.selectable()
return canv
def keypress(
self,
size: tuple[int, int], # type: ignore[override]
key: str,
) -> str | None:
from urwid.command_map import Command
# Maybe offer key to original widget
if self._forward_keypress or self.force_forward_keypress:
ow = self._original_widget
ow_size = self._get_original_widget_size(size)
# Remember the previous cursor position if possible
if hasattr(ow, "get_cursor_coords"):
self._old_cursor_coords = ow.get_cursor_coords(ow_size)
key = ow.keypress(ow_size, key)
if key is None:
return None
# Handle up/down, page up/down, etc.
command_map = self._command_map
if command_map[key] == Command.UP:
self._scroll_action = SCROLL_LINE_UP
elif command_map[key] == Command.DOWN:
self._scroll_action = SCROLL_LINE_DOWN
elif command_map[key] == Command.PAGE_UP:
self._scroll_action = SCROLL_PAGE_UP
elif command_map[key] == Command.PAGE_DOWN:
self._scroll_action = SCROLL_PAGE_DOWN
elif command_map[key] == Command.MAX_LEFT: # 'home'
self._scroll_action = SCROLL_TO_TOP
elif command_map[key] == Command.MAX_RIGHT: # 'end'
self._scroll_action = SCROLL_TO_END
else:
return key
self._invalidate()
return None
def mouse_event(
self,
size: tuple[int, int], # type: ignore[override]
event: str,
button: int,
col: int,
row: int,
focus: bool,
) -> bool | None:
ow = self._original_widget
if hasattr(ow, "mouse_event"):
ow_size = self._get_original_widget_size(size)
row += self._trim_top
return ow.mouse_event(ow_size, event, button, col, row, focus)
return False
def _adjust_trim_top(self, canv: Canvas, size: tuple[int, int]) -> None:
"""Adjust self._trim_top according to self._scroll_action"""
action = self._scroll_action
self._scroll_action = None
_maxcol, maxrow = size
trim_top = self._trim_top
canv_rows = canv.rows()
if trim_top < 0:
# Negative trim_top values use bottom of canvas as reference
trim_top = canv_rows - maxrow + trim_top + 1
if canv_rows <= maxrow:
self._trim_top = 0 # Reset scroll position
return
def ensure_bounds(new_trim_top: int) -> int:
return max(0, min(canv_rows - maxrow, new_trim_top))
if action == SCROLL_LINE_UP:
self._trim_top = ensure_bounds(trim_top - 1)
elif action == SCROLL_LINE_DOWN:
self._trim_top = ensure_bounds(trim_top + 1)
elif action == SCROLL_PAGE_UP:
self._trim_top = ensure_bounds(trim_top - maxrow + 1)
elif action == SCROLL_PAGE_DOWN:
self._trim_top = ensure_bounds(trim_top + maxrow - 1)
elif action == SCROLL_TO_TOP:
self._trim_top = 0
elif action == SCROLL_TO_END:
self._trim_top = canv_rows - maxrow
else:
self._trim_top = ensure_bounds(trim_top)
# If the cursor was moved by the most recent keypress, adjust trim_top
# so that the new cursor position is within the displayed canvas part.
# But don't do this if the cursor is at the top/bottom edge so we can still scroll out
if self._old_cursor_coords is not None and self._old_cursor_coords != canv.cursor and canv.cursor is not None:
self._old_cursor_coords = None
_curscol, cursrow = canv.cursor
if cursrow < self._trim_top:
self._trim_top = cursrow
elif cursrow >= self._trim_top + maxrow:
self._trim_top = max(0, cursrow - maxrow + 1)
def _get_original_widget_size(
self,
size: tuple[int, int], # type: ignore[override]
) -> tuple[int] | tuple[()]:
ow = self._original_widget
sizing = ow.sizing()
if Sizing.FLOW in sizing:
return (size[0],)
if Sizing.FIXED in sizing:
return ()
raise ScrollableError(f"{ow!r} sizing is not supported")
def get_scrollpos(self, size: tuple[int, int] | None = None, focus: bool = False) -> int:
"""Current scrolling position.
Lower limit is 0, upper limit is the maximum number of rows with the given maxcol minus maxrow.
..note::
The returned value may be too low or too high if the position has
changed but the widget wasn't rendered yet.
"""
return self._trim_top
def set_scrollpos(self, position: typing.SupportsInt) -> None:
"""Set scrolling position
If `position` is positive it is interpreted as lines from the top.
If `position` is negative it is interpreted as lines from the bottom.
Values that are too high or too low values are automatically adjusted during rendering.
"""
self._trim_top = int(position)
self._invalidate()
def rows_max(self, size: tuple[int, int] | None = None, focus: bool = False) -> int:
"""Return the number of rows for `size`
If `size` is not given, the currently rendered number of rows is returned.
"""
if size is not None:
ow = self._original_widget
ow_size = self._get_original_widget_size(size)
sizing = ow.sizing()
if Sizing.FIXED in sizing:
self._rows_max_cached = ow.pack(ow_size, focus)[1]
elif Sizing.FLOW in sizing:
self._rows_max_cached = ow.rows(ow_size, focus)
else:
raise ScrollableError(f"Not a flow/box widget: {self._original_widget!r}")
return self._rows_max_cached
class ScrollBar(WidgetDecoration[WrappedWidget]):
Symbols = ScrollbarSymbols
def sizing(self) -> frozenset[Sizing]:
return frozenset((Sizing.BOX,))
def selectable(self) -> bool:
return True
def __init__(
self,
widget: WrappedWidget,
thumb_char: str = ScrollbarSymbols.FULL_BLOCK,
trough_char: str = " ",
side: Literal["left", "right"] = SCROLLBAR_RIGHT,
width: int = 1,
) -> None:
"""Box widget that adds a scrollbar to `widget`
`widget` must be a box widget with the following methods:
- `get_scrollpos` takes the arguments `size` and `focus` and returns the index of the first visible row.
- `set_scrollpos` (optional; needed for mouse click support) takes the index of the first visible row.
- `rows_max` takes `size` and `focus` and returns the total number of rows `widget` can render.
`thumb_char` is the character used for the scrollbar handle.
`trough_char` is used for the space above and below the handle.
`side` must be 'left' or 'right'.
`width` specifies the number of columns the scrollbar uses.
"""
if Sizing.BOX not in widget.sizing():
raise ValueError(f"Not a box widget: {widget!r}")
if not any(isinstance(w, SupportsScroll) for w in orig_iter(widget)):
raise TypeError(f"Not a scrollable widget: {widget!r}")
super().__init__(widget)
self._thumb_char = thumb_char
self._trough_char = trough_char
self.scrollbar_side = side
self.scrollbar_width = max(1, width)
self._original_widget_size = (0, 0)
def render(
self,
size: tuple[int, int], # type: ignore[override]
focus: bool = False,
) -> Canvas:
from urwid import canvas
def render_no_scrollbar() -> Canvas:
self._original_widget_size = size
return ow.render(size, focus)
def render_for_scrollbar() -> Canvas:
self._original_widget_size = ow_size
return ow.render(ow_size, focus)
maxcol, maxrow = size
ow_size = (max(0, maxcol - self._scrollbar_width), maxrow)
sb_width = maxcol - ow_size[0]
ow = self._original_widget
ow_base = self.scrolling_base_widget
# Use hasattr instead of protocol: hasattr will return False in case of getattr raise AttributeError
# Use __length_hint__ first since it's less resource intensive
use_relative = (
isinstance(ow_base, SupportsRelativeScroll)
and any(hasattr(ow_base, attrib) for attrib in ("__length_hint__", "__len__"))
and ow_base.require_relative_scroll(size, focus)
)
if use_relative:
# `operator.length_hint` is Protocol (Spec) over class based and can end false-negative on the instance
# use length_hint-like approach with safe `AttributeError` handling
ow_len = getattr(ow_base, "__len__", getattr(ow_base, "__length_hint__", int))()
ow_canv = render_for_scrollbar()
visible_amount = ow_base.get_visible_amount(ow_size, focus)
pos = ow_base.get_first_visible_pos(ow_size, focus)
# in the case of estimated length, it can be smaller than real widget length
ow_len = max(ow_len, visible_amount, pos)
posmax = ow_len - visible_amount
thumb_weight = min(1.0, visible_amount / max(1, ow_len))
if ow_len == visible_amount:
# Corner case: formally all contents indexes should be visible, but this does not mean all rows
use_relative = False
if not use_relative:
ow_rows_max = ow_base.rows_max(size, focus)
if ow_rows_max <= maxrow:
# Canvas fits without scrolling - no scrollbar needed
return render_no_scrollbar()
ow_canv = render_for_scrollbar()
ow_rows_max = ow_base.rows_max(ow_size, focus)
pos = ow_base.get_scrollpos(ow_size, focus)
posmax = ow_rows_max - maxrow
thumb_weight = min(1.0, maxrow / max(1, ow_rows_max))
# Thumb shrinks/grows according to the ratio of <number of visible lines> / <number of total lines>
thumb_height = max(1, round(thumb_weight * maxrow)) # pylint: disable=possibly-used-before-assignment
# Thumb may only touch top/bottom if the first/last row is visible
top_weight = float(pos) / max(1, posmax) # pylint: disable=possibly-used-before-assignment
top_height = int((maxrow - thumb_height) * top_weight)
if top_height == 0 and top_weight > 0:
top_height = 1
# Bottom part is remaining space
bottom_height = maxrow - thumb_height - top_height
# Create scrollbar canvas
# Creating SolidCanvases of correct height may result in
# "cviews do not fill gaps in shard_tail!" or "cviews overflow gaps in shard_tail!" exceptions.
# Stacking the same SolidCanvas is a workaround.
# https://github.com/urwid/urwid/issues/226#issuecomment-437176837
top = canvas.SolidCanvas(self._trough_char, sb_width, 1)
thumb = canvas.SolidCanvas(self._thumb_char, sb_width, 1)
bottom = canvas.SolidCanvas(self._trough_char, sb_width, 1)
sb_canv = canvas.CanvasCombine(
(
*((top, None, False) for _ in range(top_height)),
*((thumb, None, False) for _ in range(thumb_height)),
*((bottom, None, False) for _ in range(bottom_height)),
),
)
combinelist = [
(ow_canv, None, True, ow_size[0]), # pylint: disable=possibly-used-before-assignment
(sb_canv, None, False, sb_width),
]
if self._scrollbar_side != SCROLLBAR_LEFT:
return canvas.CanvasJoin(combinelist)
return canvas.CanvasJoin(reversed(combinelist))
@property
def scrollbar_width(self) -> int:
"""Columns the scrollbar uses"""
return max(1, self._scrollbar_width)
@scrollbar_width.setter
def scrollbar_width(self, width: typing.SupportsInt) -> None:
self._scrollbar_width = max(1, int(width))
self._invalidate()
@property
def scrollbar_side(self) -> Literal["left", "right"]:
"""Where to display the scrollbar; must be 'left' or 'right'"""
return self._scrollbar_side
@scrollbar_side.setter
def scrollbar_side(self, side: Literal["left", "right"]) -> None:
if side not in {SCROLLBAR_LEFT, SCROLLBAR_RIGHT}:
raise ValueError(f'scrollbar_side must be "left" or "right", not {side!r}')
self._scrollbar_side = side
self._invalidate()
@property
def scrolling_base_widget(self) -> SupportsScroll | SupportsRelativeScroll:
"""Nearest `original_widget` that is compatible with the scrolling API"""
w = self
for w in orig_iter(self):
if isinstance(w, SupportsScroll):
return w
raise ScrollableError(f"Not compatible to be wrapped by ScrollBar: {w!r}")
def keypress(
self,
size: tuple[int, int], # type: ignore[override]
key: str,
) -> str | None:
return self._original_widget.keypress(self._original_widget_size, key)
def mouse_event(
self,
size: tuple[int, int], # type: ignore[override]
event: str,
button: int,
col: int,
row: int,
focus: bool,
) -> bool | None:
ow = self._original_widget
ow_size = self._original_widget_size
handled: bool | None = False
if hasattr(ow, "mouse_event"):
handled = ow.mouse_event(ow_size, event, button, col, row, focus)
if not handled and hasattr(ow, "set_scrollpos"):
if button == 4: # scroll wheel up
pos = ow.get_scrollpos(ow_size)
newpos = max(pos - 1, 0)
ow.set_scrollpos(newpos)
return True
if button == 5: # scroll wheel down
pos = ow.get_scrollpos(ow_size)
ow.set_scrollpos(pos + 1)
return True
return handled

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from urwid.canvas import SolidCanvas
from .constants import SHADE_SYMBOLS, Sizing
from .widget import Widget
class SolidFill(Widget):
"""
A box widget that fills an area with a single character
"""
_selectable = False
ignore_focus = True
_sizing = frozenset([Sizing.BOX])
Symbols = SHADE_SYMBOLS
def __init__(self, fill_char: str = " ") -> None:
"""
:param fill_char: character to fill area with
:type fill_char: bytes or unicode
>>> SolidFill(u'8')
<SolidFill box widget '8'>
"""
super().__init__()
self.fill_char = fill_char
def _repr_words(self) -> list[str]:
return [*super()._repr_words(), repr(self.fill_char)]
def render(
self,
size: tuple[int, int], # type: ignore[override]
focus: bool = False,
) -> SolidCanvas:
"""
Render the Fill as a canvas and return it.
>>> SolidFill().render((4,2)).text # ... = b in Python 3
[...' ', ...' ']
>>> SolidFill('#').render((5,3)).text
[...'#####', ...'#####', ...'#####']
"""
maxcol, maxrow = size
return SolidCanvas(self.fill_char, maxcol, maxrow)

View File

@@ -0,0 +1,363 @@
from __future__ import annotations
import typing
from urwid import text_layout
from urwid.canvas import apply_text_layout
from urwid.split_repr import remove_defaults
from urwid.str_util import calc_width
from urwid.util import decompose_tagmarkup, get_encoding
from .constants import Align, Sizing, WrapMode
from .widget import Widget, WidgetError
if typing.TYPE_CHECKING:
from collections.abc import Hashable
from typing_extensions import Literal
from urwid.canvas import TextCanvas
class TextError(WidgetError):
pass
class Text(Widget):
"""
a horizontally resizeable text widget
"""
_sizing = frozenset([Sizing.FLOW, Sizing.FIXED])
ignore_focus = True
_repr_content_length_max = 140
def __init__(
self,
markup: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]],
align: Literal["left", "center", "right"] | Align = Align.LEFT,
wrap: Literal["space", "any", "clip", "ellipsis"] | WrapMode = WrapMode.SPACE,
layout: text_layout.TextLayout | None = None,
) -> None:
"""
:param markup: content of text widget, one of:
bytes or unicode
text to be displayed
(*display attribute*, *text markup*)
*text markup* with *display attribute* applied to all parts
of *text markup* with no display attribute already applied
[*text markup*, *text markup*, ... ]
all *text markup* in the list joined together
:type markup: :ref:`text-markup`
:param align: typically ``'left'``, ``'center'`` or ``'right'``
:type align: text alignment mode
:param wrap: typically ``'space'``, ``'any'``, ``'clip'`` or ``'ellipsis'``
:type wrap: text wrapping mode
:param layout: defaults to a shared :class:`StandardTextLayout` instance
:type layout: text layout instance
>>> Text(u"Hello")
<Text fixed/flow widget 'Hello'>
>>> t = Text(('bold', u"stuff"), 'right', 'any')
>>> t
<Text fixed/flow widget 'stuff' align='right' wrap='any'>
>>> print(t.text)
stuff
>>> t.attrib
[('bold', 5)]
"""
super().__init__()
self._cache_maxcol: int | None = None
self.set_text(markup)
self.set_layout(align, wrap, layout)
def _repr_words(self) -> list[str]:
"""
Show the text in the repr in python3 format (b prefix for byte strings) and truncate if it's too long
"""
first = super()._repr_words()
text = self.get_text()[0]
rest = repr(text)
if len(rest) > self._repr_content_length_max:
rest = (
rest[: self._repr_content_length_max * 2 // 3 - 3] + "..." + rest[-self._repr_content_length_max // 3 :]
)
return [*first, rest]
def _repr_attrs(self) -> dict[str, typing.Any]:
attrs = {
**super()._repr_attrs(),
"align": self._align_mode,
"wrap": self._wrap_mode,
}
return remove_defaults(attrs, Text.__init__)
def _invalidate(self) -> None:
self._cache_maxcol = None
super()._invalidate()
def set_text(self, markup: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]]) -> None:
"""
Set content of text widget.
:param markup: see :class:`Text` for description.
:type markup: text markup
>>> t = Text(u"foo")
>>> print(t.text)
foo
>>> t.set_text(u"bar")
>>> print(t.text)
bar
>>> t.text = u"baz" # not supported because text stores text but set_text() takes markup
Traceback (most recent call last):
AttributeError: can't set attribute
"""
self._text, self._attrib = decompose_tagmarkup(markup)
self._invalidate()
def get_text(self) -> tuple[str | bytes, list[tuple[Hashable, int]]]:
"""
:returns: (*text*, *display attributes*)
*text*
complete bytes/unicode content of text widget
*display attributes*
run length encoded display attributes for *text*, eg.
``[('attr1', 10), ('attr2', 5)]``
>>> Text(u"Hello").get_text() # ... = u in Python 2
(...'Hello', [])
>>> Text(('bright', u"Headline")).get_text()
(...'Headline', [('bright', 8)])
>>> Text([('a', u"one"), u"two", ('b', u"three")]).get_text()
(...'onetwothree', [('a', 3), (None, 3), ('b', 5)])
"""
return self._text, self._attrib
@property
def text(self) -> str | bytes:
"""
Read-only property returning the complete bytes/unicode content
of this widget
"""
return self.get_text()[0]
@property
def attrib(self) -> list[tuple[Hashable, int]]:
"""
Read-only property returning the run-length encoded display
attributes of this widget
"""
return self.get_text()[1]
def set_align_mode(self, mode: Literal["left", "center", "right"] | Align) -> None:
"""
Set text alignment mode. Supported modes depend on text layout
object in use but defaults to a :class:`StandardTextLayout` instance
:param mode: typically ``'left'``, ``'center'`` or ``'right'``
:type mode: text alignment mode
>>> t = Text(u"word")
>>> t.set_align_mode('right')
>>> t.align
'right'
>>> t.render((10,)).text # ... = b in Python 3
[...' word']
>>> t.align = 'center'
>>> t.render((10,)).text
[...' word ']
>>> t.align = 'somewhere'
Traceback (most recent call last):
TextError: Alignment mode 'somewhere' not supported.
"""
if not self.layout.supports_align_mode(mode):
raise TextError(f"Alignment mode {mode!r} not supported.")
self._align_mode = mode
self._invalidate()
def set_wrap_mode(self, mode: Literal["space", "any", "clip", "ellipsis"] | WrapMode) -> None:
"""
Set text wrapping mode. Supported modes depend on text layout
object in use but defaults to a :class:`StandardTextLayout` instance
:param mode: typically ``'space'``, ``'any'``, ``'clip'`` or ``'ellipsis'``
:type mode: text wrapping mode
>>> t = Text(u"some words")
>>> t.render((6,)).text # ... = b in Python 3
[...'some ', ...'words ']
>>> t.set_wrap_mode('clip')
>>> t.wrap
'clip'
>>> t.render((6,)).text
[...'some w']
>>> t.wrap = 'any' # Urwid 0.9.9 or later
>>> t.render((6,)).text
[...'some w', ...'ords ']
>>> t.wrap = 'somehow'
Traceback (most recent call last):
TextError: Wrap mode 'somehow' not supported.
"""
if not self.layout.supports_wrap_mode(mode):
raise TextError(f"Wrap mode {mode!r} not supported.")
self._wrap_mode = mode
self._invalidate()
def set_layout(
self,
align: Literal["left", "center", "right"] | Align,
wrap: Literal["space", "any", "clip", "ellipsis"] | WrapMode,
layout: text_layout.TextLayout | None = None,
) -> None:
"""
Set the text layout object, alignment and wrapping modes at
the same time.
:type align: text alignment mode
:param wrap: typically 'space', 'any', 'clip' or 'ellipsis'
:type wrap: text wrapping mode
:param layout: defaults to a shared :class:`StandardTextLayout` instance
:type layout: text layout instance
>>> t = Text(u"hi")
>>> t.set_layout('right', 'clip')
>>> t
<Text fixed/flow widget 'hi' align='right' wrap='clip'>
"""
if layout is None:
layout = text_layout.default_layout
self._layout = layout
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)
@property
def layout(self):
return self._layout
def render(
self,
size: tuple[int] | tuple[()], # type: ignore[override]
focus: bool = False,
) -> TextCanvas:
"""
Render contents with wrapping and alignment. Return canvas.
See :meth:`Widget.render` for parameter details.
>>> Text(u"important things").render((18,)).text
[b'important things ']
>>> Text(u"important things").render((11,)).text
[b'important ', b'things ']
>>> Text("demo text").render(()).text
[b'demo text']
"""
text, attr = self.get_text()
if size:
(maxcol,) = size
else:
maxcol, _ = self.pack(focus=focus)
trans = self.get_line_translation(maxcol, (text, attr))
return apply_text_layout(text, attr, trans, maxcol)
def rows(self, size: tuple[int], focus: bool = False) -> int:
"""
Return the number of rows the rendered text requires.
See :meth:`Widget.rows` for parameter details.
>>> Text(u"important things").rows((18,))
1
>>> Text(u"important things").rows((11,))
2
"""
(maxcol,) = size
return len(self.get_line_translation(maxcol))
def get_line_translation(
self,
maxcol: int,
ta: tuple[str | bytes, list[tuple[Hashable, int]]] | None = None,
) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:
"""
Return layout structure used to map self.text to a canvas.
This method is used internally, but may be useful for debugging custom layout classes.
:param maxcol: columns available for display
:type maxcol: int
:param ta: ``None`` or the (*text*, *display attributes*) tuple
returned from :meth:`.get_text`
:type ta: text and display attributes
"""
if not self._cache_maxcol or self._cache_maxcol != maxcol:
self._update_cache_translation(maxcol, ta)
return self._cache_translation
def _update_cache_translation(
self,
maxcol: int,
ta: tuple[str | bytes, list[tuple[Hashable, int]]] | None,
) -> None:
if ta:
text, _attr = ta
else:
text, _attr = self.get_text()
self._cache_maxcol = maxcol
self._cache_translation = self.layout.layout(text, maxcol, self._align_mode, self._wrap_mode)
def pack(
self,
size: tuple[()] | tuple[int] | None = None, # type: ignore[override]
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
clipping, as a single element tuple.
:param size: ``None`` or ``()`` for unlimited screen columns (like FIXED sizing)
or (*maxcol*,) to specify a maximum column size
:type size: widget size
:param focus: widget is focused on
:type focus: bool
>>> Text(u"important things").pack()
(16, 1)
>>> Text(u"important things").pack((15,))
(9, 2)
>>> Text(u"important things").pack((8,))
(8, 2)
>>> Text(u"important things").pack(())
(16, 1)
"""
text, attr = self.get_text()
if size:
(maxcol,) = size
if not hasattr(self.layout, "pack"):
return maxcol, self.rows(size, focus)
trans = self.get_line_translation(maxcol, (text, attr))
cols = self.layout.pack(maxcol, trans)
return (cols, len(trans))
if text:
if isinstance(text, bytes):
text = text.decode(get_encoding())
return (
max(calc_width(line, 0, len(line)) for line in text.splitlines(keepends=False)),
text.count("\n") + 1,
)
return 0, 1

View File

@@ -0,0 +1,532 @@
# Generic TreeWidget/TreeWalker class
# Copyright (c) 2010 Rob Lanphier
# Copyright (C) 2004-2010 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
"""
Urwid tree view
Features:
- custom selectable widgets for trees
- custom list walker for displaying widgets in a tree fashion
"""
from __future__ import annotations
import typing
from .columns import Columns
from .constants import WHSettings
from .listbox import ListBox, ListWalker
from .padding import Padding
from .text import Text
from .widget import WidgetWrap
from .wimp import SelectableIcon
if typing.TYPE_CHECKING:
from collections.abc import Hashable, Sequence
__all__ = ("ParentNode", "TreeListBox", "TreeNode", "TreeWalker", "TreeWidget", "TreeWidgetError")
class TreeWidgetError(RuntimeError):
pass
class TreeWidget(WidgetWrap[Padding[typing.Union[Text, Columns]]]):
"""A widget representing something in a nested tree display."""
indent_cols = 3
unexpanded_icon = SelectableIcon("+", 0)
expanded_icon = SelectableIcon("-", 0)
def __init__(self, node: TreeNode) -> None:
self._node = node
self._innerwidget: Text | None = None
self.is_leaf = not hasattr(node, "get_first_child")
self.expanded = True
widget = self.get_indented_widget()
super().__init__(widget)
def selectable(self) -> bool:
"""
Allow selection of non-leaf nodes so children may be (un)expanded
"""
return not self.is_leaf
def get_indented_widget(self) -> Padding[Text | Columns]:
widget = self.get_inner_widget()
if not self.is_leaf:
widget = Columns(
[(1, [self.unexpanded_icon, self.expanded_icon][self.expanded]), widget],
dividechars=1,
)
indent_cols = self.get_indent_cols()
return Padding(widget, width=(WHSettings.RELATIVE, 100), left=indent_cols)
def update_expanded_icon(self) -> None:
"""Update display widget text for parent widgets"""
# icon is first element in columns indented widget
icon = [self.unexpanded_icon, self.expanded_icon][self.expanded]
self._w.base_widget.contents[0] = (icon, (WHSettings.GIVEN, 1, False))
def get_indent_cols(self) -> int:
return self.indent_cols * self.get_node().get_depth()
def get_inner_widget(self) -> Text:
if self._innerwidget is None:
self._innerwidget = self.load_inner_widget()
return self._innerwidget
def load_inner_widget(self) -> Text:
return Text(self.get_display_text())
def get_node(self) -> TreeNode:
return self._node
def get_display_text(self) -> str | tuple[Hashable, str] | list[str | tuple[Hashable, str]]:
return f"{self.get_node().get_key()}: {self.get_node().get_value()!s}"
def next_inorder(self) -> TreeWidget | None:
"""Return the next TreeWidget depth first from this one."""
# first check if there's a child widget
first_child = self.first_child()
if first_child is not None:
return first_child
# now we need to hunt for the next sibling
this_node = self.get_node()
next_node = this_node.next_sibling()
depth = this_node.get_depth()
while next_node is None and depth > 0:
# keep going up the tree until we find an ancestor next sibling
this_node = this_node.get_parent()
next_node = this_node.next_sibling()
depth -= 1
if depth != this_node.get_depth():
raise ValueError(depth)
if next_node is None:
# we're at the end of the tree
return None
return next_node.get_widget()
def prev_inorder(self) -> TreeWidget | None:
"""Return the previous TreeWidget depth first from this one."""
this_node = self._node
prev_node = this_node.prev_sibling()
if prev_node is not None:
# we need to find the last child of the previous widget if its
# expanded
prev_widget = prev_node.get_widget()
last_child = prev_widget.last_child()
if last_child is None:
return prev_widget
return last_child
# need to hunt for the parent
depth = this_node.get_depth()
if prev_node is None and depth == 0:
return None
if prev_node is None:
prev_node = this_node.get_parent()
return prev_node.get_widget()
def keypress(
self,
size: tuple[int] | tuple[()],
key: str,
) -> str | None:
"""Handle expand & collapse requests (non-leaf nodes)"""
if self.is_leaf:
return key
if key in {"+", "right"}:
self.expanded = True
self.update_expanded_icon()
return None
if key == "-":
self.expanded = False
self.update_expanded_icon()
return None
if self._w.selectable():
return super().keypress(size, key)
return key
def mouse_event(
self,
size: tuple[int] | tuple[()],
event: str,
button: int,
col: int,
row: int,
focus: bool,
) -> bool:
if self.is_leaf or event != "mouse press" or button != 1:
return False
if row == 0 and col == self.get_indent_cols():
self.expanded = not self.expanded
self.update_expanded_icon()
return True
return False
def first_child(self) -> TreeWidget | None:
"""Return first child if expanded."""
if self.is_leaf or not self.expanded:
return None
if self._node.has_children():
first_node = self._node.get_first_child()
return first_node.get_widget()
return None
def last_child(self) -> TreeWidget | None:
"""Return last child if expanded."""
if self.is_leaf or not self.expanded:
return None
if self._node.has_children():
last_child = self._node.get_last_child().get_widget()
else:
return None
# recursively search down for the last descendant
last_descendant = last_child.last_child()
if last_descendant is None:
return last_child
return last_descendant
class TreeNode:
"""
Store tree contents and cache TreeWidget objects.
A TreeNode consists of the following elements:
* key: accessor token for parent nodes
* value: subclass-specific data
* parent: a TreeNode which contains a pointer back to this object
* widget: The widget used to render the object
"""
def __init__(
self,
value: typing.Any,
parent: ParentNode | None = None,
key: Hashable | None = None,
depth: int | None = None,
) -> None:
self._key = key
self._parent = parent
self._value = value
self._depth = depth
self._widget: TreeWidget | None = None
def get_widget(self, reload: bool = False) -> TreeWidget:
"""Return the widget for this node."""
if self._widget is None or reload:
self._widget = self.load_widget()
return self._widget
def load_widget(self) -> TreeWidget:
return TreeWidget(self)
def get_depth(self) -> int:
if self._depth is self._parent is None:
self._depth = 0
elif self._depth is None:
self._depth = self._parent.get_depth() + 1
return self._depth
def get_index(self) -> int | None:
if self.get_depth() == 0:
return None
return self.get_parent().get_child_index(self.get_key())
def get_key(self) -> Hashable | None:
return self._key
def set_key(self, key: Hashable | None) -> None:
self._key = key
def change_key(self, key: Hashable | None) -> None:
self.get_parent().change_child_key(self._key, key)
def get_parent(self) -> ParentNode:
if self._parent is None and self.get_depth() > 0:
self._parent = self.load_parent()
return self._parent
def load_parent(self):
"""Provide TreeNode with a parent for the current node. This function
is only required if the tree was instantiated from a child node
(virtual function)"""
raise TreeWidgetError("virtual function. Implement in subclass")
def get_value(self):
return self._value
def is_root(self) -> bool:
return self.get_depth() == 0
def next_sibling(self) -> TreeNode | None:
if self.get_depth() > 0:
return self.get_parent().next_child(self.get_key())
return None
def prev_sibling(self) -> TreeNode | None:
if self.get_depth() > 0:
return self.get_parent().prev_child(self.get_key())
return None
def get_root(self) -> ParentNode:
root = self
while root.get_parent() is not None:
root = root.get_parent()
return root
class ParentNode(TreeNode):
"""Maintain sort order for TreeNodes."""
def __init__(
self,
value: typing.Any,
parent: ParentNode | None = None,
key: Hashable = None,
depth: int | None = None,
) -> None:
super().__init__(value, parent=parent, key=key, depth=depth)
self._child_keys: Sequence[Hashable] | None = None
self._children: dict[Hashable, TreeNode] = {}
def get_child_keys(self, reload: bool = False) -> Sequence[Hashable]:
"""Return a possibly ordered list of child keys"""
if self._child_keys is None or reload:
self._child_keys = self.load_child_keys()
return self._child_keys
def load_child_keys(self) -> Sequence[Hashable]:
"""Provide ParentNode with an ordered list of child keys (virtual function)"""
raise TreeWidgetError("virtual function. Implement in subclass")
def get_child_widget(self, key) -> TreeWidget:
"""Return the widget for a given key. Create if necessary."""
return self.get_child_node(key).get_widget()
def get_child_node(self, key, reload: bool = False) -> TreeNode:
"""Return the child node for a given key. Create if necessary."""
if key not in self._children or reload:
self._children[key] = self.load_child_node(key)
return self._children[key]
def load_child_node(self, key: Hashable) -> TreeNode:
"""Load the child node for a given key (virtual function)"""
raise TreeWidgetError("virtual function. Implement in subclass")
def set_child_node(self, key: Hashable, node: TreeNode) -> None:
"""Set the child node for a given key.
Useful for bottom-up, lazy population of a tree.
"""
self._children[key] = node
def change_child_key(self, oldkey: Hashable, newkey: Hashable) -> None:
if newkey in self._children:
raise TreeWidgetError(f"{newkey} is already in use")
self._children[newkey] = self._children.pop(oldkey)
self._children[newkey].set_key(newkey)
def get_child_index(self, key: Hashable) -> int:
try:
return self.get_child_keys().index(key)
except ValueError as exc:
raise TreeWidgetError(
f"Can't find key {key} in ParentNode {self.get_key()}\nParentNode items: {self.get_child_keys()!s}"
).with_traceback(exc.__traceback__) from exc
def next_child(self, key: Hashable) -> TreeNode | None:
"""Return the next child node in index order from the given key."""
index = self.get_child_index(key)
# the given node may have just been deleted
if index is None:
return None
index += 1
child_keys = self.get_child_keys()
if index < len(child_keys):
# get the next item at same level
return self.get_child_node(child_keys[index])
return None
def prev_child(self, key: Hashable) -> TreeNode | None:
"""Return the previous child node in index order from the given key."""
index = self.get_child_index(key)
if index is None:
return None
child_keys = self.get_child_keys()
index -= 1
if index >= 0:
# get the previous item at same level
return self.get_child_node(child_keys[index])
return None
def get_first_child(self) -> TreeNode:
"""Return the first TreeNode in the directory."""
child_keys = self.get_child_keys()
return self.get_child_node(child_keys[0])
def get_last_child(self) -> TreeNode:
"""Return the last TreeNode in the directory."""
child_keys = self.get_child_keys()
return self.get_child_node(child_keys[-1])
def has_children(self) -> bool:
"""Does this node have any children?"""
return len(self.get_child_keys()) > 0
class TreeWalker(ListWalker):
"""ListWalker-compatible class for displaying TreeWidgets
positions are TreeNodes."""
def __init__(self, start_from) -> None:
"""start_from: TreeNode with the initial focus."""
self.focus = start_from
def get_focus(self):
widget = self.focus.get_widget()
return widget, self.focus
def set_focus(self, focus) -> None:
self.focus = focus
self._modified()
# pylint: disable=arguments-renamed # its bad, but we should not change API
def get_next(self, start_from) -> tuple[TreeWidget, TreeNode] | tuple[None, None]:
target = start_from.get_widget().next_inorder()
if target is None:
return None, None
return target, target.get_node()
def get_prev(self, start_from) -> tuple[TreeWidget, TreeNode] | tuple[None, None]:
target = start_from.get_widget().prev_inorder()
if target is None:
return None, None
return target, target.get_node()
# pylint: enable=arguments-renamed
class TreeListBox(ListBox):
"""A ListBox with special handling for navigation and
collapsing of TreeWidgets"""
def keypress(
self,
size: tuple[int, int], # type: ignore[override]
key: str,
) -> str | None:
key: str | None = super().keypress(size, key)
return self.unhandled_input(size, key)
def unhandled_input(self, size: tuple[int, int], data: str) -> str | None:
"""Handle macro-navigation keys"""
if data == "left":
self.move_focus_to_parent(size)
return None
if data == "-":
self.collapse_focus_parent(size)
return None
return data
def collapse_focus_parent(self, size: tuple[int, int]) -> None:
"""Collapse parent directory."""
_widget, pos = self.body.get_focus()
self.move_focus_to_parent(size)
_pwidget, ppos = self.body.get_focus()
if pos != ppos:
self.keypress(size, "-")
def move_focus_to_parent(self, size: tuple[int, int]) -> None:
"""Move focus to parent of widget in focus."""
_widget, pos = self.body.get_focus()
parentpos = pos.get_parent()
if parentpos is None:
return
middle, top, _bottom = self.calculate_visible(size)
row_offset, _focus_widget, _focus_pos, _focus_rows, _cursor = middle # pylint: disable=unpacking-non-sequence
_trim_top, fill_above = top # pylint: disable=unpacking-non-sequence
for _widget, pos, rows in fill_above:
row_offset -= rows
if pos == parentpos:
self.change_focus(size, pos, row_offset)
return
self.change_focus(size, pos.get_parent())
def _keypress_max_left(self, size: tuple[int, int]) -> None:
self.focus_home(size)
def _keypress_max_right(self, size: tuple[int, int]) -> None:
self.focus_end(size)
def focus_home(self, size: tuple[int, int]) -> None:
"""Move focus to very top."""
_widget, pos = self.body.get_focus()
rootnode = pos.get_root()
self.change_focus(size, rootnode)
def focus_end(self, size: tuple[int, int]) -> None:
"""Move focus to far bottom."""
maxrow, _maxcol = size
_widget, pos = self.body.get_focus()
lastwidget = pos.get_root().get_widget().last_child()
if lastwidget:
lastnode = lastwidget.get_node()
self.change_focus(size, lastnode, maxrow - 1)

View File

@@ -0,0 +1,845 @@
# Urwid basic widget classes
# Copyright (C) 2004-2012 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
import functools
import logging
import typing
import warnings
from operator import attrgetter
from urwid import signals
from urwid.canvas import Canvas, CanvasCache, CompositeCanvas
from urwid.command_map import command_map
from urwid.split_repr import split_repr
from urwid.util import MetaSuper
from .constants import Sizing
if typing.TYPE_CHECKING:
from collections.abc import Callable, Hashable
WrappedWidget = typing.TypeVar("WrappedWidget")
LOGGER = logging.getLogger(__name__)
class WidgetMeta(MetaSuper, signals.MetaSignals):
"""
Bases: :class:`MetaSuper`, :class:`MetaSignals`
Automatic caching of render and rows methods.
Class variable *no_cache* is a list of names of methods to not cache
automatically. Valid method names for *no_cache* are ``'render'`` and
``'rows'``.
Class variable *ignore_focus* if defined and set to ``True`` indicates
that the canvas this widget renders is not affected by the focus
parameter, so it may be ignored when caching.
"""
def __init__(cls, name, bases, d):
no_cache = d.get("no_cache", [])
super().__init__(name, bases, d)
if "render" in d:
if "render" not in no_cache:
render_fn = cache_widget_render(cls)
else:
render_fn = nocache_widget_render(cls)
cls.render = render_fn
if "rows" in d and "rows" not in no_cache:
cls.rows = cache_widget_rows(cls)
if "no_cache" in d:
del cls.no_cache
if "ignore_focus" in d:
del cls.ignore_focus
class WidgetError(Exception):
"""Widget specific errors."""
class WidgetWarning(Warning):
"""Widget specific warnings."""
def validate_size(widget, size, canv):
"""
Raise a WidgetError if a canv does not match size.
"""
if (size and size[1:] != (0,) and size[0] != canv.cols()) or (len(size) > 1 and size[1] != canv.rows()):
raise WidgetError(
f"Widget {widget!r} rendered ({canv.cols():d} x {canv.rows():d}) canvas when passed size {size!r}!"
)
def cache_widget_render(cls):
"""
Return a function that wraps the cls.render() method
and fetches and stores canvases with CanvasCache.
"""
ignore_focus = bool(getattr(cls, "ignore_focus", False))
fn = cls.render
@functools.wraps(fn)
def cached_render(self, size, focus=False):
focus = focus and not ignore_focus
canv = CanvasCache.fetch(self, cls, size, focus)
if canv:
return canv
canv = fn(self, size, focus=focus)
validate_size(self, size, canv)
if canv.widget_info:
canv = CompositeCanvas(canv)
canv.finalize(self, size, focus)
CanvasCache.store(cls, canv)
return canv
cached_render.original_fn = fn
return cached_render
def nocache_widget_render(cls):
"""
Return a function that wraps the cls.render() method
and finalizes the canvas that it returns.
"""
fn = cls.render
if hasattr(fn, "original_fn"):
fn = fn.original_fn
@functools.wraps(fn)
def finalize_render(self, size, focus=False):
canv = fn(self, size, focus=focus)
if canv.widget_info:
canv = CompositeCanvas(canv)
validate_size(self, size, canv)
canv.finalize(self, size, focus)
return canv
finalize_render.original_fn = fn
return finalize_render
def nocache_widget_render_instance(self):
"""
Return a function that wraps the cls.render() method
and finalizes the canvas that it returns, but does not
cache the canvas.
"""
fn = self.render.original_fn
@functools.wraps(fn)
def finalize_render(size, focus=False):
canv = fn(self, size, focus=focus)
if canv.widget_info:
canv = CompositeCanvas(canv)
canv.finalize(self, size, focus)
return canv
finalize_render.original_fn = fn
return finalize_render
def cache_widget_rows(cls):
"""
Return a function that wraps the cls.rows() method
and returns rows from the CanvasCache if available.
"""
ignore_focus = bool(getattr(cls, "ignore_focus", False))
fn = cls.rows
@functools.wraps(fn)
def cached_rows(self, size: tuple[int], focus: bool = False) -> int:
focus = focus and not ignore_focus
canv = CanvasCache.fetch(self, cls, size, focus)
if canv:
return canv.rows()
return fn(self, size, focus)
return cached_rows
class Widget(metaclass=WidgetMeta):
"""
Widget base class
.. attribute:: _selectable
:annotation: = False
The default :meth:`.selectable` method returns this value.
.. attribute:: _sizing
:annotation: = frozenset(['flow', 'box', 'fixed'])
The default :meth:`.sizing` method returns this value.
.. attribute:: _command_map
:annotation: = urwid.command_map
A shared :class:`CommandMap` instance. May be redefined in subclasses or widget instances.
.. method:: rows(size, focus=False)
.. note::
This method is not implemented in :class:`.Widget` but
must be implemented by any flow widget. See :meth:`.sizing`.
See :meth:`Widget.render` for parameter details.
:returns: The number of rows required for this widget given a number of columns in *size*
This is the method flow widgets use to communicate their size to other
widgets without having to render a canvas. This should be a quick
calculation as this function may be called a number of times in normal
operation. If your implementation may take a long time you should add
your own caching here.
There is some metaclass magic defined in the :class:`Widget`
metaclass :class:`WidgetMeta` that causes the
result of this function to be retrieved from any
canvas cached by :class:`CanvasCache`, so if your widget
has been rendered you may not receive calls to this function. The class
variable :attr:`ignore_focus` may be defined and set to ``True`` if this
widget renders the same size regardless of the value of the *focus*
parameter.
.. method:: get_cursor_coords(size)
.. note::
This method is not implemented in :class:`.Widget` but
must be implemented by any widget that may return cursor
coordinates as part of the canvas that :meth:`render` returns.
:param size: See :meth:`Widget.render` for details.
:type size: widget size
:returns: (*col*, *row*) if this widget has a cursor, ``None`` otherwise
Return the cursor coordinates (*col*, *row*) of a cursor that will appear
as part of the canvas rendered by this widget when in focus, or ``None``
if no cursor is displayed.
The :class:`ListBox` widget
uses this method to make sure a cursor in the focus widget is not scrolled out of view.
It is a separate method to avoid having to render the whole widget while calculating layout.
Container widgets will typically call the :meth:`.get_cursor_coords` method on their focus widget.
.. method:: get_pref_col(size)
.. note::
This method is not implemented in :class:`.Widget` but may be implemented by a subclass.
:param size: See :meth:`Widget.render` for details.
:type size: widget size
:returns: a column number or ``'left'`` for the leftmost available
column or ``'right'`` for the rightmost available column
Return the preferred column for the cursor to be displayed in this
widget. This value might not be the same as the column returned from
:meth:`get_cursor_coords`.
The :class:`ListBox` and :class:`Pile`
widgets call this method on a widget losing focus and use the value
returned to call :meth:`.move_cursor_to_coords` on the widget becoming
the focus. This allows the focus to move up and down through widgets
while keeping the cursor in approximately the same column on screen.
.. method:: move_cursor_to_coords(size, col, row)
.. note::
This method is not implemented in :class:`.Widget` but may be implemented by a subclass.
Not implementing this method is equivalent to having a method that always returns
``False``.
:param size: See :meth:`Widget.render` for details.
:type size: widget size
:param col: new column for the cursor, 0 is the left edge of this widget
:type col: int
:param row: new row for the cursor, 0 it the top row of this widget
:type row: int
:returns: ``True`` if the position was set successfully anywhere on *row*, ``False`` otherwise
"""
_selectable = False
_sizing = frozenset([Sizing.FLOW, Sizing.BOX, Sizing.FIXED])
_command_map = command_map
def __init__(self) -> None:
self.logger = logging.getLogger(f"{self.__class__.__module__}.{self.__class__.__name__}")
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: Hashable, *args) -> None:
"""Convenience function to emit signals with self as first argument."""
signals.emit_signal(self, name, self, *args)
def selectable(self) -> bool:
"""
:returns: ``True`` if this is a widget that is designed to take the
focus, i.e. it contains something the user might want to
interact with, ``False`` otherwise,
This default implementation returns :attr:`._selectable`.
Subclasses may leave these is if the are not selectable,
or if they are always selectable they may
set the :attr:`_selectable` class variable to ``True``.
If this method returns ``True`` then the :meth:`.keypress` method
must be implemented.
Returning ``False`` does not guarantee that this widget will never be in
focus, only that this widget will usually be skipped over when changing
focus. It is still possible for non selectable widgets to have the focus
(typically when there are no other selectable widgets visible).
"""
return self._selectable
def sizing(self) -> frozenset[Sizing]:
"""
:returns: A frozenset including one or more of ``'box'``, ``'flow'`` and
``'fixed'``. Default implementation returns the value of
:attr:`._sizing`, which for this class includes all three.
The sizing modes returned indicate the modes that may be
supported by this widget, but is not sufficient to know
that using that sizing mode will work. Subclasses should
make an effort to remove sizing modes they know will not
work given the state of the widget, but many do not yet
do this.
If a sizing mode is missing from the set then the widget
should fail when used in that mode.
If ``'flow'`` is among the values returned then the other
methods in this widget must be able to accept a
single-element tuple (*maxcol*,) to their ``size``
parameter, and the :meth:`rows` method must be defined.
If ``'box'`` is among the values returned then the other
methods must be able to accept a two-element tuple
(*maxcol*, *maxrow*) to their size parameter.
If ``'fixed'`` is among the values returned then the other
methods must be able to accept an empty tuple () to
their size parameter, and the :meth:`pack` method must
be defined.
"""
return self._sizing
def pack(self, size: tuple[()] | tuple[int] | tuple[int, int], focus: bool = False) -> tuple[int, int]:
"""
See :meth:`Widget.render` for parameter details.
:returns: A "packed" size (*maxcol*, *maxrow*) for this widget
Calculate and return a minimum
size where all content could still be displayed. Fixed widgets must
implement this method and return their size when ``()`` is passed as the
*size* parameter.
This default implementation returns the *size* passed, or the *maxcol*
passed and the value of :meth:`rows` as the *maxrow* when (*maxcol*,)
is passed as the *size* parameter.
.. note::
This is a new method that hasn't been fully implemented across the
standard widget types. In particular it has not yet been
implemented for container widgets.
:class:`Text` widgets have implemented this method.
You can use :meth:`Text.pack` to calculate the minimum
columns and rows required to display a text widget without wrapping,
or call it iteratively to calculate the minimum number of columns
required to display the text wrapped into a target number of rows.
"""
if not size:
if Sizing.FIXED in self.sizing():
raise NotImplementedError(f"{self!r} must override Widget.pack()")
raise WidgetError(f"Cannot pack () size, this is not a fixed widget: {self!r}")
if len(size) == 1:
if Sizing.FLOW in self.sizing():
return (*size, self.rows(size, focus)) # pylint: disable=no-member # can not announce abstract
raise WidgetError(f"Cannot pack (maxcol,) size, this is not a flow widget: {self!r}")
return size
@property
def base_widget(self) -> Widget:
"""Read-only property that steps through decoration widgets and returns the one at the base.
This default implementation returns self.
"""
return self
@property
def focus(self) -> Widget | None:
"""
Read-only property returning the child widget in focus for container widgets.
This default implementation always returns ``None``, indicating that this widget has no children.
"""
return None
def _not_a_container(self, val=None):
raise IndexError(f"No focus_position, {self!r} is not a container widget")
focus_position = property(
_not_a_container,
_not_a_container,
doc="""
Property for reading and setting the focus position for
container widgets. This default implementation raises
:exc:`IndexError`, making normal widgets fail the same way
accessing :attr:`.focus_position` on an empty container widget would.
""",
)
def __repr__(self):
"""A friendly __repr__ for widgets.
Designed to be extended by subclasses with _repr_words and _repr_attr methods.
"""
return split_repr(self)
def _repr_words(self) -> list[str]:
words = []
if self.selectable():
words = ["selectable", *words]
if self.sizing() and self.sizing() != frozenset([Sizing.FLOW, Sizing.BOX, Sizing.FIXED]):
words.append("/".join(sorted(self.sizing())))
return [*words, "widget"]
def _repr_attrs(self) -> dict[str, typing.Any]:
return {}
def keypress(
self,
size: tuple[()] | tuple[int] | tuple[int, int],
key: str,
) -> str | None:
"""Keyboard input handler.
:param size: See :meth:`Widget.render` for details
:type size: tuple[()] | tuple[int] | tuple[int, int]
:param key: a single keystroke value; see :ref:`keyboard-input`
:type key: str
:return: ``None`` if *key* was handled by *key* (the same value passed) if *key* was not handled
:rtype: str | None
"""
if not self.selectable():
if hasattr(self, "logger"):
self.logger.debug(f"keypress sent to non selectable widget {self!r}")
else:
warnings.warn(
f"Widget {self.__class__.__name__} did not call 'super().__init__()",
WidgetWarning,
stacklevel=3,
)
LOGGER.debug(f"Widget {self!r} is not selectable")
return key
def mouse_event(
self,
size: tuple[()] | tuple[int] | tuple[int, int],
event: str,
button: int,
col: int,
row: int,
focus: bool,
) -> bool | None:
"""Mouse event handler.
:param size: See :meth:`Widget.render` for details.
:type size: tuple[()] | tuple[int] | tuple[int, int]
:param event: Values such as ``'mouse press'``, ``'ctrl mouse press'``,
``'mouse release'``, ``'meta mouse release'``,
``'mouse drag'``; see :ref:`mouse-input`
:type event: str
:param button: 1 through 5 for press events, often 0 for release events
(which button was released is often not known)
:type button: int
:param col: Column of the event, 0 is the left edge of this widget
:type col: int
:param row: Row of the event, 0 it the top row of this widget
:type row: int
:param focus: Set to ``True`` if this widget or one of its children is in focus
:type focus: bool
:return: ``True`` if the event was handled by this widget, ``False`` otherwise
:rtype: bool | None
"""
if not self.selectable():
if hasattr(self, "logger"):
self.logger.debug(f"Widget {self!r} is not selectable")
else:
warnings.warn(
f"Widget {self.__class__.__name__} not called 'super().__init__()",
WidgetWarning,
stacklevel=3,
)
LOGGER.debug(f"Widget {self!r} is not selectable")
return False
def render(
self,
size: tuple[()] | tuple[int] | tuple[int, int],
focus: bool = False,
) -> Canvas:
"""Render widget and produce canvas
:param size: One of the following, *maxcol* and *maxrow* are integers > 0:
(*maxcol*, *maxrow*)
for box sizing -- the parent chooses the exact
size of this widget
(*maxcol*,)
for flow sizing -- the parent chooses only the
number of columns for this widget
()
for fixed sizing -- this widget is a fixed size
which can't be adjusted by the parent
:type size: widget size
:param focus: set to ``True`` if this widget or one of its children is in focus
:type focus: bool
:returns: A :class:`Canvas` subclass instance containing the rendered content of this widget
:class:`Text` widgets return a :class:`TextCanvas` (arbitrary text and display attributes),
:class:`SolidFill` widgets return a :class:`SolidCanvas` (a single character repeated across the whole surface)
and container widgets return a :class:`CompositeCanvas` (one or more other canvases arranged arbitrarily).
If *focus* is ``False``, the returned canvas may not have a cursor position set.
There is some metaclass magic defined in the :class:`Widget` metaclass :class:`WidgetMeta`
that causes the result of this method to be cached by :class:`CanvasCache`.
Later calls will automatically look up the value in the cache first.
As a small optimization the class variable :attr:`ignore_focus`
may be defined and set to ``True`` if this widget renders the same
canvas regardless of the value of the *focus* parameter.
Any time the content of a widget changes it should call
:meth:`_invalidate` to remove any cached canvases, or the widget
may render the cached canvas instead of creating a new one.
"""
raise NotImplementedError
class FlowWidget(Widget):
"""
Deprecated. Inherit from Widget and add:
_sizing = frozenset(['flow'])
at the top of your class definition instead.
Base class of widgets that determine their rows from the number of
columns available.
"""
_sizing = frozenset([Sizing.FLOW])
def __init__(self, *args, **kwargs):
warnings.warn(
"""
FlowWidget is deprecated. Inherit from Widget and add:
_sizing = frozenset(['flow'])
at the top of your class definition instead.""",
DeprecationWarning,
stacklevel=3,
)
super().__init__()
def rows(self, size: tuple[int], focus: bool = False) -> int:
"""
All flow widgets must implement this function.
"""
raise NotImplementedError()
def render(self, size: tuple[int], focus: bool = False) -> Canvas: # type: ignore[override]
"""
All widgets must implement this function.
"""
raise NotImplementedError()
class BoxWidget(Widget):
"""
Deprecated. Inherit from Widget and add:
_sizing = frozenset(['box'])
_selectable = True
at the top of your class definition instead.
Base class of width and height constrained widgets such as
the top level widget attached to the display object
"""
_selectable = True
_sizing = frozenset([Sizing.BOX])
def __init__(self, *args, **kwargs):
warnings.warn(
"""
BoxWidget is deprecated. Inherit from Widget and add:
_sizing = frozenset(['box'])
_selectable = True
at the top of your class definition instead.""",
DeprecationWarning,
stacklevel=3,
)
super().__init__()
def render(self, size: tuple[int, int], focus: bool = False) -> Canvas: # type: ignore[override]
"""
All widgets must implement this function.
"""
raise NotImplementedError()
def fixed_size(size: tuple[()]) -> None:
"""
raise ValueError if size != ().
Used by FixedWidgets to test size parameter.
"""
if size != ():
raise ValueError(f"FixedWidget takes only () for size.passed: {size!r}")
class FixedWidget(Widget):
"""
Deprecated. Inherit from Widget and add:
_sizing = frozenset(['fixed'])
at the top of your class definition instead.
Base class of widgets that know their width and height and
cannot be resized
"""
_sizing = frozenset([Sizing.FIXED])
def __init__(self, *args, **kwargs):
warnings.warn(
"""
FixedWidget is deprecated. Inherit from Widget and add:
_sizing = frozenset(['fixed'])
at the top of your class definition instead.""",
DeprecationWarning,
stacklevel=3,
)
super().__init__()
def render(self, size: tuple[()], focus: bool = False) -> Canvas: # type: ignore[override]
"""
All widgets must implement this function.
"""
raise NotImplementedError()
def pack(self, size: tuple[()] = (), focus: bool = False) -> tuple[int, int]: # type: ignore[override]
"""
All fixed widgets must implement this function.
"""
raise NotImplementedError()
def delegate_to_widget_mixin(attribute_name: str) -> type[Widget]:
"""
Return a mixin class that delegates all standard widget methods
to an attribute given by attribute_name.
This mixin is designed to be used as a superclass of another widget.
"""
# FIXME: this is so common, let's add proper support for it
# when layout and rendering are separated
get_delegate = attrgetter(attribute_name)
class DelegateToWidgetMixin(Widget):
no_cache: typing.ClassVar[list[str]] = ["rows"] # crufty metaclass work-around
def render(self, size, focus: bool = False) -> CompositeCanvas:
canv = get_delegate(self).render(size, focus=focus)
return CompositeCanvas(canv)
@property
def selectable(self) -> Callable[[], bool]:
return get_delegate(self).selectable
@property
def get_cursor_coords(self) -> Callable[[tuple[()] | tuple[int] | tuple[int, int]], tuple[int, int] | None]:
# TODO(Aleksei): Get rid of property usage after getting rid of "if getattr"
return get_delegate(self).get_cursor_coords
@property
def get_pref_col(self) -> Callable[[tuple[()] | tuple[int] | tuple[int, int]], int | None]:
# TODO(Aleksei): Get rid of property usage after getting rid of "if getattr"
return get_delegate(self).get_pref_col
def keypress(self, size: tuple[()] | tuple[int] | tuple[int, int], key: str) -> str | None:
return get_delegate(self).keypress(size, key)
@property
def move_cursor_to_coords(self) -> Callable[[[tuple[()] | tuple[int] | tuple[int, int], int, int]], bool]:
# TODO(Aleksei): Get rid of property usage after getting rid of "if getattr"
return get_delegate(self).move_cursor_to_coords
@property
def rows(self) -> Callable[[tuple[int], bool], int]:
return get_delegate(self).rows
@property
def mouse_event(
self,
) -> Callable[[tuple[()] | tuple[int] | tuple[int, int], str, int, int, int, bool], bool | None]:
# TODO(Aleksei): Get rid of property usage after getting rid of "if getattr"
return get_delegate(self).mouse_event
@property
def sizing(self) -> Callable[[], frozenset[Sizing]]:
return get_delegate(self).sizing
@property
def pack(self) -> Callable[[tuple[()] | tuple[int] | tuple[int, int], bool], tuple[int, int]]:
return get_delegate(self).pack
return DelegateToWidgetMixin
class WidgetWrapError(Exception):
pass
class WidgetWrap(delegate_to_widget_mixin("_wrapped_widget"), typing.Generic[WrappedWidget]):
def __init__(self, w: WrappedWidget) -> None:
"""
w -- widget to wrap, stored as self._w
This object will pass the functions defined in Widget interface
definition to self._w.
The purpose of this widget is to provide a base class for
widgets that compose other widgets for their display and
behaviour. The details of that composition should not affect
users of the subclass. The subclass may decide to expose some
of the wrapped widgets by behaving like a ContainerWidget or
WidgetDecoration, or it may hide them from outside access.
"""
super().__init__()
if not isinstance(w, Widget):
obj_class_path = f"{w.__class__.__module__}.{w.__class__.__name__}"
warnings.warn(
f"{obj_class_path} is not subclass of Widget",
DeprecationWarning,
stacklevel=2,
)
self._wrapped_widget = w
@property
def _w(self) -> WrappedWidget:
return self._wrapped_widget
@_w.setter
def _w(self, new_widget: WrappedWidget) -> None:
"""
Change the wrapped widget. This is meant to be called
only by subclasses.
>>> size = (10,)
>>> ww = WidgetWrap(Edit("hello? ","hi"))
>>> ww.render(size).text # ... = b in Python 3
[...'hello? hi ']
>>> ww.selectable()
True
>>> ww._w = Text("goodbye") # calls _set_w()
>>> ww.render(size).text
[...'goodbye ']
>>> ww.selectable()
False
"""
self._wrapped_widget = new_widget
self._invalidate()
def _set_w(self, w: WrappedWidget) -> None:
"""
Change the wrapped widget. This is meant to be called
only by subclasses.
>>> from urwid import Edit, Text
>>> size = (10,)
>>> ww = WidgetWrap(Edit("hello? ","hi"))
>>> ww.render(size).text # ... = b in Python 3
[...'hello? hi ']
>>> ww.selectable()
True
>>> ww._w = Text("goodbye") # calls _set_w()
>>> ww.render(size).text
[...'goodbye ']
>>> ww.selectable()
False
"""
warnings.warn(
"_set_w is deprecated. Please use 'WidgetWrap._w' property directly",
DeprecationWarning,
stacklevel=2,
)
self._wrapped_widget = w
self._invalidate()
def _test():
import doctest
doctest.testmod()
if __name__ == "__main__":
_test()

View File

@@ -0,0 +1,170 @@
from __future__ import annotations
import typing
import warnings
from urwid.canvas import CompositeCanvas
from .widget import Widget, WidgetError, WidgetWarning, delegate_to_widget_mixin
if typing.TYPE_CHECKING:
from typing_extensions import Literal
from .constants import Sizing
__all__ = (
"WidgetDecoration",
"WidgetDisable",
"WidgetError",
"WidgetPlaceholder",
"WidgetWarning",
"delegate_to_widget_mixin",
)
WrappedWidget = typing.TypeVar("WrappedWidget")
class WidgetDecoration(Widget, typing.Generic[WrappedWidget]): # pylint: disable=abstract-method
"""
original_widget -- the widget being decorated
This is a base class for decoration widgets,
widgets that contain one or more widgets and only ever have a single focus.
This type of widget will affect the display or behaviour of the original_widget,
but it is not part of determining a chain of focus.
Don't actually do this -- use a WidgetDecoration subclass instead, these are not real widgets:
>>> from urwid import Text
>>> WidgetDecoration(Text(u"hi"))
<WidgetDecoration fixed/flow widget <Text fixed/flow widget 'hi'>>
.. Warning:
WidgetDecoration do not implement ``render`` method.
Implement it or forward to the widget in the subclass.
"""
def __init__(self, original_widget: WrappedWidget) -> None:
# TODO(Aleksei): reduce amount of multiple inheritance usage
# Special case: subclasses with multiple inheritance causes `super` call wrong way
# Call parent __init__ explicit
Widget.__init__(self)
if not isinstance(original_widget, Widget):
obj_class_path = f"{original_widget.__class__.__module__}.{original_widget.__class__.__name__}"
warnings.warn(
f"{obj_class_path} is not subclass of Widget",
DeprecationWarning,
stacklevel=2,
)
self._original_widget = original_widget
def _repr_words(self) -> list[str]:
return [*super()._repr_words(), repr(self._original_widget)]
@property
def original_widget(self) -> WrappedWidget:
return self._original_widget
@original_widget.setter
def original_widget(self, original_widget: WrappedWidget) -> None:
self._original_widget = original_widget
self._invalidate()
def _get_original_widget(self) -> WrappedWidget:
warnings.warn(
f"Method `{self.__class__.__name__}._get_original_widget` is deprecated, "
f"please use property `{self.__class__.__name__}.original_widget`",
DeprecationWarning,
stacklevel=2,
)
return self.original_widget
def _set_original_widget(self, original_widget: WrappedWidget) -> None:
warnings.warn(
f"Method `{self.__class__.__name__}._set_original_widget` is deprecated, "
f"please use property `{self.__class__.__name__}.original_widget`",
DeprecationWarning,
stacklevel=2,
)
self.original_widget = original_widget
@property
def base_widget(self) -> Widget:
"""
Return the widget without decorations. If there is only one
Decoration then this is the same as original_widget.
>>> from urwid import Text
>>> t = Text('hello')
>>> wd1 = WidgetDecoration(t)
>>> wd2 = WidgetDecoration(wd1)
>>> wd3 = WidgetDecoration(wd2)
>>> wd3.original_widget is wd2
True
>>> wd3.base_widget is t
True
"""
visited = {self}
w = self
while hasattr(w, "_original_widget"):
w = w._original_widget
if w in visited:
break
visited.add(w)
return w
def _get_base_widget(self) -> Widget:
warnings.warn(
f"Method `{self.__class__.__name__}._get_base_widget` is deprecated, "
f"please use property `{self.__class__.__name__}.base_widget`",
DeprecationWarning,
stacklevel=2,
)
return self.base_widget
def selectable(self) -> bool:
return self._original_widget.selectable()
def sizing(self) -> frozenset[Sizing]:
return self._original_widget.sizing()
class WidgetPlaceholder(delegate_to_widget_mixin("_original_widget"), WidgetDecoration[WrappedWidget]):
"""
This is a do-nothing decoration widget that can be used for swapping
between widgets without modifying the container of this widget.
This can be useful for making an interface with a number of distinct
pages or for showing and hiding menu or status bars.
The widget displayed is stored as the self.original_widget property and
can be changed by assigning a new widget to it.
"""
class WidgetDisable(WidgetDecoration[WrappedWidget]):
"""
A decoration widget that disables interaction with the widget it
wraps. This widget always passes focus=False to the wrapped widget,
even if it somehow does become the focus.
"""
no_cache: typing.ClassVar[list[str]] = ["rows"]
ignore_focus = True
def selectable(self) -> Literal[False]:
return False
def rows(self, size: tuple[int], focus: bool = False) -> int:
return self._original_widget.rows(size, False)
def sizing(self) -> frozenset[Sizing]:
return self._original_widget.sizing()
def pack(self, size, focus: bool = False) -> tuple[int, int]:
return self._original_widget.pack(size, False)
def render(self, size, focus: bool = False) -> CompositeCanvas:
canv = self._original_widget.render(size, False)
return CompositeCanvas(canv)

View File

@@ -0,0 +1,796 @@
# Urwid Window-Icon-Menu-Pointer-style widget classes
# Copyright (C) 2004-2011 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
import typing
from urwid.canvas import CompositeCanvas
from urwid.command_map import Command
from urwid.signals import connect_signal
from urwid.text_layout import calc_coords
from urwid.util import is_mouse_press
from .columns import Columns
from .constants import Align, WrapMode
from .text import Text
from .widget import WidgetError, WidgetWrap
if typing.TYPE_CHECKING:
from collections.abc import Callable, Hashable, MutableSequence
from typing_extensions import Literal, Self
from urwid.canvas import TextCanvas
from urwid.text_layout import TextLayout
_T = typing.TypeVar("_T")
class SelectableIcon(Text):
ignore_focus = False
_selectable = True
def __init__(
self,
text: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]],
cursor_position: int = 0,
align: Literal["left", "center", "right"] | Align = Align.LEFT,
wrap: Literal["space", "any", "clip", "ellipsis"] | WrapMode = WrapMode.SPACE,
layout: TextLayout | None = None,
) -> None:
"""
:param text: markup for this widget; see :class:`Text` for
description of text markup
:param cursor_position: position the cursor will appear in the
text when this widget is in focus
:param align: typically ``'left'``, ``'center'`` or ``'right'``
:type align: text alignment mode
:param wrap: typically ``'space'``, ``'any'``, ``'clip'`` or ``'ellipsis'``
:type wrap: text wrapping mode
:param layout: defaults to a shared :class:`StandardTextLayout` instance
:type layout: text layout instance
This is a text widget that is selectable. A cursor
displayed at a fixed location in the text when in focus.
This widget has no special handling of keyboard or mouse input.
"""
super().__init__(text, align=align, wrap=wrap, layout=layout)
self._cursor_position = cursor_position
def render( # type: ignore[override]
self,
size: tuple[int] | tuple[()], # type: ignore[override]
focus: bool = False,
) -> TextCanvas | CompositeCanvas: # type: ignore[override]
"""
Render the text content of this widget with a cursor when
in focus.
>>> si = SelectableIcon(u"[!]")
>>> si
<SelectableIcon selectable fixed/flow widget '[!]'>
>>> si.render((4,), focus=True).cursor
(0, 0)
>>> si = SelectableIcon("((*))", 2)
>>> si.render((8,), focus=True).cursor
(2, 0)
>>> si.render((2,), focus=True).cursor
(0, 1)
>>> si.render(()).cursor
>>> si.render(()).text
[b'((*))']
>>> si.render((), focus=True).cursor
(2, 0)
"""
c: TextCanvas | CompositeCanvas = super().render(size, focus)
if focus:
# create a new canvas so we can add a cursor
c = CompositeCanvas(c)
c.cursor = self.get_cursor_coords(size)
return c
def get_cursor_coords(self, size: tuple[int] | tuple[()]) -> tuple[int, int] | None:
"""
Return the position of the cursor if visible. This method
is required for widgets that display a cursor.
"""
if self._cursor_position > len(self.text):
return None
# find out where the cursor will be displayed based on
# the text layout
if size:
(maxcol,) = size
else:
maxcol, _ = self.pack()
trans = self.get_line_translation(maxcol)
x, y = calc_coords(self.text, trans, self._cursor_position)
if maxcol <= x:
return None
return x, y
def keypress(
self,
size: tuple[int] | tuple[()], # type: ignore[override]
key: str,
) -> str:
"""
No keys are handled by this widget. This method is
required for selectable widgets.
"""
return key
class CheckBoxError(WidgetError):
pass
class CheckBox(WidgetWrap[Columns]):
states: typing.ClassVar[dict[bool | Literal["mixed"], SelectableIcon]] = {
True: SelectableIcon("[X]", 1),
False: SelectableIcon("[ ]", 1),
"mixed": SelectableIcon("[#]", 1),
}
reserve_columns = 4
# allow users of this class to listen for change events
# sent when the state of this widget is modified
# (this variable is picked up by the MetaSignals metaclass)
signals: typing.ClassVar[list[str]] = ["change", "postchange"]
@typing.overload
def __init__(
self,
label: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]],
state: bool = False,
has_mixed: typing.Literal[False] = False,
on_state_change: Callable[[Self, bool, _T], typing.Any] | None = None,
user_data: _T = ...,
checked_symbol: str | None = ...,
) -> None: ...
@typing.overload
def __init__(
self,
label: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]],
state: bool = False,
has_mixed: typing.Literal[False] = False,
on_state_change: Callable[[Self, bool], typing.Any] | None = None,
user_data: None = None,
checked_symbol: str | None = ...,
) -> None: ...
@typing.overload
def __init__(
self,
label: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]],
state: typing.Literal["mixed"] | bool = False,
has_mixed: typing.Literal[True] = True,
on_state_change: Callable[[Self, bool | typing.Literal["mixed"], _T], typing.Any] | None = None,
user_data: _T = ...,
checked_symbol: str | None = ...,
) -> None: ...
@typing.overload
def __init__(
self,
label: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]],
state: typing.Literal["mixed"] | bool = False,
has_mixed: typing.Literal[True] = True,
on_state_change: Callable[[Self, bool | typing.Literal["mixed"]], typing.Any] | None = None,
user_data: None = None,
checked_symbol: str | None = ...,
) -> None: ...
def __init__(
self,
label: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]],
state: bool | Literal["mixed"] = False,
has_mixed: typing.Literal[False, True] = False, # MyPy issue: Literal[True, False] is not equal `bool`
on_state_change: (
Callable[[Self, bool, _T], typing.Any]
| Callable[[Self, bool], typing.Any]
| Callable[[Self, bool | typing.Literal["mixed"], _T], typing.Any]
| Callable[[Self, bool | typing.Literal["mixed"]], typing.Any]
| None
) = None,
user_data: _T | None = None,
checked_symbol: str | None = None,
):
"""
:param label: markup for check box label
:param state: False, True or "mixed"
:param has_mixed: True if "mixed" is a state to cycle through
:param on_state_change: shorthand for connect_signal()
function call for a single callback
:param user_data: user_data for on_state_change
..note:: `pack` method expect, that `Columns` backend widget is not modified from outside
Signals supported: ``'change'``, ``"postchange"``
Register signal handler with::
urwid.connect_signal(check_box, 'change', callback, user_data)
where callback is callback(check_box, new_state [,user_data])
Unregister signal handlers with::
urwid.disconnect_signal(check_box, 'change', callback, user_data)
>>> CheckBox("Confirm")
<CheckBox selectable fixed/flow widget 'Confirm' state=False>
>>> CheckBox("Yogourt", "mixed", True)
<CheckBox selectable fixed/flow widget 'Yogourt' state='mixed'>
>>> cb = CheckBox("Extra onions", True)
>>> cb
<CheckBox selectable fixed/flow widget 'Extra onions' state=True>
>>> cb.render((20,), focus=True).text
[b'[X] Extra onions ']
>>> CheckBox("Test", None)
Traceback (most recent call last):
...
ValueError: None not in (True, False, 'mixed')
"""
if state not in self.states:
raise ValueError(f"{state!r} not in {tuple(self.states.keys())}")
self._label = Text(label)
self.has_mixed = has_mixed
self._state = state
if checked_symbol:
self.states[True] = SelectableIcon(f"[{checked_symbol}]", 1)
# The old way of listening for a change was to pass the callback
# in to the constructor. Just convert it to the new way:
if on_state_change:
connect_signal(self, "change", on_state_change, user_data)
# Initial create expect no callbacks call, create explicit
super().__init__(
Columns(
[(self.reserve_columns, self.states[state]), self._label],
focus_column=0,
),
)
def pack(self, size: tuple[()] | tuple[int] | None = None, focus: bool = False) -> tuple[str, str]:
"""Pack for widget.
:param size: size data. Special case: None - get minimal widget size to fit
:param focus: widget is focused
>>> cb = CheckBox("test")
>>> cb.pack((10,))
(10, 1)
>>> cb.pack()
(8, 1)
>>> ml_cb = CheckBox("Multi\\nline\\ncheckbox")
>>> ml_cb.pack()
(12, 3)
>>> ml_cb.pack((), True)
(12, 3)
"""
return super().pack(size or (), focus)
def _repr_words(self) -> list[str]:
return [*super()._repr_words(), repr(self.label)]
def _repr_attrs(self) -> dict[str, typing.Any]:
return {**super()._repr_attrs(), "state": self.state}
def set_label(self, label: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]]):
"""
Change the check box label.
label -- markup for label. See Text widget for description
of text markup.
>>> cb = CheckBox(u"foo")
>>> cb
<CheckBox selectable fixed/flow widget 'foo' state=False>
>>> cb.set_label(('bright_attr', u"bar"))
>>> cb
<CheckBox selectable fixed/flow widget 'bar' state=False>
"""
self._label.set_text(label)
# no need to call self._invalidate(). WidgetWrap takes care of
# that when self.w changes
def get_label(self):
"""
Return label text.
>>> cb = CheckBox(u"Seriously")
>>> print(cb.get_label())
Seriously
>>> print(cb.label)
Seriously
>>> cb.set_label([('bright_attr', u"flashy"), u" normal"])
>>> print(cb.label) # only text is returned
flashy normal
"""
return self._label.text
label = property(get_label)
def set_state(
self,
state: bool | Literal["mixed"],
do_callback: bool = True,
) -> None:
"""
Set the CheckBox state.
state -- True, False or "mixed"
do_callback -- False to suppress signal from this change
>>> from urwid import disconnect_signal
>>> changes = []
>>> def callback_a(user_data, cb, state):
... changes.append("A %r %r" % (state, user_data))
>>> def callback_b(cb, state):
... changes.append("B %r" % state)
>>> cb = CheckBox('test', False, False)
>>> key1 = connect_signal(cb, 'change', callback_a, user_args=("user_a",))
>>> key2 = connect_signal(cb, 'change', callback_b)
>>> cb.set_state(True) # both callbacks will be triggered
>>> cb.state
True
>>> disconnect_signal(cb, 'change', callback_a, user_args=("user_a",))
>>> cb.state = False
>>> cb.state
False
>>> cb.set_state(True)
>>> cb.state
True
>>> cb.set_state(False, False) # don't send signal
>>> changes
["A True 'user_a'", 'B True', 'B False', 'B True']
"""
if self._state == state:
return
if state not in self.states:
raise CheckBoxError(f"{self!r} Invalid state: {state!r}")
# self._state is None is a special case when the CheckBox
# has just been created
old_state = self._state
if do_callback:
self._emit("change", state)
self._state = state
# rebuild the display widget with the new state
self._w = Columns([(self.reserve_columns, self.states[state]), self._label], focus_column=0)
if do_callback:
self._emit("postchange", old_state)
def get_state(self) -> bool | Literal["mixed"]:
"""Return the state of the checkbox."""
return self._state
state = property(get_state, set_state)
def keypress(self, size: tuple[int], key: str) -> str | None:
"""
Toggle state on 'activate' command.
>>> assert CheckBox._command_map[' '] == 'activate'
>>> assert CheckBox._command_map['enter'] == 'activate'
>>> size = (10,)
>>> cb = CheckBox('press me')
>>> cb.state
False
>>> cb.keypress(size, ' ')
>>> cb.state
True
>>> cb.keypress(size, ' ')
>>> cb.state
False
"""
if self._command_map[key] != Command.ACTIVATE:
return key
self.toggle_state()
return None
def toggle_state(self) -> None:
"""
Cycle to the next valid state.
>>> cb = CheckBox("3-state", has_mixed=True)
>>> cb.state
False
>>> cb.toggle_state()
>>> cb.state
True
>>> cb.toggle_state()
>>> cb.state
'mixed'
>>> cb.toggle_state()
>>> cb.state
False
"""
if self.state is False:
self.set_state(True)
elif self.state is True:
if self.has_mixed:
self.set_state("mixed")
else:
self.set_state(False)
elif self.state == "mixed":
self.set_state(False)
def mouse_event(self, size: tuple[int], event: str, button: int, x: int, y: int, focus: bool) -> bool:
"""
Toggle state on button 1 press.
>>> size = (20,)
>>> cb = CheckBox("clickme")
>>> cb.state
False
>>> cb.mouse_event(size, 'mouse press', 1, 2, 0, True)
True
>>> cb.state
True
"""
if button != 1 or not is_mouse_press(event):
return False
self.toggle_state()
return True
class RadioButton(CheckBox):
states: typing.ClassVar[dict[bool | Literal["mixed"], SelectableIcon]] = {
True: SelectableIcon("(X)", 1),
False: SelectableIcon("( )", 1),
"mixed": SelectableIcon("(#)", 1),
}
reserve_columns = 4
@typing.overload
def __init__(
self,
group: MutableSequence[RadioButton],
label: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]],
state: bool | Literal["first True"] = ...,
on_state_change: Callable[[Self, bool, _T], typing.Any] | None = None,
user_data: _T = ...,
) -> None: ...
@typing.overload
def __init__(
self,
group: MutableSequence[RadioButton],
label: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]],
state: bool | Literal["first True"] = ...,
on_state_change: Callable[[Self, bool], typing.Any] | None = None,
user_data: None = None,
) -> None: ...
def __init__(
self,
group: MutableSequence[RadioButton],
label: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]],
state: bool | Literal["first True"] = "first True",
on_state_change: Callable[[Self, bool, _T], typing.Any] | Callable[[Self, bool], typing.Any] | None = None,
user_data: _T | None = None,
) -> None:
"""
:param group: list for radio buttons in same group
:param label: markup for radio button label
:param state: False, True, "mixed" or "first True"
:param on_state_change: shorthand for connect_signal()
function call for a single 'change' callback
:param user_data: user_data for on_state_change
..note:: `pack` method expect, that `Columns` backend widget is not modified from outside
This function will append the new radio button to group.
"first True" will set to True if group is empty.
Signals supported: ``'change'``, ``"postchange"``
Register signal handler with::
urwid.connect_signal(radio_button, 'change', callback, user_data)
where callback is callback(radio_button, new_state [,user_data])
Unregister signal handlers with::
urwid.disconnect_signal(radio_button, 'change', callback, user_data)
>>> bgroup = [] # button group
>>> b1 = RadioButton(bgroup, u"Agree")
>>> b2 = RadioButton(bgroup, u"Disagree")
>>> len(bgroup)
2
>>> b1
<RadioButton selectable fixed/flow widget 'Agree' state=True>
>>> b2
<RadioButton selectable fixed/flow widget 'Disagree' state=False>
>>> b2.render((15,), focus=True).text # ... = b in Python 3
[...'( ) Disagree ']
"""
if state == "first True":
state = not group
self.group = group
super().__init__(label, state, False, on_state_change, user_data) # type: ignore[call-overload]
group.append(self)
def set_state(self, state: bool | Literal["mixed"], do_callback: bool = True) -> None:
"""
Set the RadioButton state.
state -- True, False or "mixed"
do_callback -- False to suppress signal from this change
If state is True all other radio buttons in the same button
group will be set to False.
>>> bgroup = [] # button group
>>> b1 = RadioButton(bgroup, u"Agree")
>>> b2 = RadioButton(bgroup, u"Disagree")
>>> b3 = RadioButton(bgroup, u"Unsure")
>>> b1.state, b2.state, b3.state
(True, False, False)
>>> b2.set_state(True)
>>> b1.state, b2.state, b3.state
(False, True, False)
>>> def relabel_button(radio_button, new_state):
... radio_button.set_label(u"Think Harder!")
>>> key = connect_signal(b3, 'change', relabel_button)
>>> b3
<RadioButton selectable fixed/flow widget 'Unsure' state=False>
>>> b3.set_state(True) # this will trigger the callback
>>> b3
<RadioButton selectable fixed/flow widget 'Think Harder!' state=True>
"""
if self._state == state:
return
super().set_state(state, do_callback)
# if we're clearing the state we don't have to worry about
# other buttons in the button group
if state is not True:
return
# clear the state of each other radio button
for cb in self.group:
if cb is self:
continue
if cb.state:
cb.state = False
def toggle_state(self) -> None:
"""
Set state to True.
>>> bgroup = [] # button group
>>> b1 = RadioButton(bgroup, "Agree")
>>> b2 = RadioButton(bgroup, "Disagree")
>>> b1.state, b2.state
(True, False)
>>> b2.toggle_state()
>>> b1.state, b2.state
(False, True)
>>> b2.toggle_state()
>>> b1.state, b2.state
(False, True)
"""
self.set_state(True)
class Button(WidgetWrap[Columns]):
button_left = Text("<")
button_right = Text(">")
signals: typing.ClassVar[list[str]] = ["click"]
@typing.overload
def __init__(
self,
label: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]],
on_press: Callable[[Self, _T], typing.Any] | None = None,
user_data: _T = ...,
*,
align: Literal["left", "center", "right"] | Align = ...,
wrap: Literal["space", "any", "clip", "ellipsis"] | WrapMode = ...,
layout: TextLayout | None = ...,
) -> None: ...
@typing.overload
def __init__(
self,
label: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]],
on_press: Callable[[Self], typing.Any] | None = None,
user_data: None = None,
*,
align: Literal["left", "center", "right"] | Align = ...,
wrap: Literal["space", "any", "clip", "ellipsis"] | WrapMode = ...,
layout: TextLayout | None = ...,
) -> None: ...
def __init__(
self,
label: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]],
on_press: Callable[[Self, _T], typing.Any] | Callable[[Self], typing.Any] | None = None,
user_data: _T | None = None,
*,
align: Literal["left", "center", "right"] | Align = Align.LEFT,
wrap: Literal["space", "any", "clip", "ellipsis"] | WrapMode = WrapMode.SPACE,
layout: TextLayout | None = None,
) -> None:
"""
:param label: markup for button label
:param on_press: shorthand for connect_signal()
function call for a single callback
:param user_data: user_data for on_press
:param align: typically ``'left'``, ``'center'`` or ``'right'``
:type align: label alignment mode
:param wrap: typically ``'space'``, ``'any'``, ``'clip'`` or ``'ellipsis'``
:type wrap: label wrapping mode
:param layout: defaults to a shared :class:`StandardTextLayout` instance
:type layout: text layout instance
..note:: `pack` method expect, that `Columns` backend widget is not modified from outside
Signals supported: ``'click'``
Register signal handler with::
urwid.connect_signal(button, 'click', callback, user_data)
where callback is callback(button [,user_data])
Unregister signal handlers with::
urwid.disconnect_signal(button, 'click', callback, user_data)
>>> from urwid.util import set_temporary_encoding
>>> Button(u"Ok")
<Button selectable fixed/flow widget 'Ok'>
>>> b = Button("Cancel")
>>> b.render((15,), focus=True).text # ... = b in Python 3
[b'< Cancel >']
>>> aligned_button = Button("Test", align=Align.CENTER)
>>> aligned_button.render((10,), focus=True).text
[b'< Test >']
>>> wrapped_button = Button("Long label", wrap=WrapMode.ELLIPSIS)
>>> with set_temporary_encoding("utf-8"):
... wrapped_button.render((7,), focus=False).text[0].decode('utf-8')
'< Lo… >'
"""
self._label = SelectableIcon(label, 0, align=align, wrap=wrap, layout=layout)
cols = Columns(
[(1, self.button_left), self._label, (1, self.button_right)],
dividechars=1,
)
super().__init__(cols)
# The old way of listening for a change was to pass the callback
# in to the constructor. Just convert it to the new way:
if on_press:
connect_signal(self, "click", on_press, user_data)
def pack(self, size: tuple[()] | tuple[int] | None = None, focus: bool = False) -> tuple[int, int]:
"""Pack for widget.
:param size: size data. Special case: None - get minimal widget size to fit
:param focus: widget is focused
>>> btn = Button("Some button")
>>> btn.pack((10,))
(10, 2)
>>> btn.pack()
(15, 1)
>>> btn.pack((), True)
(15, 1)
"""
return super().pack(size or (), focus)
def _repr_words(self) -> list[str]:
# include button.label in repr(button)
return [*super()._repr_words(), repr(self.label)]
def set_label(self, label: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]]) -> None:
"""
Change the button label.
label -- markup for button label
>>> b = Button("Ok")
>>> b.set_label(u"Yup yup")
>>> b
<Button selectable fixed/flow widget 'Yup yup'>
"""
self._label.set_text(label)
def get_label(self) -> str | bytes:
"""
Return label text.
>>> b = Button(u"Ok")
>>> print(b.get_label())
Ok
>>> print(b.label)
Ok
"""
return self._label.text
label = property(get_label)
def keypress(self, size: tuple[int], key: str) -> str | None:
"""
Send 'click' signal on 'activate' command.
>>> assert Button._command_map[' '] == 'activate'
>>> assert Button._command_map['enter'] == 'activate'
>>> size = (15,)
>>> b = Button(u"Cancel")
>>> clicked_buttons = []
>>> def handle_click(button):
... clicked_buttons.append(button.label)
>>> key = connect_signal(b, 'click', handle_click)
>>> b.keypress(size, 'enter')
>>> b.keypress(size, ' ')
>>> clicked_buttons # ... = u in Python 2
[...'Cancel', ...'Cancel']
"""
if self._command_map[key] != Command.ACTIVATE:
return key
self._emit("click")
return None
def mouse_event(self, size: tuple[int], event: str, button: int, x: int, y: int, focus: bool) -> bool:
"""
Send 'click' signal on button 1 press.
>>> size = (15,)
>>> b = Button(u"Ok")
>>> clicked_buttons = []
>>> def handle_click(button):
... clicked_buttons.append(button.label)
>>> key = connect_signal(b, 'click', handle_click)
>>> b.mouse_event(size, 'mouse press', 1, 4, 0, True)
True
>>> b.mouse_event(size, 'mouse press', 2, 4, 0, True) # ignored
False
>>> clicked_buttons # ... = u in Python 2
[...'Ok']
"""
if button != 1 or not is_mouse_press(event):
return False
self._emit("click")
return True
def _test():
import doctest
doctest.testmod()
if __name__ == "__main__":
_test()