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
247 lines
9.2 KiB
QML
247 lines
9.2 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Dialogs
|
|
import QtQuick.Layouts
|
|
|
|
// The input line: multiline, Enter sends, the button changes with the turn
|
|
// 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 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 {
|
|
id: root
|
|
objectName: "composer"
|
|
property bool running: false
|
|
property bool compact: false
|
|
property string note: "" // a failure, or what was just attached
|
|
property var attachments: [] // AgentCore::pending, "name (mime, 12 KB)"
|
|
property alias text: input.text
|
|
signal submitted(string text)
|
|
|
|
function submit() {
|
|
const t = input.text.trim()
|
|
if (!t.length || running)
|
|
return
|
|
input.text = ""
|
|
submitted(t)
|
|
}
|
|
|
|
/* A file: from the picker, or a URL dropped on the window. */
|
|
function localPath(url) {
|
|
let s = String(url)
|
|
if (s.startsWith("file://"))
|
|
s = s.substring(7)
|
|
/* Windows keeps its drive letter after the slash. */
|
|
if (/^\/[A-Za-z]:/.test(s))
|
|
s = s.substring(1)
|
|
return decodeURIComponent(s)
|
|
}
|
|
|
|
function attachPath(path) {
|
|
const r = agent.attachFile(path)
|
|
note = r.length ? r : ""
|
|
}
|
|
|
|
radius: Theme.radius
|
|
color: Theme.surface
|
|
border.width: 1
|
|
border.color: input.activeFocus ? Theme.accentDim : Theme.border
|
|
/* 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 {
|
|
fill: parent
|
|
margins: Theme.pad
|
|
}
|
|
spacing: 6
|
|
|
|
// What the next prompt will carry, in the engine's own words.
|
|
Flow {
|
|
Layout.fillWidth: true
|
|
visible: root.attachments.length > 0
|
|
spacing: 6
|
|
|
|
Repeater {
|
|
model: root.attachments
|
|
delegate: Rectangle {
|
|
id: chip
|
|
required property var modelData
|
|
required property int index
|
|
|
|
objectName: "attachChip" + chip.index
|
|
implicitWidth: chipText.implicitWidth + 14
|
|
implicitHeight: 24
|
|
radius: Theme.radiusSm
|
|
color: Theme.surfaceAlt
|
|
border.width: 1
|
|
border.color: Theme.border
|
|
Text {
|
|
id: chipText
|
|
anchors.centerIn: parent
|
|
text: String(chip.modelData)
|
|
color: Theme.textDim
|
|
font.pixelSize: Theme.smallSize
|
|
}
|
|
}
|
|
}
|
|
|
|
Button {
|
|
id: clearAtt
|
|
objectName: "clearAttachments"
|
|
text: "clear all"
|
|
implicitHeight: 24
|
|
onClicked: {
|
|
agent.clearPending()
|
|
root.note = ""
|
|
}
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: clearAtt.hovered ? Theme.surfaceHi : "transparent"
|
|
border.width: 1
|
|
border.color: Theme.border
|
|
}
|
|
contentItem: Text {
|
|
text: clearAtt.text
|
|
color: clearAtt.hovered ? Theme.text : Theme.textFaint
|
|
font.pixelSize: Theme.smallSize
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
visible: root.note.length > 0
|
|
text: root.note
|
|
color: Theme.warn
|
|
font.pixelSize: Theme.smallSize
|
|
wrapMode: Text.Wrap
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Theme.gap
|
|
|
|
TextArea {
|
|
id: input
|
|
objectName: "composerInput"
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: Math.min(root.compact ? 120 : 170,
|
|
Math.max(56, contentHeight + 12))
|
|
placeholderText: root.running
|
|
? "the turn is running — Esc stops it"
|
|
: (root.compact
|
|
? "Ask for something…"
|
|
: "Ask for something (Enter sends, Shift+Enter for a new line)")
|
|
placeholderTextColor: Theme.textFaint
|
|
color: Theme.text
|
|
font.pixelSize: Theme.bodySize
|
|
wrapMode: TextArea.Wrap
|
|
selectByMouse: true
|
|
selectionColor: Theme.accentDim
|
|
inputMethodHints: Qt.ImhMultiLine | Qt.ImhNoPredictiveText |
|
|
Qt.ImhNoAutoUppercase
|
|
background: Rectangle { color: "transparent" }
|
|
enabled: !root.running
|
|
|
|
Keys.onPressed: function (ev) {
|
|
if (ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter) {
|
|
if (!(ev.modifiers & Qt.ShiftModifier)) {
|
|
root.submit()
|
|
ev.accepted = true
|
|
}
|
|
return
|
|
}
|
|
/* An image has nothing the editor could paste, so Ctrl+V
|
|
* becomes an attachment there; text stays with the
|
|
* editor's own paste. */
|
|
if ((ev.modifiers & Qt.ControlModifier) && ev.key === Qt.Key_V &&
|
|
agent.clipboardHasImage()) {
|
|
root.note = agent.attachClipboard()
|
|
ev.accepted = true
|
|
}
|
|
}
|
|
}
|
|
|
|
ToolButton {
|
|
id: attach
|
|
objectName: "attachButton"
|
|
Layout.alignment: Qt.AlignBottom
|
|
implicitWidth: root.compact ? Theme.touchTarget : 40
|
|
implicitHeight: Theme.touchTarget + 4
|
|
enabled: !root.running
|
|
ToolTip.visible: hovered
|
|
ToolTip.text: "Attach a file — or drop one on the window, or paste an image"
|
|
contentItem: ThemeIcon {
|
|
name: "mail-attachment"
|
|
glyph: "📎"
|
|
size: 20
|
|
color: attach.enabled ? (attach.hovered ? Theme.text : Theme.textDim)
|
|
: Theme.textFaint
|
|
}
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: attach.hovered && attach.enabled ? Theme.surfaceHi
|
|
: "transparent"
|
|
}
|
|
onClicked: picker.open()
|
|
}
|
|
|
|
Button {
|
|
id: action
|
|
objectName: "sendButton"
|
|
Layout.alignment: Qt.AlignBottom
|
|
implicitWidth: root.compact ? 68 : 88
|
|
implicitHeight: Theme.touchTarget + 4
|
|
text: root.running ? "Stop" : "Send"
|
|
enabled: root.running || input.text.trim().length > 0
|
|
onClicked: root.running ? agent.cancel() : root.submit()
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: !action.enabled ? Theme.surfaceAlt
|
|
: root.running ? (action.hovered ? "#e05666" : Theme.err)
|
|
: (action.hovered ? "#5cc0f0" : Theme.accent)
|
|
}
|
|
contentItem: Text {
|
|
text: action.text
|
|
color: action.enabled ? Theme.accentInk : Theme.textFaint
|
|
font.pixelSize: Theme.bodySize
|
|
font.bold: true
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
}
|
|
|
|
// On a narrow window the hint would wrap into the editor's space.
|
|
Text {
|
|
Layout.fillWidth: true
|
|
visible: !root.compact
|
|
text: {
|
|
if (root.running)
|
|
return "working — Esc cancels the turn"
|
|
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
|
|
}
|
|
color: Theme.textFaint
|
|
font.pixelSize: Theme.smallSize
|
|
}
|
|
}
|
|
|
|
FileDialog {
|
|
id: picker
|
|
title: "Attach a file"
|
|
fileMode: FileDialog.OpenFile
|
|
onAccepted: root.attachPath(root.localPath(selectedFile))
|
|
}
|
|
}
|