mirror of
https://github.com/celesrenata/end-4-flakes.git
synced 2026-06-10 12:09:27 -05:00
Make flake self-contained - consolidate installer-replication
BREAKING CHANGE: Remove external dots-hyprland dependency - Imported all essential configs from dots-hyprland/installer-replication - Added complete configs/ directory with: - hypr/ - Hyprland configuration - quickshell/ - Quickshell widgets and config - applications/ - Application configurations - scripts/ - Utility scripts - matugen/ - Material You theming - Updated flake.nix to use local ./configs instead of external repo - Simplified update-flake script (removed external repo management) - Updated README to reflect self-contained architecture - All builds pass with local configurations Benefits: - No external repository dependencies - Faster builds (no network dependencies) - Version controlled configs in single repo - Easier maintenance and development - Complete installer replication in one place
This commit is contained in:
@@ -0,0 +1,701 @@
|
||||
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 Quickshell
|
||||
|
||||
Item {
|
||||
id: root
|
||||
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: "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);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
Behavior on contentY {
|
||||
NumberAnimation {
|
||||
id: scrollAnim
|
||||
duration: Appearance.animation.scroll.duration
|
||||
easing.type: Appearance.animation.scroll.type
|
||||
easing.bezierCurve: Appearance.animation.scroll.bezierCurve
|
||||
}
|
||||
}
|
||||
|
||||
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 columnSpacing: 5
|
||||
Layout.fillWidth: true
|
||||
radius: Appearance.rounding.small
|
||||
color: Appearance.colors.colLayer1
|
||||
implicitWidth: messageInputField.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: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
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: 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
|
||||
|
||||
property int lastResponseLength: 0
|
||||
|
||||
clip: true
|
||||
layer.enabled: true
|
||||
layer.effect: OpacityMask {
|
||||
maskSource: Rectangle {
|
||||
width: swipeView.width
|
||||
height: swipeView.height
|
||||
radius: Appearance.rounding.small
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on contentY {
|
||||
NumberAnimation {
|
||||
id: scrollAnim
|
||||
duration: Appearance.animation.scroll.duration
|
||||
easing.type: Appearance.animation.scroll.type
|
||||
easing.bezierCurve: Appearance.animation.scroll.bezierCurve
|
||||
}
|
||||
}
|
||||
|
||||
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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
Rectangle {
|
||||
id: sidebarLeft
|
||||
|
||||
property bool visible: false
|
||||
|
||||
width: 350
|
||||
height: Screen.height
|
||||
color: "@SURFACE_COLOR@"
|
||||
|
||||
// Slide animation
|
||||
x: visible ? 0 : -width
|
||||
Behavior on x {
|
||||
NumberAnimation {
|
||||
duration: 300
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 15
|
||||
|
||||
// AI Chat Section
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 300
|
||||
color: "@SURFACE_VARIANT_COLOR@"
|
||||
radius: 12
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 15
|
||||
|
||||
Text {
|
||||
text: "AI Assistant"
|
||||
color: "@ON_SURFACE_COLOR@"
|
||||
font.pixelSize: 16
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
TextArea {
|
||||
id: aiChatArea
|
||||
placeholderText: "Ask me anything..."
|
||||
color: "@ON_SURFACE_COLOR@"
|
||||
wrapMode: TextArea.Wrap
|
||||
readOnly: true
|
||||
}
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: aiInput
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "Type your message..."
|
||||
color: "@ON_SURFACE_COLOR@"
|
||||
|
||||
onAccepted: {
|
||||
sendAiMessage(text)
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calendar Widget
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 200
|
||||
color: "@SURFACE_VARIANT_COLOR@"
|
||||
radius: 12
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 15
|
||||
|
||||
Text {
|
||||
text: "Calendar"
|
||||
color: "@ON_SURFACE_COLOR@"
|
||||
font.pixelSize: 16
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
// Simple calendar display
|
||||
Text {
|
||||
text: new Date().toLocaleDateString()
|
||||
color: "@ON_SURFACE_COLOR@"
|
||||
font.pixelSize: 24
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Text {
|
||||
text: new Date().toLocaleTimeString()
|
||||
color: "@ON_SURFACE_VARIANT_COLOR@"
|
||||
font.pixelSize: 14
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Todo List
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 250
|
||||
color: "@SURFACE_VARIANT_COLOR@"
|
||||
radius: 12
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 15
|
||||
|
||||
Text {
|
||||
text: "Todo List"
|
||||
color: "@ON_SURFACE_COLOR@"
|
||||
font.pixelSize: 16
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
ListView {
|
||||
id: todoList
|
||||
model: ListModel {
|
||||
ListElement { text: "Welcome to dots-hyprland!"; completed: false }
|
||||
ListElement { text: "Customize your desktop"; completed: false }
|
||||
ListElement { text: "Explore AI features"; completed: false }
|
||||
}
|
||||
|
||||
delegate: Row {
|
||||
width: parent.width
|
||||
spacing: 10
|
||||
|
||||
CheckBox {
|
||||
checked: model.completed
|
||||
onToggled: model.completed = checked
|
||||
}
|
||||
|
||||
Text {
|
||||
text: model.text
|
||||
color: "@ON_SURFACE_COLOR@"
|
||||
font.strikeout: model.completed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextField {
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "Add new todo..."
|
||||
color: "@ON_SURFACE_COLOR@"
|
||||
|
||||
onAccepted: {
|
||||
if (text.trim() !== "") {
|
||||
todoList.model.append({text: text, completed: false})
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// System Information
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 150
|
||||
color: "@SURFACE_VARIANT_COLOR@"
|
||||
radius: 12
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 15
|
||||
|
||||
Text {
|
||||
text: "System Info"
|
||||
color: "@ON_SURFACE_COLOR@"
|
||||
font.pixelSize: 16
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "CPU: " + getCpuUsage() + "%"
|
||||
color: "@ON_SURFACE_COLOR@"
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Memory: " + getMemoryUsage() + "%"
|
||||
color: "@ON_SURFACE_COLOR@"
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Uptime: " + getUptime()
|
||||
color: "@ON_SURFACE_COLOR@"
|
||||
font.pixelSize: 12
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendAiMessage(message) {
|
||||
// Send message to AI service
|
||||
aiChatArea.append("You: " + message)
|
||||
|
||||
// Call AI service (implementation depends on provider)
|
||||
callAiService(message)
|
||||
}
|
||||
|
||||
function callAiService(message) {
|
||||
// Implementation for AI service calls
|
||||
// This would integrate with the AI module
|
||||
aiChatArea.append("AI: I received your message: " + message)
|
||||
}
|
||||
|
||||
function getCpuUsage() {
|
||||
// Placeholder - would integrate with system monitoring
|
||||
return Math.floor(Math.random() * 100)
|
||||
}
|
||||
|
||||
function getMemoryUsage() {
|
||||
// Placeholder - would integrate with system monitoring
|
||||
return Math.floor(Math.random() * 100)
|
||||
}
|
||||
|
||||
function getUptime() {
|
||||
// Placeholder - would integrate with system monitoring
|
||||
return "2h 30m"
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: true
|
||||
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
|
||||
visible: opacity > 0
|
||||
opacity: status === Image.Ready ? 1 : 0
|
||||
fillMode: Image.PreserveAspectFit
|
||||
source: modelData.preview_url
|
||||
sourceSize.width: root.rowHeight * modelData.aspect_ratio
|
||||
sourceSize.height: root.rowHeight
|
||||
|
||||
layer.enabled: true
|
||||
layer.effect: OpacityMask {
|
||||
maskSource: Rectangle {
|
||||
width: root.rowHeight * modelData.aspect_ratio
|
||||
height: root.rowHeight
|
||||
radius: imageRadius
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
|
||||
}
|
||||
}
|
||||
|
||||
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'`
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user