class Image(Text): def __init__(self, master, style_def=None): super().__init__(master, style_def) self._picker = Button(self, **self.style.dark_button, width=25, height=25, text="...") self._entry.pack_forget() self._picker.pack(side="right") self._entry.pack(side="left", fill="x") self._picker.on_click(self._pick) def _change(self, *_): # Do not broadcast changes for invalid paths # TODO Add indicator for invalid paths if not os.path.exists(self.get()): return super()._change() def _pick(self, *_): path = filedialog.askopenfilename(parent=self) if path: self._entry.set(path) if self._on_change: self._on_change(path)
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)
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.button, text=text, height=25) btn.configure(**self.style.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
class Image(Text): def __init__(self, master, style_def=None): super().__init__(master, style_def) self._picker = Button(self, **self.style.button, width=25, height=25, text="...") self._entry.pack_forget() self._picker.pack(side="right") self._entry.pack(side="left", fill="x") self._picker.on_click(self._pick) def _change(self, *_): # Do not broadcast changes for invalid paths # TODO Add indicator for invalid paths if not os.path.exists(self.get()): return super()._change() def _pick(self, *_): path = filedialog.askopenfilename(parent=self) if path: try: path_opt = get_active_pref(self).get("designer::image_path") except: path_opt = "absolute" if path_opt == "absolute": # use path as is (absolute) pass else: path = pathlib.Path(path) current = pathlib.Path(os.getcwd()) if (current in path.parents and path_opt == "mixed") or path_opt == "relative": # use relative path try: # use relative path if possible path = os.path.relpath(path, current) except ValueError: pass path = str(path) self._entry.set(path) if self._on_change: self._on_change(path)
class SearchBar(Frame): def __init__(self, master=None, **cnf): super().__init__(master, **cnf) self.config(**self.style.no_highlight, **self.style.dark) self._entry = Entry(self, **self.style.dark_input) self._clear_btn = Button(self, image=get_icon_image("close", 15, 15), **self.style.dark_button, width=25, height=25) self._clear_btn.pack(side="right", fill="y") self._clear_btn.on_click(self._clear) Label(self, **self.style.dark_text, image=get_icon_image("search", 15, 15)).pack(side="left") self._entry.pack(side="left", fill="both", expand=True, padx=2) self._entry.on_entry(self._change) self._on_change = None self._on_clear = None def focus_set(self): super().focus_set() self._entry.focus_set() def on_query_change(self, func, *args, **kwargs): self._on_change = lambda val: func(val, *args, **kwargs) def on_query_clear(self, func, *args, **kwargs): self._on_clear = lambda: func(*args, **kwargs) def _clear(self, *_): if self._on_clear: self._on_clear() def _change(self, *_): if self._on_change: self._on_change(self._entry.get())
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()
class VariablePane(BaseFeature): name = "Variablepane" icon = "text" _defaults = {**BaseFeature._defaults, "side": "right"} _definitions = { "name": { "name": "name", "type": "text", } } _empty_message = "No variables added" 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) def start_search(self, *_): if self.variables: super().start_search() self._variable_pane.scroll_to_start() def on_search_query(self, query): matches = [] self._variable_pane.clear_children() for item in self.variables: if query in item.name: self._show(item) matches.append(item) if not matches: self._show_overlay(True, text="No matches found", image=get_icon_image("search", 25, 25)) else: self.select(matches[0]) self._show_overlay(False) self._search_query = query def on_search_clear(self): self.on_search_query("") self._search_query = None # remove overlay if we have variables otherwise show it self._show_overlay(not self.variables) super().on_search_clear() def _get_add_menu(self): _types = VariableItem._types return [(tk.COMMAND, _types[i].get("name"), get_icon_image(_types[i].get("icon"), 14, 14), functools.partial(self.menu_add_var, i), {}) for i in _types] def create_menu(self): return ( ("cascade", "Add", get_icon_image("add", 14, 14), None, { "menu": self._get_add_menu() }), ("command", "Delete", get_icon_image("delete", 14, 14), self._delete, {}), ("command", "Search", get_icon_image("search", 14, 14), self.start_search, {}), ) def _show_overlay(self, flag=True, **kwargs): if flag: kwargs["text"] = kwargs.get("text", self._empty_message) kwargs["image"] = kwargs.get("image", get_icon_image("add", 25, 25)) self._overlay.lift() self._overlay.configure(**kwargs) self._overlay.place(x=0, y=0, relwidth=1, relheight=1) else: self._overlay.place_forget() def menu_add_var(self, var_type, **kw): item = self.add_var(var_type, **kw) self.select(item) def add_var(self, var_type, **kw): var = var_type(self.studio) item_count = len( list(filter(lambda x: x.var_type == var_type, self.variables))) + 1 name = kw.get('name', f"{var_type.__name__}_{item_count}") value = kw.get('value') item = VariableItem(self._variable_pane.body, var, name) item.bind("<Button-1>", lambda e: self.select(item)) if value is not None: item.set(value) self._show(item) self._show_overlay(False) if self._search_query is not None: # reapply search if any self.on_search_query(self._search_query) elif not self.variables: self.select(item) VariableManager.add(item) return item def delete_var(self, var): self._hide(var) VariableManager.remove(var) def _delete(self, *_): if self._selected: self.delete_var(self._selected) if self.variables: self.select(self.variables[0]) else: self._show_overlay(True) def clear_variables(self): # the list is likely to change during iteration, create local copy variables = list(self.variables) for var in variables: self.delete_var(var) self._show_overlay(True) @property def variables(self): return VariableManager.variables() def select(self, item): if item == self._selected: return item.select() if self._selected: self._selected.deselect() self._selected = item self._detail_for(item) def _show(self, item): item.pack(side="top", fill="x") def _hide(self, item): item.pack_forget() def _get_editor(self, variable): editor_type = variable.definition["type"] if not self._editors.get(editor_type): # we do not have that type of editor yet, create it self._editors[editor_type] = editors.get_editor( self._detail_pane.body, variable.definition) return self._editors[editor_type] def refresh(self): # redraw variables for current context self._variable_pane.body.clear_children() has_selection = False if not self.variables: self._show_overlay(True) else: self._show_overlay(False) for item in self.variables: self._show(item) if not has_selection: self.select(item) has_selection = True # reapply search query if any if self._search_query is not None: self.on_search_query(self._search_query) def _detail_for(self, variable): _editor = self._get_editor(variable) if self._editor != _editor: # we need to change current editor completely if self._editor: self._editor.pack_forget() self._editor = _editor self._editor.set(variable.value) self._editor.pack(side="top", fill="x") self._editor.on_change(variable.set) self.var_name.set(variable.name) self.var_name.on_change(variable.set_name) self.var_type_lbl["text"] = variable.var_type_name def on_session_clear(self): self.clear_variables() def on_context_switch(self): VariableManager.set_context(self.studio.context) self.refresh()
class VariablePane(BaseFeature): name = "Variablepane" icon = "text" _defaults = {**BaseFeature._defaults, "side": "right"} _definitions = { "name": { "name": "name", "type": "text", } } 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 = [] def start_search(self, *_): if len(self.variables): super().start_search() self._variable_pane.scroll_to_start() def on_search_query(self, query): matches = [] self._variable_pane.clear_children() for item in self.variables: if query in item.name: self._show(item) matches.append(item) if not len(matches): self._show_overlay(True, text="No matches found", image=get_icon_image("search", 25, 25)) else: self.select(matches[0]) self._show_overlay(False) def on_search_clear(self): self.on_search_query("") self._show_overlay(False) super().on_search_clear() def _get_add_menu(self): _types = VariableItem._types return [(tk.COMMAND, _types[i].get("name"), get_icon_image(_types[i].get("icon"), 14, 14), functools.partial(self.add_var, i), {}) for i in _types] def create_menu(self): return ( ("cascade", "Add", get_icon_image("add", 14, 14), None, { "menu": self._get_add_menu() }), ("command", "Delete", get_icon_image("delete", 14, 14), self._delete, {}), ("command", "Search", get_icon_image("search", 14, 14), self.start_search, {}), ) def _show_overlay(self, flag=True, **kwargs): if flag: self._overlay.lift() self._overlay.configure(**kwargs) self._overlay.place(x=0, y=0, relwidth=1, relheight=1) else: self._overlay.place_forget() def add_var(self, var_type, **kw): var = var_type(self.studio) item_count = len( list(filter(lambda x: x.var_type == var_type, self.variables))) + 1 name = kw.get('name', f"{var_type.__name__}_{item_count}") value = kw.get('value') item = VariableItem(self._variable_pane.body, var, name) item.bind("<Button-1>", lambda e: self.select(item)) if value is not None: item.set(value) VariableManager.add(item) self._show(item) self._show_overlay(False) self.select(item) def delete_var(self, var): self._hide(var) VariableManager.remove(var) def _delete(self, *_): if self._selected: self.delete_var(self._selected) if len(self.variables): self.select(self.variables[0]) else: self._show_overlay(True) def clear_variables(self): # the list is likely to change during iteration, create local copy variables = list(self.variables) for var in variables: self.delete_var(var) self._show_overlay(True) @property def variables(self): return VariableManager.variables def select(self, item): if item == self._selected: return item.select() if self._selected: self._selected.deselect() self._selected = item self._detail_for(item) def _show(self, item): item.pack(side="top", fill="x") def _hide(self, item): item.pack_forget() def _detail_for(self, variable): self._detail_pane.clear_children() Label(self._detail_pane.body, **self.style.dark_text_passive, text="Type", anchor="w").pack(fill="x", side="top") Label(self._detail_pane.body, **self.style.dark_text, text=variable.var_type_name, anchor="w").pack(fill="x", side="top") Label(self._detail_pane.body, **self.style.dark_text_passive, text="Name", anchor="w").pack(fill="x", side="top") name = editors.get_editor(self._detail_pane.body, self._definitions["name"]) name.pack(side="top", fill="x") name.set(variable.name) name.on_change(variable.set_name) Label(self._detail_pane.body, **self.style.dark_text_passive, text="Value", anchor="w").pack(fill="x", side="top") value = editors.get_editor(self._detail_pane.body, variable.definition) value.set(variable.value) value.pack(side="top", fill="x") value.on_change(variable.set) def on_session_clear(self): self.clear_variables()
class BaseFeature(Pane): _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", "pane": { "height": 300, "index": 1000, # setting 1000 allows the feature pane to pick an index }, "pos": { "initialized": False, "x": 20, "y": 20, "width": 200, "height": 200, } } @classmethod def update_defaults(cls): pref = Preferences.acquire() path = "features::{}".format(cls.name) if not pref.exists(path): pref.set(path, copy.deepcopy(cls._defaults)) else: pref.update_defaults(path, copy.deepcopy(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 = StringVar(None, self.get_pref('side')) self.is_visible = BooleanVar(None, self.get_pref('visible')) t.trace_add( "write", lambda *_: self.set_pref('inactive_transparency', t.get())) self.studio = studio Label(self._header, **self.style.text_accent, text=self.name).pack(side="left") self._min = Button(self._header, image=get_icon_image("close", 15, 15), **self.style.button, width=25, height=25) self._min.pack(side="right") self._min.on_click(self.minimize) self._pref = MenuButton(self._header, **self.style.button) self._pref.configure(image=get_icon_image("settings", 15, 15)) self._pref.pack(side="right") self._pref.tooltip("Options") 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.surface) 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 Preferences.acquire().get(cls.get_pref_path(short_path)) @classmethod def set_pref(cls, short_path, value): Preferences.acquire().set(cls.get_pref_path(short_path), value) @classmethod def get_instance(cls): return cls._instance 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_context_switch(self): """ Override to perform operations when the active tab changes """ pass def on_context_close(self, context): """ Override to perform operations when a tab context is closed """ 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) self.is_visible.set(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) self.is_visible.set(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.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.transient(self.master.window) 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: if self.winfo_ismapped(): self.set_pref("pane::height", self.height) return self.update_idletasks() geometry = parse_geometry(self.geometry(), default=0) if geometry: # more accurate # cast geometry values returned to int self.set_pref( "pos", dict({k: int(v) for k, v in geometry.items()}, initialized=True)) else: raise Exception("Could not parse window geometry") 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)
class ComponentPane(BaseFeature): CLASSES = { "native": { "widgets": native.widgets }, "legacy": { "widgets": legacy.widgets }, } name = "Components" _var_init = False _defaults = {**BaseFeature._defaults, "widget_set": "native"} 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")) def _init_var(self, master=None): self._var_init = True for widget_set in self.CLASSES: self.CLASSES[widget_set]["var"] = BooleanVar(master, False) def _widget_sets_as_menu(self): return [ ( "checkbutton", # Type checkbutton i.capitalize(), # Label as title case None, # Image partial(self.collect_groups, i), # The callback { "variable": self.CLASSES[i]["var"] } # Additional config including the variable associated ) for i in self.CLASSES ] @property def selectors(self): return self._selectors def create_menu(self): return ( ("command", "Search", get_icon_image("search", 14, 14), self.start_search, {}), ("cascade", "Widget set", None, None, { "menu": (*self._widget_sets_as_menu(), ) }), ) def collect_groups(self, widget_set): for other_set in [i for i in self.CLASSES if i != widget_set]: self.CLASSES[other_set]["var"].set(False) self.CLASSES[widget_set]["var"].set(True) self._widget_set.set(widget_set) self._select_pane.clear_children() self._pool = {} components = self.CLASSES.get(widget_set)["widgets"] for component in components: group = component.group.name if group in self._pool: self._pool[group].append( Component(self._widget_pane.body, component)) else: self._pool[group] = [ Component(self._widget_pane.body, component) ] self.render_groups() # component pool has changed so invalidate the cache self._component_cache = None self.set_pref("widget_set", widget_set) def get_components(self): if self._component_cache: return self._component_cache else: # flatten component pool and store to cache self._component_cache = [j for i in self._pool.values() for j in i] return self._component_cache def select(self, selector): if self._selected is not None: self._selected.deselect() selector.select() self._selected = selector self._widget_pane.clear_children() for component in self._pool[selector.name]: component.pack(side="top", pady=2, fill="x") def render_groups(self): self._selectors = [] for group in self._pool: self.add_selector(Selector(self._select_pane.body, text=group)) if len(self._selectors): self.select(self._selectors[0]) def add_selector(self, selector): self._selectors.append(selector) selector.bind("<Button-1>", lambda *_: self.select(selector)) selector.pack(side="top", pady=2, fill="x") def hide_selectors(self): for selector in self._selectors: selector.pack_forget() def show_selectors(self): for selector in self._selectors: selector.pack(side="top", pady=2, fill="x") def start_search(self, *_): super().start_search() self._widget_pane.scroll_to_start() if self._selected is not None: self._selected.deselect() self.hide_selectors() self._search_selector.pack(side="top", pady=2, fill="x") self._widget_pane.clear_children() # Display all components by running an empty query self.on_search_query("") def on_search_clear(self): super().on_search_clear() if len(self._selectors): self.select(self._selectors[0]) self._search_selector.pack_forget() self.show_selectors() def on_search_query(self, query): for component in self.get_components(): if query.lower() in component.component.display_name.lower(): component.pack(side="top", pady=2, fill="x") else: component.pack_forget()
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)
class ComponentTree(BaseFeature): name = "Component Tree" icon = "treeview" def __init__(self, master, studio=None, **cnf): super().__init__(master, studio, **cnf) self._tree = ComponentTreeView(self) self._tree.pack(side="top", fill="both", expand=True, pady=4) # self._tree.sample() self._tree.on_select(self._trigger_select) self._toggle_btn = Button(self._header, image=get_icon_image("chevron_down", 15, 15), **self.style.dark_button, width=25, height=25) self._toggle_btn.pack(side="right") self._toggle_btn.on_click(self._toggle) self._selected = None self._expanded = False self.studio.designer.node = self._tree 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 _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 node.bind_all('<Button-3>', 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: self._tree.insert(None, node) else: parent = widget.layout.node parent.insert(None, node) 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)
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)
class Updater(Frame): def __init__(self, master): super().__init__(master) self.config(**self.style.surface) self._progress = ProgressBar(self) self._progress.pack(side="top", fill="x", padx=20, pady=20) self._progress_text = Label(self, **self.style.text_small, anchor="w") self._progress_text.pack(side="top", fill="x", padx=20, pady=10) self._message = Label( self, **self.style.text, anchor="w", compound="left", wrap=400, justify="left", pady=5, padx=5, ) self._action_btn = Button(self, text="Retry", **self.style.button_highlight, width=80, height=25) self.extra_info = Text(self, width=40, height=6, state='disabled', font='consolas 10') self.pack(fill="both", expand=True) self.check_for_update() def show_button(self, text, func): self._action_btn.config(text=text) self._action_btn.on_click(func) self._action_btn.pack(side="bottom", anchor="e", pady=5, padx=5) def show_progress(self, message): self.clear_children() self._progress_text.configure(text=message) self._progress.pack(side="top", fill="x", padx=20, pady=20) self._progress_text.pack(side="top", fill="x", padx=20, pady=10) def show_error(self, message, retry_func): self.show_error_plain(message) self.show_button("Retry", retry_func) def show_error_plain(self, message): self.show_message(message, MessageDialog.ICON_ERROR) def update_found(self, version): self.show_info( f"New version formation-studio {version} found. Do you want to install?" ) self.show_button("Install", lambda _: self.install(version)) def show_info(self, message): self.show_message(message, MessageDialog.ICON_INFO) def show_message(self, message, icon): self.clear_children() self._message.configure(text=message, image=get_icon_image(icon, 50, 50)) self._message.pack(side="top", padx=20, pady=10) def install(self, version): self.show_progress(f"Updating to formation-studio {version}") self.upgrade(version) @classmethod def check(cls, master): dialog = MessageDialog(master, cls) dialog.title("Formation updater") dialog.focus_set() return dialog @as_thread def check_for_update(self, *_): self._progress.mode(ProgressBar.INDETERMINATE) self.show_progress("Checking for updates ...") try: content = urlopen( "https://pypi.org/pypi/formation-studio/json").read() data = json.loads(content) ver = data["info"]["version"] if ver <= formation.__version__: self.show_info("You are using the latest version") else: self.update_found(ver) except URLError: self.show_error( "Failed to connect. Check your internet connection" " and try again.", self.check_for_update) @as_thread def upgrade(self, version): try: # run formation cli upgrade command proc_info = subprocess.run([sys.executable, "-m", "studio", "-u"], capture_output=True) if proc_info.returncode != 0 or proc_info.stderr: self.show_error_plain( "Something went wrong. Failed to upgrade formation-studio" f" to version {version} ," f" Exited with code: {proc_info.returncode}") if proc_info.stderr: self.extra_info.config(state="normal") self.extra_info.pack(side="top", fill="x", padx=20, pady=10) self.extra_info.clear() self.extra_info.set(str(proc_info.stderr)) self.extra_info.config(state="disabled") else: self.show_info( "Upgrade successful. Restart to complete installation") self.show_button( "Restart", lambda _: get_routine("STUDIO_RESTART").invoke()) return except Exception as e: self.show_error_plain(e) self.show_button("Retry", lambda _: self.install(version))
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)
class ElementPane(Pane): name = "Widget tree" MAX_STARTING_DEPTH = 4 def __init__(self, master, debugger): super(ElementPane, self).__init__(master) Label(self._header, **self.style.text_accent, text=self.name).pack(side="left") ElementTreeView.Node.debugger = debugger self._tree = ElementTreeView(self) self._tree.pack(side="top", fill="both", expand=True, pady=4) self._tree.allow_multi_select(True) self._tree.on_select(self.on_select) 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", padx=2) self._search_btn.on_click(self.start_search) self._reload_btn = Button( self._header, **self.style.button, image=get_icon_image("rotate_clockwise", 15, 15), width=25, height=25, ) self._reload_btn.pack(side="right", padx=2) self._reload_btn.tooltip("reload tree") 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", padx=2) self._select_btn = ToggleButton( self._header, **self.style.button, image=get_icon_image("cursor", 15, 15), width=25, height=25, ) self._select_btn.pack(side="right", padx=2) self._select_btn.tooltip("select element to inspect") self.debugger = debugger self._tree.add_as_node( widget=debugger.root).update_preload_status(False) self.debugger.bind("<<WidgetCreated>>", self.on_widget_created) self.debugger.bind("<<WidgetDeleted>>", self.on_widget_deleted) self.debugger.bind("<<WidgetModified>>", self.on_widget_modified) tkinter.Misc.bind_all(self.debugger.root, "<Motion>", self.on_motion) tkinter.Misc.bind_all(self.debugger.root, "<Button-1>", self.on_widget_tap) tkinter.Misc.bind_all(self.debugger.root, "<Button-3>", self.on_widget_tap) tkinter.Misc.bind_all(self.debugger.root, "<Map>", self.on_widget_map) tkinter.Misc.bind_all(self.debugger.root, "<Unmap>", self.on_widget_unmap) self.highlighted = None @property def selected(self): return self._tree.get() def on_select(self): self.debugger.update_selection(self._tree.get()) def on_widget_tap(self, event): if self._select_btn.get(): try: # widget = self.debugger.root.winfo_containing(event.x_root, event.y_root) widget = event.widget # print(widget) if widget.winfo_toplevel() == self.debugger or getattr( widget, "_dbg_ignore", False): widget = None except (KeyError, AttributeError): widget = None if widget: node = self._tree.expand_to(widget) if node: node.select(event) def on_motion(self, event): if self._select_btn.get(): try: # widget = self.debugger.root.winfo_containing(event.x_root, event.y_root) widget = event.widget if widget.winfo_toplevel() == self.debugger or getattr( widget, "_dbg_ignore", False): widget = None # print(f"motion : {widget} <> {event.widget}") except (KeyError, AttributeError): widget = None self.debugger.highlight_widget(widget) self.highlighted = widget def on_widget_created(self, _): widget = self.debugger.active_widget parent_node = getattr(widget.master, "_dbg_node", None) if parent_node: if parent_node.loaded: parent_node.add_as_node(widget=widget) self.debugger.hook_widget(widget) else: parent_node.update_preload_status(True) def on_widget_deleted(self, _): widget = self.debugger.active_widget parent_node = getattr(widget.master, "_dbg_node", None) if parent_node: if parent_node.loaded: node = widget._dbg_node if node in self.selected: self._tree.toggle_from_selection(node) parent_node.remove(widget._dbg_node) else: parent_node.update_preload_status(False) def on_widget_modified(self, _): if self.debugger.active_widget not in self.selected: return def on_widget_map(self, _): pass def on_widget_unmap(self, _): pass
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()
class ComponentPane(BaseFeature): CLASSES = { "native": {"widgets": native.widgets}, "legacy": {"widgets": legacy.widgets}, } name = "Components" _var_init = False _defaults = { **BaseFeature._defaults, "widget_set": "native" } _custom_pref_path = "studio::custom_widget_paths" 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() @property def custom_widgets(self): return self._custom_widgets def auto_find_load_custom(self, *modules): # locate and load all custom widgets in modules # module can be a module or a path to module file self._custom_widgets = [] errors = {} for module in modules: if isinstance(module, str): try: module = import_path(module) except Exception as e: errors[module] = e continue for attr in dir(module): if type(getattr(module, attr)) == WidgetMeta: self._custom_widgets.append(getattr(module, attr)) if errors: error_msg = "\n\n".join( [f"{path}\n{error}" for path, error in errors.items()] ) MessageDialog.show_error( parent=self.window, message=f"Error loading widgets \n\n{error_msg}" ) return self._custom_widgets def _init_custom(self, paths): # reload custom widget modules try: widgets = self.auto_find_load_custom(*paths) except Exception as e: return if not widgets: if self._custom_group is not None: self.unregister_group(self._custom_group) self._custom_group = None return if self._custom_group is None: self._custom_group = self.register_group( "Custom", widgets, ComponentGroup, ) else: self._custom_group.update_components(widgets) # this will force group to be re-rendered self.select(self._custom_group.selector) def _reload_custom(self): self._init_custom(Preferences.acquire().get(self._custom_pref_path)) def _init_var(self, master=None): self._var_init = True for widget_set in self.CLASSES: self.CLASSES[widget_set]["var"] = BooleanVar(master, False) def _widget_sets_as_menu(self): return [ ("checkbutton", # Type checkbutton i.capitalize(), # Label as title case None, # Image partial(self.collect_groups, i), # The callback {"variable": self.CLASSES[i]["var"]} # Additional config including the variable associated ) for i in self.CLASSES ] @property def selectors(self): return self._selectors def create_menu(self): return ( ( "command", "Reload custom widgets", get_icon_image("rotate_clockwise", 14, 14), self._reload_custom, {} ), ( "command", "Search", get_icon_image("search", 14, 14), self.start_search, {} ), ("cascade", "Widget set", get_icon_image("blank", 14, 14), None, {"menu": ( *self._widget_sets_as_menu(), )}), ) def collect_groups(self, widget_set): for other_set in [i for i in self.CLASSES if i != widget_set]: self.CLASSES[other_set]["var"].set(False) self.CLASSES[widget_set]["var"].set(True) self._widget_set.set(widget_set) self._select_pane.clear_children() self._pool = {} components = self.CLASSES.get(widget_set)["widgets"] for component in components: group = component.group.name if group in self._pool: self._pool[group].append(Component(self._widget_pane.body, component)) else: self._pool[group] = [Component(self._widget_pane.body, component)] self.render_groups() # component pool has changed so invalidate the cache self._component_cache = None self.set_pref("widget_set", widget_set) def get_components(self): if self._component_cache: # cache hit return self._component_cache # flatten component pool and store to cache self._component_cache = [j for i in self._pool.values() for j in i] self._component_cache.extend( [item for g in self._extern_groups for item in g.components] ) return self._component_cache def select(self, selector): if self._selected is not None: self._selected.deselect() selector.select() self._selected = selector self._widget_pane.clear_children() if isinstance(selector.group, ComponentGroup): components = selector.group.components else: components = self._pool[selector.name] for component in components: component.pack(side="top", pady=2, fill="x") def _auto_select(self): # automatically pick a selector when no groups have # been explicitly selected and the pane is in limbo if self._selectors: self.select(self._selectors[0]) else: self._widget_pane.clear_children() self._selected = None def render_groups(self): self._selectors = [] for group in self._pool: self.add_selector(Selector(self._select_pane.body, text=group)) self._auto_select() self.render_extern_groups() def render_extern_groups(self): for group in self._extern_groups: if group.supports(self._widget): self.add_selector(group.selector) else: self.remove_selector(group.selector) if self._selected == group.selector: self._auto_select() def add_selector(self, selector): if selector in self._selectors: return self._selectors.append(selector) selector.bind("<Button-1>", lambda *_: self.select(selector)) selector.pack(side="top", pady=2, fill="x") def remove_selector(self, selector): if selector in self._selectors: self._selectors.remove(selector) selector.pack_forget() def hide_selectors(self): for selector in self._selectors: selector.pack_forget() def show_selectors(self): for selector in self._selectors: selector.pack(side="top", pady=2, fill="x") def register_group(self, name, items, group_class, evaluator=None, component_class=None): group = group_class(self._widget_pane.body, name, items, evaluator, component_class) self._extern_groups.append(group) # link up selector and group group.selector = Selector(self._select_pane.body, text=group.name) group.selector.group = group self.render_extern_groups() return group def unregister_group(self, group): if group in self._extern_groups: self.remove_selector(group.selector) self._extern_groups.remove(group) self._auto_select() def on_select(self, widget): self._widget = widget self.render_extern_groups() def start_search(self, *_): super().start_search() self._widget_pane.scroll_to_start() if self._selected is not None: self._selected.deselect() self.hide_selectors() self._search_selector.pack(side="top", pady=2, fill="x") self._widget_pane.clear_children() # Display all components by running an empty query self.on_search_query("") def on_search_clear(self): super().on_search_clear() if self._selectors: self.select(self._selectors[0]) self._search_selector.pack_forget() self.show_selectors() def on_search_query(self, query): for component in self.get_components(): if query.lower() in component.component.display_name.lower(): component.pack(side="top", pady=2, fill="x") else: component.pack_forget()
class StylePane(BaseFeature): name = "Style pane" icon = "edit" _defaults = { **BaseFeature._defaults, "side": "right", } def __init__(self, master, studio, **cnf): super().__init__(master, studio, **cnf) self.body = ScrolledFrame(self, **self.style.dark) self.body.pack(side="top", fill="both", expand=True) self._toggle_btn = Button(self._header, image=get_icon_image("chevron_down", 15, 15), **self.style.dark_button, width=25, height=25) self._toggle_btn.pack(side="right") self._toggle_btn.on_click(self._toggle) 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.groups = [] self._identity_group = self.add_group(IdentityGroup) self._layout_group = self.add_group(LayoutGroup) self._attribute_group = self.add_group(AttributeGroup) self._empty_frame = Frame(self.body) self.show_empty() self._current = None self._expanded = False def create_menu(self): return (("command", "Search", get_icon_image("search", 14, 14), self.start_search, {}), ("command", "Expand all", get_icon_image("chevron_down", 14, 14), self.expand_all, {}), ("command", "Collapse all", get_icon_image("chevron_up", 14, 14), self.collapse_all, {})) def add_group(self, group_class) -> StyleGroup: if not issubclass(group_class, StyleGroup): raise ValueError('type required.') group = group_class(self.body.body) self.groups.append(group) group.pack(side='top', fill='x', pady=4) return group def show_empty(self): self.remove_empty() self._empty_frame.place(x=0, y=0, relheight=1, relwidth=1) Label(self._empty_frame, text="You have not selected any item", **self.style.dark_text_passive).place(x=0, y=0, relheight=1, relwidth=1) def remove_empty(self): self._empty_frame.clear_children() self._empty_frame.place_forget() def show_loading(self): self.remove_empty() self._empty_frame.place(x=0, y=0, relheight=1, relwidth=1) Label(self._empty_frame, text="Loading...", **self.style.dark_text_passive).place(x=0, y=0, relheight=1, relwidth=1) def styles_for(self, widget): self._current = widget if widget is None: self.show_empty() return self.show_loading() for group in self.groups: group.on_widget_change(widget) self.remove_empty() self.body.update_idletasks() def layout_for(self, widget): self._layout_group.on_widget_change(widget) def on_select(self, widget): self.styles_for(widget) def on_widget_change(self, old_widget, new_widget=None): self.styles_for(new_widget) def on_widget_layout_change(self, widget): self.layout_for(widget) def expand_all(self): for group in self.groups: group.expand() self._expanded = True self._toggle_btn.config(image=get_icon_image("chevron_up", 15, 15)) def clear_all(self): for group in self.groups: group.clear_children() def collapse_all(self): for group in self.groups: group.collapse() self._expanded = False self._toggle_btn.config(image=get_icon_image("chevron_down", 15, 15)) def _toggle(self, *_): if not self._expanded: self.expand_all() else: self.collapse_all() def __update_frames(self): for group in self.groups: group.update_state() def start_search(self, *_): if self._current: super().start_search() self.body.scroll_to_start() def on_search_query(self, query): for group in self.groups: group.on_search_query(query) self.__update_frames() def on_search_clear(self): for group in self.groups: group.on_search_clear() # The search bar is being closed and we need to bring everything back super().on_search_clear()
class StylePaneFramework: def setup_style_pane(self): self.body = ScrolledFrame(self, **self.style.surface) self.body.pack(side="top", fill="both", expand=True) self._toggle_btn = Button(self.get_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.get_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.groups = [] self._empty_frame = Frame(self.body) self.show_empty() self._current = None self._expanded = False self._is_loading = False self._search_query = None def get_header(self): raise NotImplementedError() @property def supported_groups(self): return [ group for group in self.groups if group.supports_widget(self._current) ] def create_menu(self): return (("command", "Search", get_icon_image("search", 14, 14), self.start_search, {}), ("command", "Expand all", get_icon_image("chevron_down", 14, 14), self.expand_all, {}), ("command", "Collapse all", get_icon_image("chevron_up", 14, 14), self.collapse_all, {})) def extern_apply(self, group_class, prop, value, widget=None, silent=False): for group in self.groups: if group.__class__ == group_class: group.apply(prop, value, widget, silent) return raise ValueError(f"Class {group_class.__class__.__name__} not found") def last_action(self): raise NotImplementedError() def new_action(self, action): raise NotImplementedError() def widget_modified(self, widget): raise NotImplementedError() def add_group(self, group_class, **kwargs) -> StyleGroup: if not issubclass(group_class, StyleGroup): raise ValueError('type required.') group = group_class(self.body.body, self, **kwargs) self.groups.append(group) self.show_group(group) return group def add_group_instance(self, group_instance, show=False): if not isinstance(group_instance, StyleGroup): raise ValueError('Expected object of type StyleGroup.') self.groups.append(group_instance) if show: self.show_group(group_instance) def hide_group(self, group): if group.self_positioned: group._hide_group() return group.pack_forget() def show_group(self, group): if group.self_positioned: group._show_group() return group.pack(side='top', fill='x', pady=12) def show_empty(self): self.remove_empty() self._empty_frame.place(x=0, y=0, relheight=1, relwidth=1) Label(self._empty_frame, text="You have not selected any item", **self.style.text_passive).place(x=0, y=0, relheight=1, relwidth=1) def remove_empty(self): self._empty_frame.clear_children() self._empty_frame.place_forget() def show_loading(self): if platform_is(LINUX) or self._is_loading: # render transitions in linux are very fast and glitch free # for other platforms or at least for windows we need to hide the glitching return self.remove_empty() self._empty_frame.place(x=0, y=0, relheight=1, relwidth=1) Label(self._empty_frame, text="Loading...", **self.style.text_passive).place(x=0, y=0, relheight=1, relwidth=1) self._is_loading = True def remove_loading(self): self.remove_empty() self._is_loading = False def styles_for(self, widget): self._current = widget if widget is None: self.show_empty() return for group in self.groups: if group.supports_widget(widget): self.show_group(group) group.on_widget_change(widget) else: self.hide_group(group) self.remove_loading() self.body.update_idletasks() def layout_for(self, widget): for group in self.groups: if group.handles_layout: group.on_widget_change(widget) self.remove_loading() def on_select(self, widget): self.styles_for(widget) def on_widget_change(self, old_widget, new_widget=None): if new_widget is None: new_widget = old_widget self.styles_for(new_widget) def on_widget_layout_change(self, widget): self.layout_for(widget) def expand_all(self): for group in self.groups: group.expand() self._expanded = True self._toggle_btn.config(image=get_icon_image("chevron_up", 15, 15)) def clear_all(self): for group in self.groups: group.clear_children() def collapse_all(self): for group in self.groups: group.collapse() self._expanded = False self._toggle_btn.config(image=get_icon_image("chevron_down", 15, 15)) def _toggle(self, *_): if not self._expanded: self.expand_all() else: self.collapse_all() def __update_frames(self): for group in self.groups: group.update_state() def start_search(self, *_): if self._current: super().start_search() self.body.scroll_to_start() def on_search_query(self, query): for group in self.groups: group.on_search_query(query) self.__update_frames() self.body.scroll_to_start() self._search_query = query def on_search_clear(self): for group in self.groups: group.on_search_clear() # The search bar is being closed and we need to bring everything back super().on_search_clear() self._search_query = None