Beispiel #1
0
 def render(self, _):
     self.detail = Label(self, **self.style.text, text=self.message)
     self.detail.pack(fill="x")
     warn_frame = Frame(self, **self.style.surface)
     self._warning = Label(
         warn_frame,
         **self.style.text_passive,
         padx=5,
         anchor='w',
         compound="left",
         image=get_tk_image("dialog_warning", 15, 15),
     )
     self.event_pad = Label(
         self, **self.style.text_accent)
     self._add_button(text="Cancel", value=None)
     self._add_button(text="Okay", command=self.exit_with_key, focus=True)
     warn_frame.pack(side="bottom", fill="x")
     self.event_pad.config(
         **self.style.bright, takefocus=True,
         text="Tap here to begin capturing shortcuts."
     )
     self.event_pad.bind("<Any-KeyPress>", self.on_key_change)
     # for some reason alt needs to be bound separately
     self.event_pad.bind("<Alt-KeyPress>", self.on_key_change)
     self.event_pad.bind("<Button-1>", lambda e: self.event_pad.focus_set())
     self.event_pad.pack(fill="both", expand=True)
Beispiel #2
0
class CollapseFrame(Frame):
    def __init__(self, master, **cnf):
        super().__init__(master, **cnf)
        self.config(**self.style.dark)
        self._label_frame = Frame(self, **self.style.bright, height=20)
        self._label_frame.pack(side="top", fill="x", padx=2)
        self._label_frame.pack_propagate(0)
        self._label = Label(self._label_frame, **self.style.bright,
                            **self.style.text_bright)
        self._label.pack(side="left")
        self._collapse_btn = Button(self._label_frame,
                                    width=20,
                                    **self.style.bright,
                                    **self.style.text_bright)
        self._collapse_btn.config(text=get_icon("triangle_up"))
        self._collapse_btn.pack(side="right", fill="y")
        self._collapse_btn.on_click(self.toggle)
        self.body = Frame(self, **self.style.dark)
        self.body.pack(side="top", fill="both", pady=2)
        self.__ref = Frame(self.body, height=0, width=0, **self.style.dark)
        self.__ref.pack(side="top")
        self._collapsed = False

    def update_state(self):
        self.__ref.pack(side="top")

    def collapse(self, *_):
        if not self._collapsed:
            self.body.pack_forget()
            self._collapse_btn.config(text=get_icon("triangle_down"))
            self.pack_propagate(0)
            self.config(height=20)
            self._collapsed = True

    def clear_children(self):
        self.body.clear_children()

    def expand(self, *_):
        if self._collapsed:
            self.body.pack(side="top", fill="both")
            self.pack_propagate(1)
            self._collapse_btn.config(text=get_icon("triangle_up"))
            self._collapsed = False

    def toggle(self, *_):
        if self._collapsed:
            self.expand()
        else:
            self.collapse()

    @property
    def label(self):
        return self._label["text"]

    @label.setter
    def label(self, value):
        self._label.config(text=value)
Beispiel #3
0
    def __init__(self, master, studio=None, **cnf):
        super().__init__(master, studio, **cnf)
        f = Frame(self, **self.style.dark)
        f.pack(side="top", fill="both", expand=True, pady=4)
        f.pack_propagate(0)

        self._variable_pane = ScrolledFrame(f, width=150)
        self._variable_pane.place(x=0, y=0, relwidth=0.4, relheight=1)

        self._detail_pane = ScrolledFrame(f, width=150)
        self._detail_pane.place(relx=0.4,
                                y=0,
                                relwidth=0.6,
                                relheight=1,
                                x=15,
                                width=-20)

        self._search_btn = Button(self._header,
                                  image=get_icon_image("search", 15, 15),
                                  width=25,
                                  height=25,
                                  **self.style.dark_button)
        self._search_btn.pack(side="right")
        self._search_btn.on_click(self.start_search)
        self._add = MenuButton(self._header, **self.style.dark_button)
        self._add.configure(image=get_icon_image("add", 15, 15))
        self._add.pack(side="right")
        self._delete_btn = Button(self._header,
                                  image=get_icon_image("delete", 15, 15),
                                  width=25,
                                  height=25,
                                  **self.style.dark_button)
        self._delete_btn.pack(side="right")
        self._delete_btn.on_click(self._delete)
        self._var_types_menu = self.make_menu(self._get_add_menu(), self._add)
        self._add.config(menu=self._var_types_menu)
        self._selected = None
        self._links = {}
        self._overlay = Label(f,
                              **self.style.dark_text_passive,
                              text="Add variables",
                              compound="top")
        self._overlay.configure(image=get_icon_image("add", 25, 25))
        self._show_overlay(True)
        self._editors = []
Beispiel #4
0
    def __init__(self, master, studio=None, **cnf):
        if not self._var_init:
            self._init_var(studio)
        super().__init__(master, studio, **cnf)

        f = Frame(self, **self.style.surface)
        f.pack(side="top", fill="both", expand=True, pady=4)
        f.pack_propagate(0)

        self._widget_set = Spinner(self._header, width=150)
        self._widget_set.config(**self.style.no_highlight)
        self._widget_set.set_values(list(self.CLASSES.keys()))
        self._widget_set.pack(side="left")
        self._widget_set.on_change(self.collect_groups)
        self._select_pane = ScrolledFrame(f, width=150)
        self._select_pane.place(x=0, y=0, relwidth=0.4, relheight=1)

        self._search_btn = Button(self._header, image=get_icon_image("search", 15, 15), width=25, height=25,
                                  **self.style.button)
        self._search_btn.pack(side="right")
        self._search_btn.on_click(self.start_search)
        self._search_selector = Label(self._select_pane.body, **self.style.text, text="search", anchor="w")
        self._search_selector.configure(**self.style.hover)

        self._widget_pane = ScrolledFrame(f, width=150)
        self._select_pane.body.config(**self.style.surface)
        self._widget_pane.place(relx=0.4, y=0, relwidth=0.6, relheight=1)

        self._pool = {}
        self._selectors = []
        self._selected = None
        self._component_cache = None
        self._extern_groups = []
        self._widget = None
        self.collect_groups(self.get_pref("widget_set"))
        # add custom widgets config to settings
        templates.update(_widget_pref_template)
        self._custom_group = None
        self._custom_widgets = []
        Preferences.acquire().add_listener(self._custom_pref_path, self._init_custom)
        self._reload_custom()
Beispiel #5
0
class Pane(Frame):
    def __init__(self, master, **cnf):
        super(Pane, self).__init__(master, **cnf)
        self.config(**self.style.surface)
        self._header = Frame(self,
                             **self.style.surface,
                             **self.style.highlight_dim,
                             height=30)
        self._header.pack(side="top", fill="x")
        self._header.pack_propagate(0)
        self._search_bar = SearchBar(self._header, height=20)
        self._search_bar.on_query_clear(self.on_search_clear)
        self._search_bar.on_query_change(self.on_search_query)

    def on_search_query(self, query: str):
        """
        Called when inbuilt search feature is queried. Use the query string to display the
        necessary search results
        :param query: String of current search query
        :return: None
        """
        pass

    def on_search_clear(self):
        """
        Called when the user terminates the search bar. Ensure you make a call to the super
        method for the bar to actually get closed. This method can be used to restore the
        feature state to when not performing a search
        :return:
        """
        self.quit_search()
        pass

    def start_search(self, *_):
        self._search_bar.place(relwidth=1, relheight=1)
        self._search_bar.lift()
        self._search_bar.focus_set()

    def quit_search(self, *_):
        self._search_bar.place_forget()
Beispiel #6
0
    def __init__(self, master, studio=None, **cnf):
        if not self._var_init:
            self._init_var(studio)
        super().__init__(master, studio, **cnf)

        f = Frame(self, **self.style.dark)
        f.pack(side="top", fill="both", expand=True, pady=4)
        f.pack_propagate(0)

        self._widget_set = Spinner(self._header, width=150)
        self._widget_set.config(**self.style.no_highlight)
        self._widget_set.set_values(list(self.CLASSES.keys()))
        self._widget_set.pack(side="left")
        self._widget_set.on_change(self.collect_groups)
        self._select_pane = ScrolledFrame(f, width=150)
        self._select_pane.place(x=0, y=0, relwidth=0.4, relheight=1)

        self._search_btn = Button(self._header,
                                  image=get_icon_image("search", 15, 15),
                                  width=25,
                                  height=25,
                                  **self.style.dark_button)
        self._search_btn.pack(side="right")
        self._search_btn.on_click(self.start_search)
        self._search_selector = Label(self._select_pane.body,
                                      **self.style.dark_text,
                                      text="search",
                                      anchor="w")
        self._search_selector.configure(**self.style.dark_on_hover)

        self._widget_pane = ScrolledFrame(f, width=150, bg="orange")
        self._select_pane.body.config(**self.style.dark)
        self._widget_pane.place(relx=0.4, y=0, relwidth=0.6, relheight=1)

        self._pool = {}
        self._selectors = []
        self._selected = None
        self._component_cache = None
        self.collect_groups(self.get_pref("widget_set"))
