jmri_decoderpro.panes
Pane composition — combine standard programmer panes + decoder panes.
The DecoderPro-style tab bar isn't a flat list from any single source. Panes come from:
- the active
~jmri_decoderpro.ProgrammerConfig(the standard Basic / Motor / Function Map / ... tabs); - the decoder file's own
<pane>elements (extras — SUSI decoders, Digitrax OpSw meta-controls, sound-decoder tabs, ...).
The user's spec pins the display order of the standard tabs (Comments
and Legacy Function Map dropped). We follow that order, drop tabs whose
pane has no variables the loaded decoder can serve, then append any
decoder-declared panes that aren't already in the standard set — matching
JMRI Java's decoderFilePanes="yes" behaviour.
A synthetic "Roster Entry" tab always comes first (it renders the roster row itself, not decoder CVs).
1"""Pane composition — combine standard programmer panes + decoder panes. 2 3The DecoderPro-style tab bar isn't a flat list from any single source. 4Panes come from: 5 6* the active :class:`~jmri_decoderpro.ProgrammerConfig` (the standard 7 Basic / Motor / Function Map / ... tabs); 8* the decoder file's own ``<pane>`` elements (extras — SUSI decoders, 9 Digitrax OpSw meta-controls, sound-decoder tabs, ...). 10 11The user's spec pins the *display order* of the standard tabs (Comments 12and Legacy Function Map dropped). We follow that order, drop tabs whose 13pane has no variables the loaded decoder can serve, then append any 14decoder-declared panes that aren't already in the standard set — matching 15JMRI Java's ``decoderFilePanes="yes"`` behaviour. 16 17A synthetic "Roster Entry" tab always comes first (it renders the 18roster row itself, not decoder CVs). 19""" 20from __future__ import annotations 21 22import re 23from dataclasses import dataclass, field 24 25from jmri_core.persistence.decoder_xml import DecoderPane, DecoderVariable 26from jmri_core.persistence.programmer_xml import ProgrammerConfig 27 28 29# Fixed display order for the DecoderPro standard tabs, per the spec. 30# "Comments" and "Legacy Function Map" are intentionally excluded. 31_STANDARD_TAB_ORDER: tuple[str, ...] = ( 32 "Basic", 33 "Motor", 34 "Basic Speed Control", 35 "Speed Table", 36 "Function Map", 37 "Lights", 38 "Analog Control", 39 "Consist", 40 "Advanced", 41 "Sound", 42 "Sound Levels", 43 "CVs", 44 "Alt Sound Levels", 45 "Equalizer", 46 "DDE", 47 "Reverb", 48 "ASC", 49 "ID", 50) 51 52# Panes we always drop even if a programmer/decoder declares them, 53# matching the user's spec. 54_DROPPED_TAB_NAMES = frozenset({"Comments", "Legacy Function Map"}) 55 56# Marker slug for the synthetic Roster Entry tab. 57ROSTER_ENTRY_SLUG = "roster-entry" 58ROSTER_ENTRY_NAME = "Roster Entry" 59 60 61@dataclass(frozen=True) 62class ComposedPane: 63 """One entry in the composed tab list. 64 65 * ``name`` — display label (English default) 66 * ``slug`` — URL-safe id used in ``/programmer/loco/<id>/pane/<slug>`` 67 * ``pane`` — the underlying :class:`DecoderPane` (None for the 68 synthetic Roster Entry tab) 69 * ``is_synthetic`` — True for tabs the framework injects rather than 70 pulls from XML (currently just Roster Entry) 71 * ``source`` — where the pane came from: ``"roster"`` | 72 ``"programmer"`` | ``"decoder"`` 73 * ``items_available`` — variable ``item`` names the loaded decoder 74 actually provides. A subset of ``pane.items`` filtered against the 75 decoder's variable table. 76 """ 77 78 name: str 79 slug: str 80 source: str 81 pane: DecoderPane | None = None 82 is_synthetic: bool = False 83 items_available: tuple[str, ...] = field(default_factory=tuple) 84 85 @property 86 def is_empty(self) -> bool: 87 """True when the pane has no variables the decoder can serve. 88 89 The Roster Entry tab is never empty (it always has roster fields). 90 A pane with ``<cvtable/>`` or ``<dccaddress/>`` is also never 91 empty — those widgets don't depend on named variables. 92 """ 93 if self.is_synthetic: 94 return False 95 if self.pane is not None and ( 96 self.pane.has_cvtable 97 or self.pane.has_dcc_address 98 or self.pane.has_speed_table 99 or self.pane.has_fnmapping 100 ): 101 return False 102 return not self.items_available 103 104 105def compose_panes( 106 variables: list[DecoderVariable] | tuple[DecoderVariable, ...], 107 programmer: ProgrammerConfig | None, 108 decoder_panes: list[DecoderPane] | tuple[DecoderPane, ...] = (), 109 *, 110 show_empty: bool | None = None, 111) -> list[ComposedPane]: 112 """Build the ordered tab list for a loco's programmer view. 113 114 Behaviour: 115 116 1. Synthetic ``Roster Entry`` tab always first. 117 2. Standard tabs from *programmer*, filtered against 118 ``_STANDARD_TAB_ORDER``. Any standard tab the programmer doesn't 119 declare is skipped. 120 3. Any decoder-declared pane from *decoder_panes* that isn't already 121 covered by name lands after the standard set. 122 4. Empty panes are removed unless *show_empty* forces them in 123 (mirrors JMRI's ``showEmptyPanes`` semantics; default follows the 124 programmer's own setting when *show_empty* is None). 125 """ 126 if show_empty is None and programmer is not None: 127 show_empty = programmer.show_empty_panes.lower() == "yes" 128 if show_empty is None: 129 show_empty = False 130 131 decoder_items = {v.item for v in variables if v.item} 132 133 out: list[ComposedPane] = [ 134 ComposedPane( 135 name=ROSTER_ENTRY_NAME, 136 slug=ROSTER_ENTRY_SLUG, 137 source="roster", 138 is_synthetic=True, 139 ) 140 ] 141 142 # Decoder-declared panes take priority over the standard programmer 143 # pane with the same name — SoundTraxx Tsunami2 ships its own 144 # "Function Map" pane (Extended Function Map) that replaces 145 # Comprehensive.xml's generic ``<fnmapping/>`` matrix, ESU/Zimo do 146 # the same for their model-specific tabs, etc. Index the 147 # decoder-declared panes by name up front so the standard-pane pass 148 # can substitute them in place, preserving the standard tab order. 149 decoder_by_name: dict[str, DecoderPane] = {} 150 include_decoder_panes = programmer is None or programmer.decoder_file_panes 151 if include_decoder_panes: 152 for pane in decoder_panes: 153 if pane.name and pane.name not in _DROPPED_TAB_NAMES: 154 decoder_by_name.setdefault(pane.name, pane) 155 156 # Standard panes — declared by the programmer, ordered by spec. 157 programmer_by_name: dict[str, DecoderPane] = {} 158 if programmer is not None: 159 for pane in programmer.panes: 160 if pane.name and pane.name not in _DROPPED_TAB_NAMES: 161 programmer_by_name.setdefault(pane.name, pane) 162 163 seen_slugs: set[str] = {ROSTER_ENTRY_SLUG} 164 for standard_name in _STANDARD_TAB_ORDER: 165 pane = programmer_by_name.pop(standard_name, None) 166 if pane is None: 167 continue 168 # Prefer the decoder-declared variant when it exists. 169 override = decoder_by_name.pop(standard_name, None) 170 if override is not None: 171 composed = _compose_one(override, "decoder", decoder_items) 172 else: 173 composed = _compose_one(pane, "programmer", decoder_items) 174 if composed.is_empty and not show_empty: 175 continue 176 seen_slugs.add(composed.slug) 177 out.append(composed) 178 179 # Programmer-declared panes that don't match the fixed spec order — 180 # append after the standard block so downstream users still see them. 181 for pane in programmer_by_name.values(): 182 override = decoder_by_name.pop(pane.name, None) 183 if override is not None: 184 composed = _compose_one(override, "decoder", decoder_items) 185 else: 186 composed = _compose_one(pane, "programmer", decoder_items) 187 if composed.is_empty and not show_empty: 188 continue 189 if composed.slug in seen_slugs: 190 continue 191 seen_slugs.add(composed.slug) 192 out.append(composed) 193 194 # Remaining decoder-declared panes — ones with no matching standard 195 # tab. Append after the standard block. 196 seen_names = {c.name for c in out} 197 for pane in decoder_by_name.values(): 198 if pane.name in seen_names: 199 continue 200 composed = _compose_one(pane, "decoder", decoder_items) 201 if composed.is_empty and not show_empty: 202 continue 203 if composed.slug in seen_slugs: 204 continue 205 seen_slugs.add(composed.slug) 206 seen_names.add(composed.name) 207 out.append(composed) 208 209 return out 210 211 212def _compose_one( 213 pane: DecoderPane, source: str, decoder_items: set[str], 214) -> ComposedPane: 215 """Filter one pane's items against the decoder's actual variable set. 216 217 Deduplicates repeated items while preserving order. JMRI's real 218 pane fragments sometimes reference the same variable twice — a 219 numeric input plus a slider for Vstart, for example — because 220 JMRI's Java UI can render two linked widgets sharing decoder 221 state. Our HTML output only has one row per variable, so a repeat 222 would produce two independent rows with divergent state after a 223 write. Take only the first occurrence. 224 """ 225 seen: set[str] = set() 226 items_available: list[str] = [] 227 for item in pane.items: 228 if item not in decoder_items or item in seen: 229 continue 230 seen.add(item) 231 items_available.append(item) 232 return ComposedPane( 233 name=pane.name, 234 slug=slugify_pane_name(pane.name), 235 source=source, 236 pane=pane, 237 items_available=tuple(items_available), 238 ) 239 240 241_SLUG_STRIP = re.compile(r"[^a-z0-9]+") 242 243 244def slugify_pane_name(name: str) -> str: 245 """Turn a pane display name into a URL-safe slug.""" 246 lower = name.strip().lower() 247 slug = _SLUG_STRIP.sub("-", lower).strip("-") 248 return slug or "pane" 249 250 251def find_pane_by_slug( 252 panes: list[ComposedPane], slug: str, 253) -> ComposedPane | None: 254 for p in panes: 255 if p.slug == slug: 256 return p 257 return None
62@dataclass(frozen=True) 63class ComposedPane: 64 """One entry in the composed tab list. 65 66 * ``name`` — display label (English default) 67 * ``slug`` — URL-safe id used in ``/programmer/loco/<id>/pane/<slug>`` 68 * ``pane`` — the underlying :class:`DecoderPane` (None for the 69 synthetic Roster Entry tab) 70 * ``is_synthetic`` — True for tabs the framework injects rather than 71 pulls from XML (currently just Roster Entry) 72 * ``source`` — where the pane came from: ``"roster"`` | 73 ``"programmer"`` | ``"decoder"`` 74 * ``items_available`` — variable ``item`` names the loaded decoder 75 actually provides. A subset of ``pane.items`` filtered against the 76 decoder's variable table. 77 """ 78 79 name: str 80 slug: str 81 source: str 82 pane: DecoderPane | None = None 83 is_synthetic: bool = False 84 items_available: tuple[str, ...] = field(default_factory=tuple) 85 86 @property 87 def is_empty(self) -> bool: 88 """True when the pane has no variables the decoder can serve. 89 90 The Roster Entry tab is never empty (it always has roster fields). 91 A pane with ``<cvtable/>`` or ``<dccaddress/>`` is also never 92 empty — those widgets don't depend on named variables. 93 """ 94 if self.is_synthetic: 95 return False 96 if self.pane is not None and ( 97 self.pane.has_cvtable 98 or self.pane.has_dcc_address 99 or self.pane.has_speed_table 100 or self.pane.has_fnmapping 101 ): 102 return False 103 return not self.items_available
One entry in the composed tab list.
name— display label (English default)slug— URL-safe id used in/programmer/loco/<id>/pane/<slug>pane— the underlyingDecoderPane(None for the synthetic Roster Entry tab)is_synthetic— True for tabs the framework injects rather than pulls from XML (currently just Roster Entry)source— where the pane came from:"roster"|"programmer"|"decoder"items_available— variableitemnames the loaded decoder actually provides. A subset ofpane.itemsfiltered against the decoder's variable table.
86 @property 87 def is_empty(self) -> bool: 88 """True when the pane has no variables the decoder can serve. 89 90 The Roster Entry tab is never empty (it always has roster fields). 91 A pane with ``<cvtable/>`` or ``<dccaddress/>`` is also never 92 empty — those widgets don't depend on named variables. 93 """ 94 if self.is_synthetic: 95 return False 96 if self.pane is not None and ( 97 self.pane.has_cvtable 98 or self.pane.has_dcc_address 99 or self.pane.has_speed_table 100 or self.pane.has_fnmapping 101 ): 102 return False 103 return not self.items_available
True when the pane has no variables the decoder can serve.
The Roster Entry tab is never empty (it always has roster fields).
A pane with <cvtable/> or <dccaddress/> is also never
empty — those widgets don't depend on named variables.
106def compose_panes( 107 variables: list[DecoderVariable] | tuple[DecoderVariable, ...], 108 programmer: ProgrammerConfig | None, 109 decoder_panes: list[DecoderPane] | tuple[DecoderPane, ...] = (), 110 *, 111 show_empty: bool | None = None, 112) -> list[ComposedPane]: 113 """Build the ordered tab list for a loco's programmer view. 114 115 Behaviour: 116 117 1. Synthetic ``Roster Entry`` tab always first. 118 2. Standard tabs from *programmer*, filtered against 119 ``_STANDARD_TAB_ORDER``. Any standard tab the programmer doesn't 120 declare is skipped. 121 3. Any decoder-declared pane from *decoder_panes* that isn't already 122 covered by name lands after the standard set. 123 4. Empty panes are removed unless *show_empty* forces them in 124 (mirrors JMRI's ``showEmptyPanes`` semantics; default follows the 125 programmer's own setting when *show_empty* is None). 126 """ 127 if show_empty is None and programmer is not None: 128 show_empty = programmer.show_empty_panes.lower() == "yes" 129 if show_empty is None: 130 show_empty = False 131 132 decoder_items = {v.item for v in variables if v.item} 133 134 out: list[ComposedPane] = [ 135 ComposedPane( 136 name=ROSTER_ENTRY_NAME, 137 slug=ROSTER_ENTRY_SLUG, 138 source="roster", 139 is_synthetic=True, 140 ) 141 ] 142 143 # Decoder-declared panes take priority over the standard programmer 144 # pane with the same name — SoundTraxx Tsunami2 ships its own 145 # "Function Map" pane (Extended Function Map) that replaces 146 # Comprehensive.xml's generic ``<fnmapping/>`` matrix, ESU/Zimo do 147 # the same for their model-specific tabs, etc. Index the 148 # decoder-declared panes by name up front so the standard-pane pass 149 # can substitute them in place, preserving the standard tab order. 150 decoder_by_name: dict[str, DecoderPane] = {} 151 include_decoder_panes = programmer is None or programmer.decoder_file_panes 152 if include_decoder_panes: 153 for pane in decoder_panes: 154 if pane.name and pane.name not in _DROPPED_TAB_NAMES: 155 decoder_by_name.setdefault(pane.name, pane) 156 157 # Standard panes — declared by the programmer, ordered by spec. 158 programmer_by_name: dict[str, DecoderPane] = {} 159 if programmer is not None: 160 for pane in programmer.panes: 161 if pane.name and pane.name not in _DROPPED_TAB_NAMES: 162 programmer_by_name.setdefault(pane.name, pane) 163 164 seen_slugs: set[str] = {ROSTER_ENTRY_SLUG} 165 for standard_name in _STANDARD_TAB_ORDER: 166 pane = programmer_by_name.pop(standard_name, None) 167 if pane is None: 168 continue 169 # Prefer the decoder-declared variant when it exists. 170 override = decoder_by_name.pop(standard_name, None) 171 if override is not None: 172 composed = _compose_one(override, "decoder", decoder_items) 173 else: 174 composed = _compose_one(pane, "programmer", decoder_items) 175 if composed.is_empty and not show_empty: 176 continue 177 seen_slugs.add(composed.slug) 178 out.append(composed) 179 180 # Programmer-declared panes that don't match the fixed spec order — 181 # append after the standard block so downstream users still see them. 182 for pane in programmer_by_name.values(): 183 override = decoder_by_name.pop(pane.name, None) 184 if override is not None: 185 composed = _compose_one(override, "decoder", decoder_items) 186 else: 187 composed = _compose_one(pane, "programmer", decoder_items) 188 if composed.is_empty and not show_empty: 189 continue 190 if composed.slug in seen_slugs: 191 continue 192 seen_slugs.add(composed.slug) 193 out.append(composed) 194 195 # Remaining decoder-declared panes — ones with no matching standard 196 # tab. Append after the standard block. 197 seen_names = {c.name for c in out} 198 for pane in decoder_by_name.values(): 199 if pane.name in seen_names: 200 continue 201 composed = _compose_one(pane, "decoder", decoder_items) 202 if composed.is_empty and not show_empty: 203 continue 204 if composed.slug in seen_slugs: 205 continue 206 seen_slugs.add(composed.slug) 207 seen_names.add(composed.name) 208 out.append(composed) 209 210 return out
Build the ordered tab list for a loco's programmer view.
Behaviour:
- Synthetic
Roster Entrytab always first. - Standard tabs from programmer, filtered against
_STANDARD_TAB_ORDER. Any standard tab the programmer doesn't declare is skipped. - Any decoder-declared pane from decoder_panes that isn't already covered by name lands after the standard set.
- Empty panes are removed unless show_empty forces them in
(mirrors JMRI's
showEmptyPanessemantics; default follows the programmer's own setting when show_empty is None).
245def slugify_pane_name(name: str) -> str: 246 """Turn a pane display name into a URL-safe slug.""" 247 lower = name.strip().lower() 248 slug = _SLUG_STRIP.sub("-", lower).strip("-") 249 return slug or "pane"
Turn a pane display name into a URL-safe slug.