Skip to content

Commit 6807861

Browse files
committed
feat(short-id): implement clickable short ID links in object titles
1 parent b2c4965 commit 6807861

4 files changed

Lines changed: 465 additions & 5 deletions

File tree

datalab/gui/objectview.py

Lines changed: 154 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,14 @@
4646
from sigima.objects import ImageObj, SignalObj
4747

4848
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+
)
5055
from datalab.utils.qthelpers import block_signals
56+
from datalab.widgets.titledelegate import ClickableTitleDelegate
5157

5258
if TYPE_CHECKING:
5359
from typing import Any
@@ -97,6 +103,9 @@ def __init__(self, parent: QW.QWidget, objmodel: ObjectModel) -> None:
97103
self.itemDoubleClicked.connect(self.item_double_clicked)
98104
self.header().setSectionResizeMode(QW.QHeaderView.Interactive)
99105
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)
100109

101110
def __str__(self) -> str:
102111
"""Return string representation"""
@@ -201,16 +210,114 @@ def get_sel_groups(self) -> list[ObjectGroup]:
201210
"""Return selected groups"""
202211
return self.objmodel.get_groups(self.get_sel_group_uuids())
203212

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+
205262
def __update_item(
206-
item: QW.QTreeWidgetItem, obj: SignalObj | ImageObj | ObjectGroup
263+
self, item: QW.QTreeWidgetItem, obj: SignalObj | ImageObj | ObjectGroup
207264
) -> None:
208265
"""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)
210272
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))
212277
item.setData(0, QC.Qt.UserRole, get_uuid(obj))
213278

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+
214321
def populate_tree(self) -> None:
215322
"""Populate tree with objects"""
216323
uuid = self.get_current_item_id()
@@ -387,6 +494,48 @@ def __init__(self, parent: BaseDataPanel, objmodel: ObjectModel) -> None:
387494
self.__dragged_groups: list[QW.QListWidgetItem] = []
388495
self.__dragged_expanded_states: dict[QW.QListWidgetItem, bool] = {}
389496

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+
390539
def paintEvent(self, event): # pylint: disable=C0103
391540
"""Reimplement Qt method"""
392541
super().paintEvent(event)

datalab/objectmodel.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,24 @@ def patch_title_with_ids(
110110
) from exc
111111

112112

113+
#: Regex matching short IDs as embedded in computation titles
114+
#: (e.g. ``s001``, ``i012``, ``gs003``, ``gi007``).
115+
SHORT_ID_REGEX = re.compile(r"\b(g?[si])(\d{3})\b")
116+
117+
118+
def find_short_ids_in_title(title: str) -> list[tuple[int, int, str]]:
119+
"""Return a list of ``(start, end, short_id)`` tuples for every short ID
120+
occurrence found in ``title``.
121+
122+
Args:
123+
title: title string to scan
124+
125+
Returns:
126+
List of ``(start, end, short_id)`` tuples, sorted by ``start``.
127+
"""
128+
return [(m.start(), m.end(), m.group(0)) for m in SHORT_ID_REGEX.finditer(title)]
129+
130+
113131
class ObjectGroup:
114132
"""Represents a DataLab object group
115133
@@ -292,6 +310,28 @@ def get_object_or_group(self, uuid: str) -> SignalObj | ImageObj | ObjectGroup:
292310
return group
293311
raise KeyError(f"Object or group with uuid {uuid} not found")
294312

313+
def find_by_short_id(
314+
self, short_id: str
315+
) -> SignalObj | ImageObj | ObjectGroup | None:
316+
"""Return the object or group whose short ID matches ``short_id``,
317+
or ``None`` if no match is found in this model.
318+
319+
Args:
320+
short_id: short ID to look up (e.g. ``"s001"``, ``"i012"``,
321+
``"gs003"`` or ``"gi007"``).
322+
323+
Returns:
324+
The matching :class:`sigima.SignalObj`, :class:`sigima.ImageObj`
325+
or :class:`ObjectGroup` instance, or ``None``.
326+
"""
327+
for group in self._groups:
328+
if get_short_id(group) == short_id:
329+
return group
330+
for obj in self._objects.values():
331+
if get_short_id(obj) == short_id:
332+
return obj
333+
return None
334+
295335
def get_group(self, uuid: str) -> ObjectGroup:
296336
"""Return group with uuid"""
297337
for group in self._groups:
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
2+
3+
"""
4+
Short ID title link unit test.
5+
6+
Validates the helpers underpinning the clickable short-ID feature in
7+
:mod:`datalab.gui.objectview`:
8+
9+
- :func:`datalab.objectmodel.find_short_ids_in_title`
10+
- :func:`datalab.widgets.titledelegate._build_html`
11+
"""
12+
13+
# pylint: disable=invalid-name # Allows short reference names like x, y, ...
14+
15+
from __future__ import annotations
16+
17+
from datalab.objectmodel import find_short_ids_in_title
18+
from datalab.widgets.titledelegate import SHORT_ID_URL_SCHEME, _build_html
19+
20+
21+
def test_find_short_ids_in_title_basic() -> None:
22+
"""``find_short_ids_in_title`` extracts every short ID with its bounds."""
23+
matches = find_short_ids_in_title("s003: average(s001, s002)")
24+
assert [m[2] for m in matches] == ["s003", "s001", "s002"]
25+
assert matches[0][0] == 0 # leading short ID starts at offset 0
26+
27+
28+
def test_find_short_ids_in_title_mixed_kinds() -> None:
29+
"""Image, group and signal short IDs are all detected."""
30+
matches = find_short_ids_in_title("i012: derived(s001, gi003)")
31+
assert [m[2] for m in matches] == ["i012", "s001", "gi003"]
32+
33+
34+
def test_find_short_ids_in_title_no_false_positives() -> None:
35+
"""Random ``letter+digits`` patterns are not mistaken for short IDs."""
36+
# `s12345` has too many digits; `s1` has too few.
37+
assert find_short_ids_in_title("s12345 then s1") == []
38+
# Substrings inside a word must not match either.
39+
assert find_short_ids_in_title("class s001abc") == []
40+
41+
42+
def test_build_html_skips_leading_short_id() -> None:
43+
"""The leading ``s001:`` part is rendered as plain text."""
44+
html = _build_html("s003: average(s001, s002)")
45+
# Leading "s003" must NOT be wrapped in an anchor
46+
assert html.startswith("s003")
47+
assert f'href="{SHORT_ID_URL_SCHEME}:s003"' not in html
48+
# But s001 and s002 inside the body must be anchors
49+
assert f'href="{SHORT_ID_URL_SCHEME}:s001"' in html
50+
assert f'href="{SHORT_ID_URL_SCHEME}:s002"' in html
51+
52+
53+
def test_build_html_escapes_text() -> None:
54+
"""Surrounding text is HTML-escaped to avoid markup injection."""
55+
html = _build_html("s003: <not a tag> & friends")
56+
assert "&lt;not a tag&gt;" in html
57+
assert "&amp;" in html
58+
59+
60+
def test_build_html_no_short_ids_returns_plain_text() -> None:
61+
"""Without short IDs, the output is just the escaped text."""
62+
assert _build_html("Just a title") == "Just a title"

0 commit comments

Comments
 (0)