Initial commit
This commit is contained in:
commit
8d1a045333
333 changed files with 41117 additions and 0 deletions
821
ii/modules/sidebarLeft/AiChat.qml
Normal file
821
ii/modules/sidebarLeft/AiChat.qml
Normal file
|
|
@ -0,0 +1,821 @@
|
|||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.modules.common.functions
|
||||
import "./aiChat/"
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Qt5Compat.GraphicalEffects
|
||||
import QtQuick.Dialogs
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Hyprland
|
||||
|
||||
Item {
|
||||
id: root
|
||||
function openAttachDialog() {
|
||||
// Prefer a native floating dialog on Hyprland for proper centering
|
||||
if (Hyprland.focusedMonitor) {
|
||||
// Trigger zenity file picker, then center the focused window shortly after
|
||||
centerTimer.restart();
|
||||
externalFilePicker.running = true;
|
||||
} else {
|
||||
attachFileDialog.open();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: externalFilePicker
|
||||
command: ["bash", "-lc", "zenity --file-selection --title='Select a file to attach' || true"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const path = text.trim();
|
||||
if (path.length > 0) Ai.attachFile(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: centerTimer
|
||||
interval: 120
|
||||
repeat: false
|
||||
onTriggered: centerFocused.running = true
|
||||
}
|
||||
|
||||
Process {
|
||||
id: centerFocused
|
||||
command: ["bash", "-lc", "hyprctl dispatch focuswindow title:'Select a file to attach'; if hyprctl activewindow -j | grep -q '\"title\": \"Select a file to attach\"'; then hyprctl dispatch setfloating active; hyprctl dispatch centerwindow; fi"]
|
||||
}
|
||||
FileDialog {
|
||||
id: attachFileDialog
|
||||
title: Translation.tr("Select a file to attach")
|
||||
onAccepted: {
|
||||
if (attachFileDialog.selectedFile && attachFileDialog.selectedFile.toString().length > 0) {
|
||||
Ai.attachFile(attachFileDialog.selectedFile.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
property var inputField: messageInputField
|
||||
property string commandPrefix: "/"
|
||||
|
||||
property var suggestionQuery: ""
|
||||
property var suggestionList: []
|
||||
|
||||
onFocusChanged: (focus) => {
|
||||
if (focus) {
|
||||
root.inputField.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onPressed: (event) => {
|
||||
messageInputField.forceActiveFocus()
|
||||
if (event.modifiers === Qt.NoModifier) {
|
||||
if (event.key === Qt.Key_PageUp) {
|
||||
messageListView.contentY = Math.max(0, messageListView.contentY - messageListView.height / 2)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_PageDown) {
|
||||
messageListView.contentY = Math.min(messageListView.contentHeight - messageListView.height / 2, messageListView.contentY + messageListView.height / 2)
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
property var allCommands: [
|
||||
{
|
||||
name: "attach",
|
||||
description: Translation.tr("Attach a file. Only works with Gemini."),
|
||||
execute: (args) => {
|
||||
Ai.attachFile(args.join(" ").trim());
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "model",
|
||||
description: Translation.tr("Choose model"),
|
||||
execute: (args) => {
|
||||
Ai.setModel(args[0]);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "tool",
|
||||
description: Translation.tr("Set the tool to use for the model."),
|
||||
execute: (args) => {
|
||||
// console.log(args)
|
||||
if (args.length == 0 || args[0] == "get") {
|
||||
Ai.addMessage(Translation.tr("Usage: %1tool TOOL_NAME").arg(root.commandPrefix), Ai.interfaceRole);
|
||||
} else {
|
||||
const tool = args[0];
|
||||
const switched = Ai.setTool(tool);
|
||||
if (switched) {
|
||||
Ai.addMessage(Translation.tr("Tool set to: %1").arg(tool), Ai.interfaceRole);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "prompt",
|
||||
description: Translation.tr("Set the system prompt for the model."),
|
||||
execute: (args) => {
|
||||
if (args.length === 0 || args[0] === "get") {
|
||||
Ai.printPrompt();
|
||||
return;
|
||||
}
|
||||
Ai.loadPrompt(args.join(" ").trim());
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "key",
|
||||
description: Translation.tr("Set API key"),
|
||||
execute: (args) => {
|
||||
if (args[0] == "get") {
|
||||
Ai.printApiKey()
|
||||
} else {
|
||||
Ai.setApiKey(args[0]);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "save",
|
||||
description: Translation.tr("Save chat"),
|
||||
execute: (args) => {
|
||||
const joinedArgs = args.join(" ")
|
||||
if (joinedArgs.trim().length == 0) {
|
||||
Ai.addMessage(Translation.tr("Usage: %1save CHAT_NAME").arg(root.commandPrefix), Ai.interfaceRole);
|
||||
return;
|
||||
}
|
||||
Ai.saveChat(joinedArgs)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "load",
|
||||
description: Translation.tr("Load chat"),
|
||||
execute: (args) => {
|
||||
const joinedArgs = args.join(" ")
|
||||
if (joinedArgs.trim().length == 0) {
|
||||
Ai.addMessage(Translation.tr("Usage: %1load CHAT_NAME").arg(root.commandPrefix), Ai.interfaceRole);
|
||||
return;
|
||||
}
|
||||
Ai.loadChat(joinedArgs)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
description: Translation.tr("Clear chat history"),
|
||||
execute: () => {
|
||||
Ai.clearMessages();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "temp",
|
||||
description: Translation.tr("Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5."),
|
||||
execute: (args) => {
|
||||
// console.log(args)
|
||||
if (args.length == 0 || args[0] == "get") {
|
||||
Ai.printTemperature()
|
||||
} else {
|
||||
const temp = parseFloat(args[0]);
|
||||
Ai.setTemperature(temp);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
description: Translation.tr("Markdown test"),
|
||||
execute: () => {
|
||||
Ai.addMessage(`
|
||||
<think>
|
||||
A longer think block to test revealing animation
|
||||
OwO wem ipsum dowo sit amet, consekituwet awipiscing ewit, sed do eiuwsmod tempow inwididunt ut wabowe et dowo mawa. Ut enim ad minim weniam, quis nostwud exeucitation uwuwamcow bowowis nisi ut awiquip ex ea commowo consequat. Duuis aute iwuwe dowo in wepwependewit in wowuptate velit esse ciwwum dowo eu fugiat nuwa pawiatuw. Excepteuw sint occaecat cupidatat non pwowoident, sunt in cuwpa qui officia desewunt mowit anim id est wabowum. Meouw! >w<
|
||||
Mowe uwu wem ipsum!
|
||||
</think>
|
||||
## ✏️ Markdown test
|
||||
### Formatting
|
||||
|
||||
- *Italic*, \`Monospace\`, **Bold**, [Link](https://example.com)
|
||||
- Arch lincox icon <img src="${Quickshell.shellPath("assets/icons/arch-symbolic.svg")}" height="${Appearance.font.pixelSize.small}"/>
|
||||
|
||||
### Table
|
||||
|
||||
Quickshell vs AGS/Astal
|
||||
|
||||
| | Quickshell | AGS/Astal |
|
||||
|--------------------------|------------------|-------------------|
|
||||
| UI Toolkit | Qt | Gtk3/Gtk4 |
|
||||
| Language | QML | Js/Ts/Lua |
|
||||
| Reactivity | Implied | Needs declaration |
|
||||
| Widget placement | Mildly difficult | More intuitive |
|
||||
| Bluetooth & Wifi support | ❌ | ✅ |
|
||||
| No-delay keybinds | ✅ | ❌ |
|
||||
| Development | New APIs | New syntax |
|
||||
|
||||
### Code block
|
||||
|
||||
Just a hello world...
|
||||
|
||||
\`\`\`cpp
|
||||
#include <bits/stdc++.h>
|
||||
// This is intentionally very long to test scrolling
|
||||
const std::string GREETING = \"UwU\";
|
||||
int main(int argc, char* argv[]) {
|
||||
std::cout << GREETING;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### LaTeX
|
||||
|
||||
|
||||
Inline w/ dollar signs: $\\frac{1}{2} = \\frac{2}{4}$
|
||||
|
||||
Inline w/ double dollar signs: $$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$
|
||||
|
||||
Inline w/ backslash and square brackets \\[\\int_0^\\infty \\frac{1}{x^2} dx = \\infty\\]
|
||||
|
||||
Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
|
||||
`,
|
||||
Ai.interfaceRole);
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
function handleInput(inputText) {
|
||||
if (inputText.startsWith(root.commandPrefix)) {
|
||||
// Handle special commands
|
||||
const command = inputText.split(" ")[0].substring(1);
|
||||
const args = inputText.split(" ").slice(1);
|
||||
const commandObj = root.allCommands.find(cmd => cmd.name === `${command}`);
|
||||
if (commandObj) {
|
||||
commandObj.execute(args);
|
||||
} else {
|
||||
Ai.addMessage(Translation.tr("Unknown command: ") + command, Ai.interfaceRole);
|
||||
}
|
||||
}
|
||||
else {
|
||||
Ai.sendUserMessage(inputText);
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: decodeImageAndAttachProc
|
||||
property string imageDecodePath: Directories.cliphistDecode
|
||||
property string imageDecodeFileName: "image"
|
||||
property string imageDecodeFilePath: `${imageDecodePath}/${imageDecodeFileName}`
|
||||
function handleEntry(entry: string) {
|
||||
imageDecodeFileName = parseInt(entry.match(/^(\d+)\t/)[1])
|
||||
decodeImageAndAttachProc.exec(["bash", "-c",
|
||||
`[ -f ${imageDecodeFilePath} ] || echo '${StringUtils.shellSingleQuoteEscape(entry)}' | ${Cliphist.cliphistBinary} decode > '${imageDecodeFilePath}'`
|
||||
])
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode === 0) {
|
||||
Ai.attachFile(imageDecodeFilePath);
|
||||
} else {
|
||||
console.error("[AiChat] Failed to decode image in clipboard content")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component StatusItem: MouseArea {
|
||||
id: statusItem
|
||||
property string icon
|
||||
property string statusText
|
||||
property string description
|
||||
hoverEnabled: true
|
||||
implicitHeight: statusItemRowLayout.implicitHeight
|
||||
implicitWidth: statusItemRowLayout.implicitWidth
|
||||
|
||||
RowLayout {
|
||||
id: statusItemRowLayout
|
||||
spacing: 0
|
||||
MaterialSymbol {
|
||||
text: statusItem.icon
|
||||
iconSize: Appearance.font.pixelSize.huge
|
||||
color: Appearance.colors.colSubtext
|
||||
}
|
||||
StyledText {
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
text: statusItem.statusText
|
||||
color: Appearance.colors.colSubtext
|
||||
animateChange: true
|
||||
}
|
||||
}
|
||||
|
||||
StyledToolTip {
|
||||
content: statusItem.description
|
||||
extraVisibleCondition: false
|
||||
alternativeVisibleCondition: statusItem.containsMouse
|
||||
}
|
||||
}
|
||||
|
||||
component StatusSeparator: Rectangle {
|
||||
implicitWidth: 4
|
||||
implicitHeight: 4
|
||||
radius: implicitWidth / 2
|
||||
color: Appearance.colors.colOutlineVariant
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: columnLayout
|
||||
anchors.fill: parent
|
||||
|
||||
RowLayout { // Status
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
spacing: 10
|
||||
|
||||
StatusItem {
|
||||
icon: Ai.currentModelHasApiKey ? "key" : "key_off"
|
||||
statusText: ""
|
||||
description: Ai.currentModelHasApiKey ? Translation.tr("API key is set\nChange with /key YOUR_API_KEY") : Translation.tr("No API key\nSet it with /key YOUR_API_KEY")
|
||||
}
|
||||
StatusSeparator {}
|
||||
StatusItem {
|
||||
icon: "device_thermostat"
|
||||
statusText: Ai.temperature.toFixed(1)
|
||||
description: Translation.tr("Temperature\nChange with /temp VALUE")
|
||||
}
|
||||
StatusSeparator {
|
||||
visible: Ai.tokenCount.total > 0
|
||||
}
|
||||
StatusItem {
|
||||
visible: Ai.tokenCount.total > 0
|
||||
icon: "token"
|
||||
statusText: Ai.tokenCount.total
|
||||
description: Translation.tr("Total token count\nInput: %1\nOutput: %2")
|
||||
.arg(Ai.tokenCount.input)
|
||||
.arg(Ai.tokenCount.output)
|
||||
}
|
||||
}
|
||||
|
||||
Item { // Messages
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
StyledListView { // Message list
|
||||
id: messageListView
|
||||
anchors.fill: parent
|
||||
spacing: 10
|
||||
popin: false
|
||||
|
||||
touchpadScrollFactor: Config.options.interactions.scrolling.touchpadScrollFactor * 1.4
|
||||
mouseScrollFactor: Config.options.interactions.scrolling.mouseScrollFactor * 1.4
|
||||
|
||||
property int lastResponseLength: 0
|
||||
|
||||
clip: true
|
||||
layer.enabled: true
|
||||
layer.effect: OpacityMask {
|
||||
maskSource: Rectangle {
|
||||
width: swipeView.width
|
||||
height: swipeView.height
|
||||
radius: Appearance.rounding.small
|
||||
}
|
||||
}
|
||||
|
||||
add: null // Prevent function calls from being janky
|
||||
|
||||
model: ScriptModel {
|
||||
values: Ai.messageIDs.filter(id => {
|
||||
const message = Ai.messageByID[id];
|
||||
return message?.visibleToUser ?? true;
|
||||
})
|
||||
}
|
||||
delegate: AiMessage {
|
||||
required property var modelData
|
||||
required property int index
|
||||
messageIndex: index
|
||||
messageData: {
|
||||
Ai.messageByID[modelData]
|
||||
}
|
||||
messageInputField: root.inputField
|
||||
}
|
||||
}
|
||||
|
||||
Item { // Placeholder when list is empty
|
||||
opacity: Ai.messageIDs.length === 0 ? 1 : 0
|
||||
visible: opacity > 0
|
||||
anchors.fill: parent
|
||||
|
||||
Behavior on opacity {
|
||||
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
spacing: 5
|
||||
|
||||
MaterialSymbol {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
iconSize: 60
|
||||
color: Appearance.m3colors.m3outline
|
||||
text: "neurology"
|
||||
}
|
||||
StyledText {
|
||||
id: widgetNameText
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
font.pixelSize: Appearance.font.pixelSize.larger
|
||||
font.family: Appearance.font.family.title
|
||||
color: Appearance.m3colors.m3outline
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: Translation.tr("Large language models")
|
||||
}
|
||||
StyledText {
|
||||
id: widgetDescriptionText
|
||||
Layout.fillWidth: true
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
color: Appearance.m3colors.m3outline
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
wrapMode: Text.Wrap
|
||||
text: Translation.tr("Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DescriptionBox {
|
||||
text: root.suggestionList[suggestions.selectedIndex]?.description ?? ""
|
||||
showArrows: root.suggestionList.length > 1
|
||||
}
|
||||
|
||||
FlowButtonGroup { // Suggestions
|
||||
id: suggestions
|
||||
visible: root.suggestionList.length > 0 && messageInputField.text.length > 0
|
||||
property int selectedIndex: 0
|
||||
Layout.fillWidth: true
|
||||
spacing: 5
|
||||
|
||||
Repeater {
|
||||
id: suggestionRepeater
|
||||
model: {
|
||||
suggestions.selectedIndex = 0
|
||||
return root.suggestionList.slice(0, 10)
|
||||
}
|
||||
delegate: ApiCommandButton {
|
||||
id: commandButton
|
||||
colBackground: suggestions.selectedIndex === index ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colSecondaryContainer
|
||||
bounce: false
|
||||
contentItem: StyledText {
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
color: Appearance.m3colors.m3onSurface
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: modelData.displayName ?? modelData.name
|
||||
}
|
||||
|
||||
onHoveredChanged: {
|
||||
if (commandButton.hovered) {
|
||||
suggestions.selectedIndex = index;
|
||||
}
|
||||
}
|
||||
onClicked: {
|
||||
suggestions.acceptSuggestion(modelData.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function acceptSuggestion(word) {
|
||||
const words = messageInputField.text.trim().split(/\s+/);
|
||||
if (words.length > 0) {
|
||||
words[words.length - 1] = word;
|
||||
} else {
|
||||
words.push(word);
|
||||
}
|
||||
const updatedText = words.join(" ") + " ";
|
||||
messageInputField.text = updatedText;
|
||||
messageInputField.cursorPosition = messageInputField.text.length;
|
||||
messageInputField.forceActiveFocus();
|
||||
}
|
||||
|
||||
function acceptSelectedWord() {
|
||||
if (suggestions.selectedIndex >= 0 && suggestions.selectedIndex < suggestionRepeater.count) {
|
||||
const word = root.suggestionList[suggestions.selectedIndex].name;
|
||||
suggestions.acceptSuggestion(word);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { // Input area
|
||||
id: inputWrapper
|
||||
property real spacing: 5
|
||||
Layout.fillWidth: true
|
||||
radius: Appearance.rounding.small
|
||||
color: Appearance.colors.colLayer1
|
||||
implicitHeight: Math.max(inputFieldRowLayout.implicitHeight + inputFieldRowLayout.anchors.topMargin
|
||||
+ commandButtonsRow.implicitHeight + commandButtonsRow.anchors.bottomMargin + spacing, 45)
|
||||
+ (attachedFileIndicator.implicitHeight + spacing + attachedFileIndicator.anchors.topMargin)
|
||||
clip: true
|
||||
border.color: Appearance.colors.colOutlineVariant
|
||||
border.width: 1
|
||||
|
||||
Behavior on implicitHeight {
|
||||
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
|
||||
}
|
||||
|
||||
AttachedFileIndicator {
|
||||
id: attachedFileIndicator
|
||||
anchors {
|
||||
top: parent.top
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
margins: visible ? 5 : 0
|
||||
}
|
||||
filePath: Ai.pendingFilePath
|
||||
onRemove: Ai.attachFile("")
|
||||
}
|
||||
|
||||
RowLayout { // Input field and send button
|
||||
id: inputFieldRowLayout
|
||||
anchors {
|
||||
top: attachedFileIndicator.bottom
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
topMargin: 5
|
||||
}
|
||||
spacing: 0
|
||||
|
||||
StyledTextArea { // The actual TextArea
|
||||
id: messageInputField
|
||||
wrapMode: TextArea.Wrap
|
||||
Layout.fillWidth: true
|
||||
padding: 10
|
||||
color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant
|
||||
placeholderText: Translation.tr('Message the model... "%1" for commands').arg(root.commandPrefix)
|
||||
|
||||
background: null
|
||||
|
||||
onTextChanged: { // Handle suggestions
|
||||
if (messageInputField.text.length === 0) {
|
||||
root.suggestionQuery = ""
|
||||
root.suggestionList = []
|
||||
return
|
||||
} else if (messageInputField.text.startsWith(`${root.commandPrefix}model`)) {
|
||||
root.suggestionQuery = messageInputField.text.split(" ")[1] ?? ""
|
||||
const modelResults = Fuzzy.go(root.suggestionQuery, Ai.modelList.map(model => {
|
||||
return {
|
||||
name: Fuzzy.prepare(model),
|
||||
obj: model,
|
||||
}
|
||||
}), {
|
||||
all: true,
|
||||
key: "name"
|
||||
})
|
||||
root.suggestionList = modelResults.map(model => {
|
||||
return {
|
||||
name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "model ") : ""}${model.target}`,
|
||||
displayName: `${Ai.models[model.target].name}`,
|
||||
description: `${Ai.models[model.target].description}`,
|
||||
}
|
||||
})
|
||||
} else if (messageInputField.text.startsWith(`${root.commandPrefix}prompt`)) {
|
||||
root.suggestionQuery = messageInputField.text.split(" ")[1] ?? ""
|
||||
const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.promptFiles.map(file => {
|
||||
return {
|
||||
name: Fuzzy.prepare(file),
|
||||
obj: file,
|
||||
}
|
||||
}), {
|
||||
all: true,
|
||||
key: "name"
|
||||
})
|
||||
root.suggestionList = promptFileResults.map(file => {
|
||||
return {
|
||||
name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "prompt ") : ""}${file.target}`,
|
||||
displayName: `${FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target))}`,
|
||||
description: Translation.tr("Load prompt from %1").arg(file.target),
|
||||
}
|
||||
})
|
||||
} else if (messageInputField.text.startsWith(`${root.commandPrefix}save`)) {
|
||||
root.suggestionQuery = messageInputField.text.split(" ")[1] ?? ""
|
||||
const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.savedChats.map(file => {
|
||||
return {
|
||||
name: Fuzzy.prepare(file),
|
||||
obj: file,
|
||||
}
|
||||
}), {
|
||||
all: true,
|
||||
key: "name"
|
||||
})
|
||||
root.suggestionList = promptFileResults.map(file => {
|
||||
const chatName = FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target)).trim()
|
||||
return {
|
||||
name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "save ") : ""}${chatName}`,
|
||||
displayName: `${chatName}`,
|
||||
description: Translation.tr("Save chat to %1").arg(chatName),
|
||||
}
|
||||
})
|
||||
} else if (messageInputField.text.startsWith(`${root.commandPrefix}load`)) {
|
||||
root.suggestionQuery = messageInputField.text.split(" ")[1] ?? ""
|
||||
const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.savedChats.map(file => {
|
||||
return {
|
||||
name: Fuzzy.prepare(file),
|
||||
obj: file,
|
||||
}
|
||||
}), {
|
||||
all: true,
|
||||
key: "name"
|
||||
})
|
||||
root.suggestionList = promptFileResults.map(file => {
|
||||
const chatName = FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target)).trim()
|
||||
return {
|
||||
name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "load ") : ""}${chatName}`,
|
||||
displayName: `${chatName}`,
|
||||
description: Translation.tr(`Load chat from %1`).arg(file.target),
|
||||
}
|
||||
})
|
||||
} else if (messageInputField.text.startsWith(`${root.commandPrefix}tool`)) {
|
||||
root.suggestionQuery = messageInputField.text.split(" ")[1] ?? ""
|
||||
const toolResults = Fuzzy.go(root.suggestionQuery, Ai.availableTools.map(tool => {
|
||||
return {
|
||||
name: Fuzzy.prepare(tool),
|
||||
obj: tool,
|
||||
}
|
||||
}), {
|
||||
all: true,
|
||||
key: "name"
|
||||
})
|
||||
root.suggestionList = toolResults.map(tool => {
|
||||
const toolName = tool.target
|
||||
return {
|
||||
name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "tool ") : ""}${tool.target}`,
|
||||
displayName: toolName,
|
||||
description: Ai.toolDescriptions[toolName],
|
||||
}
|
||||
})
|
||||
} else if(messageInputField.text.startsWith(root.commandPrefix)) {
|
||||
root.suggestionQuery = messageInputField.text
|
||||
root.suggestionList = root.allCommands.filter(cmd => cmd.name.startsWith(messageInputField.text.substring(1))).map(cmd => {
|
||||
return {
|
||||
name: `${root.commandPrefix}${cmd.name}`,
|
||||
description: `${cmd.description}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function accept() {
|
||||
root.handleInput(text)
|
||||
text = ""
|
||||
}
|
||||
|
||||
Keys.onPressed: (event) => {
|
||||
if (event.key === Qt.Key_Tab) {
|
||||
suggestions.acceptSelectedWord();
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_Up && suggestions.visible) {
|
||||
suggestions.selectedIndex = Math.max(0, suggestions.selectedIndex - 1);
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_Down && suggestions.visible) {
|
||||
suggestions.selectedIndex = Math.min(root.suggestionList.length - 1, suggestions.selectedIndex + 1);
|
||||
event.accepted = true;
|
||||
} else if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return)) {
|
||||
if (event.modifiers & Qt.ShiftModifier) {
|
||||
// Insert newline
|
||||
messageInputField.insert(messageInputField.cursorPosition, "\n")
|
||||
event.accepted = true
|
||||
} else { // Accept text
|
||||
const inputText = messageInputField.text
|
||||
messageInputField.clear()
|
||||
root.handleInput(inputText)
|
||||
event.accepted = true
|
||||
}
|
||||
} else if ((event.modifiers & Qt.ControlModifier) && event.key === Qt.Key_V) { // Intercept Ctrl+V to handle image pasting
|
||||
// Try image paste first
|
||||
const currentClipboardEntry = Cliphist.entries[0]
|
||||
if (/^\d+\t\[\[.*binary data.*\d+x\d+.*\]\]$/.test(currentClipboardEntry)) { // First entry = currently copied entry = image?
|
||||
decodeImageAndAttachProc.handleEntry(currentClipboardEntry)
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
event.accepted = false; // No image, let text pasting proceed
|
||||
} else if (event.key === Qt.Key_Escape) { // Esc to detach file
|
||||
if (Ai.pendingFilePath.length > 0) {
|
||||
Ai.attachFile("");
|
||||
event.accepted = true;
|
||||
} else {
|
||||
event.accepted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RippleButton { // Send button
|
||||
id: sendButton
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.rightMargin: 5
|
||||
implicitWidth: 40
|
||||
implicitHeight: 40
|
||||
buttonRadius: Appearance.rounding.small
|
||||
enabled: messageInputField.text.length > 0
|
||||
toggled: enabled
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: sendButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
const inputText = messageInputField.text
|
||||
root.handleInput(inputText)
|
||||
messageInputField.clear()
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
iconSize: Appearance.font.pixelSize.larger
|
||||
// fill: sendButton.enabled ? 1 : 0
|
||||
color: sendButton.enabled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2Disabled
|
||||
text: "send"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout { // Controls
|
||||
id: commandButtonsRow
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 5
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 5
|
||||
spacing: 4
|
||||
|
||||
property var commandsShown: [
|
||||
{
|
||||
name: "",
|
||||
sendDirectly: false,
|
||||
dontAddSpace: true,
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
sendDirectly: true,
|
||||
},
|
||||
]
|
||||
|
||||
ApiInputBoxIndicator { // Model indicator
|
||||
icon: "api"
|
||||
text: Ai.getModel().name
|
||||
tooltipText: Translation.tr("Current model: %1\nSet it with %2model MODEL")
|
||||
.arg(Ai.getModel().name)
|
||||
.arg(root.commandPrefix)
|
||||
}
|
||||
|
||||
ApiInputBoxIndicator { // Tool indicator
|
||||
icon: "service_toolbox"
|
||||
text: Ai.currentTool.charAt(0).toUpperCase() + Ai.currentTool.slice(1)
|
||||
tooltipText: Translation.tr("Current tool: %1\nSet it with %2tool TOOL")
|
||||
.arg(Ai.currentTool)
|
||||
.arg(root.commandPrefix)
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
RippleButton { // Attach file button
|
||||
id: attachButton
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
implicitWidth: 36
|
||||
implicitHeight: 36
|
||||
buttonRadius: Appearance.rounding.small
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
iconSize: Appearance.font.pixelSize.larger
|
||||
color: Appearance.m3colors.m3onPrimary
|
||||
text: "attach_file"
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: openAttachDialog()
|
||||
}
|
||||
StyledToolTip {
|
||||
content: Translation.tr("Attach a file")
|
||||
}
|
||||
}
|
||||
|
||||
ButtonGroup { // Command buttons
|
||||
padding: 0
|
||||
|
||||
Repeater { // Command buttons
|
||||
model: commandButtonsRow.commandsShown
|
||||
delegate: ApiCommandButton {
|
||||
property string commandRepresentation: `${root.commandPrefix}${modelData.name}`
|
||||
buttonText: commandRepresentation
|
||||
onClicked: {
|
||||
if(modelData.sendDirectly) {
|
||||
root.handleInput(commandRepresentation)
|
||||
} else {
|
||||
messageInputField.text = commandRepresentation + (modelData.dontAddSpace ? "" : " ")
|
||||
messageInputField.cursorPosition = messageInputField.text.length
|
||||
messageInputField.forceActiveFocus()
|
||||
}
|
||||
if (modelData.name === "clear") {
|
||||
messageInputField.text = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
583
ii/modules/sidebarLeft/Anime.qml
Normal file
583
ii/modules/sidebarLeft/Anime.qml
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.modules.common.functions
|
||||
import "./anime/"
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Qt5Compat.GraphicalEffects
|
||||
import Quickshell
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property var inputField: tagInputField
|
||||
readonly property var responses: Booru.responses
|
||||
property string previewDownloadPath: Directories.booruPreviews
|
||||
property string downloadPath: Directories.booruDownloads
|
||||
property string nsfwPath: Directories.booruDownloadsNsfw
|
||||
property string commandPrefix: "/"
|
||||
property real scrollOnNewResponse: 100
|
||||
property int tagSuggestionDelay: 210
|
||||
property var suggestionQuery: ""
|
||||
property var suggestionList: []
|
||||
|
||||
Connections {
|
||||
target: Config
|
||||
function onReadyChanged() {
|
||||
if (Config.options.policies.weeb !== 0) {
|
||||
Quickshell.execDetached(["bash", "-c", `mkdir -p '${root.downloadPath}' && mkdir -p '${root.nsfwPath}'`])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Booru
|
||||
function onTagSuggestion(query, suggestions) {
|
||||
root.suggestionQuery = query;
|
||||
root.suggestionList = suggestions;
|
||||
}
|
||||
}
|
||||
|
||||
property var allCommands: [
|
||||
{
|
||||
name: "mode",
|
||||
description: Translation.tr("Set the current API provider"),
|
||||
execute: (args) => {
|
||||
Booru.setProvider(args[0]);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
description: Translation.tr("Clear the current list of images"),
|
||||
execute: () => {
|
||||
Booru.clearResponses();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "next",
|
||||
description: Translation.tr("Get the next page of results"),
|
||||
execute: () => {
|
||||
if (root.responses.length > 0) {
|
||||
const lastResponse = root.responses[root.responses.length - 1];
|
||||
root.handleInput(`${lastResponse.tags.join(" ")} ${parseInt(lastResponse.page) + 1}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "safe",
|
||||
description: Translation.tr("Disable NSFW content"),
|
||||
execute: () => {
|
||||
Persistent.states.booru.allowNsfw = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "lewd",
|
||||
description: Translation.tr("Allow NSFW content"),
|
||||
execute: () => {
|
||||
Persistent.states.booru.allowNsfw = true;
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
function handleInput(inputText) {
|
||||
if (inputText.startsWith(root.commandPrefix)) {
|
||||
// Handle special commands
|
||||
const command = inputText.split(" ")[0].substring(1);
|
||||
const args = inputText.split(" ").slice(1);
|
||||
const commandObj = root.allCommands.find(cmd => cmd.name === `${command}`);
|
||||
if (commandObj) {
|
||||
commandObj.execute(args);
|
||||
} else {
|
||||
Booru.addSystemMessage(Translation.tr("Unknown command: ") + command);
|
||||
}
|
||||
}
|
||||
else if (inputText.trim() == "+") {
|
||||
if (root.responses.length > 0) {
|
||||
const lastResponse = root.responses[root.responses.length - 1]
|
||||
root.handleInput(lastResponse.tags.join(" ") + ` ${parseInt(lastResponse.page) + 1}`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Create tag list
|
||||
const tagList = inputText.split(/\s+/).filter(tag => tag.length > 0);
|
||||
let pageIndex = 1;
|
||||
for (let i = 0; i < tagList.length; ++i) { // Detect page number
|
||||
if (/^\d+$/.test(tagList[i])) {
|
||||
pageIndex = parseInt(tagList[i], 10);
|
||||
tagList.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Booru.makeRequest(tagList, Persistent.states.booru.allowNsfw, Config.options.sidebar.booru.limit, pageIndex);
|
||||
}
|
||||
}
|
||||
|
||||
onFocusChanged: (focus) => {
|
||||
if (focus) {
|
||||
tagInputField.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onPressed: (event) => {
|
||||
tagInputField.forceActiveFocus()
|
||||
if (event.modifiers === Qt.NoModifier) {
|
||||
if (event.key === Qt.Key_PageUp) {
|
||||
booruResponseListView.contentY = Math.max(0, booruResponseListView.contentY - booruResponseListView.height / 2)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_PageDown) {
|
||||
booruResponseListView.contentY = Math.min(booruResponseListView.contentHeight - booruResponseListView.height / 2, booruResponseListView.contentY + booruResponseListView.height / 2)
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ColumnLayout {
|
||||
id: columnLayout
|
||||
anchors.fill: parent
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
StyledListView { // Booru responses
|
||||
id: booruResponseListView
|
||||
anchors.fill: parent
|
||||
spacing: 10
|
||||
|
||||
touchpadScrollFactor: Config.options.interactions.scrolling.touchpadScrollFactor * 1.4
|
||||
mouseScrollFactor: Config.options.interactions.scrolling.mouseScrollFactor * 1.4
|
||||
|
||||
property int lastResponseLength: 0
|
||||
|
||||
clip: true
|
||||
layer.enabled: true
|
||||
layer.effect: OpacityMask {
|
||||
maskSource: Rectangle {
|
||||
width: swipeView.width
|
||||
height: swipeView.height
|
||||
radius: Appearance.rounding.small
|
||||
}
|
||||
}
|
||||
|
||||
model: ScriptModel {
|
||||
values: {
|
||||
if(root.responses.length > booruResponseListView.lastResponseLength) {
|
||||
if (booruResponseListView.lastResponseLength > 0 && root.responses[booruResponseListView.lastResponseLength].provider != "system")
|
||||
booruResponseListView.contentY = booruResponseListView.contentY + root.scrollOnNewResponse
|
||||
booruResponseListView.lastResponseLength = root.responses.length
|
||||
}
|
||||
return root.responses
|
||||
}
|
||||
}
|
||||
delegate: BooruResponse {
|
||||
responseData: modelData
|
||||
tagInputField: root.inputField
|
||||
previewDownloadPath: root.previewDownloadPath
|
||||
downloadPath: root.downloadPath
|
||||
nsfwPath: root.nsfwPath
|
||||
}
|
||||
}
|
||||
|
||||
Item { // Placeholder when list is empty
|
||||
opacity: root.responses.length === 0 ? 1 : 0
|
||||
visible: opacity > 0
|
||||
anchors.fill: parent
|
||||
|
||||
Behavior on opacity {
|
||||
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
spacing: 5
|
||||
|
||||
MaterialSymbol {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
iconSize: 60
|
||||
color: Appearance.m3colors.m3outline
|
||||
text: "bookmark_heart"
|
||||
}
|
||||
StyledText {
|
||||
id: widgetNameText
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
font.pixelSize: Appearance.font.pixelSize.larger
|
||||
font.family: Appearance.font.family.title
|
||||
color: Appearance.m3colors.m3outline
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: Translation.tr("Anime boorus")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { // Queries awaiting response
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: 10
|
||||
implicitHeight: pendingBackground.implicitHeight
|
||||
opacity: Booru.runningRequests > 0 ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: pendingBackground
|
||||
color: Appearance.m3colors.m3inverseSurface
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitHeight: pendingText.implicitHeight + 12 * 2
|
||||
radius: Appearance.rounding.verysmall
|
||||
|
||||
StyledText {
|
||||
id: pendingText
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
color: Appearance.m3colors.m3inverseOnSurface
|
||||
wrapMode: Text.Wrap
|
||||
text: Translation.tr("%1 queries pending").arg(Booru.runningRequests)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DescriptionBox { // Tag suggestion description
|
||||
text: root.suggestionList[tagSuggestions.selectedIndex]?.description ?? ""
|
||||
showArrows: root.suggestionList.length > 1
|
||||
}
|
||||
|
||||
FlowButtonGroup { // Tag suggestions
|
||||
id: tagSuggestions
|
||||
visible: root.suggestionList.length > 0 && tagInputField.text.length > 0
|
||||
property int selectedIndex: 0
|
||||
Layout.fillWidth: true
|
||||
spacing: 5
|
||||
|
||||
Repeater {
|
||||
id: tagSuggestionRepeater
|
||||
model: {
|
||||
tagSuggestions.selectedIndex = 0
|
||||
return root.suggestionList.slice(0, 10)
|
||||
}
|
||||
delegate: ApiCommandButton {
|
||||
id: tagButton
|
||||
colBackground: tagSuggestions.selectedIndex === index ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colSecondaryContainer
|
||||
bounce: false
|
||||
contentItem: RowLayout {
|
||||
anchors.centerIn: parent
|
||||
spacing: 5
|
||||
StyledText {
|
||||
Layout.fillWidth: false
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
color: Appearance.colors.colOnSecondaryContainer
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: modelData.displayName ?? modelData.name
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: false
|
||||
visible: modelData.count !== undefined
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
color: Appearance.colors.colOnSecondaryContainer
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
text: modelData.count ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
onHoveredChanged: {
|
||||
if (tagButton.hovered) {
|
||||
tagSuggestions.selectedIndex = index;
|
||||
}
|
||||
}
|
||||
onClicked: {
|
||||
tagSuggestions.acceptTag(modelData.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function acceptTag(tag) {
|
||||
const words = tagInputField.text.trim().split(/\s+/);
|
||||
if (words.length > 0) {
|
||||
words[words.length - 1] = tag;
|
||||
} else {
|
||||
words.push(tag);
|
||||
}
|
||||
const updatedText = words.join(" ") + " ";
|
||||
tagInputField.text = updatedText;
|
||||
tagInputField.cursorPosition = tagInputField.text.length;
|
||||
tagInputField.forceActiveFocus();
|
||||
}
|
||||
|
||||
function acceptSelectedTag() {
|
||||
if (tagSuggestions.selectedIndex >= 0 && tagSuggestions.selectedIndex < tagSuggestionRepeater.count) {
|
||||
const tag = root.suggestionList[tagSuggestions.selectedIndex].name;
|
||||
tagSuggestions.acceptTag(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { // Tag input area
|
||||
id: tagInputContainer
|
||||
property real columnSpacing: 5
|
||||
Layout.fillWidth: true
|
||||
radius: Appearance.rounding.small
|
||||
color: Appearance.colors.colLayer1
|
||||
implicitWidth: tagInputField.implicitWidth
|
||||
implicitHeight: Math.max(inputFieldRowLayout.implicitHeight + inputFieldRowLayout.anchors.topMargin
|
||||
+ commandButtonsRow.implicitHeight + commandButtonsRow.anchors.bottomMargin + columnSpacing, 45)
|
||||
clip: true
|
||||
border.color: Appearance.colors.colOutlineVariant
|
||||
border.width: 1
|
||||
|
||||
Behavior on implicitHeight {
|
||||
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
|
||||
}
|
||||
|
||||
RowLayout { // Input field and send button
|
||||
id: inputFieldRowLayout
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: 5
|
||||
spacing: 0
|
||||
|
||||
StyledTextArea { // The actual TextArea
|
||||
id: tagInputField
|
||||
wrapMode: TextArea.Wrap
|
||||
Layout.fillWidth: true
|
||||
padding: 10
|
||||
color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant
|
||||
renderType: Text.NativeRendering
|
||||
placeholderText: Translation.tr('Enter tags, or "%1" for commands').arg(root.commandPrefix)
|
||||
|
||||
background: null
|
||||
|
||||
property Timer searchTimer: Timer { // Timer for tag suggestions
|
||||
interval: root.tagSuggestionDelay
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
const inputText = tagInputField.text
|
||||
const words = inputText.trim().split(/\s+/);
|
||||
if (words.length > 0) {
|
||||
Booru.triggerTagSearch(words[words.length - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onTextChanged: { // Handle tag suggestions
|
||||
if(tagInputField.text.length === 0) {
|
||||
root.suggestionQuery = ""
|
||||
root.suggestionList = []
|
||||
searchTimer.stop();
|
||||
return
|
||||
}
|
||||
if(tagInputField.text.startsWith(`${root.commandPrefix}mode`)) {
|
||||
root.suggestionQuery = tagInputField.text.split(" ")[1] ?? ""
|
||||
const providerResults = Fuzzy.go(root.suggestionQuery, Booru.providerList.map(provider => {
|
||||
return {
|
||||
name: Fuzzy.prepare(provider),
|
||||
obj: provider,
|
||||
}
|
||||
}), {
|
||||
all: true,
|
||||
key: "name"
|
||||
})
|
||||
root.suggestionList = providerResults.map(provider => {
|
||||
return {
|
||||
name: `${tagInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "mode ") : ""}${provider.target}`,
|
||||
displayName: `${Booru.providers[provider.target].name}`,
|
||||
description: `${Booru.providers[provider.target].description}`,
|
||||
}
|
||||
})
|
||||
searchTimer.stop();
|
||||
return
|
||||
}
|
||||
if(tagInputField.text.startsWith(root.commandPrefix)) {
|
||||
root.suggestionQuery = tagInputField.text
|
||||
root.suggestionList = root.allCommands.filter(cmd => cmd.name.startsWith(tagInputField.text.substring(1))).map(cmd => {
|
||||
return {
|
||||
name: `${root.commandPrefix}${cmd.name}`,
|
||||
description: `${cmd.description}`,
|
||||
}
|
||||
})
|
||||
searchTimer.stop();
|
||||
return
|
||||
}
|
||||
searchTimer.restart();
|
||||
}
|
||||
|
||||
function accept() {
|
||||
root.handleInput(text)
|
||||
text = ""
|
||||
}
|
||||
|
||||
Keys.onPressed: (event) => {
|
||||
if (event.key === Qt.Key_Tab) {
|
||||
tagSuggestions.acceptSelectedTag();
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_Up) {
|
||||
tagSuggestions.selectedIndex = Math.max(0, tagSuggestions.selectedIndex - 1);
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_Down) {
|
||||
tagSuggestions.selectedIndex = Math.min(root.suggestionList.length - 1, tagSuggestions.selectedIndex + 1);
|
||||
event.accepted = true;
|
||||
} else if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return)) {
|
||||
if (event.modifiers & Qt.ShiftModifier) {
|
||||
// Insert newline
|
||||
tagInputField.insert(tagInputField.cursorPosition, "\n")
|
||||
event.accepted = true
|
||||
} else { // Accept text
|
||||
const inputText = tagInputField.text
|
||||
root.handleInput(inputText)
|
||||
tagInputField.clear()
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RippleButton { // Send button
|
||||
id: sendButton
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.rightMargin: 5
|
||||
implicitWidth: 40
|
||||
implicitHeight: 40
|
||||
buttonRadius: Appearance.rounding.small
|
||||
enabled: tagInputField.text.length > 0
|
||||
toggled: enabled
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: sendButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
const inputText = tagInputField.text
|
||||
root.handleInput(inputText)
|
||||
tagInputField.clear()
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
iconSize: Appearance.font.pixelSize.larger
|
||||
// fill: sendButton.enabled ? 1 : 0
|
||||
color: sendButton.enabled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2Disabled
|
||||
text: "send"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout { // Controls
|
||||
id: commandButtonsRow
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 5
|
||||
anchors.leftMargin: 5
|
||||
anchors.rightMargin: 5
|
||||
spacing: 5
|
||||
|
||||
property var commandsShown: [
|
||||
{
|
||||
name: "mode",
|
||||
sendDirectly: false,
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
sendDirectly: true,
|
||||
},
|
||||
]
|
||||
|
||||
ApiInputBoxIndicator { // Tool indicator
|
||||
icon: "api"
|
||||
text: Booru.providers[Booru.currentProvider].name
|
||||
tooltipText: Translation.tr("Current API endpoint: %1\nSet it with %2mode PROVIDER")
|
||||
.arg(Booru.providers[Booru.currentProvider].url)
|
||||
.arg(root.commandPrefix)
|
||||
}
|
||||
|
||||
StyledText {
|
||||
font.pixelSize: Appearance.font.pixelSize.large
|
||||
color: Appearance.colors.colOnLayer1
|
||||
text: "•"
|
||||
}
|
||||
|
||||
Item { // NSFW toggle
|
||||
visible: width > 0
|
||||
implicitWidth: switchesRow.implicitWidth
|
||||
Layout.fillHeight: true
|
||||
|
||||
RowLayout {
|
||||
id: switchesRow
|
||||
spacing: 5
|
||||
anchors.centerIn: parent
|
||||
|
||||
MouseArea {
|
||||
hoverEnabled: true
|
||||
PointingHandInteraction {}
|
||||
onClicked: {
|
||||
nsfwSwitch.checked = !nsfwSwitch.checked
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillHeight: true
|
||||
Layout.leftMargin: 10
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
color: nsfwSwitch.enabled ? Appearance.colors.colOnLayer1 : Appearance.m3colors.m3outline
|
||||
text: Translation.tr("Allow NSFW")
|
||||
}
|
||||
StyledSwitch {
|
||||
id: nsfwSwitch
|
||||
enabled: Booru.currentProvider !== "zerochan"
|
||||
scale: 0.6
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
checked: (Persistent.states.booru.allowNsfw && Booru.currentProvider !== "zerochan")
|
||||
onCheckedChanged: {
|
||||
if (!nsfwSwitch.enabled) return;
|
||||
Persistent.states.booru.allowNsfw = checked;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
ButtonGroup {
|
||||
padding: 0
|
||||
Repeater { // Command buttons
|
||||
id: commandRepeater
|
||||
model: commandButtonsRow.commandsShown
|
||||
delegate: ApiCommandButton {
|
||||
property string commandRepresentation: `${root.commandPrefix}${modelData.name}`
|
||||
buttonText: commandRepresentation
|
||||
colBackground: Appearance.colors.colLayer2
|
||||
|
||||
onClicked: {
|
||||
if(modelData.sendDirectly) {
|
||||
root.handleInput(commandRepresentation)
|
||||
} else {
|
||||
tagInputField.text = commandRepresentation + " "
|
||||
tagInputField.cursorPosition = tagInputField.text.length
|
||||
tagInputField.forceActiveFocus()
|
||||
}
|
||||
if (modelData.name === "clear") {
|
||||
tagInputField.text = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
26
ii/modules/sidebarLeft/ApiCommandButton.qml
Normal file
26
ii/modules/sidebarLeft/ApiCommandButton.qml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import QtQuick
|
||||
|
||||
GroupButton {
|
||||
id: button
|
||||
property string buttonText
|
||||
|
||||
horizontalPadding: 8
|
||||
verticalPadding: 6
|
||||
|
||||
baseWidth: contentItem.implicitWidth + horizontalPadding * 2
|
||||
clickedWidth: baseWidth + 20
|
||||
baseHeight: contentItem.implicitHeight + verticalPadding * 2
|
||||
buttonRadius: down ? Appearance.rounding.verysmall : Appearance.rounding.small
|
||||
|
||||
colBackground: Appearance.colors.colLayer2
|
||||
colBackgroundHover: Appearance.colors.colLayer2Hover
|
||||
colBackgroundActive: Appearance.colors.colLayer2Active
|
||||
|
||||
contentItem: StyledText {
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: buttonText
|
||||
color: Appearance.m3colors.m3onSurface
|
||||
}
|
||||
}
|
||||
48
ii/modules/sidebarLeft/ApiInputBoxIndicator.qml
Normal file
48
ii/modules/sidebarLeft/ApiInputBoxIndicator.qml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.services
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
Item { // Model indicator
|
||||
id: root
|
||||
property string icon: "api"
|
||||
property string text: ""
|
||||
property string tooltipText: ""
|
||||
implicitHeight: rowLayout.implicitHeight + 4 * 2
|
||||
implicitWidth: rowLayout.implicitWidth + 4 * 2
|
||||
|
||||
RowLayout {
|
||||
id: rowLayout
|
||||
anchors.centerIn: parent
|
||||
|
||||
MaterialSymbol {
|
||||
text: root.icon
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
}
|
||||
StyledText {
|
||||
id: providerName
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
color: Appearance.m3colors.m3onSurface
|
||||
elide: Text.ElideRight
|
||||
text: root.text
|
||||
animateChange: true
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
active: root.tooltipText?.length > 0
|
||||
anchors.fill: parent
|
||||
sourceComponent: MouseArea {
|
||||
id: mouseArea
|
||||
hoverEnabled: true
|
||||
|
||||
StyledToolTip {
|
||||
id: toolTip
|
||||
extraVisibleCondition: false
|
||||
alternativeVisibleCondition: mouseArea.containsMouse // Show tooltip when hovered
|
||||
content: root.tooltipText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
ii/modules/sidebarLeft/DescriptionBox.qml
Normal file
62
ii/modules/sidebarLeft/DescriptionBox.qml
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
Item { // Tag suggestion description
|
||||
id: root
|
||||
property alias text: tagDescriptionText.text
|
||||
property bool showArrows: true
|
||||
property bool showTab: true
|
||||
|
||||
visible: tagDescriptionText.text.length > 0
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: tagDescriptionBackground.implicitHeight
|
||||
|
||||
Rectangle {
|
||||
id: tagDescriptionBackground
|
||||
color: Appearance.colors.colLayer2
|
||||
anchors.fill: parent
|
||||
radius: Appearance.rounding.verysmall
|
||||
implicitHeight: descriptionRow.implicitHeight + 5 * 2
|
||||
|
||||
RowLayout {
|
||||
id: descriptionRow
|
||||
spacing: 4
|
||||
anchors {
|
||||
fill: parent
|
||||
leftMargin: 10
|
||||
rightMargin: 10
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: tagDescriptionText
|
||||
Layout.fillWidth: true
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
color: Appearance.colors.colOnLayer2
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
KeyboardKey {
|
||||
visible: root.showArrows
|
||||
key: "↑"
|
||||
}
|
||||
KeyboardKey {
|
||||
visible: root.showArrows
|
||||
key: "↓"
|
||||
}
|
||||
StyledText {
|
||||
visible: root.showArrows && root.showTab
|
||||
text: Translation.tr("or")
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
}
|
||||
KeyboardKey {
|
||||
id: tagDescriptionKey
|
||||
visible: root.showTab
|
||||
key: "Tab"
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
202
ii/modules/sidebarLeft/SidebarLeft.qml
Normal file
202
ii/modules/sidebarLeft/SidebarLeft.qml
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
|
||||
Scope { // Scope
|
||||
id: root
|
||||
property int sidebarPadding: 15
|
||||
property bool detach: false
|
||||
property Component contentComponent: SidebarLeftContent {}
|
||||
property Item sidebarContent
|
||||
|
||||
Component.onCompleted: {
|
||||
root.sidebarContent = contentComponent.createObject(null, {
|
||||
"scopeRoot": root,
|
||||
});
|
||||
sidebarLoader.item.contentParent.children = [root.sidebarContent];
|
||||
}
|
||||
|
||||
onDetachChanged: {
|
||||
if (root.detach) {
|
||||
sidebarContent.parent = null; // Detach content from sidebar
|
||||
sidebarLoader.active = false; // Unload sidebar
|
||||
detachedSidebarLoader.active = true; // Load detached window
|
||||
detachedSidebarLoader.item.contentParent.children = [sidebarContent];
|
||||
} else {
|
||||
sidebarContent.parent = null; // Detach content from window
|
||||
detachedSidebarLoader.active = false; // Unload detached window
|
||||
sidebarLoader.active = true; // Load sidebar
|
||||
sidebarLoader.item.contentParent.children = [sidebarContent];
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: sidebarLoader
|
||||
active: true
|
||||
|
||||
sourceComponent: PanelWindow { // Window
|
||||
id: sidebarRoot
|
||||
visible: GlobalStates.sidebarLeftOpen
|
||||
|
||||
property bool extend: false
|
||||
property real sidebarWidth: sidebarRoot.extend ? Appearance.sizes.sidebarWidthExtended : Appearance.sizes.sidebarWidth
|
||||
property var contentParent: sidebarLeftBackground
|
||||
|
||||
function hide() {
|
||||
GlobalStates.sidebarLeftOpen = false
|
||||
}
|
||||
|
||||
exclusiveZone: 0
|
||||
implicitWidth: Appearance.sizes.sidebarWidthExtended + Appearance.sizes.elevationMargin
|
||||
WlrLayershell.namespace: "quickshell:sidebarLeft"
|
||||
// Hyprland 0.49: OnDemand is Exclusive, Exclusive just breaks click-outside-to-close
|
||||
// WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||
color: "transparent"
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
left: true
|
||||
bottom: true
|
||||
}
|
||||
|
||||
mask: Region {
|
||||
item: sidebarLeftBackground
|
||||
}
|
||||
|
||||
HyprlandFocusGrab { // Click outside to close
|
||||
id: grab
|
||||
windows: [ sidebarRoot ]
|
||||
active: sidebarRoot.visible
|
||||
onActiveChanged: { // Focus the selected tab
|
||||
if (active) sidebarLeftBackground.children[0].focusActiveItem()
|
||||
}
|
||||
onCleared: () => {
|
||||
if (!active) sidebarRoot.hide()
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
StyledRectangularShadow {
|
||||
target: sidebarLeftBackground
|
||||
radius: sidebarLeftBackground.radius
|
||||
}
|
||||
Rectangle {
|
||||
id: sidebarLeftBackground
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.topMargin: Appearance.sizes.hyprlandGapsOut
|
||||
anchors.leftMargin: Appearance.sizes.hyprlandGapsOut
|
||||
width: sidebarRoot.sidebarWidth - Appearance.sizes.hyprlandGapsOut - Appearance.sizes.elevationMargin
|
||||
height: parent.height - Appearance.sizes.hyprlandGapsOut * 2
|
||||
color: Appearance.colors.colLayer0
|
||||
border.width: 1
|
||||
border.color: Appearance.colors.colLayer0Border
|
||||
radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1
|
||||
|
||||
Behavior on width {
|
||||
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
|
||||
}
|
||||
|
||||
Keys.onPressed: (event) => {
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
sidebarRoot.hide();
|
||||
}
|
||||
if (event.modifiers === Qt.ControlModifier) {
|
||||
if (event.key === Qt.Key_O) {
|
||||
sidebarRoot.extend = !sidebarRoot.extend;
|
||||
}
|
||||
else if (event.key === Qt.Key_P) {
|
||||
root.detach = !root.detach;
|
||||
}
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: detachedSidebarLoader
|
||||
active: false
|
||||
|
||||
sourceComponent: FloatingWindow {
|
||||
id: detachedSidebarRoot
|
||||
visible: GlobalStates.sidebarLeftOpen
|
||||
property var contentParent: detachedSidebarBackground
|
||||
|
||||
Rectangle {
|
||||
id: detachedSidebarBackground
|
||||
anchors.fill: parent
|
||||
color: Appearance.colors.colLayer0
|
||||
|
||||
Keys.onPressed: (event) => {
|
||||
if (event.modifiers === Qt.ControlModifier) {
|
||||
if (event.key === Qt.Key_P) {
|
||||
root.detach = !root.detach;
|
||||
}
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "sidebarLeft"
|
||||
|
||||
function toggle(): void {
|
||||
GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
GlobalStates.sidebarLeftOpen = false
|
||||
}
|
||||
|
||||
function open(): void {
|
||||
GlobalStates.sidebarLeftOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
GlobalShortcut {
|
||||
name: "sidebarLeftToggle"
|
||||
description: "Toggles left sidebar on press"
|
||||
|
||||
onPressed: {
|
||||
GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen;
|
||||
}
|
||||
}
|
||||
|
||||
GlobalShortcut {
|
||||
name: "sidebarLeftOpen"
|
||||
description: "Opens left sidebar on press"
|
||||
|
||||
onPressed: {
|
||||
GlobalStates.sidebarLeftOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
GlobalShortcut {
|
||||
name: "sidebarLeftClose"
|
||||
description: "Closes left sidebar on press"
|
||||
|
||||
onPressed: {
|
||||
GlobalStates.sidebarLeftOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
GlobalShortcut {
|
||||
name: "sidebarLeftToggleDetach"
|
||||
description: "Detach left sidebar into a window/Attach it back"
|
||||
|
||||
onPressed: {
|
||||
root.detach = !root.detach;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
106
ii/modules/sidebarLeft/SidebarLeftContent.qml
Normal file
106
ii/modules/sidebarLeft/SidebarLeftContent.qml
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.services
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Qt5Compat.GraphicalEffects
|
||||
|
||||
Item {
|
||||
id: root
|
||||
required property var scopeRoot
|
||||
anchors.fill: parent
|
||||
property var tabButtonList: [
|
||||
...(Config.options.policies.ai !== 0 ? [{"icon": "neurology", "name": Translation.tr("Intelligence")}] : []),
|
||||
{"icon": "translate", "name": Translation.tr("Translator")},
|
||||
...(Config.options.policies.weeb === 1 ? [{"icon": "bookmark_heart", "name": Translation.tr("Anime")}] : [])
|
||||
]
|
||||
property int selectedTab: 0
|
||||
|
||||
function focusActiveItem() {
|
||||
swipeView.currentItem.forceActiveFocus()
|
||||
}
|
||||
|
||||
Keys.onPressed: (event) => {
|
||||
if (event.modifiers === Qt.ControlModifier) {
|
||||
if (event.key === Qt.Key_PageDown) {
|
||||
root.selectedTab = Math.min(root.selectedTab + 1, root.tabButtonList.length - 1)
|
||||
event.accepted = true;
|
||||
}
|
||||
else if (event.key === Qt.Key_PageUp) {
|
||||
root.selectedTab = Math.max(root.selectedTab - 1, 0)
|
||||
event.accepted = true;
|
||||
}
|
||||
else if (event.key === Qt.Key_Tab) {
|
||||
root.selectedTab = (root.selectedTab + 1) % root.tabButtonList.length;
|
||||
event.accepted = true;
|
||||
}
|
||||
else if (event.key === Qt.Key_Backtab) {
|
||||
root.selectedTab = (root.selectedTab - 1 + root.tabButtonList.length) % root.tabButtonList.length;
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: sidebarPadding
|
||||
|
||||
spacing: sidebarPadding
|
||||
|
||||
PrimaryTabBar { // Tab strip
|
||||
id: tabBar
|
||||
tabButtonList: root.tabButtonList
|
||||
externalTrackedTab: root.selectedTab
|
||||
function onCurrentIndexChanged(currentIndex) {
|
||||
root.selectedTab = currentIndex
|
||||
}
|
||||
}
|
||||
|
||||
SwipeView { // Content pages
|
||||
id: swipeView
|
||||
Layout.topMargin: 5
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 10
|
||||
|
||||
currentIndex: tabBar.externalTrackedTab
|
||||
onCurrentIndexChanged: {
|
||||
tabBar.enableIndicatorAnimation = true
|
||||
root.selectedTab = currentIndex
|
||||
}
|
||||
|
||||
clip: true
|
||||
layer.enabled: true
|
||||
layer.effect: OpacityMask {
|
||||
maskSource: Rectangle {
|
||||
width: swipeView.width
|
||||
height: swipeView.height
|
||||
radius: Appearance.rounding.small
|
||||
}
|
||||
}
|
||||
|
||||
contentChildren: [
|
||||
...(Config.options.policies.ai !== 0 ? [aiChat.createObject()] : []),
|
||||
translator.createObject(),
|
||||
...(Config.options.policies.weeb === 0 ? [] : [anime.createObject()])
|
||||
]
|
||||
}
|
||||
|
||||
Component {
|
||||
id: aiChat
|
||||
AiChat {}
|
||||
}
|
||||
Component {
|
||||
id: translator
|
||||
Translator {}
|
||||
}
|
||||
Component {
|
||||
id: anime
|
||||
Anime {}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
246
ii/modules/sidebarLeft/Translator.qml
Normal file
246
ii/modules/sidebarLeft/Translator.qml
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.modules.common.functions
|
||||
import "./translator/"
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
/**
|
||||
* Translator widget with the `trans` commandline tool.
|
||||
*/
|
||||
Item {
|
||||
id: root
|
||||
// Widgets
|
||||
property var inputField: inputCanvas.inputTextArea
|
||||
// Widget variables
|
||||
property bool translationFor: false // Indicates if the translation is for an autocorrected text
|
||||
property string translatedText: ""
|
||||
property list<string> languages: []
|
||||
// Options
|
||||
property string targetLanguage: Config.options.language.translator.targetLanguage
|
||||
property string sourceLanguage: Config.options.language.translator.sourceLanguage
|
||||
property string hostLanguage: targetLanguage
|
||||
|
||||
property bool showLanguageSelector: false
|
||||
property bool languageSelectorTarget: false // true for target language, false for source language
|
||||
|
||||
function showLanguageSelectorDialog(isTargetLang: bool) {
|
||||
root.languageSelectorTarget = isTargetLang;
|
||||
root.showLanguageSelector = true
|
||||
}
|
||||
|
||||
onFocusChanged: (focus) => {
|
||||
if (focus) {
|
||||
root.inputField.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: translateTimer
|
||||
interval: Config.options.sidebar.translator.delay
|
||||
repeat: false
|
||||
onTriggered: () => {
|
||||
if (root.inputField.text.trim().length > 0) {
|
||||
// console.log("Translating with command:", translateProc.command);
|
||||
translateProc.running = false;
|
||||
translateProc.buffer = ""; // Clear the buffer
|
||||
translateProc.running = true; // Restart the process
|
||||
} else {
|
||||
root.translatedText = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: translateProc
|
||||
command: ["bash", "-c", `trans -no-theme -no-bidi`
|
||||
+ ` -source '${StringUtils.shellSingleQuoteEscape(root.sourceLanguage)}'`
|
||||
+ ` -target '${StringUtils.shellSingleQuoteEscape(root.targetLanguage)}'`
|
||||
+ ` -no-ansi '${StringUtils.shellSingleQuoteEscape(root.inputField.text.trim())}'`]
|
||||
property string buffer: ""
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
translateProc.buffer += data + "\n";
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
// 1. Split into sections by double newlines
|
||||
const sections = translateProc.buffer.trim().split(/\n\s*\n/);
|
||||
// console.log("BUFFER:", translateProc.buffer);
|
||||
// console.log("SECTIONS:", sections);
|
||||
|
||||
// 2. Extract relevant data
|
||||
root.translatedText = sections.length > 1 ? sections[1].trim() : "";
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getLanguagesProc
|
||||
command: ["trans", "-list-languages", "-no-bidi"]
|
||||
property list<string> bufferList: ["auto"]
|
||||
running: true
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
getLanguagesProc.bufferList.push(data.trim());
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
// Ensure "auto" is always the first language
|
||||
let langs = getLanguagesProc.bufferList
|
||||
.filter(lang => lang.trim().length > 0 && lang !== "auto")
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
langs.unshift("auto");
|
||||
root.languages = langs;
|
||||
getLanguagesProc.bufferList = []; // Clear the buffer
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
Flickable {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
contentHeight: contentColumn.implicitHeight
|
||||
|
||||
ColumnLayout {
|
||||
id: contentColumn
|
||||
anchors.fill: parent
|
||||
|
||||
LanguageSelectorButton { // Target language button
|
||||
id: targetLanguageButton
|
||||
displayText: root.targetLanguage
|
||||
onClicked: {
|
||||
root.showLanguageSelectorDialog(true);
|
||||
}
|
||||
}
|
||||
|
||||
TextCanvas { // Content translation
|
||||
id: outputCanvas
|
||||
isInput: false
|
||||
placeholderText: Translation.tr("Translation goes here...")
|
||||
property bool hasTranslation: (root.translatedText.trim().length > 0)
|
||||
text: hasTranslation ? root.translatedText : ""
|
||||
GroupButton {
|
||||
id: copyButton
|
||||
baseWidth: height
|
||||
buttonRadius: Appearance.rounding.small
|
||||
enabled: outputCanvas.displayedText.trim().length > 0
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
iconSize: Appearance.font.pixelSize.larger
|
||||
text: "content_copy"
|
||||
color: copyButton.enabled ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext
|
||||
}
|
||||
onClicked: {
|
||||
Quickshell.clipboardText = outputCanvas.displayedText
|
||||
}
|
||||
}
|
||||
GroupButton {
|
||||
id: searchButton
|
||||
baseWidth: height
|
||||
buttonRadius: Appearance.rounding.small
|
||||
enabled: outputCanvas.displayedText.trim().length > 0
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
iconSize: Appearance.font.pixelSize.larger
|
||||
text: "travel_explore"
|
||||
color: searchButton.enabled ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext
|
||||
}
|
||||
onClicked: {
|
||||
let url = Config.options.search.engineBaseUrl + outputCanvas.displayedText;
|
||||
for (let site of Config.options.search.excludedSites) {
|
||||
url += ` -site:${site}`;
|
||||
}
|
||||
Qt.openUrlExternally(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
LanguageSelectorButton { // Source language button
|
||||
id: sourceLanguageButton
|
||||
displayText: root.sourceLanguage
|
||||
onClicked: {
|
||||
root.showLanguageSelectorDialog(false);
|
||||
}
|
||||
}
|
||||
|
||||
TextCanvas { // Content input
|
||||
id: inputCanvas
|
||||
isInput: true
|
||||
placeholderText: Translation.tr("Enter text to translate...")
|
||||
onInputTextChanged: {
|
||||
translateTimer.restart();
|
||||
}
|
||||
GroupButton {
|
||||
id: pasteButton
|
||||
baseWidth: height
|
||||
buttonRadius: Appearance.rounding.small
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
iconSize: Appearance.font.pixelSize.larger
|
||||
text: "content_paste"
|
||||
color: deleteButton.enabled ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext
|
||||
}
|
||||
onClicked: {
|
||||
root.inputField.text = Quickshell.clipboardText
|
||||
}
|
||||
}
|
||||
GroupButton {
|
||||
id: deleteButton
|
||||
baseWidth: height
|
||||
buttonRadius: Appearance.rounding.small
|
||||
enabled: inputCanvas.inputTextArea.text.length > 0
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
iconSize: Appearance.font.pixelSize.larger
|
||||
text: "close"
|
||||
color: deleteButton.enabled ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext
|
||||
}
|
||||
onClicked: {
|
||||
root.inputField.text = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: root.showLanguageSelector
|
||||
visible: root.showLanguageSelector
|
||||
z: 9999
|
||||
sourceComponent: SelectionDialog {
|
||||
id: languageSelectorDialog
|
||||
titleText: Translation.tr("Select Language")
|
||||
items: root.languages
|
||||
defaultChoice: root.languageSelectorTarget ? root.targetLanguage : root.sourceLanguage
|
||||
onCanceled: () => {
|
||||
root.showLanguageSelector = false;
|
||||
}
|
||||
onSelected: (result) => {
|
||||
root.showLanguageSelector = false;
|
||||
if (!result || result.length === 0) return; // No selection made
|
||||
|
||||
if (root.languageSelectorTarget) {
|
||||
root.targetLanguage = result;
|
||||
Config.options.language.translator.targetLanguage = result; // Save to config
|
||||
} else {
|
||||
root.sourceLanguage = result;
|
||||
Config.options.language.translator.sourceLanguage = result; // Save to config
|
||||
}
|
||||
|
||||
translateTimer.restart(); // Restart translation after language change
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
311
ii/modules/sidebarLeft/aiChat/AiMessage.qml
Normal file
311
ii/modules/sidebarLeft/aiChat/AiMessage.qml
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell.Io
|
||||
import Quickshell
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
property int messageIndex
|
||||
property var messageData
|
||||
property var messageInputField
|
||||
|
||||
property real messagePadding: 7
|
||||
property real contentSpacing: 3
|
||||
|
||||
property bool enableMouseSelection: false
|
||||
property bool renderMarkdown: true
|
||||
property bool editing: false
|
||||
|
||||
property list<var> messageBlocks: StringUtils.splitMarkdownBlocks(root.messageData?.content)
|
||||
|
||||
anchors.left: parent?.left
|
||||
anchors.right: parent?.right
|
||||
implicitHeight: columnLayout.implicitHeight + root.messagePadding * 2
|
||||
|
||||
radius: Appearance.rounding.normal
|
||||
color: Appearance.colors.colLayer1
|
||||
|
||||
function saveMessage() {
|
||||
if (!root.editing) return;
|
||||
// Get all Loader children (each represents a segment)
|
||||
const segments = messageContentColumnLayout.children
|
||||
.map(child => child.segment)
|
||||
.filter(segment => (segment));
|
||||
|
||||
// Reconstruct markdown
|
||||
const newContent = segments.map(segment => {
|
||||
if (segment.type === "code") {
|
||||
const lang = segment.lang ? segment.lang : "";
|
||||
// Remove trailing newlines
|
||||
const code = segment.content.replace(/\n+$/, "");
|
||||
return "```" + lang + "\n" + code + "\n```";
|
||||
} else {
|
||||
return segment.content;
|
||||
}
|
||||
}).join("");
|
||||
|
||||
root.editing = false
|
||||
root.messageData.content = newContent;
|
||||
}
|
||||
|
||||
Keys.onPressed: (event) => {
|
||||
if ( // Prevent de-select
|
||||
event.key === Qt.Key_Control ||
|
||||
event.key == Qt.Key_Shift ||
|
||||
event.key == Qt.Key_Alt ||
|
||||
event.key == Qt.Key_Meta
|
||||
) {
|
||||
event.accepted = true
|
||||
}
|
||||
// Ctrl + S to save
|
||||
if ((event.key === Qt.Key_S) && event.modifiers == Qt.ControlModifier) {
|
||||
root.saveMessage();
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout { // Main layout of the whole thing
|
||||
id: columnLayout
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: messagePadding
|
||||
spacing: root.contentSpacing
|
||||
|
||||
RowLayout { // Header
|
||||
spacing: 15
|
||||
Layout.fillWidth: true
|
||||
|
||||
Rectangle { // Name
|
||||
id: nameWrapper
|
||||
color: Appearance.colors.colSecondaryContainer
|
||||
// color: "transparent"
|
||||
radius: Appearance.rounding.small
|
||||
implicitHeight: Math.max(nameRowLayout.implicitHeight + 5 * 2, 30)
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
RowLayout {
|
||||
id: nameRowLayout
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
spacing: 7
|
||||
|
||||
Item {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.fillHeight: true
|
||||
implicitWidth: messageData?.role == 'assistant' ? modelIcon.width : roleIcon.implicitWidth
|
||||
implicitHeight: messageData?.role == 'assistant' ? modelIcon.height : roleIcon.implicitHeight
|
||||
|
||||
CustomIcon {
|
||||
id: modelIcon
|
||||
anchors.centerIn: parent
|
||||
visible: messageData?.role == 'assistant' && Ai.models[messageData?.model].icon
|
||||
width: Appearance.font.pixelSize.large
|
||||
height: Appearance.font.pixelSize.large
|
||||
source: messageData?.role == 'assistant' ? Ai.models[messageData?.model].icon :
|
||||
messageData?.role == 'user' ? 'linux-symbolic' : 'desktop-symbolic'
|
||||
|
||||
colorize: true
|
||||
color: Appearance.m3colors.m3onSecondaryContainer
|
||||
}
|
||||
|
||||
MaterialSymbol {
|
||||
id: roleIcon
|
||||
anchors.centerIn: parent
|
||||
visible: !modelIcon.visible
|
||||
iconSize: Appearance.font.pixelSize.larger
|
||||
color: Appearance.m3colors.m3onSecondaryContainer
|
||||
text: messageData?.role == 'user' ? 'person' :
|
||||
messageData?.role == 'interface' ? 'settings' :
|
||||
messageData?.role == 'assistant' ? 'neurology' :
|
||||
'computer'
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: providerName
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
font.pixelSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.m3colors.m3onSecondaryContainer
|
||||
text: messageData?.role == 'assistant' ? Ai.models[messageData?.model].name :
|
||||
(messageData?.role == 'user' && SystemInfo.username) ? SystemInfo.username :
|
||||
Translation.tr("Interface")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button { // Not visible to model
|
||||
id: modelVisibilityIndicator
|
||||
visible: messageData?.role == 'interface'
|
||||
implicitWidth: 16
|
||||
implicitHeight: 30
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
background: Item
|
||||
|
||||
MaterialSymbol {
|
||||
id: notVisibleToModelText
|
||||
anchors.centerIn: parent
|
||||
iconSize: Appearance.font.pixelSize.small
|
||||
color: Appearance.colors.colSubtext
|
||||
text: "visibility_off"
|
||||
}
|
||||
StyledToolTip {
|
||||
content: Translation.tr("Not visible to model")
|
||||
}
|
||||
}
|
||||
|
||||
ButtonGroup {
|
||||
spacing: 5
|
||||
|
||||
AiMessageControlButton {
|
||||
id: copyButton
|
||||
buttonIcon: activated ? "inventory" : "content_copy"
|
||||
|
||||
onClicked: {
|
||||
Quickshell.clipboardText = root.messageData?.content
|
||||
copyButton.activated = true
|
||||
copyIconTimer.restart()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: copyIconTimer
|
||||
interval: 1500
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
copyButton.activated = false
|
||||
}
|
||||
}
|
||||
|
||||
StyledToolTip {
|
||||
content: Translation.tr("Copy")
|
||||
}
|
||||
}
|
||||
AiMessageControlButton {
|
||||
id: editButton
|
||||
activated: root.editing
|
||||
enabled: root.messageData?.done ?? false
|
||||
buttonIcon: "edit"
|
||||
onClicked: {
|
||||
root.editing = !root.editing
|
||||
if (!root.editing) { // Save changes
|
||||
root.saveMessage()
|
||||
}
|
||||
}
|
||||
StyledToolTip {
|
||||
content: root.editing ? Translation.tr("Save") : Translation.tr("Edit")
|
||||
}
|
||||
}
|
||||
AiMessageControlButton {
|
||||
id: toggleMarkdownButton
|
||||
activated: !root.renderMarkdown
|
||||
buttonIcon: "code"
|
||||
onClicked: {
|
||||
root.renderMarkdown = !root.renderMarkdown
|
||||
}
|
||||
StyledToolTip {
|
||||
content: Translation.tr("View Markdown source")
|
||||
}
|
||||
}
|
||||
AiMessageControlButton {
|
||||
id: deleteButton
|
||||
buttonIcon: "close"
|
||||
onClicked: {
|
||||
Ai.removeMessage(root.messageIndex)
|
||||
}
|
||||
StyledToolTip {
|
||||
content: Translation.tr("Delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
Layout.fillWidth: true
|
||||
active: root.messageData?.localFilePath && root.messageData?.localFilePath.length > 0
|
||||
sourceComponent: AttachedFileIndicator {
|
||||
filePath: root.messageData?.localFilePath
|
||||
canRemove: false
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout { // Message content
|
||||
id: messageContentColumnLayout
|
||||
|
||||
spacing: 0
|
||||
Repeater {
|
||||
model: root.messageBlocks.length
|
||||
delegate: Loader {
|
||||
required property int index
|
||||
property var thisBlock: root.messageBlocks[index]
|
||||
Layout.fillWidth: true
|
||||
// property var segment: thisBlock
|
||||
property var segmentContent: thisBlock.content
|
||||
property var segmentLang: thisBlock.lang
|
||||
property var messageData: root.messageData
|
||||
property var editing: root.editing
|
||||
property var renderMarkdown: root.renderMarkdown
|
||||
property var enableMouseSelection: root.enableMouseSelection
|
||||
property bool thinking: root.messageData?.thinking ?? true
|
||||
property bool done: root.messageData?.done ?? false
|
||||
property bool completed: thisBlock.completed ?? false
|
||||
|
||||
source: thisBlock.type === "code" ? "MessageCodeBlock.qml" :
|
||||
thisBlock.type === "think" ? "MessageThinkBlock.qml" :
|
||||
"MessageTextBlock.qml"
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flow { // Annotations
|
||||
visible: root.messageData?.annotationSources?.length > 0
|
||||
spacing: 5
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignLeft
|
||||
|
||||
Repeater {
|
||||
model: ScriptModel {
|
||||
values: root.messageData?.annotationSources || []
|
||||
}
|
||||
delegate: AnnotationSourceButton {
|
||||
required property var modelData
|
||||
displayText: modelData.text
|
||||
url: modelData.url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flow { // Search queries
|
||||
visible: root.messageData?.searchQueries?.length > 0
|
||||
spacing: 5
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignLeft
|
||||
|
||||
Repeater {
|
||||
model: ScriptModel {
|
||||
values: root.messageData?.searchQueries || []
|
||||
}
|
||||
delegate: SearchQueryButton {
|
||||
required property var modelData
|
||||
query: modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
26
ii/modules/sidebarLeft/aiChat/AiMessageControlButton.qml
Normal file
26
ii/modules/sidebarLeft/aiChat/AiMessageControlButton.qml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.services
|
||||
import QtQuick
|
||||
|
||||
GroupButton {
|
||||
id: button
|
||||
property string buttonIcon
|
||||
property bool activated: false
|
||||
toggled: activated
|
||||
|
||||
baseWidth: height
|
||||
|
||||
contentItem: MaterialSymbol {
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
iconSize: Appearance.font.pixelSize.larger
|
||||
text: buttonIcon
|
||||
color: button.activated ? Appearance.m3colors.m3onPrimary :
|
||||
button.enabled ? Appearance.m3colors.m3onSurface :
|
||||
Appearance.colors.colOnLayer1Inactive
|
||||
|
||||
Behavior on color {
|
||||
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
ii/modules/sidebarLeft/aiChat/AnnotationSourceButton.qml
Normal file
52
ii/modules/sidebarLeft/aiChat/AnnotationSourceButton.qml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.modules.common.functions
|
||||
import qs.services
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell.Hyprland
|
||||
|
||||
RippleButton {
|
||||
id: root
|
||||
property string displayText
|
||||
property string url
|
||||
|
||||
property real faviconSize: 20
|
||||
implicitHeight: 30
|
||||
leftPadding: (implicitHeight - faviconSize) / 2
|
||||
rightPadding: 10
|
||||
buttonRadius: Appearance.rounding.full
|
||||
colBackground: Appearance.colors.colSurfaceContainerHighest
|
||||
colBackgroundHover: Appearance.colors.colSurfaceContainerHighestHover
|
||||
colRipple: Appearance.colors.colSurfaceContainerHighestActive
|
||||
|
||||
PointingHandInteraction {}
|
||||
onClicked: {
|
||||
if (url) {
|
||||
Qt.openUrlExternally(url)
|
||||
GlobalStates.sidebarLeftOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Item {
|
||||
anchors.centerIn: parent
|
||||
implicitWidth: rowLayout.implicitWidth
|
||||
implicitHeight: rowLayout.implicitHeight
|
||||
RowLayout {
|
||||
id: rowLayout
|
||||
anchors.fill: parent
|
||||
spacing: 5
|
||||
Favicon {
|
||||
url: root.url
|
||||
size: root.faviconSize
|
||||
displayText: root.displayText
|
||||
}
|
||||
StyledText {
|
||||
id: text
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: displayText
|
||||
color: Appearance.m3colors.m3onSurface
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
160
ii/modules/sidebarLeft/aiChat/AttachedFileIndicator.qml
Normal file
160
ii/modules/sidebarLeft/aiChat/AttachedFileIndicator.qml
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Qt5Compat.GraphicalEffects
|
||||
import Quickshell.Io
|
||||
import qs
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.services
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
signal remove()
|
||||
property bool canRemove: true
|
||||
property string filePath: ""
|
||||
property string mimeType: ""
|
||||
property real maxHeight: 200
|
||||
property real imageWidth: -1
|
||||
property real imageHeight: -1
|
||||
property real scale: Math.min(root.maxHeight / imageHeight, root.width / imageWidth)
|
||||
onFilePathChanged: refresh()
|
||||
visible: filePath !== ""
|
||||
|
||||
function refresh() {
|
||||
root.mimeType = "";
|
||||
root.imageWidth = -1;
|
||||
root.imageHeight = -1;
|
||||
fileTypeProc.exec(["file", "-b", "--mime-type", filePath]);
|
||||
}
|
||||
|
||||
Process {
|
||||
id: fileTypeProc
|
||||
command: ["file", "-b", "--mime-type", filePath]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.mimeType = this.text;
|
||||
if (root.mimeType.startsWith("image/"))
|
||||
imageSizeProc.exec(["identify", "-format", "%wx%h", filePath]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: imageSizeProc
|
||||
command: ["identify", "-format", "%wx%h", filePath]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const dimensions = this.text.split("x");
|
||||
root.imageWidth = parseInt(dimensions[0]);
|
||||
root.imageHeight = parseInt(dimensions[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Styles/widgets
|
||||
property real horizontalPadding: 10
|
||||
property real verticalPadding: 10
|
||||
radius: Appearance.rounding.small - anchors.margins
|
||||
color: Appearance.colors.colLayer2
|
||||
implicitHeight: visible ? (contentItem.implicitHeight + verticalPadding * 2) : 0
|
||||
|
||||
ColumnLayout {
|
||||
id: contentItem
|
||||
anchors {
|
||||
fill: parent
|
||||
leftMargin: root.horizontalPadding
|
||||
rightMargin: root.horizontalPadding
|
||||
topMargin: root.verticalPadding
|
||||
bottomMargin: root.verticalPadding
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
MaterialSymbol {
|
||||
Layout.alignment: Qt.AlignTop
|
||||
text: {
|
||||
if (root.mimeType.startsWith("image/"))
|
||||
return "image";
|
||||
if (root.mimeType.startsWith("audio/"))
|
||||
return "music_note";
|
||||
if (root.mimeType.startsWith("video/"))
|
||||
return "movie";
|
||||
if (root.mimeType === "application/pdf")
|
||||
return "picture_as_pdf";
|
||||
if (root.mimeType.startsWith("text/"))
|
||||
return "description";
|
||||
return "file_present";
|
||||
}
|
||||
iconSize: Appearance.font.pixelSize.hugeass
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 4
|
||||
text: root.filePath
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
font.family: Appearance.font.family.monospace
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
RippleButton {
|
||||
visible: root.canRemove
|
||||
Layout.alignment: Qt.AlignTop
|
||||
buttonRadius: Appearance.rounding.full
|
||||
colBackground: Appearance.colors.colLayer2
|
||||
implicitHeight: 28
|
||||
implicitWidth: 28
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
text: "close"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
iconSize: Appearance.font.pixelSize.larger
|
||||
color: Appearance.colors.colOnSurfaceVariant
|
||||
}
|
||||
|
||||
onClicked: root.remove()
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: imagePreviewLoader
|
||||
visible: (root.imageWidth != -1) && (root.imageHeight != -1)
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
sourceComponent: Item {
|
||||
implicitHeight: root.imageHeight * root.scale
|
||||
implicitWidth: imagePreview.implicitWidth
|
||||
Image {
|
||||
id: imagePreview
|
||||
anchors.fill: parent
|
||||
source: Qt.resolvedUrl(root.filePath)
|
||||
fillMode: Image.PreserveAspectFit
|
||||
antialiasing: true
|
||||
asynchronous: true
|
||||
width: root.imageWidth * root.scale
|
||||
height: root.imageHeight * root.scale
|
||||
sourceSize.width: root.imageWidth * root.scale
|
||||
sourceSize.height: root.imageHeight * root.scale
|
||||
|
||||
layer.enabled: true
|
||||
layer.effect: OpacityMask {
|
||||
maskSource: Rectangle {
|
||||
width: imagePreview.width
|
||||
height: imagePreview.height
|
||||
radius: Appearance.rounding.normal
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "transparent"
|
||||
border.width: 1
|
||||
border.color: Appearance.colors.colOutlineVariant
|
||||
radius: Appearance.rounding.normal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
297
ii/modules/sidebarLeft/aiChat/MessageCodeBlock.qml
Normal file
297
ii/modules/sidebarLeft/aiChat/MessageCodeBlock.qml
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import org.kde.syntaxhighlighting
|
||||
|
||||
ColumnLayout {
|
||||
id: root
|
||||
// These are needed on the parent loader
|
||||
property bool editing: parent?.editing ?? false
|
||||
property bool renderMarkdown: parent?.renderMarkdown ?? true
|
||||
property bool enableMouseSelection: parent?.enableMouseSelection ?? false
|
||||
property var segmentContent: parent?.segmentContent ?? ({})
|
||||
property var segmentLang: parent?.segmentLang ?? "txt"
|
||||
property bool isCommandRequest: segmentLang === "command"
|
||||
property var displayLang: (isCommandRequest ? "bash" : segmentLang)
|
||||
property var messageData: parent?.messageData ?? {}
|
||||
|
||||
property real codeBlockBackgroundRounding: Appearance.rounding.small
|
||||
property real codeBlockHeaderPadding: 3
|
||||
property real codeBlockComponentSpacing: 2
|
||||
|
||||
spacing: codeBlockComponentSpacing
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
|
||||
Rectangle { // Code background
|
||||
Layout.fillWidth: true
|
||||
topLeftRadius: codeBlockBackgroundRounding
|
||||
topRightRadius: codeBlockBackgroundRounding
|
||||
bottomLeftRadius: Appearance.rounding.unsharpen
|
||||
bottomRightRadius: Appearance.rounding.unsharpen
|
||||
color: Appearance.colors.colSurfaceContainerHighest
|
||||
implicitHeight: codeBlockTitleBarRowLayout.implicitHeight + codeBlockHeaderPadding * 2
|
||||
|
||||
RowLayout { // Language and buttons
|
||||
id: codeBlockTitleBarRowLayout
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: codeBlockHeaderPadding
|
||||
anchors.rightMargin: codeBlockHeaderPadding
|
||||
spacing: 5
|
||||
|
||||
StyledText {
|
||||
id: codeBlockLanguage
|
||||
Layout.alignment: Qt.AlignLeft
|
||||
Layout.fillWidth: false
|
||||
Layout.topMargin: 7
|
||||
Layout.bottomMargin: 7
|
||||
Layout.leftMargin: 10
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
font.weight: Font.DemiBold
|
||||
color: Appearance.colors.colOnLayer2
|
||||
text: root.displayLang ? Repository.definitionForName(root.displayLang).name : "plain"
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
ButtonGroup {
|
||||
AiMessageControlButton {
|
||||
id: copyCodeButton
|
||||
buttonIcon: activated ? "inventory" : "content_copy"
|
||||
|
||||
onClicked: {
|
||||
Quickshell.clipboardText = segmentContent
|
||||
copyCodeButton.activated = true
|
||||
copyIconTimer.restart()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: copyIconTimer
|
||||
interval: 1500
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
copyCodeButton.activated = false
|
||||
}
|
||||
}
|
||||
StyledToolTip {
|
||||
content: Translation.tr("Copy code")
|
||||
}
|
||||
}
|
||||
AiMessageControlButton {
|
||||
id: saveCodeButton
|
||||
buttonIcon: activated ? "check" : "save"
|
||||
|
||||
onClicked: {
|
||||
const downloadPath = FileUtils.trimFileProtocol(Directories.downloads)
|
||||
Quickshell.execDetached(["bash", "-c",
|
||||
`echo '${StringUtils.shellSingleQuoteEscape(segmentContent)}' > '${downloadPath}/code.${segmentLang || "txt"}'`
|
||||
])
|
||||
Quickshell.execDetached(["notify-send",
|
||||
Translation.tr("Code saved to file"),
|
||||
Translation.tr("Saved to %1").arg(`${downloadPath}/code.${segmentLang || "txt"}`),
|
||||
"-a", "Shell"
|
||||
])
|
||||
saveCodeButton.activated = true
|
||||
saveIconTimer.restart()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: saveIconTimer
|
||||
interval: 1500
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
saveCodeButton.activated = false
|
||||
}
|
||||
}
|
||||
StyledToolTip {
|
||||
content: Translation.tr("Save to Downloads")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout { // Line numbers and code
|
||||
spacing: codeBlockComponentSpacing
|
||||
|
||||
Rectangle { // Line numbers
|
||||
implicitWidth: 40
|
||||
implicitHeight: lineNumberColumnLayout.implicitHeight
|
||||
Layout.fillHeight: true
|
||||
Layout.fillWidth: false
|
||||
topLeftRadius: Appearance.rounding.unsharpen
|
||||
bottomLeftRadius: codeBlockBackgroundRounding
|
||||
topRightRadius: Appearance.rounding.unsharpen
|
||||
bottomRightRadius: Appearance.rounding.unsharpen
|
||||
color: Appearance.colors.colLayer2
|
||||
|
||||
ColumnLayout {
|
||||
id: lineNumberColumnLayout
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
rightMargin: 5
|
||||
top: parent.top
|
||||
topMargin: 6
|
||||
}
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: codeTextArea.text.split("\n").length
|
||||
Text {
|
||||
required property int index
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignRight
|
||||
font.family: Appearance.font.family.monospace
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
color: Appearance.colors.colSubtext
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: index + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { // Code background
|
||||
Layout.fillWidth: true
|
||||
topLeftRadius: Appearance.rounding.unsharpen
|
||||
bottomLeftRadius: Appearance.rounding.unsharpen
|
||||
topRightRadius: Appearance.rounding.unsharpen
|
||||
bottomRightRadius: codeBlockBackgroundRounding
|
||||
color: Appearance.colors.colLayer2
|
||||
implicitHeight: codeColumnLayout.implicitHeight
|
||||
|
||||
ColumnLayout {
|
||||
id: codeColumnLayout
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
ScrollView {
|
||||
id: codeScrollView
|
||||
Layout.fillWidth: true
|
||||
// Layout.fillHeight: true
|
||||
implicitWidth: parent.width
|
||||
implicitHeight: codeTextArea.implicitHeight + 1
|
||||
contentWidth: codeTextArea.width - 1
|
||||
// contentHeight: codeTextArea.contentHeight
|
||||
clip: true
|
||||
ScrollBar.vertical.policy: ScrollBar.AlwaysOff
|
||||
|
||||
ScrollBar.horizontal: ScrollBar {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
padding: 5
|
||||
policy: ScrollBar.AsNeeded
|
||||
opacity: visualSize == 1 ? 0 : 1
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Appearance.animation.elementMoveFast.duration
|
||||
easing.type: Appearance.animation.elementMoveFast.type
|
||||
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Rectangle {
|
||||
implicitHeight: 6
|
||||
radius: Appearance.rounding.small
|
||||
color: Appearance.colors.colLayer2Active
|
||||
}
|
||||
}
|
||||
|
||||
TextArea { // Code
|
||||
id: codeTextArea
|
||||
Layout.fillWidth: true
|
||||
readOnly: !editing
|
||||
selectByMouse: enableMouseSelection || editing
|
||||
renderType: Text.NativeRendering
|
||||
font.family: Appearance.font.family.monospace
|
||||
font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
|
||||
selectionColor: Appearance.colors.colSecondaryContainer
|
||||
// wrapMode: TextEdit.Wrap
|
||||
color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1
|
||||
|
||||
text: segmentContent
|
||||
onTextChanged: {
|
||||
segmentContent = text
|
||||
}
|
||||
|
||||
Keys.onPressed: (event) => {
|
||||
if (event.key === Qt.Key_Tab) {
|
||||
// Insert 4 spaces at cursor
|
||||
const cursor = codeTextArea.cursorPosition;
|
||||
codeTextArea.insert(cursor, " ");
|
||||
codeTextArea.cursorPosition = cursor + 4;
|
||||
event.accepted = true;
|
||||
} else if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) {
|
||||
codeTextArea.copy();
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
SyntaxHighlighter {
|
||||
id: highlighter
|
||||
textEdit: codeTextArea
|
||||
repository: Repository
|
||||
definition: Repository.definitionForName(root.displayLang || "plaintext")
|
||||
theme: Appearance.syntaxHighlightingTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
Loader {
|
||||
active: root.isCommandRequest && root.messageData.functionPending
|
||||
visible: active
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 6
|
||||
Layout.topMargin: 0
|
||||
sourceComponent: RowLayout {
|
||||
Item { Layout.fillWidth: true }
|
||||
ButtonGroup {
|
||||
GroupButton {
|
||||
contentItem: StyledText {
|
||||
text: Translation.tr("Reject")
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
color: Appearance.colors.colOnLayer2
|
||||
}
|
||||
onClicked: Ai.rejectCommand(root.messageData)
|
||||
}
|
||||
GroupButton {
|
||||
toggled: true
|
||||
contentItem: StyledText {
|
||||
text: Translation.tr("Approve")
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
color: Appearance.colors.colOnPrimary
|
||||
}
|
||||
onClicked: Ai.approveCommand(root.messageData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MouseArea to block scrolling
|
||||
// MouseArea {
|
||||
// id: codeBlockMouseArea
|
||||
// anchors.fill: parent
|
||||
// acceptedButtons: editing ? Qt.NoButton : Qt.LeftButton
|
||||
// cursorShape: (enableMouseSelection || editing) ? Qt.IBeamCursor : Qt.ArrowCursor
|
||||
// onWheel: (event) => {
|
||||
// event.accepted = false
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
142
ii/modules/sidebarLeft/aiChat/MessageTextBlock.qml
Normal file
142
ii/modules/sidebarLeft/aiChat/MessageTextBlock.qml
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell.Hyprland
|
||||
|
||||
ColumnLayout {
|
||||
id: root
|
||||
// These are needed on the parent loader
|
||||
property bool editing: parent?.editing ?? false
|
||||
property bool renderMarkdown: parent?.renderMarkdown ?? true
|
||||
property bool enableMouseSelection: parent?.enableMouseSelection ?? false
|
||||
property string segmentContent: parent?.segmentContent ?? ({})
|
||||
property var messageData: parent?.messageData ?? {}
|
||||
property bool done: parent?.done ?? true
|
||||
property list<string> renderedLatexHashes: []
|
||||
|
||||
property string renderedSegmentContent: ""
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
Timer {
|
||||
id: renderTimer
|
||||
interval: 1000
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
renderLatex()
|
||||
for (const hash of renderedLatexHashes) {
|
||||
handleRenderedLatex(hash, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderLatex() {
|
||||
// Regex for $...$, $$...$$, \[...\]
|
||||
// Note: This is a simple approach and may need refinement for edge cases
|
||||
let regex = /(\$\$([\s\S]+?)\$\$)|(\$([^\$]+?)\$)|(\\\[((?:.|\n)+?)\\\])|(\\\(([\s\S]+?)\\\))/g;
|
||||
let match;
|
||||
while ((match = regex.exec(segmentContent)) !== null) {
|
||||
let expression = match[1] || match[2] || match[3] || match[4] || match[5] || match[6] || match[7] || match[8];
|
||||
if (expression) {
|
||||
Qt.callLater(() => {
|
||||
const [renderHash, isNew] = LatexRenderer.requestRender(expression.trim());
|
||||
if (!renderedLatexHashes.includes(renderHash)) {
|
||||
renderedLatexHashes.push(renderHash);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleRenderedLatex(hash, force = false) {
|
||||
if (renderedLatexHashes.includes(hash) || force) {
|
||||
const imagePath = LatexRenderer.renderedImagePaths[hash];
|
||||
const markdownImage = ``;
|
||||
|
||||
const expression = LatexRenderer.processedExpressions[hash];
|
||||
renderedSegmentContent = renderedSegmentContent.replace(expression, markdownImage);
|
||||
}
|
||||
}
|
||||
|
||||
onDoneChanged: {
|
||||
renderTimer.restart();
|
||||
}
|
||||
onEditingChanged: {
|
||||
if (!editing) {
|
||||
renderLatex()
|
||||
} else {
|
||||
// console.log("Editing mode enabled", segmentContent)
|
||||
textArea.text = segmentContent
|
||||
}
|
||||
}
|
||||
|
||||
onSegmentContentChanged: {
|
||||
// console.log("Segment content changed: " + segmentContent);
|
||||
renderedSegmentContent = segmentContent;
|
||||
if (!root.editing && segmentContent) {
|
||||
root.renderLatex();
|
||||
}
|
||||
}
|
||||
|
||||
onRenderedSegmentContentChanged: {
|
||||
// console.log("Rendered segment content changed: " + renderedSegmentContent);
|
||||
if (renderedSegmentContent) {
|
||||
textArea.text = renderedSegmentContent;
|
||||
}
|
||||
}
|
||||
|
||||
// When something finishes rendering
|
||||
// 1. Check if the hash is in the list
|
||||
// 2. If it is, replace the expression with the image path
|
||||
Connections {
|
||||
target: LatexRenderer
|
||||
function onRenderFinished(hash, imagePath) {
|
||||
const expression = LatexRenderer.processedExpressions[hash];
|
||||
// console.log("Render finished: " + hash + " " + expression);
|
||||
handleRenderedLatex(hash);
|
||||
}
|
||||
}
|
||||
|
||||
TextArea {
|
||||
id: textArea
|
||||
|
||||
Layout.fillWidth: true
|
||||
readOnly: !editing
|
||||
selectByMouse: enableMouseSelection || editing
|
||||
renderType: Text.NativeRendering
|
||||
font.family: Appearance.font.family.reading
|
||||
font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
|
||||
selectionColor: Appearance.colors.colSecondaryContainer
|
||||
wrapMode: TextEdit.Wrap
|
||||
color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1
|
||||
textFormat: renderMarkdown ? TextEdit.MarkdownText : TextEdit.PlainText
|
||||
text: Translation.tr("Waiting for response...")
|
||||
|
||||
onTextChanged: {
|
||||
if (!root.editing) return
|
||||
segmentContent = text
|
||||
}
|
||||
|
||||
onLinkActivated: (link) => {
|
||||
Qt.openUrlExternally(link)
|
||||
GlobalStates.sidebarLeftOpen = false
|
||||
}
|
||||
|
||||
MouseArea { // Pointing hand for links
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.NoButton // Only for hover
|
||||
hoverEnabled: true
|
||||
cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor :
|
||||
(enableMouseSelection || editing) ? Qt.IBeamCursor : Qt.ArrowCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
173
ii/modules/sidebarLeft/aiChat/MessageThinkBlock.qml
Normal file
173
ii/modules/sidebarLeft/aiChat/MessageThinkBlock.qml
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Qt5Compat.GraphicalEffects
|
||||
|
||||
Item {
|
||||
id: root
|
||||
// These are needed on the parent loader
|
||||
property bool editing: parent?.editing ?? false
|
||||
property bool renderMarkdown: parent?.renderMarkdown ?? true
|
||||
property bool enableMouseSelection: parent?.enableMouseSelection ?? false
|
||||
property string segmentContent: parent?.segmentContent ?? ({})
|
||||
property var messageData: parent?.messageData ?? {}
|
||||
property bool done: parent?.done ?? true
|
||||
property bool completed: parent?.completed ?? false
|
||||
|
||||
property real thinkBlockBackgroundRounding: Appearance.rounding.small
|
||||
property real thinkBlockHeaderPaddingVertical: 3
|
||||
property real thinkBlockHeaderPaddingHorizontal: 10
|
||||
property real thinkBlockComponentSpacing: 2
|
||||
|
||||
property var collapseAnimation: messageTextBlock.implicitHeight > 40 ? Appearance.animation.elementMoveEnter : Appearance.animation.elementMoveFast
|
||||
property bool collapsed: true /* should be root.completed but its kinda buggy rn so nope */
|
||||
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: collapsed ? header.implicitHeight : columnLayout.implicitHeight
|
||||
layer.enabled: true
|
||||
layer.effect: OpacityMask {
|
||||
maskSource: Rectangle {
|
||||
width: root.width
|
||||
height: root.height
|
||||
radius: thinkBlockBackgroundRounding
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on implicitHeight {
|
||||
enabled: root.completed ?? false
|
||||
NumberAnimation {
|
||||
duration: collapseAnimation.duration
|
||||
easing.type: collapseAnimation.type
|
||||
easing.bezierCurve: collapseAnimation.bezierCurve
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: columnLayout
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
spacing: 0
|
||||
|
||||
Rectangle { // Header background
|
||||
id: header
|
||||
color: Appearance.colors.colSurfaceContainerHighest
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: thinkBlockTitleBarRowLayout.implicitHeight + thinkBlockHeaderPaddingVertical * 2
|
||||
|
||||
MouseArea { // Click to reveal
|
||||
id: headerMouseArea
|
||||
enabled: root.completed
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
root.collapsed = !root.collapsed
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout { // Header content
|
||||
id: thinkBlockTitleBarRowLayout
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: thinkBlockHeaderPaddingHorizontal
|
||||
anchors.rightMargin: thinkBlockHeaderPaddingHorizontal
|
||||
spacing: 10
|
||||
|
||||
MaterialSymbol {
|
||||
Layout.fillWidth: false
|
||||
Layout.topMargin: 7
|
||||
Layout.bottomMargin: 7
|
||||
Layout.leftMargin: 3
|
||||
text: "linked_services"
|
||||
}
|
||||
StyledText {
|
||||
id: thinkBlockLanguage
|
||||
Layout.fillWidth: false
|
||||
Layout.alignment: Qt.AlignLeft
|
||||
text: root.completed ? Translation.tr("Thought") : (Translation.tr("Thinking") + ".".repeat(Math.random() * 4))
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
RippleButton { // Expand button
|
||||
id: expandButton
|
||||
visible: root.completed
|
||||
implicitWidth: 22
|
||||
implicitHeight: 22
|
||||
colBackground: headerMouseArea.containsMouse ? Appearance.colors.colLayer2Hover
|
||||
: ColorUtils.transparentize(Appearance.colors.colLayer2, 1)
|
||||
colBackgroundHover: Appearance.colors.colLayer2Hover
|
||||
colRipple: Appearance.colors.colLayer2Active
|
||||
|
||||
onClicked: { root.collapsed = !root.collapsed }
|
||||
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
text: "keyboard_arrow_down"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.colors.colOnLayer2
|
||||
rotation: root.collapsed ? 0 : 180
|
||||
Behavior on rotation {
|
||||
NumberAnimation {
|
||||
duration: Appearance.animation.elementMoveFast.duration
|
||||
easing.type: Appearance.animation.elementMoveFast.type
|
||||
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Item {
|
||||
id: content
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: collapsed ? 0 : contentBackground.implicitHeight + thinkBlockComponentSpacing
|
||||
clip: true
|
||||
|
||||
Behavior on implicitHeight {
|
||||
enabled: root.completed ?? false
|
||||
NumberAnimation {
|
||||
duration: collapseAnimation.duration
|
||||
easing.type: collapseAnimation.type
|
||||
easing.bezierCurve: collapseAnimation.bezierCurve
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: contentBackground
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
implicitHeight: messageTextBlock.implicitHeight
|
||||
color: Appearance.colors.colLayer2
|
||||
|
||||
// Load data for the message at the correct scope
|
||||
property bool editing: root.editing
|
||||
property bool renderMarkdown: root.renderMarkdown
|
||||
property bool enableMouseSelection: root.enableMouseSelection
|
||||
property string segmentContent: root.segmentContent
|
||||
property var messageData: root.messageData
|
||||
property bool done: root.done
|
||||
|
||||
MessageTextBlock {
|
||||
id: messageTextBlock
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
53
ii/modules/sidebarLeft/aiChat/SearchQueryButton.qml
Normal file
53
ii/modules/sidebarLeft/aiChat/SearchQueryButton.qml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import qs
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.services
|
||||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell.Hyprland
|
||||
|
||||
RippleButton {
|
||||
id: root
|
||||
property string query
|
||||
|
||||
implicitHeight: 30
|
||||
leftPadding: 6
|
||||
rightPadding: 10
|
||||
buttonRadius: Appearance.rounding.verysmall
|
||||
colBackground: Appearance.colors.colSurfaceContainerHighest
|
||||
colBackgroundHover: Appearance.colors.colSurfaceContainerHighestHover
|
||||
colRipple: Appearance.colors.colSurfaceContainerHighestActive
|
||||
|
||||
PointingHandInteraction {}
|
||||
onClicked: {
|
||||
let url = Config.options.search.engineBaseUrl + root.query;
|
||||
for (let site of (Config?.options?.search.excludedSites ?? [])) {
|
||||
url += ` -site:${site}`;
|
||||
}
|
||||
Qt.openUrlExternally(url);
|
||||
GlobalStates.sidebarLeftOpen = false;
|
||||
}
|
||||
|
||||
contentItem: Item {
|
||||
anchors.centerIn: parent
|
||||
implicitWidth: rowLayout.implicitWidth
|
||||
implicitHeight: rowLayout.implicitHeight
|
||||
RowLayout {
|
||||
id: rowLayout
|
||||
anchors.centerIn: parent
|
||||
spacing: 5
|
||||
MaterialSymbol {
|
||||
text: "search"
|
||||
iconSize: 20
|
||||
color: Appearance.m3colors.m3onSurface
|
||||
}
|
||||
StyledText {
|
||||
id: text
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: root.query
|
||||
color: Appearance.m3colors.m3onSurface
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
190
ii/modules/sidebarLeft/anime/BooruImage.qml
Normal file
190
ii/modules/sidebarLeft/anime/BooruImage.qml
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import qs
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.modules.common.functions
|
||||
import QtQml
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Qt5Compat.GraphicalEffects
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Hyprland
|
||||
|
||||
Button {
|
||||
id: root
|
||||
property var imageData
|
||||
property var rowHeight
|
||||
property bool manualDownload: false
|
||||
property string previewDownloadPath
|
||||
property string downloadPath
|
||||
property string nsfwPath
|
||||
property string fileName: decodeURIComponent((imageData.file_url).substring((imageData.file_url).lastIndexOf('/') + 1))
|
||||
property string filePath: `${root.previewDownloadPath}/${root.fileName}`
|
||||
property int maxTagStringLineLength: 50
|
||||
property real imageRadius: Appearance.rounding.small
|
||||
|
||||
property bool showActions: false
|
||||
Process {
|
||||
id: downloadProcess
|
||||
running: false
|
||||
command: ["bash", "-c", `[ -f ${root.filePath} ] || curl -sSL '${root.imageData.preview_url ?? root.imageData.sample_url}' -o '${root.filePath}'`]
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
imageObject.source = `${previewDownloadPath}/${root.fileName}`
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (root.manualDownload) {
|
||||
downloadProcess.running = true
|
||||
}
|
||||
}
|
||||
|
||||
StyledToolTip {
|
||||
content: `${StringUtils.wordWrap(root.imageData.tags, root.maxTagStringLineLength)}`
|
||||
}
|
||||
|
||||
padding: 0
|
||||
implicitWidth: root.rowHeight * modelData.aspect_ratio
|
||||
implicitHeight: root.rowHeight
|
||||
|
||||
background: Rectangle {
|
||||
implicitWidth: root.rowHeight * modelData.aspect_ratio
|
||||
implicitHeight: root.rowHeight
|
||||
radius: imageRadius
|
||||
color: Appearance.colors.colLayer2
|
||||
}
|
||||
|
||||
contentItem: Item {
|
||||
anchors.fill: parent
|
||||
|
||||
Image {
|
||||
id: imageObject
|
||||
anchors.fill: parent
|
||||
width: root.rowHeight * modelData.aspect_ratio
|
||||
height: root.rowHeight
|
||||
fillMode: Image.PreserveAspectFit
|
||||
source: modelData.preview_url
|
||||
sourceSize.width: root.rowHeight * modelData.aspect_ratio
|
||||
sourceSize.height: root.rowHeight
|
||||
|
||||
visible: opacity > 0
|
||||
opacity: status === Image.Ready ? 1 : 0
|
||||
Behavior on opacity {
|
||||
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
|
||||
}
|
||||
|
||||
layer.enabled: true
|
||||
layer.effect: OpacityMask {
|
||||
maskSource: Rectangle {
|
||||
width: root.rowHeight * modelData.aspect_ratio
|
||||
height: root.rowHeight
|
||||
radius: imageRadius
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RippleButton {
|
||||
id: menuButton
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
property real buttonSize: 30
|
||||
anchors.margins: Math.max(root.imageRadius - buttonSize / 2, 8)
|
||||
implicitHeight: buttonSize
|
||||
implicitWidth: buttonSize
|
||||
|
||||
buttonRadius: Appearance.rounding.full
|
||||
colBackground: ColorUtils.transparentize(Appearance.m3colors.m3surface, 0.3)
|
||||
colBackgroundHover: ColorUtils.transparentize(ColorUtils.mix(Appearance.m3colors.m3surface, Appearance.m3colors.m3onSurface, 0.8), 0.2)
|
||||
colRipple: ColorUtils.transparentize(ColorUtils.mix(Appearance.m3colors.m3surface, Appearance.m3colors.m3onSurface, 0.6), 0.1)
|
||||
|
||||
contentItem: MaterialSymbol {
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
iconSize: Appearance.font.pixelSize.large
|
||||
color: Appearance.m3colors.m3onSurface
|
||||
text: "more_vert"
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
root.showActions = !root.showActions
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: contextMenuLoader
|
||||
active: root.showActions
|
||||
anchors.top: menuButton.bottom
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 8
|
||||
|
||||
sourceComponent: Item {
|
||||
width: contextMenu.width
|
||||
height: contextMenu.height
|
||||
|
||||
StyledRectangularShadow {
|
||||
target: contextMenu
|
||||
}
|
||||
Rectangle {
|
||||
id: contextMenu
|
||||
anchors.centerIn: parent
|
||||
opacity: root.showActions ? 1 : 0
|
||||
visible: opacity > 0
|
||||
radius: Appearance.rounding.small
|
||||
color: Appearance.colors.colSurfaceContainer
|
||||
implicitHeight: contextMenuColumnLayout.implicitHeight + radius * 2
|
||||
implicitWidth: contextMenuColumnLayout.implicitWidth
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Appearance.animation.elementMoveFast.duration
|
||||
easing.type: Appearance.animation.elementMoveFast.type
|
||||
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: contextMenuColumnLayout
|
||||
anchors.centerIn: parent
|
||||
spacing: 0
|
||||
|
||||
MenuButton {
|
||||
id: openFileLinkButton
|
||||
Layout.fillWidth: true
|
||||
buttonText: Translation.tr("Open file link")
|
||||
onClicked: {
|
||||
root.showActions = false
|
||||
Hyprland.dispatch("keyword cursor:no_warps true")
|
||||
Qt.openUrlExternally(root.imageData.file_url)
|
||||
Hyprland.dispatch("keyword cursor:no_warps false")
|
||||
}
|
||||
}
|
||||
MenuButton {
|
||||
id: sourceButton
|
||||
visible: root.imageData.source && root.imageData.source.length > 0
|
||||
Layout.fillWidth: true
|
||||
buttonText: Translation.tr("Go to source (%1)").arg(StringUtils.getDomain(root.imageData.source))
|
||||
enabled: root.imageData.source && root.imageData.source.length > 0
|
||||
onClicked: {
|
||||
root.showActions = false
|
||||
Hyprland.dispatch("keyword cursor:no_warps true")
|
||||
Qt.openUrlExternally(root.imageData.source)
|
||||
Hyprland.dispatch("keyword cursor:no_warps false")
|
||||
}
|
||||
}
|
||||
MenuButton {
|
||||
id: downloadButton
|
||||
Layout.fillWidth: true
|
||||
buttonText: Translation.tr("Download")
|
||||
onClicked: {
|
||||
root.showActions = false
|
||||
Quickshell.execDetached(["bash", "-c",
|
||||
`curl '${root.imageData.file_url}' -o '${root.imageData.is_nsfw ? root.nsfwPath : root.downloadPath}/${root.fileName}' && notify-send '${Translation.tr("Download complete")}' '${root.downloadPath}/${root.fileName}' -a 'Shell'`
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
294
ii/modules/sidebarLeft/anime/BooruResponse.qml
Normal file
294
ii/modules/sidebarLeft/anime/BooruResponse.qml
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.modules.common.functions
|
||||
import "../"
|
||||
import qs.services
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Qt5Compat.GraphicalEffects
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
property var responseData
|
||||
property var tagInputField
|
||||
|
||||
property string previewDownloadPath
|
||||
property string downloadPath
|
||||
property string nsfwPath
|
||||
|
||||
property real availableWidth: parent.width
|
||||
property real rowTooShortThreshold: 190
|
||||
property real imageSpacing: 5
|
||||
property real responsePadding: 5
|
||||
|
||||
anchors.left: parent?.left
|
||||
anchors.right: parent?.right
|
||||
implicitHeight: columnLayout.implicitHeight + root.responsePadding * 2
|
||||
|
||||
Component.onCompleted: {
|
||||
// Break property bind to prevent aggressive updates
|
||||
availableWidth = parent.width
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: parent
|
||||
function onWidthChanged() {
|
||||
updateWidthTimer.restart()
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: updateWidthTimer
|
||||
interval: 100
|
||||
onTriggered: {
|
||||
availableWidth = parent.width
|
||||
}
|
||||
}
|
||||
|
||||
radius: Appearance.rounding.normal
|
||||
color: Appearance.colors.colLayer1
|
||||
|
||||
ColumnLayout {
|
||||
id: columnLayout
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: responsePadding
|
||||
spacing: root.imageSpacing
|
||||
|
||||
RowLayout { // Header
|
||||
Rectangle { // Provider name
|
||||
id: providerNameWrapper
|
||||
color: Appearance.colors.colSecondaryContainer
|
||||
radius: Appearance.rounding.small
|
||||
implicitWidth: providerName.implicitWidth + 10 * 2
|
||||
implicitHeight: Math.max(providerName.implicitHeight + 5 * 2, 30)
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
StyledText {
|
||||
id: providerName
|
||||
anchors.centerIn: parent
|
||||
font.pixelSize: Appearance.font.pixelSize.large
|
||||
color: Appearance.m3colors.m3onSecondaryContainer
|
||||
text: Booru.providers[root.responseData.provider].name
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Item { // Page number
|
||||
visible: root.responseData.page != "" && root.responseData.page > 0
|
||||
implicitWidth: Math.max(pageNumber.implicitWidth + 10 * 2, 30)
|
||||
implicitHeight: pageNumber.implicitHeight + 5 * 2
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
StyledText {
|
||||
id: pageNumber
|
||||
anchors.centerIn: parent
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
color: Appearance.colors.colOnLayer2
|
||||
// text: `Page ${root.responseData.page}`
|
||||
text: Translation.tr("Page %1").arg(root.responseData.page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledFlickable { // Tag strip
|
||||
id: tagsFlickable
|
||||
visible: root.responseData.tags.length > 0
|
||||
Layout.alignment: Qt.AlignLeft
|
||||
Layout.fillWidth: {
|
||||
return true
|
||||
}
|
||||
implicitHeight: tagRowLayout.implicitHeight
|
||||
// height: tagRowLayout.implicitHeight
|
||||
contentWidth: tagRowLayout.implicitWidth
|
||||
|
||||
clip: true
|
||||
layer.enabled: true
|
||||
layer.effect: OpacityMask {
|
||||
maskSource: Rectangle {
|
||||
width: tagsFlickable.width
|
||||
height: tagsFlickable.height
|
||||
radius: Appearance.rounding.small
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on height {
|
||||
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
|
||||
}
|
||||
Behavior on implicitHeight {
|
||||
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: tagRowLayout
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
|
||||
Repeater {
|
||||
id: tagRepeater
|
||||
model: root.responseData.tags
|
||||
|
||||
ApiCommandButton {
|
||||
Layout.fillWidth: false
|
||||
buttonText: modelData
|
||||
onClicked: {
|
||||
if(root.tagInputField.text.length !== 0) root.tagInputField.text += " "
|
||||
root.tagInputField.text += modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
StyledText { // Message
|
||||
id: messageText
|
||||
Layout.fillWidth: true
|
||||
visible: root.responseData.message.length > 0
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
color: Appearance.colors.colOnLayer1
|
||||
text: root.responseData.message
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.margins: responsePadding
|
||||
textFormat: Text.MarkdownText
|
||||
onLinkActivated: (link) => {
|
||||
Qt.openUrlExternally(link)
|
||||
GlobalStates.sidebarLeftOpen = false
|
||||
}
|
||||
PointingHandLinkHover {}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: ScriptModel {
|
||||
values: {
|
||||
// Greedily add images to a row as long as rowHeight >= rowTooShortThreshold
|
||||
let i = 0;
|
||||
let rows = [];
|
||||
const responseList = root.responseData.images;
|
||||
const minRowHeight = rowTooShortThreshold;
|
||||
const availableImageWidth = availableWidth - root.imageSpacing - (responsePadding * 2);
|
||||
|
||||
while (i < responseList.length) {
|
||||
let row = {
|
||||
height: 0,
|
||||
images: [],
|
||||
};
|
||||
let j = i;
|
||||
let combinedAspect = 0;
|
||||
let rowHeight = 0;
|
||||
|
||||
// Try to add as many images as possible without going below minRowHeight
|
||||
while (j < responseList.length) {
|
||||
combinedAspect += responseList[j].aspect_ratio;
|
||||
// Subtract imageSpacing for each gap between images in the row
|
||||
let imagesInRow = j - i + 1;
|
||||
let totalSpacing = root.imageSpacing * (imagesInRow - 1);
|
||||
let rowAvailableWidth = availableImageWidth - totalSpacing;
|
||||
rowHeight = rowAvailableWidth / combinedAspect;
|
||||
if (rowHeight < minRowHeight) {
|
||||
combinedAspect -= responseList[j].aspect_ratio;
|
||||
imagesInRow -= 1;
|
||||
totalSpacing = root.imageSpacing * (imagesInRow - 1);
|
||||
rowAvailableWidth = availableImageWidth - totalSpacing;
|
||||
rowHeight = rowAvailableWidth / combinedAspect;
|
||||
break;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
|
||||
// If we couldn't add any image (shouldn't happen), add at least one
|
||||
if (j === i) {
|
||||
row.images.push(responseList[i]);
|
||||
row.height = availableImageWidth / responseList[i].aspect_ratio;
|
||||
rows.push(row);
|
||||
i++;
|
||||
} else {
|
||||
for (let k = i; k < j; k++) {
|
||||
row.images.push(responseList[k]);
|
||||
}
|
||||
// Recalculate spacing for the final row
|
||||
let imagesInRow = j - i;
|
||||
let totalSpacing = root.imageSpacing * (imagesInRow - 1);
|
||||
let rowAvailableWidth = availableImageWidth - totalSpacing;
|
||||
row.height = rowAvailableWidth / combinedAspect;
|
||||
rows.push(row);
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
delegate: RowLayout {
|
||||
id: imageRow
|
||||
required property var modelData
|
||||
property var rowHeight: modelData.height
|
||||
spacing: root.imageSpacing
|
||||
|
||||
Repeater {
|
||||
model: modelData.images
|
||||
delegate: BooruImage {
|
||||
required property var modelData
|
||||
imageData: modelData
|
||||
rowHeight: imageRow.rowHeight
|
||||
imageRadius: imageRow.modelData.images.length == 1 ? 50 : Appearance.rounding.normal
|
||||
// Download manually to reduce redundant requests or make sure downloading works
|
||||
manualDownload: ["danbooru", "waifu.im", "t.alcy.cc"].includes(root.responseData.provider)
|
||||
previewDownloadPath: root.previewDownloadPath
|
||||
downloadPath: root.downloadPath
|
||||
nsfwPath: root.nsfwPath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RippleButton { // Next page button
|
||||
id: button
|
||||
property string buttonText
|
||||
visible: root.responseData.page != "" && root.responseData.page > 0
|
||||
|
||||
Layout.alignment: Qt.AlignRight
|
||||
implicitHeight: 30
|
||||
leftPadding: 10
|
||||
rightPadding: 5
|
||||
|
||||
onClicked: {
|
||||
tagInputField.text = `${responseData.tags.join(" ")} ${parseInt(root.responseData.page) + 1}`
|
||||
tagInputField.accept()
|
||||
}
|
||||
|
||||
buttonRadius: Appearance.rounding.small
|
||||
colBackground: Appearance.colors.colSurfaceContainerHighest
|
||||
colBackgroundHover: Appearance.colors.colSurfaceContainerHighestHover
|
||||
colRipple: Appearance.colors.colSurfaceContainerHighestActive
|
||||
|
||||
contentItem: Item {
|
||||
anchors.fill: parent
|
||||
implicitHeight: nextPageRow.implicitHeight
|
||||
implicitWidth: nextPageRow.implicitWidth
|
||||
|
||||
RowLayout {
|
||||
id: nextPageRow
|
||||
anchors.centerIn: parent
|
||||
spacing: 0
|
||||
StyledText {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
text: "Next page"
|
||||
color: Appearance.m3colors.m3onSurface
|
||||
}
|
||||
MaterialSymbol {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
iconSize: Appearance.font.pixelSize.larger
|
||||
color: Appearance.m3colors.m3onSurface
|
||||
text: "chevron_right"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
ii/modules/sidebarLeft/translator/LanguageSelectorButton.qml
Normal file
41
ii/modules/sidebarLeft/translator/LanguageSelectorButton.qml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
RippleButton {
|
||||
id: root
|
||||
property string displayText: ""
|
||||
colBackground: Appearance.colors.colLayer2
|
||||
|
||||
implicitWidth: contentItem.implicitWidth + horizontalPadding * 2
|
||||
implicitHeight: contentItem.implicitHeight + verticalPadding * 2
|
||||
|
||||
contentItem: Item {
|
||||
anchors.centerIn: parent
|
||||
implicitWidth: languageRow.implicitWidth
|
||||
implicitHeight: languageText.implicitHeight
|
||||
RowLayout {
|
||||
id: languageRow
|
||||
anchors.centerIn: parent
|
||||
spacing: 0
|
||||
StyledText {
|
||||
id: languageText
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.leftMargin: 5
|
||||
text: root.displayText
|
||||
color: Appearance.colors.colOnLayer2
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
}
|
||||
MaterialSymbol {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
iconSize: Appearance.font.pixelSize.hugeass
|
||||
text: "arrow_drop_down"
|
||||
color: Appearance.colors.colOnLayer2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
89
ii/modules/sidebarLeft/translator/TextCanvas.qml
Normal file
89
ii/modules/sidebarLeft/translator/TextCanvas.qml
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
property bool isInput: true // true for input, false for output
|
||||
property string placeholderText
|
||||
property string text: ""
|
||||
property var inputTextArea: isInput ? inputLoader.item : undefined
|
||||
readonly property string displayedText: isInput ? inputLoader.item.text :
|
||||
root.text.length > 0 ? outputLoader.item.text : ""
|
||||
default property alias actionButtons: actions.data
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: Math.max(150, inputColumn.implicitHeight)
|
||||
color: isInput ? Appearance.colors.colLayer1 : Appearance.colors.colSurfaceContainer
|
||||
radius: Appearance.rounding.normal
|
||||
border.color: isInput ? Appearance.colors.colOutlineVariant : "transparent"
|
||||
border.width: isInput ? 1 : 0
|
||||
|
||||
signal inputTextChanged(); // Signal emitted when text changes
|
||||
|
||||
ColumnLayout {
|
||||
id: inputColumn
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
Loader {
|
||||
id: inputLoader
|
||||
active: root.isInput
|
||||
visible: root.isInput
|
||||
Layout.fillWidth: true
|
||||
sourceComponent: StyledTextArea { // Input area
|
||||
id: inputTextArea
|
||||
placeholderText: root.placeholderText
|
||||
wrapMode: TextEdit.Wrap
|
||||
textFormat: TextEdit.PlainText
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
color: Appearance.colors.colOnLayer1
|
||||
padding: 15
|
||||
background: null
|
||||
onTextChanged: root.inputTextChanged()
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: outputLoader
|
||||
active: !root.isInput
|
||||
visible: !root.isInput
|
||||
Layout.fillWidth: true
|
||||
sourceComponent: StyledText { // Output area
|
||||
id: outputTextArea
|
||||
padding: 15
|
||||
wrapMode: Text.Wrap
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
color: root.text.length > 0 ? Appearance.colors.colOnLayer1 : Appearance.colors.colSubtext
|
||||
text: root.text.length > 0 ? root.text : root.placeholderText
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
|
||||
RowLayout { // Status row
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 10
|
||||
spacing: 10
|
||||
|
||||
Loader {
|
||||
active: root.isInput
|
||||
visible: root.isInput
|
||||
Layout.leftMargin: 10
|
||||
sourceComponent: Text {
|
||||
text: Translation.tr("%1 characters").arg(inputLoader.item.text.length)
|
||||
color: Appearance.colors.colOnLayer1
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
ButtonGroup {
|
||||
id: actions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue