From 4946780c3d5895ce864c08c95afad1bc75e4bf1f Mon Sep 17 00:00:00 2001 From: ObaraEmmanuel Date: Mon, 10 Jul 2023 23:21:49 +0300 Subject: [PATCH 01/15] Implement multiselect foundation --- hoverset/ui/widgets.py | 10 +- studio/feature/__init__.py | 6 +- studio/feature/component_tree.py | 75 +++-- studio/feature/design.py | 479 ++++++++++++++++++------------- studio/lib/layouts.py | 64 ++++- studio/lib/pseudo.py | 94 +++++- studio/main.py | 163 ++++++----- studio/selection.py | 118 ++++++++ studio/ui/geometry.py | 44 ++- studio/ui/highlight.py | 166 ++++++++++- 10 files changed, 862 insertions(+), 357 deletions(-) create mode 100644 studio/selection.py diff --git a/hoverset/ui/widgets.py b/hoverset/ui/widgets.py index 00a7995..83f993c 100644 --- a/hoverset/ui/widgets.py +++ b/hoverset/ui/widgets.py @@ -2558,6 +2558,10 @@ def _load_images(self): cls.BLANK = get_icon_image("blank", 14, 14) cls.__icons_loaded = True + @property + def selected(self): + return self._selected + @property def depth(self): return self._depth @@ -2718,8 +2722,7 @@ def remove(self, node=None): self._set_expander(self.BLANK) def expand(self): - if len(self.nodes) == 0: - # There is nothing to expand + if self._expanded: return self.pack_propagate(True) for node in filter(lambda n: n._visible, self.nodes): @@ -2728,8 +2731,7 @@ def expand(self): self._expanded = True def collapse(self): - if len(self.nodes) == 0: - # There is nothing to collapse + if not self._expanded: return for node in self.nodes: node.pack_forget() diff --git a/studio/feature/__init__.py b/studio/feature/__init__.py index f57daa8..ad67f96 100644 --- a/studio/feature/__init__.py +++ b/studio/feature/__init__.py @@ -7,9 +7,9 @@ FEATURES = ( ComponentPane, ComponentTree, - StylePane, - EventPane, - VariablePane, + #StylePane, + #EventPane, + #VariablePane, ) diff --git a/studio/feature/component_tree.py b/studio/feature/component_tree.py index d9fa415..cf37daa 100644 --- a/studio/feature/component_tree.py +++ b/studio/feature/component_tree.py @@ -77,8 +77,8 @@ def __init__(self, master, studio=None, **cnf): 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.studio.bind("<>", self._select, "+") - self._selected = None self._expanded = False self._tree = None @@ -92,6 +92,7 @@ def on_context_switch(self): self._tree = self.studio.designer.node else: self._tree = ComponentTreeView(self.body) + self._tree.allow_multi_select(True) self._tree.on_select(self._trigger_select) self.studio.designer.node = self._tree self._tree.pack(fill="both", expand=True) @@ -142,38 +143,52 @@ def on_widget_add(self, widget: PseudoWidget, parent=None): ) def _trigger_select(self): - if self._selected and self._selected.widget == self._tree.get().widget: + if self.studio.selection == self.selection(): return - self.studio.select(self._tree.get().widget, self) - self._selected = self._tree.get() - def select(self, widget): - if widget: + self.studio.selection.set(self.selection()) + + def _select(self, _): + if self.studio.selection == self.selection(): + return + + if not self._tree: + return + nodes = self.studio_selection() + + for node in list(self._tree.get()): + if node not in nodes: + self._tree.deselect(node) + + for node in nodes: + if not node.selected: + node.select(silently=True) + + def selection(self): + if not self._tree: + return [] + return [i.widget for i in self._tree.get()] + + def studio_selection(self): + return [i.node for i in self.studio.selection] + + def on_widget_delete(self, widgets, silently=False): + for widget in widgets: + widget.node.remove() + + def on_widget_restore(self, widgets): + for widget in widgets: + widget.layout.node.add(widget.node) + + def on_widget_layout_change(self, widgets): + for widget in widgets: 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) + 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"): diff --git a/studio/feature/design.py b/studio/feature/design.py index 96ce258..8dc38cb 100644 --- a/studio/feature/design.py +++ b/studio/feature/design.py @@ -12,7 +12,7 @@ from hoverset.data import actions from hoverset.data.keymap import KeyMap from hoverset.data.images import get_tk_image -from hoverset.ui.widgets import Label, Text, FontStyle, Checkbutton, CompoundList +from hoverset.ui.widgets import Label, Text, FontStyle, Checkbutton, CompoundList, EventMask from hoverset.ui.dialogs import MessageDialog from hoverset.ui.icons import get_icon_image as icon from hoverset.ui.menu import MenuUtils, LoadLater, EnableIf @@ -23,7 +23,7 @@ from studio.lib.pseudo import PseudoWidget, Container, Groups from studio.parsers.loader import DesignBuilder, BaseStudioAdapter from studio.ui import geometry -from studio.ui.highlight import HighLight +from studio.ui.highlight import WidgetHighlighter from studio.ui.widgets import DesignPad, CoordinateIndicator from studio.context import BaseContext from studio import __version__ @@ -37,6 +37,10 @@ class DesignLayoutStrategy(PlaceLayoutStrategy): def add_new(self, widget, x, y): self.container.add(widget, x, y, layout=self.container) + def resize_widget(self, widget, direction, delta): + info = self._info_with_delta(widget, direction, delta) + self.container.place_child(widget, **info) + def _move(self, widget, bounds): self.container.position(widget, self.container.canvas_bounds(bounds)) @@ -105,6 +109,7 @@ class Designer(DesignPad, Container): def __init__(self, master, studio): super().__init__(master) self.id = None + self._level = -1 self.context = master self.studio = studio self.name_generator = NameGenerator(self.studio.pref) @@ -114,16 +119,6 @@ def __init__(self, master, studio): 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 @@ -152,7 +147,8 @@ def __init__(self, master, studio): 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 ()),), + # Allow widget specific menu only when a single widget is selected + (LoadLater(lambda: self.studio.selection[0].create_menu() if self.studio.selection.is_single() else ()),), self.studio, self.style ) @@ -170,6 +166,32 @@ def __init__(self, master, studio): self._text_editor.on_change(self._text_change) self._text_editor.bind("", self._text_hide) self._base_font = FontStyle() + self._selected = set() + self._studio_bindings = [] + self._move_selection = [] + self._all_bound = None + self._bound_highlight = WidgetHighlighter(self, self.style) + self._container_highlight = WidgetHighlighter(self, self.style) + + # These variables help in skipping of several rendering frames to reduce lag when dragging items + self._skip_var = 0 + # The maximum rendering to skip (currently 80%) for every one successful render. Ensure its + # not too big otherwise we won't be moving and resizing items at all and not too small otherwise the lag would + # be unbearable + self._skip_max = 4 + self._surge_delta = (0, 0) + self._update_throttling() + self.studio.pref.add_listener( + "designer::frame_skip", + self._update_throttling + ) + + def clear_studio_bindings(self): + for binding in self._studio_bindings: + self.studio.unbind(binding) + + def add_studio_binding(self, *args): + self._studio_bindings.append(self.studio.bind(*args)) def _get_designer(self): return self @@ -182,7 +204,7 @@ def set_pos(self, event): 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")) + self._skip_max = self.studio.pref.get("designer::frame_skip") def _show_empty(self, flag, **kw): if flag: @@ -204,12 +226,12 @@ def _set_shortcuts(self): 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), - ) + # 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() @@ -284,7 +306,7 @@ def open_file(self, path=None): def clear(self): # Warning: this method deletes elements irreversibly # remove the current root objects and their descendants - self.studio.select(None) + self.studio.selection.clear() # create a copy since self.objects will mostly change during iteration # remove root and dangling objects for widget in self.objects: @@ -359,36 +381,6 @@ def as_node(self, widget): 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 - obj_class = BaseStudioAdapter._get_class(node) - if obj_class.is_toplevel and paste_to != self: - self._show_toplevel_warning() - return - if obj_class.group != Groups.container and self.root_obj is None: - self._show_root_widget_warning() - return - - layout = paste_to if isinstance(paste_to, Container) else paste_to.layout - width = int(node["layout"].get("width", 10)) - height = int(node["layout"].get("height", 10)) - 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 @@ -396,24 +388,140 @@ def _get_unique(self, obj_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 _on_handle_active(self, widget, direction): + if direction == "all": + self._move_selection = self.studio.selection.siblings(widget) + self._all_bound = geometry.overall_bounds([w.get_bounds() for w in self._move_selection]) + for obj in self._move_selection: + obj.layout.change_start(obj) + else: + for obj in self._selected: + if not obj.layout.allow_resize: + continue + obj.layout.change_start(obj) + + def _on_handle_inactive(self, widget, direction): + layouts_changed = [] + if direction == "all": + if not self.current_container: + return + self._bound_highlight.clear() + self._container_highlight.clear() + container = self.current_container + self.current_container = None + container.clear_hover() + objs = self.studio.selection.siblings(widget) + toplevel_warning = False + for obj in objs: + if obj.is_toplevel and container != self: + toplevel_warning = True + continue + container.add_widget(obj, obj.get_bounds()) + layouts_changed.append(obj) + if obj == self.root_obj and container != self: + self.root_obj = container + + if toplevel_warning: + self._show_toplevel_warning() + else: + for obj in self._selected: + if not obj.layout.allow_resize: + continue + obj.layout.widget_released(obj) + layouts_changed.append(obj) + + self.create_restore(layouts_changed) + self.studio.widget_layout_changed(layouts_changed) + self._skip_var = 0 + + def _on_handle_resize(self, widget, direction, delta): + if self._skip_var < self._skip_max: + self._skip_var += 1 + self._surge_delta = (self._surge_delta[0] + delta[0], self._surge_delta[1] + delta[1]) + return + self._skip_var = 0 + delta = (self._surge_delta[0] + delta[0], self._surge_delta[1] + delta[1]) + self._surge_delta = (0, 0) + + if direction != "all": + # resize + for obj in self._selected: + if obj.layout.allow_resize: + obj.layout.resize_widget(obj, direction, delta) + else: + # move + self._on_handle_move(widget, delta) + + # TODO handle realtime layout changes + # if obj.layout.layout_strategy.realtime_support: + # self.studio.widget_layout_changed(obj) + + def _on_handle_move(self, _, delta): + dx, dy = delta + objs = self._move_selection + all_bound = self._all_bound + dx = 0 if all_bound[0] + dx < 0 else dx + dy = 0 if all_bound[1] + dy < 0 else dy + + if dx == dy == 0: + return + + all_bound = (all_bound[0] + dx, all_bound[1] + dy, all_bound[2] + dx, all_bound[3] + dy) + current = self.current_container + container = self.layout_at(all_bound) + self._all_bound = all_bound + self._container_highlight.highlight(container) + # self._bound_highlight.highlight_bounds(all_bound) + + if container != current and current is not None: + current.end_move() + current.clear_hover() + + if container is not None and container != current: + container.show_hover() + self.current_container = current = container + + for w in objs: + x1, y1, x2, y2 = w.get_bounds() + current.move_widget(w, [x1 + dx, y1 + dy, x2 + dx, y2 + dy]) + + def set_active_container(self, container): + if self.current_container is not None: + self.current_container.clear_highlight() + self.current_container = container + + def layout_at(self, bounds): + for container in sorted(filter(lambda x: isinstance(x, Container) and x not in self._selected, 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 geometry.is_within(geometry.bounds(container), bounds): + return container + if self.current_container and geometry.compute_overlap(geometry.bounds(self.current_container), bounds): + return self.current_container + return self + 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.on_handle_resize(self._on_handle_resize) + obj.on_handle_active(self._on_handle_active) + obj.on_handle_inactive(self._on_handle_inactive) + MenuUtils.bind_all_context(obj, lambda e: self.show_menu(e, obj), add='+') - obj.bind_all('', lambda e: self.highlight.set_function(self.highlight.move, e), add='+') obj.bind_all('', self.on_motion, '+') - obj.bind_all('', self.highlight.clear_resize, '+') obj.bind_all('', self._stop_displace, '+') + if "text" in obj.keys(): obj.bind_all("", lambda _: self._show_text_editor(obj)) + self.objects.append(obj) if self.root_obj is None: self.root_obj = obj @@ -422,15 +530,17 @@ def _attach(self, obj): 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) + if not self._selected: + self.studio.selection.set(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) + if event.state & EventMask.CONTROL: + self.studio.selection.toggle(obj) + else: + self.studio.selection.set(obj) def load(self, obj_class, name, container, attributes, layout, bounds=None): obj = obj_class(self, name) @@ -501,13 +611,55 @@ def add(self, obj_class: PseudoWidget.__class__, x, y, **kwargs): return obj + def paste(self, clipboard, silently=False, paste_to=None): + if paste_to is None and len(self.studio.selection) != 1: + return + + if paste_to is None: + paste_to = self.studio.selection[0] + + if paste_to is None: + return + + x, y = self._last_click_pos or (self.winfo_rootx() + 50, self.winfo_rooty() + 50) + # slightly displace click position so multiple pastes are still visible + self._last_click_pos = x + 5, y + 5 + + objs = [] + restore_points = [] + + for node, bound in clipboard: + obj_class = BaseStudioAdapter._get_class(node) + if obj_class.is_toplevel and paste_to != self: + self._show_toplevel_warning() + return + if obj_class.group != Groups.container and self.root_obj is None: + self._show_root_widget_warning() + return + + layout = paste_to if isinstance(paste_to, Container) else paste_to.layout + bound = geometry.resolve_bounds(geometry.displace(bound, x, y), self) + obj = self.builder.load_section(node, layout, bound) + objs.append(obj) + restore_points.append(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(objs, True), + lambda _: self.restore(objs, restore_points, [w.layout for w in objs]) + )) + return objs + 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 restore(self, widgets, restore_points, containers): + for container, widget, restore_point in zip(containers, widgets, restore_points): + container.restore_widget(widget, restore_point) + self._replace_all(widget) + self.studio.on_restore(widgets) def _replace_all(self, widget): # Recursively add widget and all its children to objects @@ -518,26 +670,29 @@ def _replace_all(self, widget): for child in widget._children: self._replace_all(child) - def delete(self, widget, silently=False): - if not widget: + def delete(self, widgets, silently=False): + if not widgets: return if not silently: - restore_point = widget.layout.get_restore(widget) + restore_points = [widget.layout.get_restore(widget) for widget in widgets] + layouts = [widget.layout for widget in widgets] self.studio.new_action(Action( - lambda _: self.restore(widget, restore_point, widget.layout), - lambda _: self.studio.delete(widget, True) + lambda _: self.restore(widgets, restore_points, layouts), + lambda _: self.studio.delete(widgets, 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) + self.studio.delete(widgets, self) + + for widget in widgets: + 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) @@ -549,23 +704,6 @@ def _uproot_widget(self, widget): 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], @@ -577,24 +715,25 @@ def parse_bounds(self, bounds): 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() + def _select(self, _): + if self.studio.designer != self: return - self.focus_set() - if self.current_obj == obj: + current_selection = set(self.studio.selection.widgets) + if current_selection == self._selected: 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) + for w in self._selected - current_selection: + w.clear_highlight() + + for w in current_selection - self._selected: + w.show_highlight() + + self._selected = current_selection + self.focus_set() + + @property + def selected(self): + return self._selected def _stop_displace(self, _): if self._displace_active: @@ -627,119 +766,53 @@ def displace(self, side): 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: + def create_restore(self, widgets): + if not widgets: return - if container is not None and container != obj: - container.clear_highlight() - if self.current_action == self.MOVE: - if container != self and obj.is_toplevel: - self._show_toplevel_warning() - return - 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: + + prev_restore_points = [widget.recent_layout_info for widget in widgets] + cur_restore_points = [widget.layout.get_restore(widget) for widget in widgets] + + if prev_restore_points == cur_restore_points: return - prev_container = prev_restore_point["container"] - container = widget.layout + + prev_containers = [i["container"] for i in prev_restore_points] + containers = [widget.layout for widget in widgets] def undo(_): - container.remove_widget(widget) - prev_container.restore_widget(widget, prev_restore_point) + for widget, prev_restore_point, container, prev_container in zip( + widgets, prev_restore_points, containers, prev_containers): + 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) + for widget, cur_restore_point, container, prev_container in zip( + widgets, cur_restore_points, containers, prev_containers): + 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 = 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: - if current is not None: - current.clear_highlight() - container.show_highlight() - self.current_container = container - container.move_widget(obj, new_bound) - else: - if current is not None: - current.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 self.current_obj.max_size or self.current_obj.min_size: - b = geometry.constrain_bounds( - new_bound, - self.current_obj.max_size, - self.current_obj.min_size - ) - new_bound = b - - 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) + self.studio.style_pane.apply_style("text", self._text_editor.get_all(), list(self.selected)[0]) def _show_text_editor(self, widget): - assert widget == self.current_obj + assert widget in self.selected 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() + # suppress change event while we set initial value + self._text_editor.on_change(lambda *_: None) self._text_editor.insert("1.0", widget["text"]) + self._text_editor.on_change(self._text_change) def _collect_text_config(self, widget): s = ttk.Style() @@ -760,9 +833,6 @@ def _collect_text_config(self, widget): 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 @@ -827,6 +897,10 @@ def on_context_set(self): self.designer.open_new() self._loaded = True self.studio.set_path(self.path) + self.designer.add_studio_binding("<>", self.designer._select, "+") + + def on_context_unset(self): + self.designer.clear_studio_bindings() def on_load_complete(self): # the design load thread is done @@ -879,9 +953,6 @@ def redo(self): def can_persist(self): return self.path is not None - def on_context_unset(self): - pass - def on_app_close(self): return self.designer.on_app_close() diff --git a/studio/lib/layouts.py b/studio/lib/layouts.py index 63b47e8..c63fe17 100644 --- a/studio/lib/layouts.py +++ b/studio/lib/layouts.py @@ -56,6 +56,7 @@ class BaseLayoutStrategy: manager = "place" # Default layout manager in use realtime_support = False # dictates whether strategy supports realtime updates to its values, most do not dimensions_in_px = False # Whether to use pixel units for width and height + allow_resize = False # Whether to allow resizing of widgets def __init__(self, container): self.parent = container.parent @@ -90,9 +91,11 @@ def widget_released(self, widget): def change_start(self, widget): widget.recent_layout_info = self.get_restore(widget) + widget.last_stable_bounds = widget.get_bounds() def move_widget(self, widget, bounds): if widget in self.children: + bounds = widget.last_stable_bounds self.remove_widget(widget) if widget not in self.temporal_children: self.temporal_children.append(widget) @@ -105,13 +108,17 @@ def move_widget(self, widget, bounds): pass self._move(widget, bounds) + def end_move(self): + # empty temporal children + self.temporal_children.clear() + def _move(self, widget, bounds): # Make the bounds relative to the layout for proper positioning bounds = geometry.relative_bounds(bounds, self.container.body) self.container.position(widget, bounds) - def resize_widget(self, widget, bounds): - self._move(widget, bounds) + def resize_widget(self, widget, direction, delta): + raise NotImplementedError("Layout should provide resize method") def restore_widget(self, widget, data=None): raise NotImplementedError("Layout should provide restoration method") @@ -230,6 +237,7 @@ class PlaceLayoutStrategy(BaseLayoutStrategy): manager = "place" realtime_support = True dimensions_in_px = True + allow_resize = True def clear_all(self): for child in self.children: @@ -244,6 +252,32 @@ def add_widget(self, widget, bounds=None, **kwargs): widget.place_configure(**kwargs) self.children.append(widget) + def _info_with_delta(self, widget, direction, delta): + info = self.info(widget) + info.update(x=int(info["x"]), y=int(info["y"]), width=int(info["width"]), height=int(info["height"])) + dx, dy = delta + if direction == "n": + info.update(y=info["y"] + dy, height=info["height"] - dy) + elif direction == "s": + info["height"] = info["height"] + dy + elif direction == "e": + info["width"] = info["width"] + dx + elif direction == "w": + info.update(x=info["x"] + dx, width=info["width"] - dx) + elif direction == "nw": + info.update(x=info["x"] + dx, y=info["y"] + dy, width=info["width"] - dx, height=info["height"] - dy) + elif direction == "ne": + info.update(y=info["y"] + dy, width=info["width"] + dx, height=info["height"] - dy) + elif direction == "sw": + info.update(x=info["x"] + dx, width=info["width"] - dx, height=info["height"] + dy) + elif direction == "se": + info.update(width=info["width"] + dx, height=info["height"] + dy) + return info + + def resize_widget(self, widget, direction, delta): + info = self._info_with_delta(widget, direction, delta) + widget.place_configure(**info) + def remove_widget(self, widget): super().remove_widget(widget) widget.place_forget() @@ -345,10 +379,8 @@ def _redraw(self, start_index=0): for child in affected: child.pack(**cache.get(child, {})) - def resize_widget(self, widget, bounds): - if not self.temp_info: - self.temp_info = self._pack_info(widget) - self._move(widget, bounds) + def resize_widget(self, widget, direction, delta): + pass def _pack_info(self, widget): try: @@ -491,8 +523,7 @@ def redraw(self, widget): self.attach(child, *dimensions[child]) self._children.append(child) - def resize_widget(self, widget, bounds): - super().resize_widget(widget, bounds) + def resize_widget(self, widget, direction, delta): widget.update_idletasks() self.redraw(widget) @@ -688,15 +719,17 @@ def widget_released(self, widget): self._temp = None self.clear_indicators() - def resize_widget(self, widget, bounds): - if not self._temp: - self._temp = self._grid_info(widget) - self._move(widget, bounds) + def resize_widget(self, widget, direction, delta): + pass def _move(self, widget, bounds): super()._move(widget, bounds) self._location_analysis(bounds) + def end_move(self): + super().end_move() + self.clear_indicators() + def add_widget(self, widget, bounds=None, **kwargs): super().remove_widget(widget) super().add_widget(widget, bounds, **kwargs) @@ -715,6 +748,9 @@ def _widget_at(self, row, column): return self.container.body.grid_slaves(column, row) def _location_analysis(self, bounds): + if len(self.temporal_children) > 1: + # cannot perform analysis with more than one widget + return self.clear_indicators() self._edge_indicator.update_idletasks() bounds = geometry.relative_bounds(bounds, self.container.body) @@ -891,7 +927,7 @@ def _redraw(self, start_index=0): self.container.body.add(child) self.container.body.tab(child, **cache.get(child, {})) - def resize_widget(self, widget, bounds): + def resize_widget(self, widget, direction, delta): pass def add_widget(self, widget, bounds=None, **kwargs): @@ -1001,7 +1037,7 @@ def _redraw(self, start_index=0): def clear_all(self): pass - def resize_widget(self, widget, bounds): + def resize_widget(self, widget, direction, delta): pass def add_widget(self, widget, bounds=None, **kwargs): diff --git a/studio/lib/pseudo.py b/studio/lib/pseudo.py index 28b812e..7c9b01b 100644 --- a/studio/lib/pseudo.py +++ b/studio/lib/pseudo.py @@ -12,7 +12,7 @@ from studio.lib import layouts from studio.lib.variables import VariableManager from studio.lib.properties import get_properties -from studio.ui.highlight import WidgetHighlighter +from studio.ui.highlight import BoxHandle from studio.ui.tree import MalleableTree @@ -112,9 +112,11 @@ class PseudoWidget: } def setup_widget(self): + self.designer = self.master self.level = 0 self.layout = None self.recent_layout_info = None + self.last_stable_bounds = None self._properties = get_properties(self) self.set_name(self.id) self.node = None @@ -123,10 +125,68 @@ def setup_widget(self): self.max_size = None self.min_size = None MenuUtils.bind_context(self, self.__handle_context_menu, add='+') + self._handle = None + + self._on_handle_active = getattr(self, "_on_handle_active", None) + self._on_handle_inactive = getattr(self, "_on_handle_inactive", None) + self._on_handle_resize = getattr(self, "_on_handle_resize", None) + self._on_handle_move = getattr(self, "_on_handle_move", None) def set_name(self, name): pass + def get_bounds(self): + self.update_idletasks() + x1 = self.winfo_x() + y1 = self.winfo_y() + x2 = self.winfo_width() + x1 + y2 = self.winfo_height() + y1 + return x1, y1, x2, y2 + + def show_highlight(self, *_): + if not self._handle: + self._handle = BoxHandle.acquire(self, self.master) + self._handle.show() + + def clear_highlight(self): + if not self._handle: + return + self._handle.hide() + self._handle = None + + def show_hover(self, *_): + pass + + def clear_hover(self): + pass + + def on_handle_active(self, callback): + self._on_handle_active = callback + + def on_handle_inactive(self, callback): + self._on_handle_inactive = callback + + def on_handle_resize(self, callback): + self._on_handle_resize = callback + + def on_handle_move(self, callback): + self._on_handle_move = callback + + def handle_active(self, direction): + if self._on_handle_active: + self._on_handle_active(self, direction) + + def handle_inactive(self, direction): + if self._on_handle_inactive: + self._on_handle_inactive(self, direction) + + def handle_resize(self, direction, delta): + if self._on_handle_resize: + self._on_handle_resize(self, direction, delta) + + def handle_move(self): + pass + def get_image_path(self): if hasattr(self, "image_path"): return self.image_path @@ -245,6 +305,9 @@ def get_resolved_methods(self): def handle_method(self, name, *args, **kwargs): pass + def __repr__(self): + return f"{self.__class__.__name__}<{self.id}>" + class Container(PseudoWidget): LAYOUTS = layouts.layouts @@ -256,7 +319,6 @@ def setup_widget(self): self._level = 0 self._children = [] self.temporal_children = [] - self._highlighter = WidgetHighlighter(self.master) if len(self.LAYOUTS) == 0: raise ValueError("No layouts have been defined") self.layout_strategy = layouts.PlaceLayoutStrategy(self) @@ -267,14 +329,6 @@ def setup_widget(self): def _get_designer(self): return self.master - def show_highlight(self, *_): - self._highlighter.highlight(self) - - def clear_highlight(self): - self._highlighter.clear() - self._temporal_children = [] - self.layout_strategy.clear_indicators() - def lift(self, above_this): super().lift(above_this) if self.body != self: @@ -287,6 +341,19 @@ def react(self, x, y): self.react_to_pos(x, y) self.show_highlight() + def clear_highlight(self): + super().clear_highlight() + self._temporal_children = [] + self.layout_strategy.clear_indicators() + + @property + def allow_resize(self): + return self.layout_strategy.allow_resize + + @property + def realtime_support(self): + return self.layout_strategy.realtime_support + @property def level(self): return self._level @@ -406,8 +473,11 @@ def change_start(self, widget): def move_widget(self, widget, bounds): self.layout_strategy.move_widget(widget, bounds) - def resize_widget(self, widget, bounds): - self.layout_strategy.resize_widget(widget, bounds) + def end_move(self): + self.layout_strategy.end_move() + + def resize_widget(self, widget, direction, delta): + self.layout_strategy.resize_widget(widget, direction, delta) def get_restore(self, widget): return self.layout_strategy.get_restore(widget) diff --git a/studio/main.py b/studio/main.py index 2b92ab9..4beb332 100644 --- a/studio/main.py +++ b/studio/main.py @@ -12,34 +12,34 @@ import tkinterDnD -from studio.feature.design import DesignContext, MultiSaveDialog -from studio.feature import FEATURES, StylePane -from studio.feature._base import BaseFeature, FeaturePane -from studio.tools import ToolManager -from studio.ui.widgets import SideBar -from studio.ui.about import about_window -from studio.preferences import Preferences, open_preferences -from studio.resource_loader import ResourceLoader -from studio.updates import Updater -from studio.context import BaseContext import studio - +from formation import AppBuilder +from formation.formats import get_file_types, get_file_extensions +from hoverset.data import actions +from hoverset.data.images import load_tk_image +from hoverset.data.keymap import ShortcutManager, CharKey, KeyMap, BlankKey +from hoverset.data.utils import get_resource_path +from hoverset.platform import platform_is, MAC +from hoverset.ui.dialogs import MessageDialog +from hoverset.ui.icons import get_icon_image +from hoverset.ui.menu import MenuUtils, EnableIf, dynamic_menu, LoadLater from hoverset.ui.widgets import ( Application, Frame, PanedWindow, Button, ActionNotifier, TabView, Label ) -from hoverset.ui.icons import get_icon_image -from hoverset.data.images import load_tk_image from hoverset.util.execution import Action -from hoverset.data.utils import get_resource_path -from hoverset.ui.dialogs import MessageDialog -from hoverset.ui.menu import MenuUtils, EnableIf, dynamic_menu, LoadLater -from hoverset.data import actions -from hoverset.data.keymap import ShortcutManager, CharKey, KeyMap, BlankKey -from hoverset.platform import platform_is, MAC - -from formation import AppBuilder -from formation.formats import get_file_types, get_file_extensions +from studio.context import BaseContext +from studio.feature import FEATURES, StylePane +from studio.feature._base import BaseFeature, FeaturePane +from studio.feature.design import DesignContext, MultiSaveDialog +from studio.preferences import Preferences, open_preferences +from studio.resource_loader import ResourceLoader +from studio.selection import Selection +from studio.tools import ToolManager +from studio.ui import geometry +from studio.ui.about import about_window +from studio.ui.widgets import SideBar +from studio.updates import Updater pref = Preferences.acquire() @@ -99,10 +99,10 @@ def __init__(self, master=None, **cnf): icon = get_icon_image self.actions = ( - ("Delete", icon("delete", 20, 20), lambda e: self.delete(), "Delete selected widget"), + ("Delete", icon("delete", 20, 20), lambda e: self.delete(), "Delete selected widgets"), ("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"), + ("Cut", icon("cut", 20, 20), lambda e: self.cut(), "Cut selected widgets"), ("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(), @@ -116,7 +116,7 @@ def __init__(self, master=None, **cnf): ) self.init_toolbar() - self.selected = None + self._selection = Selection(self) # set the image option to blank if there is no image for the menu option self.blank_img = blank_img = icon("blank", 14, 14) @@ -124,12 +124,12 @@ def __init__(self, master=None, **cnf): # -------------------------------------------- menu definition ------------------------------------------------ self.menu_template = (EnableIf( - lambda: self.selected, + lambda: bool(self.selection), ("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, + lambda: self._clipboard is not None and len(self.selection) < 2, ("command", "paste", icon("clipboard", 14, 14), actions.get('STUDIO_PASTE'), {}) ), ("command", "cut", icon("cut", 14, 14), actions.get('STUDIO_CUT'), {}), @@ -257,7 +257,7 @@ def __init__(self, master=None, **cnf): self.style_pane = self.get_feature(StylePane) # initialize tools with everything ready - self.tool_manager.initialize() + # self.tool_manager.initialize() self._ignore_tab_status = False self._startup() @@ -267,6 +267,10 @@ def __init__(self, master=None, **cnf): self._left.restore_size() self._right.restore_size() + @property + def selection(self): + return self._selection + def on_context_switch(self, _): selected = self.tab_view.selected if isinstance(self.context, BaseContext): @@ -287,9 +291,9 @@ def on_context_switch(self, _): # switch selection to that of the new context if self.designer: - self.select(self.designer.current_obj, self.designer) + self.selection.set(self.designer.selected) else: - self.select(None) + self.selection.clear() self.save_tab_status() def on_context_close(self, context): @@ -365,7 +369,7 @@ def _get_window_state(self): if self.wm_attributes("-zoomed"): return 'zoomed' return 'normal' - except: + except tkinter.TclError: # works for windows and mac os return self.state() @@ -373,7 +377,7 @@ def _set_window_state(self, state): try: # works in windows and mac os self.state(state) - except: + except tkinter.TclError: self.wm_attributes('-zoomed', state == 'zoomed') def _save_position(self): @@ -417,11 +421,6 @@ 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') @@ -631,16 +630,6 @@ def maximize(self, feature): 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) @@ -654,45 +643,67 @@ def widget_modified(self, widget1, source=None, widget2=None): self.designer.on_widget_change(widget1, widget2) self.tool_manager.on_widget_change(widget1, widget2) - def widget_layout_changed(self, widget): + def widget_layout_changed(self, widgets): for feature in self.features: - feature.on_widget_layout_change(widget) - self.tool_manager.on_widget_layout_change(widget) + feature.on_widget_layout_change(widgets) + self.tool_manager.on_widget_layout_change(widgets) + + def make_clipboard(self, widgets): + bounds = geometry.overall_bounds([w.get_bounds() for w in widgets]) + data = [] + for widget in widgets: + data.append(( + self.designer.as_node(widget), + geometry.relative_to_bounds(widget.get_bounds(), bounds), + )) + return data - def delete(self, widget=None, source=None): - widget = self.selected if widget is None else widget - if widget is None: + def copy(self): + if self.designer and self.selection: + # store the current objects as nodes in the clipboard + self._clipboard = self.make_clipboard(self.selection.compact()) + pass + + def delete(self, widgets=None, source=None): + widgets = list(self.selection.compact()) if widgets is None else widgets + if not widgets: return - if self.selected == widget: - self.select(None) + + if any(widget in self.selection for widget in widgets): + self.selection.clear() + if self.designer and source != self.designer: - self.designer.delete(widget) + self.designer.delete(widgets) + for feature in self.features: - feature.on_widget_delete(widget) - self.tool_manager.on_widget_delete(widget) + feature.on_widget_delete(widgets) + self.tool_manager.on_widget_delete(widgets) - def cut(self, widget=None, source=None): + def cut(self, widgets=None, source=None): if not self.designer: return - widget = self.selected if widget is None else widget - if not widget: + + widgets = list(self.selection.compact()) if widgets is None else widgets + if not widgets: return - if self.selected == widget: - self.select(None) - self._clipboard = self.designer.as_node(widget) + + if any(widget in self.selection for widget in widgets): + self.selection.clear() + + self._clipboard = self.make_clipboard(widgets) if source != self.designer: - self.designer.delete(widget, True) + self.designer.delete(widgets) for feature in self.features: - feature.on_widget_delete(widget, True) - self.tool_manager.on_widget_delete(widget) + feature.on_widget_delete(widgets, True) + self.tool_manager.on_widget_delete(widgets) def duplicate(self): - if self.designer and self.selected: - self.designer.paste(self.designer.as_node(self.selected)) + if self.designer and self.selection: + self.designer.paste(self.make_clipboard(self.selection.compact())) - def on_restore(self, widget): + def on_restore(self, widgets): for feature in self.features: - feature.on_widget_restore(widget) + feature.on_widget_restore(widgets) def on_feature_change(self, new, old): self.features.insert(self.features.index(old), new) @@ -859,11 +870,11 @@ def _register_actions(self): 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')), + routine(self.cut, 'STUDIO_CUT', 'Cut selected widgets', 'studio', CTRL + CharKey('x')), + routine(self.copy, 'STUDIO_COPY', 'Copy selected widgets', 'studio', CTRL + CharKey('c')), + routine(self.paste, 'STUDIO_PASTE', 'Paste selected widgets', 'studio', CTRL + CharKey('v')), + routine(self.delete, 'STUDIO_DELETE', 'Delete selected widgets', 'studio', KeyMap.DELETE), + routine(self.duplicate, 'STUDIO_DUPLICATE', 'Duplicate selected widgets', 'studio', CTRL + CharKey('d')), ) self.shortcuts.add_routines( routine(self.undo, 'STUDIO_UNDO', 'Undo last action', 'studio', CTRL + CharKey('Z')), diff --git a/studio/selection.py b/studio/selection.py new file mode 100644 index 0000000..2e33055 --- /dev/null +++ b/studio/selection.py @@ -0,0 +1,118 @@ +# ======================================================================= # +# Copyright (C) 2023 Hoverset Group. # +# ======================================================================= # + +from __future__ import annotations +from studio.lib.pseudo import PseudoWidget + + +class Selection: + + def __init__(self, widget): + self.widget = widget + self.widgets: list[PseudoWidget] = [] + self._reduced = [] + + def set(self, widgets: list[PseudoWidget] | PseudoWidget): + if isinstance(widgets, PseudoWidget): + widgets = [widgets] + if self.widgets == widgets: + return + self.widgets = list(widgets) + self._on_change() + + def add(self, widget): + if widget in self.widgets: + return + self.widgets.append(widget) + self._on_change() + + def remove(self, widget): + if widget not in self.widgets: + return + self.widgets.remove(widget) + self._on_change() + + def clear(self): + if not self.widgets: + return + self.widgets.clear() + self._on_change() + + def toggle(self, widget): + if widget in self.widgets: + self.remove(widget) + else: + self.add(widget) + + def _on_change(self): + self.widget.event_generate("<>") + self._reduce_hierarchy() + + def _reduce_hierarchy(self): + self._reduced = [] + selected = set(self.widgets) + + for widget in self.widgets: + current = widget.layout + + while current: + if current in selected: + break + current = current.layout + else: + self._reduced.append(widget) + return self._reduced + + def compact(self): + return self._reduced + + def is_single(self) -> bool: + return len(self.widgets) == 1 + + def is_same_type(self) -> bool: + if not self.widgets: + return False + return all(isinstance(w, type(self.widgets[0])) for w in self.widgets) + + def is_same_parent(self) -> bool: + if not self.widgets: + return False + return all(w.layout == self.widgets[0].layout for w in self.widgets) + + def siblings(self, widget): + if not self.widgets: + return [] + return [w for w in self.widgets if w.layout == widget.layout] + + def __iter__(self): + return iter(self.widgets) + + def __len__(self): + return len(self.widgets) + + def __getitem__(self, index): + return self.widgets[index] + + def __contains__(self, item): + return item in self.widgets + + def __str__(self): + return str(self.widgets) + + def __repr__(self): + return repr(self.widgets) + + def __bool__(self): + return bool(self.widgets) + + def __eq__(self, other): + if isinstance(other, Selection): + return self.widgets == other.widgets + return self.widgets == other + + def __ne__(self, other): + if isinstance(other, Selection): + return self.widgets != other.widgets + return self.widgets != other + \ No newline at end of file diff --git a/studio/ui/geometry.py b/studio/ui/geometry.py index b5fc5e9..822a4e7 100644 --- a/studio/ui/geometry.py +++ b/studio/ui/geometry.py @@ -86,6 +86,17 @@ def relative_bounds(bd, widget): return bd[0] - ref[0], bd[1] - ref[1], bd[2] - ref[0], bd[3] - ref[1] +def relative_to_bounds(bound1, bound2): + """ + Convert bounds ``bound1`` to be relative to ``bound2`` + + :param bound1: bounds to be converted + :param bound2: bounds to which ``bound1`` is to be relative to + :return: relative bound tuple + """ + return bound1[0] - bound2[0], bound1[1] - bound2[1], bound1[2] - bound2[0], bound1[3] - bound2[1] + + def resolve_position(position, widget): """ Convert an absolute position such that it is relative to a ``widget`` @@ -138,6 +149,18 @@ def center(bound): return (bound[2] - bound[0]) // 2, (bound[3] - bound[1]) // 2 +def displace(bound, dx, dy): + """ + Displace a bound by ``dx`` and ``dy`` + + :param bound: a bound tuple + :param dx: displacement along x-axis + :param dy: displacement along y-axis + :return: displaced bound tuple + """ + return bound[0] + dx, bound[1] + dy, bound[2] + dx, bound[3] + dy + + def is_within(bound1, bound2) -> bool: """ Checks whether bound2 is within bound1 i.e bound1 completely encloses bound2 @@ -147,9 +170,7 @@ def is_within(bound1, bound2) -> bool: :return: ``True`` if ``bound1`` encloses ``bound2`` else ``False`` """ overlap = compute_overlap(bound1, bound2) - if overlap == bound2: - return True - return False + return overlap == bound2 def dimensions(bound): @@ -207,6 +228,23 @@ def constrain_bounds(bound, maxsize, minsize): return x1, y1, x1 + max(min(max_w, x2 - x1), min_w), y1 + max(min(max_h, y2 - y1), min_h) +def overall_bounds(bound_list): + """ + Get the bounds of a set of bounds + + :param bound_list: A list of bounds + :return: A bound tuple containing the bounds of all the bounds + in the list + """ + x1, y1, x2, y2 = float('inf'), float('inf'), -float('inf'), -float('inf') + for bound in bound_list: + x1 = min(x1, bound[0]) + y1 = min(y1, bound[1]) + x2 = max(x2, bound[2]) + y2 = max(y2, bound[3]) + return x1, y1, x2, y2 + + def parse_geometry(geometry, default=None): """ Parse a tk geometry string and return a dict with the width, height, diff --git a/studio/ui/highlight.py b/studio/ui/highlight.py index 1eb5b3b..4dc42e2 100644 --- a/studio/ui/highlight.py +++ b/studio/ui/highlight.py @@ -1,6 +1,8 @@ import tkinter as tk +from collections import defaultdict from hoverset.platform import platform_is, WINDOWS, LINUX +from hoverset.ui.widgets import EventMask from studio.ui import geometry from studio.ui.widgets import DesignPad @@ -16,10 +18,152 @@ def resize_cursor() -> tuple: if platform_is(LINUX): return "bottom_right_corner", "bottom_left_corner" # Use circles for other platforms - return ("circle",)*2 + return ("circle",) * 2 + + +class Dot(tk.Frame): + _corner_cursors = resize_cursor() + _cursor_map = dict( + n="sb_v_double_arrow", + s="sb_v_double_arrow", + e="sb_h_double_arrow", + w="sb_h_double_arrow", + c="fleur", + nw=_corner_cursors[0], + ne=_corner_cursors[1], + sw=_corner_cursors[1], + se=_corner_cursors[0], + all="fleur" + ) + + def __init__(self, handle, direction): + super().__init__(handle.master) + self.direction = direction + color = handle.master.style.colors["accent"] + self.config(width=6, height=6, bg=color, cursor=self._cursor_map[direction]) + self.handle = handle + self.bind("", self.on_press) + self.bind("", self.on_release) + self.bind("", self.on_move) + self.fix = (0, 0) + + def on_move(self, event): + if not event.state & EventMask.MOUSE_BUTTON_1: + return + x, y = self.fix + self.fix = (event.x_root, event.y_root) + self.handle.on_dot_move(self.direction, (event.x_root - x, event.y_root - y)) + + def on_press(self, event): + self.fix = (event.x_root, event.y_root) + self.handle.set_direction(self.direction) + + def on_release(self, _): + self.handle.set_direction(None) + + +class Handle: + + _pool = defaultdict(list) + + def __init__(self, widget, master=None): + self.widget = widget + self.master = widget if master is None else master + self.active_direction = None + self.dots = [] + self._hover = False + self._showing = False + + def set_direction(self, direction): + if direction is None: + self.widget.handle_inactive(self.active_direction) + self.active_direction = None + return + self.active_direction = direction + self.widget.handle_active(direction) + + def on_dot_move(self, direction, delta): + self.widget.handle_resize(direction, delta) + + def redraw(self): + raise NotImplementedError + + def show(self): + if self._showing: + return + self._showing = True + self.redraw() + + def hide(self): + if not self._showing: + return + for dot in self.dots: + dot.place_forget() + self._showing = False + if not self._hover: + self.release() + + def hover(self): + if self._hover: + return + self._hover = True + self.redraw() + + def unhover(self): + if not self._hover: + return + self._hover = False + self.redraw() + if not self._showing: + self.release() + + def release(self): + Handle._pool[(self.__class__, self.master)].append(self) + + @classmethod + def acquire(cls, widget, master=None): + master = widget if master is None else master + if not cls._pool[(cls, master)]: + obj = cls(widget, master) + else: + obj = cls._pool[(cls, master)].pop() + obj.widget = widget + return obj + + +class BoxHandle(Handle): + + def __init__(self, widget, master=None): + super().__init__(widget, master) + self.directions = ["n", "s", "e", "w", "nw", "ne", "sw", "se", "all"] + self.dots = [Dot(self, direction) for direction in self.directions] + + def redraw(self): + n, s, e, w, nw, ne, sw, se, c = self.dots + radius = 2 + # border-mode has to be outside so the highlight covers the entire widget + extra = dict(in_=self.widget, bordermode="outside") + nw.place(**extra, x=-radius, y=-radius) + ne.place(**extra, relx=1, x=-radius, y=-radius) + sw.place(**extra, x=-radius, rely=1, y=-radius) + se.place(**extra, relx=1, x=-radius, rely=1, y=-radius) + n.place(**extra, relx=0.5, x=-radius, y=-radius) + s.place(**extra, relx=0.5, x=-radius, rely=1, y=-radius) + e.place(**extra, relx=1, x=-radius, rely=0.5, y=-radius) + w.place(**extra, x=-radius, rely=0.5, y=-radius) + c.place(**extra, relx=0.5, rely=0.5, x=-radius, y=-radius) + + +class LinearHandle(Handle): + + def __init__(self, widget, master=None): + super().__init__(widget, master) + + def redraw(self): + pass -class HighLight: +class _HighLight: """ This class is responsible for the Highlight on selected objects on the designer. It allows resizing, moving and access to the currently selected widget. It also provides a way to attach listeners for any changes to the @@ -48,10 +192,10 @@ def __init__(self, parent: DesignPad): h_background = self.designer.style.colors["accent"] h_force_visible = dict(relief="groove", bd=1) - self.l = tk.Frame(parent, bg=h_background, width=self.OUTLINE, cursor="fleur", **h_force_visible) - self.r = tk.Frame(parent, bg=h_background, width=self.OUTLINE, cursor="fleur", **h_force_visible) - self.t = tk.Frame(parent, bg=h_background, height=self.OUTLINE, cursor="fleur", **h_force_visible) - self.b = tk.Frame(parent, bg=h_background, height=self.OUTLINE, cursor="fleur", **h_force_visible) + # self.l = tk.Frame(parent, bg=h_background, width=self.OUTLINE, cursor="fleur", **h_force_visible) + # self.r = tk.Frame(parent, bg=h_background, width=self.OUTLINE, cursor="fleur", **h_force_visible) + # self.t = tk.Frame(parent, bg=h_background, height=self.OUTLINE, cursor="fleur", **h_force_visible) + # self.b = tk.Frame(parent, bg=h_background, height=self.OUTLINE, cursor="fleur", **h_force_visible) _cursors = resize_cursor() self.nw = tk.Frame(parent, bg=h_background, width=self.SIZER_LENGTH, height=self.SIZER_LENGTH, @@ -85,7 +229,7 @@ def __init__(self, parent: DesignPad): self.w.bind("", lambda e: self.set_function(self.w_resize, e)) self._elements = [ - self.l, self.r, self.t, self.b, self.nw, self.ne, self.sw, self.se, self.n, self.s, self.e, self.w + self.nw, self.ne, self.sw, self.se, self.n, self.s, self.e, self.w ] # ============================================== bindings ===================================================== @@ -208,10 +352,10 @@ def redraw(self, widget, radius=None): # border-mode has to be outside so the highlight covers the entire widget extra = dict(in_=widget, bordermode="outside") - self.l.place(**extra, relheight=1) - self.r.place(**extra, relx=1, relheight=1) - self.t.place(**extra, relwidth=1) - self.b.place(**extra, rely=1, relwidth=1) + # self.l.place(**extra, relheight=1) + # self.r.place(**extra, relx=1, relheight=1) + # self.t.place(**extra, relwidth=1) + # self.b.place(**extra, rely=1, relwidth=1) self.nw.place(**extra, x=-radius, y=-radius) self.ne.place(**extra, relx=1, x=-radius, y=-radius) From f4e59cd36627bc079ef75aa8ba0984e6dfdd2b1d Mon Sep 17 00:00:00 2001 From: ObaraEmmanuel Date: Fri, 18 Aug 2023 23:48:50 +0300 Subject: [PATCH 02/15] Implement style pane for multi select --- hoverset/ui/widgets.py | 4 +- studio/feature/__init__.py | 2 +- studio/feature/_base.py | 31 +-- studio/feature/component_tree.py | 12 +- studio/feature/components.py | 1 + studio/feature/design.py | 75 ++--- studio/feature/stylepane.py | 270 +++++++++++------- studio/lib/handles.py | 235 ++++++++++++++++ studio/lib/layouts.py | 7 +- studio/lib/legacy.py | 1 - studio/lib/native.py | 2 + studio/lib/properties.py | 52 +++- studio/lib/pseudo.py | 65 ++++- studio/main.py | 36 ++- studio/ui/highlight.py | 463 +------------------------------ 15 files changed, 584 insertions(+), 672 deletions(-) create mode 100644 studio/lib/handles.py diff --git a/hoverset/ui/widgets.py b/hoverset/ui/widgets.py index 83f993c..5f0be78 100644 --- a/hoverset/ui/widgets.py +++ b/hoverset/ui/widgets.py @@ -2714,10 +2714,10 @@ def remove(self, node=None): self.collapse() self.nodes.remove(node) node.pack_forget() - if was_expanded: + if was_expanded and len(self.nodes) > 0: # If the parent was expanded when we began removal we expand it again self.expand() - if len(self.nodes) == 0: + if not self.nodes: # remove the expansion icon self._set_expander(self.BLANK) diff --git a/studio/feature/__init__.py b/studio/feature/__init__.py index ad67f96..ce64762 100644 --- a/studio/feature/__init__.py +++ b/studio/feature/__init__.py @@ -7,7 +7,7 @@ FEATURES = ( ComponentPane, ComponentTree, - #StylePane, + StylePane, #EventPane, #VariablePane, ) diff --git a/studio/feature/_base.py b/studio/feature/_base.py index 9f7892b..d3410f8 100644 --- a/studio/feature/_base.py +++ b/studio/feature/_base.py @@ -118,27 +118,18 @@ def set_pref(cls, short_path, value): def get_instance(cls): return cls._instance - def on_select(self, widget): + def on_widgets_change(self, widgets): """ - Called when a widget is selected in the designer - :param widget: selected widget - :return:None - """ - pass - - def on_widget_change(self, old_widget, new_widget=None): - """ - Called when a widget is fundamentally altered - :param old_widget: Altered widget - :param new_widget: The new widget taking the older widgets place + Called when the widgets in the designer are changed + :param widgets: list of widgets :return: None """ pass - def on_widget_layout_change(self, widget): + def on_widgets_layout_change(self, widgets): """ - Called when layout options of a widget are changed - :param widget: Widget with altered layout options + Called when layout options of a widgets are changed + :param widgets: Widgets with altered layout options :return: None """ pass @@ -152,10 +143,10 @@ def on_widget_add(self, widget, parent): """ pass - def on_widget_delete(self, widget, silently=False): + def on_widgets_delete(self, widgets, silently=False): """ - Called when a widget is deleted from the designer - :param widget: deleted widget + Called when widgets are deleted from the designer + :param widgets: deleted widgets :param silently: flag indicating whether the deletion should be treated implicitly which is useful for instance when you don't want the deletion to be logged in the undo stack @@ -163,10 +154,10 @@ def on_widget_delete(self, widget, silently=False): """ pass - def on_widget_restore(self, widget): + def on_widgets_restore(self, widgets): """ Called when a deleted widget is restored - :param widget: restored widget + :param widgets: widgets to be restored :return: None """ pass diff --git a/studio/feature/component_tree.py b/studio/feature/component_tree.py index cf37daa..ad3fa55 100644 --- a/studio/feature/component_tree.py +++ b/studio/feature/component_tree.py @@ -172,15 +172,15 @@ def selection(self): def studio_selection(self): return [i.node for i in self.studio.selection] - def on_widget_delete(self, widgets, silently=False): + def on_widgets_delete(self, widgets, silently=False): for widget in widgets: widget.node.remove() - def on_widget_restore(self, widgets): + def on_widgets_restore(self, widgets): for widget in widgets: widget.layout.node.add(widget.node) - def on_widget_layout_change(self, widgets): + def on_widgets_layout_change(self, widgets): for widget in widgets: node = widget.node if widget.layout == self.studio.designer: @@ -199,9 +199,9 @@ def on_context_close(self, context): 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_widgets_change(self, widgets): + for widget in widgets: + widget.node.widget_modified(widget) def on_search_query(self, query: str): self._tree.search(query) diff --git a/studio/feature/components.py b/studio/feature/components.py index bd6f5b3..0439f28 100644 --- a/studio/feature/components.py +++ b/studio/feature/components.py @@ -64,6 +64,7 @@ def on_drag_end(self, event): widget = self.event_first(event, self, Container) if isinstance(widget, Container): widget.add_new(self.component, *self.window.drag_window.get_center()) + widget.clear_highlight() class SelectableComponent(Component): diff --git a/studio/feature/design.py b/studio/feature/design.py index 8dc38cb..b3c6449 100644 --- a/studio/feature/design.py +++ b/studio/feature/design.py @@ -23,7 +23,6 @@ from studio.lib.pseudo import PseudoWidget, Container, Groups from studio.parsers.loader import DesignBuilder, BaseStudioAdapter from studio.ui import geometry -from studio.ui.highlight import WidgetHighlighter from studio.ui.widgets import DesignPad, CoordinateIndicator from studio.context import BaseContext from studio import __version__ @@ -45,7 +44,7 @@ def _move(self, widget, bounds): self.container.position(widget, self.container.canvas_bounds(bounds)) def add_widget(self, widget, bounds=None, **kwargs): - super(PlaceLayoutStrategy, self).add_widget(widget, bounds=None, **kwargs) + super(PlaceLayoutStrategy, self).add_widget(widget, bounds=bounds, **kwargs) super(PlaceLayoutStrategy, self).remove_widget(widget) if bounds is None: x = kwargs.get("x", 10) @@ -114,6 +113,7 @@ def __init__(self, master, studio): self.studio = studio self.name_generator = NameGenerator(self.studio.pref) self.setup_widget() + self.designer = self self.parent = self self.config(**self.style.bright, takefocus=True) self.objects = [] @@ -127,7 +127,6 @@ def __init__(self, master, studio): self._frame.bind("", lambda _: self.focus_set(), '+') self._frame.bind("", self.set_pos, '+') self._frame.bind('', self.on_motion, '+') - self._frame.bind('', self._stop_displace, '+') self._padding = 30 self.design_path = None self.builder = DesignBuilder(self) @@ -170,8 +169,6 @@ def __init__(self, master, studio): self._studio_bindings = [] self._move_selection = [] self._all_bound = None - self._bound_highlight = WidgetHighlighter(self, self.style) - self._container_highlight = WidgetHighlighter(self, self.style) # These variables help in skipping of several rendering frames to reduce lag when dragging items self._skip_var = 0 @@ -225,13 +222,6 @@ def _set_shortcuts(self): 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() @@ -411,11 +401,9 @@ def _on_handle_inactive(self, widget, direction): if direction == "all": if not self.current_container: return - self._bound_highlight.clear() - self._container_highlight.clear() container = self.current_container self.current_container = None - container.clear_hover() + container.clear_highlight() objs = self.studio.selection.siblings(widget) toplevel_warning = False for obj in objs: @@ -437,7 +425,7 @@ def _on_handle_inactive(self, widget, direction): layouts_changed.append(obj) self.create_restore(layouts_changed) - self.studio.widget_layout_changed(layouts_changed) + self.studio.widgets_layout_changed(layouts_changed) self._skip_var = 0 def _on_handle_resize(self, widget, direction, delta): @@ -460,7 +448,7 @@ def _on_handle_resize(self, widget, direction, delta): # TODO handle realtime layout changes # if obj.layout.layout_strategy.realtime_support: - # self.studio.widget_layout_changed(obj) + # self.studio.widgets_layout_changed(obj) def _on_handle_move(self, _, delta): dx, dy = delta @@ -476,15 +464,13 @@ def _on_handle_move(self, _, delta): current = self.current_container container = self.layout_at(all_bound) self._all_bound = all_bound - self._container_highlight.highlight(container) - # self._bound_highlight.highlight_bounds(all_bound) if container != current and current is not None: current.end_move() - current.clear_hover() + current.clear_highlight() if container is not None and container != current: - container.show_hover() + container.show_highlight() self.current_container = current = container for w in objs: @@ -517,7 +503,6 @@ def _attach(self, obj): MenuUtils.bind_all_context(obj, lambda e: self.show_menu(e, obj), add='+') obj.bind_all('', self.on_motion, '+') - obj.bind_all('', self._stop_displace, '+') if "text" in obj.keys(): obj.bind_all("", lambda _: self._show_text_editor(obj)) @@ -539,7 +524,7 @@ def _handle_select(self, obj, event): self._last_click_pos = event.x_root, event.y_root if event.state & EventMask.CONTROL: self.studio.selection.toggle(obj) - else: + elif obj not in self.studio.selection: self.studio.selection.set(obj) def load(self, obj_class, name, container, attributes, layout, bounds=None): @@ -723,10 +708,10 @@ def _select(self, _): return for w in self._selected - current_selection: - w.clear_highlight() + w.clear_handle() for w in current_selection - self._selected: - w.show_highlight() + w.show_handle() self._selected = current_selection self.focus_set() @@ -735,37 +720,6 @@ def _select(self, _): def selected(self): return self._selected - 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 _on_start(self): obj = self.current_obj if obj is not None: @@ -789,20 +743,23 @@ def undo(_): widgets, prev_restore_points, containers, prev_containers): container.remove_widget(widget) prev_container.restore_widget(widget, prev_restore_point) + self.studio.widgets_layout_changed(widgets) def redo(_): for widget, cur_restore_point, container, prev_container in zip( widgets, cur_restore_points, containers, prev_containers): prev_container.remove_widget(widget) container.restore_widget(widget, cur_restore_point) + self.studio.widgets_layout_changed(widgets) self.studio.new_action(Action(undo, redo)) def _text_change(self): - self.studio.style_pane.apply_style("text", self._text_editor.get_all(), list(self.selected)[0]) + self.studio.style_pane.apply_style("text", self._text_editor.get_all()) def _show_text_editor(self, widget): - assert widget in self.selected + if any("text" not in w.keys() for w in self.selected): + return self._text_editor.lift(widget) cnf = self._collect_text_config(widget) self._text_editor.config(**cnf) @@ -833,7 +790,7 @@ def _collect_text_config(self, widget): def _text_hide(self, *_): self._text_editor.place_forget() - def on_widget_change(self, old_widget, new_widget=None): + def on_widgets_change(self, widgets): pass def on_widget_add(self, widget, parent): diff --git a/studio/feature/stylepane.py b/studio/feature/stylepane.py index 54f7c6e..baaac60 100644 --- a/studio/feature/stylepane.py +++ b/studio/feature/stylepane.py @@ -15,9 +15,10 @@ from studio.feature._base import BaseFeature from studio.ui.editors import StyleItem, get_display_name, get_editor from studio.ui.widgets import CollapseFrame -from studio.lib.pseudo import Container +from studio.lib.pseudo import Container, PseudoWidget from studio.lib.layouts import GridLayoutStrategy -from studio.preferences import Preferences, get_active_pref +from studio.preferences import Preferences +from studio.lib.properties import get_combined_properties, combine_properties class ReusableStyleItem(StyleItem): @@ -100,20 +101,20 @@ class StyleGroup(CollapseFrame): def __init__(self, master, pane, **cnf): super().__init__(master) + self.pane_row = 0 self.style_pane = pane self.configure(**{**self.style.surface, **cnf}) self._empty_message = "Select an item to see styles" self._empty = Frame(self.body, **self.style.surface) self._empty_label = Label(self._empty, **self.style.text_passive,) self._empty_label.pack(fill="both", expand=True, pady=15) - self._widget = None self._prev_widget = None self._has_initialized = False # Flag to mark whether Style Items have been created self.items = {} @property - def widget(self): - return self._widget + def widgets(self): + return self.style_pane.selection def can_optimize(self): return False @@ -163,9 +164,8 @@ def _show_empty(self, text=None): def _remove_empty(self): self._empty.pack_forget() - def on_widget_change(self, widget): - self._widget = widget - if widget is None: + def on_widgets_change(self): + if not self.widgets: self.collapse() return definitions = self.get_definition() @@ -180,7 +180,7 @@ def on_widget_change(self, widget): ReusableStyleItem.free_all(self.items.values()) self.items.clear() add = self.add - list(map(lambda p: add(ReusableStyleItem.acquire(self, definitions[p], self.apply), ), definitions)) + list(map(lambda p: add(ReusableStyleItem.acquire(self, definitions[p], self.apply), ), sorted(definitions))) if not self.items: self._show_empty() else: @@ -188,43 +188,47 @@ def on_widget_change(self, widget): # self.style_pane.body.scroll_to_start() self._has_initialized = True - self._prev_widget = widget + self._prev_widgets = self.widgets - def _apply_action(self, prop, value, widget, data): - self.apply(prop, value, widget, True) + def _apply_action(self, prop, value, widgets, data): + self.apply(prop, value, widgets, True) def _get_action_data(self, widget, prop): return {} - def _get_key(self, widget, prop): - return f"{widget}:{self.__class__.__name__}:{prop}" + def _get_key(self, widgets, prop): + return f"{'.'.join([w.id for w in widgets])}:{self.__class__.__name__}:{prop}" - def apply(self, prop, value, widget=None, silent=False): - is_external = widget is not None - widget = self.widget if widget is None else widget - if widget is None: + def apply(self, prop, value, widgets=None, silent=False): + is_external = widgets is not None + widgets = self.widgets if widgets is None else widgets + if not widgets: return try: - prev_val = self._get_prop(prop, widget) - data = self._get_action_data(widget, prop) - self._set_prop(prop, value, widget) - new_data = self._get_action_data(widget, prop) - self.style_pane.widget_modified(widget) + prev_val = [self._get_prop(prop, widget) for widget in widgets] + data = [self._get_action_data(widget, prop) for widget in widgets] if is_external: - if widget == self.widget: + list(map(lambda x: self._set_prop(prop, x[0], x[1]), zip(value, widgets))) + else: + [self._set_prop(prop, value, widget) for widget in widgets] + new_data = [self._get_action_data(widget, prop) for widget in widgets] + self.style_pane.widgets_modified(widgets) + if is_external: + if widgets == self.widgets: self.items[prop].set_silently(value) if silent: return - key = self._get_key(widget, prop) + key = self._get_key(widgets, prop) action = self.style_pane.last_action() + if action is None or action.key != key: self.style_pane.new_action(Action( - lambda _: self._apply_action(prop, prev_val, widget, data), - lambda _: self._apply_action(prop, value, widget, new_data), + lambda _: self._apply_action(prop, prev_val, widgets, data), + lambda _: self._apply_action(prop, [value for _ in widgets], widgets, new_data), key=key, )) else: - action.update_redo(lambda _: self._apply_action(prop, value, widget, new_data)) + action.update_redo(lambda _: self._apply_action(prop, [value for _ in widgets], widgets, new_data)) except Exception as e: # Empty string values are too common to be useful in logger debug if value != '': @@ -234,7 +238,7 @@ def apply(self, prop, value, widget=None, silent=False): def get_definition(self): return {} - def supports_widget(self, widget): + def supports_widgets(self): return True def on_search_query(self, query): @@ -263,23 +267,29 @@ def __init__(self, master, pane, **cnf): self.label = "Widget identity" def get_definition(self): - if hasattr(self.widget, 'identity'): - return self.widget.identity + if not self.widgets: + return + if hasattr(self.widgets[0], 'identity'): + return self.widgets[0].identity return None def can_optimize(self): return self._has_initialized + def supports_widgets(self): + return len(self.widgets) == 1 + class AttributeGroup(StyleGroup): def __init__(self, master, pane, **cnf): super().__init__(master, pane, **cnf) self.label = "Attributes" + self._prev_classes = set() def get_definition(self): - if hasattr(self.widget, 'properties'): - return self.widget.properties + if all(isinstance(widget, PseudoWidget) for widget in self.widgets): + return get_combined_properties(self.widgets) return {} def _get_action_data(self, widget, prop): @@ -287,15 +297,24 @@ def _get_action_data(self, widget, prop): return widget.get_all_info() super()._get_action_data(widget, prop) - def _apply_action(self, prop, value, widget, data): - self.apply(prop, value, widget, True) - if prop == "layout" and data and isinstance(widget, Container): - widget.config_all_widgets(data) - if self.widget in widget._children: - self.style_pane._layout_group.on_widget_change(self.widget) + def _apply_action(self, prop, value, widgets, data): + self.apply(prop, value, widgets, True) + if prop == "layout": + has_change = False + for widget, info in zip(widgets, data): + widget.config_all_widgets(info) + if any(w in widget._children for w in self.widgets): + has_change = True + + if has_change: + self.style_pane._layout_group.on_widgets_change(self.widgets) def can_optimize(self): - return self._widget.__class__ == self._prev_widget.__class__ and self._has_initialized + classes = set(w.__class__ for w in self.widgets) + if classes != self._prev_classes: + self._prev_classes = classes + return False + return True class ColumnConfig(StyleGroup): @@ -312,8 +331,14 @@ def __init__(self, master, pane, **cnf): def _get_index_def(self): definition = dict(GridLayoutStrategy.COLUMN_DEF) - column = self.widget.grid_info()["column"] if self.is_grid(self.widget) else 0 - definition["value"] = column + if self.widgets: + definition["value"] = self.widgets[0].grid_info()["column"] + + for w in self.widgets: + if w.grid_info()["column"] != definition["value"]: + definition["value"] = '' + break + return definition def _get_prop(self, prop, widget): @@ -334,9 +359,9 @@ def is_grid(self, widget): return widget.layout.layout_strategy.__class__ == GridLayoutStrategy def get_definition(self): - if not self.is_grid(self.widget): + if not self.widgets and self.is_grid(self.widgets[0]): return {} - return self.widget.layout.layout_strategy.get_column_def(self.widget) + return combine_properties([w.layout.layout_strategy.get_column_def(w) for w in self.widgets]) def can_optimize(self): return self._has_initialized @@ -346,11 +371,12 @@ def clear_children(self): self._hide(child) def _update_index(self): - self._index.set(self.widget.grid_info()["column"]) + if self.widgets: + self._index.set(self.widgets[0].grid_info()["column"]) - def on_widget_change(self, widget): - if self.is_grid(widget): - super().on_widget_change(widget) + def on_widgets_change(self): + if all(self.is_grid(widget) for widget in self.widgets): + super().on_widgets_change() self._index._editor.set_def(self._get_index_def()) self._update_index() @@ -359,8 +385,14 @@ class RowConfig(ColumnConfig): def _get_index_def(self): definition = dict(GridLayoutStrategy.ROW_DEF) - row = self.widget.grid_info()["row"] if self.is_grid(self.widget) else 0 - definition["value"] = row + if self.widgets: + definition["value"] = self.widgets[0].grid_info()["row"] + + for w in self.widgets: + if w.grid_info()["row"] != definition["value"]: + definition["value"] = '' + break + return definition def _get_prop(self, prop, widget): @@ -376,12 +408,13 @@ def _set_prop(self, prop, value, widget): widget.layout._row_conf.add(row) def _update_index(self): - self._index.set(self.widget.grid_info()["row"]) + if self.widgets: + self._index.set(self.widgets[0].grid_info()["column"]) def get_definition(self): - if not self.is_grid(self.widget): + if not self.widgets and self.is_grid(self.widgets[0]): return {} - return self.widget.layout.layout_strategy.get_row_def(self.widget) + return combine_properties([w.layout.layout_strategy.get_row_def(w) for w in self.widgets]) class GridConfig(Frame): @@ -407,6 +440,7 @@ def __init__(self, master, pane, **cnf): self.label = "Layout" self._prev_layout = None self._grid_config = GridConfig(self.body, pane) + self._last_keys = set() def _get_prop(self, prop, widget): info = widget.layout.layout_strategy.info(widget) @@ -415,15 +449,17 @@ def _get_prop(self, prop, widget): def _set_prop(self, prop, value, widget): widget.layout.apply(prop, value, widget) - def on_widget_change(self, widget): - super().on_widget_change(widget) - self._prev_layout = widget.layout.layout_strategy - if widget: - self.label = f"Layout ({widget.layout.layout_strategy.name})" + def on_widgets_change(self): + super().on_widgets_change() + layout_strategy = self.widgets[0].layout.layout_strategy if self.widgets else None + self._prev_layout = layout_strategy + + if self.widgets: + self.label = f"Layout ({self.widgets[0].layout.layout_strategy.name})" else: self.label = "Layout" - if widget.layout.layout_strategy.__class__ == GridLayoutStrategy: + if layout_strategy.__class__ == GridLayoutStrategy: self._show_grid_conf(True) else: self._show_grid_conf(False) @@ -436,13 +472,17 @@ def _show_grid_conf(self, flag): self._grid_config.pack_forget() def can_optimize(self): - layout_strategy = self.widget.layout.layout_strategy - return layout_strategy.__class__ == self._prev_layout.__class__ \ - and self._layout_equal(self.widget, self._prev_widget) + keys = set(self.get_definition().keys()) + layout = self.widgets[0].layout.layout_strategy if self.widgets else None + if self._last_keys != keys or self._prev_layout != layout: + self._last_keys = keys + self._prev_layout = layout + return False + return True def get_definition(self): - if self.widget is not None: - return self.widget.layout.definition_for(self.widget) + if self.widgets: + return combine_properties([w.layout.definition_for(w) for w in self.widgets]) return {} def _layout_equal(self, widget1, widget2): @@ -450,9 +490,15 @@ def _layout_equal(self, widget1, widget2): def2 = widget2.layout.layout_strategy.get_def(widget2) return def1 == def2 - def supports_widget(self, widget): + def supports_widgets(self): # toplevel widgets do not need layout - return not widget.is_toplevel + if any(widget.is_toplevel for widget in self.widgets): + return False + if not self.widgets: + return False + strategy = self.widgets[0].layout.layout_strategy + # only support widgets with the same layout strategy + return all(widget.layout.layout_strategy == strategy for widget in self.widgets) class WindowGroup(StyleGroup): @@ -474,12 +520,12 @@ def can_optimize(self): return True def get_definition(self): - if self.widget is not None: - return self.widget.window_definition() + if self.widgets: + return combine_properties([widget.window_definition() for widget in self.widgets]) return {} - def supports_widget(self, widget): - return widget.is_toplevel + def supports_widgets(self): + return all(widget.is_toplevel for widget in self.widgets) class StylePaneFramework: @@ -503,17 +549,29 @@ def setup_style_pane(self): self._empty_frame = Frame(self.body) self.show_empty() + self._selection = [] self._current = None self._expanded = False self._is_loading = False self._search_query = None + self._current_row = 0 + + self.body.body.columnconfigure(0, weight=1) def get_header(self): raise NotImplementedError() + @property + def selection(self): + return self._selection + + @property + def widgets(self): + return self._selection + @property def supported_groups(self): - return [group for group in self.groups if group.supports_widget(self._current)] + return [group for group in self.groups if group.supports_widgets(self._current)] def create_menu(self): return ( @@ -522,10 +580,10 @@ def create_menu(self): ("command", "Collapse all", get_icon_image("chevron_up", 14, 14), self.collapse_all, {}) ) - def extern_apply(self, group_class, prop, value, widget=None, silent=False): + def extern_apply(self, group_class, prop, value, widgets=None, silent=False): for group in self.groups: if group.__class__ == group_class: - group.apply(prop, value, widget, silent) + group.apply(prop, value, widgets, silent) return raise ValueError(f"Class {group_class.__class__.__name__} not found") @@ -535,13 +593,15 @@ def last_action(self): def new_action(self, action): raise NotImplementedError() - def widget_modified(self, widget): + def widgets_modified(self, widgets): raise NotImplementedError() def add_group(self, group_class, **kwargs) -> StyleGroup: if not issubclass(group_class, StyleGroup): raise ValueError('type required.') group = group_class(self.body.body, self, **kwargs) + group.pane_row = self._current_row + self._current_row += 1 self.groups.append(group) self.show_group(group) return group @@ -549,6 +609,8 @@ def add_group(self, group_class, **kwargs) -> StyleGroup: def add_group_instance(self, group_instance, show=False): if not isinstance(group_instance, StyleGroup): raise ValueError('Expected object of type StyleGroup.') + group_instance.pane_row = self._current_row + self._current_row += 1 self.groups.append(group_instance) if show: self.show_group(group_instance) @@ -557,13 +619,17 @@ def hide_group(self, group): if group.self_positioned: group._hide_group() return - group.pack_forget() + if not group.winfo_ismapped(): + return + group.grid_forget() def show_group(self, group): if group.self_positioned: group._show_group() return - group.pack(side='top', fill='x', pady=12) + if group.winfo_ismapped(): + return + group.grid(row=group.pane_row, column=0, sticky="nsew", pady=12) def show_empty(self): self.remove_empty() @@ -590,36 +656,40 @@ def remove_loading(self): self.remove_empty() self._is_loading = False - def styles_for(self, widget): - self._current = widget - if widget is None: + def render_styles(self): + if not self.widgets: self.show_empty() return + for group in self.groups: - if group.supports_widget(widget): + if group.supports_widgets(): self.show_group(group) - group.on_widget_change(widget) + group.on_widgets_change() else: self.hide_group(group) self.remove_loading() self.body.update_idletasks() - def layout_for(self, widget): + def render_layouts(self): for group in self.groups: - if group.handles_layout and group.supports_widget(widget): - group.on_widget_change(widget) + if group.handles_layout and group.supports_widgets(): + group.on_widgets_change() self.remove_loading() - def on_select(self, widget): - self.styles_for(widget) + def _select(self, _): + selection = list(self.studio.selection) + if selection == self._selection: + return + self._selection = selection + self.render_styles() - def on_widget_change(self, old_widget, new_widget=None): - if new_widget is None: - new_widget = old_widget - self.styles_for(new_widget) + def on_widgets_change(self, widgets): + if any(w in self.widgets for w in widgets): + self.render_styles() - def on_widget_layout_change(self, widget): - self.layout_for(widget) + def on_widgets_layout_change(self, widgets): + if any(w in self.widgets for w in widgets): + self.render_layouts() def expand_all(self): for group in self.groups: @@ -680,7 +750,7 @@ def __init__(self, master, studio, **cnf): self.setup_style_pane() pref: Preferences = Preferences.acquire() - pref.add_listener("designer::descriptive_names", lambda _: self.styles_for(self._current)) + pref.add_listener("designer::descriptive_names", lambda _: [self.render_styles(), self.render_layouts()]) self._identity_group = self.add_group(IdentityGroup) self._layout_group = self.add_group(LayoutGroup) @@ -690,11 +760,13 @@ def __init__(self, master, studio, **cnf): self.add_group_instance(self._layout_group._grid_config.column_config) self.add_group_instance(self._layout_group._grid_config.row_config) - def apply_style(self, prop, value, widget=None, silent=False): - self._attribute_group.apply(prop, value, widget, silent) + self.studio.bind("<>", self._select, add='+') + + def apply_style(self, prop, value, widgets=None, silent=False): + self._attribute_group.apply(prop, value, widgets, silent) - def apply_layout(self, prop, value, widget=None, silent=False): - self._layout_group.apply(prop, value, widget, silent) + def apply_layout(self, prop, value, widgets=None, silent=False): + self._layout_group.apply(prop, value, widgets, silent) def get_header(self): return self._header @@ -705,5 +777,5 @@ def last_action(self): def new_action(self, action): self.studio.new_action(action) - def widget_modified(self, widget): - self.studio.widget_modified(widget, self, None) + def widgets_modified(self, widgets): + self.studio.widgets_modified(widgets, None) diff --git a/studio/lib/handles.py b/studio/lib/handles.py new file mode 100644 index 0000000..1ee021f --- /dev/null +++ b/studio/lib/handles.py @@ -0,0 +1,235 @@ +# ======================================================================= # +# Copyright (C) 2023 Hoverset Group. # +# ======================================================================= # + +import tkinter as tk +from collections import defaultdict + +from hoverset.platform import platform_is, WINDOWS, LINUX +from hoverset.ui.widgets import EventMask + + +def resize_cursor() -> tuple: + r""" + Returns a tuple of the cursors to be used based on platform + :return: tuple ("nw_se", "ne_sw") cursors roughly equal to \ and / respectively + """ + if platform_is(WINDOWS): + # Windows provides corner resize cursors so use those + return "size_nw_se", "size_ne_sw" + if platform_is(LINUX): + return "bottom_right_corner", "bottom_left_corner" + # Use circles for other platforms + return ("circle",) * 2 + + +class Dot(tk.Frame): + _corner_cursors = resize_cursor() + _cursor_map = dict( + n="sb_v_double_arrow", + s="sb_v_double_arrow", + e="sb_h_double_arrow", + w="sb_h_double_arrow", + c="fleur", + nw=_corner_cursors[0], + ne=_corner_cursors[1], + sw=_corner_cursors[1], + se=_corner_cursors[0], + all="fleur" + ) + + def __init__(self, handle, direction): + super().__init__(handle.master) + self.direction = direction + color = handle.master.style.colors["accent"] + self.config( + width=6, height=6, bg=color, cursor=self._cursor_map[direction], + highlightthickness=1, highlightbackground="#000" + ) + self.handle = handle + self.bind("", self.on_press) + self.bind("", self.on_release) + self.bind("", self.on_move) + self.fix = (0, 0) + + def on_move(self, event): + if not event.state & EventMask.MOUSE_BUTTON_1: + return + x, y = self.fix + self.fix = (event.x_root, event.y_root) + self.handle.on_dot_move(self.direction, (event.x_root - x, event.y_root - y)) + + def on_press(self, event): + self.fix = (event.x_root, event.y_root) + self.handle.set_direction(self.direction) + + def on_release(self, _): + self.handle.set_direction(None) + + def set_direction(self, direction): + self.direction = direction + self.config(cursor=self._cursor_map[direction]) + + +class Edge(tk.Frame): + + def __init__(self, handle): + super().__init__(handle.master) + color = handle.master.style.colors["accent"] + self.config(bg=color, height=2, width=2) + + +class Handle: + + _pool = defaultdict(list) + + def __init__(self, widget, master=None): + self.widget = widget + self.master = widget if master is None else master + self.active_direction = None + self.dots = [] + self.edges = [] + self._hover = False + self._showing = False + + def set_direction(self, direction): + if direction is None: + self.widget.handle_inactive(self.active_direction) + self.active_direction = None + return + self.active_direction = direction + self.widget.handle_active(direction) + + def on_dot_move(self, direction, delta): + self.widget.handle_resize(direction, delta) + + def widget_config_changed(self): + pass + + def redraw(self): + raise NotImplementedError + + def active(self): + return self._showing or self._hover + + def show(self): + if self._showing: + return + self._showing = True + self.redraw() + + def hide(self): + if not self._showing: + return + for dot in self.dots: + dot.place_forget() + self._showing = False + if not self._hover: + self.release() + + def hover(self): + if self._hover: + return + self._hover = True + self.redraw() + + def unhover(self): + if not self._hover: + return + for edge in self.edges: + edge.place_forget() + self._hover = False + self.redraw() + if not self._showing: + self.release() + + def release(self): + Handle._pool[(self.__class__, self.master)].append(self) + + @classmethod + def acquire(cls, widget, master=None): + master = widget if master is None else master + if not cls._pool[(cls, master)]: + obj = cls(widget, master) + else: + obj = cls._pool[(cls, master)].pop() + obj.widget = widget + return obj + + +class BoxHandle(Handle): + + def __init__(self, widget, master=None): + super().__init__(widget, master) + self.directions = ["n", "s", "e", "w", "nw", "ne", "sw", "se", "all"] + self.dots = [Dot(self, direction) for direction in self.directions] + + def redraw(self): + if self._showing: + n, s, e, w, nw, ne, sw, se, c = self.dots + radius = 2 + # border-mode has to be outside so the highlight covers the entire widget + extra = dict(in_=self.widget, bordermode="outside") + nw.place(**extra, x=-radius, y=-radius) + ne.place(**extra, relx=1, x=-radius, y=-radius) + sw.place(**extra, x=-radius, rely=1, y=-radius) + se.place(**extra, relx=1, x=-radius, rely=1, y=-radius) + n.place(**extra, relx=0.5, x=-radius, y=-radius) + s.place(**extra, relx=0.5, x=-radius, rely=1, y=-radius) + e.place(**extra, relx=1, x=-radius, rely=0.5, y=-radius) + w.place(**extra, x=-radius, rely= 0.5, y=-radius) + c.place(**extra, relx=0.5, rely=0.5, x=-radius, y=-radius) + + if self._hover: + if not self.edges: + self.edges = [Edge(self) for _ in range(4)] + extra = dict(in_=self.widget, bordermode="outside") + n, s, e, w = self.edges + n.place(**extra, x=0, y=0, relwidth=1) + e.place(**extra, relx=1, x=-e['width'], y=0, relheight=1) + s.place(**extra, rely=1, y=-e['width'], x=0, relwidth=1) + w.place(**extra, x=0, y=0, relheight=1) + + +class LinearHandle(Handle): + + def __init__(self, widget, master=None): + super().__init__(widget, master) + self.start = Dot(self, "w") + self.end = Dot(self, "e") + self.center = Dot(self, "all") + self.dots = [self.start, self.end, self.center] + self.orient = 'horizontal' + + def _get_orientation(self): + try: + return self.widget.cget("orient") + except tk.TclError: + return "horizontal" + + def widget_config_changed(self): + if str(self._get_orientation()) != self.orient: + self.orient = str(self._get_orientation()) + for dot in self.dots: + dot.place_forget() + self.redraw() + + def redraw(self): + if self._showing: + radius = 2 + extra = dict(in_=self.widget, bordermode="outside") + + if self.orient == "horizontal": + self.start.set_direction("w") + self.end.set_direction("e") + self.center.set_direction("all") + self.start.place(**extra, x=-radius, y=-radius) + self.end.place(**extra, relx=1, x=-radius, y=-radius) + self.center.place(**extra, relx=0.5, x=-radius, y=-radius) + else: + self.start.set_direction("n") + self.end.set_direction("s") + self.center.set_direction("all") + self.start.place(**extra, x=-radius, y=-radius) + self.end.place(**extra, x=-radius, rely=1, y=-radius) + self.center.place(**extra, rely=0.5,x=-radius, y=-radius) diff --git a/studio/lib/layouts.py b/studio/lib/layouts.py index c63fe17..bd544dd 100644 --- a/studio/lib/layouts.py +++ b/studio/lib/layouts.py @@ -291,6 +291,10 @@ def restore_widget(self, widget, data=None): widget.layout = self.container widget.level = self.level + 1 widget.place_configure(**data.get("info", {})) + try: + widget.lift((self.children[-1:] or [self.container.body])[0]) + except Exception: + pass def get_restore(self, widget): return { @@ -653,6 +657,7 @@ def restore_widget(self, widget, data=None): widget.level = self.level + 1 widget.layout = self.container widget.grid(in_=self.container.body) + widget.lift() self.config_widget(widget, data.get("info", {})) def react_to(self, bounds): @@ -750,7 +755,7 @@ def _widget_at(self, row, column): def _location_analysis(self, bounds): if len(self.temporal_children) > 1: # cannot perform analysis with more than one widget - return + return 0, 0, 0, 0 self.clear_indicators() self._edge_indicator.update_idletasks() bounds = geometry.relative_bounds(bounds, self.container.body) diff --git a/studio/lib/legacy.py b/studio/lib/legacy.py index 645649b..7369280 100644 --- a/studio/lib/legacy.py +++ b/studio/lib/legacy.py @@ -3,7 +3,6 @@ from studio.lib.pseudo import ( PseudoWidget, Groups, Container, PanedContainer, _dimension_override ) -from studio.lib.toplevel import Toplevel from studio.lib.toplevel import Toplevel, Tk diff --git a/studio/lib/native.py b/studio/lib/native.py index 6fba6f9..abdc861 100644 --- a/studio/lib/native.py +++ b/studio/lib/native.py @@ -2,6 +2,7 @@ import tkinter.ttk as ttk from studio.lib.layouts import NPanedLayoutStrategy +from studio.lib.handles import LinearHandle from studio.lib.pseudo import ( PseudoWidget, Groups, Container, TabContainer, PanedContainer, _dimension_override @@ -304,6 +305,7 @@ class Separator(PseudoWidget, ttk.Separator): icon = "play" impl = ttk.Separator initial_dimensions = 150, 1 + handle_class = LinearHandle def __init__(self, master, id_): super().__init__(master) diff --git a/studio/lib/properties.py b/studio/lib/properties.py index 518f95a..7462f3d 100644 --- a/studio/lib/properties.py +++ b/studio/lib/properties.py @@ -634,7 +634,57 @@ def get_properties(widget, extern_overrides=None): for prop in properties: definition = get_resolved(prop, overrides, PROPERTY_TABLE) if definition: - definition.update(value=widget[prop]) + definition.update(value=widget.get_prop(prop)) resolved_properties[prop] = definition return resolved_properties + + +def combine_properties(properties): + """ + Return a dict of properties that are common to all widgets in the list. + """ + if not properties: + return {} + + # find the intersection of all the property names + common_properties = set.intersection(*[set(p.keys()) for p in properties]) + + # get the first definition for each common property + common_properties = {prop: properties[0][prop] for prop in common_properties} + + # check that the values are the same for each widget + for prop, definition in common_properties.items(): + for widget_properties in properties[1:]: + if widget_properties[prop]['value'] != definition['value']: + common_properties[prop]['value'] = '' + break + + return common_properties + + +def get_combined_properties(widgets): + """ + Return a dict of properties that are common to all widgets in the list. + """ + if not widgets: + return {} + + # get all the properties for each widget + properties = [widget.properties for widget in widgets] + + # find the intersection of all the property names + common_properties = set.intersection(*[set(p.keys()) for p in properties]) + + # get the first definition for each common property + common_properties = {prop: properties[0][prop] for prop in common_properties} + + # check that the values are the same for each widget + for prop, definition in common_properties.items(): + for widget_properties in properties[1:]: + if widget_properties[prop]['value'] != definition['value']: + common_properties[prop]['value'] = '' + break + + return common_properties + diff --git a/studio/lib/pseudo.py b/studio/lib/pseudo.py index 7c9b01b..50827b9 100644 --- a/studio/lib/pseudo.py +++ b/studio/lib/pseudo.py @@ -8,11 +8,12 @@ from hoverset.data.images import load_tk_image, load_image, load_image_to_widget from hoverset.ui.icons import get_icon_image from hoverset.ui.menu import MenuUtils +from hoverset.ui.widgets import EventMask from hoverset.util.execution import import_path from studio.lib import layouts from studio.lib.variables import VariableManager from studio.lib.properties import get_properties -from studio.ui.highlight import BoxHandle +from studio.lib.handles import BoxHandle from studio.ui.tree import MalleableTree @@ -122,9 +123,11 @@ def setup_widget(self): self.node = None self.__on_context = None self.last_menu_position = (0, 0) + self._pos_fix = (0, 0) self.max_size = None self.min_size = None MenuUtils.bind_context(self, self.__handle_context_menu, add='+') + self._handle_cls = getattr(self.__class__, "handle_class", BoxHandle) self._handle = None self._on_handle_active = getattr(self, "_on_handle_active", None) @@ -132,6 +135,12 @@ def setup_widget(self): self._on_handle_resize = getattr(self, "_on_handle_resize", None) self._on_handle_move = getattr(self, "_on_handle_move", None) + self.bind("", self._on_press, add='+') + self.bind("", self._on_release, add='+') + self.bind("", self._on_drag, add='+') + + self._active = False + def set_name(self, name): pass @@ -143,22 +152,53 @@ def get_bounds(self): y2 = self.winfo_height() + y1 return x1, y1, x2, y2 - def show_highlight(self, *_): + def _on_press(self, event): + if not self._handle: + return + self._pos_fix = (event.x_root, event.y_root) + self.handle_active('all') + self._active = True + + def _on_release(self, _): + if not self._active: + return + self.handle_inactive('all') + self._active = False + + def _on_drag(self, event): + if not self._active or not event.state & EventMask.MOUSE_BUTTON_1: + return + x, y = self._pos_fix + self._pos_fix = (event.x_root, event.y_root) + self.handle_resize('all', (event.x_root - x, event.y_root - y)) + + def show_handle(self, *_): + if not self._handle_cls: + return if not self._handle: - self._handle = BoxHandle.acquire(self, self.master) + self._handle = self._handle_cls.acquire(self, self.master) self._handle.show() - def clear_highlight(self): + def clear_handle(self): if not self._handle: return self._handle.hide() - self._handle = None + if not self._handle.active(): + self._handle = None - def show_hover(self, *_): - pass + def show_highlight(self, *_): + if not self._handle_cls: + return + if not self._handle: + self._handle = self._handle_cls.acquire(self, self.master) + self._handle.hover() - def clear_hover(self): - pass + def clear_highlight(self): + if not self._handle: + return + self._handle.unhover() + if not self._handle.active(): + self._handle = None def on_handle_active(self, callback): self._on_handle_active = callback @@ -237,7 +277,10 @@ def configure(self, options=None, **kw): if intercept: intercept.set(self, kw[opt], opt) kw.pop(opt) - return super().config(**kw) + ret = super().config(**kw) + if kw and self._handle: + self._handle.widget_config_changed() + return ret def bind_all(self, sequence, func=None, add=None): # we should be able to bind studio events @@ -426,7 +469,7 @@ def configure(self, **kwargs): return super().configure(**kwargs) def _set_layout(self, layout): - self.designer.studio.style_pane.apply_style("layout", layout, self) + self.designer.studio.style_pane.apply_style("layout", [layout], [self]) def _get_layouts_as_menu(self): layout_templates = [ diff --git a/studio/main.py b/studio/main.py index 4beb332..c3c3875 100644 --- a/studio/main.py +++ b/studio/main.py @@ -633,20 +633,26 @@ def maximize(self, feature): 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): + # TODO refactor tools + # self.tool_manager.on_widget_add(widget, parent) + + def widgets_modified(self, widgets, source=None): for feature in self.features: if feature != source: - feature.on_widget_change(widget1, widget2) + feature.on_widgets_change(widgets) if self.designer and self.designer != source: - self.designer.on_widget_change(widget1, widget2) - self.tool_manager.on_widget_change(widget1, widget2) + self.designer.on_widgets_change(widgets) + + # TODO refactor tools + # self.tool_manager.on_widget_change(widgets) - def widget_layout_changed(self, widgets): + def widgets_layout_changed(self, widgets): for feature in self.features: - feature.on_widget_layout_change(widgets) - self.tool_manager.on_widget_layout_change(widgets) + feature.on_widgets_layout_change(widgets) + + # TODO refactor tools + # self.tool_manager.on_widget_layout_change(widgets) def make_clipboard(self, widgets): bounds = geometry.overall_bounds([w.get_bounds() for w in widgets]) @@ -676,8 +682,10 @@ def delete(self, widgets=None, source=None): self.designer.delete(widgets) for feature in self.features: - feature.on_widget_delete(widgets) - self.tool_manager.on_widget_delete(widgets) + feature.on_widgets_delete(widgets) + + # TODO refactor tools + # self.tool_manager.on_widget_delete(widgets) def cut(self, widgets=None, source=None): if not self.designer: @@ -694,8 +702,10 @@ def cut(self, widgets=None, source=None): if source != self.designer: self.designer.delete(widgets) for feature in self.features: - feature.on_widget_delete(widgets, True) - self.tool_manager.on_widget_delete(widgets) + feature.on_widgets_delete(widgets, True) + + # TODO refactor tools + # self.tool_manager.on_widget_delete(widgets) def duplicate(self): if self.designer and self.selection: @@ -703,7 +713,7 @@ def duplicate(self): def on_restore(self, widgets): for feature in self.features: - feature.on_widget_restore(widgets) + feature.on_widgets_restore(widgets) def on_feature_change(self, new, old): self.features.insert(self.features.index(old), new) diff --git a/studio/ui/highlight.py b/studio/ui/highlight.py index 4dc42e2..379b475 100644 --- a/studio/ui/highlight.py +++ b/studio/ui/highlight.py @@ -1,461 +1,8 @@ -import tkinter as tk -from collections import defaultdict - -from hoverset.platform import platform_is, WINDOWS, LINUX -from hoverset.ui.widgets import EventMask -from studio.ui import geometry -from studio.ui.widgets import DesignPad - - -def resize_cursor() -> tuple: - r""" - Returns a tuple of the cursors to be used based on platform - :return: tuple ("nw_se", "ne_sw") cursors roughly equal to \ and / respectively - """ - if platform_is(WINDOWS): - # Windows provides corner resize cursors so use those - return "size_nw_se", "size_ne_sw" - if platform_is(LINUX): - return "bottom_right_corner", "bottom_left_corner" - # Use circles for other platforms - return ("circle",) * 2 - - -class Dot(tk.Frame): - _corner_cursors = resize_cursor() - _cursor_map = dict( - n="sb_v_double_arrow", - s="sb_v_double_arrow", - e="sb_h_double_arrow", - w="sb_h_double_arrow", - c="fleur", - nw=_corner_cursors[0], - ne=_corner_cursors[1], - sw=_corner_cursors[1], - se=_corner_cursors[0], - all="fleur" - ) - - def __init__(self, handle, direction): - super().__init__(handle.master) - self.direction = direction - color = handle.master.style.colors["accent"] - self.config(width=6, height=6, bg=color, cursor=self._cursor_map[direction]) - self.handle = handle - self.bind("", self.on_press) - self.bind("", self.on_release) - self.bind("", self.on_move) - self.fix = (0, 0) - - def on_move(self, event): - if not event.state & EventMask.MOUSE_BUTTON_1: - return - x, y = self.fix - self.fix = (event.x_root, event.y_root) - self.handle.on_dot_move(self.direction, (event.x_root - x, event.y_root - y)) - - def on_press(self, event): - self.fix = (event.x_root, event.y_root) - self.handle.set_direction(self.direction) - - def on_release(self, _): - self.handle.set_direction(None) - - -class Handle: - - _pool = defaultdict(list) - - def __init__(self, widget, master=None): - self.widget = widget - self.master = widget if master is None else master - self.active_direction = None - self.dots = [] - self._hover = False - self._showing = False - - def set_direction(self, direction): - if direction is None: - self.widget.handle_inactive(self.active_direction) - self.active_direction = None - return - self.active_direction = direction - self.widget.handle_active(direction) - - def on_dot_move(self, direction, delta): - self.widget.handle_resize(direction, delta) - - def redraw(self): - raise NotImplementedError - - def show(self): - if self._showing: - return - self._showing = True - self.redraw() - - def hide(self): - if not self._showing: - return - for dot in self.dots: - dot.place_forget() - self._showing = False - if not self._hover: - self.release() - - def hover(self): - if self._hover: - return - self._hover = True - self.redraw() - - def unhover(self): - if not self._hover: - return - self._hover = False - self.redraw() - if not self._showing: - self.release() - - def release(self): - Handle._pool[(self.__class__, self.master)].append(self) - - @classmethod - def acquire(cls, widget, master=None): - master = widget if master is None else master - if not cls._pool[(cls, master)]: - obj = cls(widget, master) - else: - obj = cls._pool[(cls, master)].pop() - obj.widget = widget - return obj - - -class BoxHandle(Handle): - - def __init__(self, widget, master=None): - super().__init__(widget, master) - self.directions = ["n", "s", "e", "w", "nw", "ne", "sw", "se", "all"] - self.dots = [Dot(self, direction) for direction in self.directions] - - def redraw(self): - n, s, e, w, nw, ne, sw, se, c = self.dots - radius = 2 - # border-mode has to be outside so the highlight covers the entire widget - extra = dict(in_=self.widget, bordermode="outside") - nw.place(**extra, x=-radius, y=-radius) - ne.place(**extra, relx=1, x=-radius, y=-radius) - sw.place(**extra, x=-radius, rely=1, y=-radius) - se.place(**extra, relx=1, x=-radius, rely=1, y=-radius) - n.place(**extra, relx=0.5, x=-radius, y=-radius) - s.place(**extra, relx=0.5, x=-radius, rely=1, y=-radius) - e.place(**extra, relx=1, x=-radius, rely=0.5, y=-radius) - w.place(**extra, x=-radius, rely=0.5, y=-radius) - c.place(**extra, relx=0.5, rely=0.5, x=-radius, y=-radius) - - -class LinearHandle(Handle): - - def __init__(self, widget, master=None): - super().__init__(widget, master) - - def redraw(self): - pass - - -class _HighLight: - """ - This class is responsible for the Highlight on selected objects on the designer. It allows resizing, moving and - access to the currently selected widget. It also provides a way to attach listeners for any changes to the - widgets size and position - """ - OUTLINE = 2 - SIZER_LENGTH = 6 - MIN_SIZE = 2 - - def __init__(self, parent: DesignPad): - self.parent = self.designer = parent - self._resize_func = None - self.bounds = (0, 0, parent.width, parent.height) - self.pos_on_click = None - self.pos_cache = None - self._bbox_on_click = None - self._on_resize = None - self._on_release = None - self._on_move = None - self._on_start = None - self._start_reported = False - self.current_obj = None - self.bind_ids = [] - - # These are the handle widgets that acts as guides for resizing and moving objects +# ======================================================================= # +# Copyright (C) 2019 Hoverset Group. # +# ======================================================================= # - h_background = self.designer.style.colors["accent"] - h_force_visible = dict(relief="groove", bd=1) - # self.l = tk.Frame(parent, bg=h_background, width=self.OUTLINE, cursor="fleur", **h_force_visible) - # self.r = tk.Frame(parent, bg=h_background, width=self.OUTLINE, cursor="fleur", **h_force_visible) - # self.t = tk.Frame(parent, bg=h_background, height=self.OUTLINE, cursor="fleur", **h_force_visible) - # self.b = tk.Frame(parent, bg=h_background, height=self.OUTLINE, cursor="fleur", **h_force_visible) - - _cursors = resize_cursor() - self.nw = tk.Frame(parent, bg=h_background, width=self.SIZER_LENGTH, height=self.SIZER_LENGTH, - cursor=_cursors[0], **h_force_visible) - self.ne = tk.Frame(parent, bg=h_background, width=self.SIZER_LENGTH, height=self.SIZER_LENGTH, - cursor=_cursors[1], **h_force_visible) - self.sw = tk.Frame(parent, bg=h_background, width=self.SIZER_LENGTH, height=self.SIZER_LENGTH, - cursor=_cursors[1], **h_force_visible) - self.se = tk.Frame(parent, bg=h_background, width=self.SIZER_LENGTH, height=self.SIZER_LENGTH, - cursor=_cursors[0], **h_force_visible) - self.n = tk.Frame(parent, bg=h_background, width=self.SIZER_LENGTH, height=self.SIZER_LENGTH, - cursor="sb_v_double_arrow", **h_force_visible) - self.s = tk.Frame(parent, bg=h_background, width=self.SIZER_LENGTH, height=self.SIZER_LENGTH, - cursor="sb_v_double_arrow", **h_force_visible) - self.e = tk.Frame(parent, bg=h_background, width=self.SIZER_LENGTH, height=self.SIZER_LENGTH, - cursor="sb_h_double_arrow", **h_force_visible) - self.w = tk.Frame(parent, bg=h_background, width=self.SIZER_LENGTH, height=self.SIZER_LENGTH, - cursor="sb_h_double_arrow", **h_force_visible) - - # bind all resizing corners to register their respective resize methods when pressed - # Any movement events will then call this registered method to ensure the right resize approach - # is being used. - - self.nw.bind("", lambda e: self.set_function(self.nw_resize, e)) - self.ne.bind("", lambda e: self.set_function(self.ne_resize, e)) - self.sw.bind("", lambda e: self.set_function(self.sw_resize, e)) - self.se.bind("", lambda e: self.set_function(self.se_resize, e)) - self.n.bind("", lambda e: self.set_function(self.n_resize, e)) - self.s.bind("", lambda e: self.set_function(self.s_resize, e)) - self.e.bind("", lambda e: self.set_function(self.e_resize, e)) - self.w.bind("", lambda e: self.set_function(self.w_resize, e)) - - self._elements = [ - self.nw, self.ne, self.sw, self.se, self.n, self.s, self.e, self.w - ] - - # ============================================== bindings ===================================================== - - # These variables help in skipping of several rendering frames to reduce lag when dragging items - self._skip_var = 0 - # The maximum rendering to skip (currently 80%) for every one successful render. Ensure its - # not too big otherwise we won't be moving and resizing items at all and not too small otherwise the lag would - # be unbearable - self._skip_max = 4 - self.parent.bind("", self.resize) - for elem in self._elements[4:]: - elem.bind("", self.resize) - elem.bind("", self.clear_resize) - for elem in self._elements[:4]: - elem.bind("", lambda e: self.set_function(self.move, e)) - elem.bind("", self.resize) - elem.bind("", self.clear_resize) - self.parent.bind_all("", self.clear_resize) - - def set_skip_max(self, value): - self._skip_max = value - - @staticmethod - def bounds_from_object(obj: tk.Misc): - """ - Generate a bounding box for a widget relative to its parent which can then be used to position the highlight - or by any other position dependent action. - :param obj: a tk object - :return: - """ - obj.update_idletasks() - x1 = obj.winfo_x() - y1 = obj.winfo_y() - x2 = obj.winfo_width() + x1 - y2 = obj.winfo_height() + y1 - return x1, y1, x2, y2 - - def _lift_all(self): - for elem in self._elements: - elem.lift() - - def on_resize(self, listener, *args, **kwargs): - self._on_resize = lambda bounds: listener(bounds, *args, **kwargs) - - def on_release(self, listener, *args, **kwargs): - self._on_release = lambda bounds: listener(bounds, *args, **kwargs) - - def on_move(self, listener, *args, **kwargs): - self._on_move = lambda bounds: listener(bounds, *args, **kwargs) - - def on_start(self, listener, *args, **kwargs): - self._on_start = lambda: listener(*args, **kwargs) - - def resize(self, event): - # This method is called when the motion event is fired and dispatches the event to the registered resize method - if self._resize_func: - if self._skip_var >= self._skip_max: - # Render frames and begin skip cycle - event = self._stabilised_event(event) - self._skip_var = 0 - # get the latest limits from DesignPad - self.bounds = self.parent.bound_limits() - if not self._start_reported and self._on_start: - self._on_start() - self._start_reported = True - self._resize_func(event) - else: - # Skip rendering frames - self._skip_var += 1 - - def clear_resize(self, *_): - # Clear all global resize functions and reset the resize function so that the motion event can't update - # The highlight box anymore - self._resize_func = None - self.pos_on_click = None - self.pos_cache = None - self._update_bbox() - if self._on_release is not None: - self._on_release(self._bbox_on_click) - self._bbox_on_click = None - self._skip_var = 0 - self._start_reported = False - - @property - def is_active(self) -> bool: - # currently resizing or moving widget - return bool(self._resize_func) - - @property - def bbox_on_click(self): - # Return the bounding box of the highlight - return self._bbox_on_click - - def set_function(self, func, event): - # Registers the method to be called for any drag actions which may include moving and resizing - self._resize_func = func - self.pos_on_click = self.pos_cache = self._stabilised_event(event) - self._update_bbox() - - def _update_bbox(self): - # Update the bounding box based on the current position of the highlight - if self.current_obj is None: - return - self._bbox_on_click = self.bounds_from_object(self.current_obj) - - def clear(self): - """ - Remove the highlight from view. This is temporary and can be reversed by calling the surround method on an - a widget - :return: - """ - for elem in self._elements: - elem.place_forget() - self.current_obj = None - - def redraw(self, widget, radius=None): - # Redraw the highlight in the new bounding box - radius = (self.SIZER_LENGTH - self.OUTLINE) // 2 - # border-mode has to be outside so the highlight covers the entire widget - extra = dict(in_=widget, bordermode="outside") - - # self.l.place(**extra, relheight=1) - # self.r.place(**extra, relx=1, relheight=1) - # self.t.place(**extra, relwidth=1) - # self.b.place(**extra, rely=1, relwidth=1) - - self.nw.place(**extra, x=-radius, y=-radius) - self.ne.place(**extra, relx=1, x=-radius, y=-radius) - self.sw.place(**extra, x=-radius, rely=1, y=-radius) - self.se.place(**extra, relx=1, x=-radius, rely=1, y=-radius) - self.n.place(**extra, relx=0.5, x=-radius, y=-radius) - self.s.place(**extra, relx=0.5, x=-radius, rely=1, y=-radius) - self.e.place(**extra, relx=1, x=-radius, rely=0.5, y=-radius) - self.w.place(**extra, x=-radius, rely=0.5, y=-radius) - - # ========================================= resize approaches ========================================== - - def ne_resize(self, event=None): - # perform resize in the north east direction - x1, *_, y2 = self.bbox_on_click - x2 = max(min(self.bounds[2], event.x), x1 + self.MIN_SIZE) - y1 = min(max(self.bounds[1], event.y), y2 - self.MIN_SIZE) - self._on_resize((x1, y1, x2, y2)) - - def n_resize(self, event=None): - # perform resize in the north direction - x1, _, x2, y2 = self.bbox_on_click - y1 = min(max(self.bounds[1], event.y), y2 - self.MIN_SIZE) - self._on_resize((x1, y1, x2, y2)) - - def e_resize(self, event=None): - # perform resize in the east direction - x1, y1, _, y2 = self.bbox_on_click - x2 = max(min(self.bounds[2], event.x), x1 + self.MIN_SIZE) - self._on_resize((x1, y1, x2, y2)) - - def nw_resize(self, event=None): - # perform resize in the north west direction - *_, x2, y2 = self.bbox_on_click - x1 = min(max(self.bounds[0], event.x), x2 - self.MIN_SIZE) - y1 = min(max(self.bounds[1], event.y), y2 - self.MIN_SIZE) - self._on_resize((x1, y1, x2, y2)) - - def sw_resize(self, event=None): - # perform resize in the south west direction - _, y1, x2, _ = self.bbox_on_click - x1 = min(max(self.bounds[0], event.x), x2 - self.MIN_SIZE) - y2 = max(min(self.bounds[3], event.y), y1 + self.MIN_SIZE) - self._on_resize((x1, y1, x2, y2)) - - def s_resize(self, event=None): - # perform resize in the south direction - x1, y1, x2, _ = self.bbox_on_click - y2 = max(min(self.bounds[3], event.y), y1 + self.MIN_SIZE) - self._on_resize((x1, y1, x2, y2)) - - def w_resize(self, event=None): - # perform resize in the west direction - _, y1, x2, y2 = self.bbox_on_click - x1 = min(max(self.bounds[0], event.x), x2 - self.MIN_SIZE) - self._on_resize((x1, y1, x2, y2)) - - def se_resize(self, event=None): - # perform resize in the south east direction - x1, y1, *_ = self.bbox_on_click - x2 = max(min(self.bounds[2], event.x), x1 + self.MIN_SIZE) - y2 = max(min(self.bounds[3], event.y), y1 + self.MIN_SIZE) - self._on_resize((x1, y1, x2, y2)) - - # ========================================================================================================= - - def surround(self, obj): - """ - Draw the highlight around the object(widget) :param obj - :param obj: A tk widget - :return: None - """ - self.current_obj = obj - self.redraw(obj) - self._update_bbox() - self._lift_all() - - def _stabilised_event(self, event): - # Since events are bound to the dynamically adjusted handle widgets in the highlight, - # coordinates for the event object may vary unpredictably. - # This method attempts to fix that by adjusting the x and y attributes of the event to always - # be in reference to a static parent widget in this case self.parent - geometry.make_event_relative(event, self.parent) - return event - - def move(self, event=None): - # We will use the small change approach. We detect the small change in cursor position then map this - # difference to the highlight box. - # Update the position cache with the new position so that we can calculate the subsequent small change - bounds = self.parent.bound_limits() - if self.pos_cache is not None: - delta_x, delta_y = event.x - self.pos_cache.x, event.y - self.pos_cache.y - x1, y1, x2, y2 = self.bbox_on_click - # We need to ensure the crop box does not go beyond the image on both the x and y axis - delta_x = 0 if x1 + delta_x < bounds[0] else delta_x - delta_y = 0 if y1 + delta_y < bounds[1] else delta_y - bound = (x1 + delta_x, y1 + delta_y, x2 + delta_x, y2 + delta_y) - self._on_move(bound) - self.pos_cache = event # Update the cache - self._update_bbox() +import tkinter as tk class WidgetHighlighter: @@ -524,4 +71,4 @@ def left(self, bounds): self.place(x=x, y=y, height=bounds[3] - bounds[1], width=1.5) def clear(self): - self.place_forget() + self.place_forget() \ No newline at end of file From ae62678044d554b7df06de0f0cd06a36766528b6 Mon Sep 17 00:00:00 2001 From: ObaraEmmanuel Date: Sat, 19 Aug 2023 18:53:59 +0300 Subject: [PATCH 03/15] Implement multiselect in components and event pane --- studio/feature/__init__.py | 4 +- studio/feature/components.py | 7 ++- studio/feature/eventspane.py | 85 +++++++++++++++++++++++++----------- studio/lib/events.py | 4 ++ 4 files changed, 69 insertions(+), 31 deletions(-) diff --git a/studio/feature/__init__.py b/studio/feature/__init__.py index ce64762..f57daa8 100644 --- a/studio/feature/__init__.py +++ b/studio/feature/__init__.py @@ -8,8 +8,8 @@ ComponentPane, ComponentTree, StylePane, - #EventPane, - #VariablePane, + EventPane, + VariablePane, ) diff --git a/studio/feature/components.py b/studio/feature/components.py index 0439f28..67be57c 100644 --- a/studio/feature/components.py +++ b/studio/feature/components.py @@ -275,12 +275,12 @@ def __init__(self, master, studio=None, **cnf): 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 = [] + self.studio.bind("<>", self.on_widget_select, add='+') Preferences.acquire().add_listener(self._custom_pref_path, self._init_custom) self._reload_custom() @@ -439,7 +439,7 @@ def render_groups(self): def render_extern_groups(self): for group in self._extern_groups: - if group.supports(self._widget): + if all(group.supports(w) for w in self.studio.widgets): self.add_selector(group.selector) else: self.remove_selector(group.selector) @@ -481,8 +481,7 @@ def unregister_group(self, group): self._extern_groups.remove(group) self._auto_select() - def on_select(self, widget): - self._widget = widget + def on_widget_select(self, _): self.render_extern_groups() def start_search(self, *_): diff --git a/studio/feature/eventspane.py b/studio/feature/eventspane.py index 043be8d..6a4a839 100644 --- a/studio/feature/eventspane.py +++ b/studio/feature/eventspane.py @@ -3,7 +3,7 @@ Label, CompoundList, Entry, Frame, Checkbutton, Button ) from studio.feature._base import BaseFeature -from studio.lib.events import EventBinding, make_event +from studio.lib.events import EventBinding, make_event, event_equal class BindingsTable(CompoundList): @@ -33,13 +33,10 @@ def _delete_entry(self, *_): def render(self): self.config(height=40) - seq_frame = Frame(self, **self.style.highlight) - seq_frame.grid(row=0, column=0, sticky="nsew") - seq_frame.pack_propagate(False) - self.sequence = Entry(seq_frame, **self.style.input) - self.sequence.place(x=0, y=0, relwidth=1, relheight=1, width=-40) + self.sequence = Entry(self, **self.style.input) + self.sequence.grid(row=0, column=0, sticky="nsew") self.sequence.set(self.value.sequence) - self.sequence.configure(**self.style.no_highlight) + self.sequence.configure(**self.style.highlight) self.sequence.focus_set() self.handler = Entry(self, **self.style.input) self.handler.grid(row=0, column=1, sticky="ew") @@ -56,7 +53,7 @@ def render(self): del_btn.bind("", self._delete_entry) # set the first two columns to expand evenly for column in range(2): - self.grid_columnconfigure(column, weight=1, uniform=1) + self.grid_columnconfigure(column, weight=1) for widget in (self.sequence, self.handler): widget.on_change(self._on_value_change) @@ -174,6 +171,8 @@ def __init__(self, master, studio, **cnf): self.bindings.on_item_delete(self.delete_item) self.bindings.pack(fill="both", expand=True) + self._multimap = {} + self._add = Button( self._header, **self.style.button, width=25, height=25, image=get_icon_image("add", 15, 15) @@ -192,6 +191,9 @@ def __init__(self, master, studio, **cnf): self._empty_frame = Label(self.bindings, **self.style.text_passive) self._show_empty(self.NO_SELECTION_MSG) + self.studio.bind("<>", self._on_select, "+") + self._suppress_change = False + def _show_empty(self, message): self._empty_frame.place(x=0, y=0, relwidth=1, relheight=1) self._empty_frame["text"] = message @@ -199,38 +201,71 @@ def _show_empty(self, message): def _remove_empty(self): self._empty_frame.place_forget() + def _enforce_event_map(self, widget): + if not hasattr(widget, "_event_map_"): + setattr(widget, "_event_map_", {}) + def add_new(self, *_): - if self.studio.selected is None: + if not self.studio.selection: return self._remove_empty() + + target_widget = self.studio.selection[0] 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 + target_widget._event_map_[new_binding.id] = new_binding + self._multimap[new_binding.id] = [(new_binding.id, target_widget)] + + for widget in self.studio.selection[1:]: + self._enforce_event_map(widget) + binding = make_event("<>", "", False) + widget._event_map_[binding.id] = binding + self._multimap[new_binding.id].append((binding.id, widget)) 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) + for ev_id, widget in self._multimap[item.id]: + widget._event_map_.pop(ev_id) + self._multimap.pop(item.id) self.bindings.remove(item.id) def modify_item(self, value: EventBinding): - widget = self.studio.selected - widget._event_map_[value.id] = value + if self._suppress_change: + return + for ev_id, widget in self._multimap[value.id]: + widget._event_map_[ev_id] = EventBinding(ev_id, value.sequence, value.handler, value.add) - def on_select(self, widget): - if widget is None: + def _on_select(self, _): + if not self.studio.selection: self._show_empty(self.NO_SELECTION_MSG) return self._remove_empty() - bindings = getattr(widget, "_event_map_", {}) - values = bindings.values() + bindings = [] + self._multimap.clear() + target_widget = self.studio.selection[0] + self._enforce_event_map(target_widget) + + for binding in target_widget._event_map_.values(): + is_common = True + ids = [] + for widget in self.studio.selection[1:]: + self._enforce_event_map(widget) + for b in widget._event_map_.values(): + if event_equal(binding, b): + ids.append((b.id, widget)) + break + else: + is_common = False + break + if is_common: + bindings.append(binding) + ids.append((binding.id, target_widget)) + self._multimap[binding.id] = ids + self.bindings.clear() - self.bindings.add(*values) - if not values: + self._suppress_change = True + self.bindings.add(*bindings) + self._suppress_change = False + if not bindings: self._show_empty(self.NO_EVENT_MSG) def start_search(self, *_): diff --git a/studio/lib/events.py b/studio/lib/events.py index 04233e8..92a6bc7 100644 --- a/studio/lib/events.py +++ b/studio/lib/events.py @@ -4,6 +4,10 @@ EventBinding = namedtuple("EventBinding", ["id", "sequence", "handler", "add"]) +def event_equal(event1, event2): + return event1.sequence == event2.sequence and event1.handler == event2.handler and event1.add == event2.add + + def make_event(*args, **kwargs): return EventBinding(generate_id(), *args, **kwargs) From e9ed6aedde14eb64fb8bda81fb6f1601012bccc9 Mon Sep 17 00:00:00 2001 From: ObaraEmmanuel Date: Sat, 26 Aug 2023 21:33:18 +0300 Subject: [PATCH 04/15] Implement multiselect in menu and canvas tool --- studio/feature/components.py | 2 +- studio/feature/design.py | 4 +++- studio/feature/stylepane.py | 4 ++-- studio/lib/legacy.py | 1 + studio/lib/pseudo.py | 6 +++++- studio/main.py | 17 ++++++----------- studio/tools/__init__.py | 19 ++++++++----------- studio/tools/_base.py | 12 +++++------- studio/tools/canvas.py | 36 ++++++++++++++++++++++-------------- studio/tools/menus.py | 19 ++++++++++--------- 10 files changed, 63 insertions(+), 57 deletions(-) diff --git a/studio/feature/components.py b/studio/feature/components.py index 67be57c..c0759b5 100644 --- a/studio/feature/components.py +++ b/studio/feature/components.py @@ -439,7 +439,7 @@ def render_groups(self): def render_extern_groups(self): for group in self._extern_groups: - if all(group.supports(w) for w in self.studio.widgets): + if self.studio.selection and all(group.supports(w) for w in self.studio.selection): self.add_selector(group.selector) else: self.remove_selector(group.selector) diff --git a/studio/feature/design.py b/studio/feature/design.py index b3c6449..4f99ea8 100644 --- a/studio/feature/design.py +++ b/studio/feature/design.py @@ -515,7 +515,7 @@ def _attach(self, obj): self._shortcut_mgr.bind_widget(obj) def show_menu(self, event, obj=None): - if not self._selected: + if obj and obj not in self._selected: self.studio.selection.set(obj) MenuUtils.popup(event, self._context_menu) @@ -644,6 +644,8 @@ def restore(self, widgets, restore_points, containers): for container, widget, restore_point in zip(containers, widgets, restore_points): container.restore_widget(widget, restore_point) self._replace_all(widget) + if self.root_obj in widgets: + self._show_empty(False) self.studio.on_restore(widgets) def _replace_all(self, widget): diff --git a/studio/feature/stylepane.py b/studio/feature/stylepane.py index baaac60..0252d11 100644 --- a/studio/feature/stylepane.py +++ b/studio/feature/stylepane.py @@ -288,7 +288,7 @@ def __init__(self, master, pane, **cnf): self._prev_classes = set() def get_definition(self): - if all(isinstance(widget, PseudoWidget) for widget in self.widgets): + if self.widgets and all(isinstance(widget, PseudoWidget) for widget in self.widgets): return get_combined_properties(self.widgets) return {} @@ -375,7 +375,7 @@ def _update_index(self): self._index.set(self.widgets[0].grid_info()["column"]) def on_widgets_change(self): - if all(self.is_grid(widget) for widget in self.widgets): + if self.widgets and all(self.is_grid(widget) for widget in self.widgets): super().on_widgets_change() self._index._editor.set_def(self._get_index_def()) self._update_index() diff --git a/studio/lib/legacy.py b/studio/lib/legacy.py index 7369280..2e3b29a 100644 --- a/studio/lib/legacy.py +++ b/studio/lib/legacy.py @@ -33,6 +33,7 @@ class Canvas(PseudoWidget, tk.Canvas): group = Groups.container icon = "paint" impl = tk.Canvas + allow_direct_move = False def __init__(self, master, id_): super().__init__(master) diff --git a/studio/lib/pseudo.py b/studio/lib/pseudo.py index 50827b9..3ec11d2 100644 --- a/studio/lib/pseudo.py +++ b/studio/lib/pseudo.py @@ -100,6 +100,7 @@ class PseudoWidget: icon = "play" impl = None is_toplevel = False + allow_direct_move = True # special handlers (intercepts) for attributes that need additional processing # to interface with the studio easily _intercepts = { @@ -137,7 +138,9 @@ def setup_widget(self): self.bind("", self._on_press, add='+') self.bind("", self._on_release, add='+') - self.bind("", self._on_drag, add='+') + + if self.allow_direct_move: + self.bind("", self._on_drag, add='+') self._active = False @@ -354,6 +357,7 @@ def __repr__(self): class Container(PseudoWidget): LAYOUTS = layouts.layouts + allow_direct_move = False def setup_widget(self): self.parent = self.designer = self._get_designer() diff --git a/studio/main.py b/studio/main.py index c3c3875..0e64662 100644 --- a/studio/main.py +++ b/studio/main.py @@ -257,7 +257,7 @@ def __init__(self, master=None, **cnf): self.style_pane = self.get_feature(StylePane) # initialize tools with everything ready - # self.tool_manager.initialize() + self.tool_manager.initialize() self._ignore_tab_status = False self._startup() @@ -634,8 +634,7 @@ def add(self, widget, parent=None): for feature in self.features: feature.on_widget_add(widget, parent) - # TODO refactor tools - # self.tool_manager.on_widget_add(widget, parent) + self.tool_manager.on_widget_add(widget, parent) def widgets_modified(self, widgets, source=None): for feature in self.features: @@ -644,15 +643,13 @@ def widgets_modified(self, widgets, source=None): if self.designer and self.designer != source: self.designer.on_widgets_change(widgets) - # TODO refactor tools - # self.tool_manager.on_widget_change(widgets) + self.tool_manager.on_widgets_change(widgets) def widgets_layout_changed(self, widgets): for feature in self.features: feature.on_widgets_layout_change(widgets) - # TODO refactor tools - # self.tool_manager.on_widget_layout_change(widgets) + self.tool_manager.on_widgets_layout_change(widgets) def make_clipboard(self, widgets): bounds = geometry.overall_bounds([w.get_bounds() for w in widgets]) @@ -684,8 +681,7 @@ def delete(self, widgets=None, source=None): for feature in self.features: feature.on_widgets_delete(widgets) - # TODO refactor tools - # self.tool_manager.on_widget_delete(widgets) + self.tool_manager.on_widgets_delete(widgets) def cut(self, widgets=None, source=None): if not self.designer: @@ -704,8 +700,7 @@ def cut(self, widgets=None, source=None): for feature in self.features: feature.on_widgets_delete(widgets, True) - # TODO refactor tools - # self.tool_manager.on_widget_delete(widgets) + self.tool_manager.on_widgets_delete(widgets) def duplicate(self): if self.designer and self.selection: diff --git a/studio/tools/__init__.py b/studio/tools/__init__.py index 7a3c301..e893d87 100644 --- a/studio/tools/__init__.py +++ b/studio/tools/__init__.py @@ -9,7 +9,7 @@ TOOLS = ( MenuTool, - CanvasTool + CanvasTool, ) @@ -52,7 +52,7 @@ def get_tool_menu(self, hide_unsupported=True): else: template = template[0] templates += ( - manipulator(partial(tool.supports, self.studio.selected), template), + manipulator(partial(tool.supports, self.studio.selection), template), ) # prepend a separator for context menus if templates and hide_unsupported: @@ -86,11 +86,8 @@ def dispatch(self, action, *args): for tool in self._tools: getattr(tool, action)(*args) - def on_select(self, widget): - self.dispatch("on_select", widget) - - def on_widget_delete(self, widget): - self.dispatch("on_widget_delete", widget) + def on_widgets_delete(self, widgets): + self.dispatch("on_widgets_delete", widgets) def on_app_close(self): for tool in self._tools: @@ -105,11 +102,11 @@ def on_session_clear(self): def on_widget_add(self, widget, parent): self.dispatch("on_widget_add", widget, parent) - def on_widget_change(self, old_widget, new_widget): - self.dispatch("on_widget_change", old_widget, new_widget) + def on_widgets_change(self, widgets): + self.dispatch("on_widgets_change", widgets) - def on_widget_layout_change(self, widget): - self.dispatch("on_widget_layout_change", widget) + def on_widgets_layout_change(self, widgets): + self.dispatch("on_widgets_layout_change", widgets) def on_context_switch(self): self.dispatch("on_context_switch") diff --git a/studio/tools/_base.py b/studio/tools/_base.py index 0ed1cb6..95a0ee0 100644 --- a/studio/tools/_base.py +++ b/studio/tools/_base.py @@ -24,18 +24,16 @@ def get_menu(self, studio): # default behaviour is to return an empty template return () - def supports(self, widget): + def supports(self, widgets=None): """ Checks whether the tool can work on a given widget. This information is useful for the studio to allow it render dropdown menus correctly - :param widget: A tk Widget to be checked + :param widgets: A list of tk Widgets to be checked, if None, current studio selection is used :return: True if tool can work on the widget otherwise false """ - - def on_select(self, widget): pass - def on_widget_delete(self, widget): + def on_widgets_delete(self, widgets): pass def on_app_close(self): @@ -47,10 +45,10 @@ def on_session_clear(self): def on_widget_add(self, widget, parent): pass - def on_widget_change(self, old_widget, new_widget): + def on_widgets_change(self, widgets): pass - def on_widget_layout_change(self, widget): + def on_widgets_layout_change(self, widgets): pass def on_context_switch(self): diff --git a/studio/tools/canvas.py b/studio/tools/canvas.py index aac99f7..267055b 100644 --- a/studio/tools/canvas.py +++ b/studio/tools/canvas.py @@ -580,8 +580,8 @@ def cv_items(self): # selected canvas items return self.tool.selected_items - def supports_widget(self, widget): - return isinstance(widget, Canvas) + def supports_widgets(self): + return len(self.widgets) == 1 and isinstance(self.widgets[0], Canvas) def can_optimize(self): # probably needs a rethink if we consider definition overrides @@ -604,10 +604,10 @@ def compute_prop_keys(self): # id cannot be set for multi-selected items self.prop_keys.remove('id') - def on_widget_change(self, widget): + def on_widgets_change(self): self._prev_prop_keys = self.prop_keys self.compute_prop_keys() - super(CanvasStyleGroup, self).on_widget_change(widget) + super(CanvasStyleGroup, self).on_widgets_change() self.style_pane.remove_loading() def _get_prop(self, prop, widget): @@ -621,13 +621,13 @@ def _get_key(self, widget, prop): def _get_action_data(self, widget, prop): return {item: {prop: item.cget(prop)} for item in self.cv_items} - def _apply_action(self, prop, value, widget, data): + def _apply_action(self, prop, value, widgets, data): for item in data: item.configure(data[item]) if item._controller: item._controller.update() - if self.tool.canvas == widget: - self.on_widget_change(widget) + if self.tool.canvas == widgets[0]: + self.on_widgets_change() self.tool.on_items_modified(data.keys()) def _set_prop(self, prop, value, widget): @@ -875,6 +875,8 @@ def __init__(self, studio, manager): ), ), self.studio, self.studio.style) + self.studio.bind("<>", self.on_select, "+") + @property def _ids(self): return [item.name for item_set in self.items for item in item_set._cv_items] @@ -1154,12 +1156,12 @@ def remove_controller(self, item): def selection_changed(self): # called when canvas item selection changes - self.style_group.on_widget_change(self.canvas) + self.style_group.on_widgets_change() def _update_selection(self, canvas): # update selections from the canvas tree if canvas != self.canvas: - self.studio.select(canvas) + self.studio.selection.set(canvas) # call to studio should cause canvas to be selected assert self.canvas == canvas selected = set(self.selected_items) @@ -1226,7 +1228,12 @@ def select_item(self, item, multi=False): self.selection_changed() - def on_select(self, widget): + def on_select(self, _): + if len(self.studio.selection) == 1: + widget = self.studio.selection[0] + else: + widget = None + if self.canvas == widget: return if self.canvas is not None: @@ -1271,10 +1278,11 @@ def on_items_modified(self, items): for item in items: item.node.widget_modified(item) - def on_widget_delete(self, widget): - if isinstance(widget, Canvas): - if widget in self.items: - self.items.remove(widget) + def on_widgets_delete(self, widgets): + for widget in widgets: + if isinstance(widget, Canvas): + if widget in self.items: + self.items.remove(widget) def propagate_move(self, delta_x, delta_y, source=None): for item in self.selected_items: diff --git a/studio/tools/menus.py b/studio/tools/menus.py index 96241ab..d29083b 100644 --- a/studio/tools/menus.py +++ b/studio/tools/menus.py @@ -387,21 +387,22 @@ def restore(self, widget): widget.configure(menu=self._deleted.get(widget)) self._deleted.pop(widget) - def supports(self, widget): - if widget is None: - return widget - return 'menu' in widget.keys() + def supports(self, widgets=None): + widgets = widgets or self.studio.selection + if not widgets: + return False + return all('menu' in w.keys() for w in widgets) def get_menu(self, studio): icon = get_icon_image return ( - ('command', 'Edit', icon('edit', 14, 14), lambda: self.edit(studio.selected), {}), + ('command', 'Edit', icon('edit', 14, 14), lambda: self.edit(studio.selection[0]), {}), EnableIf( - lambda: studio.selected and studio.selected['menu'] != '', - ('command', 'Remove', icon('delete', 14, 14), lambda: self.remove(studio.selected), {})), + lambda: studio.selection and studio.selection[0]['menu'] != '', + ('command', 'Remove', icon('delete', 14, 14), lambda: self.remove(studio.selection[0]), {})), EnableIf( - lambda: studio.selected and studio.selected in self._deleted, - ('command', 'Restore', icon('undo', 14, 14), lambda: self.restore(studio.selected), {})), + lambda: studio.selection and studio.selection[0] in self._deleted, + ('command', 'Restore', icon('undo', 14, 14), lambda: self.restore(studio.selection[0]), {})), EnableIf( lambda: MenuEditor._tool_map, ('command', 'Close all editors', icon('close', 14, 14), self.close_editors, {})) From 9ea3d55524f44548f5917a0415b308654890c9e8 Mon Sep 17 00:00:00 2001 From: ObaraEmmanuel Date: Sat, 9 Sep 2023 02:26:26 +0300 Subject: [PATCH 05/15] Implement widget stacking control --- studio/feature/_base.py | 7 ++++ studio/feature/design.py | 66 +++++++++++++++++++++++++++++++-- studio/lib/handles.py | 7 ++++ studio/lib/layouts.py | 80 +++++++++++++++++++++++----------------- studio/lib/pseudo.py | 19 +++++++++- studio/main.py | 37 ++++++++++++++++++- studio/tools/__init__.py | 3 ++ studio/tools/_base.py | 3 ++ studio/ui/widgets.py | 5 +++ 9 files changed, 188 insertions(+), 39 deletions(-) diff --git a/studio/feature/_base.py b/studio/feature/_base.py index d3410f8..396c075 100644 --- a/studio/feature/_base.py +++ b/studio/feature/_base.py @@ -134,6 +134,13 @@ def on_widgets_layout_change(self, widgets): """ pass + def on_widgets_reorder(self, indices): + """ + Called when the widgets in the designer are reordered within their parent. + Used to change stacking order of widgets + """ + pass + def on_widget_add(self, widget, parent): """ Called when a new widget is added to the designer diff --git a/studio/feature/design.py b/studio/feature/design.py index 4f99ea8..f74fdeb 100644 --- a/studio/feature/design.py +++ b/studio/feature/design.py @@ -55,7 +55,7 @@ def add_widget(self, widget, bounds=None, **kwargs): else: x1, y1, x2, y2 = self.container.canvas_bounds(bounds) self.container.place_child(widget, x=x1, y=y1, width=x2 - x1, height=y2 - y1) - self.children.append(widget) + self._insert(widget, widget.prev_stack_index if widget.layout == self.container else None) def remove_widget(self, widget): super().remove_widget(widget) @@ -68,7 +68,7 @@ def apply(self, prop, value, widget): def restore_widget(self, widget, data=None): data = widget.recent_layout_info if data is None else data - self.children.append(widget) + self._insert(widget, widget.prev_stack_index if widget.layout == self.container else None) widget.layout = self.container widget.level = self.level + 1 self.container.place_child(widget, **data.get("info", {})) @@ -333,6 +333,7 @@ def _load_design(self, path, progress=None): 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)) + raise e # MessageDialog.show_error(parent=self.studio, title='Error loading design', message=str(e)) finally: if progress: @@ -762,10 +763,10 @@ def _text_change(self): def _show_text_editor(self, widget): if any("text" not in w.keys() for w in self.selected): return - 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.lift(widget) self._text_editor.clear() self._text_editor.focus_set() # suppress change event while we set initial value @@ -792,6 +793,65 @@ def _collect_text_config(self, widget): def _text_hide(self, *_): self._text_editor.place_forget() + def send_back(self, steps=0): + if not (self.studio.selection and self.studio.selection.is_same_parent()): + return + + child_list = next(iter(self.studio.selection)).layout._children + widgets = sorted(self.studio.selection, key=child_list.index) + + if steps == 0: + self._update_stacking({w: index for index, w in enumerate(widgets)}) + else: + self._update_stacking({w: max(0, child_list.index(w) - steps) for w in widgets}) + + def bring_front(self, steps=0): + if not (self.studio.selection and self.studio.selection.is_same_parent()): + return + + child_list = next(iter(self.studio.selection)).layout._children + widgets = sorted(self.studio.selection, key=child_list.index) + + end = len(child_list) - 1 + if steps == 0: + self._update_stacking({w: end for w in widgets}) + else: + self._update_stacking({w: min(end, child_list.index(w) + steps) for w in widgets}) + + def _update_stacking(self, indices, silently=False): + if not indices: + return + + child_list = next(iter(indices)).layout._children + # reorder child list based on indices + for widget in indices: + child_list.remove(widget) + for widget, index in indices.items(): + child_list.insert(index, widget) + + prev_data = {} + data = {} + for index, widget in enumerate(child_list): + if widget.prev_stack_index != index: + prev_data[widget] = widget.prev_stack_index + data[widget] = index + widget.prev_stack_index = index + if index > 0: + widget.lift(child_list[index - 1]) + else: + widget.lift(widget.layout.body) + + prev_data = dict(sorted(prev_data.items(), key=lambda x: x[1])) + + if not silently and prev_data != data: + self.studio.new_action(Action( + lambda _: self._update_stacking(prev_data, True), + lambda _: self._update_stacking(data, True) + )) + + def on_widgets_reorder(self, indices): + pass + def on_widgets_change(self, widgets): pass diff --git a/studio/lib/handles.py b/studio/lib/handles.py index 1ee021f..9e41ffb 100644 --- a/studio/lib/handles.py +++ b/studio/lib/handles.py @@ -106,6 +106,12 @@ def on_dot_move(self, direction, delta): def widget_config_changed(self): pass + def lift(self): + for dot in self.dots: + dot.lift() + for edge in self.edges: + edge.lift() + def redraw(self): raise NotImplementedError @@ -154,6 +160,7 @@ def acquire(cls, widget, master=None): else: obj = cls._pool[(cls, master)].pop() obj.widget = widget + obj.lift() return obj diff --git a/studio/lib/layouts.py b/studio/lib/layouts.py index bd544dd..e6679c4 100644 --- a/studio/lib/layouts.py +++ b/studio/lib/layouts.py @@ -57,6 +57,7 @@ class BaseLayoutStrategy: realtime_support = False # dictates whether strategy supports realtime updates to its values, most do not dimensions_in_px = False # Whether to use pixel units for width and height allow_resize = False # Whether to allow resizing of widgets + stacking_support = True # Whether to allow modification of stacking order def __init__(self, container): self.parent = container.parent @@ -80,12 +81,25 @@ def bounds(self): def add_widget(self, widget, bounds=None, **kwargs): widget.level = self.level + 1 widget.layout = self.container - try: - widget.lift(self.container.body) - except Exception: - pass self.container.clear_highlight() + def _insert(self, widget, index=None): + if index is None: + self.children.append(widget) + else: + self.children.insert(index, widget) + self._update_stacking() + + if widget.prev_stack_index is None: + widget.prev_stack_index = len(self.children) - 1 + + def _update_stacking(self): + for index, widget in enumerate(self.children): + if index > 0: + widget.lift(self.children[index - 1]) + else: + widget.lift(self.container.body) + def widget_released(self, widget): pass @@ -99,13 +113,14 @@ def move_widget(self, widget, bounds): self.remove_widget(widget) if widget not in self.temporal_children: self.temporal_children.append(widget) + if widget.layout != self.container: + # if widget was originally in a different layout + # Lift widget above the last child of layout if any otherwise lift above the layout + widget.lift((self.children[-1:] or [self.container.body])[0]) + widget.level = self.level + 1 widget.layout = self.container - # Lift widget above the last child of layout if any otherwise lift above the layout - try: - widget.lift((self.children[-1:] or [self.container.body])[0]) - except Exception: - pass + self._move(widget, bounds) def end_move(self): @@ -250,7 +265,7 @@ def add_widget(self, widget, bounds=None, **kwargs): self.move_widget(widget, bounds) kwargs['in'] = self.container.body widget.place_configure(**kwargs) - self.children.append(widget) + self._insert(widget, widget.prev_stack_index if widget.layout == self.container else None) def _info_with_delta(self, widget, direction, delta): info = self.info(widget) @@ -287,14 +302,11 @@ def config_widget(self, widget, config): def restore_widget(self, widget, data=None): data = widget.recent_layout_info if data is None else data - self.children.append(widget) + self._insert(widget, widget.prev_stack_index if widget.layout == self.container else None) + widget.layout = self.container widget.level = self.level + 1 widget.place_configure(**data.get("info", {})) - try: - widget.lift((self.children[-1:] or [self.container.body])[0]) - except Exception: - pass def get_restore(self, widget): return { @@ -313,7 +325,7 @@ def copy_layout(self, widget, from_): info = from_.place_info() info["in_"] = self.container.body widget.place(**info) - self.children.append(widget) + self._insert(widget) super().add_widget(widget, (0, 0, 0, 0)) @@ -352,6 +364,7 @@ class PackLayoutStrategy(BaseLayoutStrategy): name = "pack" icon = "frame" manager = "pack" + stacking_support = False def __init__(self, container): super().__init__(container) @@ -368,7 +381,7 @@ def add_widget(self, widget, bounds=None, **kwargs): elif self._orientation == self.VERTICAL: widget.pack(in_=self.container.body, side="left") self.config_widget(widget, kwargs) - self.children.append(widget) + self._insert(widget) def redraw(self): for widget in self.children: @@ -464,7 +477,7 @@ def copy_layout(self, widget, from_): info = from_.pack_info() info["in_"] = self.container.body widget.pack(**info) - self.children.append(widget) + self._insert(widget) super().add_widget(widget, (0, 0, 0, 0)) def clear_all(self): @@ -509,7 +522,7 @@ def add_widget(self, widget, bounds=None, **kwargs): super().add_widget(widget, bounds, **kwargs) width, height = geometry.dimensions(bounds) self.attach(widget, width, height) - self.children.append(widget) + self._insert(widget) def attach(self, widget, width, height): y = self.get_last() @@ -525,7 +538,7 @@ def redraw(self, widget): child.place_forget() for child in temp: self.attach(child, *dimensions[child]) - self._children.append(child) + self._insert(child) def resize_widget(self, widget, direction, delta): widget.update_idletasks() @@ -541,7 +554,7 @@ def remove_widget(self, widget): self._children = self._children[:from_] for child in temp: self.attach(child, *dimensions[child]) - self._children.append(child) + self._insert(child) def clear_children(self): for child in self.children: @@ -653,11 +666,10 @@ def get_restore(self, widget): def restore_widget(self, widget, data=None): data = widget.recent_layout_info if data is None else data - self.children.append(widget) + self._insert(widget, widget.prev_stack_index if widget.layout == self.container else None) widget.level = self.level + 1 widget.layout = self.container widget.grid(in_=self.container.body) - widget.lift() self.config_widget(widget, data.get("info", {})) def react_to(self, bounds): @@ -746,7 +758,7 @@ def add_widget(self, widget, bounds=None, **kwargs): else: widget.grid(in_=self.container.body) self.config_widget(widget, kwargs) - self.children.append(widget) + self._insert(widget, widget.prev_stack_index if widget.layout == self.container else None) self.clear_indicators() def _widget_at(self, row, column): @@ -836,7 +848,7 @@ def copy_layout(self, widget, from_): info = from_.grid_info() info["in_"] = self.container.body widget.grid(**info) - self.children.append(widget) + self._insert(widget) super().add_widget(widget, (0, 0, 0, 0)) def clear_all(self): @@ -847,9 +859,6 @@ def clear_all(self): class TabLayoutStrategy(BaseLayoutStrategy): # TODO Extend support for side specific padding - name = "TabLayout" - icon = "notebook" - manager = "tab" DEFINITION = { "text": { "display_name": "tab text", @@ -897,6 +906,10 @@ class TabLayoutStrategy(BaseLayoutStrategy): "default": 'normal' } } + name = "TabLayout" + icon = "notebook" + manager = "tab" + stacking_support = False def __init__(self, master): super().__init__(master) @@ -940,7 +953,7 @@ def add_widget(self, widget, bounds=None, **kwargs): super().add_widget(widget, bounds, **kwargs) self.container.body.add(widget, text=widget.id) self.container.body.tab(widget, **kwargs) - self.children.append(widget) + self._insert(widget) def remove_widget(self, widget): super().remove_widget(widget) @@ -975,9 +988,6 @@ def clear_all(self): class PanedLayoutStrategy(BaseLayoutStrategy): - name = "PanedLayout" - icon = "flip_horizontal" - manager = "pane" DEFINITION = { **BaseLayoutStrategy.DEFINITION, # width and height definition "padx": COMMON_DEFINITION.get("padx"), @@ -1009,6 +1019,10 @@ class PanedLayoutStrategy(BaseLayoutStrategy): "name": "minsize", } } + name = "PanedLayout" + icon = "flip_horizontal" + manager = "pane" + stacking_support = False def get_restore(self, widget): return { @@ -1050,7 +1064,7 @@ def add_widget(self, widget, bounds=None, **kwargs): super().add_widget(widget, bounds, **kwargs) self.container.body.add(widget) self._config(widget, **kwargs) - self.children.append(widget) + self._insert(widget) def _config(self, widget, **kwargs): if not kwargs: diff --git a/studio/lib/pseudo.py b/studio/lib/pseudo.py index 3ec11d2..4aebe91 100644 --- a/studio/lib/pseudo.py +++ b/studio/lib/pseudo.py @@ -143,6 +143,7 @@ def setup_widget(self): self.bind("", self._on_drag, add='+') self._active = False + self.prev_stack_index = None def set_name(self, name): pass @@ -155,6 +156,20 @@ def get_bounds(self): y2 = self.winfo_height() + y1 return x1, y1, x2, y2 + def lift(self, above_this): + if isinstance(above_this, Container): + # first lift above container if possible + try: + super().lift(above_this.body) + except tkinter.TclError: + pass + + # then lift above highest child if any + if above_this._children: + super().lift(above_this._children[-1]) + else: + super().lift(above_this) + def _on_press(self, event): if not self._handle: return @@ -380,8 +395,8 @@ def lift(self, above_this): super().lift(above_this) if self.body != self: self.body.lift(self) - for child in self._children: - child.lift(self.body) + if self._children: + self.layout_strategy._update_stacking() def react(self, x, y): self.designer.set_active_container(self) diff --git a/studio/main.py b/studio/main.py index 0e64662..2e8bb49 100644 --- a/studio/main.py +++ b/studio/main.py @@ -22,7 +22,7 @@ from hoverset.platform import platform_is, MAC from hoverset.ui.dialogs import MessageDialog from hoverset.ui.icons import get_icon_image -from hoverset.ui.menu import MenuUtils, EnableIf, dynamic_menu, LoadLater +from hoverset.ui.menu import MenuUtils, EnableIf, dynamic_menu, LoadLater, ShowIf from hoverset.ui.widgets import ( Application, Frame, PanedWindow, Button, ActionNotifier, TabView, Label @@ -134,6 +134,17 @@ def __init__(self, master=None, **cnf): ), ("command", "cut", icon("cut", 14, 14), actions.get('STUDIO_CUT'), {}), ("separator",), + ShowIf( + lambda: self.selection[0].layout.layout_strategy.stacking_support, + EnableIf( + lambda: self.selection.is_same_parent(), + ("command", "send to back", icon("send_to_back", 14, 14), actions.get('STUDIO_BACK'), {}), + ("command", "bring to front", icon("bring_to_front", 14, 14), actions.get('STUDIO_FRONT'), {}), + ("command", "back one step", icon("send_to_back", 14, 14), actions.get('STUDIO_BACK_1'), {}), + ("command", "forward one step", icon("bring_to_front", 14, 14), actions.get('STUDIO_FRONT_1'), {}), + ), + ), + ("separator",), ("command", "delete", icon("delete", 14, 14), actions.get('STUDIO_DELETE'), {}), ),) @@ -426,6 +437,15 @@ def install_status_widget(self, widget_class, *args, **kwargs): widget.pack(side='right', padx=2, fill='y') return widget + def send_back(self, steps=0): + if self.designer and self.selection: + self.designer.send_back(steps) + + def bring_front(self, steps=0): + if self.designer and self.selection: + self.designer.bring_front(steps) + + def get_pane_info(self, pane): return self._panes.get(pane, [self._right, self._right_bar]) @@ -651,6 +671,13 @@ def widgets_layout_changed(self, widgets): self.tool_manager.on_widgets_layout_change(widgets) + def reorder_widgets(self, indices, source=None): + for feature in self.features: + if feature != source: + feature.on_widgets_reorder(indices) + + self.tool_manager.on_widgets_reorder(indices) + def make_clipboard(self, widgets): bounds = geometry.overall_bounds([w.get_bounds() for w in widgets]) data = [] @@ -884,6 +911,14 @@ def _register_actions(self): 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.send_back, 'STUDIO_BACK', 'Send selected widgets to back', 'studio', CharKey(']')), + routine(self.bring_front, 'STUDIO_FRONT', 'Bring selected widgets to front', 'studio', CharKey('[')), + routine( + lambda: self.send_back(1), + 'STUDIO_BACK_1', 'Move selected widgets back one step', 'studio', CTRL + CharKey(']')), + routine( + lambda: self.bring_front(1), + 'STUDIO_FRONT_1', 'Bring selected widgets up one step', 'studio', CTRL + CharKey('[')), # ----------------------------- 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')), diff --git a/studio/tools/__init__.py b/studio/tools/__init__.py index e893d87..79ebe2f 100644 --- a/studio/tools/__init__.py +++ b/studio/tools/__init__.py @@ -108,6 +108,9 @@ def on_widgets_change(self, widgets): def on_widgets_layout_change(self, widgets): self.dispatch("on_widgets_layout_change", widgets) + def on_widgets_reorder(self, indices): + self.dispatch("on_widgets_reorder", indices) + def on_context_switch(self): self.dispatch("on_context_switch") diff --git a/studio/tools/_base.py b/studio/tools/_base.py index 95a0ee0..2e01aeb 100644 --- a/studio/tools/_base.py +++ b/studio/tools/_base.py @@ -51,6 +51,9 @@ def on_widgets_change(self, widgets): def on_widgets_layout_change(self, widgets): pass + def on_widgets_reorder(self, indices): + pass + def on_context_switch(self): pass diff --git a/studio/ui/widgets.py b/studio/ui/widgets.py index 43bc30e..d0b1e37 100644 --- a/studio/ui/widgets.py +++ b/studio/ui/widgets.py @@ -318,6 +318,11 @@ def forget_child(self, child): self._frame.delete(self._child_map[child]) self._child_map.pop(child) + def lift_child(self, child, above_this=None): + above_this = self._child_map[above_this] if above_this in self._child_map else 'all' + if self._child_map.get(child) is not None: + self._frame.tag_raise(self._child_map[child], above_this) + def configure(self, cnf=None, **kw): self._frame.configure(cnf, **kw) return super().configure(cnf, **kw) From 6db7f0d597ad1c70d390c8fb41f786dfff865c61 Mon Sep 17 00:00:00 2001 From: ObaraEmmanuel Date: Thu, 14 Sep 2023 01:07:20 +0300 Subject: [PATCH 06/15] Fix stacking algorithm and hotkey binding --- studio/feature/design.py | 10 ++++++++-- studio/lib/legacy.py | 6 +++++- studio/lib/properties.py | 19 +++---------------- studio/main.py | 12 ++++++------ 4 files changed, 22 insertions(+), 25 deletions(-) diff --git a/studio/feature/design.py b/studio/feature/design.py index f74fdeb..64d403b 100644 --- a/studio/feature/design.py +++ b/studio/feature/design.py @@ -824,9 +824,15 @@ def _update_stacking(self, indices, silently=False): child_list = next(iter(indices)).layout._children # reorder child list based on indices - for widget in indices: + + indices = sorted( + indices.items(), + key=lambda x: (x[1], -child_list.index(x[0]) if x[1] == 0 else child_list.index(x[0])) + ) + for widget, _ in indices: child_list.remove(widget) - for widget, index in indices.items(): + + for widget, index in indices: child_list.insert(index, widget) prev_data = {} diff --git a/studio/lib/legacy.py b/studio/lib/legacy.py index 2e3b29a..27afa2e 100644 --- a/studio/lib/legacy.py +++ b/studio/lib/legacy.py @@ -1,3 +1,4 @@ +import tkinter import tkinter as tk from studio.lib.pseudo import ( @@ -41,7 +42,10 @@ def __init__(self, master, id_): self.setup_widget() def lift(self, above_this=None): - tk.Misc.lift(self, above_this) + try: + tk.Misc.lift(self, above_this) + except tkinter.TclError: + pass class Checkbutton(PseudoWidget, tk.Checkbutton): diff --git a/studio/lib/properties.py b/studio/lib/properties.py index 7462f3d..870a2c6 100644 --- a/studio/lib/properties.py +++ b/studio/lib/properties.py @@ -634,7 +634,8 @@ def get_properties(widget, extern_overrides=None): for prop in properties: definition = get_resolved(prop, overrides, PROPERTY_TABLE) if definition: - definition.update(value=widget.get_prop(prop)) + # TODO Remove this fix when no longer needed + definition.update(value=widget.get_prop(prop) if hasattr(widget, "get_prop") else widget[prop]) resolved_properties[prop] = definition return resolved_properties @@ -673,18 +674,4 @@ def get_combined_properties(widgets): # get all the properties for each widget properties = [widget.properties for widget in widgets] - # find the intersection of all the property names - common_properties = set.intersection(*[set(p.keys()) for p in properties]) - - # get the first definition for each common property - common_properties = {prop: properties[0][prop] for prop in common_properties} - - # check that the values are the same for each widget - for prop, definition in common_properties.items(): - for widget_properties in properties[1:]: - if widget_properties[prop]['value'] != definition['value']: - common_properties[prop]['value'] = '' - break - - return common_properties - + return combine_properties(properties) diff --git a/studio/main.py b/studio/main.py index 2e8bb49..b786ced 100644 --- a/studio/main.py +++ b/studio/main.py @@ -17,7 +17,7 @@ from formation.formats import get_file_types, get_file_extensions from hoverset.data import actions from hoverset.data.images import load_tk_image -from hoverset.data.keymap import ShortcutManager, CharKey, KeyMap, BlankKey +from hoverset.data.keymap import ShortcutManager, CharKey, KeyMap, BlankKey, Symbol from hoverset.data.utils import get_resource_path from hoverset.platform import platform_is, MAC from hoverset.ui.dialogs import MessageDialog @@ -135,7 +135,7 @@ def __init__(self, master=None, **cnf): ("command", "cut", icon("cut", 14, 14), actions.get('STUDIO_CUT'), {}), ("separator",), ShowIf( - lambda: self.selection[0].layout.layout_strategy.stacking_support, + lambda: self.selection and self.selection[0].layout.layout_strategy.stacking_support, EnableIf( lambda: self.selection.is_same_parent(), ("command", "send to back", icon("send_to_back", 14, 14), actions.get('STUDIO_BACK'), {}), @@ -911,14 +911,14 @@ def _register_actions(self): 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.send_back, 'STUDIO_BACK', 'Send selected widgets to back', 'studio', CharKey(']')), - routine(self.bring_front, 'STUDIO_FRONT', 'Bring selected widgets to front', 'studio', CharKey('[')), + routine(self.send_back, 'STUDIO_BACK', 'Send selected widgets to back', 'studio', Symbol(']')), + routine(self.bring_front, 'STUDIO_FRONT', 'Bring selected widgets to front', 'studio', Symbol('[')), routine( lambda: self.send_back(1), - 'STUDIO_BACK_1', 'Move selected widgets back one step', 'studio', CTRL + CharKey(']')), + 'STUDIO_BACK_1', 'Move selected widgets back one step', 'studio', CTRL + Symbol(']')), routine( lambda: self.bring_front(1), - 'STUDIO_FRONT_1', 'Bring selected widgets up one step', 'studio', CTRL + CharKey('[')), + 'STUDIO_FRONT_1', 'Bring selected widgets up one step', 'studio', CTRL + Symbol('[')), # ----------------------------- 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')), From 62fb7898b4b80062f7b373d181e2c26183e096a6 Mon Sep 17 00:00:00 2001 From: ObaraEmmanuel Date: Thu, 14 Sep 2023 01:09:57 +0300 Subject: [PATCH 07/15] Allow moving container widgets by holding shift key --- studio/lib/pseudo.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/studio/lib/pseudo.py b/studio/lib/pseudo.py index 4aebe91..6581de0 100644 --- a/studio/lib/pseudo.py +++ b/studio/lib/pseudo.py @@ -139,8 +139,7 @@ def setup_widget(self): self.bind("", self._on_press, add='+') self.bind("", self._on_release, add='+') - if self.allow_direct_move: - self.bind("", self._on_drag, add='+') + self.bind("", self._on_drag, add='+') self._active = False self.prev_stack_index = None @@ -186,6 +185,8 @@ def _on_release(self, _): def _on_drag(self, event): if not self._active or not event.state & EventMask.MOUSE_BUTTON_1: return + if not self.allow_direct_move and not event.state & EventMask.SHIFT: + return x, y = self._pos_fix self._pos_fix = (event.x_root, event.y_root) self.handle_resize('all', (event.x_root - x, event.y_root - y)) From fddbd6320aa21245641d0e35ebd0b40b8f1439b0 Mon Sep 17 00:00:00 2001 From: ObaraEmmanuel Date: Thu, 14 Sep 2023 01:10:21 +0300 Subject: [PATCH 08/15] Fix initial positioning of dialog windows --- hoverset/ui/dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hoverset/ui/dialogs.py b/hoverset/ui/dialogs.py index 33b745d..dc686e9 100644 --- a/hoverset/ui/dialogs.py +++ b/hoverset/ui/dialogs.py @@ -115,12 +115,12 @@ def __init__(self, master, render_routine=None, **kw): "SHOW_PROGRESS": self._show_progress, "BUILDER": self._builder # Allows building custom dialogs } + self.enable_centering() if render_routine in routines: # Completely custom dialogs routines[render_routine](**kw) # noqa elif render_routine is not None: render_routine(self) - self.enable_centering() self.value = None # quirk that prevents explicit window centering on linux for best results add = "+" if platform_is(WINDOWS, MAC) else None From fd4374d35de5bb2138cffedeaa18a9c4cc929cac Mon Sep 17 00:00:00 2001 From: ObaraEmmanuel Date: Thu, 21 Sep 2023 00:14:11 +0300 Subject: [PATCH 09/15] Fix grid row,column config and text option default --- studio/feature/stylepane.py | 8 ++++---- studio/lib/pseudo.py | 7 ++++++- studio/parsers/loader.py | 4 ++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/studio/feature/stylepane.py b/studio/feature/stylepane.py index 0252d11..1b53d7e 100644 --- a/studio/feature/stylepane.py +++ b/studio/feature/stylepane.py @@ -342,12 +342,12 @@ def _get_index_def(self): return definition def _get_prop(self, prop, widget): - info = widget.layout.columnconfigure(widget.grid_info()["column"]) + info = widget.layout.body.columnconfigure(widget.grid_info()["column"]) return info.get(prop) def _set_prop(self, prop, value, widget): column = int(widget.grid_info()["column"]) - widget.layout.columnconfigure(column, **{prop: value}) + widget.layout.body.columnconfigure(column, **{prop: value}) if not hasattr(widget.layout, "_column_conf"): widget.layout._column_conf = {column} else: @@ -396,12 +396,12 @@ def _get_index_def(self): return definition def _get_prop(self, prop, widget): - info = widget.layout.rowconfigure(widget.grid_info()["row"]) + info = widget.layout.body.rowconfigure(widget.grid_info()["row"]) return info.get(prop) def _set_prop(self, prop, value, widget): row = int(widget.grid_info()["row"]) - widget.layout.rowconfigure(row, **{prop: value}) + widget.layout.body.rowconfigure(row, **{prop: value}) if not hasattr(widget.layout, "_row_conf"): widget.layout._row_conf = {row} else: diff --git a/studio/lib/pseudo.py b/studio/lib/pseudo.py index 6581de0..6f7e7eb 100644 --- a/studio/lib/pseudo.py +++ b/studio/lib/pseudo.py @@ -112,6 +112,9 @@ class PseudoWidget: "variable": _VariableIntercept, "listvariable": _VariableIntercept } + _no_defaults = { + "text", + } def setup_widget(self): self.designer = self.master @@ -351,7 +354,9 @@ def get_altered_options(self): return {} options = self.properties # Get options whose values are different from their default values - return {opt: self.get_prop(opt) for opt in options if str(defaults.get(opt)) != str(self.get_prop(opt))} + return { + opt: self.get_prop(opt) for opt in options if opt in self._no_defaults or str(defaults.get(opt)) != str(self.get_prop(opt)) + } def get_method_defaults(self): return {} diff --git a/studio/parsers/loader.py b/studio/parsers/loader.py index 0259db5..2b6a14d 100644 --- a/studio/parsers/loader.py +++ b/studio/parsers/loader.py @@ -130,13 +130,13 @@ def load(cls, node, designer, parent, bounds=None): sub_attrib = dict(sub_node.attrib) if sub_attrib.get("column"): column = sub_attrib.pop("column") - obj.columnconfigure(column, sub_attrib) + obj.body.columnconfigure(column, sub_attrib) if not hasattr(obj, "_column_conf"): obj._column_conf = set() obj._column_conf.add(int(column)) elif sub_attrib.get("row"): row = sub_attrib.pop("row") - obj.rowconfigure(row, sub_attrib) + obj.body.rowconfigure(row, sub_attrib) if not hasattr(obj, "_row_conf"): obj._row_conf = set() obj._row_conf.add(int(row)) From b5cf2daee167cc91b4358eae3d62fa8618e09a38 Mon Sep 17 00:00:00 2001 From: ObaraEmmanuel Date: Thu, 21 Sep 2023 00:15:24 +0300 Subject: [PATCH 10/15] Fix closing panes on side --- studio/main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/studio/main.py b/studio/main.py index b786ced..96ed5eb 100644 --- a/studio/main.py +++ b/studio/main.py @@ -445,7 +445,6 @@ def bring_front(self, steps=0): if self.designer and self.selection: self.designer.bring_front(steps) - def get_pane_info(self, pane): return self._panes.get(pane, [self._right, self._right_bar]) @@ -455,7 +454,7 @@ def paste(self): def close_all_on_side(self, side): for feature in self.features: - if feature.side == side: + if feature._side.get() == 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() @@ -517,12 +516,13 @@ def install(self, feature) -> BaseFeature: return obj def show_all_windows(self): - for feature in self.features: + for feature in self.features: feature.maximize() def features_as_windows(self): for feature in self.features: - feature.open_as_window() + if feature.is_visible.get(): + feature.open_as_window() def features_as_docked(self): for feature in self.features: From ba717abcfc375c99491822f16d668b9fd1e818e6 Mon Sep 17 00:00:00 2001 From: ObaraEmmanuel Date: Thu, 21 Sep 2023 00:15:58 +0300 Subject: [PATCH 11/15] Fix undo of widget addition --- studio/feature/design.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/studio/feature/design.py b/studio/feature/design.py index 64d403b..fac5edf 100644 --- a/studio/feature/design.py +++ b/studio/feature/design.py @@ -585,8 +585,8 @@ def add(self, obj_class: PseudoWidget.__class__, x, y, **kwargs): 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) + 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 From 7a05b26bd3faaefbd189876768982d458794fbd9 Mon Sep 17 00:00:00 2001 From: ObaraEmmanuel Date: Thu, 21 Sep 2023 00:16:31 +0300 Subject: [PATCH 12/15] Use theme adaptable handle colors --- studio/lib/handles.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/studio/lib/handles.py b/studio/lib/handles.py index 9e41ffb..7d51287 100644 --- a/studio/lib/handles.py +++ b/studio/lib/handles.py @@ -42,9 +42,10 @@ def __init__(self, handle, direction): super().__init__(handle.master) self.direction = direction color = handle.master.style.colors["accent"] + border = handle.master.style.colors["primarydarkaccent"] self.config( width=6, height=6, bg=color, cursor=self._cursor_map[direction], - highlightthickness=1, highlightbackground="#000" + highlightthickness=1, highlightbackground=border ) self.handle = handle self.bind("", self.on_press) From f05c7ac0e11c224cdaa259b686dc5c05f5e8c6d7 Mon Sep 17 00:00:00 2001 From: ObaraEmmanuel Date: Mon, 25 Sep 2023 23:59:46 +0300 Subject: [PATCH 13/15] Designer stability improvements --- studio/feature/design.py | 61 ++++++++++++++++++++++++++++++---------- studio/lib/layouts.py | 27 ++++++++++-------- studio/ui/highlight.py | 2 +- 3 files changed, 62 insertions(+), 28 deletions(-) diff --git a/studio/feature/design.py b/studio/feature/design.py index fac5edf..64b96a0 100644 --- a/studio/feature/design.py +++ b/studio/feature/design.py @@ -46,15 +46,14 @@ def _move(self, widget, bounds): def add_widget(self, widget, bounds=None, **kwargs): super(PlaceLayoutStrategy, self).add_widget(widget, bounds=bounds, **kwargs) super(PlaceLayoutStrategy, self).remove_widget(widget) - if bounds is None: + if bounds: + self.move_widget(widget, bounds) + else: x = kwargs.get("x", 10) y = kwargs.get("y", 10) width = kwargs.get("width", 20) height = kwargs.get("height", 20) self.container.place_child(widget, x=x, y=y, width=width, height=height) - else: - x1, y1, x2, y2 = self.container.canvas_bounds(bounds) - self.container.place_child(widget, x=x1, y=y1, width=x2 - x1, height=y2 - y1) self._insert(widget, widget.prev_stack_index if widget.layout == self.container else None) def remove_widget(self, widget): @@ -182,6 +181,9 @@ def __init__(self, master, studio): "designer::frame_skip", self._update_throttling ) + self._sorted_containers = [] + self._realtime_layout_update = False + self._handle_active_data = None def clear_studio_bindings(self): for binding in self._studio_bindings: @@ -385,19 +387,40 @@ def on_motion(self, event): self._frame.canvasy(event.y) ) + def _on_handle_active_start(self, widget, direction): + self._handle_active_data = widget, direction + def _on_handle_active(self, widget, direction): if direction == "all": self._move_selection = self.studio.selection.siblings(widget) + self.current_container = self._move_selection[0].layout + self.current_container.show_highlight() + self._sorted_containers = sorted( + filter(lambda x: isinstance(x, Container) and x not in self._move_selection, self.objects), + key=lambda x: -x.level + ) self._all_bound = geometry.overall_bounds([w.get_bounds() for w in self._move_selection]) + self._realtime_layout_update = True for obj in self._move_selection: obj.layout.change_start(obj) + # disable realtime layout update if any widget's layout doesn't support it + if not obj.layout.layout_strategy.realtime_support: + self._realtime_layout_update = False else: for obj in self._selected: if not obj.layout.allow_resize: continue obj.layout.change_start(obj) + if all(w.layout.layout_strategy.realtime_support for w in self._selected): + self._realtime_layout_update = True + def _on_handle_inactive(self, widget, direction): + if self._handle_active_data is not None: + # no movement occurred so we can skip the post-processing + self._handle_active_data = None + return + layouts_changed = [] if direction == "all": if not self.current_container: @@ -427,9 +450,14 @@ def _on_handle_inactive(self, widget, direction): self.create_restore(layouts_changed) self.studio.widgets_layout_changed(layouts_changed) + self._realtime_layout_update = False self._skip_var = 0 def _on_handle_resize(self, widget, direction, delta): + if self._handle_active_data is not None: + self._on_handle_active(*self._handle_active_data) + self._handle_active_data = None + if self._skip_var < self._skip_max: self._skip_var += 1 self._surge_delta = (self._surge_delta[0] + delta[0], self._surge_delta[1] + delta[1]) @@ -443,14 +471,12 @@ def _on_handle_resize(self, widget, direction, delta): for obj in self._selected: if obj.layout.allow_resize: obj.layout.resize_widget(obj, direction, delta) + if self._realtime_layout_update: + self.studio.widgets_layout_changed(self._selected) else: # move self._on_handle_move(widget, delta) - # TODO handle realtime layout changes - # if obj.layout.layout_strategy.realtime_support: - # self.studio.widgets_layout_changed(obj) - def _on_handle_move(self, _, delta): dx, dy = delta objs = self._move_selection @@ -478,28 +504,33 @@ def _on_handle_move(self, _, delta): x1, y1, x2, y2 = w.get_bounds() current.move_widget(w, [x1 + dx, y1 + dy, x2 + dx, y2 + dy]) + if self.current_container.layout_strategy.realtime_support: + self.studio.widgets_layout_changed(objs) + def set_active_container(self, container): if self.current_container is not None: self.current_container.clear_highlight() self.current_container = container def layout_at(self, bounds): - for container in sorted(filter(lambda x: isinstance(x, Container) and x not in self._selected, self.objects), - key=lambda x: len(self.objects) - x.level): - if isinstance(self.current_obj, Container) and self.current_obj.level < container.level: - continue + candidate = None + for container in self._sorted_containers: if geometry.is_within(geometry.bounds(container), bounds): - return container + candidate = container + break if self.current_container and geometry.compute_overlap(geometry.bounds(self.current_container), bounds): + if candidate and candidate.level > self.current_container.level: + return candidate return self.current_container - return self + + return candidate or self 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.on_handle_resize(self._on_handle_resize) - obj.on_handle_active(self._on_handle_active) + obj.on_handle_active(self._on_handle_active_start) obj.on_handle_inactive(self._on_handle_inactive) MenuUtils.bind_all_context(obj, lambda e: self.show_menu(e, obj), add='+') diff --git a/studio/lib/layouts.py b/studio/lib/layouts.py index e6679c4..476b9af 100644 --- a/studio/lib/layouts.py +++ b/studio/lib/layouts.py @@ -6,6 +6,7 @@ # Copyright (C) 2019 Hoverset Group. # # ======================================================================= # from collections import defaultdict +from copy import deepcopy from studio.ui import geometry from studio.ui.highlight import WidgetHighlighter, EdgeIndicator @@ -84,12 +85,8 @@ def add_widget(self, widget, bounds=None, **kwargs): self.container.clear_highlight() def _insert(self, widget, index=None): - if index is None: - self.children.append(widget) - else: - self.children.insert(index, widget) - self._update_stacking() - + # actual insert logic does not work, we'll stick with append for now + self.children.append(widget) if widget.prev_stack_index is None: widget.prev_stack_index = len(self.children) - 1 @@ -98,7 +95,7 @@ def _update_stacking(self): if index > 0: widget.lift(self.children[index - 1]) else: - widget.lift(self.container.body) + widget.lift(self.container) def widget_released(self, widget): pass @@ -171,8 +168,8 @@ def get_def(self, widget): # May be overridden to return dynamic definitions based on the widget # Always use a copy to avoid messing with definition if self.dimensions_in_px: - return dict(self.DEFINITION) - props = dict(self.DEFINITION) + return deepcopy(self.DEFINITION) + props = deepcopy(self.DEFINITION) overrides = getattr(widget, 'DEF_OVERRIDES', {}) for key in ('width', 'height'): if key in props and key in overrides: @@ -772,6 +769,7 @@ def _location_analysis(self, bounds): self._edge_indicator.update_idletasks() bounds = geometry.relative_bounds(bounds, self.container.body) x, y = bounds[0], bounds[1] + w, h = geometry.dimensions(bounds) col, row = self.container.body.grid_location(x, y) x, y = geometry.upscale_bounds(bounds, self.container.body)[:2] slaves = self.container.body.grid_slaves(max(0, row), max(0, col)) @@ -781,6 +779,9 @@ def _location_analysis(self, bounds): bounds = *bbox[:2], bbox[0] + bbox[2], bbox[1] + bbox[3] # Make bounds relative to designer bounds = geometry.upscale_bounds(bounds, self.container.body) + if geometry.dimensions(bounds) == (0, 0): + w, h = w or 50, h or 25 + bounds = bounds[0], bounds[1], bounds[0] + w, bounds[1] + h else: bounds = geometry.bounds(slaves[0]) y_offset, x_offset = 10, 10 # 0.15*(bounds[3] - bounds[1]), 0.15*(bounds[2] - bounds[0]) @@ -812,7 +813,8 @@ def apply(self, prop, value, widget): widget.grid_configure(**{prop: value}) def react_to_pos(self, x, y): - self._location_analysis((*geometry.resolve_position((x, y), self.container.parent), 0, 0)) + x, y = geometry.resolve_position((x, y), self.container.parent) + self._location_analysis((x, y, x, y)) def info(self, widget): info = widget.grid_info() or {} @@ -1062,7 +1064,8 @@ def resize_widget(self, widget, direction, delta): def add_widget(self, widget, bounds=None, **kwargs): super().remove_widget(widget) super().add_widget(widget, bounds, **kwargs) - self.container.body.add(widget) + if str(widget) not in self.container.body.panes(): + self.container.body.add(widget) self._config(widget, **kwargs) self._insert(widget) @@ -1117,7 +1120,7 @@ def apply(self, prop, value, widget): def _config(self, widget, **kwargs): if not kwargs: - return self.container.pane(widget) + return self.container.body.pane(widget) self.container.body.pane(widget, **kwargs) def copy_layout(self, widget, from_): diff --git a/studio/ui/highlight.py b/studio/ui/highlight.py index 379b475..b119c1d 100644 --- a/studio/ui/highlight.py +++ b/studio/ui/highlight.py @@ -62,7 +62,7 @@ def top(self, bounds): self.place(x=x, y=y, height=1.5, width=bounds[2] - bounds[0]) def right(self, bounds): - x, y = bounds[2], bounds[3] + x, y = bounds[2], bounds[1] self.lift() self.place(x=x, y=y, height=bounds[3] - bounds[1], width=1.5) From d2e481c3d1e721588ceb5f53613b75fbcc92e59a Mon Sep 17 00:00:00 2001 From: ObaraEmmanuel Date: Fri, 29 Sep 2023 13:35:54 +0300 Subject: [PATCH 14/15] Fix widget stacking issues --- studio/lib/layouts.py | 8 ++++++-- studio/lib/pseudo.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/studio/lib/layouts.py b/studio/lib/layouts.py index 476b9af..ace00a9 100644 --- a/studio/lib/layouts.py +++ b/studio/lib/layouts.py @@ -85,8 +85,12 @@ def add_widget(self, widget, bounds=None, **kwargs): self.container.clear_highlight() def _insert(self, widget, index=None): - # actual insert logic does not work, we'll stick with append for now - self.children.append(widget) + if index is None: + self.children.append(widget) + else: + self.children.insert(index, widget) + self._update_stacking() + if widget.prev_stack_index is None: widget.prev_stack_index = len(self.children) - 1 diff --git a/studio/lib/pseudo.py b/studio/lib/pseudo.py index 6f7e7eb..524a604 100644 --- a/studio/lib/pseudo.py +++ b/studio/lib/pseudo.py @@ -167,7 +167,7 @@ def lift(self, above_this): pass # then lift above highest child if any - if above_this._children: + if above_this._children and self.layout != above_this: super().lift(above_this._children[-1]) else: super().lift(above_this) From c4daa23e2f3eaa0cac06d8f35af47eeb24ad7d56 Mon Sep 17 00:00:00 2001 From: ObaraEmmanuel Date: Fri, 29 Sep 2023 23:23:31 +0300 Subject: [PATCH 15/15] Implement drag select for widgets --- studio/feature/design.py | 19 +++++++++++++++++++ studio/lib/legacy.py | 1 + studio/lib/pseudo.py | 26 ++++++++++++++++++++++++-- studio/ui/highlight.py | 4 ++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/studio/feature/design.py b/studio/feature/design.py index 64b96a0..bbec13f 100644 --- a/studio/feature/design.py +++ b/studio/feature/design.py @@ -24,6 +24,7 @@ from studio.parsers.loader import DesignBuilder, BaseStudioAdapter from studio.ui import geometry from studio.ui.widgets import DesignPad, CoordinateIndicator +from studio.ui.highlight import RegionHighlighter from studio.context import BaseContext from studio import __version__ @@ -132,6 +133,7 @@ def __init__(self, master, studio): self._shortcut_mgr = KeyMap(self._frame) self._set_shortcuts() self._last_click_pos = None + self._region_highlight = RegionHighlighter(self, self.style) self._empty = Label( self, @@ -525,6 +527,23 @@ def layout_at(self, bounds): return candidate or self + def show_select_region(self, bounds): + bounds = geometry.resolve_bounds(bounds, self) + self._region_highlight.highlight_bounds(bounds) + + def clear_select_region(self): + self._region_highlight.clear() + + def select_in_region(self, widget, bounds): + if isinstance(widget, Container): + bounds = geometry.resolve_bounds(bounds, self) + to_select = [] + for child in widget._children: + if geometry.compute_overlap(child.get_bounds(), bounds): + to_select.append(child) + if to_select: + self.studio.selection.set(to_select) + def _attach(self, obj): # bind events for context menu and object selection # all widget additions call this method so clear empty message diff --git a/studio/lib/legacy.py b/studio/lib/legacy.py index 27afa2e..01e57a0 100644 --- a/studio/lib/legacy.py +++ b/studio/lib/legacy.py @@ -35,6 +35,7 @@ class Canvas(PseudoWidget, tk.Canvas): icon = "paint" impl = tk.Canvas allow_direct_move = False + allow_drag_select = False def __init__(self, master, id_): super().__init__(master) diff --git a/studio/lib/pseudo.py b/studio/lib/pseudo.py index 524a604..1ece4fb 100644 --- a/studio/lib/pseudo.py +++ b/studio/lib/pseudo.py @@ -101,6 +101,7 @@ class PseudoWidget: impl = None is_toplevel = False allow_direct_move = True + allow_drag_select = False # special handlers (intercepts) for attributes that need additional processing # to interface with the studio easily _intercepts = { @@ -145,6 +146,8 @@ def setup_widget(self): self.bind("", self._on_drag, add='+') self._active = False + self._select_mode_active = False + self._select_bounds = None self.prev_stack_index = None def set_name(self, name): @@ -173,6 +176,12 @@ def lift(self, above_this): super().lift(above_this) def _on_press(self, event): + if not self.allow_direct_move and not event.state & EventMask.SHIFT: + if not self.allow_drag_select: + return + self._select_mode_active = True + self._pos_fix = (event.x_root, event.y_root) + return if not self._handle: return self._pos_fix = (event.x_root, event.y_root) @@ -180,16 +189,28 @@ def _on_press(self, event): self._active = True def _on_release(self, _): + if self._select_mode_active: + self.designer.clear_select_region() + self._select_mode_active = False + if self._select_bounds is not None: + self.designer.select_in_region(self, self._select_bounds) + self._select_bounds = None + if not self._active: return self.handle_inactive('all') self._active = False def _on_drag(self, event): + if self._select_mode_active: + x1, y1 = self._pos_fix + x2, y2 = event.x_root, event.y_root + bounds = min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2) + self._select_bounds = bounds + self.designer.show_select_region(bounds) + if not self._active or not event.state & EventMask.MOUSE_BUTTON_1: return - if not self.allow_direct_move and not event.state & EventMask.SHIFT: - return x, y = self._pos_fix self._pos_fix = (event.x_root, event.y_root) self.handle_resize('all', (event.x_root - x, event.y_root - y)) @@ -379,6 +400,7 @@ def __repr__(self): class Container(PseudoWidget): LAYOUTS = layouts.layouts allow_direct_move = False + allow_drag_select = True def setup_widget(self): self.parent = self.designer = self._get_designer() diff --git a/studio/ui/highlight.py b/studio/ui/highlight.py index b119c1d..23a69d8 100644 --- a/studio/ui/highlight.py +++ b/studio/ui/highlight.py @@ -41,6 +41,10 @@ def clear(self): element.place_forget() +class RegionHighlighter(WidgetHighlighter): + OUTLINE = 1 + + class EdgeIndicator(tk.Frame): """ Generates a conspicuous line at the edges of a widget for various indication purposes