Beispiel #7
0
class MessageDialog(Window):
    """
    Main class for creation of hoverset themed alert dialogs. It has the various initialization
    methods for the common dialogs. The supported forms/types are shown below:

    .. _forms:

        * OKAY_CANCEL     :py:meth:`MessageDialog.ask_okay_cancel`
        * YES_NO          :py:meth:`MessageDialog.ask_question`
        * RETRY_CANCEL    :py:meth:`MessageDialog.ask_retry_cancel`
        * SHOW_PROGRESS   :py:meth:`MessageDialog.show_progress`
        * SHOW_WARNING    :py:meth:`MessageDialog.show_warning`
        * SHOW_ERROR      :py:meth:`MessageDialog.show_error`
        * SHOW_INFO       :py:meth:`MessageDialog.show_info`

    There are three icons used in the dialogs:

    .. _icons:

        * :py:attr:`MessageDialog.ICON_ERROR`
        * :py:attr:`MessageDialog.ICON_INFO`
        * :py:attr:`MessageDialog.ICON_WARNING`

    Some dialogs return values while others just notify. Below illustrates using the various dialogs

    .. code-block:: python

        # assuming we have a hoverset Application object app
        # program will wait for value to be obtained

        val = MessageDialog.ask_retry_cancel(
            title="ask_okay",
            message="This is an ask-okay-cancel message",
            parent=app
        )

        # do whatever you need with the value, in this case let's just print the response

        if val == True:
            print("retry")
        elif val == False:
            print("cancel")
        elif val is None:
            print("no value selected")

        # show dialogs don't need to return values
        MessageDialog.show_error(
            title="Error",
            message="This is an error message",
            parent=app
        )

        val = MessageDialog.builder(
            # define the buttons
            {"text": "Continue", "value": "continue", "focus": True},
            {"text": "Pause", "value": "pause"},
            {"text": "Cancel", "value": False},
            wait=True,
            title="Builder",
            message="We just built this dialog from scratch",
            parent=app,
            icon="flame"
        )
        print(val)
        # prints 'continue', 'pause' or False depending on the value of button clicked
    """

    ICON_ERROR = "dialog_error"
    ICON_INFO = "dialog_info"
    ICON_WARNING = "dialog_warning"

    OKAY_CANCEL = "OKAY_CANCEL"
    YES_NO = "YES_NO"
    RETRY_CANCEL = "RETRY_CANCEL"
    SHOW_ERROR = "SHOW_ERROR"
    SHOW_WARNING = "SHOW_WARNING"
    SHOW_INFO = "SHOW_INFO"
    SHOW_PROGRESS = "SHOW_PROGRESS"
    BUILDER = "BUILDER"

    INDETERMINATE = ProgressBar.INDETERMINATE
    DETERMINATE = ProgressBar.DETERMINATE

    _MIN_BUTTON_WIDTH = 60
    _MAX_SHAKES = 10

    def __init__(self, master, render_routine=None, **kw):
        super().__init__(master)
        self.configure(**self.style.dark)
        # ensure the dialog is above the parent window at all times
        self.transient(master)
        # take the screen focus
        self.grab_set()
        # prevent resizing by default
        self.resizable(False, False)
        self.bar = None
        # Common dialogs
        routines = {
            "OKAY_CANCEL": self._ask_okay_cancel,
            "YES_NO": self._ask_yes_no,
            "RETRY_CANCEL": self._ask_retry_cancel,
            "SHOW_ERROR": self._show_error,
            "SHOW_WARNING": self._show_warning,
            "SHOW_INFO": self._show_info,
            "SHOW_PROGRESS": self._show_progress,
            "BUILDER": self._builder  # Allows building custom dialogs
        }
        if render_routine in routines:
            # Completely custom dialogs
            routines[render_routine](**kw)  # noqa
        elif render_routine is not None:
            render_routine(self)
        self.enable_centering()
        self.value = None
        self.bind('<Visibility>', lambda _: self.grab_set())
        self.bind('<FocusOut>', lambda _: self.grab_release())
        self.bind('<FocusIn>', lambda _: self.grab_set())
        self._reaction = -1
        self.bind('<Button>', self._on_event)

    def _react(self, step):
        """
        Shake the dialog window to gain user attention

        :param step: displacement in pixels, use negative values for
            left displacement and positive for right displacement
        """
        # a value of -1 in self._reaction is a sentinel indicating no reaction is ongoing
        if self._reaction > MessageDialog._MAX_SHAKES:
            self._reaction = -1
            return
        try:
            x, y = self.winfo_x() + step, self.winfo_y()
            self.geometry('+{}+{}'.format(x, y))
            self.after(100, lambda: self._react(step * -1))
            self._reaction += 1
        except Exception:
            self._reaction = -1
            pass

    def _on_event(self, event):
        if not self.event_in(event, self):
            # the user tried to click outside the focused dialog window
            # we'll try to bring their attention back to the dialog
            self.bell()
            if self._reaction < 0:
                # displace by 2 pixels on each shake
                # use larger value for more pronounced displacement
                self._react(2)

    def _make_button_bar(self):
        self.bar = Frame(self, **self.style.dark,
                         **self.style.dark_highlight_dim)
        self.bar.pack(side="bottom", fill="x")

    def _add_button(self, **kw):
        text = kw.get("text")
        focus = kw.get("focus", False)
        # If a button bar does not already exist we need to create one
        if self.bar is None:
            self._make_button_bar()
        btn = Button(self.bar, **self.style.dark_button, text=text, height=25)
        btn.configure(**self.style.dark_highlight_active)
        btn.pack(side="right", padx=5, pady=5)
        # ensure the buttons have a minimum width of _MIN_BUTTON_WIDTH
        btn.configure(
            width=max(self._MIN_BUTTON_WIDTH, btn.measure_text(text)))
        btn.on_click(
            kw.get("command",
                   lambda _: self._terminate_with_val(kw.get("value"))))
        if focus:
            btn.focus_set()
            btn.config_all(**self.style.button_highlight)
        return btn

    def _message(self, text, icon=None):
        # set default icon to INFO
        if icon is None:
            icon = self.ICON_INFO
        Label(self,
              **self.style.dark_text,
              text=text,
              anchor="w",
              compound="left",
              wrap=600,
              justify="left",
              pady=5,
              padx=15,
              image=get_icon_image(icon, 50, 50)).pack(side="top", fill="x")

    def _ask_okay_cancel(self, **kw):
        self.title(kw.get("title", self.title()))
        self._message(kw.get("message"), kw.get("icon", self.ICON_INFO))
        self._add_button(text="Cancel",
                         focus=True,
                         command=lambda _: self._terminate_with_val(False))
        self._add_button(text="Ok",
                         command=lambda _: self._terminate_with_val(True))

    def _ask_yes_no(self, **kw):
        self.title(kw.get("title", self.title()))
        self._message(kw.get("message"), kw.get("icon", self.ICON_INFO))
        self._add_button(text="No",
                         focus=True,
                         command=lambda _: self._terminate_with_val(False))
        self._add_button(text="Yes",
                         command=lambda _: self._terminate_with_val(True))

    def _ask_retry_cancel(self, **kw):
        self.title(kw.get("title", self.title()))
        self._message(kw.get("message"), kw.get("icon", self.ICON_WARNING))
        self._add_button(text="Cancel",
                         command=lambda _: self._terminate_with_val(False))
        self._add_button(text="Retry",
                         focus=True,
                         command=lambda _: self._terminate_with_val(True))

    def _show_error(self, **kw):
        self.title(kw.get("title", self.title()))
        self._message(kw.get("message"), kw.get("icon", self.ICON_ERROR))
        self._add_button(text="Ok",
                         focus=True,
                         command=lambda _: self.destroy())

    def _show_warning(self, **kw):
        self.title(kw.get("title", self.title()))
        self._message(kw.get("message"), kw.get("icon", self.ICON_WARNING))
        self._add_button(text="Ok",
                         focus=True,
                         command=lambda _: self.destroy())

    def _show_info(self, **kw):
        self.title(kw.get("title", self.title()))
        self._message(kw.get("message"), kw.get("icon", self.ICON_INFO))
        self._add_button(text="Ok",
                         focus=True,
                         command=lambda _: self.destroy())

    def _show_progress(self, **kw):
        self.title(kw.get("title", self.title()))
        text = kw.get('message', 'progress')
        icon = None
        if kw.get('icon'):
            icon = get_icon_image(kw.get('icon'), 50, 50)
        Label(self,
              **self.style.dark_text,
              text=text,
              anchor="w",
              compound="left",
              wrap=600,
              justify="left",
              pady=5,
              padx=15,
              image=icon).pack(side="top", fill="x")
        self.progress = ProgressBar(self)
        self.progress.pack(side='top', fill='x', padx=20, pady=20)
        self.progress.mode(kw.get('mode', ProgressBar.DETERMINATE))
        self.progress.color(
            kw.get('colors', self.style.colors.get('accent', 'white')))
        self.progress.interval(kw.get('interval',
                                      ProgressBar.DEFAULT_INTERVAL))

    def _terminate_with_val(self, value):
        self.value = value
        self.destroy()

    def _builder(self, **kw):
        self.title(kw.get("title", self.title()))
        self._message(kw.get("message"), kw.get("icon", self.ICON_WARNING))
        actions = kw.get("actions")
        for action in actions:
            self._add_button(**action)

    @classmethod
    def ask(cls, form, **kw):
        """
        General method for creation common dialogs. You do not have to use this
        method directly since there are specialized methods as shown below.

        .. table::
            :align: center

            ===================================================  ==================================
            instead of                                           use this
            ===================================================  ==================================
            MessageDialog.ask(MessageDialog.OKAY_CANCEL, ...)    MessageDialog.ask_okay_cancel(...)
            MessageDialog.ask(MessageDialog.RETRY_CANCEL, ...)   MessageDialog.ask_retry_cancel(...)
            MessageDialog.ask(MessageDialog.SHOW_INFO, ...)      MessageDialog.show_info(...)
            MessageDialog.ask(MessageDialog.SHOW_ERROR, ...)     MessageDialog.show_error(...)
            MessageDialog.ask(MessageDialog.SHOW_PROGRESS, ...)  MessageDialog.show_progress(...)
            MessageDialog.ask(MessageDialog.SHOW_WARNING, ...)   MessageDialog.show_warning(...)
            MessageDialog.ask(MessageDialog.YES_NO, ...)         MessageDialog.ask_question(...)
            ===================================================  ==================================

        :param form: The type of dialog to be created as defined in forms_ above
        :param kw: The keywords arguments included. These are the common arguments:

            .. _common_args:

                * **parent**: (Required) A hoverset based toplevel widget such as :class:`hoverset.ui.widgets.Application`
                * **title**: Title to be used for the dialog window
                * **message**: Message to be displayed in the alert dialog
                * **icon**: Icon to be displayed in the dialog. Should be one of these icons_.

            .. warning::
                The ``parent`` argument is mandatory and should always be provided. Absence of the parent
                argument will result in missing theme style definitions and cause errors.

        :return: A value or ``None`` depending on the dialog type. See specialized method for mor details
        """
        parent = kw.get("parent")
        dialog = MessageDialog(parent, form, **kw)
        dialog.wait_window()
        return dialog.value

    @classmethod
    def ask_okay_cancel(cls, **kw):
        """
        Show a dialog windows with two buttons: ``okay`` and ``cancel``

        :param kw: Keyword arguments as defined in common_args_.
        :return: Returns ``True`` if "okay" is selected, ``False`` if "cancel" is selected or
            ``None`` if no choice is selected
        """
        return cls.ask(MessageDialog.OKAY_CANCEL, **kw)

    @classmethod
    def ask_question(cls, **kw):
        """
        Show a dialog window with two button: ``yes`` and ``no``

        :param kw: Keyword arguments as defined in common_args_.
        :return: Returns ``True`` if "yes" is selected, ``False`` if "no" is selected or
            ``None`` if no choice is selected
        """
        return cls.ask(MessageDialog.YES_NO, **kw)

    @classmethod
    def ask_retry_cancel(cls, **kw):
        """
        Show a dialog window with two buttons: ``retry`` and ``cancel``

        :param kw: Keyword arguments as defined in common_args_.
        :return: Returns ``True`` if "retry" is selected, ``False`` if "cancel" is selected or
            ``None`` if no choice is selected
        """
        return cls.ask(MessageDialog.RETRY_CANCEL, **kw)

    @classmethod
    def show_error(cls, **kw):
        """
        Show an error message with an error icon.

        :param kw: Keyword arguments as defined in common_args_.
        :return: ``None``
        """
        parent = kw.get("parent")
        cls(parent, MessageDialog.SHOW_ERROR, **kw)

    @classmethod
    def show_warning(cls, **kw):
        """
        Show an warning message with a warning icon.

        :param kw: Keyword arguments as defined in common_args_.
        :return: ``None``
        """
        parent = kw.get("parent")
        cls(parent, MessageDialog.SHOW_WARNING, **kw)

    @classmethod
    def show_info(cls, **kw):
        """
        Show an info message with an info icon.

        :param kw: Keyword arguments as defined in common_args_.
        :return: ``None``
        """
        parent = kw.get("parent")
        cls(parent, MessageDialog.SHOW_INFO, **kw)

    @classmethod
    def show_progress(cls, **kw):
        """

        :param kw: Config options for the progress dialog

            * **parent**: A hoverset based toplevel widget such as :class:`hoverset.ui.widgets.Application`
            * **title**: Title to be used for the dialog window
            * **message**: Message to be displayed in the alert dialog
            * **icon**: Icon to be displayed in the dialog. Should be one of these icons_.
            * **mode**: One of the two modes

                * :py:attr:`MessageDialog.DETERMINATE`
                * :py:attr:`MessageDialog.INDETERMINATE`

            * **color**: Color to be used for the progressbar
            * **interval**: The update interval in :py:attr:`MessageDialog.INDETERMINATE` in milliseconds.
              The smaller the interval the faster the animation.

        :return: The dialog window. The underlying progressbar can then be accessed through
            the property :py:attr:`MessageDialog.progress`. The progressbar is
            a :class:`hoverset.ui.widgets.ProgressBar` object and can be updated as required
        """
        parent = kw.get("parent")
        dialog = cls(parent, MessageDialog.SHOW_PROGRESS, **kw)
        return dialog

    @classmethod
    def builder(cls, *buttons, **kw):
        """
        Create custom dialogs with custom buttons, icons and return values. An example of the use of a
        builder has been provided at the beginning of this page.

        :param buttons: A tuple containing a dictionary defining the custom buttons with the following keys

            * **text**: Text to be displayed in the button
            * **value**: Value returned when button is clicked
            * **focus**: Whether to focus on button when dialog is displayed. Only a single
              button should have ``focus`` set to ``True``

        :param kw: config options for the builder:

            * **parent**: A hoverset based toplevel widget such as :class:`hoverset.ui.widgets.Application`
            * **title**: Title to be used for the dialog window
            * **message**: Message to be displayed in the alert dialog
            * **icon**: Icon to be displayed in the dialog. Should be one of these icons_.
            * **wait**: Set to ``True`` to suspend the program and wait a value to be returned or
              ``False`` to just continue program execution without waiting for a value. Useful when you just need
              to display a message

        :return: A custom value depending on the custom button clicked. If ``wait = False`` no value is returned.
            If no value is selected by the user ``None`` is returned
        """
        parent = kw.get("parent")
        kw["actions"] = buttons
        dialog = cls(parent, MessageDialog.BUILDER, **kw)
        if kw.get("wait", False):
            dialog.wait_window()
            return dialog.value
Beispiel #8
0
    def __init__(self, master=None, **cnf):
        super().__init__(master, **cnf)
        # Load icon asynchronously to prevent issues which have been known to occur when loading it synchronously
        icon_image = load_tk_image(self.ICON_PATH)
        self.iconphoto(True, icon_image)
        self.pref = pref
        self._restore_position()
        self.title('Formation Studio')
        self.protocol('WM_DELETE_WINDOW', self._on_close)
        self.shortcuts = ShortcutManager(self, pref)
        self.shortcuts.bind_all()
        self._register_actions()
        self._toolbar = Frame(self, **self.style.dark, height=30)
        self._toolbar.pack(side="top", fill="x")
        self._toolbar.pack_propagate(0)
        self._statusbar = Frame(self, **self.style.dark, height=20)
        self._statusbar.pack(side="bottom", fill="x")
        self._statusbar.pack_propagate(0)
        body = Frame(self, **self.style.dark)
        body.pack(fill="both", expand=True, side="top")
        self._right_bar = SideBar(body)
        self._right_bar.pack(side="right", fill="y")
        self._left_bar = SideBar(body)
        self._left_bar.pack(side="left", fill="y")
        self._pane = PanedWindow(body, **self.style.dark_pane_horizontal)
        self._pane.pack(side="left", fill="both", expand=True)
        self._left = PanedWindow(self._pane, **self.style.dark_pane_vertical)
        self._center = PanedWindow(self._pane, **self.style.dark_pane_vertical)
        self._right = PanedWindow(self._pane, **self.style.dark_pane_vertical)

        self._bin = []
        self._clipboard = None
        self._undo_stack = []
        self._redo_stack = []
        self.current_preview = None

        self._pane.add(self._left, minsize=320, sticky='nswe', width=320)
        self._pane.add(self._center, minsize=400, width=16000, sticky='nswe')
        self._pane.add(self._right, minsize=320, sticky='nswe', width=320)

        self._panes = {
            "left": (self._left, self._left_bar),
            "right": (self._right, self._right_bar),
            "center": (self._center, None)
        }

        icon = get_icon_image

        self.actions = (
            ("Delete", icon("delete", 20, 20), lambda e: self.delete(),
             "Delete selected widget"),
            ("Undo", icon("undo", 20,
                          20), lambda e: self.undo(), "Undo action"),
            ("Redo", icon("redo", 20,
                          20), lambda e: self.redo(), "Redo action"),
            ("Cut", icon("cut", 20,
                         20), lambda e: self.cut(), "Cut selected widget"),
            ("separator", ),
            ("Fullscreen", icon("image_editor", 20, 20),
             lambda e: self.close_all(), "Design mode"),
            ("Separate", icon("separate", 20,
                              20), lambda e: self.features_as_windows(),
             "Open features in window mode"),
            ("Dock", icon("flip_horizontal", 15, 15),
             lambda e: self.features_as_docked(), "Dock all features"),
            ("separator", ),
            ("New", icon("add", 20,
                         20), lambda e: self.open_new(), "New design"),
            ("Save", icon("save", 20,
                          20), lambda e: self.save(), "Save design"),
            ("Preview", icon("play", 20,
                             20), lambda e: self.preview(), "Preview design"),
        )

        self.init_toolbar()
        self.selected = None
        # set the image option to blank if there is no image for the menu option
        self.blank_img = blank_img = icon("blank", 14, 14)

        # -------------------------------------------- menu definition ------------------------------------------------
        self.menu_template = (EnableIf(
            lambda: self.selected,
            ("separator", ),
            ("command", "copy", icon("copy", 14,
                                     14), actions.get('STUDIO_COPY'), {}),
            ("command", "paste", icon("clipboard", 14,
                                      14), actions.get('STUDIO_PASTE'), {}),
            ("command", "cut", icon("cut", 14,
                                    14), actions.get('STUDIO_CUT'), {}),
            ("separator", ),
            ("command", "delete", icon("delete", 14,
                                       14), actions.get('STUDIO_DELETE'), {}),
        ), )

        self.menu_bar = MenuUtils.make_dynamic(
            (("cascade", "File", None, None, {
                "menu": (
                    ("command", "New", icon(
                        "add", 14, 14), actions.get('STUDIO_NEW'), {}),
                    ("command", "Open", icon(
                        "folder", 14, 14), actions.get('STUDIO_OPEN'), {}),
                    ("cascade", "Recent", icon("clock", 14, 14), None, {
                        "menu": self._create_recent_menu()
                    }),
                    ("separator", ),
                    ("command", "Save", icon(
                        "save", 14, 14), actions.get('STUDIO_SAVE'), {}),
                    ("command", "Save As", icon(
                        "save", 14, 14), actions.get('STUDIO_SAVE_AS'), {}),
                    ("separator", ),
                    ("command", "Settings", icon("settings", 14, 14),
                     actions.get('STUDIO_SETTINGS'), {}),
                    ("command", "Exit", icon(
                        "exit", 14, 14), actions.get('STUDIO_EXIT'), {}),
                )
            }), ("cascade", "Edit", None, None, {
                "menu": (
                    EnableIf(lambda: len(self._undo_stack),
                             ("command", "undo", icon("undo", 14, 14),
                              actions.get('STUDIO_UNDO'), {})),
                    EnableIf(lambda: len(self._redo_stack),
                             ("command", "redo", icon("redo", 14, 14),
                              actions.get('STUDIO_REDO'), {})),
                    *self.menu_template,
                )
            }), ("cascade", "Code", None, None, {
                "menu":
                (EnableIf(lambda: self.designer and self.designer.root_obj,
                          ("command", "Preview design", icon("play", 14, 14),
                           actions.get('STUDIO_PREVIEW'), {}),
                          ("command", "close preview", icon("close", 14, 14),
                           actions.get('STUDIO_PREVIEW_CLOSE'), {})))
            }), ("cascade", "Window", None, None, {
                "menu":
                (("command", "show all", blank_img,
                  actions.get('FEATURE_SHOW_ALL'), {}),
                 ("command", "close all", icon(
                     "close", 14, 14), actions.get('FEATURE_CLOSE_ALL'), {}),
                 ("command", "close all on the right", blank_img,
                  actions.get('FEATURE_CLOSE_RIGHT'), {}),
                 ("command", "close all on the left", blank_img,
                  actions.get('FEATURE_CLOSE_LEFT'), {}), ("separator", ),
                 ("command", "Undock all windows", blank_img,
                  actions.get('FEATURE_UNDOCK_ALL'), {}),
                 ("command", "Dock all windows", blank_img,
                  actions.get('FEATURE_DOCK_ALL'), {}), ("separator", ),
                 LoadLater(self.get_features_as_menu), ("separator", ),
                 ("command", "Save window positions", blank_img,
                  actions.get('FEATURE_SAVE_POS'), {}))
            }), ("cascade", "Tools", None, None, {
                "menu": ToolManager.get_tools_as_menu(self)
            }), ("cascade", "Help", None, None, {
                "menu": (
                    ("command", "Help", icon('dialog_info', 14, 14),
                     actions.get('STUDIO_HELP'), {}),
                    ("command", "Check for updates", icon("cloud", 14,
                                                          14), None, {}),
                    ("separator", ),
                    ("command", "About Studio", blank_img,
                     lambda: about_window(self), {}),
                )
            })), self, self.style, False)
        self.config(menu=self.menu_bar)

        self.features = []

        self.designer = Designer(self._center, self)
        self._center.add(self.designer, sticky='nswe')
        self.install(ComponentPane)
        self.install(ComponentTree)
        self.install(StylePane)
        self.install(VariablePane)
        self._startup()
        self._restore_position()
