class Fidget(QWidget, Generic[T], TemplateLike[T]): """ A QWidget that can contain a value, parsed form its children widgets. """ on_change: pyqtSignal # region inherit_me """ How do I inherit Fidget? * MAKE_TITLE, MAKE_INDICATOR, MAKE_PLAINTEXT, set these for true or false to implement default values. * __init__: Call super().__init__ and all's good. Don't fill validation_func or auto_func here, instead re-implement validate. At the end of your __init__, call init_ui **only if your class isn't going to be subclassed**. * init_ui: initialize all the widgets here. call super().init_ui. If you intend your class to be subclassed, don't add any widgets to self. if you want to add the provided widgets (see below), always do it in an if clause, since all provided widgets can be None. connect all widgets that change the outcome of parse to self's change_value slot. Provided Widgets: * title_label: a label that only contains the title of the widget. If help is provided, the label displays the help when clicked. * validation_label: a label that reads OK or ERR, depending on whether the value is parsed/valid. * plaintext_button: a button that allows raw plaintext reading and writing of the value. * auto_button: a button to automatically fill the widget's value according to external widgets. * validate: call super().validate(value) (it will call validate_func, if provided). You can raise ValidationError if the value is invalid. * parse: implement, convert the data on the widgets to a value, or raise ParseError. * plaintext_printers: yield from super().plaintext_printer, and yield whatever printers you want. * plaintext_parsers: yield from super().plaintext_parsers (empty by default), and yield whatever parsers you want. * NOTE: you can also just wrap class function with InnerParser / InnerPrinter * fill: optional, set the widget's values based on a value """ MAKE_TITLE: bool = None MAKE_INDICATOR: bool = None MAKE_PLAINTEXT: bool = None FLAGS = Qt.WindowFlags() def __new__(cls, *args, **kwargs): ret = super().__new__(cls, *args, **kwargs) ret.__new_args = (args, kwargs) return ret def __init__(self, title, *args, validation_func: Callable[[T], None] = None, auto_func: Callable[[], T] = None, make_title: bool = None, make_indicator: bool = None, make_plaintext: bool = None, help: str = None, **kwargs): """ :param title: the title of the Fidget :param args: additional arguments forwarded to QWidget :param validation_func: a validation callable, that will raise ValidationError if the parsed value is invalid :param auto_func: a function that returns an automatic value, to fill in the UI :param make_title: whether to create a title widget :param make_indicator: whether to make an indicator widget :param make_plaintext: whether to make a plaintext_edit widget :param help: a help string to describe the widget :param kwargs: additional arguments forwarded to QWidget :inheritors: don't set default values for these parameters, change the uppercase class variables instead. """ if kwargs.get('flags', None) is None: kwargs['flags'] = self.FLAGS if 'flags' in kwargs and __backend__.wrapper == QtWrapper.PYSIDE: kwargs['f'] = kwargs.pop('flags') try: super().__init__(*args, **kwargs) except (TypeError, AttributeError): print(f'args: {args}, kwargs: {kwargs}') raise self.title = title self.help = help self.make_title = first_valid(make_title=make_title, MAKE_TITLE=self.MAKE_TITLE, _self=self) self.make_indicator = first_valid(make_indicator=make_indicator, MAKE_INDICATOR=self.MAKE_INDICATOR, _self=self) self.make_plaintext = first_valid(make_plaintext=make_plaintext, MAKE_PLAINTEXT=self.MAKE_PLAINTEXT, _self=self) self.indicator_label: Optional[QLabel] = None self.auto_button: Optional[QPushButton] = None self.plaintext_button: Optional[QPushButton] = None self.title_label: Optional[QLabel] = None self._plaintext_widget: Optional[PlaintextEditWidget[T]] = None self.validation_func = validation_func self.auto_func = optional_valid(auto_func=auto_func, AUTO_FUNC=self.AUTO_FUNC, _self=self) self._suppress_update = False self._value: FidgetValue[T] = None self._joined_plaintext_printer = None self._joined_plaintext_parser = None self._plaintext_printer_delegates: List[Callable[[], Iterable[PlaintextPrinter[T]]]] = [] self._plaintext_parser_delegates: List[Callable[[], Iterable[PlaintextParser[T]]]] = [] if self.auto_func: if self.fill is None: raise Exception('auto_func can only be used on a Fidget with an implemented fill method') else: self.make_auto = True else: self.make_auto = False def init_ui(self) -> Optional[QBoxLayout]: """ initialise the internal widgets of the Fidget :inheritors: If you intend your class to be subclassed, don't add any widgets to self. """ self.setWindowTitle(self.title) if self.make_indicator: self.indicator_label = QLabel('') self.indicator_label.setTextInteractionFlags(Qt.LinksAccessibleByMouse) self.indicator_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) self.indicator_label.linkActivated.connect(self._detail_button_clicked) if self.make_auto: self.auto_button = QPushButton('auto') self.auto_button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) self.auto_button.clicked.connect(self._auto_btn_click) if self.make_plaintext: self.plaintext_button = QPushButton('text') self.plaintext_button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) self.plaintext_button.clicked.connect(self._plaintext_btn_click) self._plaintext_widget = PlaintextEditWidget(parent=self) if self.make_title: self.title_label = QLabel(self.title) self.title_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) if self.help: self.title_label.setTextInteractionFlags(Qt.LinksAccessibleByMouse) self.title_label.linkActivated.connect(self._help_clicked) # implement this method to allow the widget to be filled from outer elements (like plaintext or auto) # note that this function shouldn't be called form outside!, only call fill_value fill: Optional[Callable[[Fidget[T], T], None]] = None AUTO_FUNC: Optional[Callable[[Fidget[T]], T]] = None @abstractmethod def parse(self) -> T: """ Parse the internal UI and returned a parsed value. Or raise ParseException. :return: the parsed value """ pass def validate(self, value: T) -> None: """ Raise a ValidationError if the value is invalid :param value: the parsed value :inheritors: always call super().validate """ if self.validation_func: self.validation_func(value) @classmethod def cls_plaintext_printers(cls) -> Iterable[PlaintextPrinter[T]]: yield from cls._inner_cls_plaintext_printers() yield str yield repr yield format_spec_input_printer yield formatted_string_input_printer yield eval_printer yield exec_printer def plaintext_printers(self) -> Iterable[PlaintextPrinter[T]]: """ :return: an iterator of plaintext printers for the widget """ yield from self._inner_plaintext_printers() for d in self._plaintext_printer_delegates: yield from d() yield from self.cls_plaintext_printers() @classmethod def cls_plaintext_parsers(cls) -> Iterable[PlaintextParser[T]]: yield from cls._inner_cls_plaintext_parsers() def plaintext_parsers(self) -> Iterable[PlaintextParser[T]]: """ :return: an iterator of plaintext parsers for the widget """ yield from self._inner_plaintext_parsers() for d in self._plaintext_parser_delegates: yield from d() yield from self.cls_plaintext_parsers() def indication_changed(self, value: Union[GoodValue[T], BadValue]): pass # endregion # region call_me @contextmanager def suppress_update(self, new_value=True, call_on_exit=True): """ A context manager, while called, will suppress updates to the indicator. will update the indicator when exited. """ prev_value = self._suppress_update self._suppress_update = new_value yield new_value self._suppress_update = prev_value if call_on_exit: self.change_value() @property def joined_plaintext_parser(self): """ :return: A joining of the widget's plaintext parsers """ if not self._joined_plaintext_parser: self._joined_plaintext_parser = join_parsers(self.plaintext_parsers) return self._joined_plaintext_parser @property def joined_plaintext_printer(self): """ :return: A joining of the widget's plaintext printers """ if not self._joined_plaintext_printer: self._joined_plaintext_printer = join_printers(self.plaintext_printers) return self._joined_plaintext_printer def implicit_plaintext_parsers(self): for parser, priority in sort_adapters(self.plaintext_parsers()): if priority < 0: return yield parser def implicit_plaintext_printers(self): for printer, priority in sort_adapters(self.plaintext_printers()): if priority < 0: return yield printer @classmethod def implicit_cls_plaintext_parsers(cls): for parser, priority in sort_adapters(cls.cls_plaintext_parsers()): if priority < 0: return yield parser @classmethod def implicit_cls_plaintext_printers(cls): for printer, priority in sort_adapters(cls.cls_plaintext_printers()): if priority < 0: return yield printer def provided_pre(self, exclude=()): """ Get an iterator of the widget's provided widgets that are to appear before the main UI. :param exclude: whatever widgets to exclude """ return (yield from ( y for y in (self.title_label,) if y and y not in exclude )) def provided_post(self, exclude=()): """ Get an iterator of the widget's provided widgets that are to appear after the main UI. :param exclude: whatever widgets to exclude """ return (yield from ( y for y in (self.indicator_label, self.auto_button, self.plaintext_button) if y and y not in exclude )) @contextmanager def setup_provided(self, pre_layout: QVBoxLayout, post_layout=..., exclude=()): """ a context manager that will add the pre_provided widgets before the block and the post_provided after it. :param pre_layout: a layout to add the pre_provided to :param post_layout: a layout to add teh post_provided to, default is to use pre_layout :param exclude: which provided widgets to exclude """ for p in self.provided_pre(exclude=exclude): pre_layout.addWidget(p) yield if post_layout is ...: post_layout = pre_layout for p in self.provided_post(exclude=exclude): post_layout.addWidget(p) self._update_indicator() # endregion # region call_me_from_outside def maybe_parse(self): if self._value is None or not self._value.is_ok(): return self.parse() return self._value.value def maybe_validate(self, v): if self._value is None: self.validate(v) def fill_from_text(self, s: str): """ fill the UI from a string, by parsing it :param s: the string to parse """ if not self.fill: raise Exception(f'widget {self} does not have its fill function implemented') self.fill_value(self.joined_plaintext_parser(s)) def value(self) -> Union[GoodValue[T], BadValue]: """ :return: the current value of the widget """ if self._value is None: self._reload_value() return self._value def change_value(self, *args): """ a slot to refresh the value of the widget """ self._invalidate_value() self._update_indicator() self.on_change.emit() _template_class: Type[FidgetTemplate[T]] = FidgetTemplate @classmethod @wraps(__init__) def template(cls, *args, **kwargs) -> FidgetTemplate[T]: """ get a template of the type :param args: arguments for the template :param kwargs: keyword arguments for the template :return: the template """ return cls._template_class(cls, args, kwargs) def template_of(self) -> FidgetTemplate[T]: """ get a template to recreate the widget """ a, k = self.__new_args ret = self.template(*a, **k) return ret @classmethod def template_class(cls, class_): """ Assign a class to be this widget class's template class """ cls._template_class = class_ return class_ def __str__(self): try: return super().__str__() + ': ' + self.title except AttributeError: return super().__str__() def fill_value(self, *args, **kwargs): with self.suppress_update(): return self.fill(*args, **kwargs) def add_plaintext_printers_delegate(self, delegate: Callable[[], Iterable[PlaintextPrinter[T]]]): self._plaintext_printer_delegates.append(delegate) self._joined_plaintext_printer = None def add_plaintext_parsers_delegate(self, delegate: Callable[[], Iterable[PlaintextParser[T]]]): self._plaintext_parser_delegates.append(delegate) self._joined_plaintext_parser = None def add_plaintext_delegates(self, clone: Union[Fidget, Type[Fidget]]): if isinstance(clone, Fidget): self.add_plaintext_parsers_delegate(clone.plaintext_parsers) self.add_plaintext_printers_delegate(clone.plaintext_printers) elif isinstance(clone, type) and issubclass(clone, Fidget): self.add_plaintext_printers_delegate(clone.cls_plaintext_printers) self.add_plaintext_parsers_delegate(clone.cls_plaintext_parsers) else: raise TypeError(type(clone)) # endregion def _invalidate_value(self): """ Mark the cached value is invalid, forcing it to be re-processed when needed next """ self._value = None def _auto_btn_click(self, click_args): """ autofill the widget """ try: value = self.auto_func() except DoNotFill as e: if str(e): QMessageBox.critical(self, 'error during autofill', str(e)) return self.fill_value(value) def _plaintext_btn_click(self): """ open the plaintext dialog """ self._plaintext_widget.prep_for_show() self._plaintext_widget.show() def _update_indicator(self, *args): """ update whatever indicators need updating when the value is changed """ if self._suppress_update: return value = self.value() if self.indicator_label and self.indicator_label.parent(): if value.is_ok(): text = "<a href='...'>OK</a>" else: text = "<a href='...'>ERR</a>" tooltip = value.short_details self.indicator_label.setText(text) self.indicator_label.setToolTip(tooltip) if self.plaintext_button: self.plaintext_button.setEnabled(value.is_ok() or any(self.plaintext_parsers())) self.indication_changed(value) def _reload_value(self): """ reload the cached value """ assert self._value is None, '_reload called when a value is cached' try: value = self.parse() self.validate(value) except (ValidationError, ParseError) as e: self._value = BadValue.from_error(e) return try: details = self.joined_plaintext_printer(value) except PlaintextPrintError as e: details = 'details could not be loaded because of a parser error:\n' + error_details(e) self._value = GoodValue(value, details) def _detail_button_clicked(self, event): """ show details of the value """ value = self.value() if value.details: QMessageBox.information(self, value.type_details, value.details) if not value.is_ok(): offender: QWidget = reduce(lambda x, y: y, error_attrs(value.exception, 'offender'), None) if offender: offender.setFocus() def _help_clicked(self, event): """ show help message """ if self.help: QMessageBox.information(self, self.title, self.help) @staticmethod def _inner_plaintext_parsers(): """ get the inner plaintext parsers """ yield from () @staticmethod def _inner_plaintext_printers(): """ get the inner plaintext printers """ yield from () @staticmethod def _inner_cls_plaintext_printers(): yield from () @staticmethod def _inner_cls_plaintext_parsers(): yield from () def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) cls.on_change = pyqtSignal() inner_printers = [] inner_parsers = [] inner_cls_printers = [] inner_cls_parsers = [] for v in cls.__dict__.values(): if getattr(v, '__plaintext_printer__', False): if getattr(v, '__is_cls__', False) or isinstance(v, (classmethod, staticmethod)): inner_cls_printers.append(v) else: inner_printers.append(v) if getattr(v, '__plaintext_parser__', False): if getattr(v, '__is_cls__', False) or isinstance(v, (classmethod, staticmethod)): inner_cls_parsers.append(v) else: inner_parsers.append(v) if inner_printers: def inner_printers_func(self): yield from (p.__get__(self, type(self)) for p in inner_printers) cls._inner_plaintext_printers = inner_printers_func if inner_parsers: def inner_parsers_func(self): yield from (p.__get__(self, type(self)) for p in inner_parsers) cls._inner_plaintext_parsers = inner_parsers_func if inner_cls_printers: def inner_cls_printers_func(cls): yield from (p.__get__(None, cls) for p in inner_cls_printers) cls._inner_cls_plaintext_printers = classmethod(inner_cls_printers_func) if inner_cls_parsers: def inner_cls_parsers_func(cls): yield from (p.__get__(None, cls) for p in inner_cls_parsers) cls._inner_cls_plaintext_parsers = classmethod(inner_cls_parsers_func)
def link_to(text: str, url: str): ret = QLabel(f'''<a href='{url}'>{text}</a>''') ret.setTextInteractionFlags(Qt.LinksAccessibleByMouse) ret.setOpenExternalLinks(True) return ret