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:
WHC
2026-10-11 12:53:49 +08:00
parent 8694afca94
commit 3fd039cb51
19 changed files with 392 additions and 64 deletions
+13 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4 -4
View File
@@ -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
+8 -8
View File
@@ -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
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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("@@"))
+50
View File
@@ -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
}
}
+4 -3
View File
@@ -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…"
+4 -3
View File
@@ -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
View File
@@ -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