Beispiel #9
0
class StudioApplication(Application):
    ICON_PATH = get_resource_path(studio,
                                  "resources/images/formation_icon.png")

    def __init__(self, master=None, **cnf):
        super().__init__(master, **cnf)
        # Load icon asynchronously to prevent issues which have been known to occur when loading it synchronously
        icon_image = load_tk_image(self.ICON_PATH)
        self.iconphoto(True, icon_image)
        self.pref = pref
        self._restore_position()
        self.title('Formation Studio')
        self.protocol('WM_DELETE_WINDOW', self._on_close)
        self.shortcuts = ShortcutManager(self, pref)
        self.shortcuts.bind_all()
        self._register_actions()
        self._toolbar = Frame(self, **self.style.dark, height=30)
        self._toolbar.pack(side="top", fill="x")
        self._toolbar.pack_propagate(0)
        self._statusbar = Frame(self, **self.style.dark, height=20)
        self._statusbar.pack(side="bottom", fill="x")
        self._statusbar.pack_propagate(0)
        body = Frame(self, **self.style.dark)
        body.pack(fill="both", expand=True, side="top")
        self._right_bar = SideBar(body)
        self._right_bar.pack(side="right", fill="y")
        self._left_bar = SideBar(body)
        self._left_bar.pack(side="left", fill="y")
        self._pane = PanedWindow(body, **self.style.dark_pane_horizontal)
        self._pane.pack(side="left", fill="both", expand=True)
        self._left = PanedWindow(self._pane, **self.style.dark_pane_vertical)
        self._center = PanedWindow(self._pane, **self.style.dark_pane_vertical)
        self._right = PanedWindow(self._pane, **self.style.dark_pane_vertical)

        self._bin = []
        self._clipboard = None
        self._undo_stack = []
        self._redo_stack = []
        self.current_preview = None

        self._pane.add(self._left, minsize=320, sticky='nswe', width=320)
        self._pane.add(self._center, minsize=400, width=16000, sticky='nswe')
        self._pane.add(self._right, minsize=320, sticky='nswe', width=320)

        self._panes = {
            "left": (self._left, self._left_bar),
            "right": (self._right, self._right_bar),
            "center": (self._center, None)
        }

        icon = get_icon_image

        self.actions = (
            ("Delete", icon("delete", 20, 20), lambda e: self.delete(),
             "Delete selected widget"),
            ("Undo", icon("undo", 20,
                          20), lambda e: self.undo(), "Undo action"),
            ("Redo", icon("redo", 20,
                          20), lambda e: self.redo(), "Redo action"),
            ("Cut", icon("cut", 20,
                         20), lambda e: self.cut(), "Cut selected widget"),
            ("separator", ),
            ("Fullscreen", icon("image_editor", 20, 20),
             lambda e: self.close_all(), "Design mode"),
            ("Separate", icon("separate", 20,
                              20), lambda e: self.features_as_windows(),
             "Open features in window mode"),
            ("Dock", icon("flip_horizontal", 15, 15),
             lambda e: self.features_as_docked(), "Dock all features"),
            ("separator", ),
            ("New", icon("add", 20,
                         20), lambda e: self.open_new(), "New design"),
            ("Save", icon("save", 20,
                          20), lambda e: self.save(), "Save design"),
            ("Preview", icon("play", 20,
                             20), lambda e: self.preview(), "Preview design"),
        )

        self.init_toolbar()
        self.selected = None
        # set the image option to blank if there is no image for the menu option
        self.blank_img = blank_img = icon("blank", 14, 14)

        # -------------------------------------------- menu definition ------------------------------------------------
        self.menu_template = (EnableIf(
            lambda: self.selected,
            ("separator", ),
            ("command", "copy", icon("copy", 14,
                                     14), actions.get('STUDIO_COPY'), {}),
            ("command", "paste", icon("clipboard", 14,
                                      14), actions.get('STUDIO_PASTE'), {}),
            ("command", "cut", icon("cut", 14,
                                    14), actions.get('STUDIO_CUT'), {}),
            ("separator", ),
            ("command", "delete", icon("delete", 14,
                                       14), actions.get('STUDIO_DELETE'), {}),
        ), )

        self.menu_bar = MenuUtils.make_dynamic(
            (("cascade", "File", None, None, {
                "menu": (
                    ("command", "New", icon(
                        "add", 14, 14), actions.get('STUDIO_NEW'), {}),
                    ("command", "Open", icon(
                        "folder", 14, 14), actions.get('STUDIO_OPEN'), {}),
                    ("cascade", "Recent", icon("clock", 14, 14), None, {
                        "menu": self._create_recent_menu()
                    }),
                    ("separator", ),
                    ("command", "Save", icon(
                        "save", 14, 14), actions.get('STUDIO_SAVE'), {}),
                    ("command", "Save As", icon(
                        "save", 14, 14), actions.get('STUDIO_SAVE_AS'), {}),
                    ("separator", ),
                    ("command", "Settings", icon("settings", 14, 14),
                     actions.get('STUDIO_SETTINGS'), {}),
                    ("command", "Exit", icon(
                        "exit", 14, 14), actions.get('STUDIO_EXIT'), {}),
                )
            }), ("cascade", "Edit", None, None, {
                "menu": (
                    EnableIf(lambda: len(self._undo_stack),
                             ("command", "undo", icon("undo", 14, 14),
                              actions.get('STUDIO_UNDO'), {})),
                    EnableIf(lambda: len(self._redo_stack),
                             ("command", "redo", icon("redo", 14, 14),
                              actions.get('STUDIO_REDO'), {})),
                    *self.menu_template,
                )
            }), ("cascade", "Code", None, None, {
                "menu":
                (EnableIf(lambda: self.designer and self.designer.root_obj,
                          ("command", "Preview design", icon("play", 14, 14),
                           actions.get('STUDIO_PREVIEW'), {}),
                          ("command", "close preview", icon("close", 14, 14),
                           actions.get('STUDIO_PREVIEW_CLOSE'), {})))
            }), ("cascade", "Window", None, None, {
                "menu":
                (("command", "show all", blank_img,
                  actions.get('FEATURE_SHOW_ALL'), {}),
                 ("command", "close all", icon(
                     "close", 14, 14), actions.get('FEATURE_CLOSE_ALL'), {}),
                 ("command", "close all on the right", blank_img,
                  actions.get('FEATURE_CLOSE_RIGHT'), {}),
                 ("command", "close all on the left", blank_img,
                  actions.get('FEATURE_CLOSE_LEFT'), {}), ("separator", ),
                 ("command", "Undock all windows", blank_img,
                  actions.get('FEATURE_UNDOCK_ALL'), {}),
                 ("command", "Dock all windows", blank_img,
                  actions.get('FEATURE_DOCK_ALL'), {}), ("separator", ),
                 LoadLater(self.get_features_as_menu), ("separator", ),
                 ("command", "Save window positions", blank_img,
                  actions.get('FEATURE_SAVE_POS'), {}))
            }), ("cascade", "Tools", None, None, {
                "menu": ToolManager.get_tools_as_menu(self)
            }), ("cascade", "Help", None, None, {
                "menu": (
                    ("command", "Help", icon('dialog_info', 14, 14),
                     actions.get('STUDIO_HELP'), {}),
                    ("command", "Check for updates", icon("cloud", 14,
                                                          14), None, {}),
                    ("separator", ),
                    ("command", "About Studio", blank_img,
                     lambda: about_window(self), {}),
                )
            })), self, self.style, False)
        self.config(menu=self.menu_bar)

        self.features = []

        self.designer = Designer(self._center, self)
        self._center.add(self.designer, sticky='nswe')
        self.install(ComponentPane)
        self.install(ComponentTree)
        self.install(StylePane)
        self.install(VariablePane)
        self._startup()
        self._restore_position()

    def _startup(self):
        on_startup = pref.get("studio::on_startup")
        if on_startup == "new":
            self.open_new()
        elif on_startup == "recent":
            latest = pref.get_latest()
            if latest:
                self.open_file(latest)
        # if blank do nothing

    def _save_position(self):
        # self.update_idletasks()
        pref.set(
            "studio::pos",
            dict(
                width=self.width,
                height=self.height,
                x=self.winfo_x(),
                y=self.winfo_y(),
                state=self.state(),  # window state either zoomed or normal
            ))

    def _restore_position(self):
        pos = pref.get("studio::pos")
        if pos.get("state") == 'zoomed':
            if platform_is(WINDOWS):
                self.state('zoomed')
            else:
                self.wm_attributes('-zoomed', True)
            return
        self.state('normal')
        self.geometry('{width}x{height}+{x}+{y}'.format(**pos))

    def new_action(self, action: Action):
        """
        Register a undo redo point
        :param action: An action object implementing undo and redo methods
        :return:
        """
        self._undo_stack.append(action)
        self._redo_stack.clear()

    def undo(self):
        if not len(self._undo_stack):
            # Let's avoid popping an empty list to prevent raising IndexError
            return
        action = self._undo_stack.pop()
        action.undo()
        self._redo_stack.append(action)

    def redo(self):
        if not len(self._redo_stack):
            return
        action = self._redo_stack.pop()
        action.redo()
        self._undo_stack.append(action)

    def copy(self):
        if self.selected:
            # store the current object as an xml node in the clipboard
            self._clipboard = self.designer.as_xml_node(self.selected)

    def install_status_widget(self, widget_class, *args, **kwargs):
        widget = widget_class(self._statusbar, *args, **kwargs)
        widget.pack(side='right', padx=2, fill='y')
        return widget

    def get_pane_info(self, pane):
        return self._panes.get(pane, [self._right, self._right_bar])

    def paste(self):
        if self._clipboard is not None:
            self.designer.paste(self._clipboard)

    def close_all_on_side(self, side):
        for feature in self.features:
            if feature.side == side:
                feature.minimize()
        # To avoid errors when side is not a valid pane identifier we default to the right pane
        self._panes.get(side, (self._right, self._right_bar))[1].close_all()

    def close_all(self, *_):
        for feature in self.features:
            feature.minimize()
        self._right_bar.close_all()
        self._left_bar.close_all()

    def init_toolbar(self):
        for action in self.actions:
            if len(action) == 1:
                Frame(self._toolbar,
                      width=1,
                      bg=self.style.colors.get("primarydarkaccent")).pack(
                          side='left', fill='y', pady=3, padx=5)
                continue
            btn = Button(self._toolbar,
                         image=action[1],
                         **self.style.dark_button,
                         width=25,
                         height=25)
            btn.pack(side="left", padx=3)
            btn.tooltip(action[3])
            ActionNotifier.bind_event("<Button-1>",
                                      btn,
                                      action[2],
                                      text=action[3])

    def uninstall(self, feature):
        self.features.remove(feature)
        feature.bar.remove(feature)
        feature.pane.forget(feature)
        self._adjust_pane(feature.pane)

    def get_pane_bar(self, side):
        if side in self._panes:
            return self._panes.get(side, (self._left, self._left_bar))

    def reposition(self, feature: BaseFeature, side):
        if self.get_pane_bar(side):
            pane, bar = self.get_pane_bar(side)
            feature.bar.remove(feature)
            feature.pane.forget(feature)
            self._adjust_pane(feature.pane)
            feature.bar = bar
            feature.pane = pane
            bar.add_feature(feature)
            if feature.get_pref("mode") == "docked":
                pane.add(feature, minsize=100, height=300, sticky='nswe')
            feature.set_pref("side", side)

    def install(self, feature) -> BaseFeature:
        obj = feature(self, self)
        pane, bar = self._panes.get(obj.get_pref('side'),
                                    (self._left, self._left_bar))
        obj.pane = pane
        obj.bar = bar
        self.features.append(obj)
        if bar is not None:
            bar.add_feature(obj)
        if not obj.get_pref('visible'):
            bar.deselect(obj)
            self._adjust_pane(pane)
        else:
            bar.select(obj)
            obj.maximize()
        return obj

    def show_all_windows(self):
        for feature in self.features:
            feature.maximize()

    def features_as_windows(self):
        for feature in self.features:
            feature.open_as_window()

    def features_as_docked(self):
        for feature in self.features:
            feature.open_as_docked()

    def set_path(self, path):
        if path:
            self.title("Formation studio" + " - " + path)

    @dynamic_menu
    def _create_recent_menu(self, menu):
        # Dynamically create recent file menu every time menu is posted
        menu.image = get_icon_image("close", 14, 14)
        menu.config(**self.style.dark_context_menu)
        recent = pref.get_recent()
        for path, label in recent:
            menu.add_command(
                label=label,
                command=functools.partial(self.open_recent, path),
                image=self.blank_img,
                compound='left',
            )
        menu.add_command(label="Clear",
                         image=menu.image,
                         command=pref.clear_recent,
                         compound="left")

    def open_file(self, path=None):
        if path is None:
            path = filedialog.askopenfilename(parent=self,
                                              filetypes=[('XML', '*.xml')])
        elif not os.path.exists(path):
            MessageDialog.show_error(
                parent=self,
                title="Missing File",
                message="File {} does not exist".format(path),
            )
            return
        if path:
            self.designer.open_xml(path)
            self.set_path(path)
            pref.update_recent(path)

    def open_recent(self, path):
        self.open_file(path)

    def open_new(self):
        self.designer.open_new()
        self.set_path('untitled')

    def save(self):
        path = self.designer.save()
        self.set_path(path)
        pref.update_recent(path)

    def save_as(self):
        path = self.designer.save(new_path=True)
        self.set_path(path)
        pref.update_recent(path)

    def get_feature(self, feature_class) -> BaseFeature:
        for feature in self.features:
            if feature.__class__ == feature_class:
                return feature
        # returns None by if feature is not found

    def get_features_as_menu(self):
        # For each feature we create a menu template
        # The command value is the self.maximize method which will reopen the feature
        return [
            (
                "command",  # Type
                f.name,
                get_icon_image(f.icon, 14, 14),  # Label, image
                functools.partial(f.toggle),  # Command built from feature
                {}) for f in self.features
        ]

    def save_window_positions(self):
        for feature in self.features:
            feature.save_window_pos()
        self._save_position()

    def _adjust_pane(self, pane):
        if len(pane.panes()) == 0:
            self._pane.paneconfig(pane, minsize=0, width=0)
            self._pane.paneconfig(self._center, width=16000)
        else:
            self._pane.paneconfig(pane, minsize=320)

    def minimize(self, feature):
        feature.pane.forget(feature)
        feature.bar.deselect(feature)
        self._adjust_pane(feature.pane)

    def maximize(self, feature):
        feature.pane.add(feature, height=300, sticky='nswe')
        feature.bar.select(feature)
        self._adjust_pane(feature.pane)

    def select(self, widget, source=None):
        self.selected = widget
        if source != self.designer:
            # Select from the designer explicitly so the selection does not end up being re-fired
            self.designer.select(widget, True)
        for feature in self.features:
            if feature != source:
                feature.on_select(widget)

    def add(self, widget, parent=None):
        for feature in self.features:
            feature.on_widget_add(widget, parent)

    def widget_modified(self, widget1, source=None, widget2=None):
        for feature in self._all_features():
            if feature != source:
                feature.on_widget_change(widget1, widget2)

    def widget_layout_changed(self, widget):
        for feature in self.features:
            feature.on_widget_layout_change(widget)

    def delete(self, widget=None, source=None):
        widget = self.selected if widget is None else widget
        if widget is None:
            return
        if self.selected == widget:
            self.select(None)
        if source != self.designer:
            self.designer.delete(widget)
        for feature in self.features:
            feature.on_widget_delete(widget)

    def cut(self, widget=None, source=None):
        widget = self.selected if widget is None else widget
        if not widget:
            return
        if self.selected == widget:
            self.select(None)
        self._clipboard = self.designer.as_xml_node(widget)
        if source != self.designer:
            self.designer.delete(widget, True)
        for feature in self.features:
            feature.on_widget_delete(widget, True)

    def on_restore(self, widget):
        for feature in self.features:
            feature.on_widget_restore(widget)

    def on_feature_change(self, new, old):
        self.features.insert(self.features.index(old), new)
        self.features.remove(old)

    def on_session_clear(self, source):
        self._redo_stack.clear()
        self._undo_stack.clear()
        for feature in self._all_features():
            if feature != source:
                feature.on_session_clear()

    def preview(self):
        if self.designer.root_obj is None:
            # If there is no root object show a warning
            MessageDialog.show_warning(
                parent=self,
                title='Empty design',
                message='There is nothing to preview. Please add a root widget'
            )
            return
        # close previous preview if any
        self.close_preview()
        window = self.current_preview = Toplevel(self)
        window.wm_transient(self)
        window.build = AppBuilder(window, node=self.designer.to_xml())
        name = self.designer.design_path if self.designer.design_path is not None else "Untitled"
        window.build._app.title(os.path.basename(name))

    def close_preview(self):
        if self.current_preview:
            self.current_preview.destroy()

    def _all_features(self):
        """
        Return a tuple of all features including the designer instance
        :return: tuple of features
        """
        # We cannot unpack directly at the return statement due to a flaw in python versions < 3.8
        features = *self.features, self.designer
        return features

    def _on_close(self):
        self._save_position()
        # pass the on window close event to the features
        for feature in self._all_features():
            # if any feature returns false abort shut down
            if not feature.on_app_close():
                return
        self.destroy()

    def get_help(self):
        # Entry point for studio help functionality
        pass

    def settings(self):
        open_preferences(self)

    def _register_actions(self):
        CTRL, ALT, SHIFT = KeyMap.CONTROL, KeyMap.ALT, KeyMap.SHIFT
        routine = actions.Routine
        # These actions are best bound separately to avoid interference with text entry widgets
        actions.add(
            routine(self.cut, 'STUDIO_CUT', 'Cut selected widget', 'studio',
                    CTRL + CharKey('x')),
            routine(self.copy, 'STUDIO_COPY', 'Copy selected widget', 'studio',
                    CTRL + CharKey('c')),
            routine(self.paste, 'STUDIO_PASTE', 'Paste selected widget',
                    'studio', CTRL + CharKey('v')),
            routine(self.delete, 'STUDIO_DELETE', 'Delete selected widget',
                    'studio', KeyMap.DELETE),
        )
        self.shortcuts.add_routines(
            routine(self.undo, 'STUDIO_UNDO', 'Undo last action', 'studio',
                    CTRL + CharKey('Z')),
            routine(self.redo, 'STUDIO_REDO', 'Redo action', 'studio',
                    CTRL + CharKey('Y')),
            # -----------------------------
            routine(self.open_new, 'STUDIO_NEW', 'Open new design', 'studio',
                    CTRL + CharKey('n')),
            routine(self.open_file, 'STUDIO_OPEN', 'Open design from file',
                    'studio', CTRL + CharKey('o')),
            routine(self.save, 'STUDIO_SAVE', 'Save current design', 'studio',
                    CTRL + CharKey('s')),
            routine(self.save_as, 'STUDIO_SAVE_AS',
                    'Save current design under a new file', 'studio',
                    CTRL + SHIFT + CharKey('s')),
            routine(self.get_help, 'STUDIO_HELP', 'Show studio help', 'studio',
                    KeyMap.F(12)),
            routine(self.settings, 'STUDIO_SETTINGS', 'Open studio settings',
                    'studio', ALT + CharKey('s')),
            routine(self._on_close, 'STUDIO_EXIT', 'Exit application',
                    'studio', CTRL + CharKey('q')),
            # ------------------------------
            routine(self.show_all_windows, 'FEATURE_SHOW_ALL',
                    'Show all feature windows', 'studio', ALT + CharKey('a')),
            routine(self.close_all, 'FEATURE_CLOSE_ALL',
                    'Close all feature windows', 'studio', ALT + CharKey('x')),
            routine(lambda: self.close_all_on_side('right'),
                    'FEATURE_CLOSE_RIGHT',
                    'Close feature windows to the right', 'studio',
                    ALT + CharKey('R')),
            routine(lambda: self.close_all_on_side('left'),
                    'FEATURE_CLOSE_LEFT', 'Close feature windows to the left',
                    'studio', ALT + CharKey('L')),
            routine(self.features_as_docked, 'FEATURE_DOCK_ALL',
                    'Dock all feature windows', 'studio', ALT + CharKey('d')),
            routine(self.features_as_windows, 'FEATURE_UNDOCK_ALL',
                    'Undock all feature windows', 'studio',
                    ALT + CharKey('u')),
            routine(self.save_window_positions, 'FEATURE_SAVE_POS',
                    'Save window positions', 'studio',
                    ALT + SHIFT + CharKey('s')),
            # -----------------------------
            routine(self.preview, 'STUDIO_PREVIEW', 'Show preview', 'studio',
                    KeyMap.F(5)),
            routine(self.close_preview, 'STUDIO_PREVIEW_CLOSE',
                    'Close any preview', 'studio', ALT + KeyMap.F(5)),
        )
