|
46 | 46 | from sigima.objects import ImageObj, SignalObj |
47 | 47 |
|
48 | 48 | from datalab.config import _ |
49 | | -from datalab.objectmodel import ObjectGroup, get_short_id, get_uuid |
| 49 | +from datalab.objectmodel import ( |
| 50 | + ObjectGroup, |
| 51 | + find_short_ids_in_title, |
| 52 | + get_short_id, |
| 53 | + get_uuid, |
| 54 | +) |
50 | 55 | from datalab.utils.qthelpers import block_signals |
| 56 | +from datalab.widgets.titledelegate import ClickableTitleDelegate |
51 | 57 |
|
52 | 58 | if TYPE_CHECKING: |
53 | 59 | from typing import Any |
@@ -97,6 +103,9 @@ def __init__(self, parent: QW.QWidget, objmodel: ObjectModel) -> None: |
97 | 103 | self.itemDoubleClicked.connect(self.item_double_clicked) |
98 | 104 | self.header().setSectionResizeMode(QW.QHeaderView.Interactive) |
99 | 105 | self.itemChanged.connect(lambda item: self.resizeColumnToContents(0)) |
| 106 | + self._title_delegate = ClickableTitleDelegate(self) |
| 107 | + self.setItemDelegateForColumn(0, self._title_delegate) |
| 108 | + self.viewport().setMouseTracking(True) |
100 | 109 |
|
101 | 110 | def __str__(self) -> str: |
102 | 111 | """Return string representation""" |
@@ -201,16 +210,114 @@ def get_sel_groups(self) -> list[ObjectGroup]: |
201 | 210 | """Return selected groups""" |
202 | 211 | return self.objmodel.get_groups(self.get_sel_group_uuids()) |
203 | 212 |
|
204 | | - @staticmethod |
| 213 | + def _resolve_short_id( |
| 214 | + self, short_id: str |
| 215 | + ) -> tuple[str, SignalObj | ImageObj | ObjectGroup] | None: |
| 216 | + """Resolve a short ID embedded in a title to ``(panel_str, obj)``. |
| 217 | +
|
| 218 | + Default implementation only looks up the tree's own model and returns |
| 219 | + an empty ``panel_str``. Subclasses with access to several panels |
| 220 | + should override this method. |
| 221 | + """ |
| 222 | + obj = self.objmodel.find_by_short_id(short_id) |
| 223 | + if obj is None: |
| 224 | + return None |
| 225 | + return ("", obj) |
| 226 | + |
| 227 | + def _build_short_id_tooltip(self, text: str) -> str: |
| 228 | + """Return an HTML tooltip fragment listing the source objects |
| 229 | + referenced by short IDs embedded in ``text``, or an empty string when |
| 230 | + no such reference is found.""" |
| 231 | + matches = find_short_ids_in_title(text) |
| 232 | + rows: list[str] = [] |
| 233 | + seen: set[str] = set() |
| 234 | + for idx, (start, _end, sid) in enumerate(matches): |
| 235 | + if idx == 0 and start == 0: |
| 236 | + # Skip the leading "<short_id>:" prefix |
| 237 | + continue |
| 238 | + if sid in seen: |
| 239 | + continue |
| 240 | + seen.add(sid) |
| 241 | + resolved = self._resolve_short_id(sid) |
| 242 | + if resolved is None: |
| 243 | + continue |
| 244 | + panel_str, obj = resolved |
| 245 | + kind = ( |
| 246 | + _("group") |
| 247 | + if isinstance(obj, ObjectGroup) |
| 248 | + else _("signal") |
| 249 | + if isinstance(obj, SignalObj) |
| 250 | + else _("image") |
| 251 | + ) |
| 252 | + suffix = f" \u00b7 {panel_str}" if panel_str else "" |
| 253 | + rows.append(f"<b>{sid}</b> \u2192 {obj.title} <i>({kind}{suffix})</i>") |
| 254 | + if not rows: |
| 255 | + return "" |
| 256 | + title = _("Source objects") |
| 257 | + return ( |
| 258 | + f"<p style='white-space:pre'><i><u>{title}:</u></i><br>" |
| 259 | + f"{'<br>'.join(rows)}</p>" |
| 260 | + ) |
| 261 | + |
205 | 262 | def __update_item( |
206 | | - item: QW.QTreeWidgetItem, obj: SignalObj | ImageObj | ObjectGroup |
| 263 | + self, item: QW.QTreeWidgetItem, obj: SignalObj | ImageObj | ObjectGroup |
207 | 264 | ) -> None: |
208 | 265 | """Update item""" |
209 | | - item.setText(0, f"{get_short_id(obj)}: {obj.title}") |
| 266 | + text = f"{get_short_id(obj)}: {obj.title}" |
| 267 | + item.setText(0, text) |
| 268 | + tooltip_parts: list[str] = [] |
| 269 | + sid_tooltip = self._build_short_id_tooltip(text) |
| 270 | + if sid_tooltip: |
| 271 | + tooltip_parts.append(sid_tooltip) |
210 | 272 | if isinstance(obj, (SignalObj, ImageObj)): |
211 | | - item.setToolTip(0, metadata_to_html(obj.metadata)) |
| 273 | + meta_tooltip = metadata_to_html(obj.metadata) |
| 274 | + if meta_tooltip: |
| 275 | + tooltip_parts.append(meta_tooltip) |
| 276 | + item.setToolTip(0, "".join(tooltip_parts)) |
212 | 277 | item.setData(0, QC.Qt.UserRole, get_uuid(obj)) |
213 | 278 |
|
| 279 | + def _handle_short_id_click(self, short_id: str) -> None: |
| 280 | + """Handle a click on a short-ID hyperlink. Default implementation |
| 281 | + selects the matching object within the tree's own model.""" |
| 282 | + resolved = self._resolve_short_id(short_id) |
| 283 | + if resolved is None: |
| 284 | + return |
| 285 | + _panel_str, obj = resolved |
| 286 | + self.set_current_item_id(get_uuid(obj)) |
| 287 | + |
| 288 | + def _short_id_at(self, pos: QC.QPoint) -> str | None: |
| 289 | + """Return the short ID under viewport position ``pos``, or ``None`` |
| 290 | + when the cursor is not over an anchor.""" |
| 291 | + index = self.indexAt(pos) |
| 292 | + if not index.isValid() or index.column() != 0: |
| 293 | + return None |
| 294 | + rect = self.visualRect(index) |
| 295 | + option = self.viewOptions() |
| 296 | + option.rect = rect |
| 297 | + return self._title_delegate.anchor_at(index, rect, pos, option) |
| 298 | + |
| 299 | + # pylint: disable=invalid-name |
| 300 | + def mousePressEvent(self, event: QG.QMouseEvent) -> None: |
| 301 | + """Reimplement Qt method to handle short-ID hyperlink clicks.""" |
| 302 | + if event.button() == QC.Qt.LeftButton: |
| 303 | + short_id = self._short_id_at(event.pos()) |
| 304 | + if short_id is not None: |
| 305 | + event.accept() |
| 306 | + self._handle_short_id_click(short_id) |
| 307 | + return |
| 308 | + super().mousePressEvent(event) |
| 309 | + |
| 310 | + # pylint: disable=invalid-name |
| 311 | + def mouseMoveEvent(self, event: QG.QMouseEvent) -> None: |
| 312 | + """Reimplement Qt method to update the cursor over short-ID anchors.""" |
| 313 | + short_id = self._short_id_at(event.pos()) |
| 314 | + viewport = self.viewport() |
| 315 | + if short_id is not None: |
| 316 | + viewport.setCursor(QC.Qt.PointingHandCursor) |
| 317 | + else: |
| 318 | + viewport.unsetCursor() |
| 319 | + super().mouseMoveEvent(event) |
| 320 | + |
214 | 321 | def populate_tree(self) -> None: |
215 | 322 | """Populate tree with objects""" |
216 | 323 | uuid = self.get_current_item_id() |
@@ -387,6 +494,48 @@ def __init__(self, parent: BaseDataPanel, objmodel: ObjectModel) -> None: |
387 | 494 | self.__dragged_groups: list[QW.QListWidgetItem] = [] |
388 | 495 | self.__dragged_expanded_states: dict[QW.QListWidgetItem, bool] = {} |
389 | 496 |
|
| 497 | + def _resolve_short_id( |
| 498 | + self, short_id: str |
| 499 | + ) -> tuple[str, SignalObj | ImageObj | ObjectGroup] | None: |
| 500 | + """Resolve a short ID across the signal *and* image panels of the main |
| 501 | + window, so titles can reference objects living in either panel. |
| 502 | + """ |
| 503 | + panel: BaseDataPanel = self.parent() |
| 504 | + mainwindow = getattr(panel, "mainwindow", None) |
| 505 | + candidates: list[tuple[str, ObjectModel]] = [] |
| 506 | + if mainwindow is not None: |
| 507 | + sigpanel = getattr(mainwindow, "signalpanel", None) |
| 508 | + if sigpanel is not None: |
| 509 | + candidates.append(("signal", sigpanel.objmodel)) |
| 510 | + imgpanel = getattr(mainwindow, "imagepanel", None) |
| 511 | + if imgpanel is not None: |
| 512 | + candidates.append(("image", imgpanel.objmodel)) |
| 513 | + else: |
| 514 | + candidates.append((panel.PANEL_STR_ID, self.objmodel)) |
| 515 | + # Prefer the panel that owns this view (so a self-reference resolves |
| 516 | + # locally), then fall back to the other panel. |
| 517 | + own = panel.PANEL_STR_ID |
| 518 | + candidates.sort(key=lambda c: 0 if c[0] == own else 1) |
| 519 | + for panel_str, model in candidates: |
| 520 | + obj = model.find_by_short_id(short_id) |
| 521 | + if obj is not None: |
| 522 | + return (panel_str, obj) |
| 523 | + return None |
| 524 | + |
| 525 | + def _handle_short_id_click(self, short_id: str) -> None: |
| 526 | + """Select the referenced object, switching active panel if needed.""" |
| 527 | + resolved = self._resolve_short_id(short_id) |
| 528 | + if resolved is None: |
| 529 | + return |
| 530 | + panel_str, obj = resolved |
| 531 | + panel: BaseDataPanel = self.parent() |
| 532 | + mainwindow = getattr(panel, "mainwindow", None) |
| 533 | + if mainwindow is not None and panel_str and panel_str != panel.PANEL_STR_ID: |
| 534 | + mainwindow.set_current_panel(panel_str) |
| 535 | + mainwindow.select_objects([get_uuid(obj)], panel=panel_str) |
| 536 | + else: |
| 537 | + self.set_current_item_id(get_uuid(obj)) |
| 538 | + |
390 | 539 | def paintEvent(self, event): # pylint: disable=C0103 |
391 | 540 | """Reimplement Qt method""" |
392 | 541 | super().paintEvent(event) |
|
0 commit comments