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')) )
class Designer(DesignPad, Container): MOVE = 0x2 RESIZE = 0x3 WIDGET_INIT_PADDING = 20 WIDGET_INIT_HEIGHT = 25 name = "Designer" pane = None _coord_indicator = None def __init__(self, master, studio): super().__init__(master) self.id = None self.context = master self.studio = studio self.name_generator = NameGenerator(self.studio.pref) self.setup_widget() self.parent = self self.config(**self.style.bright, takefocus=True) self.objects = [] self.root_obj = None self.layout_strategy = DesignLayoutStrategy(self) self.highlight = HighLight(self) self.highlight.on_resize(self._on_size_changed) self.highlight.on_move(self._on_move) self.highlight.on_release(self._on_release) self.highlight.on_start(self._on_start) self._update_throttling() self.studio.pref.add_listener("designer::frame_skip", self._update_throttling) self.current_obj = None self.current_container = None self.current_action = None self._displace_active = False self._last_displace = time.time() self._frame.bind("<Button-1>", lambda _: self.focus_set(), '+') self._frame.bind("<Button-1>", self.set_pos, '+') self._frame.bind('<Motion>', self.on_motion, '+') self._frame.bind('<KeyRelease>', self._stop_displace, '+') self._padding = 30 self.design_path = None self.builder = DesignBuilder(self) self._shortcut_mgr = KeyMap(self._frame) self._set_shortcuts() self._last_click_pos = None self._empty = Label( self, image=get_tk_image("paint", 30, 30), compound="top", text=" ", **self.style.text_passive, ) self._empty.config(**self.style.bright) self._show_empty(True) # create the dynamic menu self._context_menu = MenuUtils.make_dynamic( self.studio.menu_template + (LoadLater(self.studio.tool_manager.get_tool_menu), ) + (LoadLater(lambda: self.current_obj.create_menu() if self.current_obj else ()), ), self.studio, self.style) design_menu = (EnableIf( lambda: self.studio._clipboard is not None, ("command", "paste", icon("clipboard", 14, 14), lambda: self.paste(self.studio._clipboard, paste_to=self), {})), ) self.set_up_context(design_menu) self._empty.set_up_context(design_menu) if Designer._coord_indicator is None: Designer._coord_indicator = self.studio.install_status_widget( CoordinateIndicator) self._text_editor = Text(self, wrap='none') self._text_editor.on_change(self._text_change) self._text_editor.bind("<FocusOut>", self._text_hide) self._base_font = FontStyle() def _get_designer(self): return self def focus_set(self): self._frame.focus_force() def set_pos(self, event): # store the click position for effective widget pasting self._last_click_pos = event.x_root, event.y_root def _update_throttling(self, *_): self.highlight.set_skip_max( self.studio.pref.get("designer::frame_skip")) def _show_empty(self, flag, **kw): if flag: kw['image'] = kw.get('image', get_tk_image('paint', 30, 30)) kw['text'] = kw.get('text', "Drag or paste a container here to start") self._empty.configure(**kw) self._empty.place(relwidth=1, relheight=1) else: self._empty.place_forget() def _set_shortcuts(self): shortcut_mgr = self._shortcut_mgr shortcut_mgr.bind() shortcut_mgr.add_routines( actions.get('STUDIO_COPY'), actions.get('STUDIO_CUT'), actions.get('STUDIO_DELETE'), actions.get('STUDIO_PASTE'), actions.get('STUDIO_DUPLICATE'), ) # allow control of widget position using arrow keys shortcut_mgr.add_shortcut( (lambda: self.displace('right'), KeyMap.RIGHT), (lambda: self.displace('left'), KeyMap.LEFT), (lambda: self.displace('up'), KeyMap.UP), (lambda: self.displace('down'), KeyMap.DOWN), ) def _open_default(self): self.update_idletasks() from studio.lib import legacy width = max(self.width - self._padding * 2, 300) height = max(self.height - self._padding * 2, 300) self.add(legacy.Frame, self._padding, self._padding, width=width, height=height) self.builder.generate() self.design_path = None @property def _ids(self): return [i.id for i in self.objects] def has_changed(self): # check if design has changed since last save or loading so we can prompt user to save changes builder = self.builder if self.root_obj: builder = DesignBuilder(self) builder.generate() return builder != self.builder def open_new(self): # open a blank design self.open_file(None) def to_tree(self): """ Generate node form of current design state without needing to save""" builder = DesignBuilder(self) builder.generate() return builder.root def save_prompt(self): return MessageDialog.builder( { "text": "Save", "value": True, "focus": True }, { "text": "Don't save", "value": False }, { "text": "Cancel", "value": None }, wait=True, title="Save design", message= f"Design file \"{self.context.name}\" has unsaved changes. Do you want to save them?", parent=self.studio, icon=MessageDialog.ICON_INFO) def open_file(self, path=None): if self.has_changed(): save = self.save_prompt() if save: # user opted to save saved_to = self.save() if saved_to is None: # User did not complete saving and opted to cancel return elif save is None: # user made no choice or basically selected cancel return if path: self.builder = DesignBuilder(self) progress = MessageDialog.show_progress( mode=MessageDialog.INDETERMINATE, message='Loading design file to studio...', parent=self.studio) self._load_design(path, progress) else: # if no path is supplied the default behaviour is to open a blank design self._open_default() def clear(self): # Warning: this method deletes elements irreversibly # remove the current root objects and their descendants self.studio.select(None) # create a copy since self.objects will mostly change during iteration # remove root and dangling objects for widget in self.objects: widget.destroy() self.objects.clear() self.root_obj = None def _verify_version(self): if self.builder.metadata.get("version"): _, major, __ = __version__.split(".") if major < self.builder.metadata["version"].get("major", 0): MessageDialog.show_warning( parent=self.studio, message= ("Design was made using a higher version of the studio. \n" "Some features may not be supported on this version. \n" "Update to a new version of Formation for proper handling. \n" "Note that saving may irreversibly strip off any unsupported features" )) @as_thread def _load_design(self, path, progress=None): # Loading designs is elaborate so better do it on its own thread # Capture any errors that occur while loading # This helps the user single out syntax errors and other value errors try: self.design_path = path self.root_obj = self.builder.load(path, self) self.context.on_load_complete() except Exception as e: self.clear() self.studio.on_session_clear(self) accelerator = actions.get_routine("STUDIO_RELOAD").accelerator text = f"{str(e)}\nPress {accelerator} to reload" if accelerator else f"{str(e)} \n reload design" self._show_empty(True, text=text, image=get_tk_image("dialog_error", 50, 50)) # MessageDialog.show_error(parent=self.studio, title='Error loading design', message=str(e)) finally: if progress: progress.destroy() self._verify_version() def reload(self, *_): if not self.design_path or self.studio.context != self.context: return if self.has_changed(): okay = MessageDialog.ask_okay_cancel( title="Confirm reload", message="All changes made will be lost", parent=self.studio) if not okay: # user made no choice or basically selected cancel return self.clear() self.studio.on_session_clear(self) self.open_file(self.design_path) def save(self, new_path=False): if not self.design_path or new_path: path = filedialog.asksaveasfilename(parent=self, filetypes=get_file_types(), defaultextension='.xml') if not path: return None self.design_path = path self.builder.write(self.design_path) return self.design_path def as_node(self, widget): builder = self.builder if builder is None: builder = DesignBuilder(self) return builder.to_tree(widget) def paste(self, node, silently=False, paste_to=None): if paste_to is None: paste_to = self.current_obj if paste_to is None: return layout = paste_to if isinstance(paste_to, Container) else paste_to.layout width = int(node["layout"]["width"] or 0) height = int(node["layout"]["height"] or 0) x, y = self._last_click_pos or (self.winfo_rootx() + 50, self.winfo_rooty() + 50) self._last_click_pos = x + 5, y + 5 # slightly displace click position so multiple pastes are still visible bounds = geometry.resolve_bounds((x, y, x + width, y + height), self) obj = self.builder.load_section(node, layout, bounds) restore_point = layout.get_restore(obj) # Create an undo redo point if add is not silent if not silently: self.studio.new_action( Action( # Delete silently to prevent adding the event to the undo/redo stack lambda _: self.delete(obj, True), lambda _: self.restore(obj, restore_point, obj.layout))) return obj def _get_unique(self, obj_class): """ Generate a unique id for widget belonging to a given class """ return self.name_generator.generate(obj_class, self._ids) def on_motion(self, event): self.highlight.resize(event) geometry.make_event_relative(event, self) self._coord_indicator.set_coord(self._frame.canvasx(event.x), self._frame.canvasy(event.y)) def _attach(self, obj): # bind events for context menu and object selection # all widget additions call this method so clear empty message self._show_empty(False) MenuUtils.bind_all_context(obj, lambda e: self.show_menu(e, obj), add='+') obj.bind_all( '<Shift-ButtonPress-1>', lambda e: self.highlight.set_function(self.highlight.move, e), add='+') obj.bind_all('<Motion>', self.on_motion, '+') obj.bind_all('<ButtonRelease>', self.highlight.clear_resize, '+') if "text" in obj.keys(): obj.bind_all("<Double-Button-1>", lambda _: self._show_text_editor(obj)) self.objects.append(obj) if self.root_obj is None: self.root_obj = obj obj.bind_all("<Button-1>", lambda e: self._handle_select(obj, e), add='+') # bind shortcuts self._shortcut_mgr.bind_widget(obj) def show_menu(self, event, obj=None): # select object generating the context menu event first if obj is not None: self.select(obj) MenuUtils.popup(event, self._context_menu) def _handle_select(self, obj, event): # store the click position for effective widget pasting self._last_click_pos = event.x_root, event.y_root self.select(obj) def load(self, obj_class, name, container, attributes, layout, bounds=None): obj = obj_class(self, name) obj.configure(**attributes) self._attach(obj) if bounds is not None: container.add_widget(obj, bounds) else: container.add_widget(obj, **layout) if container == self: container = None self.studio.add(obj, container) return obj def _show_root_widget_warning(self): MessageDialog.show_warning( title='Invalid root widget', parent=self.studio, message='Only containers are allowed as root widgets') def add(self, obj_class: PseudoWidget.__class__, x, y, **kwargs): if obj_class.group != Groups.container and self.root_obj is None: # We only need a container as the root widget self._show_root_widget_warning() return silent = kwargs.get("silently", False) name = self._get_unique(obj_class) obj = obj_class(self, name) if hasattr(obj, 'initial_dimensions'): width, height = obj.initial_dimensions else: width = kwargs.get( "width", self._base_font.measure(name) + self.WIDGET_INIT_PADDING) height = kwargs.get("height", self.WIDGET_INIT_HEIGHT) obj.layout = kwargs.get("intended_layout", None) self._attach(obj) # apply extra bindings required layout = kwargs.get("layout") # If the object has a layout which actually the layout at the point of creation prepare and pass it # to the layout if isinstance(layout, Container): bounds = (x, y, x + width, y + height) bounds = geometry.resolve_bounds(bounds, self) layout.add_widget(obj, bounds) self.studio.add(obj, layout) restore_point = layout.get_restore(obj) # Create an undo redo point if add is not silent if not silent: self.studio.new_action( Action( # Delete silently to prevent adding the event to the undo/redo stack lambda _: self.delete(obj, True), lambda _: self.restore(obj, restore_point, obj.layout)) ) elif obj.layout is None: # This only happens when adding the main layout. We dont need to add this action to the undo/redo stack # This main layout is attached directly to the designer obj.layout = self self.layout_strategy.add_widget(obj, x=x, y=y, width=width, height=height) self.studio.add(obj, None) return obj def select_layout(self, layout: Container): pass def restore(self, widget, restore_point, container): container.restore_widget(widget, restore_point) self.studio.on_restore(widget) self._replace_all(widget) def _replace_all(self, widget): # Recursively add widget and all its children to objects self.objects.append(widget) if self.root_obj is None: self.root_obj = widget if isinstance(widget, Container): for child in widget._children: self._replace_all(child) def delete(self, widget, silently=False): if not widget: return if not silently: restore_point = widget.layout.get_restore(widget) self.studio.new_action( Action( lambda _: self.restore(widget, restore_point, widget.layout ), lambda _: self.studio.delete(widget, True))) else: self.studio.delete(widget, self) widget.layout.remove_widget(widget) if widget == self.root_obj: # try finding another toplevel widget that can be a root obj otherwise leave it as none self.root_obj = None for w in self.layout_strategy.children: if isinstance(w, Container) or w.group == Groups.container: self.root_obj = w break self._uproot_widget(widget) if not self.objects: self._show_empty(True) def _uproot_widget(self, widget): # Recursively remove widgets and all its children if widget in self.objects: self.objects.remove(widget) if isinstance(widget, Container): for child in widget._children: self._uproot_widget(child) def set_active_container(self, container): if self.current_container is not None: self.current_container.clear_highlight() self.current_container = container def compute_overlap(self, bound1, bound2): return geometry.compute_overlap(bound1, bound2) def layout_at(self, bounds): for container in sorted(filter( lambda x: isinstance(x, Container) and x != self.current_obj, self.objects), key=lambda x: len(self.objects) - x.level): if isinstance( self.current_obj, Container) and self.current_obj.level < container.level: continue if self.compute_overlap(geometry.bounds(container), bounds): return container return None def parse_bounds(self, bounds): return { "x": bounds[0], "y": bounds[1], "width": bounds[2] - bounds[0], "height": bounds[3] - bounds[1] } def position(self, widget, bounds): self.place_child(widget, **self.parse_bounds(bounds)) def select(self, obj, explicit=False): if obj is None: self.clear_obj_highlight() self.studio.select(None, self) self.highlight.clear() return self.focus_set() if self.current_obj == obj: return self.clear_obj_highlight() self.current_obj = obj self.draw_highlight(obj) if not explicit: # The event is originating from the designer self.studio.select(obj, self) def draw_highlight(self, obj): self.highlight.surround(obj) def _stop_displace(self, _): if self._displace_active: # this ensures event is added to undo redo stack self._on_release(geometry.bounds(self.current_obj)) # mark the latest action as designer displace latest = self.studio.last_action() if latest is not None: latest.key = "designer_displace" self._displace_active = False def displace(self, side): if not self.current_obj: return if time.time() - self._last_displace < .5: self.studio.pop_last_action("designer_displace") self._on_start() self._displace_active = True self._last_displace = time.time() bounds = geometry.bounds(self.current_obj) x1, y1, x2, y2 = bounds if side == 'right': bounds = x1 + 1, y1, x2 + 1, y2 elif side == 'left': bounds = x1 - 1, y1, x2 - 1, y2 elif side == 'up': bounds = x1, y1 - 1, x2, y2 - 1 elif side == 'down': bounds = x1, y1 + 1, x2, y2 + 1 self._on_move(bounds) def clear_obj_highlight(self): if self.highlight is not None: self.highlight.clear() self.current_obj = None if self.current_container is not None: self.current_container.clear_highlight() self.current_container = None def _on_start(self): obj = self.current_obj if obj is not None: obj.layout.change_start(obj) def _on_release(self, bound): obj = self.current_obj container = self.current_container if obj is None: return if container is not None and container != obj: container.clear_highlight() if self.current_action == self.MOVE: container.add_widget(obj, bound) # If the enclosed widget was initially the root object, make the container the new root object if obj == self.root_obj and obj != self: self.root_obj = self.current_container else: obj.layout.widget_released(obj) self.studio.widget_layout_changed(obj) self.current_action = None self.create_restore(obj) elif obj.layout == self and self.current_action == self.MOVE: self.create_restore(obj) elif self.current_action == self.RESIZE: obj.layout.widget_released(obj) self.current_action = None self.create_restore(obj) def create_restore(self, widget): prev_restore_point = widget.recent_layout_info cur_restore_point = widget.layout.get_restore(widget) if prev_restore_point == cur_restore_point: return prev_container = prev_restore_point["container"] container = widget.layout def undo(_): container.remove_widget(widget) prev_container.restore_widget(widget, prev_restore_point) def redo(_): prev_container.remove_widget(widget) container.restore_widget(widget, cur_restore_point) self.studio.new_action(Action(undo, redo)) def _on_move(self, new_bound): obj = self.current_obj current_container = self.current_container if obj is None: return self.current_action = self.MOVE container: Container = self.layout_at(new_bound) if container is not None and obj != container: if container != current_container: if current_container is not None: current_container.clear_highlight() container.show_highlight() self.current_container = container container.move_widget(obj, new_bound) else: if current_container is not None: current_container.clear_highlight() self.current_container = self obj.level = 0 obj.layout = self self.move_widget(obj, new_bound) if obj.layout.layout_strategy.realtime_support: self.studio.widget_layout_changed(obj) def _on_size_changed(self, new_bound): obj = self.current_obj if obj is None: return self.current_action = self.RESIZE if isinstance(obj.layout, Container): obj.layout.resize_widget(obj, new_bound) if obj.layout.layout_strategy.realtime_support: self.studio.widget_layout_changed(obj) def _text_change(self): self.studio.style_pane.apply_style("text", self._text_editor.get_all(), self.current_obj) def _show_text_editor(self, widget): assert widget == self.current_obj self._text_editor.lift(widget) cnf = self._collect_text_config(widget) self._text_editor.config(**cnf) self._text_editor.place(in_=widget, relwidth=1, relheight=1, x=0, y=0) self._text_editor.clear() self._text_editor.focus_set() self._text_editor.insert("1.0", widget["text"]) def _collect_text_config(self, widget): s = ttk.Style() config = dict(background="#ffffff", foreground="#000000", font="TkDefaultFont") keys = widget.keys() for opt in config: if opt in keys: config[opt] = (widget[opt] or config[opt]) else: config[opt] = (s.lookup(widget.winfo_class(), opt) or config[opt]) config["insertbackground"] = config["foreground"] return config def _text_hide(self, *_): self._text_editor.place_forget() def on_select(self, widget): self.select(widget) def on_widget_change(self, old_widget, new_widget=None): pass def on_widget_add(self, widget, parent): pass def show_highlight(self, *_): pass def save_window_pos(self): pass def on_app_close(self): if self.has_changed(): save = self.save_prompt() if save: save_to = self.save() if save_to is None: return False elif save is None: return False return True
class Designer(DesignPad, Container): MOVE = 0x2 RESIZE = 0x3 name = "Designer" pane = None def __init__(self, master, studio): super().__init__(master) self.id = None self.setup_widget() self.parent = self self.studio = studio self.config(**self.style.bright, takefocus=True) self.objects = [] self.root_obj = None self.layout_strategy = DesignLayoutStrategy(self) self.highlight = HighLight(self) self.highlight.on_resize(self._on_size_changed) self.highlight.on_move(self._on_move) self.highlight.on_release(self._on_release) self._update_throttling() self.studio.pref.add_listener( "designer::frame_skip", self._update_throttling ) self.current_obj = None self.current_container = None self.current_action = None self._frame.bind("<Button-1>", lambda *_: self.focus_set()) self._frame.bind('<Motion>', self.on_motion, '+') self._padding = 30 self.design_path = None self.xml = XMLForm(self) self._load_progress = None self._shortcut_mgr = KeyMap(self._frame) self._set_shortcuts() self._last_click_pos = None # create the dynamic menu self._context_menu = MenuUtils.make_dynamic( self.studio.menu_template + ToolManager.get_tool_menu(self.studio) + (LoadLater(lambda: self.current_obj.create_menu() if self.current_obj else ()),), self.studio, self.style ) self._coord_indicator = self.studio.install_status_widget(CoordinateIndicator) self._empty = Label( self, image=get_tk_image("paint", 30, 30), compound="top", text="Drag a container here to start", **self.style.dark_text_passive, ) self._empty.config(**self.style.bright) self._show_empty(True) def focus_set(self): self._frame.focus_force() def _update_throttling(self, *_): self.highlight.set_skip_max(self.studio.pref.get("designer::frame_skip")) def _show_empty(self, flag): if flag: self._empty.place(relwidth=1, relheight=1) else: self._empty.place_forget() def _set_shortcuts(self): shortcut_mgr = self._shortcut_mgr shortcut_mgr.bind() shortcut_mgr.add_routines( actions.get('STUDIO_COPY'), actions.get('STUDIO_CUT'), actions.get('STUDIO_DELETE'), actions.get('STUDIO_PASTE'), ) # allow control of widget position using arrow keys shortcut_mgr.add_shortcut( (lambda: self.displace('right'), KeyMap.RIGHT), (lambda: self.displace('left'), KeyMap.LEFT), (lambda: self.displace('up'), KeyMap.UP), (lambda: self.displace('down'), KeyMap.DOWN), ) def _open_default(self): self.update_idletasks() from studio.lib import legacy self.add(legacy.Frame, self._padding, self._padding, width=self.width - self._padding * 2, height=self.height - self._padding * 2) self.xml.generate() self.design_path = None @property def _ids(self): return [i.id for i in self.objects] def has_changed(self): # check if design has changed since last save or loading so we can prompt user to save changes xml = self.xml if self.root_obj: xml = XMLForm(self) xml.generate() return xml != self.xml def open_new(self): # open a blank design self.open_xml(None) def to_xml(self): """ Generate xml form of current design state without needing to save""" xml = XMLForm(self) xml.generate() return xml.root def save_prompt(self): return MessageDialog.builder( {"text": "Save", "value": True, "focus": True}, {"text": "Don't save", "value": False}, {"text": "Cancel", "value": None}, wait=True, title="Save design", message="This design has unsaved changes. Do you want to save them?", parent=self.studio, icon=MessageDialog.ICON_INFO ) def open_xml(self, path=None): if self.has_changed(): save = self.save_prompt() if save: # user opted to save saved_to = self.save() if saved_to is None: # User did not complete saving and opted to cancel return elif save is None: # user made no choice or basically selected cancel return self.clear() # inform the studio about the session clearing self.studio.on_session_clear(self) if path: self.xml = XMLForm(self) self._load_design(path) else: # if no path is supplied the default behaviour is to open a blank design self._open_default() def clear(self): # Warning: this method deletes elements irreversibly # remove the current root objects and their descendants self.studio.select(None) # create a copy since self.objects will mostly change during iteration # remove root and dangling objects for widget in self.objects: widget.destroy() self.objects.clear() self.root_obj = None @as_thread def _load_design(self, path): # Loading designs is elaborate so better do it on its own thread self._load_progress = MessageDialog.show_progress( mode=MessageDialog.INDETERMINATE, message='Loading design file to studio...', parent=self.studio ) # Capture any errors that occur while loading # This helps the user single out syntax errors and other value errors try: with open(path, 'rb') as dump: self.root_obj = self.xml.load_xml(dump, self) # store the file hash so we can check for changes later self.design_path = path except Exception as e: MessageDialog.show_error(parent=self.studio, title='Error loading design', message=str(e)) finally: if self._load_progress: self._load_progress.destroy() self._load_progress = None def save(self, new_path=False): self.xml.generate() if not self.design_path or new_path: path = filedialog.asksaveasfilename(parent=self, filetypes=[("XML", "*.xml")], defaultextension='.xml') if not path: return None self.design_path = path with open(self.design_path, 'w') as dump: dump.write(self.xml.to_xml( self.studio.pref.get("designer::xml::pretty_print") )) return self.design_path def as_xml_node(self, widget): xml = self.xml if xml is None: xml = XMLForm(self) return xml.to_xml_tree(widget) def paste(self, node, silently=False): if not self.current_obj: return layout = self.current_obj if isinstance(self.current_obj, Container) else self.current_obj.layout width = int(BaseConverter.get_attr(node, "width", "layout") or 0) height = int(BaseConverter.get_attr(node, "height", "layout") or 0) x, y = self._last_click_pos or (self.winfo_rootx() + 50, self.winfo_rooty() + 50) self._last_click_pos = x + 5, y + 5 # slightly displace click position so multiple pastes are still visible bounds = geometry.resolve_bounds((x, y, x + width, y + height), self) obj = self.xml.load_section(node, layout, bounds) restore_point = layout.get_restore(obj) # Create an undo redo point if add is not silent if not silently: self.studio.new_action(Action( # Delete silently to prevent adding the event to the undo/redo stack lambda: self.delete(obj, True), lambda: self.restore(obj, restore_point, obj.layout) )) return obj def _get_unique(self, obj_class): """ Generate a unique id for widget belonging to a given class """ # start from 1 and check if name exists, if it exists keep incrementing count = 1 name = f"{obj_class.display_name}_{count}" while name in self._ids: name = f"{obj_class.display_name}_{count}" count += 1 return name def on_motion(self, event): self.highlight.resize(event) geometry.make_event_relative(event, self) self._coord_indicator.set_coord( self._frame.canvasx(event.x), self._frame.canvasy(event.y) ) def _attach(self, obj): # bind events for context menu and object selection # all widget additions call this method so clear empty message self._show_empty(False) obj.bind("<Button-3>", lambda e: self.show_menu(e, obj), add='+') obj.bind('<Shift-ButtonPress-1>', lambda e: self.highlight.set_function(self.highlight.move, e), add='+') obj.bind('<Motion>', self.on_motion, '+') obj.bind('<ButtonRelease>', self.highlight.clear_resize, '+') self.objects.append(obj) if self.root_obj is None: self.root_obj = obj obj.bind("<Button-1>", lambda e: self._handle_select(obj, e), add='+') # bind shortcuts self._shortcut_mgr.bind_widget(obj) def show_menu(self, event, obj=None): # select object generating the context menu event first if obj is not None: self.select(obj) MenuUtils.popup(event, self._context_menu) def _handle_select(self, obj, event): # store the click position for effective widget pasting self._last_click_pos = event.x_root, event.y_root self.select(obj) def load(self, obj_class, name, container, attributes, layout, bounds=None): obj = obj_class(self, name) obj.configure(**attributes) self._attach(obj) if bounds is not None: container.add_widget(obj, bounds) else: container.add_widget(obj, **layout) if container == self: container = None self.studio.add(obj, container) return obj def _show_root_widget_warning(self): MessageDialog.show_warning(title='Invalid root widget', parent=self.studio, message='Only containers are allowed as root widgets') def add(self, obj_class: PseudoWidget.__class__, x, y, **kwargs): if obj_class.group != Groups.container and self.root_obj is None: # We only need a container as the root widget self._show_root_widget_warning() return width = kwargs.get("width", 55) height = kwargs.get("height", 30) silent = kwargs.get("silently", False) name = self._get_unique(obj_class) obj = obj_class(self, name) obj.layout = kwargs.get("intended_layout", None) self._attach(obj) # apply extra bindings required layout = kwargs.get("layout") # If the object has a layout which actually the layout at the point of creation prepare and pass it # to the layout if isinstance(layout, Container): bounds = (x, y, x + width, y + height) bounds = geometry.resolve_bounds(bounds, self) layout.add_widget(obj, bounds) self.studio.add(obj, layout) restore_point = layout.get_restore(obj) # Create an undo redo point if add is not silent if not silent: self.studio.new_action(Action( # Delete silently to prevent adding the event to the undo/redo stack lambda: self.delete(obj, True), lambda: self.restore(obj, restore_point, obj.layout) )) elif obj.layout is None: # This only happens when adding the main layout. We dont need to add this action to the undo/redo stack # This main layout is attached directly to the designer obj.layout = self self.layout_strategy.add_widget(obj, x=x, y=y, width=width, height=height) self.studio.add(obj, None) return obj def select_layout(self, layout: Container): pass def restore(self, widget, restore_point, container): container.restore_widget(widget, restore_point) self.studio.on_restore(widget) self._replace_all(widget) def _replace_all(self, widget): # Recursively add widget and all its children to objects self.objects.append(widget) if self.root_obj is None: self.root_obj = widget if isinstance(widget, Container): for child in widget._children: self._replace_all(child) def delete(self, widget, silently=False): if not widget: return if not silently: restore_point = widget.layout.get_restore(widget) self.studio.new_action(Action( lambda: self.restore(widget, restore_point, widget.layout), lambda: self.studio.delete(widget) )) else: self.studio.delete(widget, self) widget.layout.remove_widget(widget) if widget == self.root_obj: # try finding another toplevel widget that can be a root obj otherwise leave it as none self.root_obj = None for w in self.layout_strategy.children: if isinstance(w, Container): self.root_obj = w break self._uproot_widget(widget) def _uproot_widget(self, widget): # Recursively remove widgets and all its children if widget in self.objects: self.objects.remove(widget) if isinstance(widget, Container): for child in widget._children: self._uproot_widget(child) def react(self, event): layout = self.event_first(event, self, Container, ignore=self) if self.current_container is not None: self.current_container.clear_highlight() self.current_container = None if isinstance(layout, Container): self.current_container = layout layout.react_to_pos(event.x_root, event.y_root) layout.show_highlight() def compute_overlap(self, bound1, bound2): return geometry.compute_overlap(bound1, bound2) def layout_at(self, bounds): for container in sorted(filter(lambda x: isinstance(x, Container) and x != self.current_obj, self.objects), key=lambda x: len(self.objects) - x.level): if isinstance(self.current_obj, Container) and self.current_obj.level < container.level: continue if self.compute_overlap(geometry.bounds(container), bounds): return container return None def parse_bounds(self, bounds): return { "x": bounds[0], "y": bounds[1], "width": bounds[2] - bounds[0], "height": bounds[3] - bounds[1] } def position(self, widget, bounds): self.place_child(widget, **self.parse_bounds(bounds)) def select(self, obj, explicit=False): if obj is None: self.clear_obj_highlight() self.studio.select(None, self) self.highlight.clear() return self.focus_set() if self.current_obj == obj: return self.clear_obj_highlight() self.current_obj = obj self.draw_highlight(obj) if not explicit: # The event is originating from the designer self.studio.select(obj, self) def draw_highlight(self, obj): self.highlight.surround(obj) def displace(self, side): if not self.current_obj: return bounds = geometry.bounds(self.current_obj) x1, y1, x2, y2 = bounds if side == 'right': bounds = x1 + 1, y1, x2 + 1, y2 elif side == 'left': bounds = x1 - 1, y1, x2 - 1, y2 elif side == 'up': bounds = x1, y1 - 1, x2, y2 - 1 elif side == 'down': bounds = x1, y1 + 1, x2, y2 + 1 self._on_move(bounds) self._on_release(bounds) def clear_obj_highlight(self): if self.highlight is not None: self.highlight.clear() self.current_obj = None if self.current_container is not None: self.current_container.clear_highlight() self.current_container = None def _on_release(self, bound): obj = self.current_obj container = self.current_container if obj is None: return if container is not None and container != obj: container.clear_highlight() if self.current_action == self.MOVE: container.add_widget(obj, bound) # If the enclosed widget was initially the root object, make the container the new root object if obj == self.root_obj and obj != self: self.root_obj = self.current_container else: obj.layout.widget_released(obj) self.studio.widget_layout_changed(obj) self.current_action = None elif self.current_action == self.RESIZE: obj.layout.widget_released(obj) self.current_action = None def create_restore(self, widget): restore_point = widget.layout.get_restore() self.studio.new_action(Action( lambda: self.restore(widget, restore_point, widget.layout), lambda: self.studio.delete(widget) )) def _on_move(self, new_bound): obj = self.current_obj current_container = self.current_container if obj is None: return self.current_action = self.MOVE container: Container = self.layout_at(new_bound) if container is not None and obj != container: if container != current_container: if current_container is not None: current_container.clear_highlight() container.show_highlight() self.current_container = container container.move_widget(obj, new_bound) else: if current_container is not None: current_container.clear_highlight() self.current_container = self obj.level = 0 obj.layout = self self.move_widget(obj, new_bound) if obj.layout.layout_strategy.realtime_support: self.studio.widget_layout_changed(obj) def _on_size_changed(self, new_bound): obj = self.current_obj if obj is None: return self.current_action = self.RESIZE if isinstance(obj.layout, Container): obj.layout.resize_widget(obj, new_bound) if obj.layout.layout_strategy.realtime_support: self.studio.widget_layout_changed(obj) def on_select(self, widget): self.select(widget) def on_widget_change(self, old_widget, new_widget=None): pass def on_widget_add(self, widget, parent): pass def show_highlight(self, *_): pass def on_app_close(self): if self.has_changed(): save = self.save_prompt() if save: self.save() elif save is None: return False return True
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 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 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 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 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()