Beispiel #10
0
class BaseFeature(Frame):
    _instance = None
    name = "Feature"
    pane = None
    bar = None
    icon = "blank"
    _view_mode = None
    _transparency_flag = None
    _side = None
    rec = (20, 20, 300, 300)  # Default window mode position
    _defaults = {
        "mode": "docked",
        "inactive_transparency": False,
        "position": "left",
        "visible": True,
        "side": "left",
        "pos": {
            "initialized": False,
            "x": 20,
            "y": 20,
            "width": 200,
            "height": 200,
        }
    }

    @classmethod
    def update_defaults(cls):
        path = "features::{}".format(cls.name)
        if not pref.exists(path):
            pref.set(path, dict(**cls._defaults))
        else:
            pref.update_defaults(path, dict(**cls._defaults))

    def __init__(self, master, studio=None, **cnf):
        super().__init__(master, **cnf)
        self.update_defaults()
        self.__class__._instance = self
        if not self.__class__._view_mode:
            self.__class__._view_mode = StringVar(None, self.get_pref('mode'))
            self.__class__._transparency_flag = t = BooleanVar(None, self.get_pref('inactive_transparency'))
            self.__class__._side = side = StringVar(None, self.get_pref('side'))
            t.trace_add("write", lambda *_: self.set_pref('inactive_transparency', t.get()))
        self.studio = studio
        self._header = Frame(self, **self.style.dark, **self.style.dark_highlight_dim, height=30)
        self._header.pack(side="top", fill="x")
        self._header.pack_propagate(0)
        self._header.allow_drag = True
        Label(self._header, **self.style.dark_text_passive, text=self.name).pack(side="left")
        self._min = Button(self._header, image=get_icon_image("close", 15, 15), **self.style.dark_button, width=25,
                           height=25)
        self._min.pack(side="right")
        self._min.on_click(self.minimize)
        self._pref = MenuButton(self._header, **self.style.dark_button)
        self._pref.configure(image=get_icon_image("settings", 15, 15))
        self._pref.pack(side="right")
        self._pref.tooltip("Options")
        self._search_bar = SearchBar(self._header, height=20)
        self._search_bar.on_query_clear(self.on_search_clear)
        self._search_bar.on_query_change(self.on_search_query)
        menu = self.make_menu((
            ("cascade", "View Mode", None, None, {"menu": (
                ("radiobutton", "Docked", None, self.open_as_docked, {"variable": self._view_mode, "value": "docked"}),
                ("radiobutton", "Window", None, self.open_as_window, {"variable": self._view_mode, "value": "window"}),
            )}),
            ("cascade", "Position", None, None, {"menu": (
                ("radiobutton", "Left", None, lambda: self.reposition("left"),
                 {"variable": self._side, "value": "left"}),
                ("radiobutton", "Right", None, lambda: self.reposition("right"),
                 {"variable": self._side, "value": "right"}),
            )}),
            EnableIf(lambda: self._view_mode.get() == 'window',
                     ("cascade", "Window options", None, None, {"menu": (
                         (
                             "checkbutton", "Transparent when inactive", None, None,
                             {"variable": self._transparency_flag}),
                     )})),
            ("command", "Close", get_icon_image("close", 14, 14), self.minimize, {}),
            ("separator",),
            *self.create_menu()
        ), self._pref)
        self._pref.config(menu=menu)
        # self._pref.on_click(self.minimize)
        self.config(**self.style.dark)
        self.indicator = None
        self.window_handle = None
        self.on_focus(self._on_focus_get)
        self.on_focus_lost(self._on_focus_release)
        self.on_close(self.close_window)
        self._mode_map = {
            'window': self.open_as_window,
            'docked': self.open_as_docked
        }

    @classmethod
    def get_pref_path(cls, short_path):
        return "features::{}::{}".format(cls.name, short_path)

    @classmethod
    def get_pref(cls, short_path):
        return pref.get(cls.get_pref_path(short_path))

    @classmethod
    def set_pref(cls, short_path, value):
        pref.set(cls.get_pref_path(short_path), value)

    @classmethod
    def get_instance(cls):
        return cls._instance

    def start_search(self, *_):
        self._search_bar.place(relwidth=1, relheight=1)
        self._search_bar.lift()
        self._search_bar.focus_set()

    def quit_search(self, *_):
        self._search_bar.place_forget()

    def on_search_query(self, query: str):
        """
        Called when inbuilt search feature is queried. Use the query string to display the
        necessary search results
        :param query: String of current search query
        :return: None
        """
        pass

    def on_search_clear(self):
        """
        Called when the user terminates the search bar. Ensure you make a call to the super
        method for the bar to actually get closed. This method can be used to restore the
        feature state to when not performing a search
        :return:
        """
        self.quit_search()
        pass

    def on_select(self, widget):
        """
        Called when a widget is selected in the designer
        :param widget: selected widget
        :return:None
        """
        pass

    def on_widget_change(self, old_widget, new_widget=None):
        """
        Called when a widget is fundamentally altered
        :param old_widget: Altered widget
        :param new_widget: The new widget taking the older widgets place
        :return: None
        """
        pass

    def on_widget_layout_change(self, widget):
        """
        Called when layout options of a widget are changed
        :param widget: Widget with altered layout options
        :return: None
        """
        pass

    def on_widget_add(self, widget, parent):
        """
        Called when a new widget is added to the designer
        :param widget: widget
        :param parent: the container widget to which thw widget is added
        :return: None
        """
        pass

    def on_widget_delete(self, widget, silently=False):
        """
        Called when a widget is deleted from the designer
        :param widget: deleted widget
        :param silently: flag indicating whether the deletion should be treated implicitly
        which is useful for instance when you don't want the deletion to be logged in the
        undo stack
        :return: None
        """
        pass

    def on_widget_restore(self, widget):
        """
        Called when a deleted widget is restored
        :param widget: restored widget
        :return: None
        """
        pass

    def on_session_clear(self):
        """
        Override to perform operations before a session is cleared and the studio
        resets to a new design
        :return: None
        """
        pass

    def on_app_close(self) -> bool:
        """
        Override to perform operations before the studio app closes.
        :return: True to allow shutdown to proceed or False to abort shutdown
        """
        return True

    def minimize(self, *_):
        if self.window_handle:
            self.close_window()
            return
        self.studio.minimize(self)
        self.set_pref("visible", False)

    def maximize(self):
        if self.get_pref("mode") == "window":
            self.open_as_window()
            self.bar.select(self)
        else:
            self.studio.maximize(self)
        self.set_pref("visible", True)

    def toggle(self):
        if self.get_pref("visible"):
            self.minimize()
        else:
            self.maximize()

    def create_menu(self):
        """
        Override this method to provide additional menu options
        :return: tuple of menu templates i.e. (type, label, image, callback, **additional_config)
        """
        # return an empty tuple as default
        return ()

    def _on_focus_release(self):
        if self._transparency_flag.get() and self.window_handle:
            if self.window_handle:
                self.window_handle.wm_attributes('-alpha', 0.3)
        if self.window_handle:
            self.save_window_pos()

    def _on_focus_get(self):
        if self.window_handle:
            self.window_handle.wm_attributes('-alpha', 1.0)

    def open_as_docked(self):
        self._view_mode.set("docked")
        self.set_pref('mode', 'docked')
        if self.window_handle:
            self.master.window.wm_forget(self)
            self.window_handle = None
            self.maximize()

    def reposition(self, side):
        self._side.set(side)
        self.studio.reposition(self, side)

    def open_as_window(self):
        if TkVersion < 8.5:
            logging.error("Window mode is not supported in current tk version")
            return
        self.master.window.wm_forget(self)
        rec = absolute_position(self) if not self.get_pref("pos::initialized") else (
            self.get_pref("pos::x"),
            self.get_pref("pos::y"),
            self.get_pref("pos::width"),
            self.get_pref("pos::height"),
        )
        self.window.wm_manage(self)
        # Allow us to create a hook in the close method of the window manager
        self.bind_close()
        self.title(self.name)
        self.transient(self.master.window)
        self.geometry('{}x{}+{}+{}'.format(rec[2], rec[3], rec[0], rec[1]))
        self.update_idletasks()
        self.window_handle = self
        self._view_mode.set("window")
        self.set_pref("mode", "window")
        self.studio._adjust_pane(self.pane)
        self.save_window_pos()
        if self.focus_get() != self and self.get_pref("inactive_transparency"):
            self.window_handle.wm_attributes('-alpha', 0.3)

    def save_window_pos(self):
        if not self.window_handle:
            return
        self.set_pref("pos", dict(
            x=self.winfo_x(),
            y=self.winfo_y(),
            width=self.width,
            height=self.height,
            initialized=True
        ))

    def close_window(self):
        if self.window_handle:
            # Store the current position of our window handle to used when it is reopened
            self.save_window_pos()
            self.master.window.wm_forget(self)
            self.window_handle = None
            self.studio.minimize(self)
            self.set_pref("visible", False)
Beispiel #11
0
class CollapseFrame(Frame):
    __icons_loaded = False
    EXPAND = None
    COLLAPSE = None

    def __init__(self, master, **cnf):
        super().__init__(master, **cnf)
        self._load_icons()
        self.config(**self.style.surface)
        self._label_frame = Frame(self, **self.style.bright, height=20)
        self._label_frame.pack(side="top", fill="x", padx=2)
        self._label_frame.pack_propagate(0)
        self._label = Label(self._label_frame, **self.style.bright,
                            **self.style.text_bright)
        self._label.pack(side="left")
        self._collapse_btn = Button(self._label_frame,
                                    width=20,
                                    **self.style.bright,
                                    **self.style.text_bright)
        self._collapse_btn.config(image=self.COLLAPSE)
        self._collapse_btn.pack(side="right", fill="y")
        self._collapse_btn.on_click(self.toggle)
        self.body = Frame(self, **self.style.surface)
        self.body.pack(side="top", fill="both", pady=2)
        self.__ref = Frame(self.body, height=0, width=0, **self.style.surface)
        self.__ref.pack(side="top")
        self._collapsed = False

    @classmethod
    def _load_icons(cls):
        if cls.__icons_loaded:
            return
        cls.EXPAND = get_icon_image("triangle_down", 14, 14)
        cls.COLLAPSE = get_icon_image("triangle_up", 14, 14)

    def update_state(self):
        self.__ref.pack(side="top")

    def collapse(self, *_):
        if not self._collapsed:
            self.body.pack_forget()
            self._collapse_btn.config(image=self.EXPAND)
            self.pack_propagate(0)
            self.config(height=20)
            self._collapsed = True

    def clear_children(self):
        self.body.clear_children()

    def expand(self, *_):
        if self._collapsed:
            self.body.pack(side="top", fill="both")
            self.pack_propagate(1)
            self._collapse_btn.config(image=self.COLLAPSE)
            self._collapsed = False

    def toggle(self, *_):
        if self._collapsed:
            self.expand()
        else:
            self.collapse()

    @property
    def label(self):
        return self._label["text"]

    @label.setter
    def label(self, value):
        self._label.config(text=value)
