Merge branch 'main' into feat/task-runner-ci
C++ / fmt (pull_request) Successful in 6s
JS/TS / fmt & lint (pull_request) Successful in 18s
Python / fmt & lint (pull_request) Successful in 34s
C++ / build & lint (pull_request) Failing after 2m29s
Python / test & nuitka build (pull_request) Successful in 2m5s
Rust / build, clippy & fmt (pull_request) Failing after 4m46s
C++ / fmt (pull_request) Successful in 6s
JS/TS / fmt & lint (pull_request) Successful in 18s
Python / fmt & lint (pull_request) Successful in 34s
C++ / build & lint (pull_request) Failing after 2m29s
Python / test & nuitka build (pull_request) Successful in 2m5s
Rust / build, clippy & fmt (pull_request) Failing after 4m46s
This commit is contained in:
@@ -12,7 +12,7 @@ TextField {
|
|||||||
implicitHeight: contentHeight + topPadding + bottomPadding
|
implicitHeight: contentHeight + topPadding + bottomPadding
|
||||||
implicitWidth: contentWidth + leftPadding + rightPadding
|
implicitWidth: contentWidth + leftPadding + rightPadding
|
||||||
placeholderTextColor: Colors.palette.m3onSurfaceVariant // No anim cause placeholder is custom
|
placeholderTextColor: Colors.palette.m3onSurfaceVariant // No anim cause placeholder is custom
|
||||||
renderType: echoMode === TextField.Password ? TextField.QtRendering : TextField.QtRendering
|
renderType: TextField.QtRendering
|
||||||
selectedTextColor: color
|
selectedTextColor: color
|
||||||
selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4)
|
selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4)
|
||||||
verticalAlignment: TextInput.AlignVCenter
|
verticalAlignment: TextInput.AlignVCenter
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
|
import ZShell.Services
|
||||||
import ZShell.Components
|
import ZShell.Components
|
||||||
import ZShell.Config
|
import ZShell.Config
|
||||||
import ZShell.Llm
|
import ZShell.Llm
|
||||||
@@ -90,7 +91,9 @@ Item {
|
|||||||
signal requestClose
|
signal requestClose
|
||||||
|
|
||||||
readonly property bool atNewestEnd: !listLoader.item || listLoader.item.contentY < 4
|
readonly property bool atNewestEnd: !listLoader.item || listLoader.item.contentY < 4
|
||||||
|
readonly property bool importing: importIds.length > 0
|
||||||
|
|
||||||
|
property var importIds: []
|
||||||
property var pendingAttachments: []
|
property var pendingAttachments: []
|
||||||
|
|
||||||
function attachFiles(): void {
|
function attachFiles(): void {
|
||||||
@@ -541,7 +544,7 @@ Item {
|
|||||||
bg.border.color: Colors.palette.m3outlineVariant
|
bg.border.color: Colors.palette.m3outlineVariant
|
||||||
bg.color: Colors.tPalette.m3surfaceContainerLowest
|
bg.color: Colors.tPalette.m3surfaceContainerLowest
|
||||||
fastGlow: LlmClient.busy
|
fastGlow: LlmClient.busy
|
||||||
glow.opacity: input.activeFocus && (!LlmClient.busy || ChatState.isWindow)
|
glow.opacity: !ChatState.narrowSidebarExpanded ? (input.activeFocus && (!LlmClient.busy || ChatState.isWindow) ? 1 : 0) : 0
|
||||||
font: Tokens.font.body.small
|
font: Tokens.font.body.small
|
||||||
implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding)
|
implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding)
|
||||||
focus: true
|
focus: true
|
||||||
@@ -578,4 +581,52 @@ Item {
|
|||||||
root.send(text);
|
root.send(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: DropImporter
|
||||||
|
|
||||||
|
function onImportFinished(id: int, paths: var, errors: var): void {
|
||||||
|
if (!root.importIds.includes(id))
|
||||||
|
return;
|
||||||
|
root.importIds = root.importIds.filter(i => i !== id);
|
||||||
|
|
||||||
|
if (paths.length > 0) {
|
||||||
|
const added = root.chatData.addAttachments(paths);
|
||||||
|
root.pendingAttachments = root.pendingAttachments.concat(added);
|
||||||
|
}
|
||||||
|
for (const e of errors)
|
||||||
|
console.warn("Drop import:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DropArea {
|
||||||
|
id: dropArea
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
onEntered: drag => {
|
||||||
|
drag.accepted = drag.hasUrls || drag.hasText || drag.hasHtml || drag.formats.some(f => f.startsWith("image/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
onDropped: drop => {
|
||||||
|
const formats = drop.formats;
|
||||||
|
const urls = drop.hasUrls ? drop.urls : [];
|
||||||
|
const text = drop.hasText ? drop.text : "";
|
||||||
|
const html = drop.hasHtml ? drop.html : "";
|
||||||
|
|
||||||
|
const imgFormat = formats.find(f => f.startsWith("image/"));
|
||||||
|
const raw = imgFormat ? drop.getDataAsArrayBuffer(imgFormat) : new ArrayBuffer(0);
|
||||||
|
|
||||||
|
switch (DropImporter.decide(urls, text, html, raw.byteLength > 0)) {
|
||||||
|
case DropImporter.Attach:
|
||||||
|
root.importIds = [...root.importIds, DropImporter.importAttachments(urls, html, raw)];
|
||||||
|
break;
|
||||||
|
case DropImporter.InsertText:
|
||||||
|
input.insert(input.cursorPosition, text || urls.map(u => u.toString()).join("\n"));
|
||||||
|
root.focusInput();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
drop.acceptProposedAction();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,50 @@ CustomRect {
|
|||||||
|
|
||||||
opacity: root.slim ? 0 : 1
|
opacity: root.slim ? 0 : 1
|
||||||
|
|
||||||
|
state: titleText.readOnly ? "static" : "editing"
|
||||||
|
|
||||||
|
states: [
|
||||||
|
State {
|
||||||
|
name: "static"
|
||||||
|
PropertyChanges {
|
||||||
|
staticText.opacity: 1
|
||||||
|
}
|
||||||
|
PropertyChanges {
|
||||||
|
titleText.opacity: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
State {
|
||||||
|
name: "editing"
|
||||||
|
PropertyChanges {
|
||||||
|
staticText.opacity: 0
|
||||||
|
}
|
||||||
|
PropertyChanges {
|
||||||
|
titleText.opacity: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
transitions: [
|
||||||
|
// Going static: titleText fades out (animated), staticText snaps in
|
||||||
|
Transition {
|
||||||
|
to: "static"
|
||||||
|
Anim {
|
||||||
|
target: titleText
|
||||||
|
property: "opacity"
|
||||||
|
type: Anim.SlowEffects
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Going editing: staticText fades out (animated), titleText snaps in
|
||||||
|
Transition {
|
||||||
|
to: "editing"
|
||||||
|
Anim {
|
||||||
|
target: staticText
|
||||||
|
property: "opacity"
|
||||||
|
type: Anim.SlowEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
Anim {
|
Anim {
|
||||||
type: Anim.DefaultEffects
|
type: Anim.DefaultEffects
|
||||||
@@ -106,7 +150,7 @@ CustomRect {
|
|||||||
color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 1)
|
color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 1)
|
||||||
implicitHeight: titleText.implicitHeight + Tokens.padding.extraSmall * 2
|
implicitHeight: titleText.implicitHeight + Tokens.padding.extraSmall * 2
|
||||||
radius: Tokens.rounding.full
|
radius: Tokens.rounding.full
|
||||||
implicitWidth: titleText.implicitWidth + Tokens.padding.large * 2
|
implicitWidth: titleText.width + Tokens.padding.large * 2
|
||||||
opacity: titleText.readOnly ? 0 : 1
|
opacity: titleText.readOnly ? 0 : 1
|
||||||
|
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
@@ -122,11 +166,6 @@ CustomRect {
|
|||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
implicitHeight: titleText.implicitHeight + Tokens.padding.small * 2
|
implicitHeight: titleText.implicitHeight + Tokens.padding.small * 2
|
||||||
|
|
||||||
layer.enabled: true
|
|
||||||
layer.effect: OpacityMask {
|
|
||||||
maskSource: fadeMask
|
|
||||||
}
|
|
||||||
|
|
||||||
TextFieldBase {
|
TextFieldBase {
|
||||||
id: titleText
|
id: titleText
|
||||||
|
|
||||||
@@ -134,10 +173,12 @@ CustomRect {
|
|||||||
anchors.leftMargin: readOnly ? 0 : Tokens.padding.large
|
anchors.leftMargin: readOnly ? 0 : Tokens.padding.large
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2
|
anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2
|
||||||
|
width: Math.min(implicitWidth, parent.width - Tokens.padding.large * 2)
|
||||||
font: Tokens.font.body.medium
|
font: Tokens.font.body.medium
|
||||||
text: root.modelData.title || qsTr("New chat")
|
text: root.modelData.title || qsTr("New chat")
|
||||||
readOnly: true
|
readOnly: true
|
||||||
color: Colors.palette.m3onSurface
|
color: Colors.palette.m3onSurface
|
||||||
|
opacity: 0
|
||||||
|
|
||||||
Behavior on anchors.leftMargin {
|
Behavior on anchors.leftMargin {
|
||||||
Anim {}
|
Anim {}
|
||||||
@@ -146,6 +187,11 @@ CustomRect {
|
|||||||
Anim {}
|
Anim {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelTitleEdit(): void {
|
||||||
|
text = root.modelData.title;
|
||||||
|
readOnly = true;
|
||||||
|
}
|
||||||
|
|
||||||
onEditingFinished: root.modelData.title = text
|
onEditingFinished: root.modelData.title = text
|
||||||
onReadOnlyChanged: {
|
onReadOnlyChanged: {
|
||||||
if (!readOnly) {
|
if (!readOnly) {
|
||||||
@@ -156,27 +202,24 @@ CustomRect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
CustomRect {
|
CustomText {
|
||||||
id: fadeMask
|
id: staticText
|
||||||
|
|
||||||
anchors.fill: titleWrapper
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: titleText.readOnly ? 0 : Tokens.padding.large
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: titleText.readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2
|
||||||
|
anchors.right: parent.right
|
||||||
|
font: titleText.font
|
||||||
|
text: titleText.text
|
||||||
|
elide: CustomText.ElideRight
|
||||||
|
|
||||||
layer.enabled: true
|
Behavior on anchors.leftMargin {
|
||||||
visible: false
|
Anim {}
|
||||||
|
|
||||||
gradient: Gradient {
|
|
||||||
orientation: Gradient.Horizontal
|
|
||||||
|
|
||||||
GradientStop {
|
|
||||||
color: Qt.rgba(1, 1, 1, 1.0)
|
|
||||||
position: 0.85
|
|
||||||
}
|
}
|
||||||
|
Behavior on anchors.topMargin {
|
||||||
GradientStop {
|
Anim {}
|
||||||
color: Qt.rgba(1, 1, 1, 0)
|
|
||||||
position: 1.0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,7 +366,13 @@ CustomRect {
|
|||||||
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
|
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
|
||||||
inactiveOnColor: Colors.palette.m3onSurfaceVariant
|
inactiveOnColor: Colors.palette.m3onSurfaceVariant
|
||||||
|
|
||||||
onClicked: root.remove(root.modelData)
|
onClicked: {
|
||||||
|
if (!titleText.readOnly) {
|
||||||
|
titleText.cancelTitleEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.remove(root.modelData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton {
|
IconButton {
|
||||||
@@ -366,7 +415,10 @@ CustomRect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
onClicked: root.expanded = !root.expanded
|
onClicked: {
|
||||||
|
root.expanded = !root.expanded;
|
||||||
|
titleText.readOnly = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialIcon {
|
MaterialIcon {
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ Item {
|
|||||||
label.transform: Rotation {
|
label.transform: Rotation {
|
||||||
origin.y: fabRoot.label.height / 2
|
origin.y: fabRoot.label.height / 2
|
||||||
origin.x: fabRoot.label.width / 2
|
origin.x: fabRoot.label.width / 2
|
||||||
angle: ChatState.fabExpanded && (ChatState.narrowSidebarExpanded && !ChatState.isWide || !ChatState.isWindow) ? 135 : 0
|
angle: ChatState.fabExpanded && (ChatState.narrowSidebarExpanded || !ChatState.isWide || !ChatState.isWindow) ? 135 : 0
|
||||||
|
|
||||||
Behavior on angle {
|
Behavior on angle {
|
||||||
Anim {}
|
Anim {}
|
||||||
@@ -148,8 +148,9 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
if (root.slim && !ChatState.narrowSidebarExpanded) {
|
if ((root.slim && !ChatState.narrowSidebarExpanded) || ChatState.shrinkSidebarOverride) {
|
||||||
root.requestExpand();
|
root.requestExpand();
|
||||||
|
ChatState.shrinkSidebarOverride = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +184,10 @@ Item {
|
|||||||
name: "pop out in window"
|
name: "pop out in window"
|
||||||
icon: "open_in_new"
|
icon: "open_in_new"
|
||||||
}
|
}
|
||||||
|
ListElement {
|
||||||
|
name: "shrink sidebar"
|
||||||
|
icon: "left_panel_close"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomRect {
|
CustomRect {
|
||||||
@@ -196,7 +201,18 @@ Item {
|
|||||||
|
|
||||||
radius: sLayer.pressed ? Tokens.rounding.small : implicitHeight / 2
|
radius: sLayer.pressed ? Tokens.rounding.small : implicitHeight / 2
|
||||||
color: Colors.palette.m3primaryContainer
|
color: Colors.palette.m3primaryContainer
|
||||||
visible: !(modelData.name === "pop out in window" && ChatState.isWindow)
|
visible: {
|
||||||
|
const n = modelData.name;
|
||||||
|
const w = ChatState.isWindow;
|
||||||
|
const wide = ChatState.isWide;
|
||||||
|
|
||||||
|
if (n === "pop out in window" && w)
|
||||||
|
return false;
|
||||||
|
if (n === "shrink sidebar" && (!w || !wide))
|
||||||
|
return false;
|
||||||
|
else
|
||||||
|
return true;
|
||||||
|
}
|
||||||
clip: true
|
clip: true
|
||||||
|
|
||||||
Behavior on radius {
|
Behavior on radius {
|
||||||
@@ -297,6 +313,8 @@ Item {
|
|||||||
root.newChatRequest();
|
root.newChatRequest();
|
||||||
else if (name === "pop out in window")
|
else if (name === "pop out in window")
|
||||||
Detach.create();
|
Detach.create();
|
||||||
|
else if (name === "shrink sidebar")
|
||||||
|
ChatState.shrinkSidebarOverride = true;
|
||||||
|
|
||||||
ChatState.fabExpanded = false;
|
ChatState.fabExpanded = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Singleton {
|
|||||||
property bool settledSlim: false
|
property bool settledSlim: false
|
||||||
property bool settledWide: false
|
property bool settledWide: false
|
||||||
property bool narrowSidebarExpanded: false
|
property bool narrowSidebarExpanded: false
|
||||||
property bool windowIsWide: false
|
property bool shrinkSidebarOverride: false
|
||||||
property ShellScreen screen
|
property ShellScreen screen
|
||||||
property bool sidebarVisible: ShellState.forActive().sidebar
|
property bool sidebarVisible: ShellState.forActive().sidebar
|
||||||
|
|
||||||
@@ -28,6 +28,9 @@ Singleton {
|
|||||||
root.currentIdx = -1;
|
root.currentIdx = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onIsWideChanged: if (isWide)
|
||||||
|
narrowSidebarExpanded = false
|
||||||
|
|
||||||
onSidebarVisibleChanged: if (!sidebarVisible)
|
onSidebarVisibleChanged: if (!sidebarVisible)
|
||||||
fabExpanded = false
|
fabExpanded = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,9 +220,8 @@ CustomRect {
|
|||||||
|
|
||||||
Layout.alignment: Qt.AlignVCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
color: root.codeHeaderColor
|
color: root.codeHeaderColor
|
||||||
font: Tokens.font.label.small
|
font: Tokens.font.label.builders.small.capitalization(Font.Capitalize).build()
|
||||||
text: CodeIcons.name(root.language)
|
text: CodeIcons.name(root.language)
|
||||||
visible: root.language.length > 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -21,12 +21,8 @@ Item {
|
|||||||
property bool chatOpen: ChatState.chatSession !== null
|
property bool chatOpen: ChatState.chatSession !== null
|
||||||
|
|
||||||
readonly property bool isWide: root.width >= root.breakpoint
|
readonly property bool isWide: root.width >= root.breakpoint
|
||||||
|
readonly property bool wideExpandedSidebar: isWide && !ChatState.shrinkSidebarOverride
|
||||||
readonly property bool showList: isWide || !chatOpen
|
readonly property bool narrowExpandedSidebar: !isWide && ChatState.narrowSidebarExpanded
|
||||||
readonly property bool showContent: !isWide && chatOpen
|
|
||||||
|
|
||||||
function openConversation(conv: ChatSession, index: int): void {
|
|
||||||
}
|
|
||||||
|
|
||||||
BlobGroup {
|
BlobGroup {
|
||||||
id: blobGroup
|
id: blobGroup
|
||||||
@@ -54,9 +50,9 @@ Item {
|
|||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
anchors.margins: Tokens.padding.medium
|
anchors.margins: Tokens.padding.medium
|
||||||
implicitWidth: root.isWide || ChatState.narrowSidebarExpanded ? Config.sidebar.sizes.width : root.iconSize
|
implicitWidth: root.wideExpandedSidebar || root.narrowExpandedSidebar ? Config.sidebar.sizes.width : root.iconSize
|
||||||
highlight: true
|
highlight: true
|
||||||
slim: !root.isWide && !ChatState.narrowSidebarExpanded
|
slim: !root.wideExpandedSidebar && !root.narrowExpandedSidebar
|
||||||
z: 1
|
z: 1
|
||||||
|
|
||||||
Behavior on implicitWidth {
|
Behavior on implicitWidth {
|
||||||
@@ -86,13 +82,13 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ClipRect {
|
Item {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.leftMargin: invertedRect.borderLeft
|
anchors.leftMargin: invertedRect.borderLeft
|
||||||
anchors.topMargin: invertedRect.borderTop
|
anchors.topMargin: invertedRect.borderTop
|
||||||
anchors.bottomMargin: invertedRect.borderBottom
|
anchors.bottomMargin: invertedRect.borderBottom
|
||||||
anchors.rightMargin: invertedRect.borderRight
|
anchors.rightMargin: invertedRect.borderRight
|
||||||
radius: invertedRect.radius
|
clip: true
|
||||||
|
|
||||||
children: [contentArea, dim]
|
children: [contentArea, dim]
|
||||||
}
|
}
|
||||||
@@ -100,9 +96,10 @@ Item {
|
|||||||
CustomRect {
|
CustomRect {
|
||||||
id: dim
|
id: dim
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
radius: invertedRect.radius
|
||||||
|
|
||||||
color: Colors.palette.m3shadow
|
color: Colors.palette.m3shadow
|
||||||
opacity: ChatState.narrowSidebarExpanded && !root.isWide ? 0.3 : 0
|
opacity: root.narrowExpandedSidebar ? 0.3 : 0
|
||||||
z: 1
|
z: 1
|
||||||
|
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
@@ -155,7 +152,7 @@ Item {
|
|||||||
id: dimArea
|
id: dimArea
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
preventStealing: true
|
preventStealing: true
|
||||||
enabled: !root.isWide && ChatState.narrowSidebarExpanded
|
enabled: root.narrowExpandedSidebar
|
||||||
cursorShape: undefined
|
cursorShape: undefined
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
@@ -175,12 +172,6 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Binding {
|
|
||||||
target: ChatState
|
|
||||||
property: "windowIsWide"
|
|
||||||
value: root.showList
|
|
||||||
}
|
|
||||||
|
|
||||||
Binding {
|
Binding {
|
||||||
target: ChatState
|
target: ChatState
|
||||||
property: "isWide"
|
property: "isWide"
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ FontBuilder FontBuilder::letterSpacing(qreal spacing, bool absolute) {
|
|||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
FontBuilder FontBuilder::capitalisation(QFont::Capitalization cap) {
|
FontBuilder FontBuilder::capitalization(QFont::Capitalization cap) {
|
||||||
m_font.setCapitalization(cap);
|
m_font.setCapitalization(cap);
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class FontBuilder {
|
|||||||
[[nodiscard]] Q_INVOKABLE FontBuilder
|
[[nodiscard]] Q_INVOKABLE FontBuilder
|
||||||
letterSpacing(qreal spacing, bool absolute = true);
|
letterSpacing(qreal spacing, bool absolute = true);
|
||||||
[[nodiscard]] Q_INVOKABLE FontBuilder
|
[[nodiscard]] Q_INVOKABLE FontBuilder
|
||||||
capitalisation(QFont::Capitalization cap);
|
capitalization(QFont::Capitalization cap);
|
||||||
[[nodiscard]] Q_INVOKABLE FontBuilder vaxis(const QString& tag, float value);
|
[[nodiscard]] Q_INVOKABLE FontBuilder vaxis(const QString& tag, float value);
|
||||||
[[nodiscard]] Q_INVOKABLE FontBuilder vaxes(const QVariantMap& axes);
|
[[nodiscard]] Q_INVOKABLE FontBuilder vaxes(const QVariantMap& axes);
|
||||||
[[nodiscard]] Q_INVOKABLE QFont build() const;
|
[[nodiscard]] Q_INVOKABLE QFont build() const;
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ qml_module(ZShell-services
|
|||||||
networkusage.hpp networkusage.cpp
|
networkusage.hpp networkusage.cpp
|
||||||
lyriccandidate.hpp lyriccandidate.cpp
|
lyriccandidate.hpp lyriccandidate.cpp
|
||||||
lyrics.hpp lyrics.cpp
|
lyrics.hpp lyrics.cpp
|
||||||
|
dropimporter.hpp dropimporter.cpp
|
||||||
LIBRARIES
|
LIBRARIES
|
||||||
Qt6::Core
|
Qt6::Core
|
||||||
Qt6::Qml
|
Qt6::Qml
|
||||||
Qt6::Concurrent
|
Qt6::Concurrent
|
||||||
|
Qt6::Network
|
||||||
PkgConfig::Pipewire
|
PkgConfig::Pipewire
|
||||||
PkgConfig::Aubio
|
PkgConfig::Aubio
|
||||||
PkgConfig::Cava
|
PkgConfig::Cava
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
#include "dropimporter.hpp"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QMetaObject>
|
||||||
|
#include <QMimeDatabase>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr qint64 kMaxBytes = 100 * 1024 * 1024;
|
||||||
|
|
||||||
|
bool isHttp(const QUrl& u) {
|
||||||
|
return u.scheme() == QLatin1String("http") ||
|
||||||
|
u.scheme() == QLatin1String("https");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString firstImgSrc(const QString& html) {
|
||||||
|
static const QRegularExpression re(
|
||||||
|
R"(<img\b[^>]*?\bsrc\s*=\s*["']([^"']+)["'])",
|
||||||
|
QRegularExpression::CaseInsensitiveOption);
|
||||||
|
const auto m = re.match(html);
|
||||||
|
if (!m.hasMatch()) return {};
|
||||||
|
return m.captured(1).replace(QLatin1String("&"), QLatin1String("&"));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool looksLikeUrl(const QString& text) {
|
||||||
|
const QString t = text.trimmed();
|
||||||
|
if (t.isEmpty() || t.contains(QRegularExpression(QStringLiteral("\\s"))))
|
||||||
|
return false;
|
||||||
|
const QUrl u(t);
|
||||||
|
return u.isValid() && (isHttp(u) || u.isLocalFile());
|
||||||
|
}
|
||||||
|
|
||||||
|
QString sanitize(QString name) {
|
||||||
|
name = QFileInfo(name).fileName();
|
||||||
|
name.replace(
|
||||||
|
QRegularExpression(QStringLiteral("[\\\\/:*?\"<>|\\x00-\\x1f]")),
|
||||||
|
QStringLiteral("_"));
|
||||||
|
return name.left(120);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString fileNameFor(QNetworkReply* reply, const QUrl& url) {
|
||||||
|
static const QRegularExpression cd(
|
||||||
|
QStringLiteral("filename\\*?=(?:UTF-8'')?\"?([^\";]+)\"?"),
|
||||||
|
QRegularExpression::CaseInsensitiveOption);
|
||||||
|
const auto m =
|
||||||
|
cd.match(QString::fromUtf8(reply->rawHeader("Content-Disposition")));
|
||||||
|
QString name = m.hasMatch()
|
||||||
|
? QUrl::fromPercentEncoding(m.captured(1).toUtf8())
|
||||||
|
: url.fileName();
|
||||||
|
name = sanitize(name);
|
||||||
|
return name.isEmpty() ? QStringLiteral("download") : name;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
namespace ZShell::Services {
|
||||||
|
|
||||||
|
DropImporter::DropImporter(QObject* parent) : QObject(parent) {}
|
||||||
|
|
||||||
|
DropImporter::Decision DropImporter::decide(
|
||||||
|
const QList<QUrl>& urls,
|
||||||
|
const QString& text,
|
||||||
|
const QString& html,
|
||||||
|
bool hasRawImage) const {
|
||||||
|
for (const QUrl& u : urls)
|
||||||
|
if (u.isLocalFile()) return Attach;
|
||||||
|
|
||||||
|
const QString src = firstImgSrc(html);
|
||||||
|
if (hasRawImage ||
|
||||||
|
(!src.isEmpty() && (text.trimmed().isEmpty() || looksLikeUrl(text))))
|
||||||
|
return Attach;
|
||||||
|
|
||||||
|
QMimeDatabase db;
|
||||||
|
for (const QUrl& u : urls) {
|
||||||
|
if (!isHttp(u) || QFileInfo(u.path()).suffix().isEmpty()) continue;
|
||||||
|
const QMimeType mt = db.mimeTypeForUrl(u);
|
||||||
|
if (!mt.isDefault() && !mt.inherits(QStringLiteral("text/html")))
|
||||||
|
return Attach;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.isEmpty() || !urls.isEmpty()) return InsertText;
|
||||||
|
return Ignore;
|
||||||
|
}
|
||||||
|
|
||||||
|
int DropImporter::importAttachments(
|
||||||
|
const QList<QUrl>& urls, const QString& html, const QByteArray& rawImage) {
|
||||||
|
const int id = ++m_nextId;
|
||||||
|
m_requests[id].pending = 1;
|
||||||
|
|
||||||
|
if (!rawImage.isEmpty()) {
|
||||||
|
saveBytes(id, rawImage);
|
||||||
|
} else if (const QString src = firstImgSrc(html); !src.isEmpty()) {
|
||||||
|
if (src.startsWith(QLatin1String("data:"), Qt::CaseInsensitive)) {
|
||||||
|
const int comma = src.indexOf(QLatin1Char(','));
|
||||||
|
if (comma < 0) {
|
||||||
|
failWith(id, tr("Malformed data URI"));
|
||||||
|
} else {
|
||||||
|
const bool b64 = src.left(comma).contains(
|
||||||
|
QLatin1String(";base64"), Qt::CaseInsensitive);
|
||||||
|
const QString payload = src.mid(comma + 1);
|
||||||
|
saveBytes(
|
||||||
|
id,
|
||||||
|
b64 ? QByteArray::fromBase64(payload.toLatin1())
|
||||||
|
: QByteArray::fromPercentEncoding(payload.toUtf8()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const QUrl u(src);
|
||||||
|
if (u.isLocalFile())
|
||||||
|
addLocal(id, u);
|
||||||
|
else if (isHttp(u))
|
||||||
|
download(id, u);
|
||||||
|
else
|
||||||
|
failWith(id, tr("Unsupported image source"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const QUrl& u : urls) {
|
||||||
|
if (u.isLocalFile())
|
||||||
|
addLocal(id, u);
|
||||||
|
else if (isHttp(u))
|
||||||
|
download(id, u);
|
||||||
|
else
|
||||||
|
failWith(id, tr("Unsupported URL: %1").arg(u.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this, [this, id] { finishUnit(id); }, Qt::QueuedConnection);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DropImporter::addLocal(int id, const QUrl& url) {
|
||||||
|
const QFileInfo fi(url.toLocalFile());
|
||||||
|
if (!fi.exists())
|
||||||
|
failWith(id, tr("File not found: %1").arg(fi.filePath()));
|
||||||
|
else if (fi.isDir())
|
||||||
|
failWith(id, tr("Folders can't be attached: %1").arg(fi.fileName()));
|
||||||
|
else if (fi.size() > kMaxBytes)
|
||||||
|
failWith(id, tr("File too large: %1").arg(fi.fileName()));
|
||||||
|
else
|
||||||
|
succeed(id, fi.absoluteFilePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
void DropImporter::saveBytes(int id, const QByteArray& bytes) {
|
||||||
|
if (bytes.isEmpty() || bytes.size() > kMaxBytes) {
|
||||||
|
failWith(id, tr("Empty or oversized data"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString dir = newSubdir();
|
||||||
|
if (dir.isEmpty()) {
|
||||||
|
failWith(id, tr("No temp directory"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString suffix =
|
||||||
|
QMimeDatabase().mimeTypeForData(bytes).preferredSuffix();
|
||||||
|
const QString path =
|
||||||
|
dir + QStringLiteral("/dropped") +
|
||||||
|
(suffix.isEmpty() ? QString() : QLatin1Char('.') + suffix);
|
||||||
|
|
||||||
|
QFile f(path);
|
||||||
|
if (!f.open(QIODevice::WriteOnly) || f.write(bytes) != bytes.size()) {
|
||||||
|
failWith(id, tr("Could not write temp file"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
f.close();
|
||||||
|
succeed(id, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DropImporter::download(int id, const QUrl& url) {
|
||||||
|
const QString dir = newSubdir();
|
||||||
|
if (dir.isEmpty()) {
|
||||||
|
failWith(id, tr("No temp directory"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
++m_requests[id].pending;
|
||||||
|
|
||||||
|
QNetworkRequest req(url);
|
||||||
|
req.setAttribute(
|
||||||
|
QNetworkRequest::RedirectPolicyAttribute,
|
||||||
|
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||||
|
QNetworkReply* reply = m_nam.get(req);
|
||||||
|
auto* file = new QFile(dir + QStringLiteral("/download.part"), reply);
|
||||||
|
|
||||||
|
connect(reply, &QNetworkReply::readyRead, this, [reply, file] {
|
||||||
|
if (!file->isOpen() && !file->open(QIODevice::WriteOnly)) {
|
||||||
|
reply->abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
file->write(reply->readAll());
|
||||||
|
if (file->size() > kMaxBytes) reply->abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(
|
||||||
|
reply,
|
||||||
|
&QNetworkReply::finished,
|
||||||
|
this,
|
||||||
|
[this, reply, file, id, dir, url] {
|
||||||
|
reply->deleteLater();
|
||||||
|
const bool tooBig = file->size() > kMaxBytes;
|
||||||
|
const bool wrote = file->isOpen();
|
||||||
|
file->close();
|
||||||
|
|
||||||
|
if (tooBig) {
|
||||||
|
failWith(id, tr("Download too large: %1").arg(url.toString()));
|
||||||
|
} else if (reply->error() != QNetworkReply::NoError) {
|
||||||
|
failWith(
|
||||||
|
id,
|
||||||
|
tr("Download failed (%1): %2")
|
||||||
|
.arg(url.toString(), reply->errorString()));
|
||||||
|
} else if (!wrote) {
|
||||||
|
failWith(id, tr("Empty response: %1").arg(url.toString()));
|
||||||
|
} else {
|
||||||
|
const QString name = fileNameFor(reply, url);
|
||||||
|
QString finalPath = dir + QLatin1Char('/') + name;
|
||||||
|
if (QFile::rename(file->fileName(), finalPath)) {
|
||||||
|
if (QFileInfo(name).suffix().isEmpty()) {
|
||||||
|
const QString sfx =
|
||||||
|
QMimeDatabase()
|
||||||
|
.mimeTypeForFile(
|
||||||
|
finalPath, QMimeDatabase::MatchContent)
|
||||||
|
.preferredSuffix();
|
||||||
|
if (!sfx.isEmpty() &&
|
||||||
|
QFile::rename(
|
||||||
|
finalPath, finalPath + QLatin1Char('.') + sfx))
|
||||||
|
finalPath += QLatin1Char('.') + sfx;
|
||||||
|
}
|
||||||
|
succeed(id, finalPath);
|
||||||
|
} else {
|
||||||
|
failWith(id, tr("Could not store download"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finishUnit(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void DropImporter::succeed(int id, const QString& path) {
|
||||||
|
if (auto it = m_requests.find(id); it != m_requests.end())
|
||||||
|
it->files.append(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DropImporter::failWith(int id, const QString& message) {
|
||||||
|
if (auto it = m_requests.find(id); it != m_requests.end())
|
||||||
|
it->errors.append(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DropImporter::finishUnit(int id) {
|
||||||
|
auto it = m_requests.find(id);
|
||||||
|
if (it == m_requests.end() || --it->pending > 0) return;
|
||||||
|
const Request r = *it;
|
||||||
|
m_requests.erase(it);
|
||||||
|
emit importFinished(id, r.files, r.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString DropImporter::newSubdir() {
|
||||||
|
if (!m_tempDir.isValid()) return {};
|
||||||
|
const QString p = m_tempDir.filePath(QString::number(++m_counter));
|
||||||
|
return QDir().mkpath(p) ? p : QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ZShell::Services
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <QHash>
|
||||||
|
|
||||||
|
#include <QList>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QQmlEngine>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
namespace ZShell::Services {
|
||||||
|
|
||||||
|
class DropImporter : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_SINGLETON
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum Decision { Ignore, InsertText, Attach };
|
||||||
|
Q_ENUM(Decision)
|
||||||
|
|
||||||
|
explicit DropImporter(QObject* parent = nullptr);
|
||||||
|
|
||||||
|
Q_INVOKABLE Decision decide(
|
||||||
|
const QList<QUrl>& urls,
|
||||||
|
const QString& text,
|
||||||
|
const QString& html,
|
||||||
|
bool hasRawImage) const;
|
||||||
|
|
||||||
|
Q_INVOKABLE int importAttachments(
|
||||||
|
const QList<QUrl>& urls,
|
||||||
|
const QString& html,
|
||||||
|
const QByteArray& rawImage);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void importFinished(
|
||||||
|
int id, const QStringList& paths, const QStringList& errors);
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Request {
|
||||||
|
int pending = 0;
|
||||||
|
QStringList files;
|
||||||
|
QStringList errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
void addLocal(int id, const QUrl& url);
|
||||||
|
void download(int id, const QUrl& url);
|
||||||
|
void saveBytes(int id, const QByteArray& bytes);
|
||||||
|
void succeed(int id, const QString& path);
|
||||||
|
void failWith(int id, const QString& message);
|
||||||
|
void finishUnit(int id);
|
||||||
|
QString newSubdir();
|
||||||
|
|
||||||
|
QNetworkAccessManager m_nam;
|
||||||
|
QTemporaryDir m_tempDir;
|
||||||
|
QHash<int, Request> m_requests;
|
||||||
|
int m_nextId = 0;
|
||||||
|
int m_counter = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ZShell::Services
|
||||||
Reference in New Issue
Block a user