subiquity/subiquitycore/ui/utils.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

384 lines
10 KiB
Python
Raw Normal View History

# Copyright 2015 Canonical, Ltd.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
""" UI utilities """
import logging
from functools import partialmethod
from urwid import ACTIVATE, AttrMap, CompositeCanvas, LineBox
from urwid import Padding as _Padding
2018-06-25 22:05:37 +00:00
from urwid import SelectableIcon, Text, WidgetDecoration, WidgetDisable, connect_signal
2023-07-25 21:26:25 +00:00
from subiquitycore.ui.buttons import cancel_btn, other_btn
from subiquitycore.ui.container import ListBox, Pile, WidgetWrap
from subiquitycore.ui.spinner import Spinner
from subiquitycore.ui.stretchy import Stretchy
2018-06-26 03:11:42 +00:00
from subiquitycore.ui.table import TableRow
2018-06-25 22:12:09 +00:00
from subiquitycore.ui.width import widget_width
log = logging.getLogger("subiquitycore.ui.utils")
def apply_padders(cls):
"""Decorator for generating useful padding methods
Loops through and generates methods like:
Padding.push_1(Widget)
Sets the left padding attribute by 1
Padding.pull_24(Widget)
Sets right padding attribute by 24.
Padding.center_50(Widget)
Provides center padding with a relative width of 50
"""
padding_count = 100
for i in range(1, padding_count):
setattr(cls, "push_{}".format(i), partialmethod(_Padding, left=i))
setattr(cls, "pull_{}".format(i), partialmethod(_Padding, right=i))
setattr(
cls,
"fixed_{}".format(i),
partialmethod(_Padding, align="center", width=i, min_width=i),
)
setattr(
cls,
"center_{}".format(i),
partialmethod(_Padding, align="center", width=("relative", i)),
)
setattr(
cls,
"left_{}".format(i),
partialmethod(_Padding, align="left", width=("relative", i)),
)
setattr(
cls,
"right_{}".format(i),
partialmethod(_Padding, align="right", width=("relative", i)),
)
return cls
@apply_padders
class Padding:
"""Padding methods
.. py:meth:: push_X(:class:`urwid.Widget`)
This method supports padding the left side of the widget
from 1-99, for example:
.. code::
Padding.push_20(Text("This will be indented 20 columns")
.. py:meth:: pull_X(:class:`urwid.Widget`)
This method supports padding the right side of the widget
from 1-99, for example:
.. code::
Padding.pull_20(Text("This will be right indented 20 columns")
.. py:meth:: fixed_X(:class:`urwid.Widget`)
This method supports padding the widget to a fixed size and
centering it.
from 1-99, for example:
.. code::
Padding.fixed_20(Text("This will be centered and fixed sized
of 20 columns"))
.. py:meth:: center_X(:class:`urwid.Widget`)
This method centers a widget with X being the relative width of
the widget.
.. code::
Padding.center_10(Text("This will be centered with a "
"width of 10 columns"))
.. py:meth:: left_X(:class:`urwid.Widget`)
This method aligns a widget left with X being the relative width of
the widget.
.. code::
Padding.left_10(Text("This will be left aligned with a "
"width of 10 columns"))
.. py:meth:: right_X(:class:`urwid.Widget`)
This method right aligns a widget with X being the relative width of
the widget.
.. code::
Padding.right_10(Text("This will be right aligned with a "
"width of 10 columns"))
"""
2023-07-25 21:26:25 +00:00
line_break = partialmethod(Text)
# This makes assumptions about the style names defined by both
# subiquity and console_conf. The fix is to stop using the Color class
# below, I think.
STYLE_NAMES = set(
[
"body",
2018-06-20 04:15:58 +00:00
"danger_button focus",
"danger_button",
"done_button focus",
"done_button",
"frame_button focus",
"frame_button",
2018-06-20 04:15:58 +00:00
"frame_header",
"frame_header_fringe",
2018-06-20 04:15:58 +00:00
"info_error",
"info_minor",
2018-06-20 04:15:58 +00:00
"info_primary",
"menu_button focus",
"menu_button",
"other_button focus",
"other_button",
"progress_complete",
2018-06-20 04:15:58 +00:00
"progress_incomplete",
"scrollbar focus",
"scrollbar",
2018-06-20 04:15:58 +00:00
"string_input focus",
"string_input",
"user_code",
]
)
def apply_style_map(cls):
"""Applies AttrMap attributes to Color class
Eg:
Color.frame_header(Text("I'm text in the Orange frame header"))
Color.body(Text("Im text in wrapped with the body color"))
"""
for k in STYLE_NAMES:
kf = k + " focus"
if kf in STYLE_NAMES:
2017-09-07 10:47:56 +00:00
setattr(cls, k, partialmethod(AttrMap, attr_map=k, focus_map=kf))
else:
2017-09-07 10:47:56 +00:00
setattr(cls, k, partialmethod(AttrMap, attr_map=k))
return cls
@apply_style_map
class Color:
"""Partial methods for :class:`~subiquity.palette.STYLES`
.. py:meth:: frame_header(:class:`urwid.Widget`)
This method colors widget based on the style map used.
.. code::
Color.frame_header(Text("This will use foreground and background "
"defined from the STYLES attribute"))
"""
2023-07-25 21:26:25 +00:00
pass
_disable_everything_map = {k: "info_minor" for k in STYLE_NAMES | set([None])}
def disabled(w):
return WidgetDisable(AttrMap(w, _disable_everything_map))
def undisabled(w):
if isinstance(w, WidgetDisable):
w = w.original_widget
if isinstance(w, AttrMap):
w = w.original_widget
return w
def button_pile(buttons):
2018-06-25 22:12:09 +00:00
width = 14
for button in buttons:
2018-06-25 22:12:09 +00:00
width = max(widget_width(button), width)
return _Padding(Pile(buttons), min_width=width, width=width, align="center")
2019-04-29 02:57:12 +00:00
def screen(rows, buttons=None, focus_buttons=True, excerpt=None, narrow_rows=False):
"""Helper to create a common screen layout.
The commonest screen layout in subiquity is:
[ 1 line padding (optional) ]
excerpt (optional)
[ 1 line padding ]
2018-05-30 22:28:29 +00:00
Box widget (usually a ListBox)
[ 1 line padding ]
a button_pile
[ 1 line padding ]
This helper makes creating this a 1-liner.
"""
if isinstance(rows, list):
rows = ListBox(rows)
if narrow_rows:
rows = Padding.center_63(rows)
if buttons is None:
focus_buttons = False
elif isinstance(buttons, list):
buttons = button_pile(buttons)
excerpt_rows = []
if excerpt is not None:
excerpt_rows = [
("pack", Text(excerpt)),
("pack", Text("")),
]
body = [
rows,
("pack", Text("")),
]
if buttons is not None:
body.extend(
[
("pack", buttons),
("pack", Text("")),
]
)
pile = Pile(excerpt_rows + body)
if focus_buttons:
pile.focus_position = len(excerpt_rows) + 2
return Padding.center_79(pile, min_width=76)
2018-06-25 22:05:37 +00:00
class CursorOverride(WidgetDecoration):
"""Decoration to override where the cursor goes when a widget is focused."""
has_original_width = True
def __init__(self, w, cursor_x=0):
super().__init__(w)
self.cursor_x = cursor_x
def get_cursor_coords(self, size):
return self.cursor_x, 0
def rows(self, size, focus):
return self._original_widget.rows(size, focus)
def keypress(self, size, focus):
return self._original_widget.keypress(size, focus)
def render(self, size, focus=False):
c = self._original_widget.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
class ClickableIcon(SelectableIcon):
"""Like Button, but simpler."""
2023-07-25 21:26:25 +00:00
signals = ["click"]
def keypress(self, size, key):
if self._command_map[key] != ACTIVATE:
return key
self._emit("click")
2018-06-26 03:11:42 +00:00
def make_action_menu_row(
cells, menu, attr_map="menu_button", focus_map="menu_button focus", cursor_x=2
):
2018-07-06 01:57:55 +00:00
row = TableRow(cells)
2018-06-26 03:11:42 +00:00
if not isinstance(attr_map, dict):
attr_map = {None: attr_map}
if not isinstance(focus_map, dict):
focus_map = {None: focus_map}
am = AttrMap(CursorOverride(row, cursor_x=cursor_x), attr_map, focus_map)
connect_signal(menu, "open", lambda menu: am.set_attr_map(focus_map))
connect_signal(menu, "close", lambda menu: am.set_attr_map(attr_map))
return am
2019-11-04 00:32:19 +00:00
def rewrap(text):
paras = text.split("\n\n")
return "\n\n".join([p.replace("\n", " ") for p in paras]).strip()
class SomethingFailed(Stretchy):
def __init__(self, parent, msg, stderr, *, btn_label: str = _("Close")) -> None:
self.parent = parent
ok = other_btn(label=btn_label, on_press=self.close)
widgets = [
Text(msg),
Text(""),
Text(stderr.strip("\n")),
Text(""),
button_pile([ok]),
]
super().__init__("", widgets, 2, 4)
def close(self, sender):
self.parent.remove_overlay()
class LoadingDialog(WidgetWrap):
def __init__(self, parent, msg, task_to_cancel):
self.parent = parent
self.spinner = Spinner(style="dots")
self.spinner.start()
self.closed = False
# | text |
# 12 34
self.width = len(msg) + 4
widgets = [
("pack", Text(" " + msg)),
("pack", self.spinner),
]
if task_to_cancel is not None:
self.task_to_cancel = task_to_cancel
cancel = cancel_btn(label=_("Cancel"), on_press=self.close)
widgets.append(("pack", button_pile([cancel])))
super().__init__(LineBox(Pile(widgets)))
def close(self, sender=None):
if self.closed:
return
if sender is not None:
self.task_to_cancel.cancel()
self.closed = True
self.spinner.stop()
self.parent.remove_overlay()