Beispiel #12
0
class MenuEditor(BaseToolWindow):
    # TODO Add context menu for nodes
    # TODO Add style search
    # TODO Handle widget change from the studio main control
    _MESSAGE_EDITOR_EMPTY = "No item selected"

    def __init__(self, master, widget, menu=None):
        super().__init__(master, widget)
        self.title(f'Edit menu for {widget.id}')
        if not isinstance(menu, tk.Menu):
            menu = tk.Menu(widget, tearoff=False)
            widget.configure(menu=menu)
        self._base_menu = menu
        self._tool_bar = Frame(self, **self.style.dark, **self.style.dark_highlight_dim, height=30)
        self._tool_bar.pack(side="top", fill="x")
        self._tool_bar.pack_propagate(False)
        self._pane = PanedWindow(self, **self.style.dark_pane_horizontal)
        self._tree = MenuTree(self._pane, widget, menu)
        self._tree.allow_multi_select(True)
        self._tree.on_select(self._refresh_styles)
        self._tree.on_structure_change(self._refresh_styles)

        self._editor_pane = ScrolledFrame(self._pane)
        self._editor_pane_cover = Label(self._editor_pane, **self.style.dark_text_passive)
        self._editor_pane.pack(side="top", fill="both", expand=True)
        self._menu_item_styles = CollapseFrame(self._editor_pane.body)
        self._menu_item_styles.pack(side="top", fill="x", pady=4)
        self._menu_item_styles.label = "Menu Item attributes"
        self._menu_styles = CollapseFrame(self._editor_pane.body)
        self._menu_styles.pack(side="top", fill="x", pady=4)
        self._menu_styles.label = "Menu attributes"
        self._style_item_ref = {}
        self._menu_style_ref = {}
        self._prev_selection = None

        self._add = MenuButton(self._tool_bar, **self.style.dark_button)
        self._add.pack(side="left")
        self._add.configure(image=get_icon_image("add", 15, 15))
        _types = MenuTree.Node._type_def
        menu_types = self._tool_bar.make_menu(
            [(
                tk.COMMAND,
                i.title(),
                get_icon_image(_types[i][0], 14, 14),
                functools.partial(self.add_item, i), {}
            ) for i in _types],
            self._add)
        menu_types.configure(tearoff=True)
        self._add.config(menu=menu_types)
        self._delete_btn = Button(self._tool_bar, image=get_icon_image("delete", 15, 15), **self.style.dark_button,
                                  width=25,
                                  height=25)
        self._delete_btn.pack(side="left")
        self._delete_btn.on_click(self._delete)

        self._preview_btn = Button(self._tool_bar, image=get_icon_image("play", 15, 15), **self.style.dark_button,
                                   width=25, height=25)
        self._preview_btn.pack(side="left")
        self._preview_btn.on_click(self._preview)

        self._pane.pack(side="top", fill="both", expand=True)
        self._pane.add(self._tree, minsize=350, sticky='nswe', width=350, height=500)
        self._pane.add(self._editor_pane, minsize=320, sticky='nswe', width=320, height=500)
        self.load_menu(menu, self._tree)
        self._show_editor_message(self._MESSAGE_EDITOR_EMPTY)
        self.enable_centering()
        self.focus_set()
        self._load_all_properties()

    def _show_editor_message(self, message):
        # Show an overlay message
        self._editor_pane_cover.config(text=message)
        self._editor_pane_cover.place(x=0, y=0, relwidth=1, relheight=1)

    def _clear_editor_message(self):
        self._editor_pane_cover.place_forget()

    def _show(self, item):
        item.pack(fill="x", pady=1)

    def _hide(self, item):
        item.pack_forget()

    def _add_item(self, item):
        # add a menu item style editor
        self._style_item_ref[item.name] = item
        self._show(item)

    def _add_menu_item(self, item):
        # add a parent menu style editor
        self._menu_style_ref[item.name] = item
        self._show(item)

    def _load_all_properties(self):
        # Generate all style editors that may be needed by any of the types of menu items
        # This needs to be called only once
        ref = dict(PROPERTY_TABLE)
        ref.update(MENU_PROPERTY_TABLE)
        for prop in MENU_PROPERTIES:
            if not ref.get(prop):
                continue
            definition = dict(ref.get(prop))
            definition['name'] = prop
            self._add_item(StyleItem(self._menu_item_styles, definition, self._on_item_change))
        menu_prop = get_properties(self._base_menu)
        for key in menu_prop:
            definition = menu_prop[key]
            self._add_menu_item(StyleItem(self._menu_styles, definition, self._on_menu_item_change))

    def _on_item_change(self, prop, value):
        # Called when the style of a menu item changes
        for node in self._tree.get():
            menu_config(node._menu, node.get_index(), **{prop: value})
            # For changes in label we need to change the label on the node as well node
            node.label = node._menu.entrycget(node.get_index(), 'label')

    def _on_menu_item_change(self, prop, value):
        nodes = self._tree.get()
        menus = set([node._menu for node in nodes])
        for menu in menus:
            menu[prop] = value

    def _refresh_styles(self):
        # TODO Fix false value change when releasing ctrl key during multi-selecting
        # called when structure or selection changes
        nodes = self._tree.get()  # get current selection
        if not nodes:
            # if no nodes are currently selected display message
            self._show_editor_message(self._MESSAGE_EDITOR_EMPTY)
            return
        self._clear_editor_message()  # remove any messages
        # get intersection of styles for currently selected nodes
        # these will be the styles common to all the nodes selected, use sets for easy analysis
        styles = set(nodes[0].get_options().keys())
        for node in nodes:
            styles &= set(node.get_options().keys())
        # populate editors with values of the last item
        # TODO this is not the best approach, no value should be set for an option if it is not the same for all nodes
        node = nodes[-1]
        for style_item in self._style_item_ref.values():
            # styles for menu items
            if style_item.name in styles:
                self._show(style_item)
                style_item.set(node.get_option(style_item.name))
            else:
                self._hide(style_item)

        for style_item in self._menu_style_ref.values():
            # styles for the menu
            style_item.set(node._menu.cget(style_item.name))

    def _preview(self, *_):
        self.widget.event_generate("<Button-1>")

    def _delete(self, *_):
        # create a copy since the list may change during iteration
        selected = list(self._tree.get())
        for node in selected:
            self._tree.deselect(node)
            node.remove()
        self._refresh_styles()

    def add_item(self, _type):
        label = f"{_type.title()}"
        selected = self._tree.get()
        if len(selected) == 1 and selected[0].type == tk.CASCADE:
            node = selected[0]
        else:
            node = self._tree

        if _type != tk.SEPARATOR:
            node._sub_menu.add(_type, label=label)
        else:
            node._sub_menu.add(_type)
        node.add_menu_item(type=_type, label=label, index=tk.END)

    def load_menu(self, menu, node):
        # if the widget has a menu we need to populate the tree when the editor is created
        # we do this recursively to be able to capture even cascades
        # we cannot directly access all items in a menu or its size
        # but we can get the index of the last item and use that to get size and hence viable indexes
        size = menu.index(tk.END)
        if size is None:
            # menu is empty
            return
        for i in range(size + 1):
            if menu.type(i) == tk.CASCADE:
                label = menu.entrycget(i, "label")
                # get the cascades sub-menu and load it recursively
                sub = self.nametowidget(menu.entrycget(i, "menu"))
                item_node = node.add_menu_item(type=menu.type(i), label=label, index=i, sub_menu=sub)
                self.load_menu(item_node._sub_menu, item_node)
            elif menu.type(i) == tk.SEPARATOR:
                # Does not need a label, set it to the default 'separator'
                node.add_menu_item(type=menu.type(i), index=i, label='separator')
            elif menu.type(i) != 'tearoff':
                # skip any tear_off item since they cannot be directly manipulated
                label = menu.entrycget(i, "label")
                node.add_menu_item(type=menu.type(i), label=label, index=i)
Beispiel #13
0
    def __init__(self, master, studio=None, **cnf):
        super().__init__(master, studio, **cnf)
        f = Frame(self, **self.style.surface)
        f.pack(side="top", fill="both", expand=True, pady=4)
        f.pack_propagate(0)

        self._variable_pane = ScrolledFrame(f, width=150)
        self._variable_pane.place(x=0, y=0, relwidth=0.4, relheight=1)

        self._detail_pane = ScrolledFrame(f, width=150)
        self._detail_pane.place(relx=0.4,
                                y=0,
                                relwidth=0.6,
                                relheight=1,
                                x=15,
                                width=-20)

        Label(self._detail_pane.body,
              **self.style.text_passive,
              text="Type",
              anchor="w").pack(side="top", fill="x")
        self.var_type_lbl = Label(self._detail_pane.body,
                                  **self.style.text,
                                  anchor="w")
        self.var_type_lbl.pack(side="top", fill="x")
        Label(self._detail_pane.body,
              **self.style.text_passive,
              text="Name",
              anchor="w").pack(side="top", fill="x")
        self.var_name = editors.get_editor(self._detail_pane.body,
                                           self._definitions["name"])
        self.var_name.pack(side="top", fill="x")
        Label(self._detail_pane.body,
              **self.style.text_passive,
              text="Value",
              anchor="w").pack(fill="x", side="top")
        self._editors = {}
        self._editor = None

        self._search_btn = Button(self._header,
                                  image=get_icon_image("search", 15, 15),
                                  width=25,
                                  height=25,
                                  **self.style.button)
        self._search_btn.pack(side="right")
        self._search_btn.on_click(self.start_search)
        self._search_query = None

        self._add = MenuButton(self._header, **self.style.button)
        self._add.configure(image=get_icon_image("add", 15, 15))
        self._add.pack(side="right")
        self._delete_btn = Button(self._header,
                                  image=get_icon_image("delete", 15, 15),
                                  width=25,
                                  height=25,
                                  **self.style.button)
        self._delete_btn.pack(side="right")
        self._delete_btn.on_click(self._delete)
        self._var_types_menu = self.make_menu(self._get_add_menu(),
                                              self._add,
                                              title="Add variable")
        self._var_types_menu.configure(tearoff=True)
        self._add.config(menu=self._var_types_menu)
        self._selected = None
        self._links = {}
        self._overlay = Label(f,
                              **self.style.text_passive,
                              text=self._empty_message,
                              compound="top")
        self._overlay.configure(image=get_icon_image("add", 25, 25))
        self._show_overlay(True)
Beispiel #14
0
class EventPane(BaseFeature):
    name = "Event pane"
    icon = "blank"
    _defaults = {
        **BaseFeature._defaults,
        "side": "right",
    }
    NO_SELECTION_MSG = "You have not selected any widget selected"
    NO_EVENT_MSG = "You have not added any bindings"
    NO_MATCH_MSG = "No items match your search"

    def __init__(self, master, studio, **cnf):
        super().__init__(master, studio, **cnf)
        self.header = Frame(self, **self.style.surface)
        self.header.pack(side="top", fill="x")
        for i, title in enumerate(("Sequence", "Handler", "Add", " " * 3)):
            Label(
                self.header,
                **self.style.text_passive,
                text=title,
                anchor="w",
            ).grid(row=0, column=i, sticky='ew')

        # set the first two columns to expand evenly
        for column in range(2):
            self.header.grid_columnconfigure(column, weight=1, uniform=1)

        self.bindings = BindingsTable(self)
        self.bindings.on_value_change(self.modify_item)
        self.bindings.on_item_delete(self.delete_item)
        self.bindings.pack(fill="both", expand=True)

        self._add = Button(self._header,
                           **self.style.button,
                           width=25,
                           height=25,
                           image=get_icon_image("add", 15, 15))
        self._add.pack(side="right")
        self._add.tooltip("Add event binding")
        self._add.on_click(self.add_new)

        self._search_btn = Button(
            self._header,
            **self.style.button,
            image=get_icon_image("search", 15, 15),
            width=25,
            height=25,
        )
        self._search_btn.pack(side="right")
        self._search_btn.on_click(self.start_search)

        self._empty_frame = Label(self.bindings, **self.style.text_passive)
        self._show_empty(self.NO_SELECTION_MSG)

    def _show_empty(self, message):
        self._empty_frame.place(x=0, y=0, relwidth=1, relheight=1)
        self._empty_frame["text"] = message

    def _remove_empty(self):
        self._empty_frame.place_forget()

    def add_new(self, *_):
        if self.studio.selected is None:
            return
        self._remove_empty()
        new_binding = make_event("<>", "", False)
        widget = self.studio.selected
        if not hasattr(widget, "_event_map_"):
            setattr(widget, "_event_map_", {})
        widget._event_map_[new_binding.id] = new_binding
        self.bindings.add(new_binding)

    def delete_item(self, item):
        widget = self.studio.selected
        if widget is None:
            return
        widget._event_map_.pop(item.id)
        self.bindings.remove(item.id)

    def modify_item(self, value: EventBinding):
        widget = self.studio.selected
        widget._event_map_[value.id] = value

    def on_select(self, widget):
        if widget is None:
            self._show_empty(self.NO_SELECTION_MSG)
            return
        self._remove_empty()
        bindings = getattr(widget, "_event_map_", {})
        values = bindings.values()
        self.bindings.clear()
        self.bindings.add(*values)
        if not values:
            self._show_empty(self.NO_EVENT_MSG)

    def start_search(self, *_):
        if self.studio.selected:
            super().start_search()

    def on_search_query(self, query: str):
        showing = 0
        self._remove_empty()
        self.bindings.hide_all()
        for item in self.bindings.items:
            if query in item.value.sequence or query in item.value.handler:
                item.show()
                showing += 1
        if not showing:
            self._show_empty(self.NO_MATCH_MSG)

    def on_search_clear(self):
        self._remove_empty()
        self.bindings.hide_all()
        for item in self.bindings.items:
            item.show()
        super().on_search_clear()
