Designer quality of life "mods"

I have made a couple more nice features (at least I think) and made it easier to access the table wizard I made.

it adds a tool bar at the top of the property editor for perspective:

this includes 5 features

  • Copys The Custom propertys of selected component
  • paste the custom propertys to selected component
  • copys the params of selected component
  • paste the params to the selected component
  • appends the style from clipboard on top of existing styles
  • opens the table manager (if a table is selected)

the structure is as follows:
Dev.Clipboard:


import json
from java.awt import Toolkit
from java.awt.datatransfer import StringSelection, DataFlavor

def set_text(text):
    try:
        toolkit = Toolkit.getDefaultToolkit()
        toolkit.getSystemClipboard().setContents(StringSelection(text), None)
    except Exception as e:
        print("ERROR: Failed to write to clipboard: %s" % e)

def get_dict():
    try:
        toolkit = Toolkit.getDefaultToolkit()
        text = toolkit.getSystemClipboard().getData(DataFlavor.stringFlavor)
        if not text: return None
        
        payload = json.loads(text)
        if isinstance(payload, dict):
            return payload
        print("ERROR: Clipboard is not a valid JSON Dictionary.")
        return None
    except ValueError:
        print("ERROR: Clipboard content is not valid JSON.")
        return None
    except Exception as e:
        print("ERROR: Failed to read clipboard: %s" % e)
        return None

Dev.Context:

from java.awt import Window, Frame

def get_workspace_and_selection():
    designer_window = None
    for w in Window.getWindows():
        if isinstance(w, Frame) and w.getTitle() and "Ignition Designer" in w.getTitle():
            designer_window = w
            break

    if not designer_window:
         return None, None, None

    try:
        ctx = designer_window.getContext()
        p_mod = ctx.getModule("com.inductiveautomation.perspective")
        ws = p_mod.getWorkspace()
        editor = ws.getViewEditor() if hasattr(ws, 'getViewEditor') else ws.getSelectedEditor()
        selection = editor.getSelection() if hasattr(editor, 'getSelection') else None
        return ws, editor, selection
    except Exception:
        return None, None, None

Dev.PropertyActions:

import json
from java.awt.event import ActionListener
import Dev.Clipboard as clip_mgr
import Dev.Context as ctx_mgr

try:
    from com.inductiveautomation.perspective.common.api import PropertyType
except ImportError:
    from com.inductiveautomation.perspective.common.config import PropertyType

class CopyAction(ActionListener):
    def __init__(self, target_prop_type):
        self.target_prop_type = target_prop_type 
        
    def actionPerformed(self, event):
        _, _, selection = ctx_mgr.get_workspace_and_selection()
        if not selection or selection.isEmpty(): return
            
        comp_details = selection.getComponentDetails()
        if not comp_details or comp_details.isEmpty(): return
            
        comp = comp_details.get(0)
        prop_node = getattr(comp, self.target_prop_type, None)
        if prop_node is None: return
            
        raw = prop_node() if callable(prop_node) else prop_node
        try:
            json_str = unicode(raw.toString() if hasattr(raw, "toString") else str(raw))
            parsed = json.loads(json_str)
            clip_mgr.set_text(json.dumps(parsed, indent=4))
            print("SUCCESS: Copied '%s' properties to clipboard!" % self.target_prop_type)
        except Exception as e:
            print("ERROR: Failed to copy properties: %s" % e)

class PasteAction(ActionListener):
    def __init__(self, target_prop_type):
        self.target_prop_type = target_prop_type
        
    def actionPerformed(self, event):
        payload = clip_mgr.get_dict()
        if not payload: 
            print("INFO: Clipboard is empty or does not contain a valid dictionary.")
            return

        _, _, selection = ctx_mgr.get_workspace_and_selection()
        if not selection or selection.isEmpty(): 
            print("INFO: No component selected.")
            return
            
        p_type = PropertyType.custom if self.target_prop_type == "custom" else PropertyType.params
        
        try:
           
            json_payload = json.dumps(payload)
           
            selection.write(p_type, "", json_payload)
            
            print("SUCCESS: Natively injected into '%s'!" % self.target_prop_type)

        except Exception as e:
            print("ERROR: Failed to paste properties: %s" % e)

