# Designer quality of life "mods"

**URL:** <https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195>\
**Category:** Ignition\
**Tags:** scripting\
**Created:** [June 27, 2026, 8:10am UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195 "2026-06-27T08:10:18Z")\
**Posts on this page:** 20\
**Page:** 3

<div class="post-metadata">

**Author:** ![Chris\_Murphy](https://avatars.discourse-cdn.com/v4/letter/c/c5a1d2/32.png) [@Chris\_Murphy](https://forum.inductiveautomation.com/u/Chris_Murphy)\
**Post date:** [July 8, 2026, 7:03am UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/41 "2026-07-08T07:03:54Z")

</div>

Here's my QOL addition. Parameter presets. There are certain view usages that have a predetermined set of parameters, like a view to be used in a perspective table cell. I have a hot key that lets me pick from a set of predefined parameter sets and applies them to the view.

 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/6/e/6ed734392ebdd27a020f2f2abda583e753e6f554.png)

---

<div class="post-metadata">

**Author:** ![nminchin](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/nminchin/32/6911_2.png) [@nminchin](https://forum.inductiveautomation.com/u/nminchin)\
**Post date:** [July 8, 2026, 11:29am UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/42 "2026-07-08T11:29:43Z")

</div>

This sparked another idea, something to auto gen the columns list in a perspective table, taken from the columns in the data so you don't have to do this manually all the time.

---

<div class="post-metadata">

**Author:** ![code\_skin](https://avatars.discourse-cdn.com/v4/letter/c/43a26b/32.png) [@code\_skin](https://forum.inductiveautomation.com/u/code_skin)\
**Post date:** [July 8, 2026, 11:31am UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/43 "2026-07-08T11:31:50Z")

</div>

+1 ! This is so ideal.

---

<div class="post-metadata">

**Author:** ![Aiasa21](https://avatars.discourse-cdn.com/v4/letter/a/aeb1de/32.png) [@Aiasa21](https://forum.inductiveautomation.com/u/Aiasa21)\
**Post date:** [July 8, 2026, 12:11pm UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/44 "2026-07-08T12:11:03Z")

</div>

Also, it would be great to have the **option to show the row count** , just like when you filter.

---

<div class="post-metadata">

**Author:** ![MMaynard](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/mmaynard/32/3366_2.png) [@MMaynard](https://forum.inductiveautomation.com/u/MMaynard)\
**Post date:** [July 8, 2026, 12:30pm UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/45 "2026-07-08T12:30:57Z")

</div>

> [@nminchin](#):
>
> This sparked another idea, something to auto gen the columns list in a perspective table, taken from the columns in the data so you don't have to do this manually all the time.

I just bind the columns key to the data key and run this script transform.

```python
    return [
    {'field': col,
    'sortable': True,
    'visible': True,
    'editable': False,
    'resizable':True,
    'header': {
        'title': col
    }
    } for col in value[0]
]

```

Then I disable the binding, Of course add/remove whatever properties you normally use on your table columns, but this gets me 99% there.

---

<div class="post-metadata">

**Author:** ![pturmel](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/pturmel/32/3295_2.png) [@pturmel](https://forum.inductiveautomation.com/u/pturmel)\
**Post date:** [July 8, 2026, 1:42pm UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/46 "2026-07-08T13:42:32Z")

</div>

The item that would be of most use to me in some of my projects would be to allow tag folders to be opened in the tag editor, to allow viewing and editing of custom properties. The can be written by script and read by scripts and bindings, but there is no convenient manual editing UI.

---

<div class="post-metadata">

**Author:** ![code\_skin](https://avatars.discourse-cdn.com/v4/letter/c/43a26b/32.png) [@code\_skin](https://forum.inductiveautomation.com/u/code_skin)\
**Post date:** [July 8, 2026, 2:22pm UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/47 "2026-07-08T14:22:35Z")

</div>

Wrong thread?  
I think you meant to post to [Designer quality of life "mods"](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195)

---

<div class="post-metadata">

**Author:** ![Chris\_Murphy](https://avatars.discourse-cdn.com/v4/letter/c/c5a1d2/32.png) [@Chris\_Murphy](https://forum.inductiveautomation.com/u/Chris_Murphy)\
**Post date:** [July 9, 2026, 12:21am UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/48 "2026-07-09T00:21:15Z")

</div>

The UI is actually there which makes it extra frustrating.

To open it, initiate a search that will find that folder, then double click the search result.

 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/7/9/79d0c3fe6e46794154648e9ef9bd8e3e617211da.png)

it opens the tag editor scoped to the folder, you can add custom properties, tooltip etc. just need a way to open more naturally.

---

<div class="post-metadata">

**Author:** ![Chris\_Murphy](https://avatars.discourse-cdn.com/v4/letter/c/c5a1d2/32.png) [@Chris\_Murphy](https://forum.inductiveautomation.com/u/Chris_Murphy)\
**Post date:** [July 9, 2026, 3:46am UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/49 "2026-07-09T03:46:24Z")

</div>

Clanker generated 'edit folder' addition to tag browser context menu. Only available on a single folder selection in the tag browser. I actually use custom properties on folders all the time so this might be my new favourite hahaha.

EDIT: Change out the system.util.getSystemFlags() function call for ApplicationScope.getGlobalScope() since the getSystemFlags moved to system.vision and breaks this in 8.3.

```python
# QoL add-on: adds an "Edit Folder..." item to the Tag Browser right-click menu that
# opens the (folder-scoped) Tag Editor for the selected folder

from java.awt import Frame

_MENU_ITEM_KEY = "qolEditFolderItem"
_LISTENER_KEY = "qolEditFolderListener"

# Latest Tag Browser selection (list of tree nodes), kept fresh by the selection listener.
_selection = []

def _ctx_and_frame():
	for f in Frame.getFrames():
		try:
			c = f.getContext()
			if c is not None:
				return c, f
		except:
			pass
	return None, None

def _is_folder(node):
	from com.inductiveautomation.ignition.designer.tags.tree.node import FolderNode
	# Flip this to `return True` to show the item for tags/UDTs/folders alike.
	return isinstance(node, FolderNode)

def _open_editor_for_selection():
	if not _selection:
		return
	ctx, _ = _ctx_and_frame()
	if ctx is None:
		return
	ctx.getTagEditor().editTag(_selection[0].getTagPath())

def install():
	from java.awt.event import ActionListener
	from javax.swing import JMenuItem, Action
	from com.inductiveautomation.ignition.designer.sqltags.dialog import OnTagSelectedListener

	ctx, frame = _ctx_and_frame()
	if ctx is None:
		return
	rp = frame.getRootPane()
	if rp.getClientProperty(_MENU_ITEM_KEY) is not None:
		print "Edit Folder item already installed."
		return

	tb = ctx.getTagBrowser()
	item = JMenuItem(u"Edit Folder…")
	try:
		edit_icon = tb.getActions().getEdit().getValue(Action.SMALL_ICON)
		if edit_icon is not None:
			item.setIcon(edit_icon) # reuse Ignition's built-in edit icon
	except:
		pass

	class _Open(ActionListener):
		def actionPerformed(self, e):
			_open_editor_for_selection()

	item.addActionListener(_Open())

	class _Sel(OnTagSelectedListener):
		def tagSelectionChanged(self, tags):
			global _selection
			_selection = list(tags) if tags else []
			# show the item only when a single folder is selected
			item.setVisible(len(_selection) == 1 and _is_folder(_selection[0]))

	listener = _Sel()
	tb.addOnTagSelectedListener(listener)
	tb.addTagPopupMenuComponent(item, 0) # 0 = top of the right-click menu
	item.setVisible(False) # hidden until a folder is selected

	rp.putClientProperty(_MENU_ITEM_KEY, item)
	rp.putClientProperty(_LISTENER_KEY, listener)
	print "Installed: 'Edit Folder' tag context-menu item (folders only)."

def uninstall():
	# NOTE: the Tag Browser has no API to remove a popup item, so we can only HIDE it and
	# stop the listener. The item object lingers in the popup until the Designer restarts.
	ctx, frame = _ctx_and_frame()
	if frame is None:
		return
	rp = frame.getRootPane()
	item = rp.getClientProperty(_MENU_ITEM_KEY)
	if item is not None:
		item.setVisible(False)
		rp.putClientProperty(_MENU_ITEM_KEY, None)
	listener = rp.getClientProperty(_LISTENER_KEY)
	if listener is not None and ctx is not None:
		ctx.getTagBrowser().removeOnTagSelectedListener(listener)
		rp.putClientProperty(_LISTENER_KEY, None)
	print "Uninstalled: 'Edit Folder' item hidden and listener removed (restart to fully clear)."

def _is_designer():
	from com.inductiveautomation.ignition.common.model import ApplicationScope
	return ApplicationScope.isDesigner(ApplicationScope.getGlobalScope())

if _is_designer():
	install()

```

---

<div class="post-metadata">

**Author:** ![Duffanator](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/duffanator/32/3560_2.png) [@Duffanator](https://forum.inductiveautomation.com/u/Duffanator)\
**Post date:** [July 9, 2026, 7:01pm UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/50 "2026-07-09T19:01:09Z")

</div>

I'm a little behind in the Ignition updates, maybe this is already in there but...

The ability to make copies of alarms on a tag! When I have 32 alarms, one for each bit in an alarm word I hate having to type in the same alarm configuration over and over again when only the alarm name and bit number need to change.

---

<div class="post-metadata">

**Author:** ![brandon1](https://avatars.discourse-cdn.com/v4/letter/b/ea666f/32.png) [@brandon1](https://forum.inductiveautomation.com/u/brandon1)\
**Post date:** [July 9, 2026, 7:02pm UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/51 "2026-07-09T19:02:53Z")

</div>

You can already do this! Ctrl+C then Ctrl+V and you will see the alarm get copied on the tag UI editor interface.

---

<div class="post-metadata">

**Author:** ![Duffanator](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/duffanator/32/3560_2.png) [@Duffanator](https://forum.inductiveautomation.com/u/Duffanator)\
**Post date:** [July 9, 2026, 7:04pm UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/52 "2026-07-09T19:04:51Z")

</div>

Damn.. I can't believe I didn't try that before. lol. I assumed since it wasn't a right click menu it wasn't possible. Well, that's my problem solved 🙂

---

<div class="post-metadata">

**Author:** ![Steve\_Laubach](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/steve_laubach/32/60900_2.png) [@Steve\_Laubach](https://forum.inductiveautomation.com/u/Steve_Laubach)\
**Post date:** [July 9, 2026, 8:22pm UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/53 "2026-07-09T20:22:07Z")

</div>

With tags in general it's also good to note the "Copy as JSON" feature. You can copy an alarm configuration to a text editor like this and copy/paste the JSON into the tag database really easily.

It's also very useful if you're trying to make a lot of tags programmatically. You can use a script transform to convert tags from whatever format to Ignition tags for example.

---

<div class="post-metadata">

**Author:** ![pturmel](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/pturmel/32/3295_2.png) [@pturmel](https://forum.inductiveautomation.com/u/pturmel)\
**Post date:** [July 10, 2026, 12:51am UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/54 "2026-07-10T00:51:03Z")

</div>

Hmmm. This is universal enough that it makes me want to implement it in my Integration Toolkit. (Life cycle is simpler...) Objections?

---

<div class="post-metadata">

**Author:** ![justinedwards.jle](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/justinedwards.jle/32/37854_2.png) [@justinedwards.jle](https://forum.inductiveautomation.com/u/justinedwards.jle)\
**Post date:** [July 10, 2026, 2:15pm UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/55 "2026-07-10T14:15:43Z")

</div>

~~Coming soon to the Exchange... Hopefully~~

 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/9/d/9d2c5c2daaf05534a59e80cf31fc375f7743fcbe.png)

This version is considerably more robust than the one I originally posted. I had a colleague intentionally try to poke holes in it, and that process led to several improvements.

Rather than disabling the **OK** button, the patch now uses a document listener to detect `tag()` expressions and display a warning icon. For enforcement, it intercepts the **OK** button's `ActionListener` and displays a lightweight popup if the user attempts to save an expression containing a `tag()` call.

I also added a selection listener to the binding type pane, so the warning automatically disappears when the user switches to a binding type other than an Expression Binding.

 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/6/d/6d4e710728b6e7c8d593d13b56369c68d360936f.png)

**Edit:** This is now live on [The Exchange](https://inductiveautomation.com/exchange/2969/overview): 🙂

> [@Preventing the Use of Tag() Expressions in Vision](https://forum.inductiveautomation.com/t/preventing-the-use-of-tag-expressions-in-vision/116609):
>
> Introducing Tag Expression Blocker A free tool is now available on the Ignition Exchange that prevents developers from introducing tag() expressions into Vision UI bindings. [Inductive Automation Exchange: Tag Expression Blocker](https://inductiveautomation.com/exchange/2969/overview) Although the tag() function has a valid use case in expression tags, it is not recommended for other binding types due to its negative impact on performance. Tag Expression Blocker helps teams enforce this best practice by detecting and preventing these bindings before …

---

<div class="post-metadata">

**Author:** ![nminchin](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/nminchin/32/6911_2.png) [@nminchin](https://forum.inductiveautomation.com/u/nminchin)\
**Post date:** [August 7, 2026, 3:33am UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/56 "2026-08-07T03:33:09Z")

</div>

> [@nminchin](#):
>
> > [@paul-griffith](#):
> >
> > If we could just give you a 'click style.classes, start typing, it auto-filters `<some ui element>` by name down to whatever you're typing' - would you say that's "good enough" or do you actually reach for the by-prop reference often?
> 
> I'll be honest and say I haven't used this tool yet as I only recently added it 🤭. The name filtering though i've wanted for a _long time_ now. I thought to add CSS prop name _and value_ filtering in case I wanted to be able to filter for example on style classes that include specific props, like `gap`, `background-color`, `color`, `font-size`, `border`, etc., or that use specific css variable names. I think this will be more useful if you don't know the project in order to find the styles that set the font-size, for example
> 
> _ **I think the simple workflow you mention would service most use-cases though imo.** _

I take this back. I've been using this quite a bit now and the ability to filter for style classes that contain specific css prop names and/or values has been incredibly useful! For example, I can filter for style classes that:

- set font-size
- set "bold" font
- set borders
- set background colours
- use specific colours or variable names
- have animations
- etc.

Which is invaluable if you don't know the style class names that you're looking for, but you know the css props that you need. Also the ability to view in the tooltip what declarations/css props the style class selector has is also very useful. So I would advocate for building this into a new UI design as well

---

<div class="post-metadata">

**Author:** ![travisaggiebrown](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/travisaggiebrown/32/85824_2.png) [@travisaggiebrown](https://forum.inductiveautomation.com/u/travisaggiebrown)\
**Post date:** [August 14, 2026, 1:54pm UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/57 "2026-08-14T13:54:25Z")

</div>

this is slopy work at best and was done mostly by a clanker, but I was able to make a Gui wizard that auto pulls in column names and allows you to style the columns easier. you have to be selected onto the table you want when you run the command to open it.

 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/b/1/b151c4fd2c3f1644dbe2eec321590903238baf95.png)

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

try:
    from com.inductiveautomation.perspective.common.api import PropertyType
except ImportError:
    from com.inductiveautomation.perspective.common.config import PropertyType
def open_table_wizard():
    """
    Perspective Table Column Wizard Auto-Injector
    ---------------------------------------------
    Reads the data of your selected Perspective Table, spawns a native 
    Java Swing GUI to configure column settings, and injects the fully 
    compliant schema back into your component.
    """
   
    DEFAULT_COLUMN_TEMPLATE = {
      "field": "", "visible": True, "editable": False, "render": "auto",
      "justify": "auto", "align": "center", "resizable": True, "sortable": True,
      "sort": "none", "viewPath": "", "viewParams": {}, "boolean": "checkbox",
      "number": "value", "numberFormat": "0,0.##", "dateFormat": "MM/DD/YYYY",
      "width": "", "strictWidth": False, 
      "filter": { "enabled": False, "visible": "on-hover", "string": { "condition": "", "value": "" },
                  "number": { "condition": "", "value": "" }, "boolean": { "condition": "" },
                  "date": { "condition": "", "value": "" } },
      "style": { "classes": "" },
      "header": { "title": "", "justify": "left", "align": "center", "style": { "classes": "" } },
      "footer": { "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)
        formatted = formatted.replace('_', ' ').replace('-', ' ')
        words = [word.capitalize() for word in formatted.split() if word]
        return " ".join(words)

    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

    # ---------------------------------------------------------
    # 3. ENCAPSULATED SWING CLASSES
    # ---------------------------------------------------------
    class _SelectionHandler(ListSelectionListener):
        def __init__ (self, wizard):
            self.wizard = wizard
        def valueChanged(self, event):
            self.wizard._on_select(event)

    class _ColorTypingAdapter(KeyAdapter):
        def __init__ (self, update_func):
            self.update_func = update_func
        def keyReleased(self, event):
            self.update_func()

    class _ColorOkListener(ActionListener):
        def __init__ (self, chooser, result_dict):
            self.chooser = chooser
            self.result = result_dict
        def actionPerformed(self, event):
            self.result["color"] = self.chooser.getColor()

    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", {})
                    
                    h_bg = h_style.get("backgroundColor", "#1A202C")
                    h_txt = h_style.get("color", "#FFFFFF")
                    c_bg = c_style.get("backgroundColor", "#F4F5F7")
                    c_txt = c_style.get("color", "#333333")
                    f_size = c_style.get("fontSize", "13px")
                    
                    h_extra = {key: val for key, val in h_style.items() if key not in ["backgroundColor", "color"]}
                    c_extra = {key: val for key, val in c_style.items() if key not in ["backgroundColor", "color", "fontSize"]}
                    
                    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": f_size,
                        "header_bg": h_bg,
                        "header_text": h_txt,
                        "cell_bg": c_bg,
                        "cell_text": c_txt,
                        "header_extra": json.dumps(h_extra) if h_extra else "{}",
                        "cell_extra": json.dumps(c_extra) if c_extra else "{}"
                    }
                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 = {
                "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.last_sel = "-- ALL COLUMNS --"
            
            # --- Build GUI ---
            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)

            # Left Panel
            list_panel = JPanel(BorderLayout())
            list_panel.setBorder(BorderFactory.createTitledBorder("Columns"))
            list_panel.setPreferredSize(Dimension(200, 0))
            
            list_items = ["-- ALL COLUMNS --"] + self.keys
            self.col_list = JList(list_items)
            self.col_list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION)
            self.col_list.addListSelectionListener(_SelectionHandler(self))
            list_panel.add(JScrollPane(self.col_list), BorderLayout.CENTER)
            self.dialog.add(list_panel, BorderLayout.WEST)

            # Center Panel
            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):
                def action(event):
                    current_color = _hex_to_color(textfield.getText().strip(), Color.WHITE)
                    chooser = JColorChooser(current_color)
                    chooser.setPreviewPanel(JPanel())
                    for panel in chooser.getChooserPanels():
                        if panel.getDisplayName() in ["Swatches", "CMYK"]:
                            chooser.removeChooserPanel(panel)
                    result = {"color": None}
                    ok_listener = _ColorOkListener(chooser, result)
                    picker_dialog = JColorChooser.createDialog(self.dialog, "Pick a Color", True, chooser, ok_listener, None)
                    picker_dialog.setVisible(True)
                    if result["color"]:
                        hex_color = "#%02X%02X%02X" % (result["color"].getRed(), result["color"].getGreen(), result["color"].getBlue())
                        textfield.setText(hex_color)
                        self._update_previews()
                return action

            # Colors
            settings_panel.add(JLabel(" Header BG Color:"))
            h_bg_panel = JPanel(BorderLayout(5, 0))
            self.txt_h_bg = JTextField("#1A202C")
            self.txt_h_bg.addKeyListener(_ColorTypingAdapter(self._update_previews))
            btn_h_bg = JButton("🎨", actionPerformed=make_color_action(self.txt_h_bg))
            h_bg_panel.add(self.txt_h_bg, BorderLayout.CENTER)
            h_bg_panel.add(btn_h_bg, BorderLayout.EAST)
            settings_panel.add(h_bg_panel)

            settings_panel.add(JLabel(" Header Text Color:"))
            h_txt_panel = JPanel(BorderLayout(5, 0))
            self.txt_h_text = JTextField("#FFFFFF")
            self.txt_h_text.addKeyListener(_ColorTypingAdapter(self._update_previews))
            btn_h_text = JButton("🎨", actionPerformed=make_color_action(self.txt_h_text))
            h_txt_panel.add(self.txt_h_text, BorderLayout.CENTER)
            h_txt_panel.add(btn_h_text, BorderLayout.EAST)
            settings_panel.add(h_txt_panel)

            settings_panel.add(JLabel(" Header Preview:"))
            self.lbl_h_preview = JLabel(" Sample Header Text ", JLabel.CENTER)
            self.lbl_h_preview.setOpaque(True)
            self.lbl_h_preview.setBorder(BorderFactory.createLineBorder(Color.GRAY))
            settings_panel.add(self.lbl_h_preview)

            settings_panel.add(JLabel(" Cell BG Color:"))
            c_bg_panel = JPanel(BorderLayout(5, 0))
            self.txt_c_bg = JTextField("#F4F5F7")
            self.txt_c_bg.addKeyListener(_ColorTypingAdapter(self._update_previews))
            btn_c_bg = JButton("🎨", actionPerformed=make_color_action(self.txt_c_bg))
            c_bg_panel.add(self.txt_c_bg, BorderLayout.CENTER)
            c_bg_panel.add(btn_c_bg, BorderLayout.EAST)
            settings_panel.add(c_bg_panel)

            settings_panel.add(JLabel(" Cell Text Color:"))
            c_txt_panel = JPanel(BorderLayout(5, 0))
            self.txt_c_text = JTextField("#333333")
            self.txt_c_text.addKeyListener(_ColorTypingAdapter(self._update_previews))
            btn_c_text = JButton("🎨", actionPerformed=make_color_action(self.txt_c_text))
            c_txt_panel.add(self.txt_c_text, BorderLayout.CENTER)
            c_txt_panel.add(btn_c_text, BorderLayout.EAST)
            settings_panel.add(c_txt_panel)

            settings_panel.add(JLabel(" Cell Preview:"))
            self.lbl_c_preview = JLabel(" Sample Cell Data ", JLabel.CENTER)
            self.lbl_c_preview.setOpaque(True)
            self.lbl_c_preview.setBorder(BorderFactory.createLineBorder(Color.GRAY))
            settings_panel.add(self.lbl_c_preview)

            # JSON
            json_tooltip = '<html>Must use strict JSON with <b>double quotes</b>.<br>Example: <i>{"margin": "auto", "padding": "10px"}</i></html>'
            lbl_h_extra = JLabel(" Extra Header Style (JSON):")
            lbl_h_extra.setToolTipText(json_tooltip)
            settings_panel.add(lbl_h_extra)
            self.txt_h_extra = JTextField("{}")
            self.txt_h_extra.setToolTipText(json_tooltip)
            settings_panel.add(self.txt_h_extra)

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

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

            # Bottom Panel
            btn_panel = JPanel(FlowLayout(FlowLayout.RIGHT))
            inject_btn = JButton("Apply & Inject Columns", actionPerformed=self._on_inject)
            cancel_btn = JButton("Cancel", actionPerformed=self._on_cancel)
            btn_panel.add(cancel_btn)
            btn_panel.add(inject_btn)
            self.dialog.add(btn_panel, BorderLayout.SOUTH)

            self.col_list.setSelectedIndex(0)
            self._update_previews()

        def _update_previews(self):
            h_bg = _hex_to_color(self.txt_h_bg.getText().strip(), Color.DARK_GRAY)
            h_fg = _hex_to_color(self.txt_h_text.getText().strip(), Color.WHITE)
            self.lbl_h_preview.setBackground(h_bg)
            self.lbl_h_preview.setForeground(h_fg)
            
            c_bg = _hex_to_color(self.txt_c_bg.getText().strip(), Color.LIGHT_GRAY)
            c_fg = _hex_to_color(self.txt_c_text.getText().strip(), Color.BLACK)
            self.lbl_c_preview.setBackground(c_bg)
            self.lbl_c_preview.setForeground(c_fg)

        def _save_form_state(self):
            h_align = self.cb_h_align.getSelectedItem()
            c_align = self.cb_c_align.getSelectedItem()
            sort = self.chk_sort.isSelected()
            res = self.chk_res.isSelected()
            w = self.txt_width.getText().strip()
            sw = self.chk_sw.isSelected()
            f_size = self.txt_font.getText().strip()
            h_bg = self.txt_h_bg.getText().strip()
            h_txt = self.txt_h_text.getText().strip()
            c_bg = self.txt_c_bg.getText().strip()
            c_txt = self.txt_c_text.getText().strip()
            h_extra = self.txt_h_extra.getText().strip()
            c_extra = self.txt_c_extra.getText().strip()

            if self.last_sel == "-- ALL COLUMNS --":
                self.global_config = {
                    "header_align": h_align, "content_align": c_align, "sortable": sort, 
                    "resizable": res, "width": w, "strict_width": sw, "font_size": f_size,
                    "header_bg": h_bg, "header_text": h_txt, "cell_bg": c_bg, "cell_text": c_txt,
                    "header_extra": h_extra, "cell_extra": c_extra
                }
                for k in self.keys:
                    self.configs[k].update(self.global_config)
            else:
                self.configs[self.last_sel]["title"] = self.txt_title.getText()
                self.configs[self.last_sel].update({
                    "header_align": h_align, "content_align": c_align, "sortable": sort, 
                    "resizable": res, "width": w, "strict_width": sw, "font_size": f_size,
                    "header_bg": h_bg, "header_text": h_txt, "cell_bg": c_bg, "cell_text": c_txt,
                    "header_extra": h_extra, "cell_extra": c_extra
                })

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

        def _on_select(self, event):
            if not event.getValueIsAdjusting():
                new_sel = self.col_list.getSelectedValue()
                if new_sel != self.last_sel:
                    self._save_form_state()
                    self._load_form_state(new_sel)
                    self.last_sel = new_sel

        def _on_inject(self, event):
            self._save_form_state()
            self.accepted = True
            self.dialog.dispose()

        def _on_cancel(self, event):
            self.dialog.dispose()

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

    # ---------------------------------------------------------
    # 4. MAIN SCRIPT EXECUTION
    # ---------------------------------------------------------
    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:
        print "❌ Could not find Ignition Designer window."
        return

    try:
        context = designer_window.getContext()
        p_mod = context.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
        
        if not selection or selection.isEmpty():
            print "❌ No component selected. Click on your Table in the View first!"
            return

        component_details_list = selection.getComponentDetails()
        if not component_details_list or component_details_list.isEmpty():
             return
             
        component_details = component_details_list.get(0)
        
        props_raw = None
        if hasattr(component_details, 'props'):
            props_raw = component_details.props() if callable(component_details.props) else component_details.props

        if not props_raw: return

        try:
            props_json_str = unicode(props_raw.toString() if hasattr(props_raw, "toString") else str(props_raw))
            props_dict = json.loads(props_json_str)
        except Exception as e: return

        props_data = props_dict.get("data", {})
        existing_cols = props_dict.get("columns", [])
        
        # Build map of existing configurations
        existing_col_map = {}
        if isinstance(existing_cols, list):
            for c in existing_cols:
                if isinstance(c, dict) and "field" in c:
                    existing_col_map[str(c["field"])] = c

        column_keys = set()
        
        # Extract keys from Data
        if isinstance(props_data, dict) and "$columns" in props_data:
            for col_def in props_data["$columns"]:
                if isinstance(col_def, dict) and "name" in col_def:
                    column_keys.add(str(col_def["name"]))
        elif isinstance(props_data, dict) and "$a" in props_data:
            if isinstance(props_data["$a"], list) and len(props_data["$a"]) > 0:
                for key in props_data["$a"][0].keys():
                    if key != "$": column_keys.add(str(key))
        elif isinstance(props_data, list) and len(props_data) > 0:
            if isinstance(props_data[0], dict):
                for key in props_data[0].keys(): column_keys.add(str(key))
        elif isinstance(props_data, dict) and "columns" in props_data:
            for col in props_data["columns"]:
                if "name" in col: column_keys.add(str(col["name"]))

        for k in existing_col_map.keys():
            column_keys.add(k)

        if isinstance(props_data, dict) and "$columns" in props_data:
            sorted_keys = []
            for col_def in props_data["$columns"]:
                name = str(col_def["name"])
                if name in column_keys:
                    sorted_keys.append(name)
            for k in column_keys:
                if k not in sorted_keys:
                    sorted_keys.append(k)
        else:
            sorted_keys = sorted(list(column_keys))

        if not sorted_keys: 
            print "❌ No columns found in props.data or props.columns"
            return

        # 🌟 LAUNCH WIZARD
        wizard = _ColumnWizardDialog(designer_window, sorted_keys, existing_col_map)
        final_configs = wizard.show()
        
        if not final_configs:
            print "⚠️ User cancelled column injection."
            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["field"] = key
            col["align"] = c_state["content_align"]
            col["sortable"] = c_state["sortable"]
            col["resizable"] = c_state["resizable"]
            col["width"] = c_state["width"]
            col["strictWidth"] = c_state["strict_width"]
            
            if "header" not in col: col["header"] = {}
            col["header"]["title"] = c_state["title"]
            col["header"]["align"] = c_state["header_align"]
            
            if "style" not in col["header"]: col["header"]["style"] = {}
            if "style" not in col: col["style"] = {}
            
            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"] and c_state["header_extra"] != "{}":
                    header_style.update(json.loads(c_state["header_extra"]))
                if c_state["cell_extra"] and c_state["cell_extra"] != "{}":
                    cell_style.update(json.loads(c_state["cell_extra"]))
            except Exception as e:
                print "⚠️ Warning: Could not parse extra style JSON for column '%s': %s" % (key, e)

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

        new_columns_json_str = json.dumps(new_columns)

        try:
            selection.write(PropertyType.props, "columns", new_columns_json_str)
            print "✅ Successfully injected FULL column schema via Editor Wizard!"
        except Exception as final_e:
            print "❌ Failed to write properties."
            traceback.print_exc()

    except Exception as e:
        print "❌ Failed to inject."
        traceback.print_exc()

```

Edit: clanker broke it on final refactor, despite me swearing i tested it. it should actually work now

---

<div class="post-metadata">

**Author:** ![travisaggiebrown](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/travisaggiebrown/32/85824_2.png) [@travisaggiebrown](https://forum.inductiveautomation.com/u/travisaggiebrown)\
**Post date:** [August 25, 2026, 9:11pm UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/58 "2026-08-25T21:11:46Z")

</div>

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:

 ![image](https://us1.discourse-cdn.com/inductiveautomation/original/3X/d/6/d69ce3afdb6026e06e0cfb31e3da1424162b47fe.png)

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:

```python

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:

```python
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:

```python
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:

```python
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:

```python
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.

> [@Component Scripting Window Default Size](https://forum.inductiveautomation.com/t/component-scripting-window-default-size/68548/16):
>
> If anybody is interested, I've also developed a way to apply this script automatically when the designer is opened, so there is no need to ever run the script from the script console. To do this, simply package the code into a library script, and call it from a Vision client tag using the value changed event. For the client tag, I'm using the data type DateTime with the expression now(0). Since the polling rate is set to zero, the expression changes the value only once when the designer is firs…

---

<div class="post-metadata">

**Author:** ![paul-griffith](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/paul-griffith/32/37249_2.png) [@paul-griffith](https://forum.inductiveautomation.com/u/paul-griffith)\
**Post date:** [August 25, 2026, 10:22pm UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/59 "2026-08-25T22:22:27Z")

</div>

Donating this to the folks here, a \[1\] less cursed way to run, basically, designer local modules:

> **[GitHub - paul-griffith/designer-extension-agent](https://github.com/paul-griffith/designer-extension-agent)**
>
> Contribute to paul-griffith/designer-extension-agent development by creating an account on GitHub.

> <https://github.com/paul-griffith/designer-extension-agent/blob/d55a4dbfa08f376523db106fcf6447ab3f59c58e/docs/installing.md>

It'll run some files saved in your `.ignition` cache on various lifecycle hooks if you've opted in to 'attaching' it via your designer launcher configuration. That would allow you to save one set of scripts in your _local_ user profile and load it onto any number of (8.3) connected designers, without making any changes in the project.  
You can edit these files with your resident clanker, or directly inside the designer in a popup frame you invoke from the Tools menu.

No future maintenance guarantees are made, this is not an official IA product, use at your own risk, etc, etc.

* * *

1. sort of

---

<div class="post-metadata">

**Author:** ![pturmel](https://sea1.discourse-cdn.com/inductiveautomation/user_avatar/forum.inductiveautomation.com/pturmel/32/3295_2.png) [@pturmel](https://forum.inductiveautomation.com/u/pturmel)\
**Post date:** [August 25, 2026, 10:41pm UTC](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195/60 "2026-08-25T22:41:55Z")

</div>

> [@paul-griffith](#):
>
> without making any changes in the project.

_ **Blegh** _  
_ **Eeeewwwww** _  
🤮

I, at least, **want** all code that runs in my designer/Vision clients to _ **come from the gateway** _.

If I were managing a dev team, using this would be a firing offense.

[Previous page](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195.md?page=2)

[Next page](https://forum.inductiveautomation.com/t/designer-quality-of-life-mods/116195.md?page=4)
