class StyleItem(Frame): def __init__(self, parent, style_definition, on_change=None): super().__init__(parent.body) self.pref = get_active_pref(self) self.definition = style_definition self.name = style_definition.get("name") self.config(**self.style.surface) display = get_display_name(style_definition, self.pref) self._label = Label(self, **parent.style.text_passive, text=display, anchor="w") self._label.grid(row=0, column=0, sticky='ew') self._editor = get_editor(self, style_definition) self._editor.grid(row=0, column=1, sticky='ew') self.grid_columnconfigure(1, weight=1, uniform=1) self.grid_columnconfigure(0, weight=1, uniform=1) self._on_change = on_change self._editor.set(style_definition.get("value")) self._editor.on_change(self._change) def _change(self, value): if self._on_change: self._on_change(self.name, value) def set_label(self, name): self._label.configure(text=name) def on_change(self, callback, *args, **kwargs): self._on_change = lambda name, val: callback(name, val, *args, **kwargs) def hide(self): self.grid_propagate(False) self.configure(height=0, width=0) def show(self): self.grid_propagate(True) def set(self, value): self._editor.set(value) def set_silently(self, value): # disable ability to trigger on change before setting value prev_callback = self._on_change self._on_change = None self.set(value) self._on_change = prev_callback
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 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 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 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 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 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))