Beispiel #15
0
class StyleGroup(CollapseFrame):
    """
    Main subdivision of the Style pane
    """
    handles_layout = False
    self_positioned = False

    def __init__(self, master, pane, **cnf):
        super().__init__(master)
        self.style_pane = pane
        self.configure(**{**self.style.surface, **cnf})
        self._empty_message = "Select an item to see styles"
        self._empty = Frame(self.body, **self.style.surface)
        self._empty_label = Label(
            self._empty,
            **self.style.text_passive,
        )
        self._empty_label.pack(fill="both", expand=True, pady=15)
        self._widget = None
        self._prev_widget = None
        self._has_initialized = False  # Flag to mark whether Style Items have been created
        self.items = {}

    @property
    def widget(self):
        return self._widget

    def can_optimize(self):
        return False

    def add(self, style_item):
        self.items[style_item.name] = style_item
        if self.style_pane._search_query is not None:
            if self._match_query(style_item.definition,
                                 self.style_pane._search_query):
                self._show(style_item)
            # make sure item is not available for reuse whether it
            # is displayed or not
            style_item._make_available(False)
        else:
            self._show(style_item)

    def remove(self, style_item):
        if style_item.name in self.items:
            self.items.pop(style_item.name)
        self._hide(style_item)

    def _show(self, item):
        item.pack(side="top", fill="x", pady=1)

    def _hide(self, item):
        item.pack_forget()

    def _get_prop(self, prop, widget):
        return widget.get_prop(prop)

    def _set_prop(self, prop, value, widget):
        widget.configure(**{prop: value})

    def _hide_group(self):
        pass

    def _show_group(self):
        pass

    def _match_query(self, definition, query):
        return query in definition["name"] or query in definition[
            "display_name"]

    def _show_empty(self, text=None):
        self._empty.pack(fill="both", expand=True)
        text = self._empty_message if text is None else text
        self._empty_label["text"] = text

    def _remove_empty(self):
        self._empty.pack_forget()

    def on_widget_change(self, widget):
        self._widget = widget
        if widget is None:
            self.collapse()
            return
        definitions = self.get_definition()
        if self.can_optimize():
            for prop in definitions:
                self.items[prop]._re_purposed(definitions[prop])
        else:
            self.style_pane.show_loading()
            # this unmaps all style items returning them to the pool for reuse
            self.clear_children()
            # make all items held by group available for reuse
            ReusableStyleItem.free_all(self.items.values())
            self.items.clear()
            add = self.add
            list(
                map(
                    lambda p: add(
                        ReusableStyleItem.acquire(self, definitions[p], self.
                                                  apply), ), definitions))
            if not self.items:
                self._show_empty()
            else:
                self._remove_empty()
            # self.style_pane.body.scroll_to_start()

        self._has_initialized = True
        self._prev_widget = widget

    def _apply_action(self, prop, value, widget, data):
        self.apply(prop, value, widget, True)

    def _get_action_data(self, widget, prop):
        return {}

    def _get_key(self, widget, prop):
        return f"{widget}:{self.__class__.__name__}:{prop}"

    def apply(self, prop, value, widget=None, silent=False):
        is_external = widget is not None
        widget = self.widget if widget is None else widget
        if widget is None:
            return
        try:
            prev_val = self._get_prop(prop, widget)
            data = self._get_action_data(widget, prop)
            self._set_prop(prop, value, widget)
            new_data = self._get_action_data(widget, prop)
            self.style_pane.widget_modified(widget)
            if is_external:
                if widget == self.widget:
                    self.items[prop].set_silently(value)
            if silent:
                return
            key = self._get_key(widget, prop)
            action = self.style_pane.last_action()
            if action is None or action.key != key:
                self.style_pane.new_action(
                    Action(
                        lambda _: self._apply_action(prop, prev_val, widget,
                                                     data),
                        lambda _: self._apply_action(prop, value, widget,
                                                     new_data),
                        key=key,
                    ))
            else:
                action.update_redo(lambda _: self._apply_action(
                    prop, value, widget, new_data))
        except Exception as e:
            # Empty string values are too common to be useful in logger debug
            if value != '':
                logging.error(e)
                logging.error(
                    f"Could not set {self.__class__.__name__} {prop} as {value}",
                )

    def get_definition(self):
        return {}

    def supports_widget(self, widget):
        return True

    def on_search_query(self, query):
        item_found = False
        for item in self.items.values():
            if self._match_query(item.definition, query):
                self._show(item)
                item_found = True
            else:
                self._hide(item)
        if not item_found:
            self._show_empty("No items match your search")
        else:
            self._remove_empty()

    def on_search_clear(self):
        # Calling search query with empty query ensures all items are displayed
        self.clear_children()
        self.on_search_query("")
