fix(qt): featured artist names in track rows are plain spans, not underlined links

StyledText underlines every `<a>` anchor unconditionally and nothing
else in the app underlines a link. The names are now colour spans in
the same StyledText (elision kept), and the click and hover hit test
measures the prefix up to each name with the row's own font, so each
name still opens its page or its search on its own.

cc
This commit is contained in:
vicrodh committed 2026-09-14 01:16:52 -06:00
1 parent 34ea883d07
commit 64d75e497a
1 file changed
+69 -21
+69 -21
View File
@@ -990,13 +990,28 @@ Rectangle {
// Main artist first, then the featured performers, one comma
// separated list. Local / media-server rows carry no
// `featured` and stay as they are. With links enabled and
// featured names present, every name is its own StyledText
// link: the main artist opens its page as before; a featured
// featured names present, every name is its own clickable
// SPAN: the main artist opens its page as before; a featured
// name opens its page when the album credits gave it an id
// and the search for that name otherwise. StyledText elides
// like plain text (probed: truncated stays true on one line).
// and the search for that name otherwise. Spans, not `<a>`
// anchors: StyledText underlines every anchor unconditionally
// (qquickstyledtext.cpp, setFontUnderline(true)) and nothing
// else in the app underlines a link, so the hit test is done
// by measuring the prefix up to each name with the same font
// (TextMetrics below). StyledText still elides like plain
// text (probed: truncated stays true on one line).
readonly property var featured: root.showFeaturedArtists ? (root.item.featured || []) : []
readonly property bool linkMode: root.artistLink && featured.length > 0
/// The names as displayed, in order: {name, href} — href ""
/// = the main artist with no page to open.
readonly property var spans: {
var out = [{ "name": String(root.item.artist || ""), "href": root.item.artistId ? "main" : "" }]
for (var i = 0; i < featured.length; i++)
out.push({ "name": String(featured[i].name || ""), "href": "f:" + i })
return out
}
/// The span under the pointer (-1 = none).
property int hotSpan: -1
function esc(s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
}
@@ -1005,51 +1020,84 @@ Rectangle {
return ("0" + Math.round(v * 255).toString(16)).slice(-2)
}).join("")
}
function link(href, name) {
var hot = artistLine.hoveredLink === href
return '<a href="' + href + '"><font color="'
+ hex(hot ? theme.textPrimary : theme.textMuted) + '">' + esc(name) + '</font></a>'
function span(i) {
var s = artistLine.spans[i]
var hot = artistLine.hotSpan === i && s.href !== ""
return '<font color="' + hex(hot ? theme.textPrimary : theme.textMuted) + '">' + esc(s.name) + '</font>'
}
textFormat: linkMode ? Text.StyledText : Text.PlainText
text: {
var names = featured.map(function (f) { return f.name })
if (!linkMode)
return (root.item.artist || "") + (names.length ? ", " + names.join(", ") : "")
var parts = [root.item.artistId ? link("main", root.item.artist) : esc(root.item.artist)]
for (var i = 0; i < featured.length; i++)
parts.push(link("f:" + i, featured[i].name))
var parts = []
for (var i = 0; i < spans.length; i++)
parts.push(span(i))
return parts.join(", ")
}
linkColor: theme.textMuted
color: root.artistLink && root.item.artistId && artistLinkArea.containsMouse
? theme.textPrimary : theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
onLinkActivated: function (href) {
if (href === "main") {
/// Which span sits at `x` (-1 = a separator or past the text):
/// the prefix up to each name's end is measured with this
/// Text's own font, so the edges match what is drawn.
function spanAt(x) {
if (!linkMode || x < 0)
return -1
var prefix = ""
for (var i = 0; i < spans.length; i++) {
spanMetrics.text = prefix
var from = spanMetrics.advanceWidth
prefix += spans[i].name
spanMetrics.text = prefix
if (x >= from && x < spanMetrics.advanceWidth)
return i
prefix += ", "
}
return -1
}
function activate(i) {
var s = artistLine.spans[i]
if (!s || s.href === "")
return
if (s.href === "main") {
if (root.routeGoToExternally) root.goToRequested("artist")
else QbzArtist.openArtist(root.item.artistId)
return
}
var f = featured[parseInt(href.slice(2))]
var f = featured[parseInt(s.href.slice(2))]
if (!f) return
if (f.id) QbzArtist.openArtist(f.id)
else QbzSearch.searchSubmit(f.name)
}
HoverHandler {
enabled: artistLine.linkMode
cursorShape: artistLine.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
TextMetrics {
id: spanMetrics
font: artistLine.font
}
MouseArea {
id: artistLinkArea
anchors.fill: parent
enabled: root.artistLink && !!root.item.artistId && !artistLine.linkMode
enabled: root.artistLink && (artistLine.linkMode || !!root.item.artistId)
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
cursorShape: (artistLine.linkMode
? (artistLine.hotSpan >= 0 && artistLine.spans[artistLine.hotSpan].href !== "")
: enabled) ? Qt.PointingHandCursor : Qt.ArrowCursor
onPositionChanged: function (mouse) {
if (artistLine.linkMode)
artistLine.hotSpan = artistLine.spanAt(mouse.x)
}
onExited: artistLine.hotSpan = -1
// Same routing as the menu's "Go to artist" — Slint drives
// both through one handler, so gating only the menu would
// leave the LINK navigating to the track's own artist.
onClicked: {
onClicked: function (mouse) {
if (artistLine.linkMode) {
var i = artistLine.spanAt(mouse.x)
if (i >= 0)
artistLine.activate(i)
return
}
if (root.routeGoToExternally) root.goToRequested("artist")
else QbzArtist.openArtist(root.item.artistId)
}