The Horizontal Menu has no built-in selected-tab highlight like a tab container. You build it yourself: a property change script reads the selected index path, walks props.items, and writes a style class onto every item. One level is easy to script. Two levels work with nested loops. The third level is where the usual approaches fail. Get it running with the recursive function below, then harden it.
Drop the Nested For-Loop Approach
The usual first fix adds one for loop per menu level. It works for two levels and fails at three, for two reasons:
- It does not scale. Every new menu layer needs another hand-written loop in both the select branch and the deselect branch.
-
Copy-paste index errors. In the typical three-level version, the innermost loop compares
t == currentValue.value[1].value. It should read index[2]. The third level then follows the second-level selection instead of its own, so the wrong tab lights up.
The other quick fix highlights currentValue.value[0] and un-highlights previousValue.value[0]. That only touches the top level. It also depends on the previous value being accurate. If the menu is edited, the view reloads, or the property changes twice quickly, stale highlights remain.
The fix that holds up is a recursive walk that rewrites the class on every item, every time. Because nothing depends on the previous state, stale highlights clear on the next change.
Check: Confirm your menu is deeper than two levels, or will become deeper. If it is, go straight to the recursive method.
Back Up the Menu Items First
The script writes the whole props.items structure back to the component. A bug can mangle labels, targets, or nesting, and a large menu takes a long time to rebuild by hand.
- In the Designer, select the Horizontal Menu and copy
props.items. - Create a custom property on the component or view, for example
custom.itemsBackup. - Paste the items into it.
- Save the project.
Check: Expand the backup and confirm the item count and nesting depth match props.items. If a test run corrupts the menu, paste the backup over props.items and continue.
Create the Selected and Deselected Style Classes
The script sets style.classes on each menu item to one of two project style paths:
| Constant | Style class path | Purpose |
|---|---|---|
SELECTED_CLASS |
Components/HorizontalMenu/HorizontalMenu_Selected |
Selected item and its parents (for example, yellow background) |
UNSELECTED_CLASS |
Components/HorizontalMenu/HorizontalMenu_Deselected |
All other items (for example, black background) |
- Create both styles in the project Styles folder under
Components/HorizontalMenu/. - Give the two styles clearly different backgrounds so you can spot a wrong highlight immediately.
Check: Type each class path by hand into one item's style.classes and confirm the color changes. If nothing changes, the path is wrong, and no script will fix that. Correct the path before you continue.
Convert the Selection to a Plain Index List
The change script receives currentValue.value as a list of qualified values, one per menu level. Each element holds the index at that level, so you read it with .value. The recursive function expects plain integers, one per depth, for example [0, 1, 2] or [0,1,2,3,0]:
selection = [item.value for item in currentValue.value]
Position 0 is the top-level index. Position 1 is the index within that item's children, and so on. An empty list means nothing is selected.
Check: Temporarily add system.perspective.print(selection) after that line. Click through the menu and confirm the list length equals the depth you clicked, and that each number matches the item's position among its siblings (zero-based).
Add the Sanitize Helper to the Project Library
Component properties read from a script are Ignition wrapper objects, not plain Python dicts and lists. Editing them in place across a deep structure and writing them back is unreliable. The fix is to convert the whole tree to native Python structures, modify that copy, and write it back in a single assignment.
Put this helper in a project library script named util, so it is called as project.util.sanitizeIgnitionObject:
def sanitizeIgnitionObject(element):
if hasattr(element, '__iter__'):
if hasattr(element, 'keys'):
return dict((k, sanitizeIgnitionObject(element[k])) for k in element.keys())
else:
return list(sanitizeIgnitionObject(x) for x in element)
return element
Anything with keys becomes a dict. Anything else iterable becomes a list. Scalars pass through unchanged.
Check: In the change script, log type(project.util.sanitizeIgnitionObject(self.props.items)). It should report a list, and its elements should be dicts.
Install the Recursive Highlighter
Put this in a project library script named menu, so it is called as project.menu.recursiveSetHighlight:
def recursiveSetHighlight(items, selection, parentSelected=False, idx=0):
"""
Recursively searches through a menu structure and highlights
the selected item and its parents
"""
SELECTED_CLASS = "Components/HorizontalMenu/HorizontalMenu_Selected"
UNSELECTED_CLASS = "Components/HorizontalMenu/HorizontalMenu_Deselected"
selected = False
for itemIdx, item in enumerate(items):
# Allow highlight on the first level, or when the parent is selected
# and this index matches the selection at this depth
if (parentSelected or idx == 0) and idx < len(selection) and itemIdx == selection[idx]:
item['style']['classes'] = SELECTED_CLASS
selected = True
# Not the item we want: unselect it
else:
item['style']['classes'] = UNSELECTED_CLASS
selected = False
# Apply highlighting to child items
if len(item['items']):
item['items'] = recursiveSetHighlight(item['items'], selection, selected, idx + 1)
return items
Two details make this version correct, and each one fixes a failure seen in earlier versions:
-
The
parentSelectedgate. An item below the top level can only be selected if its own parent is selected. Without this gate, the function matches the index at each depth anywhere in the tree. If two top-level tabs each have children, the child at the same position under the unselected tab also lights up. -
selected = Falsein theelsebranch. The variableselectedis shared by every sibling in the loop. Without the reset, once one sibling is selected, the siblings after it passparentSelected=Trueto their children. Those children then highlight at the third level.
| Symptom | Cause | Fix |
|---|---|---|
| Only the top row highlights | Script handles only currentValue.value[0]
|
Use the recursive function |
| Third level follows the second-level selection | Nested loop compares against value[1] instead of value[2]
|
Replace the loops with recursion |
| Two branches highlight, with a matching child under each | Index matched per depth without a parent check | Add the parentSelected gate |
| Extra third-level items highlight under siblings of the selected tab |
selected not reset in else
|
Add selected = False
|
| Old highlights persist | Deselect logic depends on previousValue
|
Rewrite every item's class on each change |
KeyError on 'items' or 'style'
|
An item lacks that key in its JSON | Add the key to the item, or use item.get('items', [])
|
The function returns the list itself. Do not index the result with [0]. That indexing belonged to an earlier version that returned a tuple (items, highlightParent).
Check: Every menu item, including leaf items, needs a style object and an items list, even if the list is empty. Scan the item JSON before running the script.
Wire the Change Script
Add these lines to the property change script that receives the selection path, which is the same script that exposes currentValue and previousValue:
selection = [item.value for item in currentValue.value]
cleanedStructure = project.util.sanitizeIgnitionObject(self.props.items)
highlightedStructure = project.menu.recursiveSetHighlight(cleanedStructure, selection)
self.props.items = highlightedStructure
Pitfalls to check before you save:
-
Do not attach this script to
props.itemsitself. The last line writesprops.items, which would fire the script again and loop. - Write back once. Assign the whole structure in one line. Do not write individual item paths inside the loop.
-
Remove the old per-level loops and any
previousValuedeselect code. Two scripts writing classes will fight each other.
Check: Save, open a session, and click one top-level tab. That tab alone should use the selected style, and every other item should use the deselected style.
Verify End to End
- Top level: Click each top-level tab in turn. Exactly one tab highlights each time, and the previous one clears.
- Second level: Open a submenu and pick a child. The child and its parent highlight. Nothing under other top-level tabs highlights.
- Same-index trap: Pick two top-level tabs that both have children at the same positions, for example child 1 under tab 0 and under tab 1. Select one. The matching child under the other tab must stay deselected.
-
Third level and deeper: Select a leaf three levels down. Only the full path from top to leaf highlights. Siblings after the selected item at each level stay deselected. This test proves the
selected = Falsereset. - Empty selection: Clear the selection if your design allows it. Every item should return to the deselected style.
- Add a layer: Add a fourth level to a test item and select it. No script change should be needed.
-
Integrity: Compare labels, targets, and item counts against
custom.itemsBackup. Onlystyle.classesvalues should differ.
Stop here if step 7 shows changed labels or missing items. Restore from the backup and check that every item has style and items keys before you run the script again.
FAQ
How do I highlight the selected item in an Ignition Horizontal Menu?
Create two style classes, Components/HorizontalMenu/HorizontalMenu_Selected and HorizontalMenu_Deselected. In the selection change script, convert currentValue.value to an index list, sanitize props.items, run the recursive highlighter, and write the result back to props.items in one assignment.
How do I stop sibling submenu items from highlighting at the same index?
Pass the parent's selected state into each recursive call. Allow a highlight only when parentSelected is true or when you are at depth 0. Also reset selected = False in the else branch so a selected sibling does not pass its state to the siblings after it.
How do I convert the menu selection into a list of indexes?
Use [item.value for item in currentValue.value]. The result is a zero-based index list with one entry per depth, for example [0, 1, 2].
Why does my Horizontal Menu script throw a KeyError on 'items'?
At least one menu item has no items list or no style object in its JSON. Add an empty items array and a style object to every item, or read the children with item.get('items', []). If the menu still corrupts or highlights wrongly after the verification steps, restore the backup and contact Inductive Automation support through its official channels, with the item JSON and the script attached.