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)