Beispiel #16
0
class StudioApplication(Application):
    ICON_PATH = get_resource_path(studio, "resources/images/formation_icon.png")
    THEME_PATH = pref.get("resource::theme")

    def __init__(self, master=None, **cnf):
        super().__init__(master, **cnf)
        # Load icon asynchronously to prevent issues which have been known to occur when loading it synchronously
        icon_image = load_tk_image(self.ICON_PATH)
        self.load_styles(self.THEME_PATH)
        self.iconphoto(True, icon_image)
        self.pref = pref
        self._restore_position()
        self.title('Formation Studio')
        self.protocol('WM_DELETE_WINDOW', self._on_close)
        self.shortcuts = ShortcutManager(self, pref)
        self.shortcuts.bind_all()
        self._register_actions()
        self._toolbar = Frame(self, **self.style.surface, height=30)
        self._toolbar.pack(side="top", fill="x")
        self._toolbar.pack_propagate(0)
        self._statusbar = Frame(self, **self.style.surface, height=20)
        self._statusbar.pack(side="bottom", fill="x")
        self._statusbar.pack_propagate(0)
        body = Frame(self, **self.style.surface)
        body.pack(fill="both", expand=True, side="top")
        self._right_bar = SideBar(body)
        self._right_bar.pack(side="right", fill="y")
        self._left_bar = SideBar(body)
        self._left_bar.pack(side="left", fill="y")
        self._pane = PanedWindow(body, **self.style.pane_horizontal)
        self._pane.pack(side="left", fill="both", expand=True)
        self._left = FeaturePane(self._pane, **self.style.pane_vertical)
        self._center = PanedWindow(self._pane, **self.style.pane_vertical)
        self._right = FeaturePane(self._pane, **self.style.pane_vertical)

        self._bin = []
        self._clipboard = None
        self.current_preview = None

        self._pane.add(self._left, minsize=320, sticky='nswe', width=320)
        self._pane.add(self._center, minsize=400, width=16000, sticky='nswe')
        self._pane.add(self._right, minsize=320, sticky='nswe', width=320)

        self._panes = {
            "left": (self._left, self._left_bar),
            "right": (self._right, self._right_bar),
            "center": (self._center, None)
        }

        icon = get_icon_image

        self.actions = (
            ("Delete", icon("delete", 20, 20), lambda e: self.delete(), "Delete selected widget"),
            ("Undo", icon("undo", 20, 20), lambda e: self.undo(), "Undo action"),
            ("Redo", icon("redo", 20, 20), lambda e: self.redo(), "Redo action"),
            ("Cut", icon("cut", 20, 20), lambda e: self.cut(), "Cut selected widget"),
            ("separator",),
            ("Fullscreen", icon("image_editor", 20, 20), lambda e: self.close_all(), "Design mode"),
            ("Separate", icon("separate", 20, 20), lambda e: self.features_as_windows(),
             "Open features in window mode"),
            ("Dock", icon("flip_horizontal", 15, 15), lambda e: self.features_as_docked(),
             "Dock all features"),
            ("separator",),
            ("New", icon("add", 20, 20), lambda e: self.open_new(), "New design"),
            ("Save", icon("save", 20, 20), lambda e: self.save(), "Save design"),
            ("Preview", icon("play", 20, 20), lambda e: self.preview(), "Preview design"),
        )

        self.init_toolbar()
        self.selected = None
        # set the image option to blank if there is no image for the menu option
        self.blank_img = blank_img = icon("blank", 14, 14)

        self.tool_manager = ToolManager(self)

        # -------------------------------------------- menu definition ------------------------------------------------
        self.menu_template = (EnableIf(
            lambda: self.selected,
            ("separator",),
            ("command", "copy", icon("copy", 14, 14), actions.get('STUDIO_COPY'), {}),
            ("command", "duplicate", icon("copy", 14, 14), actions.get('STUDIO_DUPLICATE'), {}),
            EnableIf(
                lambda: self._clipboard is not None,
                ("command", "paste", icon("clipboard", 14, 14), actions.get('STUDIO_PASTE'), {})
            ),
            ("command", "cut", icon("cut", 14, 14), actions.get('STUDIO_CUT'), {}),
            ("separator",),
            ("command", "delete", icon("delete", 14, 14), actions.get('STUDIO_DELETE'), {}),
        ),)

        self.menu_bar = MenuUtils.make_dynamic(
            ((
                 ("cascade", "formation", None, None, {"menu": (
                     ("command", "Restart", None, actions.get('STUDIO_RESTART'), {}),
                     ("separator", ),
                     ("command", "About Formation", icon("formation", 14, 14), lambda: about_window(self), {}),
                 ), "name": "apple"}),
             ) if platform_is(MAC) else ()) +
            (
                ("cascade", "File", None, None, {"menu": (
                    ("command", "New", icon("add", 14, 14), actions.get('STUDIO_NEW'), {}),
                    ("command", "Open", icon("folder", 14, 14), actions.get('STUDIO_OPEN'), {}),
                    ("cascade", "Recent", icon("clock", 14, 14), None, {"menu": self._create_recent_menu()}),
                    ("separator",),
                    EnableIf(
                        lambda: self.designer,
                        ("command", "Save", icon("save", 14, 14), actions.get('STUDIO_SAVE'), {}),
                        ("command", "Save As", icon("blank", 14, 14), actions.get('STUDIO_SAVE_AS'), {})
                    ),
                    EnableIf(
                        # more than one design contexts open
                        lambda: len([i for i in self.contexts if isinstance(i, DesignContext)]) > 1,
                        ("command", "Save All", icon("blank", 14, 14), actions.get('STUDIO_SAVE_ALL'), {})
                    ),
                    ("separator",),
                    ("command", "Settings", icon("settings", 14, 14), actions.get('STUDIO_SETTINGS'), {}),
                    ("command", "Restart", icon("blank", 14, 14), actions.get('STUDIO_RESTART'), {}),
                    ("command", "Exit", icon("close", 14, 14), actions.get('STUDIO_EXIT'), {}),
                )}),
                ("cascade", "Edit", None, None, {"menu": (
                    EnableIf(lambda: self.context and self.context.has_undo(),
                             ("command", "undo", icon("undo", 14, 14), actions.get('STUDIO_UNDO'), {})),
                    EnableIf(lambda: self.context and self.context.has_redo(),
                             ("command", "redo", icon("redo", 14, 14), actions.get('STUDIO_REDO'), {})),
                    *self.menu_template,
                )}),
                ("cascade", "Code", None, None, {"menu": (
                    EnableIf(
                        lambda: self.designer and self.designer.root_obj,
                        ("command", "Preview design", icon("play", 14, 14), actions.get('STUDIO_PREVIEW'), {}),
                        ("command", "close preview", icon("close", 14, 14), actions.get('STUDIO_PREVIEW_CLOSE'), {}),
                        ("separator", ),
                        EnableIf(
                            lambda: self.designer and self.designer.design_path,
                            ("command", "Reload design file", icon("rotate_clockwise", 14, 14),
                             actions.get('STUDIO_RELOAD'), {}),
                        ),
                    )
                )}),
                ("cascade", "View", None, None, {"menu": (
                    ("command", "show all panes", blank_img, actions.get('FEATURE_SHOW_ALL'), {}),
                    ("command", "close all panes", icon("close", 14, 14), actions.get('FEATURE_CLOSE_ALL'), {}),
                    ("command", "close all panes on the right", blank_img, actions.get('FEATURE_CLOSE_RIGHT'), {}),
                    ("command", "close all panes on the left", blank_img, actions.get('FEATURE_CLOSE_LEFT'), {}),
                    ("separator",),
                    ("command", "Undock all windows", blank_img, actions.get('FEATURE_UNDOCK_ALL'), {}),
                    ("command", "Dock all windows", blank_img, actions.get('FEATURE_DOCK_ALL'), {}),
                    ("separator",),
                    LoadLater(self.get_features_as_menu),
                    ("separator",),
                    EnableIf(
                        lambda: self.context,
                        ("command", "close tab", icon("close", 14, 14), actions.get('CONTEXT_CLOSE'), {}),
                        ("command", "close all tabs", blank_img, actions.get('CONTEXT_CLOSE_ALL'), {}),
                        EnableIf(
                            lambda: self.context and len(self.tab_view.tabs()) > 1,
                            ("command", "close other tabs", blank_img, actions.get('CONTEXT_CLOSE_OTHER'), {})
                        ),
                        EnableIf(
                            lambda: self.context and self.context._contexts_right(),
                            ("command", "close all tabs on the right", blank_img,
                             actions.get('CONTEXT_CLOSE_OTHER_RIGHT'), {})
                        )
                    ),
                    ("separator",),
                    ("command", "Save window positions", blank_img, actions.get('FEATURE_SAVE_POS'), {})
                )}),
                ("cascade", "Tools", None, None, {"menu": (LoadLater(self.tool_manager.get_tools_as_menu), )}),
                ("cascade", "Help", None, None, {"menu": (
                    ("command", "Help", icon('dialog_info', 14, 14), actions.get('STUDIO_HELP'), {}),
                    ("command", "Check for updates", icon("cloud", 14, 14), self._check_updates, {}),
                    ("separator",),
                    ("command", "About Formation", icon("formation", 14, 14), lambda: about_window(self), {}),
                )})
            ), self, self.style, False)

        self.config(menu=self.menu_bar)

        if platform_is(MAC):
            self.createcommand("tk::mac::ShowPreferences", lambda: actions.get('STUDIO_SETTINGS').invoke())
            self.createcommand("tk::mac::ShowHelp", lambda: actions.get('STUDIO_HELP').invoke())
            self.createcommand("tk::mac::Quit", lambda: actions.get('STUDIO_EXIT').invoke())

        self.features = []
        self.context = None
        self.contexts = []
        self.tab_view = TabView(self._center)
        self.tab_view.malleable(True)
        self.tab_view.bind("<<TabSelectionChanged>>", self.on_context_switch)
        self.tab_view.bind("<<TabClosed>>", self.on_context_close)
        self.tab_view.bind("<<TabAdded>>", self.on_context_add)
        self.tab_view.bind("<<TabOrderChanged>>", lambda _: self.save_tab_status())
        self._center.add(self.tab_view, sticky='nswe')
        self._tab_view_empty = Label(
            self.tab_view, **self.style.text_passive, compound='top',
            image=get_icon_image("paint", 60, 60)
        )
        self._tab_view_empty.config(**self.style.bright)

        # install features
        for feature in FEATURES:
            self.install(feature)

        # common feature references
        self.style_pane = self.get_feature(StylePane)

        # initialize tools with everything ready
        self.tool_manager.initialize()

        self._ignore_tab_status = False
        self._startup()
        self._exit_failures = 0
        self._is_shutting_down = False

    def on_context_switch(self, _):
        selected = self.tab_view.selected
        if isinstance(self.context, BaseContext):
            self.context.on_context_unset()

        if isinstance(selected, BaseContext):
            self.context = selected
        else:
            self.context = None

        for feature in self.features:
            feature.on_context_switch()

        self.tool_manager.on_context_switch()

        if self.context:
            selected.on_context_set()

        # switch selection to that of the new context
        if self.designer:
            self.select(self.designer.current_obj, self.designer)
        else:
            self.select(None)
        self.save_tab_status()

    def on_context_close(self, context):
        if not self.tab_view.tabs():
            self._show_empty("Open a design file")
        if context in self.contexts:
            self.contexts.remove(context)
        for feature in self.features:
            feature.on_context_close(context)
        self.tool_manager.on_context_close(context)
        self.save_tab_status()

    def on_context_add(self, _):
        self._show_empty(None)

    def add_context(self, context, select=True):
        self.contexts.append(context)
        tab = self.tab_view.add(
            context, None, False, text=context.name, icon=context.icon, closeable=True
        )
        context.tab_handle = tab
        if select:
            self.tab_view.select(tab)
        context.on_context_mount()
        self.save_tab_status()

    def create_context(self, context, *args, select=True, **kwargs):
        new_context = context(self.tab_view, self, *args, **kwargs)
        self.add_context(new_context, select)
        return new_context

    def close_context(self):
        if self.context:
            self.context.close()

    def close_all_contexts(self):
        if self.check_unsaved_changes():
            for context in list(self.contexts):
                context.close(force=True)

    def close_other_contexts(self):
        if self.context:
            self.context.close_other()

    def close_other_contexts_right(self):
        if self.context:
            self.context.close_other_right()

    @property
    def designer(self):
        if isinstance(self.context, DesignContext):
            return self.context.designer

    def _show_empty(self, text):
        if text:
            self._tab_view_empty.lift()
            self._tab_view_empty['text'] = text
            self._tab_view_empty.place(x=0, y=0, relwidth=1, relheight=1)
        else:
            self._tab_view_empty.place_forget()

    def _startup(self):
        on_startup = pref.get("studio::on_startup")
        if on_startup == "new":
            self.open_new()
        elif on_startup == "recent":
            self.restore_tabs()
        else:
            self._show_empty("Open a design file")

    def _get_window_state(self):
        try:
            if self.wm_attributes("-zoomed"):
                return 'zoomed'
            return 'normal'
        except:
            # works for windows and mac os
            return self.state()

    def _set_window_state(self, state):
        try:
            # works in windows and mac os
            self.state(state)
        except:
            self.wm_attributes('-zoomed', state == 'zoomed')

    def _save_position(self):
        # self.update_idletasks()
        pref.set("studio::pos", dict(
            geometry=self.geometry(),
            state=self._get_window_state(),  # window state either zoomed or normal
        ))

    def _restore_position(self):
        pos = pref.get("studio::pos")
        state = pos.get('state', 'zoomed')
        self._set_window_state(state)
        if state == 'normal' and pos.get('geometry'):
            self.geometry(pos['geometry'])

    def new_action(self, action: Action):
        """
        Register a undo redo point
        :param action: An action object implementing undo and redo methods
        :return:
        """
        if self.context:
            self.context.new_action(action)

    def undo(self):
        if self.context:
            self.context.undo()

    def redo(self):
        if self.context:
            self.context.redo()

    def last_action(self):
        if self.context:
            return self.context.last_action()

    def pop_last_action(self, key=None):
        if self.context:
            self.context.pop_last_action(key)

    def copy(self):
        if self.designer and self.selected:
            # store the current object as  node in the clipboard
            self._clipboard = self.designer.as_node(self.selected)

    def install_status_widget(self, widget_class, *args, **kwargs):
        widget = widget_class(self._statusbar, *args, **kwargs)
        widget.pack(side='right', padx=2, fill='y')
        return widget

    def get_pane_info(self, pane):
        return self._panes.get(pane, [self._right, self._right_bar])

    def paste(self):
        if self.designer and self._clipboard is not None:
            self.designer.paste(self._clipboard)

    def close_all_on_side(self, side):
        for feature in self.features:
            if feature.side == side:
                feature.minimize()
        # To avoid errors when side is not a valid pane identifier we default to the right pane
        self._panes.get(side, (self._right, self._right_bar))[1].close_all()

    def close_all(self, *_):
        for feature in self.features:
            feature.minimize()
        self._right_bar.close_all()
        self._left_bar.close_all()

    def init_toolbar(self):
        for action in self.actions:
            if len(action) == 1:
                Frame(self._toolbar, width=1, bg=self.style.colors.get("primarydarkaccent")).pack(
                    side='left', fill='y', pady=3, padx=5)
                continue
            btn = Button(self._toolbar, image=action[1], **self.style.button, width=25, height=25)
            btn.pack(side="left", padx=3)
            btn.tooltip(action[3])
            ActionNotifier.bind_event("<Button-1>", btn, action[2], text=action[3])

    def uninstall(self, feature):
        self.features.remove(feature)
        feature.bar.remove(feature)
        feature.pane.forget(feature)
        self._adjust_pane(feature.pane)

    def get_pane_bar(self, side):
        if side in self._panes:
            return self._panes.get(side, (self._left, self._left_bar))

    def reposition(self, feature: BaseFeature, side):
        if self.get_pane_bar(side):
            pane, bar = self.get_pane_bar(side)
            feature.bar.remove(feature)
            feature.pane.forget(feature)
            self._adjust_pane(feature.pane)
            feature.bar = bar
            feature.pane = pane
            bar.add_feature(feature)
            if feature.get_pref("mode") == "docked":
                pane.add(feature, minsize=100)
            feature.set_pref("side", side)

    def install(self, feature) -> BaseFeature:
        obj = feature(self, self)
        pane, bar = self._panes.get(obj.get_pref('side'), (self._left, self._left_bar))
        obj.pane = pane
        obj.bar = bar
        self.features.append(obj)
        if bar is not None:
            bar.add_feature(obj)
        if not obj.get_pref('visible'):
            bar.deselect(obj)
            self._adjust_pane(pane)
        else:
            bar.select(obj)
            obj.maximize()
        return obj

    def show_all_windows(self):
        for feature in self.features:
            feature.maximize()

    def features_as_windows(self):
        for feature in self.features:
            feature.open_as_window()

    def features_as_docked(self):
        for feature in self.features:
            feature.open_as_docked()

    def set_path(self, path):
        if path:
            file_dir = os.path.dirname(path)
            if os.path.exists(file_dir):
                # change working directory
                os.chdir(file_dir)
        path = path or "untitled"
        self.title("Formation studio" + " - " + str(path))

    @dynamic_menu
    def _create_recent_menu(self, menu):
        # Dynamically create recent file menu every time menu is posted
        menu.image = get_icon_image("close", 14, 14)
        menu.config(**self.style.context_menu)
        recent = pref.get_recent()
        for path, label in recent:
            menu.add_command(
                label=label,
                command=functools.partial(self.open_recent, path),
                image=self.blank_img, compound='left',
            )
        menu.add_command(
            label="Clear", image=menu.image, command=pref.clear_recent,
            compound="left"
        )

    def open_file(self, path=None):
        if path is None:
            path = filedialog.askopenfilename(parent=self, filetypes=get_file_types())
        elif not os.path.exists(path):
            MessageDialog.show_error(
                parent=self,
                title="Missing File",
                message="File {} does not exist".format(path),
            )
            return
        if path:
            # find if path is already open on the designer
            for context in self.contexts:
                if isinstance(context, DesignContext) and context.path == path:
                    # path is open, select
                    context.select()
                    break
            else:
                self.create_context(DesignContext, path)
                self.set_path(path)
                pref.update_recent(path)

    def open_recent(self, path):
        self.open_file(path)

    def open_new(self):
        context = self.create_context(DesignContext)
        self.set_path(context.name)

    def save(self):
        if self.designer:
            path = self.context.save()
            if path:
                self.set_path(path)
                self.save_tab_status()
                pref.update_recent(path)

    def save_as(self):
        if self.designer:
            path = self.context.save(new_path=True)
            if path:
                self.set_path(path)
                self.save_tab_status()
                pref.update_recent(path)

    def save_all(self):
        contexts = [
            i for i in self.contexts if isinstance(i, DesignContext) and i.designer.has_changed()
        ]
        for context in contexts:
            if context.save() is None:
                # save has been cancelled
                break

    def get_feature(self, feature_class) -> BaseFeature:
        for feature in self.features:
            if feature.__class__ == feature_class:
                return feature
        # returns None by if feature is not found

    def get_features_as_menu(self):
        # For each feature we create a menu template
        # The command value is the self.maximize method which will reopen the feature
        return [("checkbutton",  # Type
                 f.name, None,  # Label, image
                 functools.partial(f.toggle),  # Command built from feature
                 {"variable": f.is_visible}) for f in self.features]

    def save_window_positions(self):
        for feature in self.features:
            feature.save_window_pos()
        self._save_position()

    def _adjust_pane(self, pane):
        if len(pane.panes()) == 0:
            self._pane.paneconfig(pane, minsize=0, width=0)
            self._pane.paneconfig(self._center, width=16000)
        else:
            self._pane.paneconfig(pane, minsize=320)

    def minimize(self, feature):
        feature.pane.forget(feature)
        feature.bar.deselect(feature)
        self._adjust_pane(feature.pane)

    def maximize(self, feature):
        feature.pane.add(feature, minsize=100)
        feature.bar.select(feature)
        self._adjust_pane(feature.pane)

    def select(self, widget, source=None):
        self.selected = widget
        if self.designer and source != self.designer:
            # Select from the designer explicitly so the selection does not end up being re-fired
            self.designer.select(widget, True)
        for feature in self.features:
            if feature != source:
                feature.on_select(widget)
        self.tool_manager.on_select(widget)

    def add(self, widget, parent=None):
        for feature in self.features:
            feature.on_widget_add(widget, parent)
        self.tool_manager.on_widget_add(widget, parent)

    def widget_modified(self, widget1, source=None, widget2=None):
        for feature in self.features:
            if feature != source:
                feature.on_widget_change(widget1, widget2)
        if self.designer and self.designer != source:
            self.designer.on_widget_change(widget1, widget2)
        self.tool_manager.on_widget_change(widget1, widget2)

    def widget_layout_changed(self, widget):
        for feature in self.features:
            feature.on_widget_layout_change(widget)
        self.tool_manager.on_widget_layout_change(widget)

    def delete(self, widget=None, source=None):
        widget = self.selected if widget is None else widget
        if widget is None:
            return
        if self.selected == widget:
            self.select(None)
        if self.designer and source != self.designer:
            self.designer.delete(widget)
        for feature in self.features:
            feature.on_widget_delete(widget)
        self.tool_manager.on_widget_delete(widget)

    def cut(self, widget=None, source=None):
        if not self.designer:
            return
        widget = self.selected if widget is None else widget
        if not widget:
            return
        if self.selected == widget:
            self.select(None)
        self._clipboard = self.designer.as_node(widget)
        if source != self.designer:
            self.designer.delete(widget, True)
        for feature in self.features:
            feature.on_widget_delete(widget, True)
        self.tool_manager.on_widget_delete(widget)

    def duplicate(self):
        if self.designer and self.selected:
            self.designer.paste(self.designer.as_node(self.selected))

    def on_restore(self, widget):
        for feature in self.features:
            feature.on_widget_restore(widget)

    def on_feature_change(self, new, old):
        self.features.insert(self.features.index(old), new)
        self.features.remove(old)

    def on_session_clear(self, source):
        for feature in self.features:
            if feature != source:
                feature.on_session_clear()
        self.tool_manager.on_session_clear()

    def restore_tabs(self):
        # ignore all tab status changes as we restore tabs
        self._ignore_tab_status = True
        first_context = None
        has_select = False
        for context_dat in self.pref.get("studio::prev_contexts"):
            context = self.create_context(
                context_dat["class"],
                *context_dat["args"],
                select=context_dat["selected"],
                **context_dat["kwargs"]
            )
            has_select = has_select or context_dat["selected"]
            first_context = context if first_context is None else first_context
            context.deserialize(context_dat["data"])
        if not first_context:
            self._show_empty("Open a design file")
        elif not has_select:
            first_context.select()
        self._ignore_tab_status = False

    def save_tab_status(self):
        if self._ignore_tab_status:
            return
        status = []
        for tab in self.tab_view._tab_order:
            context = self.tab_view._tabs[tab]
            if isinstance(context, BaseContext) and context.can_persist():
                data = context.serialize()
                data["selected"] = self.context == context
                status.append(data)
        self.pref.set("studio::prev_contexts", status)

    def check_unsaved_changes(self, check_contexts=None):
        check_contexts = self.contexts if check_contexts is None else check_contexts
        unsaved = [
            i for i in check_contexts if isinstance(i, DesignContext) and i.designer.has_changed()
        ]
        if len(unsaved) > 1:
            contexts = MultiSaveDialog.ask_save(self, self, check_contexts)
            if contexts is None:
                return False
            for context in contexts:
                if context.designer.save() is None:
                    return False
        elif unsaved:
            return unsaved[0].designer.on_app_close()
        elif unsaved is None:
            return False
        return True

    def preview(self):
        if self.designer.root_obj is None:
            # If there is no root object show a warning
            MessageDialog.show_warning(
                parent=self,
                title='Empty design',
                message='There is nothing to preview. Please add a root widget')
            return
        # close previous preview if any
        self.close_preview()
        window = self.current_preview = Toplevel(self)
        window.wm_transient(self)
        window.build = AppBuilder(window, node=self.designer.to_tree())
        name = self.designer.design_path if self.designer.design_path is not None else "Untitled"
        window.build._app.title(os.path.basename(name))

    def close_preview(self):
        if self.current_preview:
            self.current_preview.destroy()

    def reload(self):
        if self.designer:
            self.designer.reload()

    def _force_exit_prompt(self):
        return MessageDialog.builder(
            {"text": "Force exit", "value": True, "focus": True},
            {"text": "Return to app", "value": False},
            wait=True,
            title="Exit Failure",
            message="An internal failure is preventing the app from exiting. Force exit?",
            parent=self,
            icon=MessageDialog.ICON_ERROR
        )

    def _on_close(self):
        """ Return ``True`` if exit successful otherwise ``False`` """
        if self._is_shutting_down:
            # block multiple close attempts
            return
        self._is_shutting_down = True
        try:
            self._save_position()
            # pass the on window close event to the features
            for feature in self.features:
                # if any feature returns false abort shut down
                feature.save_window_pos()
                if not feature.on_app_close():
                    self._is_shutting_down = False
                    return False
            if not self.tool_manager.on_app_close() or not self.check_unsaved_changes():
                self._is_shutting_down = False
                return False
            self.quit()
            return True
        except Exception:
            self._exit_failures += 1
            if self._exit_failures >= 2:
                force = self._force_exit_prompt()
                if force:
                    # exit by all means necessary
                    sys.exit(1)
            self._is_shutting_down = False
            return False

    def get_help(self):
        # Entry point for studio help functionality
        webbrowser.open("https://formation-studio.readthedocs.io/en/latest/")

    def settings(self):
        open_preferences(self)

    def _coming_soon(self):
        MessageDialog.show_info(
            parent=self,
            title="Coming soon",
            message="We are working hard to bring this feature to you. Hang in there.",
            icon="clock"
        )

    def _check_updates(self):
        Updater.check(self)

    def _register_actions(self):
        CTRL, ALT, SHIFT = KeyMap.CONTROL, KeyMap.ALT, KeyMap.SHIFT
        routine = actions.Routine
        # These actions are best bound separately to avoid interference with text entry widgets
        actions.add(
            routine(self.cut, 'STUDIO_CUT', 'Cut selected widget', 'studio', CTRL + CharKey('x')),
            routine(self.copy, 'STUDIO_COPY', 'Copy selected widget', 'studio', CTRL + CharKey('c')),
            routine(self.paste, 'STUDIO_PASTE', 'Paste selected widget', 'studio', CTRL + CharKey('v')),
            routine(self.delete, 'STUDIO_DELETE', 'Delete selected widget', 'studio', KeyMap.DELETE),
            routine(self.duplicate, 'STUDIO_DUPLICATE', 'Duplicate selected widget', 'studio', CTRL + CharKey('d')),
        )
        self.shortcuts.add_routines(
            routine(self.undo, 'STUDIO_UNDO', 'Undo last action', 'studio', CTRL + CharKey('Z')),
            routine(self.redo, 'STUDIO_REDO', 'Redo action', 'studio', CTRL + CharKey('Y')),
            # -----------------------------
            routine(self.open_new, 'STUDIO_NEW', 'Open new design', 'studio', CTRL + CharKey('n')),
            routine(self.open_file, 'STUDIO_OPEN', 'Open design from file', 'studio', CTRL + CharKey('o')),
            routine(self.save, 'STUDIO_SAVE', 'Save current design', 'studio', CTRL + CharKey('s')),
            routine(self.save_as, 'STUDIO_SAVE_AS', 'Save current design under a new file', 'studio',
                    CTRL + SHIFT + CharKey('s')),
            routine(self.save_all, 'STUDIO_SAVE_ALL', 'Save all open designs', 'studio', CTRL + ALT + CharKey('s')),
            routine(self.get_help, 'STUDIO_HELP', 'Show studio help', 'studio', KeyMap.F(12)),
            routine(self.settings, 'STUDIO_SETTINGS', 'Open studio settings', 'studio', ALT + CharKey('s')),
            routine(restart, 'STUDIO_RESTART', 'Restart application', 'studio', BlankKey),
            routine(self._on_close, 'STUDIO_EXIT', 'Exit application', 'studio', CTRL + CharKey('q')),
            # ------------------------------
            routine(self.show_all_windows, 'FEATURE_SHOW_ALL', 'Show all feature windows', 'studio',
                    ALT + CharKey('a')),
            routine(self.close_all, 'FEATURE_CLOSE_ALL', 'Close all feature windows', 'studio', ALT + CharKey('x')),
            routine(lambda: self.close_all_on_side('right'),
                    'FEATURE_CLOSE_RIGHT', 'Close feature windows to the right', 'studio', ALT + CharKey('R')),
            routine(lambda: self.close_all_on_side('left'),
                    'FEATURE_CLOSE_LEFT', 'Close feature windows to the left', 'studio', ALT + CharKey('L')),
            routine(self.features_as_docked, 'FEATURE_DOCK_ALL', 'Dock all feature windows', 'studio',
                    ALT + CharKey('d')),
            routine(self.features_as_windows, 'FEATURE_UNDOCK_ALL', 'Undock all feature windows', 'studio',
                    ALT + CharKey('u')),
            routine(self.save_window_positions, 'FEATURE_SAVE_POS', 'Save window positions', 'studio',
                    ALT + SHIFT + CharKey('s')),
            # -----------------------------
            routine(self.close_context, 'CONTEXT_CLOSE', 'Close tab', 'studio', CTRL + CharKey('T')),
            routine(self.close_all_contexts, 'CONTEXT_CLOSE_ALL', 'Close all tabs', 'studio',
                    CTRL + ALT + CharKey('T')),
            routine(self.close_other_contexts, 'CONTEXT_CLOSE_OTHER', 'Close other tabs', 'studio', BlankKey),
            routine(self.close_other_contexts_right, 'CONTEXT_CLOSE_OTHER_RIGHT', 'Close all tabs on the right',
                    'studio', BlankKey),
            # -----------------------------
            routine(self.preview, 'STUDIO_PREVIEW', 'Show preview', 'studio', KeyMap.F(5)),
            routine(self.close_preview, 'STUDIO_PREVIEW_CLOSE', 'Close any preview', 'studio', ALT + KeyMap.F(5)),
            routine(self.reload, 'STUDIO_RELOAD', 'Reload current design', 'studio', CTRL + CharKey('R'))
        )
