370 lines
12 KiB
Python
370 lines
12 KiB
Python
|
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
|
||
|
|
||
|
if typing.TYPE_CHECKING:
|
||
|
from typing_extensions import Literal
|
||
|
|
||
|
from .widget import Widget
|
||
|
|
||
|
|
||
|
class FillerError(Exception):
|
||
|
pass
|
||
|
|
||
|
|
||
|
class Filler(WidgetDecoration):
|
||
|
def __init__(
|
||
|
self,
|
||
|
body: Widget,
|
||
|
valign: (
|
||
|
Literal["top", "middle", "bottom"] | VAlign | tuple[Literal["relative", WHSettings.RELATIVE], int]
|
||
|
) = VAlign.MIDDLE,
|
||
|
height: int | Literal["pack"] | tuple[Literal["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 ``'flow'`` and
|
||
|
*min_height* will be ignored.
|
||
|
|
||
|
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]
|
||
|
valign = VAlign.TOP
|
||
|
elif valign[0] == "fixed bottom":
|
||
|
bottom = valign[1]
|
||
|
valign = VAlign.BOTTOM
|
||
|
|
||
|
# 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(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):
|
||
|
return {Sizing.BOX} # always a box widget
|
||
|
|
||
|
def _repr_attrs(self):
|
||
|
attrs = dict(
|
||
|
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):
|
||
|
"""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):
|
||
|
warnings.warn(
|
||
|
"backwards compatibility, widget used to be stored as body",
|
||
|
PendingDeprecationWarning,
|
||
|
stacklevel=2,
|
||
|
)
|
||
|
self.original_widget = new_body
|
||
|
|
||
|
def get_body(self):
|
||
|
"""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):
|
||
|
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], 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) = size
|
||
|
|
||
|
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], focus: bool = False) -> CompositeCanvas:
|
||
|
"""Render self.original_widget with space above and/or below."""
|
||
|
(maxcol, maxrow) = size
|
||
|
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], key: str) -> str | None:
|
||
|
"""Pass keypress to self.original_widget."""
|
||
|
(maxcol, maxrow) = size
|
||
|
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, int] | None:
|
||
|
"""Return cursor coords from self.original_widget if any."""
|
||
|
(maxcol, maxrow) = size
|
||
|
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]) -> int:
|
||
|
"""Return pref_col from self.original_widget if any."""
|
||
|
(maxcol, maxrow) = size
|
||
|
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], col: int, row: int) -> bool:
|
||
|
"""Pass to self.original_widget."""
|
||
|
(maxcol, maxrow) = size
|
||
|
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],
|
||
|
event,
|
||
|
button: int,
|
||
|
col: int,
|
||
|
row: int,
|
||
|
focus: bool,
|
||
|
) -> bool:
|
||
|
"""Pass to self.original_widget."""
|
||
|
(maxcol, maxrow) = size
|
||
|
if not hasattr(self._original_widget, "mouse_event"):
|
||
|
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
|
||
|
|
||
|
standard_alignments = {VAlign.TOP: 0, VAlign.MIDDLE: 50, VAlign.BOTTOM: 100}
|
||
|
valign = standard_alignments.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
|