Automated update
This commit is contained in:
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
1028
qutebrowser/venv/lib/python3.11/site-packages/urwid/widget/pile.py
Normal file
1028
qutebrowser/venv/lib/python3.11/site-packages/urwid/widget/pile.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user