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
ROSTER_ENTRY_SLUG = 'roster-entry'
ROSTER_ENTRY_NAME = 'Roster Entry'
@dataclass(frozen=True)
class ComposedPane:
 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 underlying DecoderPane (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 — variable item names the loaded decoder actually provides. A subset of pane.items filtered against the decoder's variable table.
ComposedPane( name: str, slug: str, source: str, pane: jmri_core.persistence.DecoderPane | None = None, is_synthetic: bool = False, items_available: tuple[str, ...] = <factory>)
name: str
slug: str
source: str
pane: jmri_core.persistence.DecoderPane | None = None
is_synthetic: bool = False
items_available: tuple[str, ...]
is_empty: bool
 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.

def compose_panes( variables: list[jmri_core.persistence.DecoderVariable] | tuple[jmri_core.persistence.DecoderVariable, ...], programmer: jmri_core.persistence.ProgrammerConfig | None, decoder_panes: list[jmri_core.persistence.DecoderPane] | tuple[jmri_core.persistence.DecoderPane, ...] = (), *, show_empty: bool | None = None) -> list[ComposedPane]:
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:

  1. Synthetic Roster Entry tab always first.
  2. Standard tabs from programmer, filtered against _STANDARD_TAB_ORDER. Any standard tab the programmer doesn't declare is skipped.
  3. Any decoder-declared pane from decoder_panes that isn't already covered by name lands after the standard set.
  4. Empty panes are removed unless show_empty forces them in (mirrors JMRI's showEmptyPanes semantics; default follows the programmer's own setting when show_empty is None).
def slugify_pane_name(name: str) -> str:
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.

def find_pane_by_slug( panes: list[ComposedPane], slug: str) -> ComposedPane | None:
252def find_pane_by_slug(
253    panes: list[ComposedPane], slug: str,
254) -> ComposedPane | None:
255    for p in panes:
256        if p.slug == slug:
257            return p
258    return None