Beispiel #17
0
    def __init__(self, master=None, **cnf):
        super().__init__(master, **cnf)
        # Load icon asynchronously to prevent issues which have been known to occur when loading it synchronously
        icon_image = load_tk_image(self.ICON_PATH)
        self.load_styles(self.THEME_PATH)
        self.iconphoto(True, icon_image)
        self.pref = pref
        self._restore_position()
        self.title('Formation Studio')
        self.protocol('WM_DELETE_WINDOW', self._on_close)
        self.shortcuts = ShortcutManager(self, pref)
        self.shortcuts.bind_all()
        self._register_actions()
        self._toolbar = Frame(self, **self.style.surface, height=30)
        self._toolbar.pack(side="top", fill="x")
        self._toolbar.pack_propagate(0)
        self._statusbar = Frame(self, **self.style.surface, height=20)
        self._statusbar.pack(side="bottom", fill="x")
        self._statusbar.pack_propagate(0)
        body = Frame(self, **self.style.surface)
        body.pack(fill="both", expand=True, side="top")
        self._right_bar = SideBar(body)
        self._right_bar.pack(side="right", fill="y")
        self._left_bar = SideBar(body)
        self._left_bar.pack(side="left", fill="y")
        self._pane = PanedWindow(body, **self.style.pane_horizontal)
        self._pane.pack(side="left", fill="both", expand=True)
        self._left = FeaturePane(self._pane, **self.style.pane_vertical)
        self._center = PanedWindow(self._pane, **self.style.pane_vertical)
        self._right = FeaturePane(self._pane, **self.style.pane_vertical)

        self._bin = []
        self._clipboard = None
        self.current_preview = None

        self._pane.add(self._left, minsize=320, sticky='nswe', width=320)
        self._pane.add(self._center, minsize=400, width=16000, sticky='nswe')
        self._pane.add(self._right, minsize=320, sticky='nswe', width=320)

        self._panes = {
            "left": (self._left, self._left_bar),
            "right": (self._right, self._right_bar),
            "center": (self._center, None)
        }

        icon = get_icon_image

        self.actions = (
            ("Delete", icon("delete", 20, 20), lambda e: self.delete(), "Delete selected widget"),
            ("Undo", icon("undo", 20, 20), lambda e: self.undo(), "Undo action"),
            ("Redo", icon("redo", 20, 20), lambda e: self.redo(), "Redo action"),
            ("Cut", icon("cut", 20, 20), lambda e: self.cut(), "Cut selected widget"),
            ("separator",),
            ("Fullscreen", icon("image_editor", 20, 20), lambda e: self.close_all(), "Design mode"),
            ("Separate", icon("separate", 20, 20), lambda e: self.features_as_windows(),
             "Open features in window mode"),
            ("Dock", icon("flip_horizontal", 15, 15), lambda e: self.features_as_docked(),
             "Dock all features"),
            ("separator",),
            ("New", icon("add", 20, 20), lambda e: self.open_new(), "New design"),
            ("Save", icon("save", 20, 20), lambda e: self.save(), "Save design"),
            ("Preview", icon("play", 20, 20), lambda e: self.preview(), "Preview design"),
        )

        self.init_toolbar()
        self.selected = None
        # set the image option to blank if there is no image for the menu option
        self.blank_img = blank_img = icon("blank", 14, 14)

        self.tool_manager = ToolManager(self)

        # -------------------------------------------- menu definition ------------------------------------------------
        self.menu_template = (EnableIf(
            lambda: self.selected,
            ("separator",),
            ("command", "copy", icon("copy", 14, 14), actions.get('STUDIO_COPY'), {}),
            ("command", "duplicate", icon("copy", 14, 14), actions.get('STUDIO_DUPLICATE'), {}),
            EnableIf(
                lambda: self._clipboard is not None,
                ("command", "paste", icon("clipboard", 14, 14), actions.get('STUDIO_PASTE'), {})
            ),
            ("command", "cut", icon("cut", 14, 14), actions.get('STUDIO_CUT'), {}),
            ("separator",),
            ("command", "delete", icon("delete", 14, 14), actions.get('STUDIO_DELETE'), {}),
        ),)

        self.menu_bar = MenuUtils.make_dynamic(
            ((
                 ("cascade", "formation", None, None, {"menu": (
                     ("command", "Restart", None, actions.get('STUDIO_RESTART'), {}),
                     ("separator", ),
                     ("command", "About Formation", icon("formation", 14, 14), lambda: about_window(self), {}),
                 ), "name": "apple"}),
             ) if platform_is(MAC) else ()) +
            (
                ("cascade", "File", None, None, {"menu": (
                    ("command", "New", icon("add", 14, 14), actions.get('STUDIO_NEW'), {}),
                    ("command", "Open", icon("folder", 14, 14), actions.get('STUDIO_OPEN'), {}),
                    ("cascade", "Recent", icon("clock", 14, 14), None, {"menu": self._create_recent_menu()}),
                    ("separator",),
                    EnableIf(
                        lambda: self.designer,
                        ("command", "Save", icon("save", 14, 14), actions.get('STUDIO_SAVE'), {}),
                        ("command", "Save As", icon("blank", 14, 14), actions.get('STUDIO_SAVE_AS'), {})
                    ),
                    EnableIf(
                        # more than one design contexts open
                        lambda: len([i for i in self.contexts if isinstance(i, DesignContext)]) > 1,
                        ("command", "Save All", icon("blank", 14, 14), actions.get('STUDIO_SAVE_ALL'), {})
                    ),
                    ("separator",),
                    ("command", "Settings", icon("settings", 14, 14), actions.get('STUDIO_SETTINGS'), {}),
                    ("command", "Restart", icon("blank", 14, 14), actions.get('STUDIO_RESTART'), {}),
                    ("command", "Exit", icon("close", 14, 14), actions.get('STUDIO_EXIT'), {}),
                )}),
                ("cascade", "Edit", None, None, {"menu": (
                    EnableIf(lambda: self.context and self.context.has_undo(),
                             ("command", "undo", icon("undo", 14, 14), actions.get('STUDIO_UNDO'), {})),
                    EnableIf(lambda: self.context and self.context.has_redo(),
                             ("command", "redo", icon("redo", 14, 14), actions.get('STUDIO_REDO'), {})),
                    *self.menu_template,
                )}),
                ("cascade", "Code", None, None, {"menu": (
                    EnableIf(
                        lambda: self.designer and self.designer.root_obj,
                        ("command", "Preview design", icon("play", 14, 14), actions.get('STUDIO_PREVIEW'), {}),
                        ("command", "close preview", icon("close", 14, 14), actions.get('STUDIO_PREVIEW_CLOSE'), {}),
                        ("separator", ),
                        EnableIf(
                            lambda: self.designer and self.designer.design_path,
                            ("command", "Reload design file", icon("rotate_clockwise", 14, 14),
                             actions.get('STUDIO_RELOAD'), {}),
                        ),
                    )
                )}),
                ("cascade", "View", None, None, {"menu": (
                    ("command", "show all panes", blank_img, actions.get('FEATURE_SHOW_ALL'), {}),
                    ("command", "close all panes", icon("close", 14, 14), actions.get('FEATURE_CLOSE_ALL'), {}),
                    ("command", "close all panes on the right", blank_img, actions.get('FEATURE_CLOSE_RIGHT'), {}),
                    ("command", "close all panes on the left", blank_img, actions.get('FEATURE_CLOSE_LEFT'), {}),
                    ("separator",),
                    ("command", "Undock all windows", blank_img, actions.get('FEATURE_UNDOCK_ALL'), {}),
                    ("command", "Dock all windows", blank_img, actions.get('FEATURE_DOCK_ALL'), {}),
                    ("separator",),
                    LoadLater(self.get_features_as_menu),
                    ("separator",),
                    EnableIf(
                        lambda: self.context,
                        ("command", "close tab", icon("close", 14, 14), actions.get('CONTEXT_CLOSE'), {}),
                        ("command", "close all tabs", blank_img, actions.get('CONTEXT_CLOSE_ALL'), {}),
                        EnableIf(
                            lambda: self.context and len(self.tab_view.tabs()) > 1,
                            ("command", "close other tabs", blank_img, actions.get('CONTEXT_CLOSE_OTHER'), {})
                        ),
                        EnableIf(
                            lambda: self.context and self.context._contexts_right(),
                            ("command", "close all tabs on the right", blank_img,
                             actions.get('CONTEXT_CLOSE_OTHER_RIGHT'), {})
                        )
                    ),
                    ("separator",),
                    ("command", "Save window positions", blank_img, actions.get('FEATURE_SAVE_POS'), {})
                )}),
                ("cascade", "Tools", None, None, {"menu": (LoadLater(self.tool_manager.get_tools_as_menu), )}),
                ("cascade", "Help", None, None, {"menu": (
                    ("command", "Help", icon('dialog_info', 14, 14), actions.get('STUDIO_HELP'), {}),
                    ("command", "Check for updates", icon("cloud", 14, 14), self._check_updates, {}),
                    ("separator",),
                    ("command", "About Formation", icon("formation", 14, 14), lambda: about_window(self), {}),
                )})
            ), self, self.style, False)

        self.config(menu=self.menu_bar)

        if platform_is(MAC):
            self.createcommand("tk::mac::ShowPreferences", lambda: actions.get('STUDIO_SETTINGS').invoke())
            self.createcommand("tk::mac::ShowHelp", lambda: actions.get('STUDIO_HELP').invoke())
            self.createcommand("tk::mac::Quit", lambda: actions.get('STUDIO_EXIT').invoke())

        self.features = []
        self.context = None
        self.contexts = []
        self.tab_view = TabView(self._center)
        self.tab_view.malleable(True)
        self.tab_view.bind("<<TabSelectionChanged>>", self.on_context_switch)
        self.tab_view.bind("<<TabClosed>>", self.on_context_close)
        self.tab_view.bind("<<TabAdded>>", self.on_context_add)
        self.tab_view.bind("<<TabOrderChanged>>", lambda _: self.save_tab_status())
        self._center.add(self.tab_view, sticky='nswe')
        self._tab_view_empty = Label(
            self.tab_view, **self.style.text_passive, compound='top',
            image=get_icon_image("paint", 60, 60)
        )
        self._tab_view_empty.config(**self.style.bright)

        # install features
        for feature in FEATURES:
            self.install(feature)

        # common feature references
        self.style_pane = self.get_feature(StylePane)

        # initialize tools with everything ready
        self.tool_manager.initialize()

        self._ignore_tab_status = False
        self._startup()
        self._exit_failures = 0
        self._is_shutting_down = False
Beispiel #18
0
class ComponentTree(BaseFeature):
    name = "Component Tree"
    icon = "treeview"

    def __init__(self, master, studio=None, **cnf):
        super().__init__(master, studio, **cnf)
        self._toggle_btn = Button(self._header,
                                  image=get_icon_image("chevron_down", 15, 15),
                                  **self.style.button,
                                  width=25,
                                  height=25)
        self._toggle_btn.pack(side="right")
        self._toggle_btn.on_click(self._toggle)

        self._search_btn = Button(
            self._header,
            **self.style.button,
            image=get_icon_image("search", 15, 15),
            width=25,
            height=25,
        )
        self._search_btn.pack(side="right")
        self._search_btn.on_click(self.start_search)
        self.body = Frame(self, **self.style.surface)
        self.body.pack(side="top", fill="both", expand=True)
        self._empty_label = Label(self.body, **self.style.text_passive)

        self._selected = None
        self._expanded = False
        self._tree = None

    def on_context_switch(self):
        if self._tree:
            self._tree.pack_forget()

        if self.studio.designer:
            self.show_empty(None)
            if self.studio.designer.node:
                self._tree = self.studio.designer.node
            else:
                self._tree = ComponentTreeView(self.body)
                self._tree.on_select(self._trigger_select)
                self.studio.designer.node = self._tree
            self._tree.pack(fill="both", expand=True)
        else:
            self.show_empty("No active Designer")

    def create_menu(self):
        return (("command", "Expand all",
                 get_icon_image("chevron_down", 14, 14), self._expand, {}),
                ("command", "Collapse all",
                 get_icon_image("chevron_up", 14, 14), self._collapse, {}))

    def show_empty(self, text):
        if text:
            self._empty_label.lift()
            self._empty_label.place(x=0, y=0, relwidth=1, relheight=1)
            self._empty_label['text'] = text
        else:
            self._empty_label.place_forget()

    def _expand(self):
        self._tree.expand_all()
        self._toggle_btn.config(image=get_icon_image("chevron_up", 15, 15))
        self._expanded = True

    def _collapse(self):
        self._tree.collapse_all()
        self._toggle_btn.config(image=get_icon_image("chevron_down", 15, 15))
        self._expanded = False

    def _toggle(self, *_):
        if self._expanded:
            self._collapse()
        else:
            self._expand()

    def on_widget_add(self, widget: PseudoWidget, parent=None):
        if parent is None:
            node = self._tree.add_as_node(widget=widget)
        else:
            parent = parent.node
            node = parent.add_as_node(widget=widget)

        # let the designer render the menu for us
        MenuUtils.bind_all_context(
            node, lambda e: self.studio.designer.show_menu(e, widget)
            if self.studio.designer else None)

    def _trigger_select(self):
        if self._selected and self._selected.widget == self._tree.get().widget:
            return
        self.studio.select(self._tree.get().widget, self)
        self._selected = self._tree.get()

    def select(self, widget):
        if widget:
            node = widget.node
            self._selected = node
            node.select(
                None, True
            )  # Select node silently to avoid triggering a duplicate selection event
        elif widget is None:
            if self._selected:
                self._selected.deselect()
                self._selected = None

    def on_select(self, widget):
        self.select(widget)

    def on_widget_delete(self, widget, silently=False):
        widget.node.remove()

    def on_widget_restore(self, widget):
        widget.layout.node.add(widget.node)

    def on_widget_layout_change(self, widget):
        node = widget.node
        if widget.layout == self.studio.designer:
            parent = self._tree
        else:
            parent = widget.layout.node
        if node.parent_node != parent:
            parent.insert(None, node)

    def on_context_close(self, context):
        if hasattr(context, "designer"):
            # delete context's tree
            if hasattr(context.designer, "node") and context.designer.node:
                context.designer.node.destroy()

    def on_session_clear(self):
        self._tree.clear()

    def on_widget_change(self, old_widget, new_widget=None):
        new_widget = new_widget if new_widget else old_widget
        new_widget.node.widget_modified(new_widget)

    def on_search_query(self, query: str):
        self._tree.search(query)

    def on_search_clear(self):
        self._tree.search("")
        super(ComponentTree, self).on_search_clear()