615 lines
21 KiB
Python
615 lines
21 KiB
Python
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"
|