import "root:/" import "root:/services" import "root:/modules/common" import "root:/modules/common/widgets" import "root:/modules/common/functions/fuzzysort.js" as Fuzzy import "root:/modules/common/functions/string_utils.js" as StringUtils import "root:/modules/common/functions/file_utils.js" as FileUtils 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: qsTr("Set the current API provider"), execute: (args) => { Booru.setProvider(args[0]); } }, { name: "clear", description: qsTr("Clear the current list of images"), execute: () => { Booru.clearResponses(); } }, { name: "next", description: qsTr("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: qsTr("Disable NSFW content"), execute: () => { Persistent.states.booru.allowNsfw = false; } }, { name: "lewd", description: qsTr("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(qsTr("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: qsTr("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: StringUtils.format(qsTr("{0} queries pending"), 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: StringUtils.format(qsTr('Enter tags, or "{0}" for commands'), 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, }, ] Item { implicitHeight: providerRowLayout.implicitHeight + 5 * 2 implicitWidth: providerRowLayout.implicitWidth + 10 * 2 RowLayout { id: providerRowLayout anchors.centerIn: parent MaterialSymbol { text: "api" iconSize: Appearance.font.pixelSize.large } StyledText { id: providerName font.pixelSize: Appearance.font.pixelSize.small color: Appearance.m3colors.m3onSurface text: Booru.providers[Booru.currentProvider].name } } StyledToolTip { id: toolTip extraVisibleCondition: false alternativeVisibleCondition: mouseArea.containsMouse // Show tooltip when hovered // content: qsTr("The current API used. Endpoint: ") + Booru.providers[Booru.currentProvider].url + qsTr("\nSet with /mode PROVIDER") content: StringUtils.format(qsTr("Current API endpoint: {0}\nSet it with {1}mode PROVIDER"), Booru.providers[Booru.currentProvider].url, root.commandPrefix) } MouseArea { id: mouseArea anchors.fill: parent hoverEnabled: true } } 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: qsTr("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 = "" } } } } } } } } }