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 int arbitraryRaceConditionDelay: 10 // milliseconds
}
@@ -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
}
@@ -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.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
}
}
}
+90 -19
View File
@@ -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)
}
}
}