class AppendStylesAction(ActionListener):
    def actionPerformed(self, event):
        payload = clip_mgr.get_dict()
        if not payload: return

        # Smart unwrap if the user copied the parent {"style": {...}} object
        if "style" in payload and isinstance(payload["style"], dict):
            payload = payload["style"]

        _, _, selection = ctx_mgr.get_workspace_and_selection()
        if not selection or selection.isEmpty(): return
            
        existing_classes = ""
        try:
            comp = selection.getComponentDetails().get(0)
            raw_props = comp.props() if callable(comp.props) else comp.props
            props_dict = json.loads(unicode(raw_props.toString() if hasattr(raw_props, "toString") else str(raw_props)))
            
            c = props_dict.get("style", {}).get("classes", "")
            if isinstance(c, basestring):
                existing_classes = c
        except Exception: pass

        # Properties that require string units (like 'px') in CSS
        pixel_properties = ["fontSize", "width", "height", "padding", "margin", 
                            "borderRadius", "borderWidth", "top", "bottom", "left", "right"]

        try:
            for key, value in payload.items():
                # 🌟 SMART TYPE CASTER: Automatically convert numeric sizes to px strings!
                if key in pixel_properties and isinstance(value, (int, float)):
                    value = "%dpx" % int(value)

                # Smart CSS Class merging
                if key == "classes" and existing_classes and isinstance(value, basestring):
                    new_classes = existing_classes
                    for c in value.split():
                        if c not in new_classes.split():
                            new_classes += " " + c
                    value = new_classes.strip()
                
                # Write natively to the style dictionary path
                selection.write(PropertyType.props, "style." + key, json.dumps(value))
                
            print("SUCCESS: Styles successfully appended!")
        except Exception as e:
            print("ERROR: Failed to append styles: %s" % e)

Dev.TableWizard:

import json
import copy
import re
from java.awt import BorderLayout, FlowLayout, GridLayout, Dimension, Color, Window, Frame
from java.awt.event import ActionListener, KeyAdapter
from javax.swing import (JPanel, JButton, JLabel, BorderFactory, JDialog, JTextField, 
                         JCheckBox, JComboBox, JList, JScrollPane, ListSelectionModel, JColorChooser)
from javax.swing.event import ListSelectionListener
import Dev.Context as ctx_mgr

try:
    from com.inductiveautomation.perspective.common.api import PropertyType
except ImportError:
    from com.inductiveautomation.perspective.common.config import PropertyType

class LaunchWizardAction(ActionListener):
    def actionPerformed(self, event):
        launch_table_wizard()

