booru: images, clear, provider setting

This commit is contained in:
end-4
2025-04-28 23:24:11 +02:00
parent 160a55d859
commit 1f5ea7b983
6 changed files with 380 additions and 36 deletions
@@ -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 QtObject hacks: QtObject {
property int arbitraryRaceConditionDelay: 10 // milliseconds property int arbitraryRaceConditionDelay: 10 // milliseconds
} }
@@ -5,15 +5,16 @@ import "root:/modules/common/widgets"
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import Qt5Compat.GraphicalEffects
import Quickshell.Io import Quickshell.Io
import Quickshell import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Hyprland import Quickshell.Hyprland
import Qt5Compat.GraphicalEffects
Item { Item {
id: root id: root
property var panelWindow
property var inputField: tagInputField
onFocusChanged: (focus) => { onFocusChanged: (focus) => {
if (focus) { if (focus) {
tagInputField.forceActiveFocus() tagInputField.forceActiveFocus()
@@ -21,6 +22,7 @@ Item {
} }
ColumnLayout { ColumnLayout {
id: columnLayout
anchors.fill: parent anchors.fill: parent
ListView { // Booru responses ListView { // Booru responses
@@ -28,21 +30,33 @@ Item {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
clip: true clip: true
model: Booru.responses layer.enabled: true
delegate: StyledText { layer.effect: OpacityMask {
id: booruResponseText maskSource: Rectangle {
text: JSON.stringify(modelData) 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 { Rectangle {
id: tagInputFieldContainer
Layout.fillWidth: true Layout.fillWidth: true
radius: Appearance.rounding.small radius: Appearance.rounding.small
border.width: 1 color: Appearance.colors.colLayer1
border.color: Appearance.m3colors.m3outlineVariant
color: "transparent"
implicitWidth: tagInputField.implicitWidth implicitWidth: tagInputField.implicitWidth
implicitHeight: tagInputField.implicitHeight implicitHeight: Math.max(tagInputField.implicitHeight, 45)
clip: true
Behavior on implicitHeight { Behavior on implicitHeight {
NumberAnimation { NumberAnimation {
@@ -53,7 +67,9 @@ Item {
TextArea { TextArea {
id: tagInputField id: tagInputField
anchors.fill: parent anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
wrapMode: TextArea.Wrap wrapMode: TextArea.Wrap
padding: 10 padding: 10
@@ -71,9 +87,32 @@ Item {
tagInputField.insert(tagInputField.cursorPosition, "\n") tagInputField.insert(tagInputField.cursorPosition, "\n")
event.accepted = true event.accepted = true
} else { } else {
// Submit on Enter or Ctrl+Enter const inputText = tagInputField.text
const tagList = tagInputField.text.split(/\s+/); if (inputText.startsWith("/")) {
Booru.makeRequest(tagList); // 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() tagInputField.clear()
event.accepted = true event.accepted = true
} }
@@ -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
}
}
}
}
}
}
}
}
}
@@ -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
}
}
@@ -5,12 +5,12 @@ import "root:/modules/common/widgets"
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import Qt5Compat.GraphicalEffects
import Quickshell.Io import Quickshell.Io
import Quickshell import Quickshell
import Quickshell.Widgets import Quickshell.Widgets
import Quickshell.Wayland import Quickshell.Wayland
import Quickshell.Hyprland import Quickshell.Hyprland
import Qt5Compat.GraphicalEffects
Scope { // Scope Scope { // Scope
id: root id: root
@@ -136,7 +136,9 @@ Scope { // Scope
text: "To be implemented" text: "To be implemented"
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
} }
Anime {} Anime {
panelWindow: sidebarRoot
}
} }
} }
+89 -18
View File
@@ -1,6 +1,7 @@
pragma Singleton pragma Singleton
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import "root:/modules/common"
import Quickshell; import Quickshell;
import Quickshell.Io; import Quickshell.Io;
import Qt.labs.platform import Qt.labs.platform
@@ -9,6 +10,7 @@ import QtQuick;
Singleton { Singleton {
id: root id: root
property var responses: []
property var getWorkingImageSource: (url) => { property var getWorkingImageSource: (url) => {
if (url.includes('pximg.net')) { if (url.includes('pximg.net')) {
return `https://www.pixiv.net/en/artworks/${url.substring(url.lastIndexOf('/') + 1).replace(/_p\d+\.(png|jpg|jpeg|gif)$/, '')}`; 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; 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: { property var providers: {
"system": { "name": "System" },
"yandere": { "yandere": {
"name": "yande.re", "name": "yande.re",
"url": "https://yande.re", "url": "https://yande.re",
@@ -27,6 +31,8 @@ Singleton {
return response.map(item => { return response.map(item => {
return { return {
"id": item.id, "id": item.id,
"width": item.width,
"height": item.height,
"aspect_ratio": item.width / item.height, "aspect_ratio": item.width / item.height,
"tags": item.tags, "tags": item.tags,
"rating": item.rating, "rating": item.rating,
@@ -50,6 +56,8 @@ Singleton {
return response.map(item => { return response.map(item => {
return { return {
"id": item.id, "id": item.id,
"width": item.width,
"height": item.height,
"aspect_ratio": item.width / item.height, "aspect_ratio": item.width / item.height,
"tags": item.tags, "tags": item.tags,
"rating": item.rating, "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": { "danbooru": {
"name": "Danbooru", "name": "Danbooru",
"url": "https://danbooru.donmai.us", "url": "https://danbooru.donmai.us",
@@ -73,6 +107,8 @@ Singleton {
return response.map(item => { return response.map(item => {
return { return {
"id": item.id, "id": item.id,
"width": item.image_width,
"height": item.image_height,
"aspect_ratio": item.image_width / item.image_height, "aspect_ratio": item.image_width / item.image_height,
"tags": item.tag_string, "tags": item.tag_string,
"rating": item.rating, "rating": item.rating,
@@ -96,6 +132,8 @@ Singleton {
return response.map(item => { return response.map(item => {
return { return {
"id": item.id, "id": item.id,
"width": item.width,
"height": item.height,
"aspect_ratio": item.width / item.height, "aspect_ratio": item.width / item.height,
"tags": item.tags, "tags": item.tags,
"rating": item.rating.replace('general', 's').charAt(0), "rating": item.rating.replace('general', 's').charAt(0),
@@ -111,11 +149,8 @@ Singleton {
} }
} }
} }
property var responses: []
onResponsesChanged: { property var currentProvider: ConfigOptions.sidebar.booru.defaultProvider
console.log("[Booru] Responses changed: " + JSON.stringify(responses))
}
property var currentProvider: "yandere"
function setProvider(provider) { function setProvider(provider) {
if (providerList.indexOf(provider) !== -1) { 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 provider = providers[currentProvider]
var baseUrl = provider.api var baseUrl = provider.api
var tagString = tags.join(" ") var tagString = tags.join(" ")
if (!nsfw) { if (!nsfw && currentProvider !== "zerochan") {
tagString += " rating:safe" tagString += " rating:safe"
} }
var params = [] var params = []
// Danbooru, Yandere, Konachan: tags & limit // Tags & limit
if (currentProvider === "danbooru" || currentProvider === "yandere" || currentProvider === "konachan") { 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("tags=" + encodeURIComponent(tagString))
params.push("limit=" + limit) params.push("limit=" + limit)
params.push("page=" + page)
} }
var url = baseUrl var url = baseUrl
if (baseUrl.indexOf("?") === -1) { if (baseUrl.indexOf("?") === -1) {
@@ -147,8 +204,8 @@ Singleton {
return url return url
} }
function makeRequest(tags, nsfw=true, limit=20) { function makeRequest(tags, nsfw=false, limit=20, page=1) {
var url = constructRequestUrl(tags, nsfw, limit) var url = constructRequestUrl(tags, nsfw, limit, page)
console.log("[Booru] Making request to " + url) console.log("[Booru] Making request to " + url)
var xhr = new XMLHttpRequest() var xhr = new XMLHttpRequest()
@@ -157,6 +214,7 @@ Singleton {
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
try { try {
// console.log("[Booru] Raw response length: " + xhr.responseText.length) // console.log("[Booru] Raw response length: " + xhr.responseText.length)
console.log("[Booru] Raw response: " + xhr.responseText)
var response = JSON.parse(xhr.responseText) var response = JSON.parse(xhr.responseText)
// Access nested properties based on listAccess // Access nested properties based on listAccess
@@ -169,9 +227,15 @@ Singleton {
} }
} }
response = providers[currentProvider].mapFunc(response) 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 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 root.responses = newResponses
} catch (e) { } catch (e) {
@@ -185,11 +249,18 @@ Singleton {
try { try {
// Required for danbooru // 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") if (currentProvider == "danbooru") {
} catch (e) { xhr.setRequestHeader("User-Agent", defaultUserAgent)
console.log("Could not set User-Agent:", e) }
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)
} }
xhr.send()
} }
} }