diff --git a/formation/handlers/layout.py b/formation/handlers/layout.py index 9800f61..faf54b9 100644 --- a/formation/handlers/layout.py +++ b/formation/handlers/layout.py @@ -70,7 +70,7 @@ def get_layout_handler(parent_node, parent): def handle(widget, config, **kwargs): parent = kwargs.get("parent") - if parent is None or isinstance(widget, (tk.Tk, tk.Toplevel)): + if parent is None or isinstance(widget, (tk.Tk, tk.Toplevel, tk.Menu)): return parent_node = kwargs.get("parent_node") layout = get_layout_handler(parent_node, parent) diff --git a/formation/handlers/misc.py b/formation/handlers/misc.py index bb13f81..957d64f 100644 --- a/formation/handlers/misc.py +++ b/formation/handlers/misc.py @@ -22,10 +22,30 @@ def handle(cls, widget, config, **kwargs): handle_method(**{prop: builder._get_var(str(properties[prop])) or ''}) +class WidgetHandler: + widget_props = ( + "menu", + ) + + @classmethod + def handle(cls, widget, config, **kwargs): + builder = kwargs.get("builder") + properties = kwargs.get("extra_config", {}) + handle_method = kwargs.get("handle_method", getattr(widget, "config", None)) + + if handle_method is None: + # no way to assign the variable so just stop here + return + for prop in properties: + # defer assignments to the builder + builder._deferred_props.append((prop, properties[prop], handle_method)) + + _common_redirect = { **{prop: image for prop in image.image_props}, **{prop: VariableHandler for prop in VariableHandler.variable_props}, **{prop: command for prop in command.command_props}, + **{prop: WidgetHandler for prop in WidgetHandler.widget_props} } @@ -61,7 +81,7 @@ def handle_method(**conf): class AttrHandler: _ignore = ( - "layout", "menu" + "layout" ) _redirect = _common_redirect diff --git a/formation/loader.py b/formation/loader.py index 9f88b9c..d1f2176 100644 --- a/formation/loader.py +++ b/formation/loader.py @@ -50,6 +50,13 @@ tk.Tk, ) +_menu_containers = ( + tk.Menubutton, + ttk.Menubutton, + tk.Toplevel, + tk.Tk, +) + _menu_item_types = ( tk.CASCADE, tk.COMMAND, @@ -58,8 +65,21 @@ tk.RADIOBUTTON, ) +_canvas_item_types = ( + "Arc", + "Bitmap", + "Image", + "Line", + "Oval", + "Polygon", + "Rectangle", + "Text", + "Window" +) + _ignore_tags = ( *_menu_item_types, + *_canvas_item_types, "event", "grid", "meta", @@ -139,11 +159,11 @@ class MenuLoaderAdapter(BaseLoaderAdapter): def load(cls, node, builder, parent): cls._load_required_fields(node) widget = BaseLoaderAdapter.load(node, builder, parent) - cls._menu_load(node, builder, None, widget) + cls._menu_load(node, builder, widget) return widget @classmethod - def _menu_load(cls, node, builder, menu=None, widget=None): + def _menu_load(cls, node, builder, menu): for sub_node in node: if sub_node.type in _ignore_tags and sub_node.type not in _menu_item_types or sub_node.is_var(): continue @@ -160,13 +180,12 @@ def _menu_load(cls, node, builder, menu=None, widget=None): menu.add(sub_node.type) index = menu.index(tk.END) dispatch_to_handlers(menu, attrib, **kwargs, menu=menu, index=index) - elif cls._get_class(sub_node) == tk.Menu: - obj_class = cls._get_class(sub_node) - menu_obj = obj_class(widget) - if widget: - widget.configure(menu=menu_obj) - dispatch_to_handlers(menu_obj, attrib, **kwargs) - elif menu: + continue + + obj_class = cls._get_class(sub_node) + if issubclass(obj_class, tk.Menu): + menu_obj = obj_class(menu) + if menu: menu.add(tk.CASCADE, menu=menu_obj) index = menu.index(tk.END) dispatch_to_handlers( @@ -194,7 +213,7 @@ class CanvasLoaderAdapter(BaseLoaderAdapter): def load(cls, node, builder, parent): canvas = BaseLoaderAdapter.load(node, builder, parent) for sub_node in node: - if sub_node.type in _ignore_tags or sub_node.is_var(): + if sub_node.type not in _canvas_item_types: continue # just additional options that may be needed down the line kwargs = { @@ -237,10 +256,7 @@ class Builder: """ _adapter_map = { - tk.Menubutton: MenuLoaderAdapter, - ttk.Menubutton: MenuLoaderAdapter, - tk.Tk: MenuLoaderAdapter, - tk.Toplevel: MenuLoaderAdapter, + tk.Menu: MenuLoaderAdapter, tk.Canvas: CanvasLoaderAdapter, # Add custom adapters here } @@ -258,6 +274,7 @@ def __init__(self, parent, **kwargs): path = kwargs.get("path") self._path = path if path is None else os.path.abspath(path) self._meta = {} + self._deferred_props = [] if kwargs.get("node"): self.load_node(kwargs.get("node")) @@ -270,6 +287,13 @@ def __init__(self, parent, **kwargs): self.load_path(self._path) Meth.call_deferred(self) + self._apply_deferred_props() + + def _apply_deferred_props(self): + for prop, value, handle_method in self._deferred_props: + value = getattr(self, value, None) + if value: + handle_method(**{prop: value}) def _arg_parser(self, a, t): if t == "image": @@ -342,15 +366,17 @@ def _load_meta(self, node, builder): def _load_widgets(self, node, builder, parent): adapter = self._get_adapter(BaseLoaderAdapter._get_class(node)) widget = adapter.load(node, builder, parent) - if not isinstance(widget, _containers): - # We don't need to load child tags of non-container widgets + if isinstance(widget, tk.Menu): + # old-style menu format, so assign it to parent "menu" attribute + if node.attrib.get("name") is None: + # old-style menu format, so assign it to parent "menu" attribute + if isinstance(parent, _menu_containers): + parent.configure(menu=widget) return widget for sub_node in node: if sub_node.is_var() or sub_node.type in _ignore_tags: # ignore variables and non widgets continue - if BaseLoaderAdapter._get_class(sub_node) == tk.Menu: - continue self._load_widgets(sub_node, builder, widget) return widget diff --git a/formation/tests/samples/all_legacy.xml b/formation/tests/samples/all_legacy.xml index 9be735b..5372013 100644 --- a/formation/tests/samples/all_legacy.xml +++ b/formation/tests/samples/all_legacy.xml @@ -30,4 +30,5 @@ layout:height="30" layout:x="484" layout:y="446"/> + diff --git a/formation/tests/samples/menu.xml b/formation/tests/samples/menu.xml new file mode 100644 index 0000000..de1bd50 --- /dev/null +++ b/formation/tests/samples/menu.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/formation/tests/support.py b/formation/tests/support.py index 1690c3f..46e6018 100644 --- a/formation/tests/support.py +++ b/formation/tests/support.py @@ -7,7 +7,7 @@ tk_supported = { tk.Button, tk.Checkbutton, tk.Label, tk.Menubutton, tk.Scrollbar, tk.Canvas, tk.Frame, tk.LabelFrame, tk.Listbox, tk.PanedWindow, - tk.Entry, tk.Message, tk.Radiobutton, tk.Scale, tk.Spinbox, tk.Text + tk.Entry, tk.Message, tk.Radiobutton, tk.Scale, tk.Spinbox, tk.Text, tk.Menu } ttk_supported = { diff --git a/formation/tests/test_menu.py b/formation/tests/test_menu.py new file mode 100644 index 0000000..4233b7d --- /dev/null +++ b/formation/tests/test_menu.py @@ -0,0 +1,62 @@ +import tkinter +import unittest + +from formation import AppBuilder +from formation.tests.support import get_resource + + +class MenuTestCase(unittest.TestCase): + builder = None + + @classmethod + def setUpClass(cls) -> None: + cls.builder = AppBuilder(path=get_resource("menu.xml")) + + @classmethod + def tearDownClass(cls) -> None: + cls.builder._app.destroy() + + def test_command(self): + pass + + def test_menubutton_legacy(self): + mb1 = self.builder.menubutton_1 + self.assertEqual(mb1.nametowidget(mb1.cget("menu")), self.builder.menu_1) + + def test_menubutton_native(self): + mb2 = self.builder.menubutton_2 + self.assertEqual(mb2.nametowidget(mb2.cget("menu")), self.builder.menu_2) + + def test_toplevel(self): + tk1 = self.builder.tk_1 + self.assertEqual(tk1.nametowidget(tk1.cget("menu")), self.builder.menu_3) + + def test_menuitem_config(self): + m1 = self.builder.menu_1 + self.assertEqual(str(m1.entrycget(0, "background")), "green") + self.assertEqual(m1.entrycget(1, "label"), "command_2") + self.assertEqual(str(m1.entrycget(2, "state")), "disabled") + self.assertEqual(str(m1.entrycget(3, "variable")), str(self.builder.str_var)) + + def test_cascade_menu_config(self): + m1 = self.builder.menu_1 + m2 = m1.nametowidget(m1.entrycget(6, "menu")) + m3 = m2.nametowidget(m2.entrycget(4, "menu")) + self.assertEqual(m3.cget("tearoff"), 0) + self.assertEqual(str(m3.cget("background")), "red") + + def test_menu_config(self): + m2 = self.builder.menu_2 + self.assertEqual(str(m2.cget("foreground")), "grey20") + self.assertEqual(m2.cget("title"), "menu_2") + + def test_command_binding(self): + self.builder.connect_callbacks(self) + m2 = self.builder.menu_2 + self.assertNotEqual(m2.cget("postcommand"), '') + self.assertNotEqual(m2.cget("tearoffcommand"), '') + self.assertNotEqual(m2.entrycget(1, "command"), '') + + def test_old_format_backwards_compatibility(self): + mb3 = self.builder.menubutton_3 + self.assertIsInstance(mb3.nametowidget(mb3.cget("menu")), tkinter.Menu) \ No newline at end of file diff --git a/hoverset/ui/themes/default.css b/hoverset/ui/themes/default.css index a49813a..6cbbb60 100644 --- a/hoverset/ui/themes/default.css +++ b/hoverset/ui/themes/default.css @@ -6,6 +6,7 @@ primaryaccent: #3a3a3a; accent: #3d8aff; secondary1: #3dff3d; + secondary2: #ff743d; } .spinbox{ diff --git a/hoverset/ui/themes/light.css b/hoverset/ui/themes/light.css index 75fa8df..6be40c0 100644 --- a/hoverset/ui/themes/light.css +++ b/hoverset/ui/themes/light.css @@ -5,7 +5,7 @@ primary: #202020; primaryaccent: #e0e0e0; accent: #228b22; - secondary1: #224c8c; + secondary1: #8c227e; } .spinbox{ diff --git a/hoverset/ui/widgets.py b/hoverset/ui/widgets.py index c6c7acf..66e4965 100644 --- a/hoverset/ui/widgets.py +++ b/hoverset/ui/widgets.py @@ -1340,7 +1340,8 @@ def on_configure(self, *_): def clear_children(self): # Unmap all children from the frame for child in self.body.winfo_children(): - child.pack_forget() + if hasattr(child, "pack_forget"): + child.pack_forget() def _detect_change(self, flag=True): # Lets set up the frame to listen to changes in size and update the scrollbars diff --git a/studio/feature/components.py b/studio/feature/components.py index 90a74e8..55dc0ce 100644 --- a/studio/feature/components.py +++ b/studio/feature/components.py @@ -2,7 +2,7 @@ from tkinter import BooleanVar, filedialog from hoverset.ui.icons import get_icon_image -from hoverset.ui.widgets import ScrolledFrame, Frame, Label, Spinner, Button +from hoverset.ui.widgets import ScrolledFrame, Frame, Label, Spinner, Button, ActionNotifier from hoverset.ui.dialogs import MessageDialog from hoverset.data.preferences import ListControl from hoverset.util.execution import import_path @@ -57,7 +57,11 @@ def drag_start_pos(self, event): def on_drag(self, event): if not self.designer: return - widget = self.designer.layout_at_pos(*self.window.drag_window.get_center()) + if issubclass(self.component, PseudoWidget) and self.component.non_visual: + widget = self.designer.widget_at_pos(*self.window.drag_window.get_center()) + else: + widget = self.designer.layout_at_pos(*self.window.drag_window.get_center()) + if widget and self.window.drag_window: bounds = geometry.absolute_bounds(self.window.drag_window) widget.react(bounds) @@ -66,7 +70,15 @@ def on_drag_end(self, event): if not self.designer: return widget = self.designer.layout_at_pos(*self.window.drag_window.get_center()) - if isinstance(widget, Container): + if issubclass(self.component, PseudoWidget) and self.component.non_visual: + if widget: + widget.clear_highlight() + widget = self.designer.widget_at_pos(*self.window.drag_window.get_center()) + if widget: + self.designer.add(self.component, 0, 0, layout=widget) + widget.clear_highlight() + self.designer.set_active_container(None) + elif isinstance(widget, Container): bounds = geometry.absolute_bounds(self.window.drag_window) widget.add_new(self.component, *bounds[:2]) widget.clear_highlight() @@ -104,6 +116,21 @@ def deselect(self, silently=False): self.controller.deselect(self, silently) +class ClickableComponent(Component): + + def __init__(self, master, component: PseudoWidget.__class__, controller): + super(ClickableComponent, self).__init__(master, component) + self.controller = controller + self.allow_drag = False + ActionNotifier.bind_event( + "", self, self.add, + text=f"{component.display_name} added" + ) + + def add(self, *_): + self.controller.select(self) + + class Selector(Label): def __init__(self, master, **cnf): @@ -179,6 +206,21 @@ def deselect(self, component, silently=False): self._on_selection_change(self.selected) +class ClickToAddGroup(ComponentGroup): + + def __init__(self, master, name, items, evaluator=None, component_class=None): + component_class = component_class or ClickableComponent + super(ClickToAddGroup, self).__init__(master, name, items, evaluator, component_class) + self._on_selection_change = None + + def on_select(self, func, *args, **kwargs): + self._on_selection_change = lambda component: func(component, *args, **kwargs) + + def select(self, component): + if self._on_selection_change: + self._on_selection_change(component.component) + + class CustomPathControl(ListControl): def __init__(self, master, pref_, path, desc, **extra): diff --git a/studio/feature/design.py b/studio/feature/design.py index cb504a9..868148c 100644 --- a/studio/feature/design.py +++ b/studio/feature/design.py @@ -198,6 +198,7 @@ def __init__(self, master, studio): self._update_throttling ) self._sorted_containers = [] + self._sorted_objs = [] self._realtime_layout_update = False self._handle_active_data = None # used to maintain id correlation for widget pasting @@ -417,6 +418,15 @@ def _refresh_container_sort(self): key=lambda x: (x.level, x.layout.layout_strategy.children.index(x)), reverse=True ) + # sort objs for non-visual parent tracking + self._sorted_objs = sorted( + self.objects, + key=lambda x: ( + x.level, + -1 if x.non_visual else x.layout.layout_strategy.children.index(x) + ), + reverse=True + ) def _on_handle_active_start(self, widget, direction): self._handle_active_data = widget, direction @@ -572,6 +582,14 @@ def layout_at_pos(self, x, y): if geometry.is_pos_within(geometry.bounds(self), (x, y)): return self + def widget_at_pos(self, x, y): + x, y = geometry.resolve_position((x, y), self) + for obj in self._sorted_objs: + if geometry.is_pos_within(geometry.bounds(obj), (x, y)): + return obj + if geometry.is_pos_within(geometry.bounds(self), (x, y)): + return self + def show_select_region(self, bounds): bounds = geometry.resolve_bounds(bounds, self) self._region_highlight.highlight_bounds(bounds) @@ -677,15 +695,16 @@ def add(self, obj_class: PseudoWidget.__class__, x, y, **kwargs): self._attach(obj) # apply extra bindings required # If the object has a layout which actually the layout at the point of creation prepare and pass it # to the layout - if isinstance(layout, Container): + restore_point = None + if isinstance(layout, Container) or obj.non_visual: if obj.non_visual: layout.add_non_visual_widget(obj) else: bounds = (x, y, x + width, y + height) bounds = geometry.resolve_bounds(bounds, self) layout.add_widget(obj, bounds) + restore_point = layout.get_restore(obj) self.studio.add(obj, layout) - restore_point = layout.get_restore(obj) # Create an undo redo point if add is not silent if not silent: self.studio.new_action(Action( diff --git a/studio/feature/stylepane.py b/studio/feature/stylepane.py index 3303ef6..559ffd8 100644 --- a/studio/feature/stylepane.py +++ b/studio/feature/stylepane.py @@ -458,7 +458,7 @@ def on_widgets_change(self): self._prev_layout = layout_strategy if self.widgets: - self.label = _("Layout") + f"({self.widgets[0].layout.layout_strategy.name})" + self.label = _("Layout") + " " + f"({self.widgets[0].layout.layout_strategy.name})" else: self.label = _("Layout") diff --git a/studio/lib/legacy.py b/studio/lib/legacy.py index f6e6ad5..38b9d8b 100644 --- a/studio/lib/legacy.py +++ b/studio/lib/legacy.py @@ -153,7 +153,7 @@ def __init__(self, master, id_): class Menu(PseudoWidget, tk.Menu): display_name = 'Menu' group = Groups.container - icon = "menu" + icon = "menubutton" impl = tk.Menu non_visual = True @@ -164,6 +164,10 @@ def __init__(self, master, id_=None, **kw): self.id = id_ self.setup_widget() + def create_menu(self): + from studio.i18n import _ + return (("cascade", _("Preview"), None, None, {'menu': self}),) + class Menubutton(PseudoWidget, tk.Menubutton): display_name = 'Menubutton' diff --git a/studio/lib/menu.py b/studio/lib/menu.py index 17368e8..69a9562 100644 --- a/studio/lib/menu.py +++ b/studio/lib/menu.py @@ -1,8 +1,20 @@ +import abc import logging import tkinter as tk from hoverset.data.images import load_tk_image +from studio.lib.properties import get_resolved, PROPERTY_TABLE, WIDGET_IDENTITY from studio.lib.variables import VariableManager, VariableItem +__all__ = ( + "MENU_ITEMS", + "Command", + "Cascade", + "CheckButton", + "RadioButton", + "Separator", + "menu_config" +) + MENU_PROPERTY_TABLE = { "hidemargin": { "name": "hidemargin", @@ -91,7 +103,149 @@ def get(menu, index, prop): } -def menu_config(parent_menu, index, key=None, **kw): +class MenuItem(abc.ABC): + OVERRIDES = {} + icon = "menubutton" + display_name = "Item" + _intercepts = { + "image": _ImageIntercept, + "selectimage": _ImageIntercept, + "variable": _VariableIntercept + } + + def __init__(self, menu, index, create=True, **kw): + self.menu = menu + self._index = index + if create: + self._create(**kw) + self.node = None + + def _create(self, *args, **options): + pass + + @property + def name(self): + if not self.menu: + return "" + if self.item_type == "separator": + return "Separator" + return self.menu.entrycget(self.index, "label") + + @property + def item_type(self): + return self.__class__.__name__.lower() + + def create_menu(self): + return () + + @property + def index(self): + return self._index + int(self.menu["tearoff"]) + + @property + def properties(self): + conf = self.configure() + resolved = {} + for prop in conf: + definition = get_resolved( + prop, self.OVERRIDES, MENU_PROPERTY_TABLE, + PROPERTY_TABLE, WIDGET_IDENTITY + ) + if definition: + definition["value"] = self.cget(prop) + definition["default"] = conf[prop][-2] + resolved[prop] = definition + return resolved + + def __setitem__(self, key, value): + self.configure({key: value}) + + def configure(self, cnf=None, **kw): + return menu_config(self.menu, self.index, None, cnf, **kw) + + def config(self, cnf=None, **kw): + # This allows un-intercepted configuration + return self.menu.entryconfigure(self.index, cnf, **kw) + + def __getitem__(self, item): + return self.menu.entrycget(self.index, item) + + def cget(self, key): + intercept = self._intercepts.get(key) + if intercept: + return intercept.get(self.menu, self.index, key) + return self.menu.entrycget(self.index, key) + + def get_altered_options(self): + keys = menu_config(self.menu, self.index) + return {key: keys[key][-1] for key in keys if keys[key][-1] != keys[key][-2]} + + +class Command(MenuItem): + icon = "play" + display_name = "Command" + + def _create(self, **options): + super()._create(**options) + self.menu.add_command(**options) + + +class Cascade(MenuItem): + icon = "menubutton" + display_name = "Cascade" + + def __init__(self, menu, index, create=True, **kw): + super().__init__(menu, index, create, **kw) + self.sub_menu = None + + def _create(self, **options): + super()._create(**options) + self.menu.add_cascade(**options) + + def create_menu(self): + from studio.i18n import _ + return ( + ("separator",), + ("cascade", _("Preview"), None, None, {'menu': self.sub_menu}), + ) + + +class CheckButton(MenuItem): + icon = "checkbox" + display_name = "Check Button" + + def _create(self, **options): + super()._create(**options) + self.menu.add_checkbutton(**options) + + +class RadioButton(MenuItem): + icon = "radiobutton" + display_name = "Radio Button" + + def _create(self, **options): + super()._create(**options) + self.menu.add_radiobutton(**options) + + +class Separator(MenuItem): + icon = "line" + display_name = "Separator" + + def _create(self, **options): + # ignore options + super()._create() + self.menu.add_separator() + + +MENU_ITEMS = ( + Command, Cascade, CheckButton, RadioButton, Separator +) + + +def menu_config(parent_menu, index, key=None, cnf=None, **kw): + cnf = cnf or {} + kw.update(cnf) if not kw: if key in _intercepts: return _intercepts.get(key).get(parent_menu, index, key) diff --git a/studio/lib/properties.py b/studio/lib/properties.py index 89da5d3..415a3b9 100644 --- a/studio/lib/properties.py +++ b/studio/lib/properties.py @@ -1,6 +1,8 @@ """ Contains all the widget properties used in the designer and specifies all the styles that can be applied to a widget """ +import tkinter + # ======================================================================= # # Copyright (C) 2019 Hoverset Group. # # ======================================================================= # @@ -289,6 +291,12 @@ def all_supported_cursors() -> tuple: "type": "number", "from": -1, }, + "menu": { + "display_name": "menu", + "type": "widget", + "include": [tkinter.Menu], + "criteria": "descendant" + }, "offrelief": { "display_name": "off relief", "type": "relief", @@ -564,10 +572,6 @@ def all_supported_cursors() -> tuple: "display_name": "label widget", "type": "color", }, - "_menu": { - "display_name": "menu", - "type": "color", - }, "_offset": { "display_name": "offset", "type": "color", diff --git a/studio/lib/pseudo.py b/studio/lib/pseudo.py index 7a568ee..c1355b2 100644 --- a/studio/lib/pseudo.py +++ b/studio/lib/pseudo.py @@ -73,6 +73,31 @@ def get(widget, _): return widget.id +class _WidgetIntercept: + + __slots__ = [] + + @staticmethod + def set(widget, value, prop): + setattr(widget, f"_wdgt_prop_{prop}", value) + try: + widget.config(**{prop: value}) + except: + pass + + @staticmethod + def get(widget, prop): + val = getattr(widget, f"_wdgt_prop_{prop}", "") + if isinstance(val, str): + try: + val = widget.nametowidget(val) + except: + pass + if isinstance(val, PseudoWidget): + val = val.id + return val + + class _VariableIntercept: __slots__ = [] @@ -104,6 +129,7 @@ class PseudoWidget: is_container = False allow_direct_move = True allow_drag_select = False + allow_resize = False non_visual = False # special handlers (intercepts) for attributes that need additional processing # to interface with the studio easily @@ -114,7 +140,8 @@ class PseudoWidget: "id": _IdIntercept, "textvariable": _VariableIntercept, "variable": _VariableIntercept, - "listvariable": _VariableIntercept + "listvariable": _VariableIntercept, + "menu": _WidgetIntercept } _no_defaults = { "text", @@ -136,6 +163,7 @@ def setup_widget(self): self._pos_fix = (0, 0) self.max_size = None self.min_size = None + self._non_visual_children = [] MenuUtils.bind_context(self, self.__handle_context_menu, add='+') self._handle_cls = None if not self.non_visual: @@ -159,6 +187,18 @@ def setup_widget(self): self.prev_stack_index = None self._has_init = True + @property + def layout_strategy(self): + return self.layout.layout_strategy + + @layout_strategy.setter + def layout_strategy(self, value): + pass + + @property + def all_children(self): + return self._non_visual_children + def set_name(self, name): pass @@ -420,6 +460,26 @@ def get_resolved_methods(self): def handle_method(self, name, *args, **kwargs): pass + def react(self, bounds): + self.designer.set_active_container(self) + self.show_highlight() + + def add_non_visual_widget(self, widget): + widget.level = self.level + widget.layout = self + self._non_visual_children.append(widget) + + def restore_widget(self, widget, restore_point): + if widget.non_visual: + self.add_non_visual_widget(widget) + + def remove_widget(self, widget): + if widget.non_visual: + self._non_visual_children.remove(widget) + + def get_restore(self, widget): + return None + def __repr__(self): return f"{self.__class__.__name__}<{self.id}>" @@ -435,10 +495,9 @@ def setup_widget(self): self.body = self self._level = 0 self._children = [] - self._non_visual_children = [] if len(self.LAYOUTS) == 0: raise ValueError("No layouts have been defined") - self.layout_strategy = layouts.PlaceLayoutStrategy(self) + self._layout_strategy = layouts.PlaceLayoutStrategy(self) self.layout_var = tkinter.StringVar() self.layout_var.set(self.layout_strategy.name) super().setup_widget() @@ -465,6 +524,14 @@ def clear_highlight(self): super().clear_highlight() self.layout_strategy.clear_indicators() + @property + def layout_strategy(self): + return self._layout_strategy + + @layout_strategy.setter + def layout_strategy(self, value): + self._layout_strategy = value + @property def all_children(self): return self._children + self._non_visual_children diff --git a/studio/main.py b/studio/main.py index 73511e3..530e7f8 100644 --- a/studio/main.py +++ b/studio/main.py @@ -375,10 +375,32 @@ def designer(self): if isinstance(self.context, DesignContext): return self.context.designer - def get_widgets(self): - if self.designer: + def _extract_descendants(self, widget, widget_set): + for child in widget.all_children: + widget_set.add(child) + self._extract_descendants(child, widget_set) + + def get_widgets(self, criteria=None): + if not self.designer: + return + + if criteria is None: return self.designer.objects + widgets = None + for widget in self.selection: + descendants = set() + if criteria == "descendant": + self._extract_descendants(widget, descendants) + elif criteria == "child": + descendants = set(widget.all_children) + if widgets: + widgets &= descendants + else: + widgets = descendants + + return widgets + def _show_empty(self, text): if text: self._tab_view_empty.lift() diff --git a/studio/parsers/loader.py b/studio/parsers/loader.py index adaa6ac..90a1832 100644 --- a/studio/parsers/loader.py +++ b/studio/parsers/loader.py @@ -38,6 +38,10 @@ class BaseStudioAdapter(BaseAdapter): 'ttk': native } + _deferred_props = ( + "menu", + ) + @classmethod def _get_class(cls, node): module, impl = node.get_mod_impl() @@ -69,8 +73,9 @@ def generate(cls, widget: PseudoWidget, parent=None): node = Node(parent, get_widget_impl(widget)) node.attrib['name'] = widget.id node["attr"] = attr - layout_options = widget.layout.get_altered_options_for(widget) - node["layout"] = layout_options + if not widget.non_visual: + layout_options = widget.layout.get_altered_options_for(widget) + node["layout"] = layout_options scroll_conf = {} if isinstance(getattr(widget, "_cnf_x_scroll", None), PseudoWidget): @@ -120,19 +125,27 @@ def generate(cls, widget: PseudoWidget, parent=None): def load(cls, node, designer, parent, bounds=None): obj_class = cls._get_class(node) attrib = node.attrib - styles = attrib.get("attr", {}) + # use copy to maintain integrity of tree on pop + styles = dict(attrib.get("attr", {})) if obj_class in (native.VerticalPanedWindow, native.HorizontalPanedWindow): - # use copy to maintain integrity of XMLForm on pop - styles = dict(styles) if 'orient' in styles: styles.pop('orient') + + _deferred_props = [] + for prop in cls._deferred_props: + if prop in styles: + _deferred_props.append((prop, styles.pop(prop))) + layout = attrib.get("layout", {}) - old_id = new_id = attrib["name"] - if not designer._is_unique_id(old_id): + old_id = new_id = attrib.get("name") + if not designer._is_unique_id(old_id) or old_id is None: new_id = designer._get_unique(obj_class) obj = designer.load(obj_class, new_id, parent, styles, layout, bounds) + for prop, value in _deferred_props: + designer._deferred_props.append((prop, value, obj.configure)) + # store id cross-mapping for post-processing if old_id != new_id: designer._xlink_map[old_id] = obj @@ -185,6 +198,7 @@ def get_altered_options(widget): class MenuStudioAdapter(BaseStudioAdapter): _types = [tk.COMMAND, tk.CHECKBUTTON, tk.RADIOBUTTON, tk.SEPARATOR, tk.CASCADE] + _tool = None @staticmethod def get_item_options(menu, index): @@ -196,20 +210,20 @@ def get_item_options(menu, index): @classmethod def generate(cls, widget: PseudoWidget, parent=None): node = BaseStudioAdapter.generate(widget, parent) - node.remove_attrib('menu', 'attr') - if widget.configure().get('menu')[-1]: - menu = widget.nametowidget(widget['menu']) - cls._menu_to_xml(node, menu) + cls._menu_to_node(node, widget) return node @classmethod def load(cls, node, designer, parent, bounds=None): widget = BaseStudioAdapter.load(node, designer, parent, bounds) - cls._menu_from_xml(node, None, widget) + cls._menu_from_node(node, widget) + if cls._tool: + cls._tool.initialize(widget) + cls._tool.rebuild_tree(widget) return widget @classmethod - def _menu_from_xml(cls, node, menu=None, widget=None): + def _menu_from_node(cls, node, menu): for sub_node in node: if sub_node.type in _ignore_tags and sub_node.type not in MenuStudioAdapter._types or sub_node.is_var(): continue @@ -222,34 +236,34 @@ def _menu_from_xml(cls, node, menu=None, widget=None): continue obj_class = cls._get_class(sub_node) - if obj_class == legacy.Menu: - menu_obj = obj_class(widget, **attrib.get("attr", {})) - if widget: - widget.configure(menu=menu_obj) - elif menu: + if issubclass(obj_class, legacy.Menu): + menu_obj = obj_class(menu, **attrib.get("attr", {})) + if menu: menu.add(tk.CASCADE, menu=menu_obj) menu_config(menu, menu.index(tk.END), **attrib.get("menu", {})) - cls._menu_from_xml(sub_node, menu_obj) + cls._menu_from_node(sub_node, menu_obj) @classmethod - def _menu_to_xml(cls, node, menu: legacy.Menu, **item_opt): + def _menu_to_node(cls, node, menu: legacy.Menu): if not menu: return size = menu.index(tk.END) if size is None: # menu is empty size = -1 - menu_node = Node(node, get_widget_impl(menu)) - menu_node["attr"] = cls.get_altered_options(menu) - menu_node["menu"] = item_opt for i in range(size + 1): if menu.type(i) == tk.CASCADE: - cls._menu_to_xml(menu_node, - menu.nametowidget(menu.entrycget(i, 'menu')), **cls.get_item_options(menu, i)) + sub_menu = menu.nametowidget(menu.entrycget(i, 'menu')) + menu_node = Node(node, get_widget_impl(sub_menu)) + menu_node["attr"] = cls.get_altered_options(sub_menu) + menu_node["menu"] = cls.get_item_options(menu, i) + cls._menu_to_node( + menu_node, + sub_menu, + ) elif menu.type(i) != 'tearoff': - sub_node = Node(menu_node, menu.type(i)) + sub_node = Node(node, menu.type(i)) sub_node["menu"] = cls.get_item_options(menu, i) - return menu_node class VariableStudioAdapter(BaseStudioAdapter): @@ -274,12 +288,16 @@ def load(cls, node, *_): class DesignBuilder: _adapter_map = { - legacy.Menubutton: MenuStudioAdapter, - native.Menubutton: MenuStudioAdapter, - legacy.Toplevel: MenuStudioAdapter, - legacy.Tk: MenuStudioAdapter + legacy.Menu: MenuStudioAdapter, } + _menu_containers = ( + legacy.Menubutton, + native.Menubutton, + legacy.Toplevel, + legacy.Tk + ) + def __init__(self, designer): self.designer = designer self.root = None @@ -312,6 +330,7 @@ def get_adapter(self, widget_class): return self._adapter_map.get(widget_class, BaseStudioAdapter) def load(self, path, designer): + designer._deferred_props = [] self.root = infer_format(path)(path=path).load() self._load_meta(self.root, designer) self._load_variables(self.root) @@ -347,6 +366,7 @@ def load_section(self, node, parent, bounds=None): be used instead :return: """ + self.designer._deferred_props = [] self._loaded_objs.clear() root = self._load_widgets(node, self.designer, parent, bounds) self._post_process(self.designer) @@ -362,15 +382,16 @@ def _load_widgets(self, node, designer, parent, bounds=None): except Exception as e: # Append line number causing error before re-raising for easier debugging by user raise e.__class__("{}{}".format(line_info, e)) from e - if not isinstance(widget, Container): - # We don't need to load child tags of non-container widgets + if isinstance(widget, legacy.Menu): + if node.attrib.get("name") is None: + # old-style menu format, so assign it to parent "menu" attribute + if isinstance(parent, self._menu_containers): + designer._deferred_props.append(("menu", widget, parent.configure)) return widget for sub_node in node: if sub_node.is_var() or sub_node.type in _ignore_tags: # ignore variables and non widget nodes continue - if BaseStudioAdapter._get_class(sub_node) == legacy.Menu: - continue self._load_widgets(sub_node, designer, widget) return widget @@ -392,6 +413,9 @@ def _post_process(self, designer): if hasattr(w, "_cnf_x_scroll"): w._cnf_x_scroll = lookup.get(w._cnf_x_scroll, '') + for prop, value, handle_method in designer._deferred_props: + handle_method(**{prop: lookup.get(value, value)}) + def to_tree(self, widget, parent=None, with_node=None): """ Convert a PseudoWidget widget and its children to a node @@ -406,11 +430,12 @@ def to_tree(self, widget, parent=None, with_node=None): if node is None: adapter = self.get_adapter(widget.__class__) node = adapter.generate(widget, parent) + for child in widget._non_visual_children: + self.to_tree(child, node) if isinstance(widget, Container): - for child in widget._non_visual_children: - self.to_tree(child, node) for child in widget._children: self.to_tree(child, node) + return node def _variables_to_tree(self, parent): diff --git a/studio/tools/__init__.py b/studio/tools/__init__.py index faeb88a..0374226 100644 --- a/studio/tools/__init__.py +++ b/studio/tools/__init__.py @@ -2,6 +2,7 @@ from hoverset.ui.menu import ShowIf, EnableIf from hoverset.ui.icons import get_icon_image +from studio.tools.menu import MenuToolX from studio.tools.menus import MenuTool from studio.tools.canvas import CanvasTool from studio.tools._base import BaseTool @@ -10,6 +11,7 @@ TOOLS = ( MenuTool, CanvasTool, + MenuToolX ) diff --git a/studio/tools/canvas.py b/studio/tools/canvas.py index 57ac492..df1db21 100644 --- a/studio/tools/canvas.py +++ b/studio/tools/canvas.py @@ -623,6 +623,7 @@ 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, widgets, data): + data = data[0] for item in data: item.configure(data[item]) if item._controller: @@ -665,7 +666,7 @@ def __init__(self, master=None, **config): self._color = self.style.colors["secondary1"] self.name_pad.configure(text=self.item.name) self.icon_pad.configure( - image=icon(self.item.icon, 15, 15) + image=icon(self.item.icon, 15, 15, color=self._color) ) self.editable = True self.strict_mode = True diff --git a/studio/tools/menu.py b/studio/tools/menu.py new file mode 100644 index 0000000..4e547e6 --- /dev/null +++ b/studio/tools/menu.py @@ -0,0 +1,541 @@ +# ======================================================================= # +# Copyright (C) 2024 Hoverset Group. # +# ======================================================================= # +import tkinter + +from hoverset.data.actions import Routine +from hoverset.data.keymap import KeyMap, CharKey +from hoverset.ui.icons import get_icon_image as icon +from hoverset.ui.menu import MenuUtils, EnableIf, LoadLater +from hoverset.util.execution import Action +from studio.feature import ComponentPane +from studio.feature.components import ClickToAddGroup +from studio.feature.stylepane import StyleGroup, AttributeGroup +from studio.i18n import _ +from studio.lib import NameGenerator +from studio.lib.legacy import Menu +from studio.lib.menu import * +from studio.lib.menu import MenuItem +from studio.parsers.loader import MenuStudioAdapter +from studio.tools._base import BaseTool +from studio.ui.tree import NestedTreeView + +_item_map = { + tkinter.COMMAND: Command, + tkinter.CHECKBUTTON: CheckButton, + tkinter.RADIOBUTTON: RadioButton, + tkinter.SEPARATOR: Separator, + tkinter.CASCADE: Cascade +} + + +class MenuTreeView(NestedTreeView): + class Node(NestedTreeView.Node): + def __init__(self, parent, **config): + super().__init__(parent, **config) + self.item: MenuItem = config.get('item') + self.item.node = self + self._color = self.style.colors["secondary2"] + self.name_pad.configure(text=self.item.name) + self.icon_pad.configure( + image=icon(self.item.icon, 15, 15, color=self._color) + ) + self.editable = True + self.strict_mode = True + + def color(self): + return self.style.colors["secondary2"] + + def widget_modified(self, widget): + if not isinstance(widget, MenuItem): + return + self.item = widget + self.name_pad.configure(text=self.item.name) + self.icon_pad.configure( + image=icon(self.item.icon, 15, 15, color=self._color) + ) + + def __init__(self, menu, **kw): + super().__init__(menu.node, **kw) + self.menu_node = menu.node + self.menu = menu + self.allow_multi_select(True) + + def add(self, node): + super().add(node) + if self not in self.menu_node.nodes: + self.menu_node.add(self) + + def insert(self, index=None, *nodes): + super().insert(index, *nodes) + if self not in self.menu_node.nodes and nodes: + self.menu_node.add(self) + + def remove(self, node): + super().remove(node) + if not self.nodes: + self.menu_node.remove(self) + + def reorder(self, reorder_data): + for item in reorder_data: + self.insert(reorder_data[item], item.node) + + +class MenuStyleGroup(StyleGroup): + + def __init__(self, master, pane, **cnf): + self.tool = cnf.pop('tool', None) + super().__init__(master, pane, **cnf) + self.label = _("Menu Item") + self.prop_keys = None + self._prev_prop_keys = set() + self._empty_message = _("Select menu item to see styles") + + @property + def menu_items(self): + return self.tool.selected_items + + def supports_widgets(self): + if len(self.widgets) != 1: + return False + widget = self.widgets[0] + if self.tool.menu != widget: + return False + return bool(self.menu_items) + + def can_optimize(self): + return self.prop_keys == self._prev_prop_keys + + def compute_prop_keys(self): + items = self.menu_items + if not items: + self.prop_keys = set() + else: + self.prop_keys = None + # determine common configs for multi-selected items + for item in self.menu_items: + if self.prop_keys is None: + self.prop_keys = set(item.configure()) + else: + self.prop_keys &= set(item.configure()) + + def on_widgets_change(self): + self._prev_prop_keys = self.prop_keys + self.compute_prop_keys() + super(MenuStyleGroup, self).on_widgets_change() + self.style_pane.remove_loading() + + def _get_prop(self, prop, widget): + # not very useful to us + return None + + def _get_key(self, widget, prop): + # generate a key identifying the multi-selection state and prop modified + return f"{','.join(map(lambda x: str(x._index), self.menu_items))}:{prop}" + + def _get_action_data(self, widget, prop): + return {item: {prop: item.cget(prop)} for item in self.menu_items} + + def _apply_action(self, prop, value, widgets, data): + data = data[0] + for item in data: + item.configure(data[item]) + if self.tool.menu == widgets[0]: + self.on_widgets_change() + self.tool.on_items_modified(data.keys()) + + def _set_prop(self, prop, value, widget): + for item in self.menu_items: + item.configure({prop: value}) + self.tool.on_items_modified(self.menu_items) + + def get_definition(self): + if not self.menu_items: + return {} + rough_definition = self.menu_items[0].properties + if len(self.menu_items) == 1: + # for single item no need to refine definitions any further + return rough_definition + resolved = {} + for prop in self.prop_keys: + if prop not in rough_definition: + continue + definition = resolved[prop] = rough_definition[prop] + # use default for value + definition.update(value=definition['default']) + return resolved + + +class CascadeMenuStyleGroup(AttributeGroup): + + def __init__(self, master, pane, **cnf): + self.tool = cnf.pop('tool', None) + super().__init__(master, pane, **cnf) + self.label = _("Cascade Menu Attributes") + self._widgets = [] + + @property + def widgets(self): + return self._widgets + + def supports_widgets(self): + items = self.tool.selected_items + if any(item.item_type != "cascade" for item in items): + return False + items = list(filter(lambda x: x.item_type == "cascade", items)) + self._widgets = [i.sub_menu for i in items] + if len(super().widgets) != 1: + return False + widget = super().widgets[0] + if widget != self.tool.menu: + return False + return isinstance(widget, Menu) and items + + def can_optimize(self): + return False + + +class MenuToolX(BaseTool): + name = 'Menu' + icon = 'menubutton' + + def __init__(self, studio, manager): + super().__init__(studio, manager) + self.menu = None + MenuStudioAdapter._tool = self + self._component_pane: ComponentPane = self.studio.get_feature(ComponentPane) + self.item_select = self._component_pane.register_group( + _("Menu"), MENU_ITEMS, ClickToAddGroup, self._evaluator + ) + self.item_select.on_select(self.on_item_add) + studio.style_pane.add_group( + MenuStyleGroup, tool=self + ) + studio.style_pane.add_group( + CascadeMenuStyleGroup, tool=self + ) + + self.generator = NameGenerator(self.studio.pref) + + self.studio.bind("<>", self.on_select, "+") + + self._clipboard = None + + self.keymap = KeyMap(None) + CTRL = KeyMap.CTRL + self.routines = ( + Routine(self.cut_items, 'CUT', 'Cut selected items', 'canvas', CTRL + CharKey('x')), + Routine(self.copy_items, 'COPY', 'Copy selected items', 'canvas', CTRL + CharKey('c')), + Routine(self.paste_items, 'PASTE', 'Paste selected items', 'canvas', CTRL + CharKey('v')), + Routine(self.remove_items, 'DELETE', 'Delete selected items', 'canvas', KeyMap.DELETE), + ) + self.keymap.add_routines(*self.routines) + + self._item_context_menu = MenuUtils.make_dynamic(( + EnableIf( + lambda: self.selected_items, + ("separator",), + ("command", _("copy"), icon("copy", 18, 18), self._get_routine('COPY'), {}), + EnableIf( + lambda: self._clipboard is not None, + ("command", _("paste"), icon("clipboard", 18, 18), self._get_routine('PASTE'), {}) + ), + ("command", _("cut"), icon("cut", 18, 18), self._get_routine('CUT'), {}), + ("separator",), + ("command", _("delete"), icon("delete", 18, 18), self._get_routine('DELETE'), {}), + LoadLater( + lambda: self.selected_items[0].create_menu() if len(self.selected_items) == 1 else ()), + ), + ), self.studio, self.studio.style) + + def _evaluator(self, widget): + return isinstance(widget, Menu) and len(self.studio.selection) == 1 + + def _get_routine(self, key): + for routine in self.routines: + if routine.key == key: + return routine + + def _show_item_menu(self, item): + def show(event): + if item not in self.selected_items: + item.node.select() + MenuUtils.popup(event, self._item_context_menu) + return show + + @staticmethod + def refresh_menu_indices(menu): + if hasattr(menu, "_items"): + for i, item in enumerate(menu._items): + item._index = i + + @property + def selected_items(self): + if self.menu: + return [node.item for node in self.menu._sub_tree.get()] + return [] + + def create_item(self, component=None, menu=None, item=None, sub_menu=None, silently=False): + if menu is None: + if item is None: + return + menu = item.menu + + index = len(menu._items) + if item is None: + name = self.generator.generate(component, None) + item = component(menu, index, label=name) + + menu._items.append(item) + if menu == self.menu or hasattr(menu, "_sub_tree"): + node = menu._sub_tree + else: + node = menu.node + + item_node = node.add_as_node(item=item) + MenuUtils.bind_all_context(item_node, self._show_item_menu(item)) + if isinstance(item, Cascade): + sub_menu = Menu(self.menu, tearoff=0) if sub_menu is None else sub_menu + sub_menu.node = sub_menu.real_node = item_node + sub_menu._items = [] + menu.entryconfig(item.index, menu=sub_menu) + item.sub_menu = sub_menu + + if not silently: + config = item.get_altered_options() + self.studio.new_action(Action( + lambda _: self.remove_items([item], silently=True), + lambda _: self.restore_items([item], [menu], [index], [config]) + )) + return item + + def cut_items(self): + if self.selected_items: + self.copy_items() + self.remove_items() + + def _deep_copy(self, item): + options = item.get_altered_options() + if 'menu' in options: + options.pop('menu') + if item.item_type == 'cascade': + return ( + item.item_type, + options, + [self._deep_copy(i) for i in item.sub_menu._items] + ) + return item.item_type, options, None + + def copy_items(self): + if self.selected_items: + self._clipboard = [ + self._deep_copy(item) for item in self.selected_items + ] + + def _create_paste_items(self, items, nodes, data): + for item_type, options, sub_items in data: + for node in nodes: + if isinstance(node, Menu): + menu = node + else: + menu = node.item.sub_menu + item = _item_map[item_type](menu, len(menu._items), **options) + self.create_item(item=item, silently=True) + item.node.widget_modified(item) + if items is not None: + items.append(item) + if sub_items: + self._create_paste_items(None, [item.node], sub_items) + + def paste_items(self, clipboard=None): + clipboard = self._clipboard if clipboard is None else clipboard + if not clipboard: + return + nodes = [i for i in self.menu._sub_tree.get() if isinstance(i.item, Cascade)] + if not nodes: + if not self.menu: + return + nodes = [self.menu] + items = [] + self._create_paste_items(items, nodes, clipboard) + + item_data = [(item, item.menu, item._index, item.get_altered_options()) for item in items] + self.studio.new_action(Action( + lambda _: self.remove_items(items, silently=True), + lambda _: self.restore_items(*zip(*item_data)) + )) + + def _deselect(self, item): + self.menu._sub_tree.deselect(item.node) + + def remove_items(self, items=None, silently=False): + if items is None: + items = self.selected_items + + # to make sure items are re-inserted in the right order later + items = sorted(items, key=lambda x: x._index) + menus, configs = [], [] + # store original indices of items to be removed + indices = [item._index for item in items] + for item in items: + menu = item.menu + if item not in menu._items: + continue + self._deselect(item) + indices.append(item._index) + configs.append(item.get_altered_options()) + menu.delete(item.index) + menu._items.remove(item) + MenuToolX.refresh_menu_indices(menu) + item.node.remove() + menus.append(menu) + + if not silently: + self.studio.new_action(Action( + lambda _: self.restore_items(items, menus, indices, configs), + lambda _: self.remove_items(items, silently=True) + )) + + def restore_items(self, items, menus, indices, configs): + unique_menus = set() + for item, menu, index, config in zip(items, menus, indices, configs): + item.menu = menu + menu._items.insert(index, item) + menu.insert(index + int(menu["tearoff"]), item.item_type, **config) + menu.real_node.insert(index, item.node) + unique_menus.add(menu) + + for menu in unique_menus: + MenuToolX.refresh_menu_indices(menu) + + def on_item_add(self, component): + nodes = [i for i in self.menu._sub_tree.get() if isinstance(i.item, Cascade)] + if not nodes: + self.create_item(component, self.menu) + return + item_data = [] + for node in nodes: + item = self.create_item(component, node.item.sub_menu, silently=True) + item_data.append((item, item.menu, item._index, item.get_altered_options())) + + self.studio.new_action(Action( + lambda _: self.remove_items(list(zip(*item_data))[0], silently=True), + lambda _: self.restore_items(*zip(*item_data)) + )) + + def initialize(self, menu): + menu._items = [] + menu._sub_tree = MenuTreeView(menu) + menu.real_node = menu._sub_tree + menu._sub_tree.on_select(self._update_selection, menu) + menu._sub_tree.on_structure_change(self._on_tree_reorder, menu) + menu._initialized = True + + def rebuild_tree(self, menu): + if not menu: + return + size = menu.index(tkinter.END) + if size is None: + # menu is empty + size = -1 + for i in range(size + 1): + if menu.type(i) == 'tearoff': + continue + item = _item_map[menu.type(i)](menu, len(menu._items), create=False) + if menu.type(i) == tkinter.CASCADE: + sub_menu = menu.nametowidget(menu.entrycget(i, 'menu')) + self.create_item(item=item, sub_menu=sub_menu, silently=True) + self.rebuild_tree(sub_menu) + else: + self.create_item(item=item, silently=True) + + def _on_tree_reorder(self, menu): + items = [node.item for node in menu._sub_tree.get()] + if not items: + return + + parent_node = items[0].node.parent_node + if isinstance(parent_node, MenuTreeView.Node): + dest_menu = parent_node.item.sub_menu + else: + dest_menu = menu + + original_data = [] + new_data = [] + for item in items: + original_data.append(( + item, item.menu, item._index + )) + new_data.append(( + item, dest_menu, item.node.index() + )) + + # insertions only work correctly with sorted indices + original_data.sort(key=lambda x: x[2]) + new_data.sort(key=lambda x: x[2]) + self._reorder(new_data, alter_tree=False) + self.studio.new_action(Action( + lambda _: self._reorder(original_data), + lambda _: self._reorder(new_data) + )) + + def _reorder(self, reorder_data, alter_tree=True): + # deletion needs to be done in reversed order of current indices + items = sorted( + [item for item, _, _ in reorder_data], + key=lambda x: x._index, reverse=True + ) + prev_configs = {} + for item in items: + prev_configs[item] = item.get_altered_options() + item.menu.delete(item.index) + item.menu._items.remove(item) + MenuToolX.refresh_menu_indices(item.menu) + if alter_tree: + item.node.remove() + + for item, menu, index in reorder_data: + prev_config = prev_configs[item] + menu.insert(index + int(menu["tearoff"]), item.item_type, **prev_config) + menu._items.insert(index, item) + MenuToolX.refresh_menu_indices(menu) + item.menu = menu + if alter_tree: + menu.real_node.insert(index, item.node) + + def _update_selection(self, menu): + if menu != self.menu: + self.studio.selection.set(menu) + self.studio.style_pane.render_styles() + + def _clear_selection(self): + for i in [node.item for node in self.menu._sub_tree.get()]: + self.menu._sub_tree.deselect(i.node) + + def on_items_modified(self, items): + for item in items: + item.node.widget_modified(item) + + def on_widget_add(self, widget, parent): + if isinstance(widget, Menu): + self.initialize(widget) + self.rebuild_tree(widget) + + def on_select(self, _): + if len(self.studio.selection) == 1: + widget = self.studio.selection[0] + else: + widget = None + + if self.menu == widget: + return + + if self.menu: + self._clear_selection() + + if isinstance(widget, Menu): + if not getattr(widget, '_initialized', False): + self.initialize(widget) + self.menu = widget + else: + self.menu = None diff --git a/studio/ui/editors.py b/studio/ui/editors.py index 39c0b8b..2bae4fe 100644 --- a/studio/ui/editors.py +++ b/studio/ui/editors.py @@ -710,7 +710,7 @@ def _get_objs(self): if hasattr(master, "get_widgets"): include = self.style_def.get("include", ()) exclude = self.style_def.get("exclude", ()) - objs = master.get_widgets() + objs = master.get_widgets(criteria=self.style_def.get("criteria")) if include: objs = list(filter(lambda x: isinstance(x, tuple(include)), objs)) if exclude: diff --git a/studio/ui/tree.py b/studio/ui/tree.py index eacd7e2..91eccbf 100644 --- a/studio/ui/tree.py +++ b/studio/ui/tree.py @@ -45,7 +45,7 @@ def __init__(self, tree, **config): self.strip.bind_all("", self.drag) self.strip.bind_all("", self.begin_drag, add='+') # use add='+' to avoid overriding the default event which selects nodes - self.strip.bind_all("", self.end_drag, add='+') + # self.strip.bind_all("", self.end_drag) self.strip.config(**self.style.highlight) # The highlight on a normal day self._on_structure_change = None # if true allows node to be dragged and repositioned @@ -54,6 +54,11 @@ def __init__(self, tree, **config): self.strict_mode = False self.configuration = config + def _init_binding(self): + for i in (self.name_pad, self.strip): + i.bind("", self.end_drag) + i.bind("", self.select) + def on_structure_change(self, callback, *args, **kwargs): self._on_structure_change = lambda: callback(*args, **kwargs) @@ -164,6 +169,8 @@ def end_drag(self, event): self.clear_indicators() MalleableTree.drag_highlight = None MalleableTree.drag_active = False + else: + self.select(event) def highlight(self): MalleableTree.drag_highlight = self @@ -317,10 +324,13 @@ def __init__(self, tree, **config): component.config(**self.style.text_secondary1) self.name_pad.config(**self.style.text_italic) + def color(self): + return self.style.colors["secondary1"] + def _load_images(self): if self.__icons_loaded: return - color = self.style.colors["secondary1"] + color = self.color() cls = self.__class__ cls.EXPANDED_ICON = get_icon_image("chevron_down", 14, 14, color=color) cls.COLLAPSED_ICON = get_icon_image("chevron_right", 14, 14, color=color)