コード例 #1
0
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)
コード例 #2
0
ファイル: __util__.py プロジェクト: talos-gis/Fidget
def link_to(text: str, url: str):
    ret = QLabel(f'''<a href='{url}'>{text}</a>''')
    ret.setTextInteractionFlags(Qt.LinksAccessibleByMouse)
    ret.setOpenExternalLinks(True)
    return ret