diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 6807e2a..4f65395 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -81,6 +81,7 @@ if(CST_APP_GUI) src/core/AgentCore.cpp src/core/ToolProvider.cpp src/ui/UiTest.cpp + src/ui/ThemeIcons.cpp src/platform/linux/NotifyTool.cpp) target_include_directories(cst-agent-app PRIVATE src "${CMAKE_CURRENT_SOURCE_DIR}/../include") @@ -104,7 +105,7 @@ if(CST_APP_GUI) # requires absolute URLs" on every start). foreach(_qml Main ChatView Sidebar SessionRow MessageCard ToolCard ThinkBlock NoteLine TodoPanel Composer ConsentDialog AskDialog SettingsDialog - Theme) + ThemeIcon Theme) set_source_files_properties(qml/${_qml}.qml PROPERTIES QT_RESOURCE_ALIAS ${_qml}.qml) endforeach() qt_add_qml_module(cst-agent-app @@ -124,6 +125,7 @@ if(CST_APP_GUI) qml/ConsentDialog.qml qml/AskDialog.qml qml/SettingsDialog.qml + qml/ThemeIcon.qml qml/Theme.qml) endif() endif() diff --git a/app/README.md b/app/README.md index 6b2ee30..7190d7e 100644 --- a/app/README.md +++ b/app/README.md @@ -200,6 +200,37 @@ list, and — with an 8×8 image put on the clipboard — `clipboardHasImage()` for that (a row as JSON — an object would not survive `QMetaObject::invokeMethod`). +## Icons, colors + +- Icons come from the desktop's **icon theme** (Breeze on KDE), through + `image://csttheme/?color=%23rrggbb` (`app/src/ui/ThemeIcons.{h,cpp}`). + Qt Quick has no icon provider of its own without Kirigami, so the window used + unicode glyphs before (a clip emoji, a gear, a hamburger): those render in + whichever font covers them — colour emoji in a grey toolbar — and are a box + on a machine without that glyph. `app/qml/ThemeIcon.qml` is the one place + that asks for an icon, and every use still names the old character as + `glyph:`: a host with no icon theme degrades to what it looked like before + rather than going blank. +- The provider fills Qt's theme search paths itself (XDG data dirs, `~/.icons` + and, on NixOS, `/run/current-system/sw/share/icons`): Qt only builds them + from XDG once a theme name is set. It then tries the theme the desktop chose, + else `breeze-dark` (this window is dark), `breeze`, `hicolor`. Rasterising + Breeze needs the Qt SVG image format plugin (`qt6.qtsvg` on Nix, part of a + desktop Qt install) — without it the icons fall back to the glyphs instead of + disappearing. +- `Theme.toolIcon(name)` maps a tool onto its icon: patch/write → pencil, + read → text page, list/glob → folder, grep → magnifier, shell → braces, + web → globe, plan → list, ask → question mark. Toolbar icons are 22 px + (Breeze's toolbar size), card and row icons 14–16 px. +- `textFaint` is readable grey, not decoration (~5:1 on the card fill): it + carries hints, secondary lines and provider URLs. Anything written *on* the + accent fill (a selected provider row, the open session) uses `accentText`, + `accentTextDim` and `okOnAccent` — green or faint grey on `accentDim` is + unreadable. The names are not `onAccent…`: QML reads every `onFoo` property + as a signal handler and refuses the assignment. +- The composer's frame is one `Theme.pad` around its column on **all** sides; + with the top margin alone the clip and Send hung over the lower border. + ## Tools the app provides A tool implemented in the front end is registered with `cst_tool_register()` @@ -258,7 +289,10 @@ The build flag that keeps Unix helpers out of the picture is - [x] session list in the sidebar: new / switch / delete, transcript replay - [x] settings dialog: provider, model, key (`cst_ps_*`), token counters - [x] attachments: picker, drop on the window, Ctrl+V image paste +- [x] icons from the desktop's icon theme (Breeze), with a glyph fallback +- [x] contrast pass: faint text and selected rows are readable - [ ] a real turn driven through the window (needs a provider key) - [ ] tool results that carry images (they are stored, not drawn yet) -- [ ] Kirigami (Breeze look), tray, global shortcuts +- [ ] Kirigami (the rest of the Breeze look: colors, controls), tray, + global shortcuts - [ ] Android APK (engine in-process, JNI platform tools) diff --git a/app/qml/ChatView.qml b/app/qml/ChatView.qml index aaa3464..988a849 100644 --- a/app/qml/ChatView.qml +++ b/app/qml/ChatView.qml @@ -123,13 +123,25 @@ Item { // Jump back to the live end of the conversation. RoundButton { + id: jump anchors.right: parent.right anchors.bottom: parent.bottom anchors.margins: Theme.padL width: Theme.jumpButton height: Theme.jumpButton visible: !view.stickBottom && list.contentHeight > list.height - text: "↓" + contentItem: ThemeIcon { + name: "go-down" + glyph: "↓" + size: 20 + color: Theme.text + } + background: Rectangle { + radius: width / 2 + color: jump.hovered ? Theme.surfaceHi : Theme.surfaceAlt + border.width: 1 + border.color: Theme.border + } onClicked: { view.stickBottom = true list.positionViewAtEnd() diff --git a/app/qml/Composer.qml b/app/qml/Composer.qml index 68da609..31adf5e 100644 --- a/app/qml/Composer.qml +++ b/app/qml/Composer.qml @@ -7,7 +7,7 @@ import QtQuick.Layouts // state. Sized for a thumb as much as for a mouse, and it asks the platform // keyboard not to autocorrect what is usually code or a path. // -// Files arrive three ways -- the 📎 picker, a drop on the window (Main.qml) +// Files arrive three ways -- the clip picker, a drop on the window (Main.qml) // and an image on the clipboard (Ctrl+V) -- and all of them end up in the // engine's pending list, which the next prompt carries. Rectangle { @@ -48,14 +48,15 @@ Rectangle { color: Theme.surface border.width: 1 border.color: input.activeFocus ? Theme.accentDim : Theme.border - implicitHeight: layout.implicitHeight + Theme.pad + /* One pad of frame on every side of the column: with the top margin + * alone the last row (the clip and Send) hung over the lower border, + * which is what put the clip on the frame instead of inside it. */ + implicitHeight: layout.implicitHeight + Theme.pad * 2 ColumnLayout { id: layout anchors { - left: parent.left - right: parent.right - top: parent.top + fill: parent margins: Theme.pad } spacing: 6 @@ -178,20 +179,17 @@ Rectangle { enabled: !root.running ToolTip.visible: hovered ToolTip.text: "Attach a file — or drop one on the window, or paste an image" - contentItem: Text { - text: "📎" - font.pixelSize: 16 + contentItem: ThemeIcon { + name: "mail-attachment" + glyph: "📎" + size: 20 color: attach.enabled ? (attach.hovered ? Theme.text : Theme.textDim) : Theme.textFaint - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter } background: Rectangle { radius: Theme.radiusSm color: attach.hovered && attach.enabled ? Theme.surfaceHi : "transparent" - border.width: 1 - border.color: Theme.border } onClicked: picker.open() } @@ -213,7 +211,7 @@ Rectangle { } contentItem: Text { text: action.text - color: action.enabled ? "#12161a" : Theme.textFaint + color: action.enabled ? Theme.accentInk : Theme.textFaint font.pixelSize: Theme.bodySize font.bold: true horizontalAlignment: Text.AlignHCenter @@ -229,7 +227,7 @@ Rectangle { text: { if (root.running) return "working — Esc cancels the turn" - let s = "Enter sends · Shift+Enter newline · Esc stops · 📎 or Ctrl+V attaches" + let s = "Enter sends · Shift+Enter newline · Esc stops · the clip attaches a file, Ctrl+V an image" if (root.attachments.length > 0 && agent.seesImages() === 0) s += " · this model is text-only, images arrive as notes" return s diff --git a/app/qml/Main.qml b/app/qml/Main.qml index 1ea0570..3166e95 100644 --- a/app/qml/Main.qml +++ b/app/qml/Main.qml @@ -218,12 +218,11 @@ ApplicationWindow { implicitHeight: Theme.touchTarget ToolTip.visible: hovered && !win.compact ToolTip.text: "Settings and plan" - contentItem: Text { - text: "☰" + contentItem: ThemeIcon { + name: "application-menu" + glyph: "☰" + size: 22 color: parent.hovered ? Theme.text : Theme.textDim - font.pixelSize: 17 - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter } background: Rectangle { radius: Theme.radiusSm @@ -267,12 +266,11 @@ ApplicationWindow { implicitHeight: Theme.touchTarget ToolTip.visible: hovered ToolTip.text: "Provider, model and key" - contentItem: Text { - text: "⚙" + contentItem: ThemeIcon { + name: "settings-configure" + glyph: "⚙" + size: 22 color: parent.hovered ? Theme.text : Theme.textDim - font.pixelSize: 16 - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter } background: Rectangle { radius: Theme.radiusSm diff --git a/app/qml/MessageCard.qml b/app/qml/MessageCard.qml index c64efcb..11d84fb 100644 --- a/app/qml/MessageCard.qml +++ b/app/qml/MessageCard.qml @@ -58,11 +58,11 @@ Rectangle { visible: card.text.length > 0 ToolTip.visible: hovered ToolTip.text: "Copy" - contentItem: Text { - text: "⧉" + contentItem: ThemeIcon { + name: "edit-copy" + glyph: "⧉" + size: 14 color: parent.hovered ? Theme.text : Theme.textFaint - font.pixelSize: Theme.smallSize - horizontalAlignment: Text.AlignHCenter } background: Rectangle { radius: Theme.radiusSm diff --git a/app/qml/SessionRow.qml b/app/qml/SessionRow.qml index 1f86f75..ae407c9 100644 --- a/app/qml/SessionRow.qml +++ b/app/qml/SessionRow.qml @@ -64,7 +64,7 @@ Rectangle { text: srow.entry !== null && srow.entry.title.length ? srow.entry.title : srow.entry !== null && srow.entry.firstUser.length ? srow.entry.firstUser : "(no prompt yet)" - color: srow.open ? Theme.text : Theme.textDim + color: srow.open ? Theme.accentText : Theme.textDim font.pixelSize: Theme.smallSize } Text { @@ -78,7 +78,7 @@ Rectangle { return n > 0 ? n + (n === 1 ? " message · " : " messages · ") + when : when } - color: Theme.textFaint + color: srow.open ? Theme.accentTextDim : Theme.textFaint font.pixelSize: Theme.smallSize } } @@ -89,12 +89,12 @@ Rectangle { implicitHeight: 26 ToolTip.visible: hovered ToolTip.text: "Delete this session and its transcript" - contentItem: Text { - text: "×" - color: parent.hovered ? Theme.err : Theme.textFaint - font.pixelSize: 15 - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter + contentItem: ThemeIcon { + name: "edit-delete" + glyph: "×" + size: 14 + color: parent.hovered ? Theme.err + : srow.open ? Theme.accentTextDim : Theme.textFaint } background: Rectangle { radius: Theme.radiusSm diff --git a/app/qml/SettingsDialog.qml b/app/qml/SettingsDialog.qml index 10d606e..1bf202e 100644 --- a/app/qml/SettingsDialog.qml +++ b/app/qml/SettingsDialog.qml @@ -167,20 +167,20 @@ Dialog { spacing: Theme.gap Text { text: prow.modelData.name - color: prow.selected ? Theme.text : Theme.textDim + color: prow.selected ? Theme.accentText : Theme.textDim font.pixelSize: Theme.smallSize font.bold: prow.selected } Text { visible: prow.modelData.current === true text: "in use" - color: Theme.ok + color: prow.selected ? Theme.okOnAccent : Theme.ok font.pixelSize: Theme.smallSize } Item { Layout.fillWidth: true } Text { text: prow.modelData.source - color: Theme.textFaint + color: prow.selected ? Theme.accentTextDim : Theme.textFaint font.pixelSize: Theme.smallSize } } @@ -195,7 +195,7 @@ Dialog { : (prow.modelData.keyEnv.length ? "no key (" + prow.modelData.keyEnv + ")" : "no key")) - color: Theme.textFaint + color: prow.selected ? Theme.accentTextDim : Theme.textFaint font.pixelSize: Theme.smallSize } } @@ -349,7 +349,7 @@ Dialog { } contentItem: Text { text: applyBtn.text - color: "#12161a" + color: Theme.accentInk font.pixelSize: Theme.bodySize font.bold: true horizontalAlignment: Text.AlignHCenter diff --git a/app/qml/Sidebar.qml b/app/qml/Sidebar.qml index 3a0534f..7cf7163 100644 --- a/app/qml/Sidebar.qml +++ b/app/qml/Sidebar.qml @@ -149,19 +149,30 @@ Rectangle { // Starting a session while a turn runs would move the // engine out from under it: the whole block is off then. ToolButton { + id: newSession objectName: "newSessionButton" - implicitWidth: 62 + implicitWidth: 68 implicitHeight: Theme.segmentHeight enabled: !side.running ToolTip.visible: hovered ToolTip.text: "Start a new session" - contentItem: Text { - text: "+ new" - color: parent.enabled ? (parent.hovered ? Theme.text : Theme.textDim) - : Theme.textFaint - font.pixelSize: Theme.smallSize - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter + contentItem: RowLayout { + spacing: 4 + ThemeIcon { + name: "list-add" + glyph: "+" + size: 14 + color: newSession.enabled + ? (newSession.hovered ? Theme.text : Theme.textDim) + : Theme.textFaint + } + Text { + text: "new" + color: newSession.enabled + ? (newSession.hovered ? Theme.text : Theme.textDim) + : Theme.textFaint + font.pixelSize: Theme.smallSize + } } background: Rectangle { radius: Theme.radiusSm diff --git a/app/qml/Theme.qml b/app/qml/Theme.qml index 0d1bd36..8c2cb7d 100644 --- a/app/qml/Theme.qml +++ b/app/qml/Theme.qml @@ -14,12 +14,24 @@ QtObject { readonly property color border: "#343b41" readonly property color text: "#eff0f1" readonly property color textDim: "#9aa2a8" - readonly property color textFaint: "#6d757b" + /* Faint is still text: small print (hints, secondary lines, a selected + * row's detail) has to stay readable on both the background and the + * accent fill, so it is a step lighter than it looks in a mockup. */ + readonly property color textFaint: "#8d959c" readonly property color accent: "#3daee9" readonly property color accentDim: "#1d6f95" + /* What is written *on* the accent fill (a selection, the open session). + * Named "accentText...", not "onAccent...": QML reads any `onFoo` as a + * signal handler and refuses the assignment. */ + readonly property color accentText: "#ffffff" + readonly property color accentTextDim: "#dceefb" + /* The dark ink a filled button carries (accent, ok, err are all light). */ + readonly property color accentInk: "#12161a" readonly property color ok: "#27ae60" readonly property color warn: "#f67400" readonly property color err: "#da4453" + /* Green on the accent fill is mud: the "in use" mark needs its own ink. */ + readonly property color okOnAccent: "#d8f5e3" readonly property int radius: 6 readonly property int radiusSm: 3 @@ -45,6 +57,32 @@ QtObject { readonly property int headerHeight: 42 readonly property int jumpButton: 40 + // Which Breeze icon stands for a tool: the card shows at a glance what + // kind of thing ran. Unknown names still get one (no glyph fallbacks for + // tools -- a missing icon just leaves the name alone). + function toolIcon(name) { + const n = String(name).toLowerCase() + if (n.indexOf("patch") >= 0 || n.indexOf("write") >= 0 || n.indexOf("edit") >= 0) + return "document-edit" + if (n.indexOf("read") >= 0 || n.indexOf("cat") >= 0) + return "text-x-generic" + if (n.indexOf("list_dir") >= 0 || n.indexOf("glob") >= 0 || + n.indexOf("find") >= 0 || n.indexOf("dir") >= 0 || n.indexOf("file") >= 0) + return "folder-open" + if (n.indexOf("grep") >= 0 || n.indexOf("search") >= 0) + return "edit-find" + if (n.indexOf("bash") >= 0 || n.indexOf("shell") >= 0 || + n.indexOf("command") >= 0 || n.indexOf("run") >= 0) + return "code-context" + if (n.indexOf("web") >= 0 || n.indexOf("http") >= 0 || n.indexOf("fetch") >= 0) + return "internet-services" + if (n.indexOf("todo") >= 0 || n.indexOf("plan") >= 0) + return "format-list-unordered" + if (n.indexOf("ask") >= 0 || n.indexOf("question") >= 0 || n.indexOf("notif") >= 0) + return "help-hint" + return "code-context" + } + // The tool output / diff line colors, used by ToolCard. function lineColor(line) { if (line.startsWith("@@")) diff --git a/app/qml/ThemeIcon.qml b/app/qml/ThemeIcon.qml new file mode 100644 index 0000000..f48fbbf --- /dev/null +++ b/app/qml/ThemeIcon.qml @@ -0,0 +1,50 @@ +import QtQuick + +// One icon, from the desktop's icon theme (image://csttheme/, see +// app/src/ui/ThemeIcons.cpp), tinted with a color of the window's own. +// +// `glyph` is the last resort: a machine with no icon theme at all gets the +// unicode character the app used before, rather than an empty hole. Every +// place that shows an icon names its glyph, so nothing can go blank. +Item { + id: root + property string name: "" + property string glyph: "" + property color color: Theme.textDim + property int size: 16 + implicitWidth: size + implicitHeight: size + + /* "#rrggbb": a color typed to a string in QML carries alpha and a + * leading '#', which the provider's query would choke on. */ + function hex(c) { + function two(v) { + const s = Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16) + return s.length < 2 ? "0" + s : s + } + return "#" + two(c.r) + two(c.g) + two(c.b) + } + + Image { + id: img + anchors.fill: parent + source: root.name.length + ? "image://csttheme/" + root.name + "?color=" + encodeURIComponent(root.hex(root.color)) + : "" + sourceSize.width: root.size + sourceSize.height: root.size + fillMode: Image.PreserveAspectFit + smooth: true + visible: status === Image.Ready + } + + Text { + anchors.fill: parent + visible: img.status === Image.Error && root.glyph.length > 0 + text: root.glyph + color: root.color + font.pixelSize: Math.round(root.size * 0.85) + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } +} diff --git a/app/qml/ThinkBlock.qml b/app/qml/ThinkBlock.qml index 9677606..3d64fe7 100644 --- a/app/qml/ThinkBlock.qml +++ b/app/qml/ThinkBlock.qml @@ -37,10 +37,11 @@ Rectangle { cursorShape: Qt.PointingHandCursor onTapped: block.expanded = !block.expanded } - Text { - text: block.expanded ? "▾" : "▸" + ThemeIcon { + name: block.expanded ? "arrow-down" : "arrow-right" + glyph: block.expanded ? "▾" : "▸" + size: Theme.smallSize + 2 color: Theme.textDim - font.pixelSize: Theme.smallSize } Text { text: block.complete ? "thinking" : "thinking…" diff --git a/app/qml/TodoPanel.qml b/app/qml/TodoPanel.qml index 74caf16..39c9acd 100644 --- a/app/qml/TodoPanel.qml +++ b/app/qml/TodoPanel.qml @@ -33,11 +33,12 @@ ColumnLayout { RowLayout { Layout.fillWidth: true spacing: Theme.gap - Text { + ThemeIcon { Layout.alignment: Qt.AlignTop - text: modelData.done ? "✓" : "○" + name: modelData.done ? "task-complete" : "box" + glyph: modelData.done ? "✓" : "○" + size: Theme.smallSize + 2 color: modelData.done ? Theme.ok : Theme.textFaint - font.pixelSize: Theme.smallSize } Text { Layout.fillWidth: true diff --git a/app/qml/ToolCard.qml b/app/qml/ToolCard.qml index 682bf87..f944b6f 100644 --- a/app/qml/ToolCard.qml +++ b/app/qml/ToolCard.qml @@ -66,6 +66,14 @@ Rectangle { radius: 3 color: card.stateColor } + /* What kind of tool this was, from the icon theme (Theme.toolIcon + * maps the engine's names onto Breeze ones). */ + ThemeIcon { + Layout.alignment: Qt.AlignVCenter + name: Theme.toolIcon(card.name) + size: 16 + color: Theme.textDim + } Text { text: card.name color: Theme.text @@ -103,11 +111,11 @@ Rectangle { implicitWidth: 26 implicitHeight: 22 visible: card.output.length > 0 - contentItem: Text { - text: card.expanded ? "▾" : "▸" + contentItem: ThemeIcon { + name: card.expanded ? "arrow-down" : "arrow-right" + glyph: card.expanded ? "▾" : "▸" + size: 14 color: parent.hovered ? Theme.text : Theme.textDim - font.pixelSize: Theme.smallSize - horizontalAlignment: Text.AlignHCenter } background: Rectangle { radius: Theme.radiusSm diff --git a/app/src/main.cpp b/app/src/main.cpp index 4ee1002..d52ced3 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -27,6 +27,7 @@ #include "core/AgentCore.h" #include "core/ToolProvider.h" #include "ui/UiTest.h" +#include "ui/ThemeIcons.h" #include "cst_version.h" namespace { @@ -192,6 +193,8 @@ int main(int argc, char **argv) { } QQmlApplicationEngine engine; + /* image://csttheme/ plus the desktop's icon theme behind it. */ + app::installThemeIcons(&engine); engine.rootContext()->setContextProperty(QStringLiteral("agent"), &core); engine.rootContext()->setContextProperty(QStringLiteral("initialWidth"), winW); engine.rootContext()->setContextProperty(QStringLiteral("initialHeight"), winH); diff --git a/app/src/ui/ThemeIcons.cpp b/app/src/ui/ThemeIcons.cpp new file mode 100644 index 0000000..77be9ba --- /dev/null +++ b/app/src/ui/ThemeIcons.cpp @@ -0,0 +1,123 @@ +#include "ui/ThemeIcons.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace app { +namespace { + +/* What a usable theme has to have; every window in the app needs it. */ +const char kProbe[] = "mail-attachment"; + +/* The desktop's own theme first (whatever the platform theme set), then + * Breeze: this window is dark, so the dark variant, and Breeze proper as + * Qt's fallback for the odd name that only lives there. Before any of that + * the search paths have to be filled in -- Qt only builds them from XDG once + * a theme name was set, and a shell that was started outside the desktop + * session (nix-shell) has no XDG_DATA_DIRS pointing at the icon packages, + * hence the system profile. */ +void adoptIconTheme() { + const QStringList known = QStandardPaths::standardLocations( + QStandardPaths::GenericDataLocation); + QStringList paths; + for (const QString &d : known) + paths << d + QStringLiteral("/icons"); + paths << QDir::homePath() + QStringLiteral("/.icons"); + const QString system = QStringLiteral("/run/current-system/sw/share/icons"); + if (!paths.contains(system) && QDir(system).exists()) + paths << system; + paths << QStringLiteral(":/icons"); + QIcon::setThemeSearchPaths(paths); + + QStringList candidates; + if (!QIcon::themeName().isEmpty()) + candidates << QIcon::themeName(); + candidates << QStringLiteral("breeze-dark") << QStringLiteral("breeze") + << QStringLiteral("hicolor"); + for (const QString &name : candidates) { + QIcon::setThemeName(name); + if (QIcon::hasThemeIcon(QLatin1String(kProbe))) + return; + } +} + +/* "?color=%23eff0f1": the tint, and nothing else so far. */ +QColor colorOption(const QString &id, QString *name) { + QColor color; + *name = id; + const int q = id.indexOf(QLatin1Char('?')); + if (q < 0) + return color; + *name = id.left(q); + const QStringList options = id.mid(q + 1).split(QLatin1Char('&')); + for (const QString &opt : options) { + if (!opt.startsWith(QLatin1String("color="))) + continue; + const QColor c(QUrl::fromPercentEncoding(opt.mid(6).toUtf8())); + if (c.isValid()) + color = c; + } + return color; +} + +qreal targetRatio() { + if (const QScreen *s = QGuiApplication::primaryScreen()) + return qMax(1.0, s->devicePixelRatio()); + return 1.0; +} + +} // namespace + +ThemeIconProvider::ThemeIconProvider() + : QQuickImageProvider(QQuickImageProvider::Image) {} + +QImage ThemeIconProvider::requestImage(const QString &id, QSize *size, + const QSize &requestedSize) { + QString name; + const QColor color = colorOption(id, &name); + if (name.isEmpty()) + return QImage(); + + const QIcon icon = QIcon::fromTheme(name); + if (icon.isNull()) + return QImage(); /* QML shows its fallback glyph */ + + const int px = requestedSize.isValid() && requestedSize.width() > 0 + ? requestedSize.width() + : 22; + const qreal ratio = targetRatio(); + QPixmap pm = icon.pixmap(QSize(px, px), ratio, QIcon::Normal, QIcon::Off); + if (pm.isNull()) + return QImage(); + if (color.isValid()) { + QPainter p(&pm); + p.setCompositionMode(QPainter::CompositionMode_SourceIn); + p.fillRect(pm.rect(), color); + } + QImage img = pm.toImage(); + img.setDevicePixelRatio(ratio); + if (size) + *size = QSize(px, px); + return img; +} + +void installThemeIcons(QQmlApplicationEngine *engine) { + adoptIconTheme(); + if (engine) + engine->addImageProvider(QStringLiteral("csttheme"), new ThemeIconProvider); +} + +QString iconThemeName() { return QIcon::themeName(); } + +bool themeIconAvailable(const QString &name) { + return QIcon::hasThemeIcon(name); +} + +} // namespace app diff --git a/app/src/ui/ThemeIcons.h b/app/src/ui/ThemeIcons.h new file mode 100644 index 0000000..7c2bfe6 --- /dev/null +++ b/app/src/ui/ThemeIcons.h @@ -0,0 +1,47 @@ +/* Icons from the desktop's icon theme (Breeze on KDE). + * + * The window did its icons with unicode glyphs (a clip emoji for attach, a + * gear, a hamburger): they render in whatever font happens to cover them -- + * colour emoji in a grey toolbar -- and on a machine without that glyph they + * are a box. Every other desktop app asks the icon theme instead, and Qt + * Quick has no image provider for it without Kirigami, so this is one: + * + * image://csttheme/?color=%23rrggbb + * + * The icon is looked up with QIcon::fromTheme() in the desktop's theme + * (breeze-dark on a KDE desktop, Breeze as fallback), tinted to the asked-for + * color and rendered at the requested size. An unknown name yields a null + * image, which QML sees as Image.Error -- that is the hook for a glyph + * fallback, used on a machine with no icon theme at all. */ + +#ifndef CST_APP_THEMEICONS_H +#define CST_APP_THEMEICONS_H + +#include +#include +#include + +class QQmlApplicationEngine; + +namespace app { + +class ThemeIconProvider : public QQuickImageProvider { + public: + ThemeIconProvider(); + QImage requestImage(const QString &id, QSize *size, + const QSize &requestedSize) override; +}; + +/* Points Qt at the desktop's icon theme and registers image://csttheme/. + * Call it on every engine that loads the window (main.cpp and --uitest). */ +void installThemeIcons(QQmlApplicationEngine *engine); + +/* The theme Qt was left on, for --selftest and the README. */ +QString iconThemeName(); + +/* Whether the theme has this icon (what the fallback glyphs are for). */ +bool themeIconAvailable(const QString &name); + +} // namespace app + +#endif /* CST_APP_THEMEICONS_H */ diff --git a/app/src/ui/UiTest.cpp b/app/src/ui/UiTest.cpp index 900c314..820016e 100644 --- a/app/src/ui/UiTest.cpp +++ b/app/src/ui/UiTest.cpp @@ -27,6 +27,7 @@ #include #include "cst_agent.h" +#include "ui/ThemeIcons.h" namespace app { namespace { @@ -173,6 +174,7 @@ int runUiTest(AgentCore &core, const QString &size) { } QQmlApplicationEngine engine; + installThemeIcons(&engine); /* the window's icons, same as main() */ engine.rootContext()->setContextProperty(QStringLiteral("agent"), &core); engine.rootContext()->setContextProperty(QStringLiteral("initialWidth"), width); engine.rootContext()->setContextProperty(QStringLiteral("initialHeight"), height); diff --git a/include/cst_version.h b/include/cst_version.h index 556a768..54077fc 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 8 -#define CST_VERSION_PATCH 13 +#define CST_VERSION_PATCH 14 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x)