summaryrefslogtreecommitdiff
path: root/ui/namespace_panel.py
blob: f8804c08285bfdf2d590777748677489301b381b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
"""
ui/namespace_panel.py

NAMESPACE panel — dockable, live browser of everything referenceable in a
script: Signals (raw device endpoints), Channels (processed/derived
values), and vars (the shared dict bridging Control Panel On Action
Scripts and Channel scripts). Read-only reference, not an editor — editing
still happens in Configuration > Channels.

Rows support multi-select (Ctrl/Shift-click). selected_references() exposes
the currently-selected Signal/Channel rows as (dev_id, ch_id, name, unit,
color) tuples — used by ConfigWindow's "Create Channel" button (Channels
tab, config_window.py), which sits below this panel rather than inside it:
this widget stays a plain reference browser with no create-channel
knowledge of its own, reusable as-is for the read-only dockable instance
on the main window (ui/main_window.py).

Docked opposite the Control Panel (ui/main_window.py), built the same way:
a QDockWidget you can drag, float, redock, or close.
"""

from __future__ import annotations
from typing import Dict, List, Tuple

from PyQt6.QtWidgets import (
    QWidget, QVBoxLayout, QHBoxLayout, QLabel,
    QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView,
    QApplication,
)
from PyQt6.QtCore import QTimer, pyqtSlot
from PyQt6.QtGui import QColor

from devices.device_registry import DeviceRegistry
from core.signal_processor import SignalProcessor

_VARS_POLL_MS = 400

_C_KIND   = 0
_C_ID     = 1
_C_NAME   = 2
_C_DIR    = 3
_C_VALUE  = 4
_C_SOURCE = 5


def _fmt_value(v) -> str:
    if v is None:
        return "—"
    if isinstance(v, float):
        return f"{v:.4g}"
    return str(v)


def _fmt_ref(dev_id: str, ch_id: str) -> str:
    """Absolute reference for a source — device_id/channel_id for a Signal
    (device_id is stable across machines even when the COM/tty port isn't,
    see ProfileManager.apply()'s port re-detection), or the bare channel_id
    for another Channel (derived channels have no device to qualify with)."""
    if dev_id == "derived":
        return ch_id
    return f"{dev_id}/{ch_id}"


