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:
Celes Renata
2025-08-08 22:26:47 -07:00
parent 22b65891ac
commit ac6d3adeb9
710 changed files with 81319 additions and 115 deletions
@@ -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 = `![latex](${imagePath})`;
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
}
}
}
}