subiquity/subiquitycore/ui/form.py

575 lines
16 KiB
Python

# Copyright 2017 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/>.
import abc
import logging
from urllib.parse import urlparse
from urwid import CheckBox, MetaSignals
from urwid import Padding as UrwidPadding
from urwid import (
RadioButton,
Text,
WidgetDecoration,
connect_signal,
delegate_to_widget_mixin,
emit_signal,
)
from subiquitycore.ui.buttons import cancel_btn, done_btn
from subiquitycore.ui.container import Pile, WidgetWrap
from subiquitycore.ui.interactive import (
EmailEditor,
IntegerEditor,
PasswordEditor,
StringEditor,
)
from subiquitycore.ui.selector import Selector
from subiquitycore.ui.table import ColSpec, TablePile, TableRow
from subiquitycore.ui.utils import Color, button_pile, disabled, screen
from subiquitycore.ui.width import widget_width
log = logging.getLogger("subiquitycore.ui.form")
# Passing NO_CAPTION as the caption of a field supresses the caption
# entirely so the field occupies the full width of the form.
NO_CAPTION = object()
# Passing NO_HELP as the help of a field supresses the gap under a
# field where the help would go. This means there is nowhere to put
# validation failures, so don't use this on fields that have any
# validation at all.
NO_HELP = object()
class Toggleable(delegate_to_widget_mixin("_original_widget"), WidgetDecoration):
has_original_width = True
def __init__(self, original):
self.original = original
self._enabled = False
self.enabled = True
@property
def enabled(self):
return self._enabled
@enabled.setter
def enabled(self, val):
if val and not self._enabled:
self.original_widget = self.original
elif not val and self._enabled:
self.original_widget = disabled(self.original)
self._enabled = val
class _Validator(WidgetWrap):
def __init__(self, field, w):
self.field = field
super().__init__(w)
def get_natural_width(self):
return widget_width(self._w)
def lost_focus(self):
self.field.showing_extra = False
lf = getattr(self._w.base_widget, "lost_focus", None)
if lf is not None:
lf()
self.field.validate()
class WantsToKnowFormField(object):
"""A marker class."""
def set_bound_form_field(self, bff):
self.bff = bff
form_colspecs = {1: ColSpec(pack=False)}
class BoundFormField(object):
def __init__(self, field, form, widget):
self.field = field
self.form = form
self.widget = widget
self.in_error = False
self._enabled = True
self._help = None
self.showing_extra = False
self._build_table()
if "change" in getattr(widget, "signals", []):
connect_signal(widget, "change", self._change)
if isinstance(widget, WantsToKnowFormField):
widget.set_bound_form_field(self)
def is_in_error(self) -> bool:
"""Tells whether this field is in error."""
return self.in_error
def _build_table(self):
widget = self.widget
if self.field.takes_default_style:
widget = Color.string_input(widget)
if self.help is not NO_HELP:
self.under_text = Text(self.help)
else:
self.under_text = Text("")
if self.field.caption is NO_CAPTION:
first_row = [(2, _Validator(self, widget))]
second_row = [(2, self.under_text)]
else:
self.caption_text = Text(_(self.field.caption))
if self.field.caption_first:
self.caption_text.align = "right"
first_row = [self.caption_text, _Validator(self, widget)]
else:
first_row = [
_Validator(
self,
UrwidPadding(widget, align="right", width=widget_width(widget)),
),
self.caption_text,
]
second_row = [Text(""), self.under_text]
rows = [first_row]
if self.help is not NO_HELP:
rows.append(second_row)
self._rows = [Toggleable(TableRow(row)) for row in rows]
self._table = TablePile(self._rows, spacing=2, colspecs=form_colspecs)
def clean(self, value):
cleaner = getattr(self.form, "clean_" + self.field.name, None)
if cleaner is not None:
value = cleaner(value)
return value
def _change(self, sender, new_val):
if self.in_error or self.showing_extra:
self.showing_extra = False
# the validator will likely inspect self.value to decide
# if the new input is valid. So self.value had better
# return the new value and we stuff it into tmpval to do
# this. It's a bit of a hack but oh well...
self.tmpval = new_val
r = self._validate()
del self.tmpval
if r is not None:
return
self.in_error = False
if not self.showing_extra and self.help is not NO_HELP:
self.under_text.set_text(self.help)
self.form.validated()
def _validate(self):
if not self._enabled:
return
try:
self.value
except ValueError as e:
return str(e)
validator = getattr(self.form, "validate_" + self.field.name, None)
if validator is not None:
return validator()
def validate(self, show_error=True):
# cleaning/validation can call show_extra to add an
# informative message. We record this by having show_extra to
# set showing_extra so we don't immediately replace this
# message with the widget's help in the case that validation
# succeeds.
r = self._validate()
if r is None:
self.in_error = False
if not self.showing_extra and self.help is not NO_HELP:
self.under_text.set_text(self.help)
else:
self.in_error = True
if show_error:
self.show_extra(("info_error", r))
self.form.validated()
def show_extra(self, extra_markup):
self.showing_extra = True
self.under_text.set_text(extra_markup)
@property
def value(self):
return self.clean(getattr(self, "tmpval", self.widget.value))
@value.setter
def value(self, val):
self.widget.value = val
@property
def help(self):
if self._help is not None:
return self._help
elif self.field.help is not None:
if isinstance(self.field.help, str):
return ("info_minor", _(self.field.help))
else:
return self.field.help
else:
return ""
@help.setter
def help(self, val):
if val is None:
val = ""
self._help = val
self.under_text.set_text(val)
@property
def caption(self):
return self.caption_text.text
@caption.setter
def caption(self, val):
self.caption_text.set_text(val)
@property
def enabled(self):
return self._enabled
@enabled.setter
def enabled(self, val):
self._enabled = val
for row in self._rows:
row.enabled = val
def use_as_confirmation(self, for_field: "BoundFormField", desc: str) -> None:
"""Mark this field as a confirmation field for another field.
This will automatically compare the value of both fields when this
field (a.k.a., the confirmation field) is changed."""
def _check_confirmation(sender, new_text):
if not for_field.value.startswith(new_text):
self.show_extra(("info_error", _(f"{desc} do not match")))
else:
self.show_extra("")
connect_signal(self.widget, "change", _check_confirmation)
class BoundSubFormField(BoundFormField):
def is_in_error(self):
"""Tells whether this field is in error. We will also check if the
subform (if enabled) reports an error.
"""
if super().is_in_error():
return True
if not self._enabled:
return False
return self.widget.form.has_validation_error()
class FormField(abc.ABC):
next_index = 0
takes_default_style = True
caption_first = True
bound_field_class = BoundFormField
def __init__(self, caption=None, help=None):
self.caption = caption
# Allows styling at instantiation
if isinstance(help, str):
self.help = ("info_minor", help)
else:
self.help = help
self.index = FormField.next_index
FormField.next_index += 1
@abc.abstractmethod
def _make_widget(self, form):
pass
def bind(self, form):
widget = self._make_widget(form)
return self.bound_field_class(self, form, widget)
def simple_field(widget_maker):
class Field(FormField):
def _make_widget(self, form):
return widget_maker()
return Field
StringField = simple_field(StringEditor)
PasswordField = simple_field(PasswordEditor)
IntegerField = simple_field(IntegerEditor)
EmailField = simple_field(EmailEditor)
class RadioButtonEditor(RadioButton):
reserve_columns = 3
@property
def value(self):
return self.state
@value.setter
def value(self, val):
self.state = val
class RadioButtonField(FormField):
caption_first = False
takes_default_style = False
def __init__(self, group, caption=None, help=None):
if group is None:
group = []
group.append(self)
self.group = group
super().__init__(caption, help)
def _make_widget(self, form):
for bf in form._fields:
if bf.field in self.group:
group = bf.widget.group
break
else:
group = []
return RadioButtonEditor(group, "")
class URLEditor(StringEditor, WantsToKnowFormField):
def __init__(self, allowed_schemes=frozenset(["http", "https"])):
self.allowed_schemes = allowed_schemes
super().__init__()
@StringEditor.value.getter
def value(self):
v = self.get_edit_text()
if v == "":
return v
parsed = urlparse(v)
if parsed.scheme not in self.allowed_schemes:
schemes = []
for s in sorted(self.allowed_schemes):
schemes.append(s)
if len(schemes) > 2:
schemes = ", ".join(schemes[:-1]) + _(", or ") + schemes[-1]
elif len(schemes) == 2:
schemes = schemes[0] + _(" or ") + schemes[1]
else:
schemes = schemes[0]
raise ValueError(
_("This field must be a {schemes} URL.").format(schemes=schemes)
)
return v
URLField = simple_field(URLEditor)
class ChoiceField(FormField):
takes_default_style = False
def __init__(self, caption=None, help=None, choices=[]):
super().__init__(caption, help)
self.choices = choices
def _make_widget(self, form):
return Selector(self.choices)
class ReadOnlyWidget(Text):
@property
def value(self):
return self.text
@value.setter
def value(self, val):
self.set_text(val)
class ReadOnlyField(FormField):
takes_default_style = False
def _make_widget(self, form):
return ReadOnlyWidget("")
class CheckBoxEditor(CheckBox):
reserve_columns = 3
@property
def value(self):
return self.state
@value.setter
def value(self, val):
self.state = val
class BooleanField(FormField):
caption_first = False
takes_default_style = False
def _make_widget(self, form):
return CheckBoxEditor("")
class MetaForm(MetaSignals):
def __init__(self, name, bases, attrs):
super().__init__(name, bases, attrs)
_unbound_fields = []
for k, v in attrs.items():
if isinstance(v, FormField):
v.name = k
if v.caption is None:
v.caption = k + ":"
_unbound_fields.append(v)
_unbound_fields.sort(key=lambda f: f.index)
self._unbound_fields = _unbound_fields
class Form(object, metaclass=MetaForm):
signals = ["submit", "cancel"]
ok_label = _("Done")
cancel_label = _("Cancel")
def __init__(self, initial={}):
self.done_btn = Toggleable(
done_btn(_(self.ok_label), on_press=self._click_done)
)
self.cancel_btn = Toggleable(
cancel_btn(_(self.cancel_label), on_press=self._click_cancel)
)
self.buttons = button_pile([self.done_btn, self.cancel_btn])
self._fields = []
for field in self._unbound_fields:
bf = field.bind(self)
setattr(self, bf.field.name, bf)
self._fields.append(bf)
if field.name in initial:
bf.value = initial[field.name]
for bf in self._fields:
bf.validate(show_error=False)
self.validated()
def enter_data(self, data):
for bf in self._fields:
if bf.field.name in data:
bf.field.value = data[bf.field.name]
def _click_done(self, sender):
emit_signal(self, "submit", self)
def _click_cancel(self, sender):
emit_signal(self, "cancel", self)
def remove_field(self, field_name):
new_fields = []
for bf in self._fields:
if bf.field.name != field_name:
new_fields.append(bf)
self._fields[:] = new_fields
def as_rows(self):
if len(self._fields) == 0:
return []
t0 = self._fields[0]._table
rows = [t0]
for field in self._fields[1:]:
rows.append(Text(""))
t = field._table
t0.bind(t)
rows.append(t)
return rows
def as_screen(self, focus_buttons=True, excerpt=None, narrow_rows=False):
return screen(
self.as_rows(),
self.buttons,
focus_buttons=focus_buttons,
excerpt=excerpt,
narrow_rows=narrow_rows,
)
def has_validation_error(self) -> bool:
"""Tells if any field is in error."""
return any(map(lambda f: f.is_in_error(), self._fields))
def validated(self):
if self.has_validation_error():
self.buttons.base_widget.contents[0][0].enabled = False
self.buttons.base_widget.focus_position = 1
else:
self.buttons.base_widget.contents[0][0].enabled = True
def as_data(self):
data = {}
for field in self._fields:
if field.enabled:
data[field.field.name] = field.value
return data
class SubFormWidget(WidgetWrap):
def __init__(self, form):
self.form = form
super().__init__(Pile(form.as_rows()))
@property
def value(self):
return self.form.as_data()
@value.setter
def value(self, data):
for k, v in data.items():
getattr(self.form, k).value = v
class SubFormField(FormField):
takes_default_style = False
bound_field_class = BoundSubFormField
def __init__(self, form_cls, caption=None, help=None):
super().__init__(caption=caption, help=help)
self.form_cls = form_cls
def _make_widget(self, form):
form = self.form_cls(form)
return SubFormWidget(form)
class SubForm(Form):
def __init__(self, parent, **kw):
self.parent = parent
super().__init__(**kw)
def validated(self):
"""Propagate the validation to the parent."""
self.parent.validated()
super().validated()