def launch_table_wizard():
    DEFAULT_COLUMN_TEMPLATE = {
      "field": "", "visible": True, "editable": False, "render": "auto",
      "justify": "auto", "align": "center", "resizable": True, "sortable": True,
      "width": "", "strictWidth": False, 
      "style": { "classes": "" },
      "header": { "title": "", "justify": "left", "align": "center", "style": { "classes": "" } },
    }

    def _format_header_title(key):
        if not key: return ""
        formatted = re.sub(r'([a-z0-9])([A-Z])', r'\1 \2', key).replace('_', ' ').replace('-', ' ')
        return " ".join([word.capitalize() for word in formatted.split() if word])

    def _hex_to_color(hex_str, default_color):
        try:
            if hex_str.startswith("#") and len(hex_str) == 7: return Color.decode(hex_str)
        except Exception: pass
        return default_color

    class _ColumnWizardDialog:
        def __init__(self, parent_window, keys, existing_col_map):
            self.keys = keys
            self.existing_col_map = existing_col_map
            self.accepted = False
            self.configs = {}
            for k in self.keys:
                if k in self.existing_col_map:
                    c = self.existing_col_map[k]
                    h_style = c.get("header", {}).get("style", {})
                    c_style = c.get("style", {})
                    self.configs[k] = {
                        "title": c.get("header", {}).get("title", _format_header_title(k)),
                        "header_align": c.get("header", {}).get("align", "center"),
                        "content_align": c.get("align", "left"),
                        "sortable": c.get("sortable", True),
                        "resizable": c.get("resizable", True),
                        "width": c.get("width", ""),
                        "strict_width": c.get("strictWidth", False),
                        "font_size": c_style.get("fontSize", "13px"),
                        "header_bg": h_style.get("backgroundColor", "#1A202C"),
                        "header_text": h_style.get("color", "#FFFFFF"),
                        "cell_bg": c_style.get("backgroundColor", "#F4F5F7"),
                        "cell_text": c_style.get("color", "#333333"),
                        "header_extra": json.dumps({key: val for key, val in h_style.items() if key not in ["backgroundColor", "color"]}),
                        "cell_extra": json.dumps({key: val for key, val in c_style.items() if key not in ["backgroundColor", "color", "fontSize"]})
                    }
                else:
                    self.configs[k] = {
                        "title": _format_header_title(k), "header_align": "center", "content_align": "left",
                        "sortable": True, "resizable": True, "width": "", "strict_width": False, "font_size": "13px",
                        "header_bg": "#1A202C", "header_text": "#FFFFFF", "cell_bg": "#F4F5F7", "cell_text": "#333333",
                        "header_extra": '{"fontWeight": "bold"}', "cell_extra": '{"fontWeight": "bold", "border": "1px solid #E5E7EB", "padding": "8px"}'
                    }
                
            self.global_config = copy.deepcopy(self.configs[self.keys[0]] if self.keys else {})
            self.last_sel = "-- ALL COLUMNS --"
            
            self.dialog = JDialog(parent_window, "Perspective Column Editor", True)
            self.dialog.setSize(650, 700) 
            self.dialog.setLayout(BorderLayout(10, 10))
            self.dialog.setLocationRelativeTo(parent_window)

            list_panel = JPanel(BorderLayout())
            list_panel.setBorder(BorderFactory.createTitledBorder("Columns"))
            list_panel.setPreferredSize(Dimension(200, 0))
            
            self.col_list = JList(["-- ALL COLUMNS --"] + self.keys)
            self.col_list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION)
            
            class _ListProxy(ListSelectionListener):
                def __init__(self, outer): self.outer = outer
                def valueChanged(self, e):
                    if not e.getValueIsAdjusting():
                        ns = self.outer.col_list.getSelectedValue()
                        if ns != self.outer.last_sel:
                            self.outer._save_form_state()
                            self.outer._load_form_state(ns)
                            self.outer.last_sel = ns
            self.col_list.addListSelectionListener(_ListProxy(self))
            
            list_panel.add(JScrollPane(self.col_list), BorderLayout.CENTER)
            self.dialog.add(list_panel, BorderLayout.WEST)

            settings_panel = JPanel(GridLayout(15, 2, 6, 6)) 
            settings_panel.setBorder(BorderFactory.createTitledBorder("Settings"))

            settings_panel.add(JLabel(" Header Title:"))
            self.txt_title = JTextField("<Multiple Values>")
            self.txt_title.setEnabled(False)
            settings_panel.add(self.txt_title)
            
            settings_panel.add(JLabel(" Header Align:"))
            self.cb_h_align = JComboBox(["left", "center", "right"])
            settings_panel.add(self.cb_h_align)

            settings_panel.add(JLabel(" Content Align:"))
            self.cb_c_align = JComboBox(["auto", "left", "center", "right"])
            settings_panel.add(self.cb_c_align)

            self.chk_sort = JCheckBox("Sortable", True)
            settings_panel.add(self.chk_sort)
            self.chk_res = JCheckBox("Resizable", True)
            settings_panel.add(self.chk_res)

            settings_panel.add(JLabel(" Width (e.g., '150px' or ''):"))
            self.txt_width = JTextField("")
            settings_panel.add(self.txt_width)
            self.chk_sw = JCheckBox("Strict Width", False)
            settings_panel.add(self.chk_sw)
            settings_panel.add(JLabel(""))

            settings_panel.add(JLabel(" Cell Font Size:"))
            self.txt_font = JTextField("13px")
            settings_panel.add(self.txt_font)

            def make_color_action(textfield):
                class ActionProxy(ActionListener):
                    def actionPerformed(self, e):
                        chooser = JColorChooser(_hex_to_color(textfield.getText().strip(), Color.WHITE))
                        class OkProxy(ActionListener):
                            def actionPerformed(self, ev):
                                c = chooser.getColor()
                                if c: 
                                    textfield.setText("#%02X%02X%02X" % (c.getRed(), c.getGreen(), c.getBlue()))
                        dg = JColorChooser.createDialog(self.outer.dialog, "Pick", True, chooser, OkProxy(), None)
                        dg.setVisible(True)
                ap = ActionProxy(); ap.outer = self
                return ap

            for lbl, txt_ref in [(" Header BG Color:", "txt_h_bg"), (" Header Text Color:", "txt_h_text"), 
                                 (" Cell BG Color:", "txt_c_bg"), (" Cell Text Color:", "txt_c_text")]:
                settings_panel.add(JLabel(lbl))
                p = JPanel(BorderLayout(5, 0))
                tf = JTextField("#000000")
                setattr(self, txt_ref, tf)
                p.add(tf, BorderLayout.CENTER)
                p.add(JButton("🎨", actionPerformed=make_color_action(tf)), BorderLayout.EAST)
                settings_panel.add(p)

            settings_panel.add(JLabel(" Extra Header Style (JSON):"))
            self.txt_h_extra = JTextField("{}")
            settings_panel.add(self.txt_h_extra)

            settings_panel.add(JLabel(" Extra Cell Style (JSON):"))
            self.txt_c_extra = JTextField("{}")
            settings_panel.add(self.txt_c_extra)

            self.dialog.add(settings_panel, BorderLayout.CENTER)

            btn_panel = JPanel(FlowLayout(FlowLayout.RIGHT))
            class BtnProxy(ActionListener):
                def __init__(self, fn): self.fn = fn
                def actionPerformed(self, e): self.fn(e)
            
            def on_inject(e):
                self._save_form_state()
                self.accepted = True
                self.dialog.dispose()
                
            btn_panel.add(JButton("Cancel", actionPerformed=BtnProxy(lambda e: self.dialog.dispose())))
            btn_panel.add(JButton("Apply & Inject Columns", actionPerformed=BtnProxy(on_inject)))
            self.dialog.add(btn_panel, BorderLayout.SOUTH)
            self.col_list.setSelectedIndex(0)

        def _save_form_state(self):
            d = {
                "header_align": self.cb_h_align.getSelectedItem(), "content_align": self.cb_c_align.getSelectedItem(),
                "sortable": self.chk_sort.isSelected(), "resizable": self.chk_res.isSelected(),
                "width": self.txt_width.getText().strip(), "strict_width": self.chk_sw.isSelected(),
                "font_size": self.txt_font.getText().strip(), "header_bg": self.txt_h_bg.getText().strip(),
                "header_text": self.txt_h_text.getText().strip(), "cell_bg": self.txt_c_bg.getText().strip(),
                "cell_text": self.txt_c_text.getText().strip(), "header_extra": self.txt_h_extra.getText().strip(),
                "cell_extra": self.txt_c_extra.getText().strip()
            }
            if self.last_sel == "-- ALL COLUMNS --":
                self.global_config = d
                for k in self.keys: self.configs[k].update(d)
            else:
                d["title"] = self.txt_title.getText()
                self.configs[self.last_sel].update(d)

        def _load_form_state(self, sel):
            c = self.global_config if sel == "-- ALL COLUMNS --" else self.configs[sel]
            self.txt_title.setText("<Multiple Values>" if sel == "-- ALL COLUMNS --" else c.get("title", ""))
            self.txt_title.setEnabled(sel != "-- ALL COLUMNS --")
            self.cb_h_align.setSelectedItem(c.get("header_align"))
            self.cb_c_align.setSelectedItem(c.get("content_align"))
            self.chk_sort.setSelected(c.get("sortable", True))
            self.chk_res.setSelected(c.get("resizable", True))
            self.txt_width.setText(c.get("width", ""))
            self.chk_sw.setSelected(c.get("strict_width", False))
            self.txt_font.setText(c.get("font_size", "13px"))
            self.txt_h_bg.setText(c.get("header_bg", ""))
            self.txt_h_text.setText(c.get("header_text", ""))
            self.txt_c_bg.setText(c.get("cell_bg", ""))
            self.txt_c_text.setText(c.get("cell_text", ""))
            self.txt_h_extra.setText(c.get("header_extra", "{}"))
            self.txt_c_extra.setText(c.get("cell_extra", "{}"))

        def show(self):
            self.dialog.setVisible(True)
            return self.configs if self.accepted else None


    # --- EXECUTE WIZARD ---
    ws, editor, selection = ctx_mgr.get_workspace_and_selection()
    if not selection or selection.isEmpty():
        print("ERROR: No Table selected.")
        return

    comp = selection.getComponentDetails().get(0)
    raw_props = comp.props() if callable(comp.props) else comp.props
    try:
        props_dict = json.loads(unicode(raw_props.toString() if hasattr(raw_props, "toString") else str(raw_props)))
    except Exception: 
        return

    props_data = props_dict.get("data", {})
    existing_cols = props_dict.get("columns", [])
    
    existing_col_map = {str(c["field"]): c for c in existing_cols if isinstance(c, dict) and "field" in c}
    column_keys = set(existing_col_map.keys())
    
    if isinstance(props_data, list) and len(props_data) > 0 and isinstance(props_data[0], dict):
        column_keys.update([str(k) for k in props_data[0].keys() if not str(k).startswith("$")])
    elif isinstance(props_data, dict):
        if "$columns" in props_data:
            column_keys.update([str(c.get("name", "")) for c in props_data["$columns"]])
        elif "columns" in props_data:
            column_keys.update([str(c.get("name", "")) for c in props_data["columns"]])
        elif "$a" in props_data and isinstance(props_data["$a"], list) and len(props_data["$a"]) > 0:
            column_keys.update([str(k) for k in props_data["$a"][0].keys() if k != "$"])
        elif "type" not in props_data: 
            column_keys.update([str(k) for k in props_data.keys() if not str(k).startswith("$")])

    sorted_keys = sorted(list(column_keys))
    
    designer_window = [w for w in Window.getWindows() if isinstance(w, Frame) and "Ignition Designer" in w.getTitle()][0]
    
    # 🌟 SMART FALLBACK: Allow JSON string pasting!
    if not sorted_keys: 
        input_str = JOptionPane.showInputDialog(
            designer_window, 
            "No columns detected (Data might be bound).\n\nYou can type comma-separated names OR paste a JSON sample row/array here:", 
            "Data Schema Entry", 
            JOptionPane.QUESTION_MESSAGE
        )
        if not input_str or not input_str.strip():
            print("INFO: User cancelled manual column entry.")
            return
            
        input_str = input_str.strip()
        
        try:
            # Try to parse the pasted text as JSON
            pasted_json = json.loads(input_str)
            if isinstance(pasted_json, list) and len(pasted_json) > 0 and isinstance(pasted_json[0], dict):
                sorted_keys = [str(k) for k in pasted_json[0].keys() if not str(k).startswith("$")]
            elif isinstance(pasted_json, dict):
                sorted_keys = [str(k) for k in pasted_json.keys() if not str(k).startswith("$")]
        except Exception:
            # If it's not valid JSON, treat it as comma-separated values
            sorted_keys = [k.strip() for k in input_str.split(",") if k.strip()]

    if not sorted_keys:
        print("ERROR: Could not parse any keys from your input.")
        return

    wizard = _ColumnWizardDialog(designer_window, sorted_keys, existing_col_map)
    final_configs = wizard.show()
    
    if not final_configs: return

    new_columns = []
    for key in sorted_keys:
        c_state = final_configs[key]
        col = existing_col_map.get(key, copy.deepcopy(DEFAULT_COLUMN_TEMPLATE))
        col.update({"field": key, "align": c_state["content_align"], "sortable": c_state["sortable"],
                    "resizable": c_state["resizable"], "width": c_state["width"], "strictWidth": c_state["strict_width"]})
        if "header" not in col: col["header"] = {}
        col["header"].update({"title": c_state["title"], "align": c_state["header_align"]})
        header_style = {"backgroundColor": c_state["header_bg"], "color": c_state["header_text"]}
        cell_style = {"backgroundColor": c_state["cell_bg"], "color": c_state["cell_text"], "fontSize": c_state["font_size"]}
        
        try:
            if c_state["header_extra"] not in ["{}", ""]: header_style.update(json.loads(c_state["header_extra"]))
            if c_state["cell_extra"] not in ["{}", ""]: cell_style.update(json.loads(c_state["cell_extra"]))
        except Exception: pass

        col["header"]["style"] = header_style
        col["style"] = cell_style
        new_columns.append(col)

    selection.write(PropertyType.props, "columns", json.dumps(new_columns))
    print("SUCCESS: Full column schema injected!")

