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 diff --git a/hoverset/ui/widgets.py b/hoverset/ui/widgets.py index 00a7995..5f0be78 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 @@ -2710,16 +2714,15 @@ 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) 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/_base.py b/studio/feature/_base.py index 9f7892b..396c075 100644 --- a/studio/feature/_base.py +++ b/studio/feature/_base.py @@ -118,28 +118,26 @@ 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 + Called when the widgets in the designer are changed + :param widgets: list of widgets + :return: None """ pass - def on_widget_change(self, old_widget, new_widget=None): + def on_widgets_layout_change(self, widgets): """ - 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 layout options of a widgets are changed + :param widgets: Widgets with altered layout options :return: None """ pass - def on_widget_layout_change(self, widget): + def on_widgets_reorder(self, indices): """ - Called when layout options of a widget are changed - :param widget: Widget with altered layout options - :return: None + Called when the widgets in the designer are reordered within their parent. + Used to change stacking order of widgets """ pass @@ -152,10 +150,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 +161,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 d9fa415..ad3fa55 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_widgets_delete(self, widgets, silently=False): + for widget in widgets: + widget.node.remove() + + def on_widgets_restore(self, widgets): + for widget in widgets: + widget.layout.node.add(widget.node) + + def on_widgets_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"): @@ -184,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..c0759b5 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): @@ -274,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() @@ -438,7 +439,7 @@ def render_groups(self): def render_extern_groups(self): for group in self._extern_groups: - if group.supports(self._widget): + 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) @@ -480,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/design.py b/studio/feature/design.py index 96ce258..bbec13f 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,8 +23,8 @@ 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.widgets import DesignPad, CoordinateIndicator +from studio.ui.highlight import RegionHighlighter from studio.context import BaseContext from studio import __version__ @@ -37,22 +37,25 @@ 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)) 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: + 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.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) @@ -65,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", {})) @@ -105,25 +108,17 @@ 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) self.setup_widget() + self.designer = self self.parent = self self.config(**self.style.bright, takefocus=True) self.objects = [] self.root_obj = None self.layout_strategy = DesignLayoutStrategy(self) - self.highlight = HighLight(self) - self.highlight.on_resize(self._on_size_changed) - self.highlight.on_move(self._on_move) - self.highlight.on_release(self._on_release) - self.highlight.on_start(self._on_start) - self._update_throttling() - self.studio.pref.add_listener( - "designer::frame_skip", - self._update_throttling - ) self.current_obj = None self.current_container = None self.current_action = None @@ -132,13 +127,13 @@ 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) 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, @@ -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,33 @@ 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 + + # 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 + ) + self._sorted_containers = [] + self._realtime_layout_update = False + self._handle_active_data = None + + 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 +205,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: @@ -203,13 +226,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() @@ -284,7 +300,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: @@ -321,6 +337,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: @@ -359,36 +376,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 +383,181 @@ 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_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: + return + container = self.current_container + self.current_container = None + container.clear_highlight() + 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.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]) + 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) + if self._realtime_layout_update: + self.studio.widgets_layout_changed(self._selected) + else: + # move + self._on_handle_move(widget, delta) + + 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 + + if container != current and current is not None: + current.end_move() + current.clear_highlight() + + if container is not None and container != current: + container.show_highlight() + 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]) + + 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): + candidate = None + for container in self._sorted_containers: + if geometry.is_within(geometry.bounds(container), bounds): + 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 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 self._show_empty(False) + obj.on_handle_resize(self._on_handle_resize) + 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='+') - 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 +566,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 obj and obj not in 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) + elif obj not in self.studio.selection: + self.studio.selection.set(obj) def load(self, obj_class, name, container, attributes, layout, bounds=None): obj = obj_class(self, name) @@ -489,8 +635,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 @@ -501,13 +647,57 @@ 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) + if self.root_obj in widgets: + self._show_empty(False) + self.studio.on_restore(widgets) def _replace_all(self, widget): # Recursively add widget and all its children to objects @@ -518,26 +708,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 +742,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,169 +753,76 @@ 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) - - def _stop_displace(self, _): - if self._displace_active: - # this ensures event is added to undo redo stack - self._on_release(geometry.bounds(self.current_obj)) - # mark the latest action as designer displace - latest = self.studio.last_action() - if latest is not None: - latest.key = "designer_displace" - self._displace_active = False - - def displace(self, side): - if not self.current_obj: - return - if time.time() - self._last_displace < .5: - self.studio.pop_last_action("designer_displace") - self._on_start() - self._displace_active = True - self._last_displace = time.time() - bounds = geometry.bounds(self.current_obj) - x1, y1, x2, y2 = bounds - if side == 'right': - bounds = x1 + 1, y1, x2 + 1, y2 - elif side == 'left': - bounds = x1 - 1, y1, x2 - 1, y2 - elif side == 'up': - bounds = x1, y1 - 1, x2, y2 - 1 - elif side == 'down': - bounds = x1, y1 + 1, x2, y2 + 1 - self._on_move(bounds) - - def clear_obj_highlight(self): - if self.highlight is not None: - self.highlight.clear() - self.current_obj = None - if self.current_container is not None: - self.current_container.clear_highlight() - self.current_container = None + for w in self._selected - current_selection: + w.clear_handle() + + for w in current_selection - self._selected: + w.show_handle() + + self._selected = current_selection + self.focus_set() + + @property + def selected(self): + return self._selected 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) + self.studio.widgets_layout_changed(widgets) 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.widgets_layout_changed(widgets) 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()) def _show_text_editor(self, widget): - assert widget == self.current_obj - self._text_editor.lift(widget) + if any("text" not in w.keys() for w in self.selected): + return 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 + 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,10 +843,72 @@ def _collect_text_config(self, widget): def _text_hide(self, *_): self._text_editor.place_forget() - def on_select(self, widget): - self.select(widget) + 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) - def on_widget_change(self, old_widget, new_widget=None): + 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 + + 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: + 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 def on_widget_add(self, widget, parent): @@ -827,6 +972,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 +1028,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/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/feature/stylepane.py b/studio/feature/stylepane.py index 54f7c6e..1b53d7e 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 self.widgets and 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,17 +331,23 @@ 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): - 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: @@ -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 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() @@ -359,29 +385,36 @@ 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): - 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: 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/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) diff --git a/studio/lib/handles.py b/studio/lib/handles.py new file mode 100644 index 0000000..7d51287 --- /dev/null +++ b/studio/lib/handles.py @@ -0,0 +1,243 @@ +# ======================================================================= # +# 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"] + border = handle.master.style.colors["primarydarkaccent"] + self.config( + width=6, height=6, bg=color, cursor=self._cursor_map[direction], + highlightthickness=1, highlightbackground=border + ) + 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 lift(self): + for dot in self.dots: + dot.lift() + for edge in self.edges: + edge.lift() + + 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 + obj.lift() + 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 63b47e8..ace00a9 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 @@ -56,6 +57,8 @@ 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 + stacking_support = True # Whether to allow modification of stacking order def __init__(self, container): self.parent = container.parent @@ -79,39 +82,59 @@ 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) + def widget_released(self, widget): pass 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) + 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): + # 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") @@ -149,8 +172,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: @@ -230,6 +253,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: @@ -242,7 +266,33 @@ 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) + 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) @@ -253,7 +303,8 @@ 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", {})) @@ -275,7 +326,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)) @@ -314,6 +365,7 @@ class PackLayoutStrategy(BaseLayoutStrategy): name = "pack" icon = "frame" manager = "pack" + stacking_support = False def __init__(self, container): super().__init__(container) @@ -330,7 +382,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: @@ -345,10 +397,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: @@ -428,7 +478,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): @@ -473,7 +523,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() @@ -489,10 +539,9 @@ 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, bounds): - super().resize_widget(widget, bounds) + def resize_widget(self, widget, direction, delta): widget.update_idletasks() self.redraw(widget) @@ -506,7 +555,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: @@ -618,7 +667,7 @@ 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) @@ -688,15 +737,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) @@ -708,17 +759,21 @@ 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): 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 0, 0, 0, 0 self.clear_indicators() 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)) @@ -728,6 +783,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]) @@ -759,7 +817,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 {} @@ -795,7 +854,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): @@ -806,9 +865,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", @@ -856,6 +912,10 @@ class TabLayoutStrategy(BaseLayoutStrategy): "default": 'normal' } } + name = "TabLayout" + icon = "notebook" + manager = "tab" + stacking_support = False def __init__(self, master): super().__init__(master) @@ -891,7 +951,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): @@ -899,7 +959,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) @@ -934,9 +994,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"), @@ -968,6 +1025,10 @@ class PanedLayoutStrategy(BaseLayoutStrategy): "name": "minsize", } } + name = "PanedLayout" + icon = "flip_horizontal" + manager = "pane" + stacking_support = False def get_restore(self, widget): return { @@ -1001,15 +1062,16 @@ 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): 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.children.append(widget) + self._insert(widget) def _config(self, widget, **kwargs): if not kwargs: @@ -1062,7 +1124,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/lib/legacy.py b/studio/lib/legacy.py index 645649b..01e57a0 100644 --- a/studio/lib/legacy.py +++ b/studio/lib/legacy.py @@ -1,9 +1,9 @@ +import tkinter import tkinter as tk from studio.lib.pseudo import ( PseudoWidget, Groups, Container, PanedContainer, _dimension_override ) -from studio.lib.toplevel import Toplevel from studio.lib.toplevel import Toplevel, Tk @@ -34,6 +34,8 @@ class Canvas(PseudoWidget, tk.Canvas): group = Groups.container icon = "paint" impl = tk.Canvas + allow_direct_move = False + allow_drag_select = False def __init__(self, master, id_): super().__init__(master) @@ -41,7 +43,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/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..870a2c6 100644 --- a/studio/lib/properties.py +++ b/studio/lib/properties.py @@ -634,7 +634,44 @@ 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]) + # 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 + + +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] + + return combine_properties(properties) diff --git a/studio/lib/pseudo.py b/studio/lib/pseudo.py index 28b812e..1ece4fb 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 WidgetHighlighter +from studio.lib.handles import BoxHandle from studio.ui.tree import MalleableTree @@ -99,6 +100,8 @@ class PseudoWidget: icon = "play" 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 = { @@ -110,23 +113,163 @@ class PseudoWidget: "variable": _VariableIntercept, "listvariable": _VariableIntercept } + _no_defaults = { + "text", + } 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 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) + 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) + + self.bind("", self._on_press, add='+') + self.bind("", self._on_release, add='+') + + 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): 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 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 and self.layout != above_this: + super().lift(above_this._children[-1]) + else: + 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) + self.handle_active('all') + 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 + 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 = self._handle_cls.acquire(self, self.master) + self._handle.show() + + def clear_handle(self): + if not self._handle: + return + self._handle.hide() + if not self._handle.active(): + self._handle = None + + 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_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 + + 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 @@ -177,7 +320,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 @@ -229,7 +375,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 {} @@ -245,9 +393,14 @@ 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 + allow_direct_move = False + allow_drag_select = True def setup_widget(self): self.parent = self.designer = self._get_designer() @@ -256,7 +409,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,26 +419,31 @@ 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: 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) 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 @@ -359,7 +516,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 = [ @@ -406,8 +563,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 c431f14..532de12 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, Symbol +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, ShowIf 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,16 +124,27 @@ 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'), {}), ("separator",), + ShowIf( + 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'), {}), + ("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'), {}), ),) @@ -267,6 +278,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 +302,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 +380,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 +388,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,16 +432,19 @@ def pop_last_action(self, key=None): if self.context: self.context.pop_last_action(key) - def copy(self): - if self.designer and self.selected: - # store the current object as node in the clipboard - self._clipboard = self.designer.as_node(self.selected) - def install_status_widget(self, widget_class, *args, **kwargs): widget = widget_class(self._statusbar, *args, **kwargs) widget.pack(side='right', padx=2, fill='y') return widget + def 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]) @@ -436,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() @@ -498,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: @@ -631,68 +650,92 @@ 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) + self.tool_manager.on_widget_add(widget, parent) - def widget_modified(self, widget1, source=None, widget2=None): + 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) + + self.tool_manager.on_widgets_change(widgets) - def widget_layout_changed(self, widget): + def widgets_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_widgets_layout_change(widgets) + + self.tool_manager.on_widgets_layout_change(widgets) - def delete(self, widget=None, source=None): - widget = self.selected if widget is None else widget - if widget is None: + 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 = [] + for widget in widgets: + data.append(( + self.designer.as_node(widget), + geometry.relative_to_bounds(widget.get_bounds(), bounds), + )) + return data + + 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_widgets_delete(widgets) + + self.tool_manager.on_widgets_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_widgets_delete(widgets, True) + + self.tool_manager.on_widgets_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_widgets_restore(widgets) def on_feature_change(self, new, old): self.features.insert(self.features.index(old), new) @@ -859,15 +902,23 @@ 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')), routine(self.redo, 'STUDIO_REDO', 'Redo action', 'studio', CTRL + CharKey('Y')), + 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 + Symbol(']')), + routine( + lambda: self.bring_front(1), + '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')), 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)) 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/tools/__init__.py b/studio/tools/__init__.py index 7a3c301..79ebe2f 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,14 @@ 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_widgets_layout_change(self, widgets): + self.dispatch("on_widgets_layout_change", widgets) - def on_widget_layout_change(self, widget): - self.dispatch("on_widget_layout_change", widget) + 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 0ed1cb6..2e01aeb 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,13 @@ 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_widgets_layout_change(self, widgets): pass - def on_widget_layout_change(self, widget): + def on_widgets_reorder(self, indices): 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, {})) 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..23a69d8 100644 --- a/studio/ui/highlight.py +++ b/studio/ui/highlight.py @@ -1,317 +1,8 @@ -import tkinter as tk - -from hoverset.platform import platform_is, WINDOWS, LINUX -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 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 - - 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.l, self.r, self.t, self.b, 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) +# ======================================================================= # +# Copyright (C) 2019 Hoverset Group. # +# ======================================================================= # - 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: @@ -350,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 @@ -371,7 +66,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) @@ -380,4 +75,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 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)