v0.8.14: icons from the desktop's theme (Breeze), readable text, composer frame
The window drew its icons with unicode glyphs -- a clip emoji for attach, a
gear, a hamburger, a chevron, a multiplication sign for delete. Those render
in whichever font happens to cover them (colour emoji in a grey toolbar) and
are a box on a machine without that glyph. They now come from the icon theme,
the way every other desktop app gets them.
Icons
- app/src/ui/ThemeIcons.{h,cpp}: a QQuickImageProvider for
image://csttheme/<name>?color=%23rrggbb -- QIcon::fromTheme() in the
desktop's theme (whatever the platform theme set, else breeze-dark, breeze,
hicolor), tinted with QPainter's SourceIn and rasterised at the requested
size and the screen's device pixel ratio. Qt only builds its theme search
paths from XDG once a theme name is set, so the provider fills them itself
(GenericDataLocation/icons, ~/.icons and, on NixOS,
/run/current-system/sw/share/icons, for a shell started outside the desktop
session). An unknown name yields a null image, which QML reports as
Image.Error.
- app/qml/ThemeIcon.qml: the one component that asks for an icon. Every use
still names the old character as `glyph:`, so a host with no icon theme
degrades to exactly what the window looked like before instead of going
blank. installThemeIcons() is called by main() and by --uitest, so the test
loads the same icons the app does.
- Replaced: nav (application-menu), settings (settings-configure), attach
(mail-attachment), copy (edit-copy), session delete (edit-delete), new
session (list-add), expand/collapse (arrow-right/arrow-down), jump to the end
(go-down), the plan checklist (task-complete/box). A tool card also shows
what kind of tool ran -- Theme.toolIcon(): pencil for patch/write, text page
for read, folder for list/glob, magnifier for grep, braces for a shell,
globe for web, list for a plan. Toolbar icons are 22 px (Breeze's toolbar
size), card and row icons 14-16 px.
Colours (the "some text is unreadable" part)
- textFaint #6d757b -> #8d959c: ~3.4:1 -> ~5:1 on the card fill. It carries
hints, provider URLs, session metadata and placeholders, so it is text, not
decoration.
- A selected provider row and the open session row write on the accent fill;
they now use accentText / accentTextDim / okOnAccent instead of faint grey
and green on blue (named "accentText", not "onAccent": QML reads any `onFoo`
property as a signal handler and refuses the assignment).
- The filled buttons (Send, Use this) take their ink from Theme.accentInk.
Composer
- The frame is one Theme.pad around its column on every side (anchors.fill,
implicitHeight + 2*pad). 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.
app/README.md gains an "Icons, colors" section (the provider, why the theme
search paths are filled by hand, the qtsvg requirement for rasterising Breeze,
the tool map, and the contrast rules).
Verified: --uitest 52/52, --selftest OK, and the 0.8.14 window relaunched in
the Plasma session (no QML warnings).
Version 0.8.14.
mode = coding
This commit is contained in:
+3
-1
@@ -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()
|
||||
|
||||
+35
-1
@@ -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/<name>?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)
|
||||
|
||||
+13
-1
@@ -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()
|
||||
|
||||
+12
-14
@@ -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
|
||||
|
||||
+8
-10
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+19
-8
@@ -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
|
||||
|
||||
+39
-1
@@ -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("@@"))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-4
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
#include "ui/ThemeIcons.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QGuiApplication>
|
||||
#include <QIcon>
|
||||
#include <QPainter>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QScreen>
|
||||
#include <QStandardPaths>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
|
||||
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<qreal>(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
|
||||
@@ -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/<icon-name>?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 <QImage>
|
||||
#include <QQuickImageProvider>
|
||||
#include <QString>
|
||||
|
||||
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 */
|
||||
@@ -27,6 +27,7 @@
|
||||
#include <cstring>
|
||||
|
||||
#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);
|
||||
|
||||
Reference in New Issue
Block a user