Give each online account its provider's icon

Four accounts distinguished only by a line of small grey text are four rows that
have to be read rather than recognised. GOA already knows what each one is.

It hands back a serialised GThemedIcon -- ". GThemedIcon goa-account-google
goa-account goa …" -- a preference-ordered fallback chain. The helper passes the
whole chain on rather than resolving it, because which of those names exists is
a property of the icon theme in use and not something a python script talking to
D-Bus should be deciding.

The page walks the chain and takes the first name the active theme actually has.
Both simpler readings were wrong and looked right: taking the first name blindly
assumes it resolves, and taking the last as a fallback assumes the most generic
name is the most likely to exist. On Adwaita the tails of these very chains --
"mail", "goa-symbolic" -- do not exist at all, so a miss would have drawn
nothing. Checked by asking Quickshell.iconPath directly, which returns empty for
a name the theme lacks; the fallback is avatar-default-symbolic, which is
present.

SettingsCard grew an optional icon for this. It is empty by default and the
header lays out exactly as before when unset, so no other card moves.

Last-sync is not here because there is nothing to show: GOA exposes no
sync-related property at all, on any of these accounts. Better to say so than to
invent a timestamp from when the page last refreshed.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-20 13:28:05 -04:00
parent 3b657e0a56
commit 495fb9b41b
4 changed files with 81 additions and 8 deletions
@@ -15,6 +15,7 @@
// quietly stops syncing for weeks. // quietly stops syncing for weeks.
import QtQuick import QtQuick
import Quickshell
import qs.config import qs.config
import qs.services import qs.services
@@ -57,6 +58,22 @@ SettingsPage {
id: accountCard id: accountCard
required property var modelData required property var modelData
// GOA hands back a preference-ordered chain; this walks it and takes
// the first name the active icon theme actually has. Taking the
// first name blindly, or the last as a fallback, both looked right
// and were not: on Adwaita the tail of these chains ("mail",
// "goa-symbolic") does not exist, so a miss would have rendered
// nothing at all.
icon: {
const chain = accountCard.modelData.providerIcons ?? [];
for (const name of chain) {
if (Quickshell.iconPath(String(name), true) !== "")
return String(name);
}
return "";
}
iconFallback: "avatar-default-symbolic"
title: accountCard.modelData.identity || accountCard.modelData.providerName title: accountCard.modelData.identity || accountCard.modelData.providerName
subtitle: accountCard.modelData.needsAttention subtitle: accountCard.modelData.needsAttention
? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing" ? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing"
@@ -9,6 +9,13 @@ Rectangle {
property string title: "" property string title: ""
property string subtitle: "" property string subtitle: ""
// An optional themed icon beside the title, for cards that represent a
// specific thing rather than a topic -- an online account is one of four
// that otherwise differ only by a line of small grey text. Empty by
// default, and when empty the header lays out exactly as it did before.
property string icon: ""
property string iconFallback: "dialog-information-symbolic"
width: parent ? parent.width : 680 width: parent ? parent.width : 680
implicitHeight: body.implicitHeight + 30 implicitHeight: body.implicitHeight + 30
radius: Theme.cardRadius + 2 radius: Theme.cardRadius + 2
@@ -32,15 +39,38 @@ Rectangle {
anchors.margins: 15 anchors.margins: 15
spacing: 0 spacing: 0
Text { Row {
width: parent.width width: parent.width
visible: root.title !== "" visible: root.title !== ""
text: root.title spacing: root.icon === "" ? 0 : 10
color: Theme.fg
font.family: Theme.fontFamily ThemedIcon {
font.pixelSize: Theme.fontSizeLarge anchors.verticalCenter: parent.verticalCenter
font.weight: Font.DemiBold visible: root.icon !== ""
bottomPadding: root.subtitle === "" ? 10 : 3 width: root.icon === "" ? 0 : 22
icon: root.icon
iconFallback: root.iconFallback
size: 22
}
Text {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - (root.icon === "" ? 0 : 32)
text: root.title
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
elide: Text.ElideRight
}
}
// The title's bottom padding lived on the title Text; it moves here so
// the Row above can centre an icon against it without the padding
// pushing the icon off-centre.
Item {
width: 1
height: root.title === "" ? 0 : (root.subtitle === "" ? 10 : 3)
} }
Text { Text {
@@ -68,6 +68,11 @@ def describe(obj):
"path": obj.get_object_path(), "path": obj.get_object_path(),
"provider": account.props.provider_type, "provider": account.props.provider_type,
"providerName": account.props.provider_name, "providerName": account.props.provider_name,
# GOA hands back a serialised GThemedIcon: ". GThemedIcon name1 name2 …",
# a preference-ordered fallback chain. Passed on as that list rather than
# resolved here, because which of those names exists is a property of the
# icon theme in use, which this has no business deciding.
"providerIcons": themed_icon_names(account.props.provider_icon),
# PresentationIdentity is the human one (an email address); Identity is # PresentationIdentity is the human one (an email address); Identity is
# the internal handle and is not always readable. # the internal handle and is not always readable.
"identity": account.props.presentation_identity or account.props.identity, "identity": account.props.presentation_identity or account.props.identity,
@@ -79,6 +84,26 @@ def describe(obj):
} }
def themed_icon_names(icon) -> list[str]:
"""The icon names out of a serialised GThemedIcon, best first.
The string form is ". GThemedIcon mail-unread-symbolic mail-symbolic mail",
where the leading "." and the type name are structure rather than content.
Anything that is not that shape yields nothing, so a caller gets an empty
list rather than a name that will never resolve.
"""
if icon is None:
return []
try:
text = icon.to_string()
except Exception:
text = str(icon)
parts = str(text or "").split()
if len(parts) < 3 or parts[0] != "." or parts[1] != "GThemedIcon":
return []
return [name for name in parts[2:] if name]
def find(client, path): def find(client, path):
for obj in client.get_accounts(): for obj in client.get_accounts():
if obj.get_object_path() == path: if obj.get_object_path() == path:
@@ -25,7 +25,8 @@ Singleton {
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-accounts" readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-accounts"
// [{ path, provider, providerName, identity, needsAttention, services: [{key,label,enabled}] }] // [{ path, provider, providerName, providerIcons, identity, needsAttention,
// services: [{key,label,enabled}] }]
property var accounts: [] property var accounts: []
property bool scanned: false property bool scanned: false
property bool busy: false property bool busy: false