Dev.UtilityToolbar:

from java.awt import FlowLayout, Window, Frame, Color, Insets
from javax.swing import JPanel, JButton, JLabel, BorderFactory
import Dev.PropertyActions as actions
import Dev.TableWizard as wizard

# Unique name to identify our injected JPanel container
MARKER_NAME = "dev_perspective_utility_panel"

def inject():
    """Builds and attaches the custom utility toolbar to the Property Editor's header bar."""
    # Run an uninstall first to ensure we never stack duplicate panels
    uninstall()
    
    designer_window = None
    for w in Window.getWindows():
        if isinstance(w, Frame) and w.getTitle() and "Ignition Designer" in w.getTitle():
            designer_window = w
            break

    if not designer_window: 
        return

    context = designer_window.getContext()
    propertyEditor = context.dockingManager.getFrame("PerspectivePropEditor")

    if propertyEditor:
        # Create the custom Swing panel and name it so we can find it during uninstall
        utilityPanel = JPanel(FlowLayout(FlowLayout.LEFT, 3, 1))
        utilityPanel.setName(MARKER_NAME)
        utilityPanel.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, Color.LIGHT_GRAY))
        utilityPanel.add(JLabel("Utils: "))
        
        buttons = [
            ("Copy Custom", actions.CopyAction("custom"), None),
            ("Paste Custom", actions.PasteAction("custom"), None),
            ("Copy Params", actions.CopyAction("params"), None),
            ("Paste Params", actions.PasteAction("params"), None),
            ("Append Styles", actions.AppendStylesAction(), Color(0, 100, 0)),
            ("Table Wizard", wizard.LaunchWizardAction(), Color(0, 70, 140))
        ]
        
        for text, action, color in buttons:
            btn = JButton(text)
            btn.setMargin(Insets(1, 4, 1, 4))
            btn.addActionListener(action)
            if color: btn.setForeground(color)
            utilityPanel.add(btn)

        # Bind the panel persistently to JIDE's Title Bar component slot
        propertyEditor.setTitleBarComponent(utilityPanel)
        
        # Redraw the frame
        propertyEditor.revalidate()
        propertyEditor.repaint()
        print("SUCCESS: Modular Toolbar bound to Property Editor!")


def uninstall():
    """Removes our custom panel and restores the Property Editor's title bar back to standard."""
    try:
        designer_window = None
        for w in Window.getWindows():
            if isinstance(w, Frame) and w.getTitle() and "Ignition Designer" in w.getTitle():
                designer_window = w
                break

        if not designer_window: 
            return False

        context = designer_window.getContext()
        propertyEditor = context.dockingManager.getFrame("PerspectivePropEditor")

        if propertyEditor:
            current_component = propertyEditor.getTitleBarComponent()
            
            # Check if our custom named panel is currently occupying the title bar
            if current_component and current_component.getName() == MARKER_NAME:
                # Wiping the title bar component slot completely restores JIDE's default layout
                propertyEditor.setTitleBarComponent(None)
                
                # Force immediate re-layout calculation and redrawing
                propertyEditor.revalidate()
                propertyEditor.repaint()
                print("SUCCESS: Modular Toolbar cleanly uninstalled from Property Editor.")
                return True
                
        return False
    except Exception as e:
        print("ERROR: Failed to uninstall Property Editor toolbar: %s" % e)
        return False

then just run Dev.UtilityToolbar.inject() or have it run in a vision client tag on change script as explained in this thread.