class NamespacePanel(QWidget):
    def __init__(self, registry: DeviceRegistry, processor: SignalProcessor):
        super().__init__()
        self.registry  = registry
        self.processor = processor
        # row key -> row index. Keys:
        #   ("signal", dev_id, ch_id) | ("channel", channel_id) | ("var", name)
        self._row_index: Dict[Tuple, int] = {}
        self._build()
        self._rebuild_rows()

        self._poll = QTimer(self)
        self._poll.setInterval(_VARS_POLL_MS)
        self._poll.timeout.connect(self._on_poll)
        self._poll.start()

    def _build(self):
        lay = QVBoxLayout(self)
        lay.setContentsMargins(0, 0, 0, 0)
        lay.setSpacing(0)

        hint = QLabel(
            "Double-click a row to copy reference to clipboard  ·  "
            "select rows to use as sources for Create Channel"
        )
        hint.setObjectName("traceSource")
        hint.setWordWrap(True)
        hint.setContentsMargins(8, 6, 8, 6)
        lay.addWidget(hint)

        self._table = QTableWidget()
        self._table.setObjectName("channelTable")
        self._table.setColumnCount(6)
        self._table.setHorizontalHeaderLabels(["Kind", "ID", "Name", "Dir", "Value", "Source"])
        hdr = self._table.horizontalHeader()
        hdr.setSectionResizeMode(_C_SOURCE, QHeaderView.ResizeMode.Stretch)
        hdr.setSectionResizeMode(_C_KIND,   QHeaderView.ResizeMode.ResizeToContents)
        hdr.setSectionResizeMode(_C_ID,     QHeaderView.ResizeMode.ResizeToContents)
        hdr.setSectionResizeMode(_C_NAME,   QHeaderView.ResizeMode.ResizeToContents)
        hdr.setSectionResizeMode(_C_DIR,    QHeaderView.ResizeMode.ResizeToContents)
        hdr.setSectionResizeMode(_C_VALUE,  QHeaderView.ResizeMode.ResizeToContents)
        self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        self._table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
        self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        self._table.setAlternatingRowColors(True)
        self._table.verticalHeader().setVisible(False)
        self._table.cellDoubleClicked.connect(self._on_double_clicked)
        lay.addWidget(self._table)

    def natural_width(self) -> int:
        """Sum of current column widths (Source, being Stretch, counted at
        a fixed placeholder rather than its live stretched width, which
        would be circular) — how wide this panel would need to be to show
        every column without a horizontal scrollbar. A query only, not an
        enforced constraint: this panel never sets its own minimum width
        (that fights QMainWindow's dock layout when this class is docked
        stacked/tabbed with another dock, e.g. Control Panel, in
        ui/main_window.py). A caller that wants "start small but readable"
        sizing — see ConfigWindow's Channels tab — uses this once for
        initial splitter sizing instead."""
        total = 0
        for i in range(self._table.columnCount()):
            total += 80 if i == _C_SOURCE else self._table.columnWidth(i)
        return total + 24

    # ── Selection ────────────────────────────────────────────────────────────

    def selected_references(self) -> List[Tuple[str, str, str, str, str]]:
        """Currently-selected Signal/Channel rows as (dev_id, ch_id, name,
        unit, color) — the shape ConfigWindow's "Create Channel" button
        needs to build a new Channel's sources. Selected "var" rows and any
        row whose underlying Signal/Channel has since vanished are skipped
        silently (vars aren't valid Channel sources; a vanished row means
        the selection is stale)."""
        rows = {idx.row() for idx in self._table.selectionModel().selectedRows()}
        out: List[Tuple[str, str, str, str, str]] = []
        for key, row in self._row_index.items():
            if row not in rows:
                continue
            if key[0] == "signal":
                _, dev_id, ch_id = key
                dev = self.registry.get_instance(dev_id)
                ch  = dev.get_signal(ch_id) if dev else None
                if ch is not None:
                    out.append((dev_id, ch_id, ch.name, ch.unit, ch.color))
            elif key[0] == "channel":
                _, channel_id = key
                dc = next((d for d in self.processor.get_derived()
                           if d.channel_id == channel_id), None)
                if dc is not None:
                    out.append(("derived", channel_id, dc.name, dc.unit, dc.color))
        return out

    # ── Row-set (structural) rebuild — devices/channels/vars added or removed ──

    def _desired_keys(self):
        keys = []
        for dev in self.registry.all_instances():
            for ch in dev.info.signals:
                if ch.enabled:
                    keys.append(("signal", dev.info.device_id, ch.signal_id))
        for dc in self.processor.get_derived():
            keys.append(("channel", dc.channel_id))
        for name in self.processor.list_vars():
            keys.append(("var", name))
        return keys

    def _rebuild_rows(self):
        keys = self._desired_keys()
        if list(self._row_index.keys()) == keys:
            return  # no structural change — values refresh independently
        self._row_index.clear()
        self._table.setRowCount(len(keys))
        for row, key in enumerate(keys):
            self._row_index[key] = row
            self._paint_row(row, key)

    def _paint_row(self, row: int, key: Tuple):
        kind = key[0]
        if kind == "signal":
            _, dev_id, ch_id = key
            dev = self.registry.get_instance(dev_id)
            ch  = dev.get_signal(ch_id) if dev else None
            name  = ch.name if ch else ch_id
            is_wr = bool(ch and ch.writable)
            self._set_row(row, "Signal", ch_id, name, "WRITE" if is_wr else "READ",
                          None, _fmt_ref(dev_id, ch_id))
        elif kind == "channel":
            _, channel_id = key
            dc = next((d for d in self.processor.get_derived()
                       if d.channel_id == channel_id), None)
            name = dc.name if dc else channel_id
            if dc and dc.sources:
                # Absolute reference(s) this Channel is chained from — same
                # format as another row's ID column, so a chain (Channel 1 ->
                # Channel 2 -> Channel 3) can be traced by eye: Channel 3's
                # Source literally matches Channel 2's ID.
                src = ", ".join(_fmt_ref(d, c) for d, c in dc.sources)
            else:
                src = dc.kind if dc else ""
            self._set_row(row, "Channel", channel_id, name, "READ", None, src)
        else:  # var
            _, name = key
            val = self.processor.get_var(name, None)
            self._set_row(row, "var", name, name, "R/W", val, "shared (vars)")

    def _set_row(self, row: int, kind: str, id_ref: str, name: str,
                 direction: str, value, source: str):
        self._table.setItem(row, _C_KIND, QTableWidgetItem(kind))
        id_item = QTableWidgetItem(id_ref)
        id_item.setForeground(QColor("#94a3b8"))
        self._table.setItem(row, _C_ID, id_item)
        self._table.setItem(row, _C_NAME, QTableWidgetItem(name))
        dir_item = QTableWidgetItem(direction)
        dir_item.setForeground(_dir_color(direction))
        self._table.setItem(row, _C_DIR, dir_item)
        self._table.setItem(row, _C_VALUE, QTableWidgetItem(_fmt_value(value)))
        self._table.setItem(row, _C_SOURCE, QTableWidgetItem(source))

    # ── Live value updates ──────────────────────────────────────────────────

    @pyqtSlot(str, str, float, float)
    def on_processed_data(self, device_id: str, channel_id: str, ts: float, value: float):
        key = ("channel", channel_id) if device_id == "derived" else ("signal", device_id, channel_id)
        row = self._row_index.get(key)
        if row is None:
            return
        item = self._table.item(row, _C_VALUE)
        if item is not None:
            item.setText(_fmt_value(value))

    def _on_poll(self):
        self._rebuild_rows()
        for name in self.processor.list_vars():
            row = self._row_index.get(("var", name))
            if row is None:
                continue
            item = self._table.item(row, _C_VALUE)
            if item is not None:
                item.setText(_fmt_value(self.processor.get_var(name, None)))

    # ── Copy-to-clipboard reference ─────────────────────────────────────────

    def _on_double_clicked(self, row: int, _col: int):
        key = next((k for k, r in self._row_index.items() if r == row), None)
        if key is None:
            return
        if key[0] == "var":
            snippet = f'vars.get("{key[1]}", 0)'
        elif key[0] == "signal":
            snippet = f'channels.get("{key[2]}", 0)'
        else:  # channel
            snippet = f'channels.get("{key[1]}", 0)'
        QApplication.clipboard().setText(snippet)


def _dir_color(direction: str) -> QColor:
    if direction == "WRITE":
        return QColor("#f59e0b")
    if direction == "R/W":
        return QColor("#818cf8")
    return QColor("#22c55e")