diff --git a/.config/quickshell/modules/common/ConfigOptions.qml b/.config/quickshell/modules/common/ConfigOptions.qml index f9434de60..c3062dd42 100644 --- a/.config/quickshell/modules/common/ConfigOptions.qml +++ b/.config/quickshell/modules/common/ConfigOptions.qml @@ -55,6 +55,17 @@ Singleton { } } + property QtObject sidebar: QtObject { + property QtObject booru: QtObject { + property bool allowNsfw: false + property string defaultProvider: "yandere" + property int limit: 20 // Images per page + property QtObject zerochan: QtObject { + // property string username + } + } + } + property QtObject hacks: QtObject { property int arbitraryRaceConditionDelay: 10 // milliseconds } diff --git a/.config/quickshell/modules/sidebarLeft/Anime.qml b/.config/quickshell/modules/sidebarLeft/Anime.qml index d92b2201e..2db36420d 100644 --- a/.config/quickshell/modules/sidebarLeft/Anime.qml +++ b/.config/quickshell/modules/sidebarLeft/Anime.qml @@ -5,15 +5,16 @@ import "root:/modules/common/widgets" import QtQuick import QtQuick.Controls import QtQuick.Layouts +import Qt5Compat.GraphicalEffects import Quickshell.Io import Quickshell -import Quickshell.Widgets -import Quickshell.Wayland import Quickshell.Hyprland -import Qt5Compat.GraphicalEffects Item { id: root + property var panelWindow + property var inputField: tagInputField + onFocusChanged: (focus) => { if (focus) { tagInputField.forceActiveFocus() @@ -21,6 +22,7 @@ Item { } ColumnLayout { + id: columnLayout anchors.fill: parent ListView { // Booru responses @@ -28,21 +30,33 @@ Item { Layout.fillWidth: true Layout.fillHeight: true clip: true - model: Booru.responses - delegate: StyledText { - id: booruResponseText - text: JSON.stringify(modelData) + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + spacing: 10 + model: ScriptModel { + values: Booru.responses + } + delegate: BooruResponse { + responseData: modelData + tagInputField: root.inputField } } Rectangle { + id: tagInputFieldContainer Layout.fillWidth: true radius: Appearance.rounding.small - border.width: 1 - border.color: Appearance.m3colors.m3outlineVariant - color: "transparent" + color: Appearance.colors.colLayer1 implicitWidth: tagInputField.implicitWidth - implicitHeight: tagInputField.implicitHeight + implicitHeight: Math.max(tagInputField.implicitHeight, 45) + clip: true Behavior on implicitHeight { NumberAnimation { @@ -53,7 +67,9 @@ Item { TextArea { id: tagInputField - anchors.fill: parent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter wrapMode: TextArea.Wrap padding: 10 @@ -71,9 +87,32 @@ Item { tagInputField.insert(tagInputField.cursorPosition, "\n") event.accepted = true } else { - // Submit on Enter or Ctrl+Enter - const tagList = tagInputField.text.split(/\s+/); - Booru.makeRequest(tagList); + const inputText = tagInputField.text + if (inputText.startsWith("/")) { + // Handle special commands + const command = inputText.split(" ")[0].substring(1); + if (command === "clear") { + Booru.clearResponses(); + } + else if (command === "mode") { + const newProvider = inputText.split(" ")[1]; + Booru.setProvider(newProvider); + Booru.addSystemMessage(`Provider set to ${Booru.providers[newProvider].name}`); + } + } + else { + // Create tag list + const tagList = inputText.split(/\s+/); + 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, ConfigOptions.sidebar.booru.allowNsfw, ConfigOptions.sidebar.booru.limit, pageIndex); + } tagInputField.clear() event.accepted = true } diff --git a/.config/quickshell/modules/sidebarLeft/BooruResponse.qml b/.config/quickshell/modules/sidebarLeft/BooruResponse.qml new file mode 100644 index 000000000..9c4925f7c --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/BooruResponse.qml @@ -0,0 +1,187 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland +import Qt5Compat.GraphicalEffects + +Rectangle { + id: root + property var responseData + property var tagInputField + + property real availableWidth: parent?.width + property real rowTooShortThreshold: 100 + property real imageSpacing: 5 + property real responsePadding: 5 + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: columnLayout.implicitHeight + root.responsePadding * 2 + + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + + ColumnLayout { + id: columnLayout + + anchors.fill: parent + anchors.margins: responsePadding + spacing: root.imageSpacing + + // Header: provider name + Rectangle { + id: providerNameWrapper + color: Appearance.m3colors.m3secondaryContainer + radius: Appearance.rounding.small + // height: providerName.implicitHeight + implicitWidth: providerName.implicitWidth + 10 * 2 + implicitHeight: Math.max(providerName.implicitHeight + 5 * 2, 30) + Layout.alignment: Qt.AlignLeft + + StyledText { + id: providerName + anchors.centerIn: parent + font.pixelSize: Appearance.font.pixelSize.large + color: Appearance.m3colors.m3onSecondaryContainer + text: Booru.providers[root.responseData.provider].name + } + } + + // Tags + Flickable { + id: tagsFlickable + Layout.alignment: Qt.AlignLeft + Layout.fillWidth: { + console.log(root.responseData) + return true + } + implicitHeight: tagRowLayout.implicitHeight + // height: tagRowLayout.implicitHeight + contentWidth: tagRowLayout.implicitWidth + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: tagsFlickable.width + height: tagsFlickable.height + radius: Appearance.rounding.small + } + } + + Behavior on height { + NumberAnimation { + duration: Appearance.animation.elementDecel.duration + easing.type: Appearance.animation.elementDecel.type + } + } + Behavior on implicitHeight { + NumberAnimation { + duration: Appearance.animation.elementDecel.duration + easing.type: Appearance.animation.elementDecel.type + } + } + + RowLayout { + id: tagRowLayout + Layout.alignment: Qt.AlignBottom + + Repeater { + id: tagRepeater + model: root.responseData.tags + + BooruTagButton { + Layout.fillWidth: false + buttonText: modelData + onClicked: { + root.tagInputField.text += " " + modelData + } + } + } + + } + } + + Repeater { + model: { + // Group two images every row, ensuring they are of the same height + // If the height ends up being too small, put one image in the row and continue + // In other words, this is similar to Android's gallery layout at largest zoom level + let i = 0; + let rows = []; + const responseList = root.responseData.images; + while (i < responseList.length) { + let row = { + height: 0, + images: [], + }; + if (i + 1 < responseList.length) { + const img1 = responseList[i]; + const img2 = responseList[i + 1]; + // Calculate combined height if both are in the same row + // Let h = row height, w1 = h * aspect1, w2 = h * aspect2 + // w1 + w2 = availableWidth => h = availableWidth / (aspect1 + aspect2) + const combinedAspect = img1.aspect_ratio + img2.aspect_ratio; + const rowHeight = (availableWidth - root.imageSpacing - responsePadding * 2) / combinedAspect; + if (rowHeight >= rowTooShortThreshold) { + row.height = rowHeight; + row.images.push(img1); + row.images.push(img2); + rows.push(row); + i += 2; + continue; + } + } + // Otherwise, put only one image in the row + rows.push({ + height: availableWidth / responseList[i].aspect_ratio, + images: [responseList[i]], + }); + i += 1; + } + return rows; + } + delegate: RowLayout { + id: imageRow + property var rowHeight: modelData.height + spacing: root.imageSpacing + + Repeater { + model: modelData.images + Rectangle { + implicitWidth: image.width + implicitHeight: image.height + radius: Appearance.rounding.small + color: Appearance.colors.colLayer2 + Image { + id: image + anchors.fill: parent + sourceSize.width: imageRow.rowHeight * modelData.aspect_ratio + sourceSize.height: imageRow.rowHeight + fillMode: Image.PreserveAspectFit + source: modelData.preview_url + width: imageRow.rowHeight * modelData.aspect_ratio + height: imageRow.rowHeight + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: image.width + height: image.height + radius: Appearance.rounding.small + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarLeft/BooruTagButton.qml b/.config/quickshell/modules/sidebarLeft/BooruTagButton.qml new file mode 100644 index 000000000..02ed3883c --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/BooruTagButton.qml @@ -0,0 +1,34 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Notifications + +Button { + id: button + property string buttonText + + implicitHeight: 30 + leftPadding: 10 + rightPadding: 10 + + // PointingHandInteraction {} + + background: Rectangle { + radius: Appearance.rounding.small + color: (button.down ? Appearance.colors.colSurfaceContainerHighestActive : + button.hovered ? Appearance.colors.colSurfaceContainerHighestHover : + Appearance.m3colors.m3surfaceContainerHighest) + + + } + + contentItem: StyledText { + horizontalAlignment: Text.AlignHCenter + text: buttonText + color: Appearance.m3colors.m3onSurface + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarLeft/SidebarLeft.qml b/.config/quickshell/modules/sidebarLeft/SidebarLeft.qml index 97b9e9b3d..7dfaaa42a 100644 --- a/.config/quickshell/modules/sidebarLeft/SidebarLeft.qml +++ b/.config/quickshell/modules/sidebarLeft/SidebarLeft.qml @@ -5,12 +5,12 @@ import "root:/modules/common/widgets" import QtQuick import QtQuick.Controls import QtQuick.Layouts +import Qt5Compat.GraphicalEffects import Quickshell.Io import Quickshell import Quickshell.Widgets import Quickshell.Wayland import Quickshell.Hyprland -import Qt5Compat.GraphicalEffects Scope { // Scope id: root @@ -136,7 +136,9 @@ Scope { // Scope text: "To be implemented" horizontalAlignment: Text.AlignHCenter } - Anime {} + Anime { + panelWindow: sidebarRoot + } } } diff --git a/.config/quickshell/services/Booru.qml b/.config/quickshell/services/Booru.qml index d49d93c3c..4f4bf8549 100644 --- a/.config/quickshell/services/Booru.qml +++ b/.config/quickshell/services/Booru.qml @@ -1,6 +1,7 @@ pragma Singleton pragma ComponentBehavior: Bound +import "root:/modules/common" import Quickshell; import Quickshell.Io; import Qt.labs.platform @@ -9,6 +10,7 @@ import QtQuick; Singleton { id: root + property var responses: [] property var getWorkingImageSource: (url) => { if (url.includes('pximg.net')) { return `https://www.pixiv.net/en/artworks/${url.substring(url.lastIndexOf('/') + 1).replace(/_p\d+\.(png|jpg|jpeg|gif)$/, '')}`; @@ -16,8 +18,10 @@ Singleton { return url; } - property var providerList: ["yandere", "konachan", "danbooru", "gelbooru"] + property var defaultUserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" + property var providerList: ["yandere", "konachan", "zerochan", "danbooru", "gelbooru"] property var providers: { + "system": { "name": "System" }, "yandere": { "name": "yande.re", "url": "https://yande.re", @@ -27,6 +31,8 @@ Singleton { return response.map(item => { return { "id": item.id, + "width": item.width, + "height": item.height, "aspect_ratio": item.width / item.height, "tags": item.tags, "rating": item.rating, @@ -50,6 +56,8 @@ Singleton { return response.map(item => { return { "id": item.id, + "width": item.width, + "height": item.height, "aspect_ratio": item.width / item.height, "tags": item.tags, "rating": item.rating, @@ -64,6 +72,32 @@ Singleton { }) } }, + "zerochan": { + "name": "Zerochan", + "url": "https://www.zerochan.net", + "api": "https://www.zerochan.net/?json", + "listAccess": ["items"], + "mapFunc": (response) => { + return response.map(item => { + return { + "id": item.id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags.join(" "), + "rating": "safe", // Zerochan doesn't have nsfw + "is_nsfw": false, + "md5": item.md5, + "preview_url": item.thumbnail, + "sample_url": item.thumbnail, + "file_url": item.thumbnail, + "file_ext": "avif", + "source": getWorkingImageSource(item.source), + "character": item.tag + } + }) + } + }, "danbooru": { "name": "Danbooru", "url": "https://danbooru.donmai.us", @@ -73,6 +107,8 @@ Singleton { return response.map(item => { return { "id": item.id, + "width": item.image_width, + "height": item.image_height, "aspect_ratio": item.image_width / item.image_height, "tags": item.tag_string, "rating": item.rating, @@ -96,6 +132,8 @@ Singleton { return response.map(item => { return { "id": item.id, + "width": item.width, + "height": item.height, "aspect_ratio": item.width / item.height, "tags": item.tags, "rating": item.rating.replace('general', 's').charAt(0), @@ -111,11 +149,8 @@ Singleton { } } } - property var responses: [] - onResponsesChanged: { - console.log("[Booru] Responses changed: " + JSON.stringify(responses)) - } - property var currentProvider: "yandere" + + property var currentProvider: ConfigOptions.sidebar.booru.defaultProvider function setProvider(provider) { if (providerList.indexOf(provider) !== -1) { @@ -125,18 +160,40 @@ Singleton { } } - function constructRequestUrl(tags, nsfw=true, limit=20) { + function clearResponses() { + responses = [] + } + + function addSystemMessage(message) { + responses.push({ + "provider": "system", + "tags": [], + "page": 1, + "images": [], + "message": `${message}` + }) + } + + function constructRequestUrl(tags, nsfw=true, limit=20, page=1) { var provider = providers[currentProvider] var baseUrl = provider.api var tagString = tags.join(" ") - if (!nsfw) { + if (!nsfw && currentProvider !== "zerochan") { tagString += " rating:safe" } var params = [] - // Danbooru, Yandere, Konachan: tags & limit - if (currentProvider === "danbooru" || currentProvider === "yandere" || currentProvider === "konachan") { + // Tags & limit + if (currentProvider === "zerochan") { + params.push("c=" + tagString) // zerochan doesn't have search in api, so we use color + params.push("l=" + limit) + params.push("s=" + "fav") + params.push("t=" + 1) + params.push("p=" + page) + } + else { params.push("tags=" + encodeURIComponent(tagString)) params.push("limit=" + limit) + params.push("page=" + page) } var url = baseUrl if (baseUrl.indexOf("?") === -1) { @@ -147,8 +204,8 @@ Singleton { return url } - function makeRequest(tags, nsfw=true, limit=20) { - var url = constructRequestUrl(tags, nsfw, limit) + function makeRequest(tags, nsfw=false, limit=20, page=1) { + var url = constructRequestUrl(tags, nsfw, limit, page) console.log("[Booru] Making request to " + url) var xhr = new XMLHttpRequest() @@ -157,6 +214,7 @@ Singleton { if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { try { // console.log("[Booru] Raw response length: " + xhr.responseText.length) + console.log("[Booru] Raw response: " + xhr.responseText) var response = JSON.parse(xhr.responseText) // Access nested properties based on listAccess @@ -169,9 +227,15 @@ Singleton { } } response = providers[currentProvider].mapFunc(response) - // console.log("[Booru] Scoped & mapped response: " + JSON.stringify(response)) + console.log("[Booru] Scoped & mapped response: " + JSON.stringify(response)) var newResponses = root.responses.slice() // make a shallow copy - newResponses.push(response) + newResponses.push({ + "provider": currentProvider, + "tags": tags, + "page": page, + "images": response, + "message": "" + }) root.responses = newResponses } catch (e) { @@ -185,11 +249,18 @@ Singleton { try { // Required for danbooru - xhr.setRequestHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36") - } catch (e) { - console.log("Could not set User-Agent:", e) - } - xhr.send() + if (currentProvider == "danbooru") { + xhr.setRequestHeader("User-Agent", defaultUserAgent) + } + else if (currentProvider == "zerochan") { + const userAgent = ConfigOptions.sidebar.booru.zerochan.username ? `Desktop sidebar booru viewer - ${ConfigOptions.sidebar.booru.zerochan.username}` : defaultUserAgent + console.log("Setting User-Agent for zerochan: " + userAgent) + xhr.setRequestHeader("User-Agent", userAgent) + } + xhr.send() + } catch (error) { + console.log("Could not set User-Agent:", error) + } } }