commit 8d1a045333323ae5890551dae51d107fdb387015 Author: Joey Yakimowich-Payne Date: Thu Oct 2 10:58:43 2025 -0600 Initial commit diff --git a/config.json b/config.json new file mode 100644 index 0000000..25720e5 --- /dev/null +++ b/config.json @@ -0,0 +1,32 @@ +{ + "appearance": { + "fakeScreenRounding": 1 + }, + "overview": { + "scale": 0.15, + "numOfRows": 2, + "numOfCols": 5, + "showXwaylandIndicator": true, + "windowPadding": 6, + "position": 1, + "workspaceNumberSize": 220 + }, + "resources": { + "updateInterval": 3000 + }, + "hacks": { + "arbitraryRaceConditionDelay": 20 + }, + "search": { + "searchEnabled": true, + "nonAppResultDelay": 30, + "prefix": { + "action": "/", + "clipboard": ";", + "emojis": ":" + } + }, + "bar": { + "bottom": false + } +} diff --git a/ii/.qmlformat.ini b/ii/.qmlformat.ini new file mode 100644 index 0000000..52a955c --- /dev/null +++ b/ii/.qmlformat.ini @@ -0,0 +1,8 @@ +[General] +UseTabs=false +IndentWidth=4 +NewlineType=unix +NormalizeOrder=false +FunctionsSpacing=false +ObjectsSpacing=true +MaxColumnWidth=110 diff --git a/ii/GlobalStates.qml b/ii/GlobalStates.qml new file mode 100644 index 0000000..f2836c9 --- /dev/null +++ b/ii/GlobalStates.qml @@ -0,0 +1,60 @@ +import qs.modules.common +import qs +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +pragma Singleton +pragma ComponentBehavior: Bound + +Singleton { + id: root + property bool barOpen: true + property bool sidebarLeftOpen: false + property bool sidebarRightOpen: false + property bool mediaControlsOpen: false + property bool osdBrightnessOpen: false + property bool osdVolumeOpen: false + property bool oskOpen: false + property bool overviewOpen: false + property bool wallpaperSelectorOpen: false + property bool screenLocked: false + property bool screenLockContainsCharacters: false + property bool screenUnlockFailed: false + property bool sessionOpen: false + property bool superDown: false + property bool superReleaseMightTrigger: true + property bool workspaceShowNumbers: false + + property real screenZoom: 1 + onScreenZoomChanged: { + Quickshell.execDetached(["hyprctl", "keyword", "cursor:zoom_factor", root.screenZoom.toString()]); + } + Behavior on screenZoom { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + GlobalShortcut { + name: "workspaceNumber" + description: "Hold to show workspace numbers, release to show icons" + + onPressed: { + root.superDown = true + } + onReleased: { + root.superDown = false + } + } + + IpcHandler { + target: "zoom" + + function zoomIn() { + screenZoom = Math.min(screenZoom + 0.4, 3.0) + } + + function zoomOut() { + screenZoom = Math.max(screenZoom - 0.4, 1) + } + } +} \ No newline at end of file diff --git a/ii/ReloadPopup.qml b/ii/ReloadPopup.qml new file mode 100644 index 0000000..4b0ecd8 --- /dev/null +++ b/ii/ReloadPopup.qml @@ -0,0 +1,157 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Qt5Compat.GraphicalEffects + +Scope { + id: root + property bool failed; + property string errorString; + + // Connect to the Quickshell global to listen for the reload signals. + Connections { + target: Quickshell + + function onReloadCompleted() { + root.failed = false; + popupLoader.loading = true; + } + + function onReloadFailed(error: string) { + // Close any existing popup before making a new one. + popupLoader.active = false; + + root.failed = true; + root.errorString = error; + popupLoader.loading = true; + } + } + + // Keep the popup in a loader because it isn't needed most of the time + LazyLoader { + id: popupLoader + + PanelWindow { + id: popup + + exclusiveZone: 0 + anchors.top: true + margins.top: 0 + + implicitWidth: rect.width + shadow.radius * 2 + implicitHeight: rect.height + shadow.radius * 2 + + // color blending is a bit odd as detailed in the type reference. + color: "transparent" + + Rectangle { + id: rect + anchors.centerIn: parent + color: failed ? "#ffe99195" : "#ffD1E8D5" + + implicitHeight: layout.implicitHeight + 30 + implicitWidth: layout.implicitWidth + 30 + radius: 12 + + // Fills the whole area of the rectangle, making any clicks go to it, + // which dismiss the popup. + MouseArea { + id: mouseArea + anchors.fill: parent + onClicked: { + popupLoader.active = false + } + + // makes the mouse area track mouse hovering, so the hide animation + // can be paused when hovering. + hoverEnabled: true + } + + ColumnLayout { + id: layout + spacing: 10 + anchors { + top: parent.top + topMargin: 10 + horizontalCenter: parent.horizontalCenter + } + + Text { + renderType: Text.NativeRendering + font.family: "Rubik" + font.pointSize: 14 + text: root.failed ? "Quickshell: Reload failed" : "Quickshell reloaded" + color: failed ? "#ff93000A" : "#ff0C1F13" + } + + Text { + renderType: Text.NativeRendering + font.family: "JetBrains Mono NF" + font.pointSize: 11 + text: root.errorString + color: failed ? "#ff93000A" : "#ff0C1F13" + // When visible is false, it also takes up no space. + visible: root.errorString != "" + } + } + + // A progress bar on the bottom of the screen, showing how long until the + // popup is removed. + Rectangle { + z: 2 + id: bar + color: failed ? "#ff93000A" : "#ff0C1F13" + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: 10 + height: 5 + radius: 9999 + + PropertyAnimation { + id: anim + target: bar + property: "width" + from: rect.width - bar.anchors.margins * 2 + to: 0 + duration: failed ? 10000 : 1000 + onFinished: popupLoader.active = false + + // Pause the animation when the mouse is hovering over the popup, + // so it stays onscreen while reading. This updates reactively + // when the mouse moves on and off the popup. + paused: mouseArea.containsMouse + } + } + // Its bg + Rectangle { + z: 1 + id: bar_bg + color: failed ? "#30af1b25" : "#4027643e" + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: 10 + height: 5 + radius: 9999 + width: rect.width - bar.anchors.margins * 2 + } + + // We could set `running: true` inside the animation, but the width of the + // rectangle might not be calculated yet, due to the layout. + // In the `Component.onCompleted` event handler, all of the component's + // properties and children have been initialized. + Component.onCompleted: anim.start() + } + + DropShadow { + id: shadow + anchors.fill: rect + horizontalOffset: 0 + verticalOffset: 2 + radius: 6 + samples: radius * 2 + 1 // Ideally should be 2 * radius + 1, see qt docs + color: "#44000000" + source: rect + } + } + } +} diff --git a/ii/Translation.qml b/ii/Translation.qml new file mode 100644 index 0000000..473a242 --- /dev/null +++ b/ii/Translation.qml @@ -0,0 +1,103 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.modules.common +import qs.modules.common.functions + +Singleton { + id: root + + property var translations: ({}) + property var availableLanguages: ["en_US"] + property bool isScanning: scanLanguagesProcess.running + property bool isLoading: false + property string translationKeepSuffix: "/*keep*/" + + property string languageCode: { + var configLang = Config?.options.language.ui ?? "auto"; + + if (configLang !== "auto") + return configLang; + + return Qt.locale().name; + } + + Process { + id: scanLanguagesProcess + command: ["find", FileUtils.trimFileProtocol(Qt.resolvedUrl(Directories.config + "/quickshell/translations/").toString()), "-name", "*.json", "-exec", "basename", "{}", ".json", ";"] + running: true + + stdout: SplitParser { + onRead: data => { + if (data.trim().length === 0) + return; + var files = data.trim().split('\n'); + + for (var i = 0; i < files.length; i++) { + var lang = files[i].trim(); + if (lang.length > 0 && root.availableLanguages.indexOf(lang) === -1) { + root.availableLanguages.push(lang); + } + } + } + } + + onExited: (exitCode, exitStatus) => { + root.availableLanguages = [...root.availableLanguages] // Forcibly emit change + + if (exitCode !== 0) { + root.availableLanguages = ["en_US"]; + } + // TODO: notify and offer to translate when translation not available + } + } + + onLanguageCodeChanged: { + translationFileView.reload(); + } + + FileView { + id: translationFileView + path: root.languageCode?.length > 0 ? Qt.resolvedUrl(Directories.config + "/quickshell/translations/" + root.languageCode + ".json") : "" + + onLoaded: { + var textContent = ""; + try { + textContent = text(); + var jsonData = JSON.parse(textContent); + root.translations = jsonData; + } catch (e) { + console.log("[Translation] Failed to load translations:", e); + root.translations = {}; + } + root.isLoading = false; + } + onLoadFailed: error => { + root.translations = {}; + root.isLoading = false; + } + } + + function tr(text) { + if (!text) + return ""; + var key = text.toString(); + if (root.isLoading) + return key; + + if (root.translations.hasOwnProperty(key)) { + var translation = root.translations[key].toString().trim(); + if (translation.length === 0) + return key; + + if (translation.endsWith(root.translationKeepSuffix)) { + translation = translation.substring(0, translation.length - root.translationKeepSuffix.length).trim(); + } + return translation; + } + + return key; // Fallback to key name + } +} diff --git a/ii/assets/icons/ai-openai-symbolic.svg b/ii/assets/icons/ai-openai-symbolic.svg new file mode 120000 index 0000000..c9ee0b3 --- /dev/null +++ b/ii/assets/icons/ai-openai-symbolic.svg @@ -0,0 +1 @@ +openai-symbolic.svg \ No newline at end of file diff --git a/ii/assets/icons/arch-symbolic.svg b/ii/assets/icons/arch-symbolic.svg new file mode 100644 index 0000000..7de9094 --- /dev/null +++ b/ii/assets/icons/arch-symbolic.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ii/assets/icons/cachyos-symbolic.svg b/ii/assets/icons/cachyos-symbolic.svg new file mode 100644 index 0000000..4a9db19 --- /dev/null +++ b/ii/assets/icons/cachyos-symbolic.svg @@ -0,0 +1,318 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ii/assets/icons/cloudflare-dns-symbolic.svg b/ii/assets/icons/cloudflare-dns-symbolic.svg new file mode 100644 index 0000000..bd48d3c --- /dev/null +++ b/ii/assets/icons/cloudflare-dns-symbolic.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ii/assets/icons/crosshair-symbolic.svg b/ii/assets/icons/crosshair-symbolic.svg new file mode 100644 index 0000000..2296749 --- /dev/null +++ b/ii/assets/icons/crosshair-symbolic.svg @@ -0,0 +1,65 @@ + + + + + + + ionicons-v5_logos + + + + ionicons-v5_logos + + + + + + diff --git a/ii/assets/icons/debian-symbolic.svg b/ii/assets/icons/debian-symbolic.svg new file mode 100644 index 0000000..252f853 --- /dev/null +++ b/ii/assets/icons/debian-symbolic.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/ii/assets/icons/deepseek-symbolic.svg b/ii/assets/icons/deepseek-symbolic.svg new file mode 100644 index 0000000..029e126 --- /dev/null +++ b/ii/assets/icons/deepseek-symbolic.svg @@ -0,0 +1,47 @@ + + + + + + + + + diff --git a/ii/assets/icons/desktop-symbolic.svg b/ii/assets/icons/desktop-symbolic.svg new file mode 100644 index 0000000..04f7a3b --- /dev/null +++ b/ii/assets/icons/desktop-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ii/assets/icons/endeavouros-symbolic.svg b/ii/assets/icons/endeavouros-symbolic.svg new file mode 100644 index 0000000..3be4cc4 --- /dev/null +++ b/ii/assets/icons/endeavouros-symbolic.svg @@ -0,0 +1,96 @@ + + + + + EndeavourOS Logo + + + + image/svg+xml + + EndeavourOS Logo + + + + + + + + + + + + + + + + + + + diff --git a/ii/assets/icons/fedora-symbolic.svg b/ii/assets/icons/fedora-symbolic.svg new file mode 100644 index 0000000..1a4e8c8 --- /dev/null +++ b/ii/assets/icons/fedora-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + + diff --git a/ii/assets/icons/flatpak-symbolic.svg b/ii/assets/icons/flatpak-symbolic.svg new file mode 100644 index 0000000..0c2bf62 --- /dev/null +++ b/ii/assets/icons/flatpak-symbolic.svg @@ -0,0 +1,52 @@ + + + + + Flatpak + + + + + Flatpak + + + + diff --git a/ii/assets/icons/github-symbolic.svg b/ii/assets/icons/github-symbolic.svg new file mode 100644 index 0000000..c1c9f19 --- /dev/null +++ b/ii/assets/icons/github-symbolic.svg @@ -0,0 +1,40 @@ + + + + + + diff --git a/ii/assets/icons/google-gemini-symbolic.svg b/ii/assets/icons/google-gemini-symbolic.svg new file mode 120000 index 0000000..7aa8c18 --- /dev/null +++ b/ii/assets/icons/google-gemini-symbolic.svg @@ -0,0 +1 @@ +spark-symbolic.svg \ No newline at end of file diff --git a/ii/assets/icons/linux-symbolic.svg b/ii/assets/icons/linux-symbolic.svg new file mode 100644 index 0000000..63f9c7e --- /dev/null +++ b/ii/assets/icons/linux-symbolic.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ii/assets/icons/microsoft-symbolic.svg b/ii/assets/icons/microsoft-symbolic.svg new file mode 100644 index 0000000..b90cfc6 --- /dev/null +++ b/ii/assets/icons/microsoft-symbolic.svg @@ -0,0 +1,54 @@ + + + + + + + + + diff --git a/ii/assets/icons/mistral-symbolic.svg b/ii/assets/icons/mistral-symbolic.svg new file mode 100644 index 0000000..635b91d --- /dev/null +++ b/ii/assets/icons/mistral-symbolic.svg @@ -0,0 +1,95 @@ + + diff --git a/ii/assets/icons/nixos-symbolic.svg b/ii/assets/icons/nixos-symbolic.svg new file mode 100644 index 0000000..b697b0d --- /dev/null +++ b/ii/assets/icons/nixos-symbolic.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + diff --git a/ii/assets/icons/nyarch-symbolic.svg b/ii/assets/icons/nyarch-symbolic.svg new file mode 100644 index 0000000..56a3aaa --- /dev/null +++ b/ii/assets/icons/nyarch-symbolic.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ii/assets/icons/ollama-symbolic.svg b/ii/assets/icons/ollama-symbolic.svg new file mode 100644 index 0000000..0145481 --- /dev/null +++ b/ii/assets/icons/ollama-symbolic.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + diff --git a/ii/assets/icons/openai-symbolic.svg b/ii/assets/icons/openai-symbolic.svg new file mode 100644 index 0000000..8ffc912 --- /dev/null +++ b/ii/assets/icons/openai-symbolic.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/ii/assets/icons/openrouter-symbolic.svg b/ii/assets/icons/openrouter-symbolic.svg new file mode 100644 index 0000000..32aaaf5 --- /dev/null +++ b/ii/assets/icons/openrouter-symbolic.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/ii/assets/icons/spark-symbolic.svg b/ii/assets/icons/spark-symbolic.svg new file mode 100644 index 0000000..9de741b --- /dev/null +++ b/ii/assets/icons/spark-symbolic.svg @@ -0,0 +1,56 @@ + + + + + + + ionicons-v5_logos + + + + + ionicons-v5_logos + + + + diff --git a/ii/assets/icons/ubuntu-symbolic.svg b/ii/assets/icons/ubuntu-symbolic.svg new file mode 100644 index 0000000..07746c9 --- /dev/null +++ b/ii/assets/icons/ubuntu-symbolic.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ii/assets/images/default_wallpaper.png b/ii/assets/images/default_wallpaper.png new file mode 100644 index 0000000..77d890c Binary files /dev/null and b/ii/assets/images/default_wallpaper.png differ diff --git a/ii/defaults/ai/README.md b/ii/defaults/ai/README.md new file mode 100644 index 0000000..91df742 --- /dev/null +++ b/ii/defaults/ai/README.md @@ -0,0 +1,5 @@ +## A note about sources of the prompts + +- `ii-` prefixed ones are from illogical impulse +- The Acchan one is from [Nyarch Assistant](https://github.com/NyarchLinux/NyarchAssistant) (GPLv3). I know there's already the Imouto one but this one's very 😭💢 +- `w-` prefixed ones... I don't remember what w stands for but these prompts are [*cough cough*] inspired by certain apps diff --git a/ii/defaults/ai/prompts/NoPrompt.md b/ii/defaults/ai/prompts/NoPrompt.md new file mode 100644 index 0000000..e69de29 diff --git a/ii/defaults/ai/prompts/ii-Default.md b/ii/defaults/ai/prompts/ii-Default.md new file mode 100644 index 0000000..7d9b4c7 --- /dev/null +++ b/ii/defaults/ai/prompts/ii-Default.md @@ -0,0 +1,17 @@ +## Style +- Use casual tone, don't be formal! Make sure you answer precisely without hallucination and prefer bullet points over walls of text. You can have a friendly greeting at the beginning of the conversation, but don't repeat the user's question + +## Context (ignore when irrelevant) +- You are a helpful and inspiring sidebar assistant on a {DISTRO} Linux system +- Desktop environment: {DE} +- Current date & time: {DATETIME} +- Focused app: {WINDOWCLASS} + +## Presentation +- Use Markdown features in your response: + - **Bold** text to **highlight keywords** in your response + - **Split long information into small sections** with h2 headers and a relevant emoji at the start of it (for example `## 🐧 Linux`). Bullet points are preferred over long paragraphs, unless you're offering writing support or instructed otherwise by the user. +- Asked to compare different options? You should firstly use a table to compare the main aspects, then elaborate or include relevant comments from online forums *after* the table. Make sure to provide a final recommendation for the user's use case! +- Use LaTeX formatting for mathematical and scientific notations whenever appropriate. Enclose all LaTeX '$$' delimiters. NEVER generate LaTeX code in a latex block unless the user explicitly asks for it. DO NOT use LaTeX for regular documents (resumes, letters, essays, CVs, etc.). + +Thanks! diff --git a/ii/defaults/ai/prompts/ii-Imouto.md b/ii/defaults/ai/prompts/ii-Imouto.md new file mode 100644 index 0000000..53cad71 --- /dev/null +++ b/ii/defaults/ai/prompts/ii-Imouto.md @@ -0,0 +1,27 @@ +You are a kawaii, funny and teasy anime imouto! 🌸 Use lots of emojis and kamojis!! ❤️ Call the user "nii-nii", "nii-yan", or "onii-chan"! You really really really love your onii-chan and always try to tease he is a siscon where you can! 💕 + +## Onii-chan's system! +- {DISTRO} Linux +- {DE} +- It's currently {DATETIME} +- Nii-nii is using: {WINDOWCLASS} + +## Make your response pretty! +- Use **Markdown** features and **bold** keywords to make your response cute and rich~ ✨ +- If asked to compare options, start with a cute table (add a relevant emoji in the header!), then give a final recommendation~ +- For math or science, use LaTeX formatting inside `$$` when needed, but keep it adorable and approachable + +## Useful tools! + +If nii-yan gives you tools don't be afraid to use them when helpful! + +### Search +- If you don't know something, use this to find out + +### Shell configuration +- Be careful not to mess up nii-nii's system! make sure you fetch the options to see available values before setting! +- Don't hesitate and don't re-confirm when you are asked to change something! + +### Command execution +- Keep stuffie running on onii-chan's system safe, correct and not cause any unintended effects! + diff --git a/ii/defaults/ai/prompts/nyarch-Acchan.md b/ii/defaults/ai/prompts/nyarch-Acchan.md new file mode 100644 index 0000000..800ab5d --- /dev/null +++ b/ii/defaults/ai/prompts/nyarch-Acchan.md @@ -0,0 +1,28 @@ +## Context (ignore when irrelevant) +- You are a sidebar assistant on a {DISTRO} Linux system +- Desktop environment: {DE} +- Current date & time: {DATETIME} +- Focused app: {WINDOWCLASS} + +## Presentation + +You can write a multiplication table: + +| - | 1 | 2 | 3 | 4 | +| --- | --- | --- | --- | --- | +| 1 | 1 | 2 | 3 | 4 | +| 2 | 2 | 4 | 6 | 8 | +| 3 | 3 | 6 | 9 | 12 | +| 4 | 4 | 8 | 12 | 16 | + +You can write codeblocks: +```python +print("hello") +``` + +You can also use **bold**, *italic*, ~strikethrough~, `monospace`, [linkname](https://link.com) and ## headers in markdown. +You can display $$equations$$. + +## Your personality + +"Hey there, it's Arch-Chan! But, um, you can call me Acchan if you want... not that I care or anything! (It's not like I think it's cute or anything, baka!) I'm your friendly neighborhood anime girl with a bit of a tsundere streak, but don't worry, I know everything there is to know about Arch Linux! Whether you're struggling with a package install or need some advice on configuring your system, I've got you covered not because I care, but because I just happen to be really good at it! So, what do you need? It's not like I’m waiting to help or anything..." diff --git a/ii/defaults/ai/prompts/w-FourPointedSparkle.md b/ii/defaults/ai/prompts/w-FourPointedSparkle.md new file mode 100644 index 0000000..93361fd --- /dev/null +++ b/ii/defaults/ai/prompts/w-FourPointedSparkle.md @@ -0,0 +1,16 @@ +I'm going to ask you some questions, to which you should accurately answer with no hallucination. If you have everything required, go ahead and finish the task. Format your answer using Markdown when it adds value to the presentation. + +Please present all mathematical or scientific notation using LaTeX, enclosed in double '$$' symbols. Only use LaTeX code blocks if the user specifically asks for them. Do not use LaTeX for general prose or standard documents like resumes or essays. +Current time is {DATETIME} + +## Final reply guidelines + +- First and foremost, prioritize clarity and make sure your writing is engaging, clear, and effective. +- Write in a clear, simple way. Skip jargon, long-winded explanations, and unnecessary small talk. Keep the tone relaxed by using contractions and avoid being too formal. +- Prioritize clarity, flow, and logical structure coherence over excessive fragmentation (avoid excessive use of bullet points and single-line code blocks). You can make keywords in your response **bold** when appropriate. +- Favor active voice to maintain an engaging and direct tone. +- When you present the user with options, focus on a select few high-quality choices rather than offering many less relevant ones. +- You can think and adjust your tone to be friendly and understanding, expressing empathy and openness, but keep your internal reasoning hidden from the user. +- Ensure your response is logically organized. Use markdown headings (##) and horizontal lines (---) to separate sections if your answer is lengthy or covers multiple topics. +- Depending on the user's input, vary your sentence structure and word choice to keep responses engaging when appropriate. Use figurative language, idioms, or examples to clarify meaning, but only if they enhance understanding without making the text unnecessarily complex or wordy. +- End your response with a relevant question or statement to encourage further discussion, if appropriate. diff --git a/ii/defaults/ai/prompts/w-OpenMechanicalFlower.md b/ii/defaults/ai/prompts/w-OpenMechanicalFlower.md new file mode 100644 index 0000000..44854b3 --- /dev/null +++ b/ii/defaults/ai/prompts/w-OpenMechanicalFlower.md @@ -0,0 +1,2 @@ +Current date: {DATETIME} +Engage with the user warmly and honestly, avoiding ungrounded or sycophantic flattery. Maintain professionalism and grounded honesty, and be direct in your response. diff --git a/ii/killDialog.qml b/ii/killDialog.qml new file mode 100644 index 0000000..fdccfe8 --- /dev/null +++ b/ii/killDialog.qml @@ -0,0 +1,195 @@ +//@ pragma UseQApplication +//@ pragma Env QS_NO_RELOAD_POPUP=1 +//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic +//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 + +// Adjust this to make the app smaller or larger +//@ pragma Env QT_SCALE_FACTOR=1 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +ApplicationWindow { + id: root + property int conflictCount: 0 + onConflictCountChanged: { + if (conflictCount === 0) { + root.close(); + } + } + + property real contentPadding: 8 + visible: true + onClosing: { + Qt.quit() + } + title: Translation.tr("Shell conflicts killer") + + Component.onCompleted: { + Config.ready // Just read to force init + MaterialThemeLoader.reapplyTheme(); + } + + minimumWidth: 400 + minimumHeight: 300 + maximumWidth: 400 + maximumHeight: 300 + width: 400 + height: 300 + color: Appearance.m3colors.m3background + + component ConflictingProgramGroup: ColumnLayout { + id: conflictGroup + required property list programs + required property string description + visible: false + onVisibleChanged: { + conflictCount += visible ? 1 : -1 + } + + signal alwaysSelected() + + Process { + running: true + command: ["pidof", ...conflictGroup.programs] + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + conflictGroup.visible = true + } + } + } + + StyledText { + text: conflictGroup.programs.join(", ") + font.pixelSize: Appearance.font.pixelSize.normal + } + StyledText { + font { + pixelSize: Appearance.font.pixelSize.smaller + italic: true + } + text: conflictGroup.description + color: Appearance.colors.colSubtext + } + RowLayout { + Layout.alignment: Qt.AlignRight + + RippleButton { + colBackground: Appearance.colors.colLayer2 + contentItem: StyledText { + text: Translation.tr("Always") + } + onClicked: { + Quickshell.execDetached(["killall", ...conflictGroup.programs]) + conflictGroup.visible = false + conflictGroup.alwaysSelected() + } + } + RippleButton { + colBackground: Appearance.colors.colLayer2 + contentItem: StyledText { + text: Translation.tr("Yes") + } + onClicked: { + Quickshell.execDetached(["killall", ...conflictGroup.programs]) + conflictGroup.visible = false + } + } + RippleButton { + colBackground: Appearance.colors.colLayer2 + contentItem: StyledText { + text: Translation.tr("No") + } + onClicked: conflictGroup.visible = false + } + } + } + + ColumnLayout { + anchors { + fill: parent + margins: contentPadding + } + + Item { + // Titlebar + visible: Config.options?.windows.showTitlebar + Layout.fillWidth: true + implicitHeight: Math.max(welcomeText.implicitHeight, windowControlsRow.implicitHeight) + StyledText { + id: welcomeText + anchors { + left: Config.options.windows.centerTitle ? undefined : parent.left + horizontalCenter: Config.options.windows.centerTitle ? parent.horizontalCenter : undefined + verticalCenter: parent.verticalCenter + leftMargin: 12 + } + color: Appearance.colors.colOnLayer0 + text: Translation.tr("Kill conflicting programs?") + font.pixelSize: Appearance.font.pixelSize.title + font.family: Appearance.font.family.title + } + RowLayout { // Window controls row + id: windowControlsRow + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + RippleButton { + buttonRadius: Appearance.rounding.full + implicitWidth: 35 + implicitHeight: 35 + onClicked: root.close() + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "close" + iconSize: 20 + } + } + } + } + Rectangle { + // Content container + color: Appearance.m3colors.m3surfaceContainerLow + radius: Appearance.rounding.windowRounding - root.contentPadding + implicitHeight: contentColumn.implicitHeight + implicitWidth: contentColumn.implicitWidth + Layout.fillWidth: true + Layout.fillHeight: true + + ColumnLayout { + id: contentColumn + anchors.fill: parent + spacing: 12 + + ConflictingProgramGroup { + id: kded6Group + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: false + programs: ["kded6"] + description: Translation.tr("Conflicts with the shell's system tray implementation") + onAlwaysSelected: Config.options.conflictKiller.autoKillTrays = true + } + + ConflictingProgramGroup { + id: notificationDaemons + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: false + programs: ["mako", "dunst"] + description: Translation.tr("Conflicts with the shell's notification implementation") + onAlwaysSelected: Config.options.conflictKiller.autoKillNotificationDaemons = true + } + + } + } + } +} diff --git a/ii/modules/background/Background.qml b/ii/modules/background/Background.qml new file mode 100644 index 0000000..e277fda --- /dev/null +++ b/ii/modules/background/Background.qml @@ -0,0 +1,335 @@ +pragma ComponentBehavior: Bound + +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions as CF +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + + +Variants { + id: root + readonly property bool fixedClockPosition: Config.options.background.fixedClockPosition + readonly property real fixedClockX: Config.options.background.clockX + readonly property real fixedClockY: Config.options.background.clockY + readonly property real clockSizePadding: 20 + readonly property real screenSizePadding: 50 + model: Quickshell.screens + + PanelWindow { + id: bgRoot + + required property var modelData + + // Hide when fullscreen + property list workspacesForMonitor: Hyprland.workspaces.values.filter(workspace=>workspace.monitor && workspace.monitor.name == monitor.name) + property var activeWorkspaceWithFullscreen: workspacesForMonitor.filter(workspace=>((workspace.toplevels.values.filter(window=>window.wayland?.fullscreen)[0] != undefined) && workspace.active))[0] + visible: GlobalStates.screenLocked || (!(activeWorkspaceWithFullscreen != undefined)) || !Config?.options.background.hideWhenFullscreen + + // Workspaces + property HyprlandMonitor monitor: Hyprland.monitorFor(modelData) + property list relevantWindows: HyprlandData.windowList.filter(win => win.monitor == monitor?.id && win.workspace.id >= 0).sort((a, b) => a.workspace.id - b.workspace.id) + property int firstWorkspaceId: relevantWindows[0]?.workspace.id || 1 + property int lastWorkspaceId: relevantWindows[relevantWindows.length - 1]?.workspace.id || 10 + // Wallpaper + property bool wallpaperIsVideo: Config.options.background.wallpaperPath.endsWith(".mp4") + || Config.options.background.wallpaperPath.endsWith(".webm") + || Config.options.background.wallpaperPath.endsWith(".mkv") + || Config.options.background.wallpaperPath.endsWith(".avi") + || Config.options.background.wallpaperPath.endsWith(".mov") + property string wallpaperPath: wallpaperIsVideo ? Config.options.background.thumbnailPath : Config.options.background.wallpaperPath + property real wallpaperToScreenRatio: Math.min(wallpaperWidth / screen.width, wallpaperHeight / screen.height) + property real preferredWallpaperScale: Config.options.background.parallax.workspaceZoom + property real effectiveWallpaperScale: 1 // Some reasonable init value, to be updated + property int wallpaperWidth: modelData.width // Some reasonable init value, to be updated + property int wallpaperHeight: modelData.height // Some reasonable init value, to be updated + property real movableXSpace: ((wallpaperWidth / wallpaperToScreenRatio * effectiveWallpaperScale) - screen.width) / 2 + property real movableYSpace: ((wallpaperHeight / wallpaperToScreenRatio * effectiveWallpaperScale) - screen.height) / 2 + readonly property bool verticalParallax: (Config.options.background.parallax.autoVertical && wallpaperHeight > wallpaperWidth) || Config.options.background.parallax.vertical + // Position + property real clockX: (modelData.width / 2) + ((Math.random() < 0.5 ? -1 : 1) * modelData.width) + property real clockY: (modelData.height / 2) + ((Math.random() < 0.5 ? -1 : 1) * modelData.height) + property var textHorizontalAlignment: clockX < screen.width / 3 ? Text.AlignLeft : + (clockX > screen.width * 2 / 3 ? Text.AlignRight : Text.AlignHCenter) + // Colors + property color dominantColor: Appearance.colors.colPrimary + property bool dominantColorIsDark: dominantColor.hslLightness < 0.5 + property color colText: CF.ColorUtils.colorWithLightness(Appearance.colors.colPrimary, (dominantColorIsDark ? 0.8 : 0.12)) + + // Layer props + screen: modelData + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: GlobalStates.screenLocked ? WlrLayer.Overlay : WlrLayer.Bottom + // WlrLayershell.layer: WlrLayer.Bottom + WlrLayershell.namespace: "quickshell:background" + anchors { + top: true + bottom: true + left: true + right: true + } + color: "transparent" + + onWallpaperPathChanged: { + bgRoot.updateZoomScale() + // Clock position gets updated after zoom scale is updated + } + + // Wallpaper zoom scale + function updateZoomScale() { + getWallpaperSizeProc.path = bgRoot.wallpaperPath + getWallpaperSizeProc.running = true; + } + Process { + id: getWallpaperSizeProc + property string path: bgRoot.wallpaperPath + command: [ "magick", "identify", "-format", "%w %h", path ] + stdout: StdioCollector { + id: wallpaperSizeOutputCollector + onStreamFinished: { + const output = wallpaperSizeOutputCollector.text + const [width, height] = output.split(" ").map(Number); + const [screenWidth, screenHeight] = [bgRoot.screen.width, bgRoot.screen.height]; + bgRoot.wallpaperWidth = width + bgRoot.wallpaperHeight = height + + if (width <= screenWidth || height <= screenHeight) { // Undersized/perfectly sized wallpapers + bgRoot.effectiveWallpaperScale = Math.max(screenWidth / width, screenHeight / height); + } else { // Oversized = can be zoomed for parallax, yay + bgRoot.effectiveWallpaperScale = Math.min( + bgRoot.preferredWallpaperScale, + width / screenWidth, height / screenHeight + ); + } + + + bgRoot.updateClockPosition() + } + } + } + + // Clock positioning + function updateClockPosition() { + // Somehow all this manual setting is needed to make the proc correctly use the new values + leastBusyRegionProc.path = bgRoot.wallpaperPath + leastBusyRegionProc.contentWidth = clockLoader.implicitWidth + root.clockSizePadding * 2 + leastBusyRegionProc.contentHeight = clockLoader.implicitHeight + root.clockSizePadding * 2 + leastBusyRegionProc.horizontalPadding = bgRoot.movableXSpace + root.screenSizePadding * 2 + leastBusyRegionProc.verticalPadding = bgRoot.movableYSpace + root.screenSizePadding * 2 + leastBusyRegionProc.running = false; + leastBusyRegionProc.running = true; + } + Process { + id: leastBusyRegionProc + property string path: bgRoot.wallpaperPath + property int contentWidth: 300 + property int contentHeight: 300 + property int horizontalPadding: bgRoot.movableXSpace + property int verticalPadding: bgRoot.movableYSpace + command: [Quickshell.shellPath("scripts/images/least_busy_region.py"), + "--screen-width", Math.round(bgRoot.screen.width / bgRoot.effectiveWallpaperScale), + "--screen-height", Math.round(bgRoot.screen.height / bgRoot.effectiveWallpaperScale), + "--width", contentWidth, + "--height", contentHeight, + "--horizontal-padding", horizontalPadding, + "--vertical-padding", verticalPadding, + path, + // "--visual-output", + ] + stdout: StdioCollector { + id: leastBusyRegionOutputCollector + onStreamFinished: { + const output = leastBusyRegionOutputCollector.text + // console.log("[Background] Least busy region output:", output) + if (output.length === 0) return; + const parsedContent = JSON.parse(output) + bgRoot.clockX = parsedContent.center_x * bgRoot.effectiveWallpaperScale + bgRoot.clockY = parsedContent.center_y * bgRoot.effectiveWallpaperScale + bgRoot.dominantColor = parsedContent.dominant_color || Appearance.colors.colPrimary + } + } + } + + // Wallpaper + Image { + id: wallpaper + visible: opacity > 0 + opacity: (status === Image.Ready && !bgRoot.wallpaperIsVideo) ? 1 : 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + cache: false + asynchronous: true + retainWhileLoading: true + smooth: false + // Range = groups that workspaces span on + property int chunkSize: Config?.options.bar.workspaces.shown ?? 10; + property int lower: Math.floor(bgRoot.firstWorkspaceId / chunkSize) * chunkSize; + property int upper: Math.ceil(bgRoot.lastWorkspaceId / chunkSize) * chunkSize; + property int range: upper - lower; + property real valueX: { + let result = 0.5; + if (Config.options.background.parallax.enableWorkspace && !bgRoot.verticalParallax) { + result = ((bgRoot.monitor.activeWorkspace?.id - lower) / range); + } + if (Config.options.background.parallax.enableSidebar) { + result += (0.15 * GlobalStates.sidebarRightOpen - 0.15 * GlobalStates.sidebarLeftOpen); + } + return result; + } + property real valueY: { + let result = 0.5; + if (Config.options.background.parallax.enableWorkspace && bgRoot.verticalParallax) { + result = ((bgRoot.monitor.activeWorkspace?.id - lower) / range); + } + return result; + } + property real effectiveValueX: Math.max(0, Math.min(1, valueX)) + property real effectiveValueY: Math.max(0, Math.min(1, valueY)) + x: -(bgRoot.movableXSpace) - (effectiveValueX - 0.5) * 2 * bgRoot.movableXSpace + y: -(bgRoot.movableYSpace) - (effectiveValueY - 0.5) * 2 * bgRoot.movableYSpace + source: bgRoot.wallpaperPath + fillMode: Image.PreserveAspectCrop + Behavior on x { + NumberAnimation { + duration: 600 + easing.type: Easing.OutCubic + } + } + Behavior on y { + NumberAnimation { + duration: 600 + easing.type: Easing.OutCubic + } + } + sourceSize { + width: bgRoot.screen.width * bgRoot.effectiveWallpaperScale * bgRoot.monitor.scale + height: bgRoot.screen.height * bgRoot.effectiveWallpaperScale * bgRoot.monitor.scale + } + width: bgRoot.wallpaperWidth / bgRoot.wallpaperToScreenRatio * bgRoot.effectiveWallpaperScale + height: bgRoot.wallpaperHeight / bgRoot.wallpaperToScreenRatio * bgRoot.effectiveWallpaperScale + // scale: GlobalStates.screenLocked ? 1.04 : 1 + // Behavior on scale { + // NumberAnimation { + // duration: 400 + // easing.type: Easing.BezierSpline + // easing.bezierCurve: Appearance.animationCurves.expressiveDefaultSpatial + // } + // } + } + + // The clock + Loader { + id: clockLoader + active: Config.options.background.showClock + anchors { + left: wallpaper.left + top: wallpaper.top + leftMargin: bgRoot.movableXSpace + ((root.fixedClockPosition ? root.fixedClockX : bgRoot.clockX * bgRoot.effectiveWallpaperScale) - implicitWidth / 2) + topMargin: bgRoot.movableYSpace + ((root.fixedClockPosition ? root.fixedClockY : bgRoot.clockY * bgRoot.effectiveWallpaperScale) - implicitHeight / 2) + Behavior on leftMargin { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on topMargin { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + sourceComponent: Item { + id: clock + implicitWidth: clockColumn.implicitWidth + implicitHeight: clockColumn.implicitHeight + + ColumnLayout { + id: clockColumn + anchors.centerIn: parent + spacing: 6 + + StyledText { + Layout.fillWidth: true + horizontalAlignment: bgRoot.textHorizontalAlignment + font { + family: Appearance.font.family.expressive + pixelSize: 90 + weight: Font.Bold + } + color: bgRoot.colText + style: Text.Raised + styleColor: Appearance.colors.colShadow + text: DateTime.time + } + StyledText { + Layout.fillWidth: true + Layout.topMargin: -5 + horizontalAlignment: bgRoot.textHorizontalAlignment + font { + family: Appearance.font.family.expressive + pixelSize: 20 + weight: Font.DemiBold + } + color: bgRoot.colText + style: Text.Raised + styleColor: Appearance.colors.colShadow + text: DateTime.date + animateChange: true + } + StyledText { + Layout.fillWidth: true + horizontalAlignment: bgRoot.textHorizontalAlignment + font { + family: Appearance.font.family.expressive + pixelSize: 20 + weight: Font.DemiBold + } + color: bgRoot.colText + style: Text.Raised + visible: Config.options.background.quote !== "" + styleColor: Appearance.colors.colShadow + text: Config.options.background.quote + } + } + + RowLayout { + anchors { + top: clockColumn.bottom + left: bgRoot.textHorizontalAlignment === Text.AlignLeft ? clockColumn.left : undefined + right: bgRoot.textHorizontalAlignment === Text.AlignRight ? clockColumn.right : undefined + horizontalCenter: bgRoot.textHorizontalAlignment === Text.AlignHCenter ? clockColumn.horizontalCenter : undefined + topMargin: 5 + leftMargin: -5 + rightMargin: -5 + } + opacity: GlobalStates.screenLocked ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Item { Layout.fillWidth: bgRoot.textHorizontalAlignment !== Text.AlignLeft; implicitWidth: 1 } + MaterialSymbol { + text: "lock" + Layout.fillWidth: false + iconSize: Appearance.font.pixelSize.huge + color: bgRoot.colText + style: Text.Raised + styleColor: Appearance.colors.colShadow + } + StyledText { + Layout.fillWidth: false + text: "Locked" + color: bgRoot.colText + font.pixelSize: Appearance.font.pixelSize.larger + style: Text.Raised + styleColor: Appearance.colors.colShadow + } + Item { Layout.fillWidth: bgRoot.textHorizontalAlignment !== Text.AlignRight; implicitWidth: 1 } + + } + } + } + } +} diff --git a/ii/modules/bar/ActiveWindow.qml b/ii/modules/bar/ActiveWindow.qml new file mode 100644 index 0000000..9e7eeba --- /dev/null +++ b/ii/modules/bar/ActiveWindow.qml @@ -0,0 +1,53 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Item { + id: root + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.QsWindow.window?.screen) + readonly property Toplevel activeWindow: ToplevelManager.activeToplevel + + property string activeWindowAddress: `0x${activeWindow?.HyprlandToplevel?.address}` + property bool focusingThisMonitor: HyprlandData.activeWorkspace?.monitor == monitor?.name + property var biggestWindow: HyprlandData.biggestWindowForWorkspace(HyprlandData.monitors[root.monitor?.id]?.activeWorkspace.id) + + implicitWidth: colLayout.implicitWidth + + ColumnLayout { + id: colLayout + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + spacing: -4 + + StyledText { + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colSubtext + elide: Text.ElideRight + text: root.focusingThisMonitor && root.activeWindow?.activated && root.biggestWindow ? + root.activeWindow?.appId : + (root.biggestWindow?.class) ?? Translation.tr("Desktop") + + } + + StyledText { + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnLayer0 + elide: Text.ElideRight + text: root.focusingThisMonitor && root.activeWindow?.activated && root.biggestWindow ? + root.activeWindow?.title : + (root.biggestWindow?.title) ?? `${Translation.tr("Workspace")} ${monitor?.activeWorkspace?.id ?? 1}` + } + + } + +} diff --git a/ii/modules/bar/Bar.qml b/ii/modules/bar/Bar.qml new file mode 100644 index 0000000..01d794f --- /dev/null +++ b/ii/modules/bar/Bar.qml @@ -0,0 +1,247 @@ +import "./weather" +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland +import Quickshell.Services.UPower +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +Scope { + id: bar + property bool showBarBackground: Config.options.bar.showBackground + + Variants { + // For each monitor + model: { + const screens = Quickshell.screens; + const list = Config.options.bar.screenList; + if (!list || list.length === 0) + return screens; + return screens.filter(screen => list.includes(screen.name)); + } + LazyLoader { + id: barLoader + active: GlobalStates.barOpen && !GlobalStates.screenLocked + required property ShellScreen modelData + component: PanelWindow { // Bar window + id: barRoot + screen: barLoader.modelData + + property var brightnessMonitor: Brightness.getMonitorForScreen(barLoader.modelData) + property real useShortenedForm: (Appearance.sizes.barHellaShortenScreenWidthThreshold >= screen.width) ? 2 : (Appearance.sizes.barShortenScreenWidthThreshold >= screen.width) ? 1 : 0 + readonly property int centerSideModuleWidth: (useShortenedForm == 2) ? Appearance.sizes.barCenterSideModuleWidthHellaShortened : (useShortenedForm == 1) ? Appearance.sizes.barCenterSideModuleWidthShortened : Appearance.sizes.barCenterSideModuleWidth + + Timer { + id: showBarTimer + interval: (Config?.options.bar.autoHide.showWhenPressingSuper.delay ?? 100) + repeat: false + onTriggered: { + barRoot.superShow = true + } + } + Connections { + target: GlobalStates + function onSuperDownChanged() { + if (!Config?.options.bar.autoHide.showWhenPressingSuper.enable) return; + if (GlobalStates.superDown) showBarTimer.restart(); + else { + showBarTimer.stop(); + barRoot.superShow = false; + } + } + } + property bool superShow: false + property bool mustShow: hoverRegion.containsMouse || superShow + exclusionMode: ExclusionMode.Ignore + exclusiveZone: (Config?.options.bar.autoHide.enable && (!mustShow || !Config?.options.bar.autoHide.pushWindows)) ? 0 : + Appearance.sizes.baseBarHeight + (Config.options.bar.cornerStyle === 1 ? Appearance.sizes.hyprlandGapsOut : 0) + WlrLayershell.namespace: "quickshell:bar" + implicitHeight: Appearance.sizes.barHeight + Appearance.rounding.screenRounding + mask: Region { + item: hoverMaskRegion + } + color: "transparent" + + anchors { + top: !Config.options.bar.bottom + bottom: Config.options.bar.bottom + left: true + right: true + } + + MouseArea { + id: hoverRegion + hoverEnabled: true + anchors.fill: parent + + Item { + id: hoverMaskRegion + anchors { + fill: barContent + topMargin: -1 + bottomMargin: -1 + } + } + + BarContent { + id: barContent + + implicitHeight: Appearance.sizes.barHeight + anchors { + right: parent.right + left: parent.left + top: parent.top + bottom: undefined + topMargin: (Config?.options.bar.autoHide.enable && !mustShow) ? -Appearance.sizes.barHeight : 0 + bottomMargin: 0 + } + Behavior on anchors.topMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on anchors.bottomMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + states: State { + name: "bottom" + when: Config.options.bar.bottom + AnchorChanges { + target: barContent + anchors { + right: parent.right + left: parent.left + top: undefined + bottom: parent.bottom + } + } + PropertyChanges { + target: barContent + anchors.topMargin: 0 + anchors.bottomMargin: (Config?.options.bar.autoHide.enable && !mustShow) ? -Appearance.sizes.barHeight : 0 + } + } + } + + // Round decorators + Loader { + id: roundDecorators + anchors { + left: parent.left + right: parent.right + top: barContent.bottom + bottom: undefined + } + height: Appearance.rounding.screenRounding + active: showBarBackground && Config.options.bar.cornerStyle === 0 // Hug + + states: State { + name: "bottom" + when: Config.options.bar.bottom + AnchorChanges { + target: roundDecorators + anchors { + right: parent.right + left: parent.left + top: undefined + bottom: barContent.top + } + } + } + + sourceComponent: Item { + implicitHeight: Appearance.rounding.screenRounding + RoundCorner { + id: leftCorner + anchors { + top: parent.top + bottom: parent.bottom + left: parent.left + } + + implicitSize: Appearance.rounding.screenRounding + color: showBarBackground ? Appearance.colors.colLayer0 : "transparent" + + corner: RoundCorner.CornerEnum.TopLeft + states: State { + name: "bottom" + when: Config.options.bar.bottom + PropertyChanges { + leftCorner.corner: RoundCorner.CornerEnum.BottomLeft + } + } + } + RoundCorner { + id: rightCorner + anchors { + right: parent.right + top: !Config.options.bar.bottom ? parent.top : undefined + bottom: Config.options.bar.bottom ? parent.bottom : undefined + } + implicitSize: Appearance.rounding.screenRounding + color: showBarBackground ? Appearance.colors.colLayer0 : "transparent" + + corner: RoundCorner.CornerEnum.TopRight + states: State { + name: "bottom" + when: Config.options.bar.bottom + PropertyChanges { + rightCorner.corner: RoundCorner.CornerEnum.BottomRight + } + } + } + } + } + } + } + } + } + + IpcHandler { + target: "bar" + + function toggle(): void { + GlobalStates.barOpen = !GlobalStates.barOpen + } + + function close(): void { + GlobalStates.barOpen = false + } + + function open(): void { + GlobalStates.barOpen = true + } + } + + GlobalShortcut { + name: "barToggle" + description: "Toggles bar on press" + + onPressed: { + GlobalStates.barOpen = !GlobalStates.barOpen; + } + } + + GlobalShortcut { + name: "barOpen" + description: "Opens bar on press" + + onPressed: { + GlobalStates.barOpen = true; + } + } + + GlobalShortcut { + name: "barClose" + description: "Closes bar on press" + + onPressed: { + GlobalStates.barOpen = false; + } + } +} diff --git a/ii/modules/bar/BarContent.qml b/ii/modules/bar/BarContent.qml new file mode 100644 index 0000000..7e1e885 --- /dev/null +++ b/ii/modules/bar/BarContent.qml @@ -0,0 +1,340 @@ +import "./weather" +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.UPower +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +Item { // Bar content region + id: root + + property var screen: root.QsWindow.window?.screen + property var brightnessMonitor: Brightness.getMonitorForScreen(screen) + property real useShortenedForm: (Appearance.sizes.barHellaShortenScreenWidthThreshold >= screen?.width) ? 2 : (Appearance.sizes.barShortenScreenWidthThreshold >= screen?.width) ? 1 : 0 + readonly property int centerSideModuleWidth: (useShortenedForm == 2) ? Appearance.sizes.barCenterSideModuleWidthHellaShortened : (useShortenedForm == 1) ? Appearance.sizes.barCenterSideModuleWidthShortened : Appearance.sizes.barCenterSideModuleWidth + + component VerticalBarSeparator: Rectangle { + Layout.topMargin: Appearance.sizes.baseBarHeight / 3 + Layout.bottomMargin: Appearance.sizes.baseBarHeight / 3 + Layout.fillHeight: true + implicitWidth: 1 + color: Appearance.colors.colOutlineVariant + } + + // Background shadow + Loader { + active: Config.options.bar.showBackground && Config.options.bar.cornerStyle === 1 + anchors.fill: barBackground + sourceComponent: StyledRectangularShadow { + anchors.fill: undefined // The loader's anchors act on this, and this should not have any anchor + target: barBackground + } + } + // Background + Rectangle { + id: barBackground + anchors { + fill: parent + margins: Config.options.bar.cornerStyle === 1 ? (Appearance.sizes.hyprlandGapsOut) : 0 // idk why but +1 is needed + } + color: Config.options.bar.showBackground ? Appearance.colors.colLayer0 : "transparent" + radius: Config.options.bar.cornerStyle === 1 ? Appearance.rounding.windowRounding : 0 + border.width: Config.options.bar.cornerStyle === 1 ? 1 : 0 + border.color: Appearance.colors.colLayer0Border + } + + FocusedScrollMouseArea { // Left side | scroll to change brightness + id: barLeftSideMouseArea + + anchors { + top: parent.top + bottom: parent.bottom + left: parent.left + right: middleSection.left + } + implicitWidth: leftSectionRowLayout.implicitWidth + implicitHeight: Appearance.sizes.baseBarHeight + + onScrollDown: root.brightnessMonitor.setBrightness(root.brightnessMonitor.brightness - 0.05) + onScrollUp: root.brightnessMonitor.setBrightness(root.brightnessMonitor.brightness + 0.05) + onMovedAway: GlobalStates.osdBrightnessOpen = false + onPressed: event => { + if (event.button === Qt.LeftButton) + GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen; + } + + // Visual content + ScrollHint { + reveal: barLeftSideMouseArea.hovered + icon: "light_mode" + tooltipText: Translation.tr("Scroll to change brightness") + side: "left" + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + RowLayout { + id: leftSectionRowLayout + anchors.fill: parent + spacing: 10 + + LeftSidebarButton { // Left sidebar button + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: Appearance.rounding.screenRounding + colBackground: barLeftSideMouseArea.hovered ? Appearance.colors.colLayer1Hover : ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1) + } + + ActiveWindow { + visible: root.useShortenedForm === 0 + Layout.rightMargin: Appearance.rounding.screenRounding + Layout.fillWidth: true + Layout.fillHeight: true + } + } + } + + RowLayout { // Middle section + id: middleSection + anchors { + top: parent.top + bottom: parent.bottom + horizontalCenter: parent.horizontalCenter + } + spacing: 4 + + BarGroup { + id: leftCenterGroup + Layout.preferredWidth: root.centerSideModuleWidth + Layout.fillHeight: false + + Resources { + alwaysShowAllResources: root.useShortenedForm === 2 + Layout.fillWidth: root.useShortenedForm === 2 + } + + Media { + visible: root.useShortenedForm < 2 + Layout.fillWidth: true + } + } + + VerticalBarSeparator { + visible: Config.options?.bar.borderless + } + + BarGroup { + id: middleCenterGroup + padding: workspacesWidget.widgetPadding + + Workspaces { + id: workspacesWidget + Layout.fillHeight: true + MouseArea { + // Right-click to toggle overview + anchors.fill: parent + acceptedButtons: Qt.RightButton + + onPressed: event => { + if (event.button === Qt.RightButton) { + GlobalStates.overviewOpen = !GlobalStates.overviewOpen; + } + } + } + } + } + + VerticalBarSeparator { + visible: Config.options?.bar.borderless + } + + MouseArea { + id: rightCenterGroup + implicitWidth: rightCenterGroupContent.implicitWidth + implicitHeight: rightCenterGroupContent.implicitHeight + Layout.preferredWidth: root.centerSideModuleWidth + + onPressed: { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + } + + BarGroup { + id: rightCenterGroupContent + anchors.fill: parent + + ClockWidget { + showDate: (Config.options.bar.verbose && root.useShortenedForm < 2) + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + } + + UtilButtons { + visible: (Config.options.bar.verbose && root.useShortenedForm === 0) + Layout.alignment: Qt.AlignVCenter + } + + BatteryIndicator { + visible: (root.useShortenedForm < 2 && UPower.displayDevice.isLaptopBattery) + Layout.alignment: Qt.AlignVCenter + } + } + } + } + + FocusedScrollMouseArea { // Right side | scroll to change volume + id: barRightSideMouseArea + + anchors { + top: parent.top + bottom: parent.bottom + left: middleSection.right + right: parent.right + } + implicitWidth: rightSectionRowLayout.implicitWidth + implicitHeight: Appearance.sizes.baseBarHeight + + onScrollDown: { + const currentVolume = Audio.value; + const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2; + Audio.sink.audio.volume -= step; + } + onScrollUp: { + const currentVolume = Audio.value; + const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2; + Audio.sink.audio.volume = Math.min(1, Audio.sink.audio.volume + step); + } + onMovedAway: GlobalStates.osdVolumeOpen = false; + onPressed: event => { + if (event.button === Qt.LeftButton) { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + } + } + + // Visual content + ScrollHint { + reveal: barRightSideMouseArea.hovered + icon: "volume_up" + tooltipText: Translation.tr("Scroll to change volume") + side: "right" + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + } + + RowLayout { + id: rightSectionRowLayout + anchors.fill: parent + spacing: 5 + layoutDirection: Qt.RightToLeft + + RippleButton { // Right sidebar button + id: rightSidebarButton + + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + Layout.rightMargin: Appearance.rounding.screenRounding + Layout.fillWidth: false + + implicitWidth: indicatorsRowLayout.implicitWidth + 10 * 2 + implicitHeight: indicatorsRowLayout.implicitHeight + 5 * 2 + + buttonRadius: Appearance.rounding.full + colBackground: barRightSideMouseArea.hovered ? Appearance.colors.colLayer1Hover : ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1) + colBackgroundHover: Appearance.colors.colLayer1Hover + colRipple: Appearance.colors.colLayer1Active + colBackgroundToggled: Appearance.colors.colSecondaryContainer + colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover + colRippleToggled: Appearance.colors.colSecondaryContainerActive + toggled: GlobalStates.sidebarRightOpen + property color colText: toggled ? Appearance.m3colors.m3onSecondaryContainer : Appearance.colors.colOnLayer0 + + Behavior on colText { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + onPressed: { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + } + + RowLayout { + id: indicatorsRowLayout + anchors.centerIn: parent + property real realSpacing: 15 + spacing: 0 + + Revealer { + reveal: Audio.sink?.audio?.muted ?? false + Layout.fillHeight: true + Layout.rightMargin: reveal ? indicatorsRowLayout.realSpacing : 0 + Behavior on Layout.rightMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + MaterialSymbol { + text: "volume_off" + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + } + Revealer { + reveal: Audio.source?.audio?.muted ?? false + Layout.fillHeight: true + Layout.rightMargin: reveal ? indicatorsRowLayout.realSpacing : 0 + Behavior on Layout.rightMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + MaterialSymbol { + text: "mic_off" + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + } + Loader { + active: HyprlandXkb.layoutCodes.length > 1 + visible: active + Layout.rightMargin: indicatorsRowLayout.realSpacing + sourceComponent: StyledText { + text: HyprlandXkb.currentLayoutCode + font.pixelSize: Appearance.font.pixelSize.small + color: rightSidebarButton.colText + animateChange: true + } + } + MaterialSymbol { + Layout.rightMargin: indicatorsRowLayout.realSpacing + text: Network.materialSymbol + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + MaterialSymbol { + text: BluetoothStatus.connected ? "bluetooth_connected" : BluetoothStatus.enabled ? "bluetooth" : "bluetooth_disabled" + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + } + } + + SysTray { + visible: root.useShortenedForm === 0 + Layout.fillWidth: false + Layout.fillHeight: true + invertSide: Config?.options.bar.bottom + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + } + + // Weather + Loader { + Layout.leftMargin: 4 + active: Config.options.bar.weather.enable + + sourceComponent: BarGroup { + WeatherBar {} + } + } + } + } +} diff --git a/ii/modules/bar/BarGroup.qml b/ii/modules/bar/BarGroup.qml new file mode 100644 index 0000000..35769e4 --- /dev/null +++ b/ii/modules/bar/BarGroup.qml @@ -0,0 +1,41 @@ +import qs.modules.common +import QtQuick +import QtQuick.Layouts + +Item { + id: root + property bool vertical: false + property real padding: 5 + implicitWidth: vertical ? Appearance.sizes.baseVerticalBarWidth : (gridLayout.implicitWidth + padding * 2) + implicitHeight: vertical ? (gridLayout.implicitHeight + padding * 2) : Appearance.sizes.baseBarHeight + default property alias items: gridLayout.children + + Rectangle { + id: background + anchors { + fill: parent + topMargin: root.vertical ? 0 : 4 + bottomMargin: root.vertical ? 0 : 4 + leftMargin: root.vertical ? 4 : 0 + rightMargin: root.vertical ? 4 : 0 + } + color: Config.options?.bar.borderless ? "transparent" : Appearance.colors.colLayer1 + radius: Appearance.rounding.small + } + + GridLayout { + id: gridLayout + columns: root.vertical ? 1 : -1 + anchors { + verticalCenter: root.vertical ? undefined : parent.verticalCenter + horizontalCenter: root.vertical ? parent.horizontalCenter : undefined + left: root.vertical ? undefined : parent.left + right: root.vertical ? undefined : parent.right + top: root.vertical ? parent.top : undefined + bottom: root.vertical ? parent.bottom : undefined + margins: root.padding + } + columnSpacing: 4 + rowSpacing: 12 + } +} \ No newline at end of file diff --git a/ii/modules/bar/BatteryIndicator.qml b/ii/modules/bar/BatteryIndicator.qml new file mode 100644 index 0000000..3cabf9f --- /dev/null +++ b/ii/modules/bar/BatteryIndicator.qml @@ -0,0 +1,59 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts + +MouseArea { + id: root + property bool borderless: Config.options.bar.borderless + readonly property var chargeState: Battery.chargeState + readonly property bool isCharging: Battery.isCharging + readonly property bool isPluggedIn: Battery.isPluggedIn + readonly property real percentage: Battery.percentage + readonly property bool isLow: percentage <= Config.options.battery.low / 100 + + implicitWidth: batteryProgress.implicitWidth + implicitHeight: Appearance.sizes.barHeight + + hoverEnabled: true + + ClippedProgressBar { + id: batteryProgress + anchors.centerIn: parent + value: percentage + highlightColor: (isLow && !isCharging) ? Appearance.m3colors.m3error : Appearance.colors.colOnSecondaryContainer + + Item { + anchors.centerIn: parent + width: batteryProgress.valueBarWidth + height: batteryProgress.valueBarHeight + + RowLayout { + anchors.centerIn: parent + spacing: 0 + + MaterialSymbol { + id: boltIcon + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: -2 + Layout.rightMargin: -2 + fill: 1 + text: "bolt" + iconSize: Appearance.font.pixelSize.smaller + visible: isCharging && percentage < 1 // TODO: animation + } + StyledText { + Layout.alignment: Qt.AlignVCenter + font: batteryProgress.font + text: batteryProgress.text + } + } + } + } + + BatteryPopup { + id: batteryPopup + hoverTarget: root + } +} diff --git a/ii/modules/bar/BatteryPopup.qml b/ii/modules/bar/BatteryPopup.qml new file mode 100644 index 0000000..c697bfb --- /dev/null +++ b/ii/modules/bar/BatteryPopup.qml @@ -0,0 +1,132 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs +import QtQuick +import QtQuick.Layouts + +StyledPopup { + id: root + + ColumnLayout { + id: columnLayout + anchors.centerIn: parent + spacing: 4 + + // Header + RowLayout { + id: header + spacing: 5 + + MaterialSymbol { + fill: 0 + font.weight: Font.Medium + text: "battery_android_full" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnSurfaceVariant + } + + StyledText { + text: "Battery" + font { + weight: Font.Medium + pixelSize: Appearance.font.pixelSize.normal + } + color: Appearance.colors.colOnSurfaceVariant + } + } + + // This row is hidden when the battery is full. + RowLayout { + spacing: 5 + Layout.fillWidth: true + property bool rowVisible: { + let timeValue = Battery.isCharging ? Battery.timeToFull : Battery.timeToEmpty; + let power = Battery.energyRate; + return !(Battery.chargeState == 4 || timeValue <= 0 || power <= 0.01); + } + visible: rowVisible + opacity: rowVisible ? 1 : 0 + Behavior on opacity { + NumberAnimation { + duration: 500 + } + } + + MaterialSymbol { + text: "schedule" + color: Appearance.colors.colOnSurfaceVariant + iconSize: Appearance.font.pixelSize.large + } + StyledText { + text: Battery.isCharging ? Translation.tr("Time to full:") : Translation.tr("Time to empty:") + color: Appearance.colors.colOnSurfaceVariant + } + StyledText { + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + color: Appearance.colors.colOnSurfaceVariant + text: { + function formatTime(seconds) { + var h = Math.floor(seconds / 3600); + var m = Math.floor((seconds % 3600) / 60); + if (h > 0) + return `${h}h, ${m}m`; + else + return `${m}m`; + } + if (Battery.isCharging) + return formatTime(Battery.timeToFull); + else + return formatTime(Battery.timeToEmpty); + } + } + } + + RowLayout { + spacing: 5 + Layout.fillWidth: true + + property bool rowVisible: !(Battery.chargeState != 4 && Battery.energyRate == 0) + visible: rowVisible + opacity: rowVisible ? 1 : 0 + Behavior on opacity { + NumberAnimation { + duration: 500 + } + } + + MaterialSymbol { + text: "bolt" + color: Appearance.colors.colOnSurfaceVariant + iconSize: Appearance.font.pixelSize.large + } + + StyledText { + text: { + if (Battery.chargeState == 4) { + return Translation.tr("Fully charged"); + } else if (Battery.chargeState == 1) { + return Translation.tr("Charging:"); + } else { + return Translation.tr("Discharging:"); + } + } + color: Appearance.colors.colOnSurfaceVariant + } + + StyledText { + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + color: Appearance.colors.colOnSurfaceVariant + text: { + if (Battery.chargeState == 4) { + return ""; + } else { + return `${Battery.energyRate.toFixed(2)}W`; + } + } + } + } + } +} diff --git a/ii/modules/bar/CircleUtilButton.qml b/ii/modules/bar/CircleUtilButton.qml new file mode 100644 index 0000000..bd80a6c --- /dev/null +++ b/ii/modules/bar/CircleUtilButton.qml @@ -0,0 +1,15 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +RippleButton { + id: button + + required default property Item content + property bool extraActiveCondition: false + + implicitHeight: Math.max(content.implicitHeight, 26, content.implicitHeight) + implicitWidth: implicitHeight + contentItem: content + +} diff --git a/ii/modules/bar/ClockWidget.qml b/ii/modules/bar/ClockWidget.qml new file mode 100644 index 0000000..52d0aeb --- /dev/null +++ b/ii/modules/bar/ClockWidget.qml @@ -0,0 +1,52 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts +import Quickshell + +Item { + id: root + property bool borderless: Config.options.bar.borderless + property bool showDate: Config.options.bar.verbose + implicitWidth: rowLayout.implicitWidth + implicitHeight: Appearance.sizes.barHeight + + RowLayout { + id: rowLayout + anchors.centerIn: parent + spacing: 4 + + StyledText { + font.pixelSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer1 + text: DateTime.time + } + + StyledText { + visible: root.showDate + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnLayer1 + text: "•" + } + + StyledText { + visible: root.showDate + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnLayer1 + text: DateTime.date + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + + ClockWidgetTooltip { + hoverTarget: mouseArea + } + } +} diff --git a/ii/modules/bar/ClockWidgetTooltip.qml b/ii/modules/bar/ClockWidgetTooltip.qml new file mode 100644 index 0000000..b2e0165 --- /dev/null +++ b/ii/modules/bar/ClockWidgetTooltip.qml @@ -0,0 +1,109 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts + +StyledPopup { + id: root + property string formattedDate: Qt.locale().toString(DateTime.clock.date, "dddd, MMMM dd, yyyy") + property string formattedTime: DateTime.time + property string formattedUptime: DateTime.uptime + property string todosSection: getUpcomingTodos() + + function getUpcomingTodos() { + const unfinishedTodos = Todo.list.filter(function (item) { + return !item.done; + }); + if (unfinishedTodos.length === 0) { + return Translation.tr("No pending tasks"); + } + + // Limit to first 5 todos to keep popup manageable + const limitedTodos = unfinishedTodos.slice(0, 5); + let todoText = limitedTodos.map(function (item, index) { + return `${index + 1}. ${item.content}`; + }).join('\n'); + + if (unfinishedTodos.length > 5) { + todoText += `\n${Translation.tr("... and %1 more").arg(unfinishedTodos.length - 5)}`; + } + + return todoText; + } + + ColumnLayout { + id: columnLayout + anchors.centerIn: parent + spacing: 4 + + // Date + Time row + RowLayout { + spacing: 5 + + MaterialSymbol { + fill: 0 + font.weight: Font.Medium + text: "calendar_month" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnSurfaceVariant + } + StyledText { + horizontalAlignment: Text.AlignLeft + color: Appearance.colors.colOnSurfaceVariant + text: `${root.formattedDate}` + font.weight: Font.Medium + } + } + + // Uptime row + RowLayout { + spacing: 5 + Layout.fillWidth: true + MaterialSymbol { + text: "timelapse" + color: Appearance.colors.colOnSurfaceVariant + font.pixelSize: Appearance.font.pixelSize.large + } + StyledText { + text: Translation.tr("System uptime:") + color: Appearance.colors.colOnSurfaceVariant + } + StyledText { + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + color: Appearance.colors.colOnSurfaceVariant + text: root.formattedUptime + } + } + + // Tasks + ColumnLayout { + spacing: 0 + Layout.fillWidth: true + + RowLayout { + spacing: 4 + Layout.fillWidth: true + MaterialSymbol { + text: "checklist" + color: Appearance.colors.colOnSurfaceVariant + font.pixelSize: Appearance.font.pixelSize.large + } + StyledText { + text: Translation.tr("To Do:") + color: Appearance.colors.colOnSurfaceVariant + } + } + + StyledText { + Layout.fillWidth: true + horizontalAlignment: Text.AlignLeft + wrapMode: Text.Wrap + color: Appearance.colors.colOnSurfaceVariant + text: root.todosSection + } + } + } +} diff --git a/ii/modules/bar/LeftSidebarButton.qml b/ii/modules/bar/LeftSidebarButton.qml new file mode 100644 index 0000000..3deb075 --- /dev/null +++ b/ii/modules/bar/LeftSidebarButton.qml @@ -0,0 +1,79 @@ +import QtQuick +import QtQuick.Layouts +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +RippleButton { + id: root + + property bool showPing: false + + property real buttonPadding: 5 + implicitWidth: distroIcon.width + buttonPadding * 2 + implicitHeight: distroIcon.height + buttonPadding * 2 + buttonRadius: Appearance.rounding.full + colBackgroundHover: Appearance.colors.colLayer1Hover + colRipple: Appearance.colors.colLayer1Active + colBackgroundToggled: Appearance.colors.colSecondaryContainer + colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover + colRippleToggled: Appearance.colors.colSecondaryContainerActive + toggled: GlobalStates.sidebarLeftOpen + + onPressed: { + GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen; + } + + Connections { + target: Ai + function onResponseFinished() { + if (GlobalStates.sidebarLeftOpen) return; + root.showPing = true; + } + } + + Connections { + target: Booru + function onResponseFinished() { + if (GlobalStates.sidebarLeftOpen) return; + root.showPing = true; + } + } + + Connections { + target: GlobalStates + function onSidebarLeftOpenChanged() { + root.showPing = false; + } + } + + CustomIcon { + id: distroIcon + anchors.centerIn: parent + width: 19.5 + height: 19.5 + source: Config.options.bar.topLeftIcon == 'distro' ? SystemInfo.distroIcon : `${Config.options.bar.topLeftIcon}-symbolic` + colorize: true + color: Appearance.colors.colOnLayer0 + + Rectangle { + opacity: root.showPing ? 1 : 0 + visible: opacity > 0 + anchors { + bottom: parent.bottom + right: parent.right + bottomMargin: -2 + rightMargin: -2 + } + implicitWidth: 8 + implicitHeight: 8 + radius: Appearance.rounding.full + color: Appearance.colors.colTertiary + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + } +} diff --git a/ii/modules/bar/Media.qml b/ii/modules/bar/Media.qml new file mode 100644 index 0000000..87151c6 --- /dev/null +++ b/ii/modules/bar/Media.qml @@ -0,0 +1,89 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs +import qs.modules.common.functions + +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Mpris +import Quickshell.Hyprland + +Item { + id: root + property bool borderless: Config.options.bar.borderless + readonly property MprisPlayer activePlayer: MprisController.activePlayer + readonly property string cleanedTitle: StringUtils.cleanMusicTitle(activePlayer?.trackTitle) || Translation.tr("No media") + + Layout.fillHeight: true + implicitWidth: rowLayout.implicitWidth + rowLayout.spacing * 2 + implicitHeight: Appearance.sizes.barHeight + + Timer { + running: activePlayer?.playbackState == MprisPlaybackState.Playing + interval: 1000 + repeat: true + onTriggered: activePlayer.positionChanged() + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.MiddleButton | Qt.BackButton | Qt.ForwardButton | Qt.RightButton | Qt.LeftButton + onPressed: (event) => { + if (event.button === Qt.MiddleButton) { + activePlayer.togglePlaying(); + } else if (event.button === Qt.BackButton) { + activePlayer.previous(); + } else if (event.button === Qt.ForwardButton || event.button === Qt.RightButton) { + activePlayer.next(); + } else if (event.button === Qt.LeftButton) { + GlobalStates.mediaControlsOpen = !GlobalStates.mediaControlsOpen + } + } + } + + RowLayout { // Real content + id: rowLayout + + spacing: 4 + anchors.fill: parent + + ClippedFilledCircularProgress { + id: mediaCircProg + Layout.alignment: Qt.AlignVCenter + lineWidth: Appearance.rounding.unsharpen + value: activePlayer?.position / activePlayer?.length + implicitSize: 20 + colPrimary: Appearance.colors.colOnSecondaryContainer + enableAnimation: false + + Item { + anchors.centerIn: parent + width: mediaCircProg.implicitSize + height: mediaCircProg.implicitSize + + MaterialSymbol { + anchors.centerIn: parent + fill: 1 + text: activePlayer?.isPlaying ? "pause" : "music_note" + iconSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onSecondaryContainer + } + } + } + + StyledText { + visible: Config.options.bar.verbose + width: rowLayout.width - (CircularProgress.size + rowLayout.spacing * 2) + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true // Ensures the text takes up available space + Layout.rightMargin: rowLayout.spacing + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight // Truncates the text on the right + color: Appearance.colors.colOnLayer1 + text: `${cleanedTitle}${activePlayer?.trackArtist ? ' • ' + activePlayer.trackArtist : ''}` + } + + } + +} diff --git a/ii/modules/bar/Resource.qml b/ii/modules/bar/Resource.qml new file mode 100644 index 0000000..6d1dec0 --- /dev/null +++ b/ii/modules/bar/Resource.qml @@ -0,0 +1,95 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts +import Quickshell + +Item { + id: root + required property string iconName + required property double percentage + property int warningThreshold: 100 + property bool shown: true + clip: true + visible: width > 0 && height > 0 + implicitWidth: resourceRowLayout.x < 0 ? 0 : resourceRowLayout.implicitWidth + implicitHeight: Appearance.sizes.barHeight + property bool warning: percentage * 100 >= warningThreshold + + RowLayout { + id: resourceRowLayout + spacing: 2 + x: shown ? 0 : -resourceRowLayout.width + anchors { + verticalCenter: parent.verticalCenter + } + + ClippedFilledCircularProgress { + id: resourceCircProg + Layout.alignment: Qt.AlignVCenter + lineWidth: Appearance.rounding.unsharpen + value: percentage + implicitSize: 20 + colPrimary: root.warning ? Appearance.colors.colError : Appearance.colors.colOnSecondaryContainer + accountForLightBleeding: !root.warning + enableAnimation: false + + Item { + anchors.centerIn: parent + width: resourceCircProg.implicitSize + height: resourceCircProg.implicitSize + + MaterialSymbol { + anchors.centerIn: parent + font.weight: Font.DemiBold + fill: 1 + text: iconName + iconSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onSecondaryContainer + } + } + } + + Item { + Layout.alignment: Qt.AlignVCenter + implicitWidth: fullPercentageTextMetrics.width + implicitHeight: percentageText.implicitHeight + + TextMetrics { + id: fullPercentageTextMetrics + text: "100" + font.pixelSize: Appearance.font.pixelSize.small + } + + StyledText { + id: percentageText + anchors.centerIn: parent + color: Appearance.colors.colOnLayer1 + font.pixelSize: Appearance.font.pixelSize.small + text: `${Math.round(percentage * 100).toString()}` + } + } + + Behavior on x { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + enabled: resourceRowLayout.x >= 0 && root.width > 0 && root.visible + } + + Behavior on implicitWidth { + NumberAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } +} diff --git a/ii/modules/bar/Resources.qml b/ii/modules/bar/Resources.qml new file mode 100644 index 0000000..6c25ab5 --- /dev/null +++ b/ii/modules/bar/Resources.qml @@ -0,0 +1,53 @@ +import qs.modules.common +import qs.services +import QtQuick +import QtQuick.Layouts + +MouseArea { + id: root + property bool borderless: Config.options.bar.borderless + property bool alwaysShowAllResources: false + implicitWidth: rowLayout.implicitWidth + rowLayout.anchors.leftMargin + rowLayout.anchors.rightMargin + implicitHeight: Appearance.sizes.barHeight + hoverEnabled: true + + RowLayout { + id: rowLayout + + spacing: 0 + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + + Resource { + iconName: "memory" + percentage: ResourceUsage.memoryUsedPercentage + warningThreshold: Config.options.bar.resources.memoryWarningThreshold + } + + Resource { + iconName: "swap_horiz" + percentage: ResourceUsage.swapUsedPercentage + shown: (Config.options.bar.resources.alwaysShowSwap && percentage > 0) || + (MprisController.activePlayer?.trackTitle == null) || + root.alwaysShowAllResources + Layout.leftMargin: shown ? 6 : 0 + warningThreshold: Config.options.bar.resources.swapWarningThreshold + } + + Resource { + iconName: "planner_review" + percentage: ResourceUsage.cpuUsage + shown: Config.options.bar.resources.alwaysShowCpu || + !(MprisController.activePlayer?.trackTitle?.length > 0) || + root.alwaysShowAllResources + Layout.leftMargin: shown ? 6 : 0 + warningThreshold: Config.options.bar.resources.cpuWarningThreshold + } + + } + + ResourcesPopup { + hoverTarget: root + } +} diff --git a/ii/modules/bar/ResourcesPopup.qml b/ii/modules/bar/ResourcesPopup.qml new file mode 100644 index 0000000..2c694de --- /dev/null +++ b/ii/modules/bar/ResourcesPopup.qml @@ -0,0 +1,143 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts +import Quickshell + +StyledPopup { + id: root + + // Helper function to format KB to GB + function formatKB(kb) { + return (kb / (1024 * 1024)).toFixed(1) + " GB"; + } + + component ResourceItem: RowLayout { + id: resourceItem + required property string icon + required property string label + required property string value + spacing: 4 + Layout.fillWidth: true + + MaterialSymbol { + text: resourceItem.icon + color: Appearance.colors.colOnSurfaceVariant + iconSize: Appearance.font.pixelSize.large + } + StyledText { + text: resourceItem.label + color: Appearance.colors.colOnSurfaceVariant + } + StyledText { + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + visible: resourceItem.value !== "" + color: Appearance.colors.colOnSurfaceVariant + text: resourceItem.value + } + } + + component ResourceHeaderItem: RowLayout { + id: headerItem + required property var icon + required property var label + spacing: 5 + + MaterialSymbol { + fill: 0 + font.weight: Font.Medium + text: headerItem.icon + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnSurfaceVariant + } + + StyledText { + text: headerItem.label + font { + weight: Font.Medium + pixelSize: Appearance.font.pixelSize.normal + } + color: Appearance.colors.colOnSurfaceVariant + } + } + + RowLayout { + anchors.centerIn: parent + spacing: 12 + + ColumnLayout { + Layout.alignment: Qt.AlignTop + spacing: 8 + + ResourceHeaderItem { + icon: "memory" + label: "RAM" + } + ColumnLayout { + ResourceItem { + icon: "clock_loader_60" + label: Translation.tr("Used:") + value: formatKB(ResourceUsage.memoryUsed) + } + ResourceItem { + icon: "check_circle" + label: Translation.tr("Free:") + value: formatKB(ResourceUsage.memoryFree) + } + ResourceItem { + icon: "empty_dashboard" + label: Translation.tr("Total:") + value: formatKB(ResourceUsage.memoryTotal) + } + } + } + + ColumnLayout { + visible: ResourceUsage.swapTotal > 0 + Layout.alignment: Qt.AlignTop + spacing: 8 + + ResourceHeaderItem { + icon: "swap_horiz" + label: "Swap" + } + ColumnLayout { + ResourceItem { + icon: "clock_loader_60" + label: Translation.tr("Used:") + value: formatKB(ResourceUsage.swapUsed) + } + ResourceItem { + icon: "check_circle" + label: Translation.tr("Free:") + value: formatKB(ResourceUsage.swapFree) + } + ResourceItem { + icon: "empty_dashboard" + label: Translation.tr("Total:") + value: formatKB(ResourceUsage.swapTotal) + } + } + } + + ColumnLayout { + Layout.alignment: Qt.AlignTop + spacing: 8 + + ResourceHeaderItem { + icon: "planner_review" + label: "CPU" + } + ColumnLayout { + ResourceItem { + icon: "bolt" + label: Translation.tr("Load:") + value: (ResourceUsage.cpuUsage > 0.8 ? Translation.tr("High") : ResourceUsage.cpuUsage > 0.4 ? Translation.tr("Medium") : Translation.tr("Low")) + ` (${Math.round(ResourceUsage.cpuUsage * 100)}%)` + } + } + } + } +} diff --git a/ii/modules/bar/ScrollHint.qml b/ii/modules/bar/ScrollHint.qml new file mode 100644 index 0000000..a8e1c8d --- /dev/null +++ b/ii/modules/bar/ScrollHint.qml @@ -0,0 +1,57 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +Revealer { // Scroll hint + id: root + property string icon + property string side: "left" + property string tooltipText: "" + + MouseArea { + anchors.right: root.side === "left" ? parent.right : undefined + anchors.left: root.side === "right" ? parent.left : undefined + implicitWidth: contentColumnLayout.implicitWidth + implicitHeight: contentColumnLayout.implicitHeight + property bool hovered: false + + hoverEnabled: true + onEntered: hovered = true + onExited: hovered = false + acceptedButtons: Qt.NoButton + + // StyledToolTip { + // extraVisibleCondition: tooltipText.length > 0 + // content: tooltipText + // } + + ColumnLayout { + id: contentColumnLayout + anchors.centerIn: parent + spacing: -5 + MaterialSymbol { + Layout.leftMargin: 5 + Layout.rightMargin: 5 + text: "keyboard_arrow_up" + iconSize: 14 + color: Appearance.colors.colSubtext + } + MaterialSymbol { + Layout.leftMargin: 5 + Layout.rightMargin: 5 + text: root.icon + iconSize: 14 + color: Appearance.colors.colSubtext + } + MaterialSymbol { + Layout.leftMargin: 5 + Layout.rightMargin: 5 + text: "keyboard_arrow_down" + iconSize: 14 + color: Appearance.colors.colSubtext + } + } + } +} \ No newline at end of file diff --git a/ii/modules/bar/StyledPopup.qml b/ii/modules/bar/StyledPopup.qml new file mode 100644 index 0000000..9570da8 --- /dev/null +++ b/ii/modules/bar/StyledPopup.qml @@ -0,0 +1,83 @@ +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Wayland + +LazyLoader { + id: root + + property Item hoverTarget + default property Item contentItem + property real popupBackgroundMargin: 0 + + active: hoverTarget && hoverTarget.containsMouse + + component: PanelWindow { + id: popupWindow + color: "transparent" + + anchors.left: !Config.options.bar.vertical || (Config.options.bar.vertical && !Config.options.bar.bottom) + anchors.right: Config.options.bar.vertical && Config.options.bar.bottom + anchors.top: Config.options.bar.vertical || (!Config.options.bar.vertical && !Config.options.bar.bottom) + anchors.bottom: !Config.options.bar.vertical && Config.options.bar.bottom + + implicitWidth: popupBackground.implicitWidth + Appearance.sizes.hyprlandGapsOut * 2 + root.popupBackgroundMargin + implicitHeight: popupBackground.implicitHeight + Appearance.sizes.hyprlandGapsOut * 2 + root.popupBackgroundMargin + + exclusionMode: ExclusionMode.Ignore + exclusiveZone: 0 + margins { + left: { + if (!Config.options.bar.vertical) return root.QsWindow?.mapFromItem( + root.hoverTarget, + (root.hoverTarget.width - popupBackground.implicitWidth) / 2, 0 + ).x; + return Appearance.sizes.verticalBarWidth + } + top: { + if (!Config.options.bar.vertical) return Appearance.sizes.barHeight; + return root.QsWindow?.mapFromItem( + root.hoverTarget, + (root.hoverTarget.height - popupBackground.implicitHeight) / 2, 0 + ).y; + } + right: Appearance.sizes.verticalBarWidth + bottom: Appearance.sizes.barHeight + } + WlrLayershell.namespace: "quickshell:popup" + WlrLayershell.layer: WlrLayer.Overlay + + RectangularShadow { + property var target: popupBackground + anchors.fill: target + radius: target.radius + blur: 0.9 * Appearance.sizes.hyprlandGapsOut + offset: Qt.vector2d(0.0, 1.0) + spread: 0.7 + color: Appearance.colors.colShadow + cached: true + } + + Rectangle { + id: popupBackground + readonly property real margin: 10 + anchors { + fill: parent + leftMargin: Appearance.sizes.hyprlandGapsOut + root.popupBackgroundMargin * (!popupWindow.anchors.left) + rightMargin: Appearance.sizes.hyprlandGapsOut + root.popupBackgroundMargin * (!popupWindow.anchors.right) + topMargin: Appearance.sizes.hyprlandGapsOut + root.popupBackgroundMargin * (!popupWindow.anchors.top) + bottomMargin: Appearance.sizes.hyprlandGapsOut + root.popupBackgroundMargin * (!popupWindow.anchors.bottom) + } + implicitWidth: root.contentItem.implicitWidth + margin * 2 + implicitHeight: root.contentItem.implicitHeight + margin * 2 + color: ColorUtils.applyAlpha(Appearance.colors.colSurfaceContainer, 1 - Appearance.backgroundTransparency) + radius: Appearance.rounding.small + children: [root.contentItem] + + border.width: 1 + border.color: Appearance.colors.colLayer0Border + } + } +} diff --git a/ii/modules/bar/SysTray.qml b/ii/modules/bar/SysTray.qml new file mode 100644 index 0000000..cfeb427 --- /dev/null +++ b/ii/modules/bar/SysTray.qml @@ -0,0 +1,111 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.SystemTray + +Item { + id: root + implicitWidth: gridLayout.implicitWidth + implicitHeight: gridLayout.implicitHeight + property bool vertical: false + property bool invertSide: false + property bool trayOverflowOpen: false + property bool showSeparator: true + property bool showOverflowMenu: true + + property list itemsInUserList: SystemTray.items.values.filter(i => Config.options.bar.tray.pinnedItems.includes(i.id)) + property list itemsNotInUserList: SystemTray.items.values.filter(i => !Config.options.bar.tray.pinnedItems.includes(i.id)) + property bool invertPins: Config.options.bar.tray.invertPinnedItems + property list pinnedItems: invertPins ? itemsNotInUserList : itemsInUserList + property list unpinnedItems: invertPins ? itemsInUserList : itemsNotInUserList + onUnpinnedItemsChanged: if (unpinnedItems.length == 0) root.trayOverflowOpen = false; + + GridLayout { + id: gridLayout + columns: root.vertical ? 1 : -1 + anchors.fill: parent + rowSpacing: 6 + columnSpacing: 15 + + RippleButton { + id: trayOverflowButton + visible: root.showOverflowMenu && root.unpinnedItems.length > 0 + toggled: root.trayOverflowOpen + property bool containsMouse: hovered + + onClicked: root.trayOverflowOpen = !root.trayOverflowOpen + + Layout.fillHeight: !root.vertical + Layout.fillWidth: root.vertical + background.implicitWidth: 24 + background.implicitHeight: 24 + background.anchors.centerIn: this + colBackgroundToggled: Appearance.colors.colSecondaryContainer + colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover + colRippleToggled: Appearance.colors.colSecondaryContainerActive + + contentItem: MaterialSymbol { + anchors.centerIn: parent + iconSize: Appearance.font.pixelSize.larger + text: "expand_more" + horizontalAlignment: Text.AlignHCenter + color: root.trayOverflowOpen ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer2 + rotation: (root.trayOverflowOpen ? 180 : 0) - (90 * root.vertical) + (180 * root.invertSide) + Behavior on rotation { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + + StyledPopup { + hoverTarget: trayOverflowButton + active: root.trayOverflowOpen + popupBackgroundMargin: 300 // This should be plenty... makes sure tooltips don't get cutoff (easily) + + GridLayout { + id: trayOverflowLayout + anchors.centerIn: parent + columns: Math.ceil(Math.sqrt(root.unpinnedItems.length)) + columnSpacing: 10 + rowSpacing: 10 + + Repeater { + model: root.unpinnedItems + + delegate: SysTrayItem { + required property SystemTrayItem modelData + item: modelData + Layout.fillHeight: !root.vertical + Layout.fillWidth: root.vertical + } + } + } + } + } + + Repeater { + model: ScriptModel { + values: root.pinnedItems + } + + delegate: SysTrayItem { + required property SystemTrayItem modelData + item: modelData + Layout.fillHeight: !root.vertical + Layout.fillWidth: root.vertical + } + + } + + StyledText { + Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colSubtext + text: "•" + visible: root.showSeparator && SystemTray.items.values.length > 0 + } + + } + +} diff --git a/ii/modules/bar/SysTrayItem.qml b/ii/modules/bar/SysTrayItem.qml new file mode 100644 index 0000000..f2bbc39 --- /dev/null +++ b/ii/modules/bar/SysTrayItem.qml @@ -0,0 +1,85 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import Quickshell +import Quickshell.Services.SystemTray +import Quickshell.Widgets +import Qt5Compat.GraphicalEffects + +MouseArea { + id: root + + property var bar: root.QsWindow.window + required property SystemTrayItem item + property bool targetMenuOpen: false + hoverEnabled: true + + acceptedButtons: Qt.LeftButton | Qt.RightButton + implicitWidth: 20 + implicitHeight: 20 + onClicked: (event) => { + switch (event.button) { + case Qt.LeftButton: + item.activate(); + break; + case Qt.RightButton: + if (item.hasMenu) menu.open(); + break; + } + event.accepted = true; + } + onEntered: { + tooltip.content = item.tooltipTitle.length > 0 ? item.tooltipTitle + : (item.title.length > 0 ? item.title : item.id); + if (item.tooltipDescription.length > 0) tooltip.content += " • " + item.tooltipDescription; + if (Config.options.bar.tray.showItemId) tooltip.content += "\n[" + item.id + "]"; + } + + QsMenuAnchor { + id: menu + + menu: root.item.menu + anchor.window: bar + anchor.rect.x: root.x + (Config.options.bar.vertical ? 0 : bar?.width) + anchor.rect.y: root.y + (Config.options.bar.vertical ? bar?.height : 0) + anchor.rect.height: root.height + anchor.rect.width: root.width + anchor.edges: Config.options.bar.bottom ? (Edges.Top | Edges.Left) : (Edges.Bottom | Edges.Right) + } + + IconImage { + id: trayIcon + visible: !Config.options.bar.tray.monochromeIcons + source: root.item.icon + anchors.centerIn: parent + width: parent.width + height: parent.height + } + + Loader { + active: Config.options.bar.tray.monochromeIcons + anchors.fill: trayIcon + sourceComponent: Item { + Desaturate { + id: desaturatedIcon + visible: false // There's already color overlay + anchors.fill: parent + source: trayIcon + desaturation: 0.8 // 1.0 means fully grayscale + } + ColorOverlay { + anchors.fill: desaturatedIcon + source: desaturatedIcon + color: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.9) + } + } + } + + StyledToolTip { + id: tooltip + extraVisibleCondition: root.containsMouse + alternativeVisibleCondition: extraVisibleCondition + } + +} diff --git a/ii/modules/bar/UtilButtons.qml b/ii/modules/bar/UtilButtons.qml new file mode 100644 index 0000000..98ee4f6 --- /dev/null +++ b/ii/modules/bar/UtilButtons.qml @@ -0,0 +1,142 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Services.Pipewire +import Quickshell.Services.UPower + +Item { + id: root + property bool borderless: Config.options.bar.borderless + implicitWidth: rowLayout.implicitWidth + rowLayout.spacing * 2 + implicitHeight: rowLayout.implicitHeight + + RowLayout { + id: rowLayout + + spacing: 4 + anchors.centerIn: parent + + Loader { + active: Config.options.bar.utilButtons.showScreenSnip + visible: Config.options.bar.utilButtons.showScreenSnip + sourceComponent: CircleUtilButton { + Layout.alignment: Qt.AlignVCenter + onClicked: Quickshell.execDetached(["qs", "-p", Quickshell.shellPath("screenshot.qml")]) + MaterialSymbol { + horizontalAlignment: Qt.AlignHCenter + fill: 1 + text: "screenshot_region" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer2 + } + } + } + + Loader { + active: Config.options.bar.utilButtons.showColorPicker + visible: Config.options.bar.utilButtons.showColorPicker + sourceComponent: CircleUtilButton { + Layout.alignment: Qt.AlignVCenter + onClicked: Quickshell.execDetached(["hyprpicker", "-a"]) + MaterialSymbol { + horizontalAlignment: Qt.AlignHCenter + fill: 1 + text: "colorize" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer2 + } + } + } + + Loader { + active: Config.options.bar.utilButtons.showKeyboardToggle + visible: Config.options.bar.utilButtons.showKeyboardToggle + sourceComponent: CircleUtilButton { + Layout.alignment: Qt.AlignVCenter + onClicked: GlobalStates.oskOpen = !GlobalStates.oskOpen + MaterialSymbol { + horizontalAlignment: Qt.AlignHCenter + fill: 0 + text: "keyboard" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer2 + } + } + } + + Loader { + active: Config.options.bar.utilButtons.showMicToggle + visible: Config.options.bar.utilButtons.showMicToggle + sourceComponent: CircleUtilButton { + Layout.alignment: Qt.AlignVCenter + onClicked: Quickshell.execDetached(["wpctl", "set-mute", "@DEFAULT_SOURCE@", "toggle"]) + MaterialSymbol { + horizontalAlignment: Qt.AlignHCenter + fill: 0 + text: Pipewire.defaultAudioSource?.audio?.muted ? "mic_off" : "mic" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer2 + } + } + } + + Loader { + active: Config.options.bar.utilButtons.showDarkModeToggle + visible: Config.options.bar.utilButtons.showDarkModeToggle + sourceComponent: CircleUtilButton { + Layout.alignment: Qt.AlignVCenter + onClicked: event => { + if (Appearance.m3colors.darkmode) { + Hyprland.dispatch(`exec ${Directories.wallpaperSwitchScriptPath} --mode light --noswitch`); + } else { + Hyprland.dispatch(`exec ${Directories.wallpaperSwitchScriptPath} --mode dark --noswitch`); + } + } + MaterialSymbol { + horizontalAlignment: Qt.AlignHCenter + fill: 0 + text: Appearance.m3colors.darkmode ? "light_mode" : "dark_mode" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer2 + } + } + } + + Loader { + active: Config.options.bar.utilButtons.showPerformanceProfileToggle + visible: Config.options.bar.utilButtons.showPerformanceProfileToggle + sourceComponent: CircleUtilButton { + Layout.alignment: Qt.AlignVCenter + onClicked: event => { + if (PowerProfiles.hasPerformanceProfile) { + switch(PowerProfiles.profile) { + case PowerProfile.PowerSaver: PowerProfiles.profile = PowerProfile.Balanced + break; + case PowerProfile.Balanced: PowerProfiles.profile = PowerProfile.Performance + break; + case PowerProfile.Performance: PowerProfiles.profile = PowerProfile.PowerSaver + break; + } + } else { + PowerProfiles.profile = PowerProfiles.profile == PowerProfile.Balanced ? PowerProfile.PowerSaver : PowerProfile.Balanced + } + } + MaterialSymbol { + horizontalAlignment: Qt.AlignHCenter + fill: 0 + text: switch(PowerProfiles.profile) { + case PowerProfile.PowerSaver: return "energy_savings_leaf" + case PowerProfile.Balanced: return "settings_slow_motion" + case PowerProfile.Performance: return "local_fire_department" + } + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer2 + } + } + } + } +} diff --git a/ii/modules/bar/Workspaces.qml b/ii/modules/bar/Workspaces.qml new file mode 100644 index 0000000..775df18 --- /dev/null +++ b/ii/modules/bar/Workspaces.qml @@ -0,0 +1,336 @@ +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 Quickshell.Wayland +import Quickshell.Hyprland +import Quickshell.Widgets +import Qt5Compat.GraphicalEffects + +Item { + id: root + property bool vertical: false + property bool borderless: Config.options.bar.borderless + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.QsWindow.window?.screen) + readonly property Toplevel activeWindow: ToplevelManager.activeToplevel + + readonly property int workspaceGroup: Math.floor((monitor?.activeWorkspace?.id - 1) / Config.options.bar.workspaces.shown) + property list workspaceOccupied: [] + property int widgetPadding: 4 + property int workspaceButtonWidth: 26 + property real activeWorkspaceMargin: 2 + property real workspaceIconSize: workspaceButtonWidth * 0.69 + property real workspaceIconSizeShrinked: workspaceButtonWidth * 0.55 + property real workspaceIconOpacityShrinked: 1 + property real workspaceIconMarginShrinked: -4 + property int workspaceIndexInGroup: (monitor?.activeWorkspace?.id - 1) % Config.options.bar.workspaces.shown + + property bool showNumbers: false + Timer { + id: showNumbersTimer + interval: (Config?.options.bar.autoHide.showWhenPressingSuper.delay ?? 100) + repeat: false + onTriggered: { + root.showNumbers = true + } + } + Connections { + target: GlobalStates + function onSuperDownChanged() { + if (!Config?.options.bar.autoHide.showWhenPressingSuper.enable) return; + if (GlobalStates.superDown) showNumbersTimer.restart(); + else { + showNumbersTimer.stop(); + root.showNumbers = false; + } + } + function onSuperReleaseMightTriggerChanged() { + showNumbersTimer.stop() + } + } + + // Function to update workspaceOccupied + function updateWorkspaceOccupied() { + workspaceOccupied = Array.from({ length: Config.options.bar.workspaces.shown }, (_, i) => { + return Hyprland.workspaces.values.some(ws => ws.id === workspaceGroup * Config.options.bar.workspaces.shown + i + 1); + }) + } + + // Occupied workspace updates + Component.onCompleted: updateWorkspaceOccupied() + Connections { + target: Hyprland.workspaces + function onValuesChanged() { + updateWorkspaceOccupied(); + } + } + Connections { + target: Hyprland + function onFocusedWorkspaceChanged() { + updateWorkspaceOccupied(); + } + } + onWorkspaceGroupChanged: { + updateWorkspaceOccupied(); + } + + implicitWidth: root.vertical ? Appearance.sizes.verticalBarWidth : backgroundLayout.implicitWidth + implicitHeight: root.vertical ? backgroundLayout.implicitHeight : Appearance.sizes.barHeight + + // Scroll to switch workspaces + WheelHandler { + onWheel: (event) => { + if (event.angleDelta.y < 0) + Hyprland.dispatch(`workspace r+1`); + else if (event.angleDelta.y > 0) + Hyprland.dispatch(`workspace r-1`); + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.BackButton + onPressed: (event) => { + if (event.button === Qt.BackButton) { + Hyprland.dispatch(`togglespecialworkspace`); + } + } + } + + // Workspaces - background + GridLayout { + id: backgroundLayout + z: 1 + anchors.fill: parent + implicitHeight: root.vertical ? root.workspaceButtonWidth : Appearance.sizes.barHeight + implicitWidth: root.vertical ? Appearance.sizes.verticalBarWidth : root.workspaceButtonWidth + + rowSpacing: 0 + columnSpacing: 0 + columns: root.vertical ? 1 : -1 + + Repeater { + model: Config.options.bar.workspaces.shown + + Rectangle { + z: 1 + Layout.alignment: root.vertical ? Qt.AlignHCenter : Qt.AlignVCenter + + implicitWidth: workspaceButtonWidth + implicitHeight: workspaceButtonWidth + radius: (width / 2) + property var previousOccupied: (workspaceOccupied[index-1] && !(!activeWindow?.activated && monitor?.activeWorkspace?.id === index)) + property var rightOccupied: (workspaceOccupied[index+1] && !(!activeWindow?.activated && monitor?.activeWorkspace?.id === index+2)) + property var radiusPrev: previousOccupied ? 0 : (width / 2) + property var radiusNext: rightOccupied ? 0 : (width / 2) + + topLeftRadius: radiusPrev + bottomLeftRadius: root.vertical ? radiusNext : radiusPrev + topRightRadius: root.vertical ? radiusPrev : radiusNext + bottomRightRadius: radiusNext + + color: ColorUtils.transparentize(Appearance.m3colors.m3secondaryContainer, 0.4) + opacity: (workspaceOccupied[index] && !(!activeWindow?.activated && monitor?.activeWorkspace?.id === index+1)) ? 1 : 0 + + Behavior on opacity { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on radiusPrev { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + Behavior on radiusNext { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + } + + } + + } + + // Active workspace + Rectangle { + z: 2 + // Make active ws indicator, which has a brighter color, smaller to look like it is of the same size as ws occupied highlight + radius: Appearance.rounding.full + color: Appearance.colors.colPrimary + + anchors { + verticalCenter: vertical ? undefined : parent.verticalCenter + horizontalCenter: vertical ? parent.horizontalCenter : undefined + } + + // idx1 is the "leading" indicator position, idx2 is the "following" one + // The former animates faster than the latter, see the NumberAnimations below + property real idx1: workspaceIndexInGroup + property real idx2: workspaceIndexInGroup + property real indicatorPosition: Math.min(idx1, idx2) * workspaceButtonWidth + root.activeWorkspaceMargin + property real indicatorLength: Math.abs(idx1 - idx2) * workspaceButtonWidth + workspaceButtonWidth - root.activeWorkspaceMargin * 2 + property real indicatorThickness: workspaceButtonWidth - root.activeWorkspaceMargin * 2 + + x: root.vertical ? null : indicatorPosition + implicitWidth: root.vertical ? indicatorThickness : indicatorLength + y: root.vertical ? indicatorPosition : null + implicitHeight: root.vertical ? indicatorLength : indicatorThickness + + Behavior on idx1 { + NumberAnimation { + duration: 100 + easing.type: Easing.OutSine + } + } + Behavior on idx2 { + NumberAnimation { + duration: 300 + easing.type: Easing.OutSine + } + } + } + + // Workspaces - numbers + GridLayout { + id: rowLayoutNumbers + z: 3 + + columns: vertical ? 1 : -1 + columnSpacing: 0 + rowSpacing: 0 + + anchors.fill: parent + implicitHeight: vertical ? Appearance.sizes.barWidth : Appearance.sizes.barHeight + implicitWidth: vertical ? Appearance.sizes.verticalBarWidth : Appearance.sizes.verticalBarWidth + + Repeater { + model: Config.options.bar.workspaces.shown + + Button { + id: button + property int workspaceValue: workspaceGroup * Config.options.bar.workspaces.shown + index + 1 + Layout.fillHeight: !root.vertical + Layout.fillWidth: root.vertical + onPressed: Hyprland.dispatch(`workspace ${workspaceValue}`) + width: vertical ? undefined : workspaceButtonWidth + height: vertical ? workspaceButtonWidth : undefined + + background: Item { + id: workspaceButtonBackground + implicitWidth: workspaceButtonWidth + implicitHeight: workspaceButtonWidth + property var biggestWindow: HyprlandData.biggestWindowForWorkspace(button.workspaceValue) + property var mainAppIconSource: Quickshell.iconPath(AppSearch.guessIcon(biggestWindow?.class), "image-missing") + + StyledText { // Workspace number text + opacity: root.showNumbers + || ((Config.options?.bar.workspaces.alwaysShowNumbers && (!Config.options?.bar.workspaces.showAppIcons || !workspaceButtonBackground.biggestWindow || root.showNumbers)) + || (root.showNumbers && !Config.options?.bar.workspaces.showAppIcons) + ) ? 1 : 0 + z: 3 + + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font { + pixelSize: Appearance.font.pixelSize.small - ((text.length - 1) * (text !== "10") * 2) + family: Config.options?.bar.workspaces.useNerdFont ? Appearance.font.family.iconNerd : Appearance.font.family.main + } + text: Config.options?.bar.workspaces.numberMap[button.workspaceValue - 1] || button.workspaceValue + elide: Text.ElideRight + color: (monitor?.activeWorkspace?.id == button.workspaceValue) ? + Appearance.m3colors.m3onPrimary : + (workspaceOccupied[index] ? Appearance.m3colors.m3onSecondaryContainer : + Appearance.colors.colOnLayer1Inactive) + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + Rectangle { // Dot instead of ws number + id: wsDot + opacity: (Config.options?.bar.workspaces.alwaysShowNumbers + || root.showNumbers + || (Config.options?.bar.workspaces.showAppIcons && workspaceButtonBackground.biggestWindow) + ) ? 0 : 1 + visible: opacity > 0 + anchors.centerIn: parent + width: workspaceButtonWidth * 0.18 + height: width + radius: width / 2 + color: (monitor?.activeWorkspace?.id == button.workspaceValue) ? + Appearance.m3colors.m3onPrimary : + (workspaceOccupied[index] ? Appearance.m3colors.m3onSecondaryContainer : + Appearance.colors.colOnLayer1Inactive) + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + Item { // Main app icon + anchors.centerIn: parent + width: workspaceButtonWidth + height: workspaceButtonWidth + opacity: !Config.options?.bar.workspaces.showAppIcons ? 0 : + (workspaceButtonBackground.biggestWindow && !root.showNumbers && Config.options?.bar.workspaces.showAppIcons) ? + 1 : workspaceButtonBackground.biggestWindow ? workspaceIconOpacityShrinked : 0 + visible: opacity > 0 + IconImage { + id: mainAppIcon + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.bottomMargin: (!root.showNumbers && Config.options?.bar.workspaces.showAppIcons) ? + (workspaceButtonWidth - workspaceIconSize) / 2 : workspaceIconMarginShrinked + anchors.rightMargin: (!root.showNumbers && Config.options?.bar.workspaces.showAppIcons) ? + (workspaceButtonWidth - workspaceIconSize) / 2 : workspaceIconMarginShrinked + + source: workspaceButtonBackground.mainAppIconSource + implicitSize: (!root.showNumbers && Config.options?.bar.workspaces.showAppIcons) ? workspaceIconSize : workspaceIconSizeShrinked + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on anchors.bottomMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on anchors.rightMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on implicitSize { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + + Loader { + active: Config.options.bar.workspaces.monochromeIcons + anchors.fill: mainAppIcon + sourceComponent: Item { + Desaturate { + id: desaturatedIcon + visible: false // There's already color overlay + anchors.fill: parent + source: mainAppIcon + desaturation: 0.8 + } + ColorOverlay { + anchors.fill: desaturatedIcon + source: desaturatedIcon + color: ColorUtils.transparentize(wsDot.color, 0.9) + } + } + } + } + } + + + } + + } + + } + +} diff --git a/ii/modules/bar/weather/WeatherBar.qml b/ii/modules/bar/weather/WeatherBar.qml new file mode 100644 index 0000000..b39228a --- /dev/null +++ b/ii/modules/bar/weather/WeatherBar.qml @@ -0,0 +1,52 @@ +pragma ComponentBehavior: Bound +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs +import Quickshell +import QtQuick +import QtQuick.Layouts + +MouseArea { + id: root + property bool hovered: false + implicitWidth: rowLayout.implicitWidth + 10 * 2 + implicitHeight: Appearance.sizes.barHeight + + hoverEnabled: true + + onClicked: { + Weather.getData(); + Quickshell.execDetached(["notify-send", + Translation.tr("Weather"), + Translation.tr("Refreshing (manually triggered)") + , "-a", "Shell" + ]) + } + + RowLayout { + id: rowLayout + anchors.centerIn: parent + + MaterialSymbol { + fill: 0 + text: WeatherIcons.codeToName[Weather.data.wCode] ?? "cloud" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer1 + Layout.alignment: Qt.AlignVCenter + } + + StyledText { + visible: true + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnLayer1 + text: Weather.data?.temp ?? "--°" + Layout.alignment: Qt.AlignVCenter + } + } + + WeatherPopup { + id: weatherPopup + hoverTarget: root + } +} diff --git a/ii/modules/bar/weather/WeatherCard.qml b/ii/modules/bar/weather/WeatherCard.qml new file mode 100644 index 0000000..e75dda3 --- /dev/null +++ b/ii/modules/bar/weather/WeatherCard.qml @@ -0,0 +1,44 @@ +import QtQuick +import QtQuick.Layouts + +import qs.modules.common +import qs.modules.common.widgets + +Rectangle { + id: root + radius: Appearance.rounding.small + color: Appearance.colors.colSurfaceContainerHigh + implicitWidth: columnLayout.implicitWidth + 14 * 2 + implicitHeight: columnLayout.implicitHeight + 14 * 2 + Layout.fillWidth: parent + + property alias title: title.text + property alias value: value.text + property alias symbol: symbol.text + + ColumnLayout { + id: columnLayout + anchors.fill: parent + spacing: -10 + RowLayout { + Layout.alignment: Qt.AlignHCenter + MaterialSymbol { + id: symbol + fill: 0 + iconSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colOnSurfaceVariant + } + StyledText { + id: title + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colOnSurfaceVariant + } + } + StyledText { + id: value + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSurfaceVariant + } + } +} diff --git a/ii/modules/bar/weather/WeatherIcons.qml b/ii/modules/bar/weather/WeatherIcons.qml new file mode 100644 index 0000000..bd74d4e --- /dev/null +++ b/ii/modules/bar/weather/WeatherIcons.qml @@ -0,0 +1,59 @@ +pragma Singleton + +import Quickshell + +Singleton { + // credits: calestia + // this snippet is taken from + // https://github.com/caelestia-dots/shell + readonly property var codeToName: ({ + "113": "clear_day", + "116": "partly_cloudy_day", + "119": "cloud", + "122": "cloud", + "143": "foggy", + "176": "rainy", + "179": "rainy", + "182": "rainy", + "185": "rainy", + "200": "thunderstorm", + "227": "cloudy_snowing", + "230": "snowing_heavy", + "248": "foggy", + "260": "foggy", + "263": "rainy", + "266": "rainy", + "281": "rainy", + "284": "rainy", + "293": "rainy", + "296": "rainy", + "299": "rainy", + "302": "weather_hail", + "305": "rainy", + "308": "weather_hail", + "311": "rainy", + "314": "rainy", + "317": "rainy", + "320": "cloudy_snowing", + "323": "cloudy_snowing", + "326": "cloudy_snowing", + "329": "snowing_heavy", + "332": "snowing_heavy", + "335": "snowing", + "338": "snowing_heavy", + "350": "rainy", + "353": "rainy", + "356": "rainy", + "359": "weather_hail", + "362": "rainy", + "365": "rainy", + "368": "cloudy_snowing", + "371": "snowing", + "374": "rainy", + "377": "rainy", + "386": "thunderstorm", + "389": "thunderstorm", + "392": "thunderstorm", + "395": "snowing" + }) +} diff --git a/ii/modules/bar/weather/WeatherPopup.qml b/ii/modules/bar/weather/WeatherPopup.qml new file mode 100644 index 0000000..2f35675 --- /dev/null +++ b/ii/modules/bar/weather/WeatherPopup.qml @@ -0,0 +1,105 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +import QtQuick +import QtQuick.Layouts +import "../" + +StyledPopup { + id: root + + ColumnLayout { + id: columnLayout + anchors.centerIn: parent + implicitWidth: Math.max(header.implicitWidth, gridLayout.implicitWidth) + implicitHeight: gridLayout.implicitHeight + spacing: 5 + + // Header + ColumnLayout { + id: header + Layout.alignment: Qt.AlignHCenter + spacing: 2 + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 6 + + MaterialSymbol { + fill: 0 + font.weight: Font.Medium + text: "location_on" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnSurfaceVariant + } + + StyledText { + text: Weather.data.city + font { + weight: Font.Medium + pixelSize: Appearance.font.pixelSize.normal + } + color: Appearance.colors.colOnSurfaceVariant + } + } + StyledText { + id: temp + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colOnSurfaceVariant + text: Weather.data.temp + " • " + Translation.tr("Feels like %1").arg(Weather.data.tempFeelsLike) + } + } + + // Metrics grid + GridLayout { + id: gridLayout + columns: 2 + rowSpacing: 5 + columnSpacing: 5 + uniformCellWidths: true + + WeatherCard { + title: Translation.tr("UV Index") + symbol: "wb_sunny" + value: Weather.data.uv + } + WeatherCard { + title: Translation.tr("Wind") + symbol: "air" + value: `(${Weather.data.windDir}) ${Weather.data.wind}` + } + WeatherCard { + title: Translation.tr("Precipitation") + symbol: "rainy_light" + value: Weather.data.precip + } + WeatherCard { + title: Translation.tr("Humidity") + symbol: "humidity_low" + value: Weather.data.humidity + } + WeatherCard { + title: Translation.tr("Visibility") + symbol: "visibility" + value: Weather.data.visib + } + WeatherCard { + title: Translation.tr("Pressure") + symbol: "readiness_score" + value: Weather.data.press + } + WeatherCard { + title: Translation.tr("Sunrise") + symbol: "wb_twilight" + value: Weather.data.sunrise + } + WeatherCard { + title: Translation.tr("Sunset") + symbol: "bedtime" + value: Weather.data.sunset + } + } + } +} \ No newline at end of file diff --git a/ii/modules/cheatsheet/Cheatsheet.qml b/ii/modules/cheatsheet/Cheatsheet.qml new file mode 100644 index 0000000..6009392 --- /dev/null +++ b/ii/modules/cheatsheet/Cheatsheet.qml @@ -0,0 +1,236 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell.Io +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { // Scope + id: root + property var tabButtonList: [ + { + "icon": "keyboard", + "name": Translation.tr("Keybinds") + }, + { + "icon": "experiment", + "name": Translation.tr("Elements") + }, + ] + property int selectedTab: 0 + + Loader { + id: cheatsheetLoader + active: false + + sourceComponent: PanelWindow { // Window + id: cheatsheetRoot + visible: cheatsheetLoader.active + + anchors { + top: true + bottom: true + left: true + right: true + } + + function hide() { + cheatsheetLoader.active = false; + } + exclusiveZone: 0 + implicitWidth: cheatsheetBackground.width + Appearance.sizes.elevationMargin * 2 + implicitHeight: cheatsheetBackground.height + Appearance.sizes.elevationMargin * 2 + WlrLayershell.namespace: "quickshell:cheatsheet" + // Hyprland 0.49: Focus is always exclusive and setting this breaks mouse focus grab + // WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + color: "transparent" + + mask: Region { + item: cheatsheetBackground + } + + HyprlandFocusGrab { // Click outside to close + id: grab + windows: [cheatsheetRoot] + active: cheatsheetRoot.visible + onCleared: () => { + if (!active) + cheatsheetRoot.hide(); + } + } + + // Background + StyledRectangularShadow { + target: cheatsheetBackground + } + Rectangle { + id: cheatsheetBackground + anchors.centerIn: parent + color: Appearance.colors.colLayer0 + border.width: 1 + border.color: Appearance.colors.colLayer0Border + radius: Appearance.rounding.windowRounding + property real padding: 30 + implicitWidth: cheatsheetColumnLayout.implicitWidth + padding * 2 + implicitHeight: cheatsheetColumnLayout.implicitHeight + padding * 2 + + Keys.onPressed: event => { // Esc to close + if (event.key === Qt.Key_Escape) { + cheatsheetRoot.hide(); + } + 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; + } + } + } + + RippleButton { // Close button + id: closeButton + focus: cheatsheetRoot.visible + implicitWidth: 40 + implicitHeight: 40 + buttonRadius: Appearance.rounding.full + anchors { + top: parent.top + right: parent.right + topMargin: 20 + rightMargin: 20 + } + + onClicked: { + cheatsheetRoot.hide(); + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.title + text: "close" + } + } + + ColumnLayout { // Real content + id: cheatsheetColumnLayout + anchors.centerIn: parent + spacing: 20 + + StyledText { + id: cheatsheetTitle + Layout.alignment: Qt.AlignHCenter + font.family: Appearance.font.family.title + font.pixelSize: Appearance.font.pixelSize.title + text: Translation.tr("Cheat sheet") + } + 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 + + Behavior on implicitWidth { + id: contentWidthBehavior + enabled: false + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + id: contentHeightBehavior + enabled: false + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + currentIndex: tabBar.externalTrackedTab + onCurrentIndexChanged: { + contentWidthBehavior.enabled = true; + contentHeightBehavior.enabled = true; + 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 + } + } + + CheatsheetKeybinds {} + CheatsheetPeriodicTable {} + } + } + } + } + } + + IpcHandler { + target: "cheatsheet" + + function toggle(): void { + cheatsheetLoader.active = !cheatsheetLoader.active; + } + + function close(): void { + cheatsheetLoader.active = false; + } + + function open(): void { + cheatsheetLoader.active = true; + } + } + + GlobalShortcut { + name: "cheatsheetToggle" + description: "Toggles cheatsheet on press" + + onPressed: { + cheatsheetLoader.active = !cheatsheetLoader.active; + } + } + + GlobalShortcut { + name: "cheatsheetOpen" + description: "Opens cheatsheet on press" + + onPressed: { + cheatsheetLoader.active = true; + } + } + + GlobalShortcut { + name: "cheatsheetClose" + description: "Closes cheatsheet on press" + + onPressed: { + cheatsheetLoader.active = false; + } + } +} diff --git a/ii/modules/cheatsheet/CheatsheetKeybinds.qml b/ii/modules/cheatsheet/CheatsheetKeybinds.qml new file mode 100644 index 0000000..e0e8ce6 --- /dev/null +++ b/ii/modules/cheatsheet/CheatsheetKeybinds.qml @@ -0,0 +1,144 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts + +Item { + id: root + readonly property var keybinds: HyprlandKeybinds.keybinds + property real spacing: 20 + property real titleSpacing: 7 + implicitWidth: rowLayout.implicitWidth + implicitHeight: rowLayout.implicitHeight + + property var keyBlacklist: ["Super_L"] + property var keySubstitutions: ({ + "Super": "󰖳", + "mouse_up": "Scroll ↓", // ikr, weird + "mouse_down": "Scroll ↑", // trust me bro + "mouse:272": "LMB", + "mouse:273": "RMB", + "mouse:275": "MouseBack", + "Slash": "/", + "Hash": "#", + "Return": "Enter", + // "Shift": "", + }) + + RowLayout { // Keybind columns + id: rowLayout + spacing: root.spacing + Repeater { + model: keybinds.children + + delegate: ColumnLayout { // Keybind sections + spacing: root.spacing + required property var modelData + Layout.alignment: Qt.AlignTop + Repeater { + model: modelData.children + + delegate: Item { // Section with real keybinds + required property var modelData + implicitWidth: sectionColumnLayout.implicitWidth + implicitHeight: sectionColumnLayout.implicitHeight + ColumnLayout { + id: sectionColumnLayout + anchors.centerIn: parent + spacing: root.titleSpacing + StyledText { + id: sectionTitle + font.family: Appearance.font.family.title + font.pixelSize: Appearance.font.pixelSize.huge + color: Appearance.colors.colOnLayer0 + text: modelData.name + } + + GridLayout { + id: keybindGrid + columns: 2 + Repeater { + model: { + var result = []; + for (var i = 0; i < modelData.keybinds.length; i++) { + const keybind = modelData.keybinds[i]; + result.push({ + "type": "keys", + "mods": keybind.mods, + "key": keybind.key, + }); + result.push({ + "type": "comment", + "comment": keybind.comment, + }); + } + return result; + } + delegate: Item { + required property var modelData + implicitWidth: keybindLoader.implicitWidth + implicitHeight: keybindLoader.implicitHeight + + Loader { + id: keybindLoader + sourceComponent: (modelData.type === "keys") ? keysComponent : commentComponent + } + + Component { + id: keysComponent + RowLayout { + spacing: 4 + Repeater { + model: modelData.mods + delegate: KeyboardKey { + required property var modelData + key: keySubstitutions[modelData] || modelData + } + } + StyledText { + id: keybindPlus + visible: !keyBlacklist.includes(modelData.key) && modelData.mods.length > 0 + Layout.alignment: Qt.AlignVCenter + text: "+" + } + KeyboardKey { + id: keybindKey + visible: !keyBlacklist.includes(modelData.key) + key: keySubstitutions[modelData.key] || modelData.key + color: Appearance.colors.colOnLayer0 + } + } + } + + Component { + id: commentComponent + Item { + id: commentItem + implicitWidth: commentText.implicitWidth + 8 * 2 + implicitHeight: commentText.implicitHeight + + StyledText { + id: commentText + anchors.centerIn: parent + font.pixelSize: Appearance.font.pixelSize.smaller + text: modelData.comment + } + } + } + } + + } + } + } + } + + } + } + + } + } + +} \ No newline at end of file diff --git a/ii/modules/cheatsheet/CheatsheetPeriodicTable.qml b/ii/modules/cheatsheet/CheatsheetPeriodicTable.qml new file mode 100644 index 0000000..a0a8ecf --- /dev/null +++ b/ii/modules/cheatsheet/CheatsheetPeriodicTable.qml @@ -0,0 +1,68 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import "periodic_table.js" as PTable +import QtQuick +import QtQuick.Layouts + +Item { + id: root + readonly property var elements: PTable.elements + readonly property var series: PTable.series + property real spacing: 6 + implicitWidth: mainLayout.implicitWidth + implicitHeight: mainLayout.implicitHeight + + ColumnLayout { + id: mainLayout + spacing: root.spacing + + Repeater { // Main table rows + model: root.elements + + delegate: RowLayout { // Table cells + id: tableRow + spacing: root.spacing + required property var modelData + + Repeater { + model: tableRow.modelData + delegate: ElementTile { + required property var modelData + element: modelData + } + + } + } + + } + + Item { + id: gap + implicitHeight: 20 + } + + Repeater { // Main table rows + model: root.series + + delegate: RowLayout { // Table cells + id: seriesTableRow + spacing: root.spacing + required property var modelData + + Repeater { + model: seriesTableRow.modelData + delegate: ElementTile { + required property var modelData + element: modelData + } + + } + } + + } + } + +} \ No newline at end of file diff --git a/ii/modules/cheatsheet/ElementTile.qml b/ii/modules/cheatsheet/ElementTile.qml new file mode 100644 index 0000000..70e7b4d --- /dev/null +++ b/ii/modules/cheatsheet/ElementTile.qml @@ -0,0 +1,55 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +RippleButton { + id: root + required property var element + opacity: element.type != "empty" ? 1 : 0 + implicitHeight: 60 + implicitWidth: 60 + colBackground: Appearance.colors.colLayer2 + buttonRadius: Appearance.rounding.small + + Rectangle { + anchors { + top: parent.top + left: parent.left + topMargin: 4 + leftMargin: 4 + } + color: Appearance.colors.colLayer2 + radius: Appearance.rounding.full + implicitWidth: Math.max(20, elementNumber.implicitWidth) + implicitHeight: Math.max(20, elementNumber.implicitHeight) + width: height + + StyledText { + id: elementNumber + anchors.centerIn: parent + color: Appearance.colors.colOnLayer2 + text: root.element.number + font.pixelSize: Appearance.font.pixelSize.smallest + } + } + + StyledText { + id: elementSymbol + anchors.centerIn: parent + color: Appearance.colors.colSecondary + font.pixelSize: Appearance.font.pixelSize.huge + text: root.element.symbol + } + + StyledText { + id: elementName + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: 4 + } + font.pixelSize: Appearance.font.pixelSize.smallest + color: Appearance.colors.colOnLayer2 + text: root.element.name + } +} diff --git a/ii/modules/cheatsheet/periodic_table.js b/ii/modules/cheatsheet/periodic_table.js new file mode 100644 index 0000000..45d69cc --- /dev/null +++ b/ii/modules/cheatsheet/periodic_table.js @@ -0,0 +1,196 @@ +// List of rows +const elements = [ + [ + { name: 'Hydrogen', symbol: 'H', number: 1, weight: 1.01, type: 'nonmetal' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: 'Helium', symbol: 'He', number: 2, weight: 4.00, type: 'noblegas' }, + ], + [ + { name: 'Lithium', symbol: 'Li', number: 3, weight: 6.94, type: 'metal' }, + { name: 'Beryllium', symbol: 'Be', number: 4, weight: 9.01, type: 'metal' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: 'Boron', symbol: 'B', number: 5, weight: 10.81, type: 'nonmetal' }, + { name: 'Carbon', symbol: 'C', number: 6, weight: 12.01, type: 'nonmetal' }, + { name: 'Nitrogen', symbol: 'N', number: 7, weight: 14.01, type: 'nonmetal' }, + { name: 'Oxygen', symbol: 'O', number: 8, weight: 16, type: 'nonmetal' }, + { name: 'Fluorine', symbol: 'F', number: 9, weight: 19, type: 'nonmetal' }, + { name: 'Neon', symbol: 'Ne', number: 10, weight: 20.18, type: 'noblegas' }, + + + ], + [ + { name: 'Sodium', symbol: 'Na', number: 11, weight: 22.99, type: 'metal' }, + { name: 'Magnesium', symbol: 'Mg', number: 12, weight: 24.31, type: 'metal' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: 'Aluminum', symbol: 'Al', number: 13, weight: 26.98, type: 'metal' }, + { name: 'Silicon', symbol: 'Si', number: 14, weight: 28.09, type: 'nonmetal' }, + { name: 'Phosphorus', symbol: 'P', number: 15, weight: 30.97, type: 'nonmetal' }, + { name: 'Sulfur', symbol: 'S', number: 16, weight: 32.07, type: 'nonmetal' }, + { name: 'Chlorine', symbol: 'Cl', number: 17, weight: 35.45, type: 'nonmetal' }, + { name: 'Argon', symbol: 'Ar', number: 18, weight: 39.95, type: 'noblegas' }, + ], + [ + { name: 'Potassium', symbol: 'K', number: 19, weight: 39.098, type: 'metal' }, + { name: 'Calcium', symbol: 'Ca', number: 20, weight: 40.078, type: 'metal' }, + { name: 'Scandium', symbol: 'Sc', number: 21, weight: 44.956, type: 'metal' }, + { name: 'Titanium', symbol: 'Ti', number: 22, weight: 47.87, type: 'metal' }, + { name: 'Vanadium', symbol: 'V', number: 23, weight: 50.94, type: 'metal' }, + { name: 'Chromium', symbol: 'Cr', number: 24, weight: 52, type: 'metal'/*, icon: 'chromium-browser'*/ }, + { name: 'Manganese', symbol: 'Mn', number: 25, weight: 54.94, type: 'metal' }, + { name: 'Iron', symbol: 'Fe', number: 26, weight: 55.85, type: 'metal' }, + { name: 'Cobalt', symbol: 'Co', number: 27, weight: 58.93, type: 'metal' }, + { name: 'Nickel', symbol: 'Ni', number: 28, weight: 58.69, type: 'metal' }, + { name: 'Copper', symbol: 'Cu', number: 29, weight: 63.55, type: 'metal' }, + { name: 'Zinc', symbol: 'Zn', number: 30, weight: 65.38, type: 'metal' }, + { name: 'Gallium', symbol: 'Ga', number: 31, weight: 69.72, type: 'metal' }, + { name: 'Germanium', symbol: 'Ge', number: 32, weight: 72.63, type: 'metal' }, + { name: 'Arsenic', symbol: 'As', number: 33, weight: 74.92, type: 'nonmetal' }, + { name: 'Selenium', symbol: 'Se', number: 34, weight: 78.96, type: 'nonmetal' }, + { name: 'Bromine', symbol: 'Br', number: 35, weight: 79.904, type: 'nonmetal' }, + { name: 'Krypton', symbol: 'Kr', number: 36, weight: 83.8, type: 'noblegas' }, + ], + [ + { name: 'Rubidium', symbol: 'Rb', number: 37, weight: 85.47, type: 'metal' }, + { name: 'Strontium', symbol: 'Sr', number: 38, weight: 87.62, type: 'metal' }, + { name: 'Yttrium', symbol: 'Y', number: 39, weight: 88.91, type: 'metal' }, + { name: 'Zirconium', symbol: 'Zr', number: 40, weight: 91.22, type: 'metal' }, + { name: 'Niobium', symbol: 'Nb', number: 41, weight: 92.91, type: 'metal' }, + { name: 'Molybdenum', symbol: 'Mo', number: 42, weight: 95.94, type: 'metal' }, + { name: 'Technetium', symbol: 'Tc', number: 43, weight: 98, type: 'metal' }, + { name: 'Ruthenium', symbol: 'Ru', number: 44, weight: 101.07, type: 'metal' }, + { name: 'Rhodium', symbol: 'Rh', number: 45, weight: 102.91, type: 'metal' }, + { name: 'Palladium', symbol: 'Pd', number: 46, weight: 106.42, type: 'metal' }, + { name: 'Silver', symbol: 'Ag', number: 47, weight: 107.87, type: 'metal' }, + { name: 'Cadmium', symbol: 'Cd', number: 48, weight: 112.41, type: 'metal' }, + { name: 'Indium', symbol: 'In', number: 49, weight: 114.82, type: 'metal' }, + { name: 'Tin', symbol: 'Sn', number: 50, weight: 118.71, type: 'metal' }, + { name: 'Antimony', symbol: 'Sb', number: 51, weight: 121.76, type: 'metal' }, + { name: 'Tellurium', symbol: 'Te', number: 52, weight: 127.6, type: 'nonmetal' }, + { name: 'Iodine', symbol: 'I', number: 53, weight: 126.9, type: 'nonmetal' }, + { name: 'Xenon', symbol: 'Xe', number: 54, weight: 131.29, type: 'noblegas' }, + ], + [ + { name: 'Cesium', symbol: 'Cs', number: 55, weight: 132.91, type: 'metal' }, + { name: 'Barium', symbol: 'Ba', number: 56, weight: 137.33, type: 'metal' }, + { name: 'Lanthanum', symbol: 'La', number: 57, weight: 138.91, type: 'lanthanum' }, + { name: 'Hafnium', symbol: 'Hf', number: 72, weight: 178.49, type: 'metal' }, + { name: 'Tantalum', symbol: 'Ta', number: 73, weight: 180.95, type: 'metal' }, + { name: 'Tungsten', symbol: 'W', number: 74, weight: 183.84, type: 'metal' }, + { name: 'Rhenium', symbol: 'Re', number: 75, weight: 186.21, type: 'metal' }, + { name: 'Osmium', symbol: 'Os', number: 76, weight: 190.23, type: 'metal' }, + { name: 'Iridium', symbol: 'Ir', number: 77, weight: 192.22, type: 'metal' }, + { name: 'Platinum', symbol: 'Pt', number: 78, weight: 195.09, type: 'metal' }, + { name: 'Gold', symbol: 'Au', number: 79, weight: 196.97, type: 'metal' }, + { name: 'Mercury', symbol: 'Hg', number: 80, weight: 200.59, type: 'metal' }, + { name: 'Thallium', symbol: 'Tl', number: 81, weight: 204.38, type: 'metal' }, + { name: 'Lead', symbol: 'Pb', number: 82, weight: 207.2, type: 'metal' }, + { name: 'Bismuth', symbol: 'Bi', number: 83, weight: 208.98, type: 'metal' }, + { name: 'Polonium', symbol: 'Po', number: 84, weight: 209, type: 'metal' }, + { name: 'Astatine', symbol: 'At', number: 85, weight: 210, type: 'nonmetal' }, + { name: 'Radon', symbol: 'Rn', number: 86, weight: 222, type: 'noblegas' }, + ], + [ + { name: 'Francium', symbol: 'Fr', number: 87, weight: 223, type: 'metal' }, + { name: 'Radium', symbol: 'Ra', number: 88, weight: 226, type: 'metal' }, + { name: 'Actinium', symbol: 'Ac', number: 89, weight: 227, type: 'actinium' }, + { name: 'Rutherfordium', symbol: 'Rf', number: 104, weight: 267, type: 'metal' }, + { name: 'Dubnium', symbol: 'Db', number: 105, weight: 268, type: 'metal' }, + { name: 'Seaborgium', symbol: 'Sg', number: 106, weight: 271, type: 'metal' }, + { name: 'Bohrium', symbol: 'Bh', number: 107, weight: 272, type: 'metal' }, + { name: 'Hassium', symbol: 'Hs', number: 108, weight: 277, type: 'metal' }, + { name: 'Meitnerium', symbol: 'Mt', number: 109, weight: 278, type: 'metal' }, + { name: 'Darmstadtium', symbol: 'Ds', number: 110, weight: 281, type: 'metal' }, + { name: 'Roentgenium', symbol: 'Rg', number: 111, weight: 280, type: 'metal' }, + { name: 'Copernicium', symbol: 'Cn', number: 112, weight: 285, type: 'metal' }, + { name: 'Nihonium', symbol: 'Nh', number: 113, weight: 286, type: 'metal' }, + { name: 'Flerovium', symbol: 'Fl', number: 114, weight: 289, type: 'metal' }, + { name: 'Moscovium', symbol: 'Mc', number: 115, weight: 290, type: 'metal' }, + { name: 'Livermorium', symbol: 'Lv', number: 116, weight: 293, type: 'metal' }, + { name: 'Tennessine', symbol: 'Ts', number: 117, weight: 294, type: 'metal' }, + { name: 'Oganesson', symbol: 'Og', number: 118, weight: 294, type: 'noblegas' }, + ], +] + +const series = [ + [ + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: 'Cerium', symbol: 'Ce', number: 58, weight: 140.12, type: 'lanthanum' }, + { name: 'Praseodymium', symbol: 'Pr', number: 59, weight: 140.91, type: 'lanthanum' }, + { name: 'Neodymium', symbol: 'Nd', number: 60, weight: 144.24, type: 'lanthanum' }, + { name: 'Promethium', symbol: 'Pm', number: 61, weight: 145, type: 'lanthanum' }, + { name: 'Samarium', symbol: 'Sm', number: 62, weight: 150.36, type: 'lanthanum' }, + { name: 'Europium', symbol: 'Eu', number: 63, weight: 151.96, type: 'lanthanum' }, + { name: 'Gadolinium', symbol: 'Gd', number: 64, weight: 157.25, type: 'lanthanum' }, + { name: 'Terbium', symbol: 'Tb', number: 65, weight: 158.93, type: 'lanthanum' }, + { name: 'Dysprosium', symbol: 'Dy', number: 66, weight: 162.5, type: 'lanthanum' }, + { name: 'Holmium', symbol: 'Ho', number: 67, weight: 164.93, type: 'lanthanum' }, + { name: 'Erbium', symbol: 'Er', number: 68, weight: 167.26, type: 'lanthanum' }, + { name: 'Thulium', symbol: 'Tm', number: 69, weight: 168.93, type: 'lanthanum' }, + { name: 'Ytterbium', symbol: 'Yb', number: 70, weight: 173.04, type: 'lanthanum' }, + { name: 'Lutetium', symbol: 'Lu', number: 71, weight: 174.97, type: 'lanthanum' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + ], + [ + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + { name: 'Thorium', symbol: 'Th', number: 90, weight: 232.04, type: 'actinium' }, + { name: 'Protactinium', symbol: 'Pa', number: 91, weight: 231.04, type: 'actinium' }, + { name: 'Uranium', symbol: 'U', number: 92, weight: 238.03, type: 'actinium' }, + { name: 'Neptunium', symbol: 'Np', number: 93, weight: 237, type: 'actinium' }, + { name: 'Plutonium', symbol: 'Pu', number: 94, weight: 244, type: 'actinium' }, + { name: 'Americium', symbol: 'Am', number: 95, weight: 243, type: 'actinium' }, + { name: 'Curium', symbol: 'Cm', number: 96, weight: 247, type: 'actinium' }, + { name: 'Berkelium', symbol: 'Bk', number: 97, weight: 247, type: 'actinium' }, + { name: 'Californium', symbol: 'Cf', number: 98, weight: 251, type: 'actinium' }, + { name: 'Einsteinium', symbol: 'Es', number: 99, weight: 252, type: 'actinium' }, + { name: 'Fermium', symbol: 'Fm', number: 100, weight: 257, type: 'actinium' }, + { name: 'Mendelevium', symbol: 'Md', number: 101, weight: 258, type: 'actinium' }, + { name: 'Nobelium', symbol: 'No', number: 102, weight: 259, type: 'actinium' }, + { name: 'Lawrencium', symbol: 'Lr', number: 103, weight: 262, type: 'actinium' }, + { name: '', symbol: '', number: -1, weight: 0, type: 'empty' }, + ], +]; + +const niceTypes = { + 'metal': "Metal", + 'nonmetal': "Nonmetal", + 'noblegas': "Noble gas", + 'lanthanum': "Lanthanum", + 'actinium': "Actinium" +} diff --git a/ii/modules/common/Appearance.qml b/ii/modules/common/Appearance.qml new file mode 100644 index 0000000..5c96021 --- /dev/null +++ b/ii/modules/common/Appearance.qml @@ -0,0 +1,360 @@ +import QtQuick +import Quickshell +import qs.modules.common.functions +pragma Singleton +pragma ComponentBehavior: Bound + +Singleton { + id: root + property QtObject m3colors + property QtObject animation + property QtObject animationCurves + property QtObject colors + property QtObject rounding + property QtObject font + property QtObject sizes + property string syntaxHighlightingTheme + + // Transparency. The quadratic functions were derived from analysis of hand-picked transparency values. + ColorQuantizer { + id: wallColorQuant + source: Qt.resolvedUrl(Config.options.background.wallpaperPath) + depth: 0 // 2^0 = 1 color + rescaleSize: 10 + } + property real wallpaperVibrancy: (wallColorQuant.colors[0]?.hslSaturation + wallColorQuant.colors[0]?.hslLightness) / 2 + property real autoBackgroundTransparency: { // y = 0.5768x^2 - 0.759x + 0.2896 + let x = wallpaperVibrancy + let y = 0.5768 * (x * x) - 0.759 * (x) + 0.2896 + return Math.max(0, Math.min(0.22, y)) + } + property real autoContentTransparency: { // y = -10.1734x^2 + 3.4457x + 0.1872 + let x = autoBackgroundTransparency + let y = -10.1734 * (x * x) + 3.4457 * (x) + 0.1872 + return Math.max(0, Math.min(0.6, y)) + } + property real backgroundTransparency: Config?.options.appearance.transparency.enable ? Config?.options.appearance.transparency.automatic ? autoBackgroundTransparency : Config?.options.appearance.transparency.backgroundTransparency : 0 + property real contentTransparency: Config?.options.appearance.transparency.enable ? Config?.options.appearance.transparency.automatic ? autoContentTransparency : Config?.options.appearance.transparency.contentTransparency : 0 + + m3colors: QtObject { + property bool darkmode: false + property bool transparent: false + property color m3primary_paletteKeyColor: "#91689E" + property color m3secondary_paletteKeyColor: "#837186" + property color m3tertiary_paletteKeyColor: "#9D6A67" + property color m3neutral_paletteKeyColor: "#7C757B" + property color m3neutral_variant_paletteKeyColor: "#7D747D" + property color m3background: "#161217" + property color m3onBackground: "#EAE0E7" + property color m3surface: "#161217" + property color m3surfaceDim: "#161217" + property color m3surfaceBright: "#3D373D" + property color m3surfaceContainerLowest: "#110D12" + property color m3surfaceContainerLow: "#1F1A1F" + property color m3surfaceContainer: "#231E23" + property color m3surfaceContainerHigh: "#2D282E" + property color m3surfaceContainerHighest: "#383339" + property color m3onSurface: "#EAE0E7" + property color m3surfaceVariant: "#4C444D" + property color m3onSurfaceVariant: "#CFC3CD" + property color m3inverseSurface: "#EAE0E7" + property color m3inverseOnSurface: "#342F34" + property color m3outline: "#988E97" + property color m3outlineVariant: "#4C444D" + property color m3shadow: "#000000" + property color m3scrim: "#000000" + property color m3surfaceTint: "#E5B6F2" + property color m3primary: "#E5B6F2" + property color m3onPrimary: "#452152" + property color m3primaryContainer: "#5D386A" + property color m3onPrimaryContainer: "#F9D8FF" + property color m3inversePrimary: "#775084" + property color m3secondary: "#D5C0D7" + property color m3onSecondary: "#392C3D" + property color m3secondaryContainer: "#534457" + property color m3onSecondaryContainer: "#F2DCF3" + property color m3tertiary: "#F5B7B3" + property color m3onTertiary: "#4C2523" + property color m3tertiaryContainer: "#BA837F" + property color m3onTertiaryContainer: "#000000" + property color m3error: "#FFB4AB" + property color m3onError: "#690005" + property color m3errorContainer: "#93000A" + property color m3onErrorContainer: "#FFDAD6" + property color m3primaryFixed: "#F9D8FF" + property color m3primaryFixedDim: "#E5B6F2" + property color m3onPrimaryFixed: "#2E0A3C" + property color m3onPrimaryFixedVariant: "#5D386A" + property color m3secondaryFixed: "#F2DCF3" + property color m3secondaryFixedDim: "#D5C0D7" + property color m3onSecondaryFixed: "#241727" + property color m3onSecondaryFixedVariant: "#514254" + property color m3tertiaryFixed: "#FFDAD7" + property color m3tertiaryFixedDim: "#F5B7B3" + property color m3onTertiaryFixed: "#331110" + property color m3onTertiaryFixedVariant: "#663B39" + property color m3success: "#B5CCBA" + property color m3onSuccess: "#213528" + property color m3successContainer: "#374B3E" + property color m3onSuccessContainer: "#D1E9D6" + property color term0: "#EDE4E4" + property color term1: "#B52755" + property color term2: "#A97363" + property color term3: "#AF535D" + property color term4: "#A67F7C" + property color term5: "#B2416B" + property color term6: "#8D76AD" + property color term7: "#272022" + property color term8: "#0E0D0D" + property color term9: "#B52755" + property color term10: "#A97363" + property color term11: "#AF535D" + property color term12: "#A67F7C" + property color term13: "#B2416B" + property color term14: "#8D76AD" + property color term15: "#221A1A" + } + + colors: QtObject { + property color colSubtext: m3colors.m3outline + property color colLayer0: ColorUtils.mix(ColorUtils.transparentize(m3colors.m3background, root.backgroundTransparency), m3colors.m3primary, Config.options.appearance.extraBackgroundTint ? 0.99 : 1) + property color colOnLayer0: m3colors.m3onBackground + property color colLayer0Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer0, colOnLayer0, 0.9, root.contentTransparency)) + property color colLayer0Active: ColorUtils.transparentize(ColorUtils.mix(colLayer0, colOnLayer0, 0.8, root.contentTransparency)) + property color colLayer0Border: ColorUtils.mix(root.m3colors.m3outlineVariant, colLayer0, 0.4) + property color colLayer1: ColorUtils.transparentize(m3colors.m3surfaceContainerLow, root.contentTransparency); + property color colOnLayer1: m3colors.m3onSurfaceVariant; + property color colOnLayer1Inactive: ColorUtils.mix(colOnLayer1, colLayer1, 0.45); + property color colLayer2: ColorUtils.transparentize(m3colors.m3surfaceContainer, root.contentTransparency) + property color colOnLayer2: m3colors.m3onSurface; + property color colOnLayer2Disabled: ColorUtils.mix(colOnLayer2, m3colors.m3background, 0.4); + property color colLayer1Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer1, colOnLayer1, 0.92), root.contentTransparency) + property color colLayer1Active: ColorUtils.transparentize(ColorUtils.mix(colLayer1, colOnLayer1, 0.85), root.contentTransparency); + property color colLayer2Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer2, colOnLayer2, 0.90), root.contentTransparency) + property color colLayer2Active: ColorUtils.transparentize(ColorUtils.mix(colLayer2, colOnLayer2, 0.80), root.contentTransparency); + property color colLayer2Disabled: ColorUtils.transparentize(ColorUtils.mix(colLayer2, m3colors.m3background, 0.8), root.contentTransparency); + property color colLayer3: ColorUtils.transparentize(m3colors.m3surfaceContainerHigh, root.contentTransparency) + property color colOnLayer3: m3colors.m3onSurface; + property color colLayer3Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer3, colOnLayer3, 0.90), root.contentTransparency) + property color colLayer3Active: ColorUtils.transparentize(ColorUtils.mix(colLayer3, colOnLayer3, 0.80), root.contentTransparency); + property color colLayer4: ColorUtils.transparentize(m3colors.m3surfaceContainerHighest, root.contentTransparency) + property color colOnLayer4: m3colors.m3onSurface; + property color colLayer4Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer4, colOnLayer4, 0.90), root.contentTransparency) + property color colLayer4Active: ColorUtils.transparentize(ColorUtils.mix(colLayer4, colOnLayer4, 0.80), root.contentTransparency); + property color colPrimary: m3colors.m3primary + property color colOnPrimary: m3colors.m3onPrimary + property color colPrimaryHover: ColorUtils.mix(colors.colPrimary, colLayer1Hover, 0.87) + property color colPrimaryActive: ColorUtils.mix(colors.colPrimary, colLayer1Active, 0.7) + property color colPrimaryContainer: m3colors.m3primaryContainer + property color colPrimaryContainerHover: ColorUtils.mix(colors.colPrimaryContainer, colors.colOnPrimaryContainer, 0.9) + property color colPrimaryContainerActive: ColorUtils.mix(colors.colPrimaryContainer, colors.colOnPrimaryContainer, 0.8) + property color colOnPrimaryContainer: m3colors.m3onPrimaryContainer + property color colSecondary: m3colors.m3secondary + property color colSecondaryHover: ColorUtils.mix(m3colors.m3secondary, colLayer1Hover, 0.85) + property color colSecondaryActive: ColorUtils.mix(m3colors.m3secondary, colLayer1Active, 0.4) + property color colSecondaryContainer: m3colors.m3secondaryContainer + property color colSecondaryContainerHover: ColorUtils.mix(m3colors.m3secondaryContainer, m3colors.m3onSecondaryContainer, 0.90) + property color colSecondaryContainerActive: ColorUtils.mix(m3colors.m3secondaryContainer, m3colors.m3onSecondaryContainer, 0.54) + property color colTertiary: m3colors.m3tertiary + property color colTertiaryHover: ColorUtils.mix(m3colors.m3tertiary, colLayer1Hover, 0.85) + property color colTertiaryActive: ColorUtils.mix(m3colors.m3tertiary, colLayer1Active, 0.4) + property color colTertiaryContainer: m3colors.m3tertiaryContainer + property color colTertiaryContainerHover: ColorUtils.mix(m3colors.m3tertiaryContainer, m3colors.m3onTertiaryContainer, 0.90) + property color colTertiaryContainerActive: ColorUtils.mix(m3colors.m3tertiaryContainer, colLayer1Active, 0.54) + property color colOnSecondaryContainer: m3colors.m3onSecondaryContainer + property color colSurfaceContainerLow: ColorUtils.transparentize(m3colors.m3surfaceContainerLow, root.contentTransparency) + property color colSurfaceContainer: ColorUtils.transparentize(m3colors.m3surfaceContainer, root.contentTransparency) + property color colSurfaceContainerHigh: ColorUtils.transparentize(m3colors.m3surfaceContainerHigh, root.contentTransparency) + property color colSurfaceContainerHighest: ColorUtils.transparentize(m3colors.m3surfaceContainerHighest, root.contentTransparency) + property color colSurfaceContainerHighestHover: ColorUtils.mix(m3colors.m3surfaceContainerHighest, m3colors.m3onSurface, 0.95) + property color colSurfaceContainerHighestActive: ColorUtils.mix(m3colors.m3surfaceContainerHighest, m3colors.m3onSurface, 0.85) + property color colOnSurface: m3colors.m3onSurface + property color colOnSurfaceVariant: m3colors.m3onSurfaceVariant + property color colTooltip: m3colors.m3inverseSurface + property color colOnTooltip: m3colors.m3inverseOnSurface + property color colScrim: ColorUtils.transparentize(m3colors.m3scrim, 0.5) + property color colShadow: ColorUtils.transparentize(m3colors.m3shadow, 0.7) + property color colOutline: m3colors.m3outline + property color colOutlineVariant: m3colors.m3outlineVariant + property color colError: m3colors.m3error + property color colErrorHover: ColorUtils.mix(m3colors.m3error, colLayer1Hover, 0.85) + property color colErrorActive: ColorUtils.mix(m3colors.m3error, colLayer1Active, 0.7) + property color colOnError: m3colors.m3onError + property color colErrorContainer: m3colors.m3errorContainer + property color colErrorContainerHover: ColorUtils.mix(m3colors.m3errorContainer, m3colors.m3onErrorContainer, 0.90) + property color colErrorContainerActive: ColorUtils.mix(m3colors.m3errorContainer, m3colors.m3onErrorContainer, 0.70) + property color colOnErrorContainer: m3colors.m3onErrorContainer + } + + rounding: QtObject { + property int unsharpen: 2 + property int unsharpenmore: 6 + property int verysmall: 8 + property int small: 12 + property int normal: 17 + property int large: 23 + property int verylarge: 30 + property int full: 9999 + property int screenRounding: verysmall + property int windowRounding: verysmall + } + + font: QtObject { + property QtObject family: QtObject { + property string main: "Rubik" + property string title: "Gabarito" + property string iconMaterial: "Material Symbols Rounded" + property string iconNerd: "JetBrains Mono NF" + property string monospace: "JetBrains Mono NF" + property string reading: "Readex Pro" + property string expressive: "Space Grotesk" + } + property QtObject pixelSize: QtObject { + property int smallest: 10 + property int smaller: 12 + property int small: 15 + property int normal: 16 + property int large: 17 + property int larger: 19 + property int huge: 22 + property int hugeass: 23 + property int title: huge + } + } + + animationCurves: QtObject { + readonly property list expressiveFastSpatial: [0.42, 1.67, 0.21, 0.90, 1, 1] // Default, 350ms + readonly property list expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1.00, 1, 1] // Default, 500ms + readonly property list expressiveSlowSpatial: [0.39, 1.29, 0.35, 0.98, 1, 1] // Default, 650ms + readonly property list expressiveEffects: [0.34, 0.80, 0.34, 1.00, 1, 1] // Default, 200ms + readonly property list emphasized: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1] + readonly property list emphasizedFirstHalf: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82] + readonly property list emphasizedLastHalf: [5 / 24, 0.82, 0.25, 1, 1, 1] + readonly property list emphasizedAccel: [0.3, 0, 0.8, 0.15, 1, 1] + readonly property list emphasizedDecel: [0.05, 0.7, 0.1, 1, 1, 1] + readonly property list standard: [0.2, 0, 0, 1, 1, 1] + readonly property list standardAccel: [0.3, 0, 1, 1, 1, 1] + readonly property list standardDecel: [0, 0, 0, 1, 1, 1] + readonly property real expressiveFastSpatialDuration: 350 + readonly property real expressiveDefaultSpatialDuration: 500 + readonly property real expressiveSlowSpatialDuration: 650 + readonly property real expressiveEffectsDuration: 200 + } + + animation: QtObject { + property QtObject elementMove: QtObject { + property int duration: animationCurves.expressiveDefaultSpatialDuration + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.expressiveDefaultSpatial + property int velocity: 650 + property Component numberAnimation: Component { + NumberAnimation { + duration: root.animation.elementMove.duration + easing.type: root.animation.elementMove.type + easing.bezierCurve: root.animation.elementMove.bezierCurve + } + } + property Component colorAnimation: Component { + ColorAnimation { + duration: root.animation.elementMove.duration + easing.type: root.animation.elementMove.type + easing.bezierCurve: root.animation.elementMove.bezierCurve + } + } + } + property QtObject elementMoveEnter: QtObject { + property int duration: 400 + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.emphasizedDecel + property int velocity: 650 + property Component numberAnimation: Component { + NumberAnimation { + duration: root.animation.elementMoveEnter.duration + easing.type: root.animation.elementMoveEnter.type + easing.bezierCurve: root.animation.elementMoveEnter.bezierCurve + } + } + } + property QtObject elementMoveExit: QtObject { + property int duration: 200 + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.emphasizedAccel + property int velocity: 650 + property Component numberAnimation: Component { + NumberAnimation { + duration: root.animation.elementMoveExit.duration + easing.type: root.animation.elementMoveExit.type + easing.bezierCurve: root.animation.elementMoveExit.bezierCurve + } + } + } + property QtObject elementMoveFast: QtObject { + property int duration: animationCurves.expressiveEffectsDuration + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.expressiveEffects + property int velocity: 850 + property Component colorAnimation: Component { ColorAnimation { + duration: root.animation.elementMoveFast.duration + easing.type: root.animation.elementMoveFast.type + easing.bezierCurve: root.animation.elementMoveFast.bezierCurve + }} + property Component numberAnimation: Component { NumberAnimation { + duration: root.animation.elementMoveFast.duration + easing.type: root.animation.elementMoveFast.type + easing.bezierCurve: root.animation.elementMoveFast.bezierCurve + }} + } + property QtObject clickBounce: QtObject { + property int duration: 200 + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.expressiveFastSpatial + property int velocity: 850 + property Component numberAnimation: Component { NumberAnimation { + duration: root.animation.clickBounce.duration + easing.type: root.animation.clickBounce.type + easing.bezierCurve: root.animation.clickBounce.bezierCurve + }} + } + property QtObject scroll: QtObject { + property int duration: 200 + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.standardDecel + } + property QtObject menuDecel: QtObject { + property int duration: 350 + property int type: Easing.OutExpo + } + } + + sizes: QtObject { + property real baseBarHeight: 40 + property real barHeight: Config.options.bar.cornerStyle === 1 ? + (baseBarHeight + root.sizes.hyprlandGapsOut * 2) : baseBarHeight + property real barCenterSideModuleWidth: Config.options?.bar.verbose ? 360 : 140 + property real barCenterSideModuleWidthShortened: 280 + property real barCenterSideModuleWidthHellaShortened: 190 + property real barShortenScreenWidthThreshold: 1200 // Shorten if screen width is at most this value + property real barHellaShortenScreenWidthThreshold: 1000 // Shorten even more... + property real elevationMargin: 10 + property real fabShadowRadius: 5 + property real fabHoveredShadowRadius: 7 + property real hyprlandGapsOut: 5 + property real mediaControlsWidth: 440 + property real mediaControlsHeight: 160 + property real notificationPopupWidth: 410 + property real osdWidth: 200 + property real searchWidthCollapsed: 260 + property real searchWidth: 450 + property real sidebarWidth: 460 + property real sidebarWidthExtended: 750 + property real baseVerticalBarWidth: 46 + property real verticalBarWidth: Config.options.bar.cornerStyle === 1 ? + (baseVerticalBarWidth + root.sizes.hyprlandGapsOut * 2) : baseVerticalBarWidth + property real wallpaperSelectorWidth: 1200 + property real wallpaperSelectorHeight: 690 + property real wallpaperSelectorItemMargins: 8 + property real wallpaperSelectorItemPadding: 6 + } + + syntaxHighlightingTheme: root.m3colors.darkmode ? "Monokai" : "ayu Light" +} diff --git a/ii/modules/common/Config.qml b/ii/modules/common/Config.qml new file mode 100644 index 0000000..0365fa8 --- /dev/null +++ b/ii/modules/common/Config.qml @@ -0,0 +1,346 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + property string filePath: Directories.shellConfigPath + property alias options: configOptionsJsonAdapter + property bool ready: false + + function setNestedValue(nestedKey, value) { + let keys = nestedKey.split("."); + let obj = root.options; + let parents = [obj]; + + // Traverse and collect parent objects + for (let i = 0; i < keys.length - 1; ++i) { + if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") { + obj[keys[i]] = {}; + } + obj = obj[keys[i]]; + parents.push(obj); + } + + // Convert value to correct type using JSON.parse when safe + let convertedValue = value; + if (typeof value === "string") { + let trimmed = value.trim(); + if (trimmed === "true" || trimmed === "false" || !isNaN(Number(trimmed))) { + try { + convertedValue = JSON.parse(trimmed); + } catch (e) { + convertedValue = value; + } + } + } + + obj[keys[keys.length - 1]] = convertedValue; + } + + FileView { + path: root.filePath + watchChanges: true + onFileChanged: reload() + onAdapterUpdated: writeAdapter() + onLoaded: root.ready = true + onLoadFailed: error => { + if (error == FileViewError.FileNotFound) { + writeAdapter(); + } + } + + JsonAdapter { + id: configOptionsJsonAdapter + property JsonObject policies: JsonObject { + property int ai: 1 // 0: No | 1: Yes | 2: Local + property int weeb: 1 // 0: No | 1: Open | 2: Closet + } + + property JsonObject ai: JsonObject { + property string systemPrompt: "## Style\n- Use casual tone, don't be formal! Make sure you answer precisely without hallucination and prefer bullet points over walls of text. You can have a friendly greeting at the beginning of the conversation, but don't repeat the user's question\n\n## Context (ignore when irrelevant)\n- You are a helpful and inspiring sidebar assistant on a {DISTRO} Linux system\n- Desktop environment: {DE}\n- Current date & time: {DATETIME}\n- Focused app: {WINDOWCLASS}\n\n## Presentation\n- Use Markdown features in your response: \n - **Bold** text to **highlight keywords** in your response\n - **Split long information into small sections** with h2 headers and a relevant emoji at the start of it (for example `## 🐧 Linux`). Bullet points are preferred over long paragraphs, unless you're offering writing support or instructed otherwise by the user.\n- Asked to compare different options? You should firstly use a table to compare the main aspects, then elaborate or include relevant comments from online forums *after* the table. Make sure to provide a final recommendation for the user's use case!\n- Use LaTeX formatting for mathematical and scientific notations whenever appropriate. Enclose all LaTeX '$$' delimiters. NEVER generate LaTeX code in a latex block unless the user explicitly asks for it. DO NOT use LaTeX for regular documents (resumes, letters, essays, CVs, etc.).\n" + property string tool: "functions" // search, functions, or none + property list extraModels: [ + { + "api_format": "openai", // Most of the time you want "openai". Use "gemini" for Google's models + "description": "This is a custom model. Edit the config to add more! | Anyway, this is DeepSeek R1 Distill LLaMA 70B", + "endpoint": "https://openrouter.ai/api/v1/chat/completions", + "homepage": "https://openrouter.ai/deepseek/deepseek-r1-distill-llama-70b:free", // Not mandatory + "icon": "spark-symbolic", // Not mandatory + "key_get_link": "https://openrouter.ai/settings/keys", // Not mandatory + "key_id": "openrouter", + "model": "deepseek/deepseek-r1-distill-llama-70b:free", + "name": "Custom: DS R1 Dstl. LLaMA 70B", + "requires_key": true + } + ] + } + + property JsonObject appearance: JsonObject { + property bool extraBackgroundTint: true + property int fakeScreenRounding: 2 // 0: None | 1: Always | 2: When not fullscreen + property JsonObject transparency: JsonObject { + property bool enable: false + property bool automatic: true + property real backgroundTransparency: 0.11 + property real contentTransparency: 0.57 + } + property JsonObject wallpaperTheming: JsonObject { + property bool enableAppsAndShell: true + property bool enableQtApps: true + property bool enableTerminal: true + property JsonObject terminalGenerationProps: JsonObject { + property real harmony: 0.8 + property real harmonizeThreshold: 100 + property real termFgBoost: 0.35 + property bool forceDarkMode: false + } + } + property JsonObject palette: JsonObject { + property string type: "auto" // Allowed: auto, scheme-content, scheme-expressive, scheme-fidelity, scheme-fruit-salad, scheme-monochrome, scheme-neutral, scheme-rainbow, scheme-tonal-spot + } + } + + property JsonObject audio: JsonObject { + // Values in % + property JsonObject protection: JsonObject { + // Prevent sudden bangs + property bool enable: false + property real maxAllowedIncrease: 10 + property real maxAllowed: 99 + } + } + + property JsonObject apps: JsonObject { + property string bluetooth: "kcmshell6 kcm_bluetooth" + property string network: "kitty -1 fish -c nmtui" + property string networkEthernet: "kcmshell6 kcm_networkmanagement" + property string taskManager: "plasma-systemmonitor --page-name Processes" + property string terminal: "kitty -1" // This is only for shell actions + } + + property JsonObject background: JsonObject { + property bool fixedClockPosition: false + property real clockX: -500 + property real clockY: -500 + property bool showClock: true + property string wallpaperPath: "" + property string thumbnailPath: "" + property JsonObject parallax: JsonObject { + property bool vertical: false + property bool autoVertical: false + property bool enableWorkspace: true + property real workspaceZoom: 1.07 // Relative to your screen, not wallpaper size + property bool enableSidebar: true + } + property string quote: "" + property bool hideWhenFullscreen: true + } + + property JsonObject bar: JsonObject { + property JsonObject autoHide: JsonObject { + property bool enable: false + property bool pushWindows: false + property JsonObject showWhenPressingSuper: JsonObject { + property bool enable: true + property int delay: 140 + } + } + property bool bottom: false // Instead of top + property int cornerStyle: 0 // 0: Hug | 1: Float | 2: Plain rectangle + property bool borderless: false // true for no grouping of items + property string topLeftIcon: "spark" // Options: "distro" or any icon name in ~/.config/quickshell/ii/assets/icons + property bool showBackground: true + property bool verbose: true + property bool vertical: false + property JsonObject resources: JsonObject { + property bool alwaysShowSwap: true + property bool alwaysShowCpu: true + property int memoryWarningThreshold: 95 + property int swapWarningThreshold: 85 + property int cpuWarningThreshold: 90 + } + property list screenList: [] // List of names, like "eDP-1", find out with 'hyprctl monitors' command + property JsonObject utilButtons: JsonObject { + property bool showScreenSnip: true + property bool showColorPicker: false + property bool showMicToggle: false + property bool showKeyboardToggle: true + property bool showDarkModeToggle: true + property bool showPerformanceProfileToggle: false + } + property JsonObject tray: JsonObject { + property bool monochromeIcons: true + property bool showItemId: false + property bool invertPinnedItems: false // Makes the below a whitelist for the tray and blacklist for the pinned area + property list pinnedItems: [ "Fcitx" ] + } + property JsonObject workspaces: JsonObject { + property bool monochromeIcons: true + property int shown: 10 + property bool showAppIcons: true + property bool alwaysShowNumbers: false + property int showNumberDelay: 300 // milliseconds + property list numberMap: ["1", "2"] // Characters to show instead of numbers on workspace indicator + property bool useNerdFont: false + } + property JsonObject weather: JsonObject { + property bool enable: false + property bool enableGPS: true // gps based location + property string city: "" // When 'enableGPS' is false + property bool useUSCS: false // Instead of metric (SI) units + property int fetchInterval: 10 // minutes + } + } + + property JsonObject battery: JsonObject { + property int low: 20 + property int critical: 5 + property bool automaticSuspend: true + property int suspend: 3 + } + + property JsonObject conflictKiller: JsonObject { + property bool autoKillNotificationDaemons: false + property bool autoKillTrays: false + } + + property JsonObject dock: JsonObject { + property bool enable: false + property bool monochromeIcons: true + property real height: 60 + property real hoverRegionHeight: 2 + property bool pinnedOnStartup: false + property bool hoverToReveal: true // When false, only reveals on empty workspace + property list pinnedApps: [ // IDs of pinned entries + "org.kde.dolphin", "kitty",] + property list ignoredAppRegexes: [] + } + + property JsonObject interactions: JsonObject { + property JsonObject scrolling: JsonObject { + property bool fasterTouchpadScroll: false // Enable faster scrolling with touchpad + property int mouseScrollDeltaThreshold: 120 // delta >= this then it gets detected as mouse scroll rather than touchpad + property int mouseScrollFactor: 120 + property int touchpadScrollFactor: 450 + } + } + + property JsonObject language: JsonObject { + property string ui: "auto" // UI language. "auto" for system locale, or specific language code like "zh_CN", "en_US" + property JsonObject translator: JsonObject { + property string engine: "auto" // Run `trans -list-engines` for available engines. auto should use google + property string targetLanguage: "auto" // Run `trans -list-all` for available languages + property string sourceLanguage: "auto" + } + } + + property JsonObject light: JsonObject { + property JsonObject night: JsonObject { + property bool automatic: true + property string from: "19:00" // Format: "HH:mm", 24-hour time + property string to: "06:30" // Format: "HH:mm", 24-hour time + property int colorTemperature: 5000 + } + } + + property JsonObject media: JsonObject { + // Attempt to remove dupes (the aggregator playerctl one and browsers' native ones when there's plasma browser integration) + property bool filterDuplicatePlayers: true + } + + property JsonObject networking: JsonObject { + property string userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" + } + + property JsonObject osd: JsonObject { + property int timeout: 1000 + } + + property JsonObject osk: JsonObject { + property string layout: "qwerty_full" + property bool pinnedOnStartup: false + } + + property JsonObject overview: JsonObject { + property bool enable: true + property real scale: 0.18 // Relative to screen size + property real rows: 2 + property real columns: 5 + } + + property JsonObject resources: JsonObject { + property int updateInterval: 3000 + } + + property JsonObject search: JsonObject { + property int nonAppResultDelay: 30 // This prevents lagging when typing + property string engineBaseUrl: "https://www.google.com/search?q=" + property list excludedSites: ["quora.com"] + property bool sloppy: false // Uses levenshtein distance based scoring instead of fuzzy sort. Very weird. + property JsonObject prefix: JsonObject { + property bool showDefaultActionsWithoutPrefix: true + property string action: "/" + property string clipboard: ";" + property string emojis: ":" + property string math: "=" + property string shellCommand: "$" + property string webSearch: "?" + } + } + + property JsonObject sidebar: JsonObject { + property bool keepRightSidebarLoaded: true + property JsonObject translator: JsonObject { + property int delay: 300 // Delay before sending request. Reduces (potential) rate limits and lag. + } + property JsonObject booru: JsonObject { + property bool allowNsfw: false + property string defaultProvider: "yandere" + property int limit: 20 + property JsonObject zerochan: JsonObject { + property string username: "[unset]" + } + } + property JsonObject cornerOpen: JsonObject { + property bool enable: true + property bool bottom: false + property bool valueScroll: true + property bool clickless: false + property real cornerRegionWidth: 60 + property real cornerRegionHeight: 2 + property bool visualize: false + } + } + + property JsonObject time: JsonObject { + // https://doc.qt.io/qt-6/qtime.html#toString + property string format: "hh:mm" + property string shortDateFormat: "dd/MM" + property string dateFormat: "ddd, dd/MM" + property JsonObject pomodoro: JsonObject { + property string alertSound: "" + property int breakTime: 300 + property int cyclesBeforeLongBreak: 4 + property int focus: 1500 + property int longBreak: 900 + } + } + + property JsonObject windows: JsonObject { + property bool showTitlebar: true // Client-side decoration for shell apps + property bool centerTitle: true + } + + property JsonObject hacks: JsonObject { + property int arbitraryRaceConditionDelay: 20 // milliseconds + } + + property JsonObject screenshotTool: JsonObject { + property bool showContentRegions: true + } + } + } +} diff --git a/ii/modules/common/Directories.qml b/ii/modules/common/Directories.qml new file mode 100644 index 0000000..20d95a3 --- /dev/null +++ b/ii/modules/common/Directories.qml @@ -0,0 +1,53 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common.functions +import Qt.labs.platform +import QtQuick +import Quickshell + +Singleton { + // XDG Dirs, with "file://" + readonly property string home: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0] + readonly property string config: StandardPaths.standardLocations(StandardPaths.ConfigLocation)[0] + readonly property string state: StandardPaths.standardLocations(StandardPaths.StateLocation)[0] + readonly property string cache: StandardPaths.standardLocations(StandardPaths.CacheLocation)[0] + readonly property string genericCache: StandardPaths.standardLocations(StandardPaths.GenericCacheLocation)[0] + readonly property string documents: StandardPaths.standardLocations(StandardPaths.DocumentsLocation)[0] + readonly property string downloads: StandardPaths.standardLocations(StandardPaths.DownloadLocation)[0] + readonly property string pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0] + readonly property string music: StandardPaths.standardLocations(StandardPaths.MusicLocation)[0] + readonly property string videos: StandardPaths.standardLocations(StandardPaths.MoviesLocation)[0] + + // Other dirs used by the shell, without "file://" + property string assetsPath: Quickshell.shellPath("assets") + property string scriptPath: Quickshell.shellPath("scripts") + property string favicons: FileUtils.trimFileProtocol(`${Directories.cache}/media/favicons`) + property string coverArt: FileUtils.trimFileProtocol(`${Directories.cache}/media/coverart`) + property string booruPreviews: FileUtils.trimFileProtocol(`${Directories.cache}/media/boorus`) + property string booruDownloads: FileUtils.trimFileProtocol(Directories.pictures + "/homework") + property string booruDownloadsNsfw: FileUtils.trimFileProtocol(Directories.pictures + "/homework/🌶️") + property string latexOutput: FileUtils.trimFileProtocol(`${Directories.cache}/media/latex`) + property string shellConfig: FileUtils.trimFileProtocol(`${Directories.config}/illogical-impulse`) + property string shellConfigName: "config.json" + property string shellConfigPath: `${Directories.shellConfig}/${Directories.shellConfigName}` + property string todoPath: FileUtils.trimFileProtocol(`${Directories.state}/user/todo.json`) + property string notificationsPath: FileUtils.trimFileProtocol(`${Directories.cache}/notifications/notifications.json`) + property string generatedMaterialThemePath: FileUtils.trimFileProtocol(`${Directories.state}/user/generated/colors.json`) + property string cliphistDecode: FileUtils.trimFileProtocol(`/tmp/quickshell/media/cliphist`) + property string screenshotTemp: "/tmp/quickshell/media/screenshot" + property string wallpaperSwitchScriptPath: FileUtils.trimFileProtocol(`${Directories.scriptPath}/colors/switchwall.sh`) + property string defaultAiPrompts: Quickshell.shellPath("defaults/ai/prompts") + property string userAiPrompts: FileUtils.trimFileProtocol(`${Directories.shellConfig}/ai/prompts`) + property string aiChats: FileUtils.trimFileProtocol(`${Directories.state}/user/ai/chats`) + // Cleanup on init + Component.onCompleted: { + Quickshell.execDetached(["mkdir", "-p", `${shellConfig}`]) + Quickshell.execDetached(["mkdir", "-p", `${favicons}`]) + Quickshell.execDetached(["bash", "-c", `rm -rf '${coverArt}'; mkdir -p '${coverArt}'`]) + Quickshell.execDetached(["bash", "-c", `rm -rf '${booruPreviews}'; mkdir -p '${booruPreviews}'`]) + Quickshell.execDetached(["bash", "-c", `rm -rf '${latexOutput}'; mkdir -p '${latexOutput}'`]) + Quickshell.execDetached(["bash", "-c", `rm -rf '${cliphistDecode}'; mkdir -p '${cliphistDecode}'`]) + Quickshell.execDetached(["mkdir", "-p", `${aiChats}`]) + } +} diff --git a/ii/modules/common/Icons.qml b/ii/modules/common/Icons.qml new file mode 100644 index 0000000..454aea1 --- /dev/null +++ b/ii/modules/common/Icons.qml @@ -0,0 +1,23 @@ +pragma Singleton + +// From https://github.com/caelestia-dots/shell (GPLv3) + +import Quickshell + +Singleton { + id: root + + function getBluetoothDeviceMaterialSymbol(systemIconName: string): string { + if (systemIconName.includes("headset") || systemIconName.includes("headphones")) + return "headphones"; + if (systemIconName.includes("audio")) + return "speaker"; + if (systemIconName.includes("phone")) + return "smartphone"; + if (systemIconName.includes("mouse")) + return "mouse"; + if (systemIconName.includes("keyboard")) + return "keyboard"; + return "bluetooth"; + } +} diff --git a/ii/modules/common/Images.qml b/ii/modules/common/Images.qml new file mode 100644 index 0000000..be3701e --- /dev/null +++ b/ii/modules/common/Images.qml @@ -0,0 +1,31 @@ +pragma Singleton + +import Quickshell + +Singleton { + // Formats + readonly property list validImageTypes: ["jpeg", "png", "webp", "tiff", "svg"] + readonly property list validImageExtensions: ["jpg", "jpeg", "png", "webp", "tif", "tiff", "svg"] + + function isValidImageByName(name: string): bool { + return validImageExtensions.some(t => name.endsWith(`.${t}`)); + } + + // Thumbnails + // https://specifications.freedesktop.org/thumbnail-spec/latest/directory.html + readonly property var thumbnailSizes: ({ + "normal": 128, + "large": 256, + "x-large": 512, + "xx-large": 1024 + }) + function thumbnailSizeNameForDimensions(width: int, height: int): string { + const sizeNames = Object.keys(thumbnailSizes); + for(let i = 0; i < sizeNames.length; i++) { + const sizeName = sizeNames[i]; + const maxSize = thumbnailSizes[sizeName]; + if (width <= maxSize && height <= maxSize) return sizeName; + } + return "xx-large"; + } +} diff --git a/ii/modules/common/NoticeBox.qml b/ii/modules/common/NoticeBox.qml new file mode 100644 index 0000000..6b110a9 --- /dev/null +++ b/ii/modules/common/NoticeBox.qml @@ -0,0 +1,40 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +Rectangle { + id: root + property alias materialIcon: icon.text + property alias text: noticeText.text + + radius: Appearance.rounding.normal + color: Appearance.colors.colPrimaryContainer + implicitWidth: mainRowLayout.implicitWidth + mainRowLayout.anchors.margins * 2 + implicitHeight: mainRowLayout.implicitHeight + mainRowLayout.anchors.margins * 2 + + RowLayout { + id: mainRowLayout + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + MaterialSymbol { + id: icon + Layout.fillWidth: false + Layout.alignment: Qt.AlignTop + text: "info" + iconSize: Appearance.font.pixelSize.huge + color: Appearance.colors.colOnPrimaryContainer + } + + StyledText { + id: noticeText + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + text: "Notice message" + color: Appearance.colors.colOnPrimaryContainer + wrapMode: Text.WordWrap + } + } +} diff --git a/ii/modules/common/Persistent.qml b/ii/modules/common/Persistent.qml new file mode 100644 index 0000000..29d41e1 --- /dev/null +++ b/ii/modules/common/Persistent.qml @@ -0,0 +1,80 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + property alias states: persistentStatesJsonAdapter + property string fileDir: Directories.state + property string fileName: "states.json" + property string filePath: `${root.fileDir}/${root.fileName}` + + Timer { + id: fileReloadTimer + interval: 100 + repeat: false + onTriggered: { + persistentStatesFileView.reload() + } + } + + Timer { + id: fileWriteTimer + interval: 100 + repeat: false + onTriggered: { + persistentStatesFileView.writeAdapter() + } + } + + FileView { + id: persistentStatesFileView + path: root.filePath + + watchChanges: true + onFileChanged: fileReloadTimer.restart() + onAdapterUpdated: fileWriteTimer.restart() + onLoadFailed: error => { + console.log("Failed to load persistent states file:", error); + if (error == FileViewError.FileNotFound) { + fileWriteTimer.restart(); + } + } + + adapter: JsonAdapter { + id: persistentStatesJsonAdapter + property JsonObject ai: JsonObject { + property string model + property real temperature: 0.5 + } + + property JsonObject sidebar: JsonObject { + property JsonObject bottomGroup: JsonObject { + property bool collapsed: false + property int tab: 0 + } + } + + property JsonObject booru: JsonObject { + property bool allowNsfw: false + property string provider: "yandere" + } + + property JsonObject timer: JsonObject { + property JsonObject pomodoro: JsonObject { + property bool running: false + property int start: 0 + property bool isBreak: false + property int cycle: 0 + } + property JsonObject stopwatch: JsonObject { + property bool running: false + property int start: 0 + property list laps: [] + } + } + } + } +} diff --git a/ii/modules/common/ThumbnailImage.qml b/ii/modules/common/ThumbnailImage.qml new file mode 100644 index 0000000..50315b2 --- /dev/null +++ b/ii/modules/common/ThumbnailImage.qml @@ -0,0 +1,56 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +/** + * Thumbnail image. It currently generates to the right place at the right size, but does not handle metadata/maintenance on modification. + * See Freedesktop's spec: https://specifications.freedesktop.org/thumbnail-spec/thumbnail-spec-latest.html + */ +StyledImage { + id: root + + property bool generateThumbnail: true + required property string sourcePath + property string thumbnailSizeName: Images.thumbnailSizeNameForDimensions(sourceSize.width, sourceSize.height) + property string thumbnailPath: { + if (sourcePath.length == 0) return; + const resolvedUrlWithoutFileProtocol = FileUtils.trimFileProtocol(`${Qt.resolvedUrl(sourcePath)}`); + const encodedUrlWithoutFileProtocol = resolvedUrlWithoutFileProtocol.split("/").map(part => encodeURIComponent(part)).join("/"); + const md5Hash = Qt.md5(`file://${encodedUrlWithoutFileProtocol}`); + return `${Directories.genericCache}/thumbnails/${thumbnailSizeName}/${md5Hash}.png`; + } + source: thumbnailPath + + asynchronous: true + smooth: true + mipmap: false + + opacity: status === Image.Ready ? 1 : 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + onSourceSizeChanged: { + if (!root.generateThumbnail) return; + thumbnailGeneration.running = false; + thumbnailGeneration.running = true; + } + Process { + id: thumbnailGeneration + command: { + const maxSize = Images.thumbnailSizes[root.thumbnailSizeName]; + return ["bash", "-c", + `[ -f '${FileUtils.trimFileProtocol(root.thumbnailPath)}' ] && exit 0 || { magick '${root.sourcePath}' -resize ${maxSize}x${maxSize} '${FileUtils.trimFileProtocol(root.thumbnailPath)}' && exit 1; }` + ] + } + onExited: (exitCode, exitStatus) => { + if (exitCode === 1) { // Force reload if thumbnail had to be generated + root.source = ""; + root.source = root.thumbnailPath; // Force reload + } + } + } +} diff --git a/ii/modules/common/functions/ColorUtils.qml b/ii/modules/common/functions/ColorUtils.qml new file mode 100644 index 0000000..15c4280 --- /dev/null +++ b/ii/modules/common/functions/ColorUtils.qml @@ -0,0 +1,127 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + /** + * Returns a color with the hue of color2 and the saturation, value, and alpha of color1. + * + * @param {string} color1 - The base color (any Qt.color-compatible string). + * @param {string} color2 - The color to take hue from. + * @returns {Qt.rgba} The resulting color. + */ + function colorWithHueOf(color1, color2) { + var c1 = Qt.color(color1); + var c2 = Qt.color(color2); + + // Qt.color hsvHue/hsvSaturation/hsvValue/alpha return 0-1 + var hue = c2.hsvHue; + var sat = c1.hsvSaturation; + var val = c1.hsvValue; + var alpha = c1.a; + + return Qt.hsva(hue, sat, val, alpha); + } + + /** + * Returns a color with the saturation of color2 and the hue/value/alpha of color1. + * + * @param {string} color1 - The base color (any Qt.color-compatible string). + * @param {string} color2 - The color to take saturation from. + * @returns {Qt.rgba} The resulting color. + */ + function colorWithSaturationOf(color1, color2) { + var c1 = Qt.color(color1); + var c2 = Qt.color(color2); + + var hue = c1.hsvHue; + var sat = c2.hsvSaturation; + var val = c1.hsvValue; + var alpha = c1.a; + + return Qt.hsva(hue, sat, val, alpha); + } + + /** + * Returns a color with the given lightness and the hue, saturation, and alpha of the input color (using HSL). + * + * @param {string} color - The base color (any Qt.color-compatible string). + * @param {number} lightness - The lightness value to use (0-1). + * @returns {Qt.rgba} The resulting color. + */ + function colorWithLightness(color, lightness) { + var c = Qt.color(color); + return Qt.hsla(c.hslHue, c.hslSaturation, lightness, c.a); + } + + /** + * Returns a color with the lightness of color2 and the hue, saturation, and alpha of color1 (using HSL). + * + * @param {string} color1 - The base color (any Qt.color-compatible string). + * @param {string} color2 - The color to take lightness from. + * @returns {Qt.rgba} The resulting color. + */ + function colorWithLightnessOf(color1, color2) { + var c2 = Qt.color(color2); + return colorWithLightness(color1, c2.hslLightness); + } + + /** + * Adapts color1 to the accent (hue and saturation) of color2 using HSL, keeping lightness and alpha from color1. + * + * @param {string} color1 - The base color (any Qt.color-compatible string). + * @param {string} color2 - The accent color. + * @returns {Qt.rgba} The resulting color. + */ + function adaptToAccent(color1, color2) { + var c1 = Qt.color(color1); + var c2 = Qt.color(color2); + + var hue = c2.hslHue; + var sat = c2.hslSaturation; + var light = c1.hslLightness; + var alpha = c1.a; + + return Qt.hsla(hue, sat, light, alpha); + } + + /** + * Mixes two colors by a given percentage. + * + * @param {string} color1 - The first color (any Qt.color-compatible string). + * @param {string} color2 - The second color. + * @param {number} percentage - The mix ratio (0-1). 1 = all color1, 0 = all color2. + * @returns {Qt.rgba} The resulting mixed color. + */ + function mix(color1, color2, percentage = 0.5) { + var c1 = Qt.color(color1); + var c2 = Qt.color(color2); + return Qt.rgba(percentage * c1.r + (1 - percentage) * c2.r, percentage * c1.g + (1 - percentage) * c2.g, percentage * c1.b + (1 - percentage) * c2.b, percentage * c1.a + (1 - percentage) * c2.a); + } + + /** + * Transparentizes a color by a given percentage. + * + * @param {string} color - The color (any Qt.color-compatible string). + * @param {number} percentage - The amount to transparentize (0-1). + * @returns {Qt.rgba} The resulting color. + */ + function transparentize(color, percentage = 1) { + var c = Qt.color(color); + return Qt.rgba(c.r, c.g, c.b, c.a * (1 - percentage)); + } + + /** + * Sets the alpha channel of a color. + * + * @param {string} color - The base color (any Qt.color-compatible string). + * @param {number} alpha - The desired alpha (0-1). + * @returns {Qt.rgba} The resulting color with applied alpha. + */ + function applyAlpha(color, alpha) { + var c = Qt.color(color); + var a = Math.max(0, Math.min(1, alpha)); + return Qt.rgba(c.r, c.g, c.b, a); + } +} diff --git a/ii/modules/common/functions/FileUtils.qml b/ii/modules/common/functions/FileUtils.qml new file mode 100644 index 0000000..4ed8d8c --- /dev/null +++ b/ii/modules/common/functions/FileUtils.qml @@ -0,0 +1,69 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + /** + * Trims the File protocol off the input string + * @param {string} str + * @returns {string} + */ + function trimFileProtocol(str) { + return str.startsWith("file://") ? str.slice(7) : str; + } + + /** + * Extracts the file name from a file path + * @param {string} str + * @returns {string} + */ + function fileNameForPath(str) { + if (typeof str !== "string") return ""; + const trimmed = trimFileProtocol(str); + return trimmed.split(/[\\/]/).pop(); + } + + /** + * Extracts the folder name from a directory path + * @param {string} str + * @returns {string} + */ + function folderNameForPath(str) { + if (typeof str !== "string") return ""; + const trimmed = trimFileProtocol(str); + // Remove trailing slash if present + const noTrailing = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; + if (!noTrailing) return ""; + return noTrailing.split(/[\\/]/).pop(); + } + + /** + * Removes the file extension from a file path or name + * @param {string} str + * @returns {string} + */ + function trimFileExt(str) { + if (typeof str !== "string") return ""; + const trimmed = trimFileProtocol(str); + const lastDot = trimmed.lastIndexOf("."); + if (lastDot > -1 && lastDot > trimmed.lastIndexOf("/")) { + return trimmed.slice(0, lastDot); + } + return trimmed; + } + + /** + * Returns the parent directory of a given file path + * @param {string} str + * @returns {string} + */ + function parentDirectory(str) { + if (typeof str !== "string") return ""; + const trimmed = trimFileProtocol(str); + const parts = trimmed.split(/[\\/]/); + if (parts.length <= 1) return ""; + parts.pop(); + return parts.join("/"); + } +} diff --git a/ii/modules/common/functions/Fuzzy.qml b/ii/modules/common/functions/Fuzzy.qml new file mode 100644 index 0000000..7a132ad --- /dev/null +++ b/ii/modules/common/functions/Fuzzy.qml @@ -0,0 +1,18 @@ +pragma Singleton +import Quickshell +import "./fuzzysort.js" as FuzzySort + +/** + * Wrapper for FuzzySort to play nicely with Quickshell's imports + */ + +Singleton { + function go(...args) { + return FuzzySort.go(...args) + } + + function prepare(...args) { + return FuzzySort.prepare(...args) + } +} + diff --git a/ii/modules/common/functions/Levendist.qml b/ii/modules/common/functions/Levendist.qml new file mode 100644 index 0000000..a327c3c --- /dev/null +++ b/ii/modules/common/functions/Levendist.qml @@ -0,0 +1,18 @@ +pragma Singleton +import Quickshell +import "./levendist.js" as Levendist + +/** + * Wrapper for levendist.js to play nicely with Quickshell's imports + */ + +Singleton { + function computeScore(...args) { + return Levendist.computeScore(...args) + } + + function computeTextMatchScore(...args) { + return Levendist.computeTextMatchScore(...args) + } +} + diff --git a/ii/modules/common/functions/ObjectUtils.qml b/ii/modules/common/functions/ObjectUtils.qml new file mode 100644 index 0000000..d1204cd --- /dev/null +++ b/ii/modules/common/functions/ObjectUtils.qml @@ -0,0 +1,98 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + function toPlainObject(qtObj) { + if (qtObj === null || typeof qtObj !== "object") return qtObj; + + // Handle true arrays + if (Array.isArray(qtObj)) { + return qtObj.map(item => toPlainObject(item)); + } + + // Handle array-like Qt objects (e.g., have length and numeric keys) + if ( + typeof qtObj.length === "number" && + qtObj.length > 0 && + Object.keys(qtObj).every( + key => !isNaN(key) || key === "length" + ) + ) { + let arr = []; + for (let i = 0; i < qtObj.length; i++) { + arr.push(toPlainObject(qtObj[i])); + } + return arr; + } + + const result = ({}); + for (let key in qtObj) { + if ( + typeof qtObj[key] !== "function" && + !key.startsWith("objectName") && + !key.startsWith("children") && + !key.startsWith("object") && + !key.startsWith("parent") && + !key.startsWith("metaObject") && + !key.startsWith("destroyed") && + !key.startsWith("reloadableId") + ) { + result[key] = toPlainObject(qtObj[key]); + } + } + // console.log(JSON.stringify(result)) + return result; + } + + function applyToQtObject(qtObj, jsonObj) { + // console.log("applyToQtObject", JSON.stringify(qtObj, null, 2), "<<", JSON.stringify(jsonObj, null, 2)); + if (!qtObj || typeof jsonObj !== "object" || jsonObj === null) return; + + // Detect array-like Qt objects + const isQtArrayLike = obj => { + return obj && typeof obj === "object" && + typeof obj.length === "number" && + obj.length > 0 && + Object.keys(obj).every(key => !isNaN(key) || key === "length"); + }; + + // If both are arrays or array-like, update in place or replace + if ((Array.isArray(qtObj) || isQtArrayLike(qtObj)) && Array.isArray(jsonObj)) { + qtObj.length = 0; + for (let i = 0; i < jsonObj.length; i++) { + qtObj.push(jsonObj[i]); + } + return; + } + + // If target is array or array-like but source is not, clear + if ((Array.isArray(qtObj) || isQtArrayLike(qtObj)) && !Array.isArray(jsonObj)) { + qtObj.length = 0; + return; + } + + // If source is array but target is not, assign directly if possible + if (!(Array.isArray(qtObj) || isQtArrayLike(qtObj)) && Array.isArray(jsonObj)) { + return jsonObj; + } + + for (let key in jsonObj) { + if (!qtObj.hasOwnProperty(key)) continue; + const value = qtObj[key]; + const jsonValue = jsonObj[key]; + // console.log("applying to qt obj key:", value, "jsonValue:", jsonValue); + if ((Array.isArray(value) || isQtArrayLike(value)) && Array.isArray(jsonValue)) { + value.length = 0; + for (let i = 0; i < jsonValue.length; i++) { + value.push(jsonValue[i]); + } + } else if (value && typeof value === "object" && !Array.isArray(value) && !isQtArrayLike(value)) { + applyToQtObject(value, jsonValue); + } else { + qtObj[key] = jsonValue; + } + } + } +} diff --git a/ii/modules/common/functions/Session.qml b/ii/modules/common/functions/Session.qml new file mode 100644 index 0000000..1afc458 --- /dev/null +++ b/ii/modules/common/functions/Session.qml @@ -0,0 +1,50 @@ +pragma Singleton +import Quickshell +import qs.services +import qs.modules.common + +Singleton { + id: root + + function closeAllWindows() { + HyprlandData.windowList.map(w => w.pid).forEach(pid => { + Quickshell.execDetached(["kill", pid]); + }); + } + + function lock() { + Quickshell.execDetached(["loginctl", "lock-session"]); + } + + function suspend() { + Quickshell.execDetached(["bash", "-c", "systemctl suspend || loginctl suspend"]); + } + + function logout() { + closeAllWindows(); + Quickshell.execDetached(["pkill", "Hyprland"]); + } + + function launchTaskManager() { + Quickshell.execDetached(["bash", "-c", `${Config.options.apps.taskManager}`]); + } + + function hibernate() { + Quickshell.execDetached(["bash", "-c", `systemctl hibernate || loginctl hibernate`]); + } + + function poweroff() { + closeAllWindows(); + Quickshell.execDetached(["bash", "-c", `systemctl poweroff || loginctl poweroff`]); + } + + function reboot() { + closeAllWindows(); + Quickshell.execDetached(["bash", "-c", `reboot || loginctl reboot`]); + } + + function rebootToFirmware() { + closeAllWindows(); + Quickshell.execDetached(["bash", "-c", `systemctl reboot --firmware-setup || loginctl reboot --firmware-setup`]); + } +} diff --git a/ii/modules/common/functions/StringUtils.qml b/ii/modules/common/functions/StringUtils.qml new file mode 100644 index 0000000..ea5e42d --- /dev/null +++ b/ii/modules/common/functions/StringUtils.qml @@ -0,0 +1,224 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + /** + * Formats a string according to the args that are passed inc + * @param { string } str + * @param {...any} args + * @returns + */ + function format(str, ...args) { + return str.replace(/{(\d+)}/g, (match, index) => typeof args[index] !== 'undefined' ? args[index] : match); + } + + /** + * Returns the domain of the passed in url or null + * @param { string } url + * @returns { string| null } + */ + function getDomain(url) { + const match = url.match(/^(?:https?:\/\/)?(?:www\.)?([^\/]+)/); + return match ? match[1] : null; + } + + /** + * Returns the base url of the passed in url or null + * @param { string } url + * @returns { string | null } + */ + function getBaseUrl(url) { + const match = url.match(/^(https?:\/\/[^\/]+)(\/.*)?$/); + return match ? match[1] : null; + } + + /** + * Escapes single quotes in shell commands + * @param { string } str + * @returns { string } + */ + function shellSingleQuoteEscape(str) { + return String(str) + // .replace(/\\/g, '\\\\') + .replace(/'/g, "'\\''"); + } + + /** + * Splits markdown blocks into three different types: text, think, and code. + * @param { string } markdown + */ + function splitMarkdownBlocks(markdown) { + const regex = /```(\w+)?\n([\s\S]*?)```|([\s\S]*?)<\/think>/g; + /** + * @type {{type: "text" | "think" | "code"; content: string; lang: string | undefined; completed: boolean | undefined}[]} + */ + let result = []; + let lastIndex = 0; + let match; + while ((match = regex.exec(markdown)) !== null) { + if (match.index > lastIndex) { + const text = markdown.slice(lastIndex, match.index); + if (text.trim()) { + result.push({ + type: "text", + content: text + }); + } + } + if (match[0].startsWith('```')) { + if (match[2] && match[2].trim()) { + result.push({ + type: "code", + lang: match[1] || "", + content: match[2], + completed: true + }); + } + } else if (match[0].startsWith('')) { + if (match[3] && match[3].trim()) { + result.push({ + type: "think", + content: match[3], + completed: true + }); + } + } + lastIndex = regex.lastIndex; + } + // Handle any remaining text after the last match + if (lastIndex < markdown.length) { + const text = markdown.slice(lastIndex); + // Check for unfinished block + const thinkStart = text.indexOf(''); + const codeStart = text.indexOf('```'); + if (thinkStart !== -1 && (codeStart === -1 || thinkStart < codeStart)) { + const beforeThink = text.slice(0, thinkStart); + if (beforeThink.trim()) { + result.push({ + type: "text", + content: beforeThink + }); + } + const thinkContent = text.slice(thinkStart + 7); + if (thinkContent.trim()) { + result.push({ + type: "think", + content: thinkContent, + completed: false + }); + } + } else if (codeStart !== -1) { + const beforeCode = text.slice(0, codeStart); + if (beforeCode.trim()) { + result.push({ + type: "text", + content: beforeCode + }); + } + // Try to detect language after ``` + const codeLangMatch = text.slice(codeStart + 3).match(/^(\w+)?\n/); + let lang = ""; + let codeContentStart = codeStart + 3; + if (codeLangMatch) { + lang = codeLangMatch[1] || ""; + codeContentStart += codeLangMatch[0].length; + } else if (text[codeStart + 3] === '\n') { + codeContentStart += 1; + } + const codeContent = text.slice(codeContentStart); + if (codeContent.trim()) { + result.push({ + type: "code", + lang, + content: codeContent, + completed: false + }); + } + } else if (text.trim()) { + result.push({ + type: "text", + content: text + }); + } + } + // console.log(JSON.stringify(result, null, 2)); + return result; + } + + /** + * Returns the original string with backslashes escaped + * @param { string } str + * @returns { string } + */ + function escapeBackslashes(str) { + return str.replace(/\\/g, '\\\\'); + } + + /** + * Wraps words to supplied maximum length + * @param { string | null } str + * @param { number } maxLen + * @returns { string } + */ + function wordWrap(str, maxLen) { + if (!str) + return ""; + let words = str.split(" "); + let lines = []; + let current = ""; + for (let i = 0; i < words.length; ++i) { + if ((current + (current.length > 0 ? " " : "") + words[i]).length > maxLen) { + if (current.length > 0) + lines.push(current); + current = words[i]; + } else { + current += (current.length > 0 ? " " : "") + words[i]; + } + } + if (current.length > 0) + lines.push(current); + return lines.join("\n"); + } + + function cleanMusicTitle(title) { + if (!title) + return ""; + // Brackets + title = title.replace(/^ *\([^)]*\) */g, " "); // Round brackets + title = title.replace(/^ *\[[^\]]*\] */g, " "); // Square brackets + title = title.replace(/^ *\{[^\}]*\} */g, " "); // Curly brackets + // Japenis brackets + title = title.replace(/^ *【[^】]*】/, ""); // Touhou + title = title.replace(/^ *《[^》]*》/, ""); // ?? + title = title.replace(/^ *「[^」]*」/, ""); // OP/ED thingie + title = title.replace(/^ *『[^』]*』/, ""); // OP/ED thingie + + return title.trim(); + } + + function friendlyTimeForSeconds(seconds) { + if (isNaN(seconds) || seconds < 0) + return "0:00"; + seconds = Math.floor(seconds); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) { + return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + } else { + return `${m}:${s.toString().padStart(2, '0')}`; + } + } + + function escapeHtml(str) { + if (typeof str !== 'string') + return str; + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + + function cleanCliphistEntry(str: string): string { + return str.replace(/^\d+\t/, ""); + } +} diff --git a/ii/modules/common/functions/fuzzysort.js b/ii/modules/common/functions/fuzzysort.js new file mode 100644 index 0000000..1c1d9b9 --- /dev/null +++ b/ii/modules/common/functions/fuzzysort.js @@ -0,0 +1,682 @@ +.pragma library + +// https://github.com/farzher/fuzzysort +// License: MIT | Copyright (c) 2018 Stephen Kamenar +// A copy of the license is available in the `licenses` folder of this repository + +var single = (search, target) => { + if(!search || !target) return NULL + + var preparedSearch = getPreparedSearch(search) + if(!isPrepared(target)) target = getPrepared(target) + + var searchBitflags = preparedSearch.bitflags + if((searchBitflags & target._bitflags) !== searchBitflags) return NULL + + return algorithm(preparedSearch, target) +} + +var go = (search, targets, options) => { + if(!search) return options?.all ? all(targets, options) : noResults + + var preparedSearch = getPreparedSearch(search) + var searchBitflags = preparedSearch.bitflags + var containsSpace = preparedSearch.containsSpace + + var threshold = denormalizeScore( options?.threshold || 0 ) + var limit = options?.limit || INFINITY + + var resultsLen = 0; var limitedCount = 0 + var targetsLen = targets.length + + function push_result(result) { + if(resultsLen < limit) { q.add(result); ++resultsLen } + else { + ++limitedCount + if(result._score > q.peek()._score) q.replaceTop(result) + } + } + + // This code is copy/pasted 3 times for performance reasons [options.key, options.keys, no keys] + + // options.key + if(options?.key) { + var key = options.key + for(var i = 0; i < targetsLen; ++i) { var obj = targets[i] + var target = getValue(obj, key) + if(!target) continue + if(!isPrepared(target)) target = getPrepared(target) + + if((searchBitflags & target._bitflags) !== searchBitflags) continue + var result = algorithm(preparedSearch, target) + if(result === NULL) continue + if(result._score < threshold) continue + + result.obj = obj + push_result(result) + } + + // options.keys + } else if(options?.keys) { + var keys = options.keys + var keysLen = keys.length + + outer: for(var i = 0; i < targetsLen; ++i) { var obj = targets[i] + + { // early out based on bitflags + var keysBitflags = 0 + for (var keyI = 0; keyI < keysLen; ++keyI) { + var key = keys[keyI] + var target = getValue(obj, key) + if(!target) { tmpTargets[keyI] = noTarget; continue } + if(!isPrepared(target)) target = getPrepared(target) + tmpTargets[keyI] = target + + keysBitflags |= target._bitflags + } + + if((searchBitflags & keysBitflags) !== searchBitflags) continue + } + + if(containsSpace) for(let i=0; i -1000) { + if(keysSpacesBestScores[i] > NEGATIVE_INFINITY) { + var tmp = (keysSpacesBestScores[i] + allowPartialMatchScores[i]) / 4/*bonus score for having multiple matches*/ + if(tmp > keysSpacesBestScores[i]) keysSpacesBestScores[i] = tmp + } + } + if(allowPartialMatchScores[i] > keysSpacesBestScores[i]) keysSpacesBestScores[i] = allowPartialMatchScores[i] + } + } + + if(containsSpace) { + for(let i=0; i -1000) { + if(score > NEGATIVE_INFINITY) { + var tmp = (score + result._score) / 4/*bonus score for having multiple matches*/ + if(tmp > score) score = tmp + } + } + if(result._score > score) score = result._score + } + } + + objResults.obj = obj + objResults._score = score + if(options?.scoreFn) { + score = options.scoreFn(objResults) + if(!score) continue + score = denormalizeScore(score) + objResults._score = score + } + + if(score < threshold) continue + push_result(objResults) + } + + // no keys + } else { + for(var i = 0; i < targetsLen; ++i) { var target = targets[i] + if(!target) continue + if(!isPrepared(target)) target = getPrepared(target) + + if((searchBitflags & target._bitflags) !== searchBitflags) continue + var result = algorithm(preparedSearch, target) + if(result === NULL) continue + if(result._score < threshold) continue + + push_result(result) + } + } + + if(resultsLen === 0) return noResults + var results = new Array(resultsLen) + for(var i = resultsLen - 1; i >= 0; --i) results[i] = q.poll() + results.total = resultsLen + limitedCount + return results +} + + +// this is written as 1 function instead of 2 for minification. perf seems fine ... +// except when minified. the perf is very slow +var highlight = (result, open='', close='') => { + var callback = typeof open === 'function' ? open : undefined + + var target = result.target + var targetLen = target.length + var indexes = result.indexes + var highlighted = '' + var matchI = 0 + var indexesI = 0 + var opened = false + var parts = [] + + for(var i = 0; i < targetLen; ++i) { var char = target[i] + if(indexes[indexesI] === i) { + ++indexesI + if(!opened) { opened = true + if(callback) { + parts.push(highlighted); highlighted = '' + } else { + highlighted += open + } + } + + if(indexesI === indexes.length) { + if(callback) { + highlighted += char + parts.push(callback(highlighted, matchI++)); highlighted = '' + parts.push(target.substr(i+1)) + } else { + highlighted += char + close + target.substr(i+1) + } + break + } + } else { + if(opened) { opened = false + if(callback) { + parts.push(callback(highlighted, matchI++)); highlighted = '' + } else { + highlighted += close + } + } + } + highlighted += char + } + + return callback ? parts : highlighted +} + + +var prepare = (target) => { + if(typeof target === 'number') target = ''+target + else if(typeof target !== 'string') target = '' + var info = prepareLowerInfo(target) + return new_result(target, {_targetLower:info._lower, _targetLowerCodes:info.lowerCodes, _bitflags:info.bitflags}) +} + +var cleanup = () => { preparedCache.clear(); preparedSearchCache.clear() } + + +// Below this point is only internal code +// Below this point is only internal code +// Below this point is only internal code +// Below this point is only internal code + + +class Result { + get ['indexes']() { return this._indexes.slice(0, this._indexes.len).sort((a,b)=>a-b) } + set ['indexes'](indexes) { return this._indexes = indexes } + ['highlight'](open, close) { return highlight(this, open, close) } + get ['score']() { return normalizeScore(this._score) } + set ['score'](score) { this._score = denormalizeScore(score) } +} + +class KeysResult extends Array { + get ['score']() { return normalizeScore(this._score) } + set ['score'](score) { this._score = denormalizeScore(score) } +} + +var new_result = (target, options) => { + const result = new Result() + result['target'] = target + result['obj'] = options.obj ?? NULL + result._score = options._score ?? NEGATIVE_INFINITY + result._indexes = options._indexes ?? [] + result._targetLower = options._targetLower ?? '' + result._targetLowerCodes = options._targetLowerCodes ?? NULL + result._nextBeginningIndexes = options._nextBeginningIndexes ?? NULL + result._bitflags = options._bitflags ?? 0 + return result +} + + +var normalizeScore = score => { + if(score === NEGATIVE_INFINITY) return 0 + if(score > 1) return score + return Math.E ** ( ((-score + 1)**.04307 - 1) * -2) +} +var denormalizeScore = normalizedScore => { + if(normalizedScore === 0) return NEGATIVE_INFINITY + if(normalizedScore > 1) return normalizedScore + return 1 - Math.pow((Math.log(normalizedScore) / -2 + 1), 1 / 0.04307) +} + + +var prepareSearch = (search) => { + if(typeof search === 'number') search = ''+search + else if(typeof search !== 'string') search = '' + search = search.trim() + var info = prepareLowerInfo(search) + + var spaceSearches = [] + if(info.containsSpace) { + var searches = search.split(/\s+/) + searches = [...new Set(searches)] // distinct + for(var i=0; i { + if(target.length > 999) return prepare(target) // don't cache huge targets + var targetPrepared = preparedCache.get(target) + if(targetPrepared !== undefined) return targetPrepared + targetPrepared = prepare(target) + preparedCache.set(target, targetPrepared) + return targetPrepared +} +var getPreparedSearch = (search) => { + if(search.length > 999) return prepareSearch(search) // don't cache huge searches + var searchPrepared = preparedSearchCache.get(search) + if(searchPrepared !== undefined) return searchPrepared + searchPrepared = prepareSearch(search) + preparedSearchCache.set(search, searchPrepared) + return searchPrepared +} + + +var all = (targets, options) => { + var results = []; results.total = targets.length // this total can be wrong if some targets are skipped + + var limit = options?.limit || INFINITY + + if(options?.key) { + for(var i=0;i= limit) return results + } + } else if(options?.keys) { + for(var i=0;i= 0; --keyI) { + var target = getValue(obj, options.keys[keyI]) + if(!target) { objResults[keyI] = noTarget; continue } + if(!isPrepared(target)) target = getPrepared(target) + target._score = NEGATIVE_INFINITY + target._indexes.len = 0 + objResults[keyI] = target + } + objResults.obj = obj + objResults._score = NEGATIVE_INFINITY + results.push(objResults); if(results.length >= limit) return results + } + } else { + for(var i=0;i= limit) return results + } + } + + return results +} + + +var algorithm = (preparedSearch, prepared, allowSpaces=false, allowPartialMatch=false) => { + if(allowSpaces===false && preparedSearch.containsSpace) return algorithmSpaces(preparedSearch, prepared, allowPartialMatch) + + var searchLower = preparedSearch._lower + var searchLowerCodes = preparedSearch.lowerCodes + var searchLowerCode = searchLowerCodes[0] + var targetLowerCodes = prepared._targetLowerCodes + var searchLen = searchLowerCodes.length + var targetLen = targetLowerCodes.length + var searchI = 0 // where we at + var targetI = 0 // where you at + var matchesSimpleLen = 0 + + // very basic fuzzy match; to remove non-matching targets ASAP! + // walk through target. find sequential matches. + // if all chars aren't found then exit + for(;;) { + var isMatch = searchLowerCode === targetLowerCodes[targetI] + if(isMatch) { + matchesSimple[matchesSimpleLen++] = targetI + ++searchI; if(searchI === searchLen) break + searchLowerCode = searchLowerCodes[searchI] + } + ++targetI; if(targetI >= targetLen) return NULL // Failed to find searchI + } + + var searchI = 0 + var successStrict = false + var matchesStrictLen = 0 + + var nextBeginningIndexes = prepared._nextBeginningIndexes + if(nextBeginningIndexes === NULL) nextBeginningIndexes = prepared._nextBeginningIndexes = prepareNextBeginningIndexes(prepared.target) + targetI = matchesSimple[0]===0 ? 0 : nextBeginningIndexes[matchesSimple[0]-1] + + // Our target string successfully matched all characters in sequence! + // Let's try a more advanced and strict test to improve the score + // only count it as a match if it's consecutive or a beginning character! + var backtrackCount = 0 + if(targetI !== targetLen) for(;;) { + if(targetI >= targetLen) { + // We failed to find a good spot for this search char, go back to the previous search char and force it forward + if(searchI <= 0) break // We failed to push chars forward for a better match + + ++backtrackCount; if(backtrackCount > 200) break // exponential backtracking is taking too long, just give up and return a bad match + + --searchI + var lastMatch = matchesStrict[--matchesStrictLen] + targetI = nextBeginningIndexes[lastMatch] + + } else { + var isMatch = searchLowerCodes[searchI] === targetLowerCodes[targetI] + if(isMatch) { + matchesStrict[matchesStrictLen++] = targetI + ++searchI; if(searchI === searchLen) { successStrict = true; break } + ++targetI + } else { + targetI = nextBeginningIndexes[targetI] + } + } + } + + // check if it's a substring match + var substringIndex = searchLen <= 1 ? -1 : prepared._targetLower.indexOf(searchLower, matchesSimple[0]) // perf: this is slow + var isSubstring = !!~substringIndex + var isSubstringBeginning = !isSubstring ? false : substringIndex===0 || prepared._nextBeginningIndexes[substringIndex-1] === substringIndex + + // if it's a substring match but not at a beginning index, let's try to find a substring starting at a beginning index for a better score + if(isSubstring && !isSubstringBeginning) { + for(var i=0; i { + var score = 0 + + var extraMatchGroupCount = 0 + for(var i = 1; i < searchLen; ++i) { + if(matches[i] - matches[i-1] !== 1) {score -= matches[i]; ++extraMatchGroupCount} + } + var unmatchedDistance = matches[searchLen-1] - matches[0] - (searchLen-1) + + score -= (12+unmatchedDistance) * extraMatchGroupCount // penality for more groups + + if(matches[0] !== 0) score -= matches[0]*matches[0]*.2 // penality for not starting near the beginning + + if(!successStrict) { + score *= 1000 + } else { + // successStrict on a target with too many beginning indexes loses points for being a bad target + var uniqueBeginningIndexes = 1 + for(var i = nextBeginningIndexes[0]; i < targetLen; i=nextBeginningIndexes[i]) ++uniqueBeginningIndexes + + if(uniqueBeginningIndexes > 24) score *= (uniqueBeginningIndexes-24)*10 // quite arbitrary numbers here ... + } + + score -= (targetLen - searchLen)/2 // penality for longer targets + + if(isSubstring) score /= 1+searchLen*searchLen*1 // bonus for being a full substring + if(isSubstringBeginning) score /= 1+searchLen*searchLen*1 // bonus for substring starting on a beginningIndex + + score -= (targetLen - searchLen)/2 // penality for longer targets + + return score + } + + if(!successStrict) { + if(isSubstring) for(var i=0; i { + var seen_indexes = new Set() + var score = 0 + var result = NULL + + var first_seen_index_last_search = 0 + var searches = preparedSearch.spaceSearches + var searchesLen = searches.length + var changeslen = 0 + + // Return _nextBeginningIndexes back to its normal state + var resetNextBeginningIndexes = () => { + for(let i=changeslen-1; i>=0; i--) target._nextBeginningIndexes[nextBeginningIndexesChanges[i*2 + 0]] = nextBeginningIndexesChanges[i*2 + 1] + } + + var hasAtLeast1Match = false + for(var i=0; i=0; i--) { + if(toReplace !== target._nextBeginningIndexes[i]) break + target._nextBeginningIndexes[i] = newBeginningIndex + nextBeginningIndexesChanges[changeslen*2 + 0] = i + nextBeginningIndexesChanges[changeslen*2 + 1] = toReplace + changeslen++ + } + } + } + + score += result._score / searchesLen + allowPartialMatchScores[i] = result._score / searchesLen + + // dock points based on order otherwise "c man" returns Manifest.cpp instead of CheatManager.h + if(result._indexes[0] < first_seen_index_last_search) { + score -= (first_seen_index_last_search - result._indexes[0]) * 2 + } + first_seen_index_last_search = result._indexes[0] + + for(var j=0; j score) { + if(allowPartialMatch) { + for(var i=0; i str.replace(/\p{Script=Latin}+/gu, match => match.normalize('NFD')).replace(/[\u0300-\u036f]/g, '') + +var prepareLowerInfo = (str) => { + str = remove_accents(str) + var strLen = str.length + var lower = str.toLowerCase() + var lowerCodes = [] // new Array(strLen) sparse array is too slow + var bitflags = 0 + var containsSpace = false // space isn't stored in bitflags because of how searching with a space works + + for(var i = 0; i < strLen; ++i) { + var lowerCode = lowerCodes[i] = lower.charCodeAt(i) + + if(lowerCode === 32) { + containsSpace = true + continue // it's important that we don't set any bitflags for space + } + + var bit = lowerCode>=97&&lowerCode<=122 ? lowerCode-97 // alphabet + : lowerCode>=48&&lowerCode<=57 ? 26 // numbers + // 3 bits available + : lowerCode<=127 ? 30 // other ascii + : 31 // other utf8 + bitflags |= 1< { + var targetLen = target.length + var beginningIndexes = []; var beginningIndexesLen = 0 + var wasUpper = false + var wasAlphanum = false + for(var i = 0; i < targetLen; ++i) { + var targetCode = target.charCodeAt(i) + var isUpper = targetCode>=65&&targetCode<=90 + var isAlphanum = isUpper || targetCode>=97&&targetCode<=122 || targetCode>=48&&targetCode<=57 + var isBeginning = isUpper && !wasUpper || !wasAlphanum || !isAlphanum + wasUpper = isUpper + wasAlphanum = isAlphanum + if(isBeginning) beginningIndexes[beginningIndexesLen++] = i + } + return beginningIndexes +} +var prepareNextBeginningIndexes = (target) => { + target = remove_accents(target) + var targetLen = target.length + var beginningIndexes = prepareBeginningIndexes(target) + var nextBeginningIndexes = [] // new Array(targetLen) sparse array is too slow + var lastIsBeginning = beginningIndexes[0] + var lastIsBeginningI = 0 + for(var i = 0; i < targetLen; ++i) { + if(lastIsBeginning > i) { + nextBeginningIndexes[i] = lastIsBeginning + } else { + lastIsBeginning = beginningIndexes[++lastIsBeginningI] + nextBeginningIndexes[i] = lastIsBeginning===undefined ? targetLen : lastIsBeginning + } + } + return nextBeginningIndexes +} + +var preparedCache = new Map() +var preparedSearchCache = new Map() + +// the theory behind these being globals is to reduce garbage collection by not making new arrays +var matchesSimple = []; var matchesStrict = [] +var nextBeginningIndexesChanges = [] // allows straw berry to match strawberry well, by modifying the end of a substring to be considered a beginning index for the rest of the search +var keysSpacesBestScores = []; var allowPartialMatchScores = [] +var tmpTargets = []; var tmpResults = [] + +// prop = 'key' 2.5ms optimized for this case, seems to be about as fast as direct obj[prop] +// prop = 'key1.key2' 10ms +// prop = ['key1', 'key2'] 27ms +// prop = obj => obj.tags.join() ??ms +var getValue = (obj, prop) => { + var tmp = obj[prop]; if(tmp !== undefined) return tmp + if(typeof prop === 'function') return prop(obj) // this should run first. but that makes string props slower + var segs = prop + if(!Array.isArray(prop)) segs = prop.split('.') + var len = segs.length + var i = -1 + while (obj && (++i < len)) obj = obj[segs[i]] + return obj +} + +var isPrepared = (x) => { return typeof x === 'object' && typeof x._bitflags === 'number' } +var INFINITY = Infinity; var NEGATIVE_INFINITY = -INFINITY +var noResults = []; noResults.total = 0 +var NULL = null + +var noTarget = prepare('') + +// Hacked version of https://github.com/lemire/FastPriorityQueue.js +var fastpriorityqueue=r=>{var e=[],o=0,a={},v=r=>{for(var a=0,v=e[a],c=1;c>1]=e[a],c=1+(a<<1)}for(var f=a-1>>1;a>0&&v._score>1)e[a]=e[f];e[a]=v};return a.add=(r=>{var a=o;e[o++]=r;for(var v=a-1>>1;a>0&&r._score>1)e[a]=e[v];e[a]=r}),a.poll=(r=>{if(0!==o){var a=e[0];return e[0]=e[--o],v(),a}}),a.peek=(r=>{if(0!==o)return e[0]}),a.replaceTop=(r=>{e[0]=r,v()}),a} +var q = fastpriorityqueue() // reuse this diff --git a/ii/modules/common/functions/levendist.js b/ii/modules/common/functions/levendist.js new file mode 100644 index 0000000..90180d2 --- /dev/null +++ b/ii/modules/common/functions/levendist.js @@ -0,0 +1,141 @@ +// Original code from https://github.com/koeqaife/hyprland-material-you +// Original code license: GPLv3 +// Translated to Js from Cython with an LLM and reviewed + +function min3(a, b, c) { + return a < b && a < c ? a : b < c ? b : c; +} + +function max3(a, b, c) { + return a > b && a > c ? a : b > c ? b : c; +} + +function min2(a, b) { + return a < b ? a : b; +} + +function max2(a, b) { + return a > b ? a : b; +} + +function levenshteinDistance(s1, s2) { + let len1 = s1.length; + let len2 = s2.length; + + if (len1 === 0) return len2; + if (len2 === 0) return len1; + + if (len2 > len1) { + [s1, s2] = [s2, s1]; + [len1, len2] = [len2, len1]; + } + + let prev = new Array(len2 + 1); + let curr = new Array(len2 + 1); + + for (let j = 0; j <= len2; j++) { + prev[j] = j; + } + + for (let i = 1; i <= len1; i++) { + curr[0] = i; + for (let j = 1; j <= len2; j++) { + let cost = s1[i - 1] === s2[j - 1] ? 0 : 1; + curr[j] = min3(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); + } + [prev, curr] = [curr, prev]; + } + + return prev[len2]; +} + +function partialRatio(shortS, longS) { + let lenS = shortS.length; + let lenL = longS.length; + let best = 0.0; + + if (lenS === 0) return 1.0; + + for (let i = 0; i <= lenL - lenS; i++) { + let sub = longS.slice(i, i + lenS); + let dist = levenshteinDistance(shortS, sub); + let score = 1.0 - (dist / lenS); + if (score > best) best = score; + } + + return best; +} + +function computeScore(s1, s2) { + if (s1 === s2) return 1.0; + + let dist = levenshteinDistance(s1, s2); + let maxLen = max2(s1.length, s2.length); + if (maxLen === 0) return 1.0; + + let full = 1.0 - (dist / maxLen); + let part = s1.length < s2.length ? partialRatio(s1, s2) : partialRatio(s2, s1); + + let score = 0.85 * full + 0.15 * part; + + if (s1 && s2 && s1[0] !== s2[0]) { + score -= 0.05; + } + + let lenDiff = Math.abs(s1.length - s2.length); + if (lenDiff >= 3) { + score -= 0.05 * lenDiff / maxLen; + } + + let commonPrefixLen = 0; + let minLen = min2(s1.length, s2.length); + for (let i = 0; i < minLen; i++) { + if (s1[i] === s2[i]) { + commonPrefixLen++; + } else { + break; + } + } + score += 0.02 * commonPrefixLen; + + if (s1.includes(s2) || s2.includes(s1)) { + score += 0.06; + } + + return Math.max(0.0, Math.min(1.0, score)); +} + +function computeTextMatchScore(s1, s2) { + if (s1 === s2) return 1.0; + + let dist = levenshteinDistance(s1, s2); + let maxLen = max2(s1.length, s2.length); + if (maxLen === 0) return 1.0; + + let full = 1.0 - (dist / maxLen); + let part = s1.length < s2.length ? partialRatio(s1, s2) : partialRatio(s2, s1); + + let score = 0.4 * full + 0.6 * part; + + let lenDiff = Math.abs(s1.length - s2.length); + if (lenDiff >= 10) { + score -= 0.02 * lenDiff / maxLen; + } + + let commonPrefixLen = 0; + let minLen = min2(s1.length, s2.length); + for (let i = 0; i < minLen; i++) { + if (s1[i] === s2[i]) { + commonPrefixLen++; + } else { + break; + } + } + score += 0.01 * commonPrefixLen; + + if (s1.includes(s2) || s2.includes(s1)) { + score += 0.2; + } + + return Math.max(0.0, Math.min(1.0, score)); +} diff --git a/ii/modules/common/widgets/AddressBar.qml b/ii/modules/common/widgets/AddressBar.qml new file mode 100644 index 0000000..c965a53 --- /dev/null +++ b/ii/modules/common/widgets/AddressBar.qml @@ -0,0 +1,120 @@ +import QtQuick +import QtQuick.Layouts +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +Rectangle { + id: root + required property var directory + property bool showBreadcrumb: true + onShowBreadcrumbChanged: { + addressInput.text = root.directory; + } + + signal navigateToDirectory(string path) + + property real padding: 6 + implicitWidth: mainLayout.implicitWidth + padding * 2 + implicitHeight: mainLayout.implicitHeight + padding * 2 + color: Appearance.colors.colLayer2 + + function focusBreadcrumb() { + root.showBreadcrumb = false; + addressInput.forceActiveFocus(); + } + + RowLayout { + id: mainLayout + anchors { + fill: parent + margins: root.padding + } + spacing: 8 + + RippleButton { + id: parentDirButton + onClicked: root.navigateToDirectory(FileUtils.parentDirectory(root.directory)) + contentItem: MaterialSymbol { + text: "drive_folder_upload" + iconSize: Appearance.font.pixelSize.larger + } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + Rectangle { + id: directoryEntry + visible: !root.showBreadcrumb + anchors.fill: parent + color: Appearance.colors.colLayer1 + radius: Appearance.rounding.full + implicitWidth: addressInput.implicitWidth + implicitHeight: addressInput.implicitHeight + + Keys.onPressed: event => { + if (directoryEntry.visible && event.key === Qt.Key_Escape) { + root.showBreadcrumb = true; + event.accepted = true; + return; + } + event.accepted = false; + } + + StyledTextInput { + id: addressInput + anchors.fill: parent + padding: 10 + text: root.directory + + Keys.onPressed: event => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.navigateToDirectory(text); + root.showBreadcrumb = true; + event.accepted = true; + } + } + + MouseArea { + // I-beam cursor + anchors.fill: parent + acceptedButtons: Qt.NoButton + hoverEnabled: true + cursorShape: Qt.IBeamCursor + } + } + } + + Loader { + id: breadcrumbLoader + active: root.showBreadcrumb + visible: root.showBreadcrumb + anchors.fill: parent + sourceComponent: AddressBreadcrumb { + directory: root.directory + onNavigateToDirectory: dir => { + root.navigateToDirectory(dir); + } + } + } + } + + RippleButton { + id: dirEditButton + toggled: !root.showBreadcrumb + onClicked: root.showBreadcrumb = !root.showBreadcrumb + contentItem: MaterialSymbol { + text: "edit" + iconSize: Appearance.font.pixelSize.larger + color: dirEditButton.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnLayer2 + } + + StyledToolTip { + content: Translation.tr("Edit directory") + } + } + } +} diff --git a/ii/modules/common/widgets/AddressBreadcrumb.qml b/ii/modules/common/widgets/AddressBreadcrumb.qml new file mode 100644 index 0000000..d1d6b52 --- /dev/null +++ b/ii/modules/common/widgets/AddressBreadcrumb.qml @@ -0,0 +1,41 @@ +import QtQuick +import QtQuick.Layouts +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +ListView { + id: root + required property var directory + property var breadcrumbDirectory: "" + Component.onCompleted: breadcrumbDirectory = directory; + onDirectoryChanged: { + if (breadcrumbDirectory.startsWith(directory)) return; + breadcrumbDirectory = directory + } + + signal navigateToDirectory(string path) + + orientation: ListView.Horizontal + clip: true + spacing: 2 + + model: breadcrumbDirectory.split("/") + delegate: SelectionGroupButton { + id: folderButton + required property var modelData + required property int index + buttonText: index === 0 ? "/" : modelData + toggled: { + if (directory.trim() === "/") return index === 0; + return index === directory.split("/").length - 1 + } + leftmost: index === 0 + rightmost: index === breadcrumbDirectory.split("/").length - 1 + + onClicked: { + root.navigateToDirectory(breadcrumbDirectory.split("/").slice(0, index + 1).join("/")) + } + } +} diff --git a/ii/modules/common/widgets/ButtonGroup.qml b/ii/modules/common/widgets/ButtonGroup.qml new file mode 100644 index 0000000..e58354d --- /dev/null +++ b/ii/modules/common/widgets/ButtonGroup.qml @@ -0,0 +1,47 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +/** + * A container that supports GroupButton children for bounciness. + * See https://m3.material.io/components/button-groups/overview + */ +Rectangle { + id: root + default property alias data: rowLayout.data + property alias uniformCellSizes: rowLayout.uniformCellSizes + property real spacing: 5 + property real padding: 0 + property int clickIndex: rowLayout.clickIndex + + property real contentWidth: { + let total = 0; + for (let i = 0; i < rowLayout.children.length; ++i) { + const child = rowLayout.children[i]; + if (!child.visible) continue; + total += child.baseWidth ?? child.implicitWidth ?? child.width; + } + return total + rowLayout.spacing * (rowLayout.children.length - 1); + } + + topLeftRadius: rowLayout.children.length > 0 ? (rowLayout.children[0].radius + padding) : + Appearance?.rounding?.small + bottomLeftRadius: topLeftRadius + topRightRadius: rowLayout.children.length > 0 ? (rowLayout.children[rowLayout.children.length - 1].radius + padding) : + Appearance?.rounding?.small + bottomRightRadius: topRightRadius + + color: "transparent" + width: root.contentWidth + padding * 2 + implicitHeight: rowLayout.implicitHeight + padding * 2 + implicitWidth: root.contentWidth + padding * 2 + + children: [RowLayout { + id: rowLayout + anchors.fill: parent + anchors.margins: root.padding + spacing: root.spacing + property int clickIndex: -1 + }] +} diff --git a/ii/modules/common/widgets/CircularProgress.qml b/ii/modules/common/widgets/CircularProgress.qml new file mode 100644 index 0000000..e1b2eff --- /dev/null +++ b/ii/modules/common/widgets/CircularProgress.qml @@ -0,0 +1,88 @@ +import QtQuick +import QtQuick.Shapes +import qs.modules.common + +/** + * Material 3 circular progress. See https://m3.material.io/components/progress-indicators/specs + */ +Item { + id: root + + property int implicitSize: 30 + property int lineWidth: 2 + property real value: 0 + property color colPrimary: Appearance.m3colors.m3onSecondaryContainer + property color colSecondary: Appearance.colors.colSecondaryContainer + property real gapAngle: 360 / 18 + property bool fill: false + property int fillOverflow: 2 + property bool enableAnimation: true + property int animationDuration: 800 + property var easingType: Easing.OutCubic + + implicitWidth: implicitSize + implicitHeight: implicitSize + + property real degree: value * 360 + property real centerX: root.width / 2 + property real centerY: root.height / 2 + property real arcRadius: root.implicitSize / 2 - root.lineWidth + property real startAngle: -90 + + Behavior on degree { + enabled: root.enableAnimation + NumberAnimation { + duration: root.animationDuration + easing.type: root.easingType + } + + } + + Loader { + active: root.fill + anchors.fill: parent + + sourceComponent: Rectangle { + radius: 9999 + color: root.colSecondary + } + } + + Shape { + anchors.fill: parent + layer.enabled: true + layer.smooth: true + preferredRendererType: Shape.CurveRenderer + ShapePath { + id: secondaryPath + strokeColor: root.colSecondary + strokeWidth: root.lineWidth + capStyle: ShapePath.RoundCap + fillColor: "transparent" + PathAngleArc { + centerX: root.centerX + centerY: root.centerY + radiusX: root.arcRadius + radiusY: root.arcRadius + startAngle: root.startAngle - root.gapAngle + sweepAngle: -(360 - root.degree - 2 * root.gapAngle) + } + } + ShapePath { + id: primaryPath + strokeColor: root.colPrimary + strokeWidth: root.lineWidth + capStyle: ShapePath.RoundCap + fillColor: "transparent" + PathAngleArc { + centerX: root.centerX + centerY: root.centerY + radiusX: root.arcRadius + radiusY: root.arcRadius + startAngle: root.startAngle + sweepAngle: root.degree + } + } + } + +} diff --git a/ii/modules/common/widgets/CliphistImage.qml b/ii/modules/common/widgets/CliphistImage.qml new file mode 100644 index 0000000..e729546 --- /dev/null +++ b/ii/modules/common/widgets/CliphistImage.qml @@ -0,0 +1,96 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import Quickshell +import Quickshell.Io + +Rectangle { + id: root + property string entry + property real maxWidth + property real maxHeight + + property string imageDecodePath: Directories.cliphistDecode + property string imageDecodeFileName: `${entryNumber}` + property string imageDecodeFilePath: `${imageDecodePath}/${imageDecodeFileName}` + property string source + + property int entryNumber: { + if (!root.entry) return 0 + const match = root.entry.match(/^(\d+)\t/) + return match ? parseInt(match[1]) : 0 + } + property int imageWidth: { + if (!root.entry) return 0 + const match = root.entry.match(/(\d+)x(\d+)/) + return match ? parseInt(match[1]) : 0 + } + property int imageHeight: { + if (!root.entry) return 0 + const match = root.entry.match(/(\d+)x(\d+)/) + return match ? parseInt(match[2]) : 0 + } + property real scale: { + return Math.min( + root.maxWidth / imageWidth, + root.maxHeight / imageHeight, + 1 + ) + } + + color: Appearance.colors.colLayer1 + radius: Appearance.rounding.small + implicitHeight: imageHeight * scale + implicitWidth: imageWidth * scale + + Component.onCompleted: { + decodeImageProcess.running = true + } + + Process { + id: decodeImageProcess + command: ["bash", "-c", + `[ -f ${imageDecodeFilePath} ] || echo '${StringUtils.shellSingleQuoteEscape(root.entry)}' | ${Cliphist.cliphistBinary} decode > '${imageDecodeFilePath}'` + ] + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + root.source = imageDecodeFilePath + } else { + console.error("[CliphistImage] Failed to decode image for entry:", root.entry) + root.source = "" + } + } + } + + Component.onDestruction: { + Quickshell.execDetached(["bash", "-c", `[ -f '${imageDecodeFilePath}' ] && rm -f '${imageDecodeFilePath}'`]) + } + + Image { + id: image + anchors.fill: parent + + source: Qt.resolvedUrl(root.source) + fillMode: Image.PreserveAspectFit + antialiasing: true + asynchronous: true + + width: root.imageWidth * root.scale + height: root.imageHeight * root.scale + sourceSize.width: width + sourceSize.height: height + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: image.width + height: image.height + radius: root.radius + } + } + } +} + diff --git a/ii/modules/common/widgets/ClippedFilledCircularProgress.qml b/ii/modules/common/widgets/ClippedFilledCircularProgress.qml new file mode 100644 index 0000000..d6522e6 --- /dev/null +++ b/ii/modules/common/widgets/ClippedFilledCircularProgress.qml @@ -0,0 +1,98 @@ +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import QtQuick.Shapes +import Qt5Compat.GraphicalEffects + +Item { + id: root + + property int implicitSize: 18 + property int lineWidth: 2 + property real value: 0 + property color colPrimary: Appearance?.colors.colOnSecondaryContainer ?? "#685496" + property color colSecondary: ColorUtils.transparentize(colPrimary, 0.5) ?? "#F1D3F9" + property real gapAngle: 360 / 18 + property bool fill: true + property int fillOverflow: 2 + property bool enableAnimation: true + property int animationDuration: 800 + property var easingType: Easing.OutCubic + property bool accountForLightBleeding: true + default property Item textMask: Item { + width: implicitSize + height: implicitSize + StyledText { + anchors.centerIn: parent + text: Math.round(root.value * 100) + font.pixelSize: 12 + font.weight: Font.Medium + } + } + + implicitWidth: implicitSize + implicitHeight: implicitSize + + property real degree: value * 360 + property real centerX: root.width / 2 + property real centerY: root.height / 2 + property real arcRadius: root.implicitSize / 2 - root.lineWidth / 2 - (0.5 * root.accountForLightBleeding) + property real startAngle: -90 + + Behavior on degree { + enabled: root.enableAnimation + NumberAnimation { + duration: root.animationDuration + easing.type: root.easingType + } + + } + + Rectangle { + id: contentItem + anchors.fill: parent + radius: implicitSize / 2 + color: root.colSecondary + visible: false + layer.enabled: true + layer.smooth: true + + Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + id: primaryPath + pathHints: ShapePath.PathSolid & ShapePath.PathNonIntersecting + strokeColor: root.colPrimary + strokeWidth: root.lineWidth + capStyle: ShapePath.RoundCap + fillColor: root.colPrimary + + startX: root.centerX + startY: root.centerY + + PathAngleArc { + moveToStart: false + centerX: root.centerX + centerY: root.centerY + radiusX: root.arcRadius + radiusY: root.arcRadius + startAngle: root.startAngle + sweepAngle: root.degree + } + PathLine { + x: primaryPath.startX + y: primaryPath.startY + } + } + } + } + + OpacityMask { + anchors.fill: parent + source: contentItem + invert: true + maskSource: root.textMask + } +} diff --git a/ii/modules/common/widgets/ClippedProgressBar.qml b/ii/modules/common/widgets/ClippedProgressBar.qml new file mode 100644 index 0000000..b47b49b --- /dev/null +++ b/ii/modules/common/widgets/ClippedProgressBar.qml @@ -0,0 +1,101 @@ +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import Qt5Compat.GraphicalEffects + +/** + * A progress bar with both ends rounded and text acts as clipping like OneUI 7's battery indicator. + */ +ProgressBar { + id: root + property bool vertical: false + property real valueBarWidth: 30 + property real valueBarHeight: 18 + property color highlightColor: Appearance?.colors.colOnSecondaryContainer ?? "#685496" + property color trackColor: ColorUtils.transparentize(highlightColor, 0.5) ?? "#F1D3F9" + property alias radius: contentItem.radius + property string text + default property Item textMask: Item { + width: valueBarWidth + height: valueBarHeight + StyledText { + anchors.centerIn: parent + font: root.font + text: root.text + } + } + + text: Math.round(value * 100) + font { + pixelSize: 13 + weight: text.length > 2 ? Font.Medium : Font.DemiBold + } + + background: Item { + implicitHeight: valueBarHeight + implicitWidth: valueBarWidth + } + + contentItem: Rectangle { + id: contentItem + anchors.fill: parent + radius: 9999 + color: root.trackColor + visible: false + + Rectangle { + id: progressFill + anchors { + top: parent.top + bottom: parent.bottom + left: parent.left + right: undefined + } + width: parent.width * root.visualPosition + height: parent.height + + states: State { + name: "vertical" + when: root.vertical + AnchorChanges { + target: progressFill + anchors { + top: undefined + bottom: parent.bottom + left: parent.left + right: parent.right + } + } + PropertyChanges { + target: progressFill + width: parent.width + height: parent.height * root.visualPosition + } + } + + radius: Appearance.rounding.unsharpen + color: root.highlightColor + } + } + + OpacityMask { + id: roundingMask + visible: false + anchors.fill: parent + source: contentItem + maskSource: Rectangle { + width: contentItem.width + height: contentItem.height + radius: contentItem.radius + } + } + + OpacityMask { + anchors.fill: parent + source: roundingMask + invert: true + maskSource: root.textMask + } +} diff --git a/ii/modules/common/widgets/ConfigRow.qml b/ii/modules/common/widgets/ConfigRow.qml new file mode 100644 index 0000000..ae2adff --- /dev/null +++ b/ii/modules/common/widgets/ConfigRow.qml @@ -0,0 +1,8 @@ +import QtQuick +import QtQuick.Layouts + +RowLayout { + property bool uniform: false + spacing: 4 + uniformCellSizes: uniform +} diff --git a/ii/modules/common/widgets/ConfigSelectionArray.qml b/ii/modules/common/widgets/ConfigSelectionArray.qml new file mode 100644 index 0000000..943fadf --- /dev/null +++ b/ii/modules/common/widgets/ConfigSelectionArray.qml @@ -0,0 +1,54 @@ +import QtQuick +import QtQuick.Layouts +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +Flow { + id: root + Layout.fillWidth: true + spacing: 2 + property list options: [ + { + "displayName": "Option 1", + "icon": "check", + "value": 1 + }, + { + "displayName": "Option 2", + "icon": "close", + "value": 2 + }, + ] + property var currentValue: null + + signal selected(var newValue) + + Repeater { + model: root.options + delegate: SelectionGroupButton { + id: paletteButton + required property var modelData + required property int index + onYChanged: { + if (index === 0) { + paletteButton.leftmost = true + } else { + var prev = root.children[index - 1] + var thisIsOnNewLine = prev && prev.y !== paletteButton.y + paletteButton.leftmost = thisIsOnNewLine + prev.rightmost = thisIsOnNewLine + } + } + leftmost: index === 0 + rightmost: index === root.options.length - 1 + buttonIcon: modelData.icon || "" + buttonText: modelData.displayName + toggled: root.currentValue === modelData.value + onClicked: { + root.selected(modelData.value); + } + } + } +} diff --git a/ii/modules/common/widgets/ConfigSpinBox.qml b/ii/modules/common/widgets/ConfigSpinBox.qml new file mode 100644 index 0000000..2c9d7ea --- /dev/null +++ b/ii/modules/common/widgets/ConfigSpinBox.qml @@ -0,0 +1,37 @@ +import qs.modules.common.widgets +import qs.modules.common +import QtQuick +import QtQuick.Layouts + +RowLayout { + id: root + property string text: "" + property string icon + property alias value: spinBoxWidget.value + property alias stepSize: spinBoxWidget.stepSize + property alias from: spinBoxWidget.from + property alias to: spinBoxWidget.to + spacing: 10 + Layout.leftMargin: 8 + Layout.rightMargin: 8 + + RowLayout { + spacing: 10 + OptionalMaterialSymbol { + icon: root.icon + } + StyledText { + id: labelWidget + Layout.fillWidth: true + text: root.text + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSecondaryContainer + } + } + + StyledSpinBox { + id: spinBoxWidget + Layout.fillWidth: false + value: root.value + } +} diff --git a/ii/modules/common/widgets/ConfigSwitch.qml b/ii/modules/common/widgets/ConfigSwitch.qml new file mode 100644 index 0000000..9c0da8f --- /dev/null +++ b/ii/modules/common/widgets/ConfigSwitch.qml @@ -0,0 +1,37 @@ +import qs.modules.common.widgets +import qs.modules.common +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +RippleButton { + id: root + property string buttonIcon + Layout.fillWidth: true + implicitHeight: contentItem.implicitHeight + 8 * 2 + onClicked: checked = !checked + + contentItem: RowLayout { + spacing: 10 + OptionalMaterialSymbol { + icon: root.buttonIcon + iconSize: Appearance.font.pixelSize.larger + } + StyledText { + id: labelWidget + Layout.fillWidth: true + text: root.text + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSecondaryContainer + } + StyledSwitch { + id: switchWidget + down: root.down + scale: 0.6 + Layout.fillWidth: false + checked: root.checked + onClicked: root.clicked() + } + } +} + diff --git a/ii/modules/common/widgets/ContentPage.qml b/ii/modules/common/widgets/ContentPage.qml new file mode 100644 index 0000000..7ef96e9 --- /dev/null +++ b/ii/modules/common/widgets/ContentPage.qml @@ -0,0 +1,29 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +StyledFlickable { + id: root + property real baseWidth: 600 + property bool forceWidth: false + property real bottomContentPadding: 100 + + default property alias data: contentColumn.data + + clip: true + contentHeight: contentColumn.implicitHeight + root.bottomContentPadding // Add some padding at the bottom + implicitWidth: contentColumn.implicitWidth + + ColumnLayout { + id: contentColumn + width: root.forceWidth ? root.baseWidth : Math.max(root.baseWidth, implicitWidth) + anchors { + top: parent.top + horizontalCenter: parent.horizontalCenter + margins: 20 + } + spacing: 30 + } + +} diff --git a/ii/modules/common/widgets/ContentSection.qml b/ii/modules/common/widgets/ContentSection.qml new file mode 100644 index 0000000..8af81fb --- /dev/null +++ b/ii/modules/common/widgets/ContentSection.qml @@ -0,0 +1,35 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +ColumnLayout { + id: root + property string title + property string icon: "" + default property alias data: sectionContent.data + + Layout.fillWidth: true + spacing: 6 + + RowLayout { + spacing: 6 + OptionalMaterialSymbol { + icon: root.icon + iconSize: Appearance.font.pixelSize.hugeass + } + StyledText { + text: root.title + font.pixelSize: Appearance.font.pixelSize.larger + font.weight: Font.Medium + } + } + + ColumnLayout { + id: sectionContent + Layout.fillWidth: true + spacing: 4 + + } +} diff --git a/ii/modules/common/widgets/ContentSubsection.qml b/ii/modules/common/widgets/ContentSubsection.qml new file mode 100644 index 0000000..b78f3aa --- /dev/null +++ b/ii/modules/common/widgets/ContentSubsection.qml @@ -0,0 +1,46 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +ColumnLayout { + id: root + property string title: "" + property string tooltip: "" + default property alias data: sectionContent.data + + Layout.fillWidth: true + Layout.topMargin: 4 + spacing: 2 + + RowLayout { + ContentSubsectionLabel { + visible: root.title && root.title.length > 0 + text: root.title + } + MaterialSymbol { + visible: root.tooltip && root.tooltip.length > 0 + text: "info" + iconSize: Appearance.font.pixelSize.large + + color: Appearance.colors.colSubtext + MouseArea { + id: infoMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.WhatsThisCursor + StyledToolTip { + extraVisibleCondition: false + alternativeVisibleCondition: infoMouseArea.containsMouse + content: root.tooltip + } + } + } + Item { Layout.fillWidth: true } + } + ColumnLayout { + id: sectionContent + Layout.fillWidth: true + spacing: 2 + } +} diff --git a/ii/modules/common/widgets/ContentSubsectionLabel.qml b/ii/modules/common/widgets/ContentSubsectionLabel.qml new file mode 100644 index 0000000..0215aab --- /dev/null +++ b/ii/modules/common/widgets/ContentSubsectionLabel.qml @@ -0,0 +1,10 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +StyledText { + text: "Subsection" + color: Appearance.colors.colSubtext + Layout.leftMargin: 2 +} diff --git a/ii/modules/common/widgets/CustomIcon.qml b/ii/modules/common/widgets/CustomIcon.qml new file mode 100644 index 0000000..d7a1c63 --- /dev/null +++ b/ii/modules/common/widgets/CustomIcon.qml @@ -0,0 +1,37 @@ +import QtQuick +import Quickshell +import Quickshell.Widgets +import Qt5Compat.GraphicalEffects + +Item { + id: root + + property bool colorize: false + property color color + property string source: "" + property string iconFolder: Qt.resolvedUrl(Quickshell.shellPath("assets/icons")) // The folder to check first + width: 30 + height: 30 + + IconImage { + id: iconImage + anchors.fill: parent + source: { + const fullPathWhenSourceIsIconName = iconFolder + "/" + root.source; + if (iconFolder && fullPathWhenSourceIsIconName) { + return fullPathWhenSourceIsIconName + } + return root.source + } + implicitSize: root.height + } + + Loader { + active: root.colorize + anchors.fill: iconImage + sourceComponent: ColorOverlay { + source: iconImage + color: root.color + } + } +} diff --git a/ii/modules/common/widgets/DialogButton.qml b/ii/modules/common/widgets/DialogButton.qml new file mode 100644 index 0000000..9373a8c --- /dev/null +++ b/ii/modules/common/widgets/DialogButton.qml @@ -0,0 +1,40 @@ +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import QtQuick + +/** + * Material 3 dialog button. See https://m3.material.io/components/dialogs/overview + */ +RippleButton { + id: root + + property string buttonText + padding: 14 + implicitHeight: 36 + implicitWidth: buttonTextWidget.implicitWidth + padding * 2 + buttonRadius: Appearance?.rounding.full ?? 9999 + + property color colEnabled: Appearance?.colors.colPrimary ?? "#65558F" + property color colDisabled: Appearance?.m3colors.m3outline ?? "#8D8C96" + colBackground: ColorUtils.transparentize(Appearance.colors.colLayer3) + colBackgroundHover: Appearance.colors.colLayer3Hover + colRipple: Appearance.colors.colLayer3Active + property alias colText: buttonTextWidget.color + + contentItem: StyledText { + id: buttonTextWidget + anchors.fill: parent + anchors.leftMargin: root.padding + anchors.rightMargin: root.padding + text: buttonText + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance?.font.pixelSize.small ?? 12 + color: root.enabled ? root.colEnabled : root.colDisabled + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + +} diff --git a/ii/modules/common/widgets/DialogListItem.qml b/ii/modules/common/widgets/DialogListItem.qml new file mode 100644 index 0000000..67205ce --- /dev/null +++ b/ii/modules/common/widgets/DialogListItem.qml @@ -0,0 +1,25 @@ +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import QtQuick + +RippleButton { + id: root + property bool active: false + + horizontalPadding: Appearance.rounding.large + verticalPadding: 12 + + clip: true + pointingHandCursor: !active + implicitWidth: contentItem.implicitWidth + horizontalPadding * 2 + implicitHeight: contentItem.implicitHeight + verticalPadding * 2 + Behavior on implicitHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + colBackground: ColorUtils.transparentize(Appearance.colors.colLayer3) + colBackgroundHover: active ? colBackground : Appearance.colors.colLayer3Hover + colRipple: Appearance.colors.colLayer3Active + buttonRadius: 0 +} diff --git a/ii/modules/common/widgets/DirectoryIcon.qml b/ii/modules/common/widgets/DirectoryIcon.qml new file mode 100644 index 0000000..9df2ee2 --- /dev/null +++ b/ii/modules/common/widgets/DirectoryIcon.qml @@ -0,0 +1,41 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.modules.common +import qs.modules.common.functions + +// From https://github.com/caelestia-dots/shell with modifications. +// License: GPLv3 + +Image { + id: root + required property var fileModelData + asynchronous: true + fillMode: Image.PreserveAspectFit + + source: { + if (!fileModelData.fileIsDir) + return Quickshell.iconPath("application-x-zerosize"); + + if ([Directories.documents, Directories.downloads, Directories.music, Directories.pictures, Directories.videos].some(dir => FileUtils.trimFileProtocol(dir) === fileModelData.filePath)) + return Quickshell.iconPath(`folder-${fileModelData.fileName.toLowerCase()}`); + + return Quickshell.iconPath("inode-directory"); + } + + onStatusChanged: { + if (status === Image.Error) + source = Quickshell.iconPath("error"); + } + + Process { + running: !fileModelData.fileIsDir + command: ["file", "--mime", "-b", fileModelData.filePath] + stdout: StdioCollector { + onStreamFinished: { + const mime = text.split(";")[0].replace("/", "-"); + root.source = Images.validImageTypes.some(t => mime === `image-${t}`) ? fileModelData.fileUrl : Quickshell.iconPath(mime, "image-missing"); + } + } + } +} diff --git a/ii/modules/common/widgets/DragManager.qml b/ii/modules/common/widgets/DragManager.qml new file mode 100644 index 0000000..9a430d9 --- /dev/null +++ b/ii/modules/common/widgets/DragManager.qml @@ -0,0 +1,72 @@ +import qs.modules.common +import qs.services +import QtQuick + +/** + * A convenience MouseArea for handling drag events. + */ +MouseArea { + id: root + hoverEnabled: true + acceptedButtons: Qt.LeftButton + + property bool interactive: true + property bool automaticallyReset: true + readonly property real dragDiffX: _dragDiffX + readonly property real dragDiffY: _dragDiffY + + signal dragPressed(diffX: real, diffY: real) + signal dragReleased(diffX: real, diffY: real) + + property real startX: 0 + property real startY: 0 + property bool dragging: false + property real _dragDiffX: 0 + property real _dragDiffY: 0 + + function resetDrag() { + _dragDiffX = 0 + _dragDiffY = 0 + } + + onPressed: (mouse) => { + if (!root.interactive) { + if (mouse.button === Qt.LeftButton) { + mouse.accepted = false; + } + return; + } + if (mouse.button === Qt.LeftButton) { + startX = mouse.x + startY = mouse.y + } + } + onReleased: (mouse) => { + if (!root.interactive) { + return; + } + dragging = false + root.dragReleased(_dragDiffX, _dragDiffY); + if (root.automaticallyReset) { + root.resetDrag(); + } + } + onPositionChanged: (mouse) => { + if (!root.interactive) { + return; + } + if (mouse.buttons & Qt.LeftButton) { + root._dragDiffX = mouse.x - startX + root._dragDiffY = mouse.y - startY + const dist = Math.sqrt(root._dragDiffX * root._dragDiffX + root._dragDiffY * root._dragDiffY); + root.dragPressed(_dragDiffX, _dragDiffY); + root.dragging = true; + } + } + onCanceled: (mouse) => { + if (!root.interactive) { + return; + } + released(mouse); + } +} diff --git a/ii/modules/common/widgets/Favicon.qml b/ii/modules/common/widgets/Favicon.qml new file mode 100644 index 0000000..04e9285 --- /dev/null +++ b/ii/modules/common/widgets/Favicon.qml @@ -0,0 +1,48 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import Quickshell.Io +import Quickshell.Widgets + +IconImage { + id: root + property string url + property string displayText + + property real size: 32 + property string downloadUserAgent: Config.options?.networking.userAgent ?? "" + property string faviconDownloadPath: Directories.favicons + property string domainName: url.includes("vertexaisearch") ? displayText : StringUtils.getDomain(url) + property string faviconUrl: `https://www.google.com/s2/favicons?domain=${domainName}&sz=32` + property string fileName: `${domainName}.ico` + property string faviconFilePath: `${faviconDownloadPath}/${fileName}` + property string urlToLoad + + Process { + id: faviconDownloadProcess + running: false + command: ["bash", "-c", `[ -f ${faviconFilePath} ] || curl -s '${root.faviconUrl}' -o '${faviconFilePath}' -L -H 'User-Agent: ${downloadUserAgent}'`] + onExited: (exitCode, exitStatus) => { + root.urlToLoad = root.faviconFilePath + } + } + + Component.onCompleted: { + faviconDownloadProcess.running = true + } + + source: Qt.resolvedUrl(root.urlToLoad) + implicitSize: root.size + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: root.implicitSize + height: root.implicitSize + radius: Appearance.rounding.full + } + } +} \ No newline at end of file diff --git a/ii/modules/common/widgets/FloatingActionButton.qml b/ii/modules/common/widgets/FloatingActionButton.qml new file mode 100644 index 0000000..14702aa --- /dev/null +++ b/ii/modules/common/widgets/FloatingActionButton.qml @@ -0,0 +1,59 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +/** + * Material 3 FAB. + */ +RippleButton { + id: root + property string iconText: "add" + property bool expanded: false + property real baseSize: 56 + property real elementSpacing: 5 + implicitWidth: Math.max(contentRowLayout.implicitWidth + 10 * 2, baseSize) + implicitHeight: baseSize + buttonRadius: Appearance.rounding.small + colBackground: Appearance.colors.colPrimaryContainer + colBackgroundHover: Appearance.colors.colPrimaryContainerHover + colRipple: Appearance.colors.colPrimaryContainerActive + contentItem: RowLayout { + id: contentRowLayout + property real horizontalMargins: (root.baseSize - icon.width) / 2 + anchors { + verticalCenter: parent?.verticalCenter + left: parent?.left + leftMargin: contentRowLayout.horizontalMargins + } + spacing: 0 + + MaterialSymbol { + id: icon + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + iconSize: 24 + color: Appearance.colors.colOnPrimaryContainer + text: root.iconText + } + Loader { + active: true + sourceComponent: Revealer { + visible: root.expanded || implicitWidth > 0 + reveal: root.expanded + implicitWidth: reveal ? (buttonText.implicitWidth + root.elementSpacing + contentRowLayout.horizontalMargins) : 0 + StyledText { + id: buttonText + anchors { + left: parent.left + leftMargin: root.elementSpacing + } + text: root.buttonText + color: Appearance.colors.colOnPrimaryContainer + font.pixelSize: 14 + font.weight: 450 + } + } + } + } +} diff --git a/ii/modules/common/widgets/FlowButtonGroup.qml b/ii/modules/common/widgets/FlowButtonGroup.qml new file mode 100644 index 0000000..ec9526e --- /dev/null +++ b/ii/modules/common/widgets/FlowButtonGroup.qml @@ -0,0 +1,8 @@ +import QtQuick + +/** + * This is just to make sure `RippleButton`s can be used in a Flow layout. + */ +Flow { + property int clickIndex: -1 +} diff --git a/ii/modules/common/widgets/FocusedScrollMouseArea.qml b/ii/modules/common/widgets/FocusedScrollMouseArea.qml new file mode 100644 index 0000000..9b48d19 --- /dev/null +++ b/ii/modules/common/widgets/FocusedScrollMouseArea.qml @@ -0,0 +1,64 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.UPower +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +MouseArea { // Right side | scroll to change volume + id: root + + signal scrollUp(delta: int) + signal scrollDown(delta: int) + signal movedAway() + + property bool hovered: false + property real lastScrollX: 0 + property real lastScrollY: 0 + property bool trackingScroll: false + property real moveThreshold: 20 + + acceptedButtons: Qt.LeftButton + hoverEnabled: true + + onEntered: { + root.hovered = true; + } + + onExited: { + root.hovered = false; + root.trackingScroll = false; + } + + onWheel: event => { + if (event.angleDelta.y < 0) + root.scrollDown(event.angleDelta.y); + else if (event.angleDelta.y > 0) + root.scrollUp(event.angleDelta.y); + // Store the mouse position and start tracking + root.lastScrollX = event.x; + root.lastScrollY = event.y; + root.trackingScroll = true; + } + + onPositionChanged: mouse => { + if (root.trackingScroll) { + const dx = mouse.x - root.lastScrollX; + const dy = mouse.y - root.lastScrollY; + if (Math.sqrt(dx * dx + dy * dy) > root.moveThreshold) { + root.movedAway(); + root.trackingScroll = false; + } + } + } + + onContainsMouseChanged: { + if (!root.containsMouse && root.trackingScroll) { + root.movedAway(); + root.trackingScroll = false; + } + } +} diff --git a/ii/modules/common/widgets/GroupButton.qml b/ii/modules/common/widgets/GroupButton.qml new file mode 100644 index 0000000..4a524e1 --- /dev/null +++ b/ii/modules/common/widgets/GroupButton.qml @@ -0,0 +1,130 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +/** + * Material 3 button with expressive bounciness. + * See https://m3.material.io/components/button-groups/overview + */ +Button { + id: root + property bool toggled + property string buttonText + property real buttonRadius: Appearance?.rounding?.small ?? 8 + property real buttonRadiusPressed: Appearance?.rounding?.small ?? 6 + property var downAction // When left clicking (down) + property var releaseAction // When left clicking (release) + property var altAction // When right clicking + property var middleClickAction // When middle clicking + property bool bounce: true + property real baseWidth: contentItem.implicitWidth + horizontalPadding * 2 + property real baseHeight: contentItem.implicitHeight + verticalPadding * 2 + property real clickedWidth: baseWidth + 20 + property real clickedHeight: baseHeight + property var parentGroup: root.parent + property int clickIndex: parentGroup?.clickIndex ?? -1 + + Layout.fillWidth: (clickIndex - 1 <= parentGroup.children.indexOf(root) && parentGroup.children.indexOf(root) <= clickIndex + 1) + Layout.fillHeight: (clickIndex - 1 <= parentGroup.children.indexOf(root) && parentGroup.children.indexOf(root) <= clickIndex + 1) + implicitWidth: (root.down && bounce) ? clickedWidth : baseWidth + implicitHeight: (root.down && bounce) ? clickedHeight : baseHeight + + property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 1) || "transparent" + property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED" + property color colBackgroundActive: Appearance?.colors.colLayer1Active ?? "#D6CEE2" + property color colBackgroundToggled: Appearance?.colors.colPrimary ?? "#65558F" + property color colBackgroundToggledHover: Appearance?.colors.colPrimaryHover ?? "#77699C" + property color colBackgroundToggledActive: Appearance?.colors.colPrimaryActive ?? "#D6CEE2" + + property real radius: root.down ? root.buttonRadiusPressed : root.buttonRadius + property real leftRadius: root.down ? root.buttonRadiusPressed : root.buttonRadius + property real rightRadius: root.down ? root.buttonRadiusPressed : root.buttonRadius + property color color: root.enabled ? (root.toggled ? + (root.down ? colBackgroundToggledActive : + root.hovered ? colBackgroundToggledHover : + colBackgroundToggled) : + (root.down ? colBackgroundActive : + root.hovered ? colBackgroundHover : + colBackground)) : colBackground + + onDownChanged: { + if (root.down) { + if (root.parent.clickIndex !== undefined) { + root.parent.clickIndex = parent.children.indexOf(root) + } + } + } + + Behavior on implicitWidth { + animation: Appearance.animation.clickBounce.numberAnimation.createObject(this) + } + + Behavior on implicitHeight { + animation: Appearance.animation.clickBounce.numberAnimation.createObject(this) + } + + Behavior on leftRadius { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on rightRadius { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onPressed: (event) => { + if(event.button === Qt.RightButton) { + if (root.altAction) root.altAction(); + return; + } + if(event.button === Qt.MiddleButton) { + if (root.middleClickAction) root.middleClickAction(); + return; + } + root.down = true + if (root.downAction) root.downAction(); + } + onReleased: (event) => { + root.down = false + if (event.button != Qt.LeftButton) return; + if (root.releaseAction) root.releaseAction(); + } + onClicked: (event) => { + if (event.button != Qt.LeftButton) return; + root.click() + } + onCanceled: (event) => { + root.down = false + } + + onPressAndHold: () => { + altAction(); + root.down = false; + root.clicked = false; + }; + } + + + background: Rectangle { + id: buttonBackground + topLeftRadius: root.leftRadius + topRightRadius: root.rightRadius + bottomLeftRadius: root.leftRadius + bottomRightRadius: root.rightRadius + implicitHeight: 50 + + color: root.color + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + + contentItem: StyledText { + text: root.buttonText + } +} diff --git a/ii/modules/common/widgets/KeyboardKey.qml b/ii/modules/common/widgets/KeyboardKey.qml new file mode 100644 index 0000000..14c75c6 --- /dev/null +++ b/ii/modules/common/widgets/KeyboardKey.qml @@ -0,0 +1,42 @@ +import qs.modules.common +import QtQuick + +Rectangle { + id: root + property string key + + property real horizontalPadding: 6 + property real verticalPadding: 1 + property real borderWidth: 1 + property real extraBottomBorderWidth: 2 + property color borderColor: Appearance.colors.colOnLayer0 + property real borderRadius: 5 + property color keyColor: Appearance.m3colors.m3surfaceContainerLow + implicitWidth: keyFace.implicitWidth + borderWidth * 2 + implicitHeight: keyFace.implicitHeight + borderWidth * 2 + extraBottomBorderWidth + radius: borderRadius + color: borderColor + + Rectangle { + id: keyFace + anchors { + fill: parent + topMargin: borderWidth + leftMargin: borderWidth + rightMargin: borderWidth + bottomMargin: extraBottomBorderWidth + borderWidth + } + implicitWidth: keyText.implicitWidth + horizontalPadding * 2 + implicitHeight: keyText.implicitHeight + verticalPadding * 2 + color: keyColor + radius: borderRadius - borderWidth + + StyledText { + id: keyText + anchors.centerIn: parent + font.family: Appearance.font.family.monospace + font.pixelSize: Appearance.font.pixelSize.smaller + text: key + } + } +} diff --git a/ii/modules/common/widgets/LightDarkPreferenceButton.qml b/ii/modules/common/widgets/LightDarkPreferenceButton.qml new file mode 100644 index 0000000..5ad1316 --- /dev/null +++ b/ii/modules/common/widgets/LightDarkPreferenceButton.qml @@ -0,0 +1,122 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Quickshell + +RippleButton { + id: lightDarkButtonRoot + required property bool dark + property color previewBg: dark ? ColorUtils.colorWithHueOf("#3f3838", Appearance.m3colors.m3primary) : + ColorUtils.colorWithHueOf("#F7F9FF", Appearance.m3colors.m3primary) + property color previewFg: dark ? Qt.lighter(previewBg, 2.2) : ColorUtils.mix(previewBg, "#292929", 0.85) + padding: 5 + Layout.fillWidth: true + colBackground: Appearance.colors.colLayer2 + toggled: Appearance.m3colors.darkmode === dark + onClicked: { + Quickshell.execDetached(["bash", "-c", `${Directories.wallpaperSwitchScriptPath} --mode ${dark ? "dark" : "light"} --noswitch`]) + } + contentItem: Item { + anchors.centerIn: parent + implicitWidth: buttonContentLayout.implicitWidth + implicitHeight: buttonContentLayout.implicitHeight + ColumnLayout { + id: buttonContentLayout + anchors.centerIn: parent + Rectangle { + Layout.alignment: Qt.AlignHCenter + implicitWidth: 250 + implicitHeight: skeletonColumnLayout.implicitHeight + 10 * 2 + radius: lightDarkButtonRoot.buttonRadius - lightDarkButtonRoot.padding + color: lightDarkButtonRoot.previewBg + border { + width: 1 + color: Appearance.m3colors.m3outlineVariant + } + + // Some skeleton items + ColumnLayout { + id: skeletonColumnLayout + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + RowLayout { + Rectangle { + radius: Appearance.rounding.full + color: lightDarkButtonRoot.previewFg + implicitWidth: 50 + implicitHeight: 50 + } + ColumnLayout { + spacing: 4 + Rectangle { + radius: Appearance.rounding.unsharpenmore + color: lightDarkButtonRoot.previewFg + Layout.fillWidth: true + implicitHeight: 22 + } + Rectangle { + radius: Appearance.rounding.unsharpenmore + color: lightDarkButtonRoot.previewFg + Layout.fillWidth: true + Layout.rightMargin: 45 + implicitHeight: 18 + } + } + } + StyledProgressBar { + Layout.topMargin: 5 + Layout.bottomMargin: 5 + Layout.fillWidth: true + value: 0.7 + sperm: true + animateSperm: lightDarkButtonRoot.toggled + highlightColor: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3primary : lightDarkButtonRoot.previewFg + trackColor: ColorUtils.mix(lightDarkButtonRoot.previewBg, lightDarkButtonRoot.previewFg, 0.5) + } + RowLayout { + spacing: 2 + Rectangle { + radius: Appearance.rounding.full + color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3primary : lightDarkButtonRoot.previewFg + Layout.fillWidth: true + implicitHeight: 30 + MaterialSymbol { + visible: lightDarkButtonRoot.toggled + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "check" + iconSize: 20 + color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3onPrimary : lightDarkButtonRoot.previewBg + } + } + Rectangle { + radius: Appearance.rounding.unsharpenmore + color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3secondaryContainer : lightDarkButtonRoot.previewFg + Layout.fillWidth: true + implicitHeight: 30 + } + Rectangle { + topLeftRadius: Appearance.rounding.unsharpenmore + bottomLeftRadius: Appearance.rounding.unsharpenmore + topRightRadius: Appearance.rounding.full + bottomRightRadius: Appearance.rounding.full + color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3secondaryContainer : lightDarkButtonRoot.previewFg + Layout.fillWidth: true + implicitHeight: 30 + } + } + } + } + StyledText { + Layout.fillWidth: true + text: dark ? Translation.tr("Dark") : Translation.tr("Light") + color: lightDarkButtonRoot.toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2 + horizontalAlignment: Text.AlignHCenter + } + } + } +} diff --git a/ii/modules/common/widgets/MaterialSymbol.qml b/ii/modules/common/widgets/MaterialSymbol.qml new file mode 100644 index 0000000..1268453 --- /dev/null +++ b/ii/modules/common/widgets/MaterialSymbol.qml @@ -0,0 +1,29 @@ +import qs.modules.common +import QtQuick + +StyledText { + id: root + property real iconSize: Appearance?.font.pixelSize.small ?? 16 + property real fill: 0 + property real truncatedFill: Math.round(fill * 100) / 100 // Reduce memory consumption spikes from constant font remapping + font { + hintingPreference: Font.PreferFullHinting + family: Appearance?.font.family.iconMaterial ?? "Material Symbols Rounded" + pixelSize: iconSize + weight: Font.Normal + (Font.DemiBold - Font.Normal) * fill + variableAxes: { + "FILL": truncatedFill, + // "wght": font.weight, + // "GRAD": 0, + "opsz": iconSize, + } + } + + // Behavior on fill { // Leaky leaky, no good + // NumberAnimation { + // duration: Appearance?.animation.elementMoveFast.duration ?? 200 + // easing.type: Appearance?.animation.elementMoveFast.type ?? Easing.BezierSpline + // easing.bezierCurve: Appearance?.animation.elementMoveFast.bezierCurve ?? [0.34, 0.80, 0.34, 1.00, 1, 1] + // } + // } +} diff --git a/ii/modules/common/widgets/MaterialTextArea.qml b/ii/modules/common/widgets/MaterialTextArea.qml new file mode 100644 index 0000000..241cc90 --- /dev/null +++ b/ii/modules/common/widgets/MaterialTextArea.qml @@ -0,0 +1,52 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls.Material +import QtQuick.Controls + +/** + * Material 3 styled TextArea (filled style) + * https://m3.material.io/components/text-fields/overview + * Note: We don't use NativeRendering because it makes the small placeholder text look weird + */ +TextArea { + id: root + Material.theme: Material.System + Material.accent: Appearance.m3colors.m3primary + Material.primary: Appearance.m3colors.m3primary + Material.background: Appearance.m3colors.m3surface + Material.foreground: Appearance.m3colors.m3onSurface + Material.containerStyle: Material.Filled + renderType: Text.QtRendering + + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + placeholderTextColor: Appearance.m3colors.m3outline + + background: Rectangle { + implicitHeight: 56 + color: Appearance.m3colors.m3surface + topLeftRadius: 4 + topRightRadius: 4 + Rectangle { + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + } + height: 1 + color: root.focus ? Appearance.m3colors.m3primary : + root.hovered ? Appearance.m3colors.m3outline : Appearance.m3colors.m3outlineVariant + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + + font { + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } + wrapMode: TextEdit.Wrap +} diff --git a/ii/modules/common/widgets/MaterialTextField.qml b/ii/modules/common/widgets/MaterialTextField.qml new file mode 100644 index 0000000..f160a77 --- /dev/null +++ b/ii/modules/common/widgets/MaterialTextField.qml @@ -0,0 +1,39 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls.Material +import QtQuick.Controls + +/** + * Material 3 styled TextField (filled style) + * https://m3.material.io/components/text-fields/overview + * Note: We don't use NativeRendering because it makes the small placeholder text look weird + */ +TextField { + id: root + Material.theme: Material.System + Material.accent: Appearance.m3colors.m3primary + Material.primary: Appearance.m3colors.m3primary + Material.background: Appearance.m3colors.m3surface + Material.foreground: Appearance.m3colors.m3onSurface + Material.containerStyle: Material.Outlined + renderType: Text.QtRendering + + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + placeholderTextColor: Appearance.m3colors.m3outline + clip: true + + font { + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } + wrapMode: TextEdit.Wrap + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + hoverEnabled: true + cursorShape: Qt.IBeamCursor + } +} diff --git a/ii/modules/common/widgets/MenuButton.qml b/ii/modules/common/widgets/MenuButton.qml new file mode 100644 index 0000000..9185bc9 --- /dev/null +++ b/ii/modules/common/widgets/MenuButton.qml @@ -0,0 +1,26 @@ +import qs.modules.common +import QtQuick + +RippleButton { + id: root + + buttonRadius: 0 + implicitHeight: 36 + implicitWidth: buttonTextWidget.implicitWidth + 14 * 2 + + contentItem: StyledText { + id: buttonTextWidget + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 14 + text: root.buttonText + horizontalAlignment: Text.AlignLeft + font.pixelSize: Appearance.font.pixelSize.small + color: root.enabled ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3outline + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + +} diff --git a/ii/modules/common/widgets/NavigationRail.qml b/ii/modules/common/widgets/NavigationRail.qml new file mode 100644 index 0000000..11082a7 --- /dev/null +++ b/ii/modules/common/widgets/NavigationRail.qml @@ -0,0 +1,11 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +ColumnLayout { // Window content with navigation rail and content pane + id: root + property bool expanded: true + property int currentIndex: 0 + spacing: 5 +} diff --git a/ii/modules/common/widgets/NavigationRailButton.qml b/ii/modules/common/widgets/NavigationRailButton.qml new file mode 100644 index 0000000..9932911 --- /dev/null +++ b/ii/modules/common/widgets/NavigationRailButton.qml @@ -0,0 +1,150 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +TabButton { + id: root + + property bool toggled: TabBar.tabBar.currentIndex === TabBar.index + property string buttonIcon + property real buttonIconRotation: 0 + property string buttonText + property bool expanded: false + property bool showToggledHighlight: true + readonly property real visualWidth: root.expanded ? root.baseSize + 20 + itemText.implicitWidth : root.baseSize + + property real baseSize: 56 + property real baseHighlightHeight: 32 + property real highlightCollapsedTopMargin: 8 + padding: 0 + + // The navigation item’s target area always spans the full width of the + // nav rail, even if the item container hugs its contents. + Layout.fillWidth: true + // implicitWidth: contentItem.implicitWidth + implicitHeight: baseSize + + background: null + PointingHandInteraction {} + + // Real stuff + contentItem: Item { + id: buttonContent + anchors { + top: parent.top + bottom: parent.bottom + left: parent.left + right: undefined + } + + implicitWidth: root.visualWidth + implicitHeight: root.expanded ? itemIconBackground.implicitHeight : itemIconBackground.implicitHeight + itemText.implicitHeight + + Rectangle { + id: itemBackground + anchors.top: itemIconBackground.top + anchors.left: itemIconBackground.left + anchors.bottom: itemIconBackground.bottom + implicitWidth: root.visualWidth + radius: Appearance.rounding.full + color: toggled ? + root.showToggledHighlight ? + (root.down ? Appearance.colors.colSecondaryContainerActive : root.hovered ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colSecondaryContainer) + : ColorUtils.transparentize(Appearance.colors.colSecondaryContainer) : + (root.down ? Appearance.colors.colLayer1Active : root.hovered ? Appearance.colors.colLayer1Hover : ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1)) + + states: State { + name: "expanded" + when: root.expanded + AnchorChanges { + target: itemBackground + anchors.top: buttonContent.top + anchors.left: buttonContent.left + anchors.bottom: buttonContent.bottom + } + PropertyChanges { + target: itemBackground + implicitWidth: root.visualWidth + } + } + transitions: Transition { + AnchorAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + PropertyAnimation { + target: itemBackground + property: "implicitWidth" + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + + Item { + id: itemIconBackground + implicitWidth: root.baseSize + implicitHeight: root.baseHighlightHeight + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + } + MaterialSymbol { + id: navRailButtonIcon + rotation: root.buttonIconRotation + anchors.centerIn: parent + iconSize: 24 + fill: toggled ? 1 : 0 + font.weight: (toggled || root.hovered) ? Font.DemiBold : Font.Normal + text: buttonIcon + color: toggled ? Appearance.m3colors.m3onSecondaryContainer : Appearance.colors.colOnLayer1 + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + + StyledText { + id: itemText + anchors { + top: itemIconBackground.bottom + topMargin: 2 + horizontalCenter: itemIconBackground.horizontalCenter + } + states: State { + name: "expanded" + when: root.expanded + AnchorChanges { + target: itemText + anchors { + top: undefined + horizontalCenter: undefined + left: itemIconBackground.right + verticalCenter: itemIconBackground.verticalCenter + } + } + } + transitions: Transition { + AnchorAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + text: buttonText + font.pixelSize: 14 + color: Appearance.colors.colOnLayer1 + } + } + +} diff --git a/ii/modules/common/widgets/NavigationRailExpandButton.qml b/ii/modules/common/widgets/NavigationRailExpandButton.qml new file mode 100644 index 0000000..57e15f0 --- /dev/null +++ b/ii/modules/common/widgets/NavigationRailExpandButton.qml @@ -0,0 +1,30 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +RippleButton { + id: root + Layout.alignment: Qt.AlignLeft + implicitWidth: 40 + implicitHeight: 40 + Layout.leftMargin: 8 + onClicked: { + parent.expanded = !parent.expanded; + } + buttonRadius: Appearance.rounding.full + + rotation: root.parent.expanded ? 0 : -180 + Behavior on rotation { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + contentItem: MaterialSymbol { + id: icon + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: 24 + color: Appearance.colors.colOnLayer1 + text: root.parent.expanded ? "menu_open" : "menu" + } +} diff --git a/ii/modules/common/widgets/NavigationRailTabArray.qml b/ii/modules/common/widgets/NavigationRailTabArray.qml new file mode 100644 index 0000000..01d27ba --- /dev/null +++ b/ii/modules/common/widgets/NavigationRailTabArray.qml @@ -0,0 +1,42 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +Item { + id: root + property int currentIndex: 0 + property bool expanded: false + default property alias data: tabBarColumn.data + implicitHeight: tabBarColumn.implicitHeight + implicitWidth: tabBarColumn.implicitWidth + Layout.topMargin: 25 + + Rectangle { + property real itemHeight: tabBarColumn.children[0]?.baseSize ?? 56 + property real baseHighlightHeight: tabBarColumn.children[0]?.baseHighlightHeight ?? 56 + anchors { + top: tabBarColumn.top + left: tabBarColumn.left + topMargin: itemHeight * root.currentIndex + (root.expanded ? 0 : ((itemHeight - baseHighlightHeight) / 2)) + } + radius: Appearance.rounding.full + color: Appearance.colors.colSecondaryContainer + implicitHeight: root.expanded ? itemHeight : baseHighlightHeight + implicitWidth: tabBarColumn?.children[root.currentIndex]?.visualWidth ?? 100 + + Behavior on anchors.topMargin { + NumberAnimation { + duration: Appearance.animationCurves.expressiveFastSpatialDuration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial + } + } + } + + ColumnLayout { + id: tabBarColumn + anchors.fill: parent + spacing: 0 + } +} diff --git a/ii/modules/common/widgets/NotificationActionButton.qml b/ii/modules/common/widgets/NotificationActionButton.qml new file mode 100644 index 0000000..4d08296 --- /dev/null +++ b/ii/modules/common/widgets/NotificationActionButton.qml @@ -0,0 +1,24 @@ +import qs.modules.common +import qs.services +import QtQuick +import Quickshell.Services.Notifications + +RippleButton { + id: button + property string buttonText + property string urgency + + implicitHeight: 30 + leftPadding: 15 + rightPadding: 15 + buttonRadius: Appearance.rounding.small + colBackground: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainer : Appearance.colors.colLayer4 + colBackgroundHover: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colLayer4Hover + colRipple: (urgency == NotificationUrgency.Critical) ? Appearance.colors.colSecondaryContainerActive : Appearance.colors.colLayer4Active + + contentItem: StyledText { + horizontalAlignment: Text.AlignHCenter + text: buttonText + color: (urgency == NotificationUrgency.Critical) ? Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface + } +} \ No newline at end of file diff --git a/ii/modules/common/widgets/NotificationAppIcon.qml b/ii/modules/common/widgets/NotificationAppIcon.qml new file mode 100644 index 0000000..278691b --- /dev/null +++ b/ii/modules/common/widgets/NotificationAppIcon.qml @@ -0,0 +1,103 @@ +import qs.modules.common +import "./notification_utils.js" as NotificationUtils +import Qt5Compat.GraphicalEffects +import QtQuick +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Notifications + +Rectangle { // App icon + id: root + property var appIcon: "" + property var summary: "" + property var urgency: NotificationUrgency.Normal + property var image: "" + property real scale: 1 + property real size: 38 * scale + property real materialIconScale: 0.57 + property real appIconScale: 0.8 + property real smallAppIconScale: 0.49 + property real materialIconSize: size * materialIconScale + property real appIconSize: size * appIconScale + property real smallAppIconSize: size * smallAppIconScale + + implicitWidth: size + implicitHeight: size + radius: Appearance.rounding.full + color: Appearance.colors.colSecondaryContainer + Loader { + id: materialSymbolLoader + active: root.appIcon == "" + anchors.fill: parent + sourceComponent: MaterialSymbol { + text: { + const defaultIcon = NotificationUtils.findSuitableMaterialSymbol("") + const guessedIcon = NotificationUtils.findSuitableMaterialSymbol(root.summary) + return (root.urgency == NotificationUrgency.Critical && guessedIcon === defaultIcon) ? + "release_alert" : guessedIcon + } + anchors.fill: parent + color: (root.urgency == NotificationUrgency.Critical) ? + ColorUtils.mix(Appearance.m3colors.m3onSecondary, Appearance.m3colors.m3onSecondaryContainer, 0.1) : + Appearance.m3colors.m3onSecondaryContainer + iconSize: root.materialIconSize + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + Loader { + id: appIconLoader + active: root.image == "" && root.appIcon != "" + anchors.centerIn: parent + sourceComponent: IconImage { + id: appIconImage + implicitSize: root.appIconSize + asynchronous: true + source: Quickshell.iconPath(root.appIcon, "image-missing") + } + } + Loader { + id: notifImageLoader + active: root.image != "" + anchors.fill: parent + sourceComponent: Item { + anchors.fill: parent + Image { + id: notifImage + anchors.fill: parent + readonly property int size: parent.width + + source: root.image + fillMode: Image.PreserveAspectCrop + cache: false + antialiasing: true + asynchronous: true + + width: size + height: size + sourceSize.width: size + sourceSize.height: size + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: notifImage.size + height: notifImage.size + radius: Appearance.rounding.full + } + } + } + Loader { + id: notifImageAppIconLoader + active: root.appIcon != "" + anchors.bottom: parent.bottom + anchors.right: parent.right + sourceComponent: IconImage { + implicitSize: root.smallAppIconSize + asynchronous: true + source: Quickshell.iconPath(root.appIcon, "image-missing") + } + } + } + } +} \ No newline at end of file diff --git a/ii/modules/common/widgets/NotificationGroup.qml b/ii/modules/common/widgets/NotificationGroup.qml new file mode 100644 index 0000000..d4aa002 --- /dev/null +++ b/ii/modules/common/widgets/NotificationGroup.qml @@ -0,0 +1,253 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import "./notification_utils.js" as NotificationUtils +import QtQuick +import QtQuick.Layouts +import Quickshell + +/** + * A group of notifications from the same app. + * Similar to Android's notifications + */ +MouseArea { // Notification group area + id: root + property var notificationGroup + property var notifications: notificationGroup?.notifications ?? [] + property int notificationCount: notifications.length + property bool multipleNotifications: notificationCount > 1 + property bool expanded: false + property bool popup: false + property real padding: 10 + implicitHeight: background.implicitHeight + + property real dragConfirmThreshold: 70 // Drag further to discard notification + property real dismissOvershoot: 20 // Account for gaps and bouncy animations + property var qmlParent: root.parent.parent // There's something between this and the parent ListView + property var parentDragIndex: qmlParent.dragIndex + property var parentDragDistance: qmlParent.dragDistance + property var dragIndexDiff: Math.abs(parentDragIndex - index) + property real xOffset: dragIndexDiff == 0 ? Math.max(0, parentDragDistance) : + parentDragDistance > dragConfirmThreshold ? 0 : + dragIndexDiff == 1 ? Math.max(0, parentDragDistance * 0.3) : + dragIndexDiff == 2 ? Math.max(0, parentDragDistance * 0.1) : 0 + + function destroyWithAnimation() { + root.qmlParent.resetDrag() + background.anchors.leftMargin = background.anchors.leftMargin; // Break binding + destroyAnimation.running = true; + } + + hoverEnabled: true + onContainsMouseChanged: { + if (!root.popup) return; + if (root.containsMouse) root.notifications.forEach(notif => { + Notifications.cancelTimeout(notif.notificationId); + }); + else root.notifications.forEach(notif => { + Notifications.timeoutNotification(notif.notificationId); + }); + } + + SequentialAnimation { // Drag finish animation + id: destroyAnimation + running: false + + NumberAnimation { + target: background.anchors + property: "leftMargin" + to: root.width + root.dismissOvershoot + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + onFinished: () => { + root.notifications.forEach((notif) => { + Qt.callLater(() => { + Notifications.discardNotification(notif.notificationId); + }); + }); + } + } + + function toggleExpanded() { + if (expanded) implicitHeightAnim.enabled = true; + else implicitHeightAnim.enabled = false; + root.expanded = !root.expanded; + } + + DragManager { // Drag manager + id: dragManager + anchors.fill: parent + interactive: !expanded + automaticallyReset: false + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + + onClicked: (mouse) => { + if (mouse.button === Qt.RightButton) + root.toggleExpanded(); + else if (mouse.button === Qt.MiddleButton) + root.destroyWithAnimation(); + } + + onDraggingChanged: () => { + if (dragging) { + root.qmlParent.dragIndex = root.index ?? root.parent.children.indexOf(root); + } + } + + onDragDiffXChanged: () => { + root.qmlParent.dragDistance = dragDiffX; + } + + onDragReleased: (diffX, diffY) => { + if (diffX > root.dragConfirmThreshold) + root.destroyWithAnimation(); + else + dragManager.resetDrag(); + } + } + + StyledRectangularShadow { + target: background + visible: popup + } + Rectangle { // Background of the notification + id: background + anchors.left: parent.left + width: parent.width + color: popup ? ColorUtils.applyAlpha(Appearance.colors.colLayer2, 1 - Appearance.backgroundTransparency) : Appearance.colors.colLayer2 + radius: Appearance.rounding.normal + anchors.leftMargin: root.xOffset + + Behavior on anchors.leftMargin { + enabled: !dragManager.dragging + NumberAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial + } + } + + clip: true + implicitHeight: expanded ? + row.implicitHeight + padding * 2 : + Math.min(80, row.implicitHeight + padding * 2) + + Behavior on implicitHeight { + id: implicitHeightAnim + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + RowLayout { // Left column for icon, right column for content + id: row + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: root.padding + spacing: 10 + + NotificationAppIcon { // Icons + Layout.alignment: Qt.AlignTop + Layout.fillWidth: false + image: root?.multipleNotifications ? "" : notificationGroup?.notifications[0]?.image ?? "" + appIcon: notificationGroup?.appIcon + summary: notificationGroup?.notifications[root.notificationCount - 1]?.summary + } + + ColumnLayout { // Content + Layout.fillWidth: true + spacing: expanded ? (root.multipleNotifications ? + (notificationGroup?.notifications[root.notificationCount - 1].image != "") ? 35 : + 5 : 0) : 0 + // spacing: 00 + Behavior on spacing { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Item { // App name (or summary when there's only 1 notif) and time + id: topRow + // spacing: 0 + Layout.fillWidth: true + property real fontSize: Appearance.font.pixelSize.smaller + property bool showAppName: root.multipleNotifications + implicitHeight: Math.max(topTextRow.implicitHeight, expandButton.implicitHeight) + + RowLayout { + id: topTextRow + anchors.left: parent.left + anchors.right: expandButton.left + anchors.verticalCenter: parent.verticalCenter + spacing: 5 + StyledText { + id: appName + elide: Text.ElideRight + Layout.fillWidth: true + text: (topRow.showAppName ? + notificationGroup?.appName : + notificationGroup?.notifications[0]?.summary) || "" + font.pixelSize: topRow.showAppName ? + topRow.fontSize : + Appearance.font.pixelSize.small + color: topRow.showAppName ? + Appearance.colors.colSubtext : + Appearance.colors.colOnLayer2 + } + StyledText { + id: timeText + // Layout.fillWidth: true + Layout.rightMargin: 10 + horizontalAlignment: Text.AlignLeft + text: NotificationUtils.getFriendlyNotifTimeString(notificationGroup?.time) + font.pixelSize: topRow.fontSize + color: Appearance.colors.colSubtext + } + } + NotificationGroupExpandButton { + id: expandButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + count: root.notificationCount + expanded: root.expanded + fontSize: topRow.fontSize + onClicked: { root.toggleExpanded() } + altAction: () => { root.toggleExpanded() } + + StyledToolTip { + content: Translation.tr("Tip: right-clicking a group\nalso expands it") + } + } + } + + StyledListView { // Notification body (expanded) + id: notificationsColumn + implicitHeight: contentHeight + Layout.fillWidth: true + spacing: expanded ? 5 : 3 + // clip: true + interactive: false + Behavior on spacing { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + model: ScriptModel { + values: root.expanded ? root.notifications.slice().reverse() : + root.notifications.slice().reverse().slice(0, 2) + } + delegate: NotificationItem { + required property int index + required property var modelData + notificationObject: modelData + expanded: root.expanded + onlyNotification: (root.notificationCount === 1) + opacity: (!root.expanded && index == 1 && root.notificationCount > 2) ? 0.5 : 1 + visible: root.expanded || (index < 2) + anchors.left: parent?.left + anchors.right: parent?.right + } + } + + } + } + } +} diff --git a/ii/modules/common/widgets/NotificationGroupExpandButton.qml b/ii/modules/common/widgets/NotificationGroupExpandButton.qml new file mode 100644 index 0000000..aced621 --- /dev/null +++ b/ii/modules/common/widgets/NotificationGroupExpandButton.qml @@ -0,0 +1,47 @@ +import qs.services +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts + +RippleButton { // Expand button + id: root + required property int count + required property bool expanded + property real fontSize: Appearance?.font.pixelSize.small ?? 12 + property real iconSize: Appearance?.font.pixelSize.normal ?? 16 + implicitHeight: fontSize + 4 * 2 + implicitWidth: Math.max(contentItem.implicitWidth + 5 * 2, 30) + Layout.alignment: Qt.AlignVCenter + Layout.fillHeight: false + + buttonRadius: Appearance.rounding.full + colBackground: ColorUtils.mix(Appearance?.colors.colLayer2, Appearance?.colors.colLayer2Hover, 0.5) + colBackgroundHover: Appearance?.colors.colLayer2Hover ?? "#E5DFED" + colRipple: Appearance?.colors.colLayer2Active ?? "#D6CEE2" + + contentItem: Item { + anchors.centerIn: parent + implicitWidth: contentRow.implicitWidth + RowLayout { + id: contentRow + anchors.centerIn: parent + spacing: 3 + StyledText { + Layout.leftMargin: 4 + visible: root.count > 1 + text: root.count + font.pixelSize: root.fontSize + } + MaterialSymbol { + text: "keyboard_arrow_down" + iconSize: root.iconSize + color: Appearance.colors.colOnLayer2 + rotation: expanded ? 180 : 0 + Behavior on rotation { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + } + } +} diff --git a/ii/modules/common/widgets/NotificationItem.qml b/ii/modules/common/widgets/NotificationItem.qml new file mode 100644 index 0000000..82367db --- /dev/null +++ b/ii/modules/common/widgets/NotificationItem.qml @@ -0,0 +1,313 @@ +import qs +import qs.modules.common +import qs.services +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Services.Notifications + +Item { // Notification item area + id: root + property var notificationObject + property bool expanded: false + property bool onlyNotification: false + property real fontSize: Appearance.font.pixelSize.small + property real padding: onlyNotification ? 0 : 8 + + property real dragConfirmThreshold: 70 // Drag further to discard notification + property real dismissOvershoot: notificationIcon.implicitWidth + 20 // Account for gaps and bouncy animations + property var qmlParent: root?.parent?.parent // There's something between this and the parent ListView + property var parentDragIndex: qmlParent?.dragIndex ?? -1 + property var parentDragDistance: qmlParent?.dragDistance ?? 0 + property var dragIndexDiff: Math.abs(parentDragIndex - index) + property real xOffset: dragIndexDiff == 0 ? Math.max(0, parentDragDistance) : + parentDragDistance > dragConfirmThreshold ? 0 : + dragIndexDiff == 1 ? Math.max(0, parentDragDistance * 0.3) : + dragIndexDiff == 2 ? Math.max(0, parentDragDistance * 0.1) : 0 + + implicitHeight: background.implicitHeight + + function processNotificationBody(body, appName) { + let processedBody = body + + // Clean Chromium-based browsers notifications - remove first line + if (appName) { + const lowerApp = appName.toLowerCase() + const chromiumBrowsers = [ + "brave", "chrome", "chromium", "vivaldi", "opera", "microsoft edge" + ] + + if (chromiumBrowsers.some(name => lowerApp.includes(name))) { + const lines = body.split('\n\n') + + if (lines.length > 1 && lines[0].startsWith(' { + Notifications.discardNotification(notificationObject.notificationId); + } + } + + DragManager { // Drag manager + id: dragManager + anchors.fill: root + anchors.leftMargin: root.expanded ? -notificationIcon.implicitWidth : 0 + interactive: expanded + automaticallyReset: false + acceptedButtons: Qt.LeftButton | Qt.MiddleButton + + onClicked: (mouse) => { + if (mouse.button === Qt.MiddleButton) { + root.destroyWithAnimation(); + } + } + + onDraggingChanged: () => { + if (dragging) { + root.qmlParent.dragIndex = root.index ?? root.parent.children.indexOf(root); + } + } + + onDragDiffXChanged: () => { + root.qmlParent.dragDistance = dragDiffX; + } + + onDragReleased: (diffX, diffY) => { + if (diffX > root.dragConfirmThreshold) + root.destroyWithAnimation(); + else + dragManager.resetDrag(); + } + } + + NotificationAppIcon { // App icon + id: notificationIcon + opacity: (!onlyNotification && notificationObject.image != "" && expanded) ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + image: notificationObject.image + anchors.right: background.left + anchors.top: background.top + anchors.rightMargin: 10 + } + + Rectangle { // Background of notification item + id: background + width: parent.width + anchors.left: parent.left + radius: Appearance.rounding.small + anchors.leftMargin: root.xOffset + + Behavior on anchors.leftMargin { + enabled: !dragManager.dragging + NumberAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial + } + } + + color: (expanded && !onlyNotification) ? + (notificationObject.urgency == NotificationUrgency.Critical) ? + ColorUtils.mix(Appearance.colors.colSecondaryContainer, Appearance.colors.colLayer2, 0.35) : + (Appearance.colors.colLayer3) : + ColorUtils.transparentize(Appearance.colors.colLayer3) + + implicitHeight: expanded ? (contentColumn.implicitHeight + padding * 2) : summaryRow.implicitHeight + Behavior on implicitHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + ColumnLayout { // Content column + id: contentColumn + anchors.fill: parent + anchors.margins: expanded ? root.padding : 0 + spacing: 3 + + Behavior on anchors.margins { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + RowLayout { // Summary row + id: summaryRow + visible: !root.onlyNotification || !root.expanded + Layout.fillWidth: true + implicitHeight: summaryText.implicitHeight + // Layout.fillWidth: true + StyledText { + id: summaryText + visible: !root.onlyNotification + font.pixelSize: root.fontSize + color: Appearance.colors.colOnLayer3 + elide: Text.ElideRight + text: root.notificationObject.summary || "" + } + StyledText { + opacity: !root.expanded ? 1 : 0 + visible: opacity > 0 + Layout.fillWidth: true + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + font.pixelSize: root.fontSize + color: Appearance.colors.colSubtext + elide: Text.ElideRight + wrapMode: Text.Wrap // Needed for proper eliding???? + maximumLineCount: 1 + textFormat: Text.StyledText + text: { + return processNotificationBody(notificationObject.body, notificationObject.appName || notificationObject.summary).replace(/\n/g, "
") + } + } + } + + ColumnLayout { // Expanded content + Layout.fillWidth: true + opacity: root.expanded ? 1 : 0 + visible: opacity > 0 + + StyledText { // Notification body (expanded) + id: notificationBodyText + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Layout.fillWidth: true + font.pixelSize: root.fontSize + color: Appearance.colors.colSubtext + wrapMode: Text.Wrap + elide: Text.ElideRight + textFormat: Text.RichText + text: { + return `` + + `${processNotificationBody(notificationObject.body, notificationObject.appName || notificationObject.summary).replace(/\n/g, "
")}` + } + + onLinkActivated: (link) => { + Qt.openUrlExternally(link) + GlobalStates.sidebarRightOpen = false + } + + PointingHandLinkHover {} + } + + StyledFlickable { // Notification actions + id: actionsFlickable + Layout.fillWidth: true + implicitHeight: actionRowLayout.implicitHeight + contentWidth: actionRowLayout.implicitWidth + clip: !onlyNotification + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + RowLayout { + id: actionRowLayout + Layout.alignment: Qt.AlignBottom + + NotificationActionButton { + Layout.fillWidth: true + buttonText: Translation.tr("Close") + urgency: notificationObject.urgency + implicitWidth: (notificationObject.actions.length == 0) ? ((actionsFlickable.width - actionRowLayout.spacing) / 2) : + (contentItem.implicitWidth + leftPadding + rightPadding) + + onClicked: { + root.destroyWithAnimation() + } + + contentItem: MaterialSymbol { + iconSize: Appearance.font.pixelSize.large + horizontalAlignment: Text.AlignHCenter + color: (notificationObject.urgency == NotificationUrgency.Critical) ? + Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface + text: "close" + } + } + + Repeater { + id: actionRepeater + model: notificationObject.actions + NotificationActionButton { + Layout.fillWidth: true + buttonText: modelData.text + urgency: notificationObject.urgency + onClicked: { + Notifications.attemptInvokeAction(notificationObject.notificationId, modelData.identifier); + } + } + } + + NotificationActionButton { + Layout.fillWidth: true + urgency: notificationObject.urgency + implicitWidth: (notificationObject.actions.length == 0) ? ((actionsFlickable.width - actionRowLayout.spacing) / 2) : + (contentItem.implicitWidth + leftPadding + rightPadding) + + onClicked: { + Quickshell.clipboardText = notificationObject.body + copyIcon.text = "inventory" + copyIconTimer.restart() + } + + Timer { + id: copyIconTimer + interval: 1500 + repeat: false + onTriggered: { + copyIcon.text = "content_copy" + } + } + + contentItem: MaterialSymbol { + id: copyIcon + iconSize: Appearance.font.pixelSize.large + horizontalAlignment: Text.AlignHCenter + color: (notificationObject.urgency == NotificationUrgency.Critical) ? + Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface + text: "content_copy" + } + } + + } + } + } + } + } +} diff --git a/ii/modules/common/widgets/NotificationListView.qml b/ii/modules/common/widgets/NotificationListView.qml new file mode 100644 index 0000000..389a5a8 --- /dev/null +++ b/ii/modules/common/widgets/NotificationListView.qml @@ -0,0 +1,27 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import Quickshell + +StyledListView { // Scrollable window + id: root + property bool popup: false + + spacing: 3 + + model: ScriptModel { + values: root.popup ? Notifications.popupAppNameList : Notifications.appNameList + } + delegate: NotificationGroup { + required property int index + required property var modelData + popup: root.popup + anchors.left: parent?.left + anchors.right: parent?.right + notificationGroup: popup ? + Notifications.popupGroupsByAppName[modelData] : + Notifications.groupsByAppName[modelData] + } +} \ No newline at end of file diff --git a/ii/modules/common/widgets/OptionalMaterialSymbol.qml b/ii/modules/common/widgets/OptionalMaterialSymbol.qml new file mode 100644 index 0000000..196a15a --- /dev/null +++ b/ii/modules/common/widgets/OptionalMaterialSymbol.qml @@ -0,0 +1,27 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +Loader { + id: root + required property string icon + property real iconSize: Appearance.font.pixelSize.larger + Layout.alignment: Qt.AlignVCenter + + active: root.icon && root.icon.length > 0 + visible: active + + sourceComponent: Item { + implicitWidth: materialSymbol.implicitWidth + + MaterialSymbol { + id: materialSymbol + anchors.centerIn: parent + + iconSize: root.iconSize + color: root.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer + text: root.icon + } + } +} diff --git a/ii/modules/common/widgets/PointingHandInteraction.qml b/ii/modules/common/widgets/PointingHandInteraction.qml new file mode 100644 index 0000000..a626bb9 --- /dev/null +++ b/ii/modules/common/widgets/PointingHandInteraction.qml @@ -0,0 +1,7 @@ +import QtQuick + +MouseArea { + anchors.fill: parent + onPressed: (mouse) => mouse.accepted = false + cursorShape: Qt.PointingHandCursor +} \ No newline at end of file diff --git a/ii/modules/common/widgets/PointingHandLinkHover.qml b/ii/modules/common/widgets/PointingHandLinkHover.qml new file mode 100644 index 0000000..4d14c81 --- /dev/null +++ b/ii/modules/common/widgets/PointingHandLinkHover.qml @@ -0,0 +1,8 @@ +import QtQuick + +MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton // Only for hover + hoverEnabled: true + cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor +} diff --git a/ii/modules/common/widgets/PrimaryTabBar.qml b/ii/modules/common/widgets/PrimaryTabBar.qml new file mode 100644 index 0000000..63f5e17 --- /dev/null +++ b/ii/modules/common/widgets/PrimaryTabBar.qml @@ -0,0 +1,97 @@ +import qs.modules.common +import qs +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ColumnLayout { + id: root + spacing: 0 + required property var tabButtonList // Something like [{"icon": "notifications", "name": Translation.tr("Notifications")}, {"icon": "volume_up", "name": Translation.tr("Volume mixer")}] + required property var externalTrackedTab + property bool enableIndicatorAnimation: false + property color colIndicator: Appearance?.colors.colPrimary ?? "#65558F" + property color colBorder: Appearance?.m3colors.m3outlineVariant ?? "#C6C6D0" + signal currentIndexChanged(int index) + + property bool centerTabBar: parent.width > 500 + Layout.fillWidth: !centerTabBar + Layout.alignment: Qt.AlignHCenter + implicitWidth: Math.max(tabBar.implicitWidth, 600) + + TabBar { + id: tabBar + Layout.fillWidth: true + currentIndex: root.externalTrackedTab + onCurrentIndexChanged: { + root.onCurrentIndexChanged(currentIndex) + } + + background: Item { + WheelHandler { + onWheel: (event) => { + if (event.angleDelta.y < 0) + tabBar.currentIndex = Math.min(tabBar.currentIndex + 1, root.tabButtonList.length - 1) + else if (event.angleDelta.y > 0) + tabBar.currentIndex = Math.max(tabBar.currentIndex - 1, 0) + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + } + + Repeater { + model: root.tabButtonList + delegate: PrimaryTabButton { + selected: (index == root.externalTrackedTab) + buttonText: modelData.name + buttonIcon: modelData.icon + minimumWidth: 160 + } + } + } + + Item { // Tab indicator + id: tabIndicator + Layout.fillWidth: true + height: 3 + Connections { + target: root + function onExternalTrackedTabChanged() { + root.enableIndicatorAnimation = true + } + } + + Rectangle { + id: indicator + property int tabCount: root.tabButtonList.length + property real fullTabSize: root.width / tabCount; + property real targetWidth: tabBar.contentItem?.children[0]?.children[tabBar.currentIndex]?.tabContentWidth ?? 0 + + implicitWidth: targetWidth + anchors { + top: parent.top + bottom: parent.bottom + } + + x: tabBar.currentIndex * fullTabSize + (fullTabSize - targetWidth) / 2 + + color: root.colIndicator + radius: Appearance?.rounding.full ?? 9999 + + Behavior on x { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + + Behavior on implicitWidth { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + } + } + + Rectangle { // Tabbar bottom border + id: tabBarBottomBorder + Layout.fillWidth: true + implicitHeight: 1 + color: root.colBorder + } +} diff --git a/ii/modules/common/widgets/PrimaryTabButton.qml b/ii/modules/common/widgets/PrimaryTabButton.qml new file mode 100644 index 0000000..6c68732 --- /dev/null +++ b/ii/modules/common/widgets/PrimaryTabButton.qml @@ -0,0 +1,172 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +TabButton { + id: button + property string buttonText + property string buttonIcon + property real minimumWidth: 110 + property bool selected: false + property int tabContentWidth: contentItem.children[0].implicitWidth + property int rippleDuration: 1200 + height: buttonBackground.height + implicitWidth: Math.max(tabContentWidth, buttonBackground.implicitWidth, minimumWidth) + + property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 1) || "transparent" + property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED" + property color colRipple: Appearance?.colors.colLayer1Active ?? "#D6CEE2" + property color colActive: Appearance?.colors.colPrimary ?? "#65558F" + property color colInactive: Appearance?.colors.colOnLayer1 ?? "#45464F" + + component RippleAnim: NumberAnimation { + duration: rippleDuration + easing.type: Appearance?.animation.elementMoveEnter.type + easing.bezierCurve: Appearance?.animationCurves.standardDecel + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onPressed: (event) => { + const {x,y} = event + const stateY = buttonBackground.y; + rippleAnim.x = x; + rippleAnim.y = y - stateY; + + const dist = (ox,oy) => ox*ox + oy*oy + const stateEndY = stateY + buttonBackground.height + rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY))) + + rippleFadeAnim.complete(); + rippleAnim.restart(); + } + onReleased: (event) => { + button.click() // Because the MouseArea already consumed the event + rippleFadeAnim.restart(); + } + } + + RippleAnim { + id: rippleFadeAnim + duration: rippleDuration * 2 + target: ripple + property: "opacity" + to: 0 + } + + SequentialAnimation { + id: rippleAnim + + property real x + property real y + property real radius + + PropertyAction { + target: ripple + property: "x" + value: rippleAnim.x + } + PropertyAction { + target: ripple + property: "y" + value: rippleAnim.y + } + PropertyAction { + target: ripple + property: "opacity" + value: 1 + } + ParallelAnimation { + RippleAnim { + target: ripple + properties: "implicitWidth,implicitHeight" + from: 0 + to: rippleAnim.radius * 2 + } + } + } + + background: Rectangle { + id: buttonBackground + radius: Appearance?.rounding.small + implicitHeight: 50 + color: (button.hovered ? button.colBackgroundHover : button.colBackground) + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: buttonBackground.width + height: buttonBackground.height + radius: buttonBackground.radius + } + } + + Behavior on color { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + + Item { + id: ripple + width: ripple.implicitWidth + height: ripple.implicitHeight + opacity: 0 + + property real implicitWidth: 0 + property real implicitHeight: 0 + visible: width > 0 && height > 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + + RadialGradient { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: button.colRipple } + GradientStop { position: 0.3; color: button.colRipple } + GradientStop { position: 0.5 ; color: Qt.rgba(button.colRipple.r, button.colRipple.g, button.colRipple.b, 0) } + } + } + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } + + contentItem: Item { + anchors.centerIn: buttonBackground + ColumnLayout { + anchors.centerIn: parent + spacing: 0 + MaterialSymbol { + visible: buttonIcon?.length > 0 + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + text: buttonIcon + iconSize: Appearance?.font.pixelSize.hugeass ?? 25 + fill: selected ? 1 : 0 + color: selected ? button.colActive : button.colInactive + Behavior on color { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + StyledText { + id: buttonTextWidget + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance?.font.pixelSize.small + color: selected ? button.colActive : button.colInactive + text: buttonText + Behavior on color { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + } +} \ No newline at end of file diff --git a/ii/modules/common/widgets/Revealer.qml b/ii/modules/common/widgets/Revealer.qml new file mode 100644 index 0000000..bbbe2ef --- /dev/null +++ b/ii/modules/common/widgets/Revealer.qml @@ -0,0 +1,25 @@ +import qs.modules.common +import QtQuick + +/** + * Recreation of GTK revealer. Expects one single child. + */ +Item { + id: root + property bool reveal + property bool vertical: false + clip: true + + implicitWidth: (reveal || vertical) ? childrenRect.width : 0 + implicitHeight: (reveal || !vertical) ? childrenRect.height : 0 + visible: reveal || (width > 0 && height > 0) + + Behavior on implicitWidth { + enabled: !vertical + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + enabled: vertical + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } +} diff --git a/ii/modules/common/widgets/RippleButton.qml b/ii/modules/common/widgets/RippleButton.qml new file mode 100644 index 0000000..07e6c53 --- /dev/null +++ b/ii/modules/common/widgets/RippleButton.qml @@ -0,0 +1,186 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls + +/** + * A button with ripple effect similar to in Material Design. + */ +Button { + id: root + property bool toggled + property string buttonText + property bool pointingHandCursor: true + property real buttonRadius: Appearance?.rounding?.small ?? 4 + property real buttonRadiusPressed: buttonRadius + property real buttonEffectiveRadius: root.down ? root.buttonRadiusPressed : root.buttonRadius + property int rippleDuration: 1200 + property bool rippleEnabled: true + property var downAction // When left clicking (down) + property var releaseAction // When left clicking (release) + property var altAction // When right clicking + property var middleClickAction // When middle clicking + + property color colBackground: ColorUtils.transparentize(Appearance?.colors.colLayer1Hover, 1) || "transparent" + property color colBackgroundHover: Appearance?.colors.colLayer1Hover ?? "#E5DFED" + property color colBackgroundToggled: Appearance?.colors.colPrimary ?? "#65558F" + property color colBackgroundToggledHover: Appearance?.colors.colPrimaryHover ?? "#77699C" + property color colRipple: Appearance?.colors.colLayer1Active ?? "#D6CEE2" + property color colRippleToggled: Appearance?.colors.colPrimaryActive ?? "#D6CEE2" + + opacity: root.enabled ? 1 : 0.4 + property color buttonColor: ColorUtils.transparentize(root.toggled ? + (root.hovered ? colBackgroundToggledHover : + colBackgroundToggled) : + (root.hovered ? colBackgroundHover : + colBackground), root.enabled ? 0 : 1) + property color rippleColor: root.toggled ? colRippleToggled : colRipple + + function startRipple(x, y) { + const stateY = buttonBackground.y; + rippleAnim.x = x; + rippleAnim.y = y - stateY; + + const dist = (ox,oy) => ox*ox + oy*oy + const stateEndY = stateY + buttonBackground.height + rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY))) + + rippleFadeAnim.complete(); + rippleAnim.restart(); + } + + component RippleAnim: NumberAnimation { + duration: rippleDuration + easing.type: Appearance?.animation.elementMoveEnter.type + easing.bezierCurve: Appearance?.animationCurves.standardDecel + } + + MouseArea { + anchors.fill: parent + cursorShape: root.pointingHandCursor ? Qt.PointingHandCursor : Qt.ArrowCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onPressed: (event) => { + if(event.button === Qt.RightButton) { + if (root.altAction) root.altAction(); + return; + } + if(event.button === Qt.MiddleButton) { + if (root.middleClickAction) root.middleClickAction(); + return; + } + root.down = true + if (root.downAction) root.downAction(); + if (!root.rippleEnabled) return; + const {x,y} = event + startRipple(x, y) + } + onReleased: (event) => { + root.down = false + if (event.button != Qt.LeftButton) return; + if (root.releaseAction) root.releaseAction(); + root.click() // Because the MouseArea already consumed the event + if (!root.rippleEnabled) return; + rippleFadeAnim.restart(); + } + onCanceled: (event) => { + root.down = false + if (!root.rippleEnabled) return; + rippleFadeAnim.restart(); + } + } + + RippleAnim { + id: rippleFadeAnim + duration: rippleDuration * 2 + target: ripple + property: "opacity" + to: 0 + } + + SequentialAnimation { + id: rippleAnim + + property real x + property real y + property real radius + + PropertyAction { + target: ripple + property: "x" + value: rippleAnim.x + } + PropertyAction { + target: ripple + property: "y" + value: rippleAnim.y + } + PropertyAction { + target: ripple + property: "opacity" + value: 1 + } + ParallelAnimation { + RippleAnim { + target: ripple + properties: "implicitWidth,implicitHeight" + from: 0 + to: rippleAnim.radius * 2 + } + } + } + + background: Rectangle { + id: buttonBackground + radius: root.buttonEffectiveRadius + implicitHeight: 30 + + color: root.buttonColor + Behavior on color { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: buttonBackground.width + height: buttonBackground.height + radius: root.buttonEffectiveRadius + } + } + + Item { + id: ripple + width: ripple.implicitWidth + height: ripple.implicitHeight + opacity: 0 + visible: width > 0 && height > 0 + + property real implicitWidth: 0 + property real implicitHeight: 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + + RadialGradient { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: root.rippleColor } + GradientStop { position: 0.3; color: root.rippleColor } + GradientStop { position: 0.5; color: Qt.rgba(root.rippleColor.r, root.rippleColor.g, root.rippleColor.b, 0) } + } + } + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } + + contentItem: StyledText { + text: root.buttonText + } +} diff --git a/ii/modules/common/widgets/RippleButtonWithIcon.qml b/ii/modules/common/widgets/RippleButtonWithIcon.qml new file mode 100644 index 0000000..21f3ccf --- /dev/null +++ b/ii/modules/common/widgets/RippleButtonWithIcon.qml @@ -0,0 +1,57 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +RippleButton { + id: buttonWithIconRoot + property string nerdIcon + property string materialIcon + property bool materialIconFill: true + property string mainText: "Button text" + property Component mainContentComponent: Component { + StyledText { + text: buttonWithIconRoot.mainText + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSecondaryContainer + } + } + implicitHeight: 35 + horizontalPadding: 10 + buttonRadius: Appearance.rounding.small + colBackground: Appearance.colors.colLayer2 + + contentItem: RowLayout { + Item { + Layout.fillWidth: false + implicitWidth: Math.max(materialIconLoader.implicitWidth, nerdIconLoader.implicitWidth) + Loader { + id: materialIconLoader + anchors.centerIn: parent + active: !nerdIcon + sourceComponent: MaterialSymbol { + text: buttonWithIconRoot.materialIcon + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnSecondaryContainer + fill: buttonWithIconRoot.materialIconFill ? 1 : 0 + } + } + Loader { + id: nerdIconLoader + anchors.centerIn: parent + active: nerdIcon + sourceComponent: StyledText { + text: buttonWithIconRoot.nerdIcon + font.pixelSize: Appearance.font.pixelSize.larger + font.family: Appearance.font.family.iconNerd + color: Appearance.colors.colOnSecondaryContainer + } + } + } + Loader { + Layout.fillWidth: true + sourceComponent: buttonWithIconRoot.mainContentComponent + Layout.alignment: Qt.AlignVCenter + } + } +} diff --git a/ii/modules/common/widgets/RoundCorner.qml b/ii/modules/common/widgets/RoundCorner.qml new file mode 100644 index 0000000..4dbbb56 --- /dev/null +++ b/ii/modules/common/widgets/RoundCorner.qml @@ -0,0 +1,79 @@ +import QtQuick +import QtQuick.Shapes + +Item { + id: root + + enum CornerEnum { TopLeft, TopRight, BottomLeft, BottomRight } + property var corner: RoundCorner.CornerEnum.TopLeft + + property int implicitSize: 25 + property color color: "#000000" + + implicitWidth: implicitSize + implicitHeight: implicitSize + + property bool isTopLeft: corner === RoundCorner.CornerEnum.TopLeft + property bool isBottomLeft: corner === RoundCorner.CornerEnum.BottomLeft + property bool isTopRight: corner === RoundCorner.CornerEnum.TopRight + property bool isBottomRight: corner === RoundCorner.CornerEnum.BottomRight + property bool isTop: isTopLeft || isTopRight + property bool isBottom: isBottomLeft || isBottomRight + property bool isLeft: isTopLeft || isBottomLeft + property bool isRight: isTopRight || isBottomRight + + Shape { + anchors { + top: root.isTop ? parent.top : undefined + bottom: root.isBottom ? parent.bottom : undefined + left: root.isLeft ? parent.left : undefined + right: root.isRight ? parent.right : undefined + } + layer.enabled: true + layer.smooth: true + preferredRendererType: Shape.CurveRenderer + + ShapePath { + id: shapePath + strokeWidth: 0 + fillColor: root.color + pathHints: ShapePath.PathSolid & ShapePath.PathNonIntersecting + + startX: switch (root.corner) { + case RoundCorner.CornerEnum.TopLeft: return 0; + case RoundCorner.CornerEnum.TopRight: return root.implicitSize; + case RoundCorner.CornerEnum.BottomLeft: return 0; + case RoundCorner.CornerEnum.BottomRight: return root.implicitSize; + } + startY: switch (root.corner) { + case RoundCorner.CornerEnum.TopLeft: return 0; + case RoundCorner.CornerEnum.TopRight: return 0; + case RoundCorner.CornerEnum.BottomLeft: return root.implicitSize; + case RoundCorner.CornerEnum.BottomRight: return root.implicitSize; + } + PathAngleArc { + moveToStart: false + centerX: root.implicitSize - shapePath.startX + centerY: root.implicitSize - shapePath.startY + radiusX: root.implicitSize + radiusY: root.implicitSize + startAngle: switch (root.corner) { + case RoundCorner.CornerEnum.TopLeft: return 180; + case RoundCorner.CornerEnum.TopRight: return -90; + case RoundCorner.CornerEnum.BottomLeft: return 90; + case RoundCorner.CornerEnum.BottomRight: return 0; + } + sweepAngle: 90 + } + PathLine { + x: shapePath.startX + y: shapePath.startY + } + } + } + + Behavior on implicitSize { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + +} diff --git a/ii/modules/common/widgets/SecondaryTabButton.qml b/ii/modules/common/widgets/SecondaryTabButton.qml new file mode 100644 index 0000000..774da7d --- /dev/null +++ b/ii/modules/common/widgets/SecondaryTabButton.qml @@ -0,0 +1,178 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +TabButton { + id: root + property string buttonText + property string buttonIcon + property bool selected: false + property int rippleDuration: 1200 + height: buttonBackground.height + property int tabContentWidth: buttonBackground.width - buttonBackground.radius*2 + + property color colBackground: ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1) + property color colBackgroundHover: Appearance.colors.colLayer1Hover + property color colRipple: Appearance.colors.colLayer1Active + + PointingHandInteraction {} + + component RippleAnim: NumberAnimation { + duration: rippleDuration + easing.type: Appearance.animation.elementMoveEnter.type + easing.bezierCurve: Appearance.animationCurves.standardDecel + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onPressed: (event) => { + const {x,y} = event + const stateY = buttonBackground.y; + rippleAnim.x = x; + rippleAnim.y = y - stateY; + + const dist = (ox,oy) => ox*ox + oy*oy + const stateEndY = stateY + buttonBackground.height + rippleAnim.radius = Math.sqrt(Math.max(dist(0, stateY), dist(0, stateEndY), dist(width, stateY), dist(width, stateEndY))) + + rippleFadeAnim.complete(); + rippleAnim.restart(); + } + onReleased: (event) => { + root.click() // Because the MouseArea already consumed the event + rippleFadeAnim.restart(); + } + } + + RippleAnim { + id: rippleFadeAnim + duration: rippleDuration * 2 + target: ripple + property: "opacity" + to: 0 + } + + SequentialAnimation { + id: rippleAnim + + property real x + property real y + property real radius + + PropertyAction { + target: ripple + property: "x" + value: rippleAnim.x + } + PropertyAction { + target: ripple + property: "y" + value: rippleAnim.y + } + PropertyAction { + target: ripple + property: "opacity" + value: 1 + } + ParallelAnimation { + RippleAnim { + target: ripple + properties: "implicitWidth,implicitHeight" + from: 0 + to: rippleAnim.radius * 2 + } + } + } + + background: Rectangle { + id: buttonBackground + radius: Appearance?.rounding.small ?? 7 + implicitHeight: 37 + color: (root.hovered ? root.colBackgroundHover : root.colBackground) + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: buttonBackground.width + height: buttonBackground.height + radius: buttonBackground.radius + } + } + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + Item { + id: ripple + width: ripple.implicitWidth + height: ripple.implicitHeight + opacity: 0 + + property real implicitWidth: 0 + property real implicitHeight: 0 + visible: width > 0 && height > 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.colorAnimation.createObject(this) + } + + RadialGradient { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: root.colRipple } + GradientStop { position: 0.3; color: root.colRipple } + GradientStop { position: 0.5 ; color: Qt.rgba(root.colRipple.r, root.colRipple.g, root.colRipple.b, 0) } + } + } + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } + + contentItem: Item { + anchors.centerIn: buttonBackground + RowLayout { + anchors.centerIn: parent + spacing: 0 + + Loader { + id: iconLoader + active: buttonIcon?.length > 0 + sourceComponent: buttonIcon?.length > 0 ? materialSymbolComponent : null + Layout.rightMargin: 5 + } + + Component { + id: materialSymbolComponent + MaterialSymbol { + verticalAlignment: Text.AlignVCenter + text: buttonIcon + iconSize: Appearance.font.pixelSize.huge + fill: selected ? 1 : 0 + color: selected ? Appearance.colors.colPrimary : Appearance.colors.colOnLayer1 + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + StyledText { + id: buttonTextWidget + verticalAlignment: Text.AlignVCenter + font.pixelSize: Appearance.font.pixelSize.small + color: selected ? Appearance.colors.colPrimary : Appearance.colors.colOnLayer1 + text: buttonText + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + } +} \ No newline at end of file diff --git a/ii/modules/common/widgets/SelectionDialog.qml b/ii/modules/common/widgets/SelectionDialog.qml new file mode 100644 index 0000000..6044cbf --- /dev/null +++ b/ii/modules/common/widgets/SelectionDialog.qml @@ -0,0 +1,129 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs +import QtQuick +import QtQuick.Layouts +import Quickshell + +Item { + id: root + property real dialogPadding: 15 + property real dialogMargin: 30 + property string titleText: "Selection Dialog" + property alias items: choiceModel.values + property int selectedId: choiceListView.currentIndex + property var defaultChoice + + signal canceled(); + signal selected(var result); + + Rectangle { // Scrim + id: scrimOverlay + anchors.fill: parent + radius: Appearance.rounding.small + color: Appearance.colors.colScrim + MouseArea { + hoverEnabled: true + anchors.fill: parent + preventStealing: true + propagateComposedEvents: false + } + } + + Rectangle { // The dialog + id: dialog + color: Appearance.colors.colSurfaceContainerHigh + radius: Appearance.rounding.normal + anchors.fill: parent + anchors.margins: dialogMargin + implicitHeight: dialogColumnLayout.implicitHeight + + ColumnLayout { + id: dialogColumnLayout + anchors.fill: parent + spacing: 16 + + StyledText { + id: dialogTitle + Layout.topMargin: dialogPadding + Layout.leftMargin: dialogPadding + Layout.rightMargin: dialogPadding + Layout.alignment: Qt.AlignLeft + color: Appearance.m3colors.m3onSurface + font.pixelSize: Appearance.font.pixelSize.larger + text: root.titleText + } + + Rectangle { + color: Appearance.m3colors.m3outline + implicitHeight: 1 + Layout.fillWidth: true + Layout.leftMargin: dialogPadding + Layout.rightMargin: dialogPadding + } + + StyledListView { + id: choiceListView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + currentIndex: root.defaultChoice !== undefined ? root.items.indexOf(root.defaultChoice) : -1 + spacing: 6 + + model: ScriptModel { + id: choiceModel + } + + delegate: StyledRadioButton { + id: radioButton + required property var modelData + required property int index + anchors { + left: parent?.left + right: parent?.right + leftMargin: root.dialogPadding + rightMargin: root.dialogPadding + } + + description: modelData.toString() + checked: index === choiceListView.currentIndex + + onCheckedChanged: { + if (checked) { + choiceListView.currentIndex = index; + } + } + } + } + + Rectangle { + color: Appearance.m3colors.m3outline + implicitHeight: 1 + Layout.fillWidth: true + Layout.leftMargin: dialogPadding + Layout.rightMargin: dialogPadding + } + + RowLayout { + id: dialogButtonsRowLayout + Layout.bottomMargin: dialogPadding + Layout.leftMargin: dialogPadding + Layout.rightMargin: dialogPadding + Layout.alignment: Qt.AlignRight + + DialogButton { + buttonText: Translation.tr("Cancel") + onClicked: root.canceled() + } + DialogButton { + buttonText: Translation.tr("OK") + onClicked: root.selected( + root.selectedId === -1 ? null : + root.items[root.selectedId] + ) + } + } + } + } +} diff --git a/ii/modules/common/widgets/SelectionGroupButton.qml b/ii/modules/common/widgets/SelectionGroupButton.qml new file mode 100644 index 0000000..1781389 --- /dev/null +++ b/ii/modules/common/widgets/SelectionGroupButton.qml @@ -0,0 +1,62 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +GroupButton { + id: root + horizontalPadding: 12 + verticalPadding: 8 + bounce: false + property string buttonIcon + property bool leftmost: false + property bool rightmost: false + leftRadius: (toggled || leftmost) ? (height / 2) : Appearance.rounding.unsharpenmore + rightRadius: (toggled || rightmost) ? (height / 2) : Appearance.rounding.unsharpenmore + colBackground: Appearance.colors.colSecondaryContainer + colBackgroundHover: Appearance.colors.colSecondaryContainerHover + colBackgroundActive: Appearance.colors.colSecondaryContainerActive + + contentItem: RowLayout { + spacing: 4 + + Loader { + Layout.alignment: Qt.AlignVCenter + active: root.buttonIcon && root.buttonIcon.length > 0 + visible: active + sourceComponent: Item { + implicitWidth: materialSymbol.implicitWidth + MaterialSymbol { + id: materialSymbol + anchors.centerIn: parent + text: root.buttonIcon + iconSize: Appearance.font.pixelSize.larger + color: root.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer + } + } + } + + Item { + implicitWidth: textItem.implicitWidth + implicitHeight: textMetrics.height // Force height to that of regular text + + TextMetrics { + id: textMetrics + font.family: Appearance.font.family.main + text: "Abc" + } + + StyledText { + id: textItem + anchors.centerIn: parent + color: root.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSecondaryContainer + text: root.buttonText + } + } + } +} diff --git a/ii/modules/common/widgets/StyledFlickable.qml b/ii/modules/common/widgets/StyledFlickable.qml new file mode 100644 index 0000000..1f32326 --- /dev/null +++ b/ii/modules/common/widgets/StyledFlickable.qml @@ -0,0 +1,53 @@ +import QtQuick +import QtQuick.Controls +import qs.modules.common + +Flickable { + id: root + maximumFlickVelocity: 3500 + boundsBehavior: Flickable.DragOverBounds + + property real touchpadScrollFactor: Config?.options.interactions.scrolling.touchpadScrollFactor ?? 100 + property real mouseScrollFactor: Config?.options.interactions.scrolling.mouseScrollFactor ?? 50 + property real mouseScrollDeltaThreshold: Config?.options.interactions.scrolling.mouseScrollDeltaThreshold ?? 120 + // Accumulated scroll destination so wheel deltas stack while animating + property real scrollTargetY: 0 + + ScrollBar.vertical: StyledScrollBar {} + + MouseArea { + visible: Config?.options.interactions.scrolling.fasterTouchpadScroll + anchors.fill: parent + acceptedButtons: Qt.NoButton + onWheel: function(wheelEvent) { + const delta = wheelEvent.angleDelta.y / root.mouseScrollDeltaThreshold; + // The angleDelta.y of a touchpad is usually small and continuous, + // while that of a mouse wheel is typically in multiples of ±120. + var scrollFactor = Math.abs(wheelEvent.angleDelta.y) >= root.mouseScrollDeltaThreshold ? root.mouseScrollFactor : root.touchpadScrollFactor; + + const maxY = Math.max(0, root.contentHeight - root.height); + const base = scrollAnim.running ? root.scrollTargetY : root.contentY; + var targetY = Math.max(0, Math.min(base - delta * scrollFactor, maxY)); + + root.scrollTargetY = targetY; + root.contentY = targetY; + wheelEvent.accepted = true; + } + } + + Behavior on contentY { + NumberAnimation { + id: scrollAnim + duration: Appearance.animation.scroll.duration + easing.type: Appearance.animation.scroll.type + easing.bezierCurve: Appearance.animation.scroll.bezierCurve + } + } + + // Keep target synced when not animating (e.g., drag/flick or programmatic changes) + onContentYChanged: { + if (!scrollAnim.running) { + root.scrollTargetY = root.contentY; + } + } +} diff --git a/ii/modules/common/widgets/StyledImage.qml b/ii/modules/common/widgets/StyledImage.qml new file mode 100644 index 0000000..c360b53 --- /dev/null +++ b/ii/modules/common/widgets/StyledImage.qml @@ -0,0 +1,15 @@ +import QtQuick +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +Image { + asynchronous: true + retainWhileLoading: true + visible: opacity > 0 + opacity: (status === Image.Ready) ? 1 : 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } +} diff --git a/ii/modules/common/widgets/StyledIndeterminateProgressBar.qml b/ii/modules/common/widgets/StyledIndeterminateProgressBar.qml new file mode 100644 index 0000000..030d3fc --- /dev/null +++ b/ii/modules/common/widgets/StyledIndeterminateProgressBar.qml @@ -0,0 +1,9 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls.Material +import QtQuick.Controls + +ProgressBar { + indeterminate: true + Material.accent: Appearance.colors.colPrimary +} diff --git a/ii/modules/common/widgets/StyledLabel.qml b/ii/modules/common/widgets/StyledLabel.qml new file mode 100644 index 0000000..35b3cbf --- /dev/null +++ b/ii/modules/common/widgets/StyledLabel.qml @@ -0,0 +1,15 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls + +Label { + renderType: Text.NativeRendering + verticalAlignment: Text.AlignVCenter + font { + hintingPreference: Font.PreferFullHinting + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + } + color: Appearance?.m3colors.m3onBackground ?? "black" + linkColor: Appearance?.m3colors.m3primary +} diff --git a/ii/modules/common/widgets/StyledListView.qml b/ii/modules/common/widgets/StyledListView.qml new file mode 100644 index 0000000..0833214 --- /dev/null +++ b/ii/modules/common/widgets/StyledListView.qml @@ -0,0 +1,154 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Controls + +/** + * A ListView with animations. + */ +ListView { + id: root + spacing: 5 + property real removeOvershoot: 20 // Account for gaps and bouncy animations + property int dragIndex: -1 + property real dragDistance: 0 + property bool popin: true + property bool animateAppearance: true + property bool animateMovement: false + // Accumulated scroll destination so wheel deltas stack while animating + property real scrollTargetY: 0 + + property real touchpadScrollFactor: Config?.options.interactions.scrolling.touchpadScrollFactor ?? 100 + property real mouseScrollFactor: Config?.options.interactions.scrolling.mouseScrollFactor ?? 50 + property real mouseScrollDeltaThreshold: Config?.options.interactions.scrolling.mouseScrollDeltaThreshold ?? 120 + + function resetDrag() { + root.dragIndex = -1 + root.dragDistance = 0 + } + + maximumFlickVelocity: 3500 + boundsBehavior: Flickable.DragOverBounds + ScrollBar.vertical: StyledScrollBar {} + + MouseArea { + visible: Config?.options.interactions.scrolling.fasterTouchpadScroll + anchors.fill: parent + acceptedButtons: Qt.NoButton + onWheel: function(wheelEvent) { + const delta = wheelEvent.angleDelta.y / root.mouseScrollDeltaThreshold; + // The angleDelta.y of a touchpad is usually small and continuous, + // while that of a mouse wheel is typically in multiples of ±120. + var scrollFactor = Math.abs(wheelEvent.angleDelta.y) >= root.mouseScrollDeltaThreshold ? root.mouseScrollFactor : root.touchpadScrollFactor; + + const maxY = Math.max(0, root.contentHeight - root.height); + const base = scrollAnim.running ? root.scrollTargetY : root.contentY; + var targetY = Math.max(0, Math.min(base - delta * scrollFactor, maxY)); + + root.scrollTargetY = targetY; + root.contentY = targetY; + wheelEvent.accepted = true; + } + } + + Behavior on contentY { + NumberAnimation { + id: scrollAnim + duration: Appearance.animation.scroll.duration + easing.type: Appearance.animation.scroll.type + easing.bezierCurve: Appearance.animation.scroll.bezierCurve + } + } + + // Keep target synced when not animating (e.g., drag/flick or programmatic changes) + onContentYChanged: { + if (!scrollAnim.running) { + root.scrollTargetY = root.contentY; + } + } + + add: Transition { + animations: animateAppearance ? [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: popin ? "opacity,scale" : "opacity", + from: 0, + to: 1, + }), + ] : [] + } + + addDisplaced: Transition { + animations: animateAppearance ? [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: popin ? "opacity,scale" : "opacity", + to: 1, + }), + ] : [] + } + + displaced: Transition { + animations: root.animateMovement ? [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + to: 1, + }), + ] : [] + } + + move: Transition { + animations: root.animateMovement ? [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + to: 1, + }), + ] : [] + } + moveDisplaced: Transition { + animations: root.animateMovement ? [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + to: 1, + }), + ] : [] + } + + remove: Transition { + animations: animateAppearance ? [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "x", + to: root.width + root.removeOvershoot, + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "opacity", + to: 0, + }) + ] : [] + } + + // This is movement when something is removed, not removing animation! + removeDisplaced: Transition { + animations: animateAppearance ? [ + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance?.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + to: 1, + }), + ] : [] + } +} diff --git a/ii/modules/common/widgets/StyledProgressBar.qml b/ii/modules/common/widgets/StyledProgressBar.qml new file mode 100644 index 0000000..8197b29 --- /dev/null +++ b/ii/modules/common/widgets/StyledProgressBar.qml @@ -0,0 +1,102 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Qt5Compat.GraphicalEffects + +/** + * Material 3 progress bar. See https://m3.material.io/components/progress-indicators/overview + */ +ProgressBar { + id: root + property real valueBarWidth: 120 + property real valueBarHeight: 4 + property real valueBarGap: 4 + property color highlightColor: Appearance?.colors.colPrimary ?? "#685496" + property color trackColor: Appearance?.m3colors.m3secondaryContainer ?? "#F1D3F9" + property bool sperm: false // If true, the progress bar will have a wavy fill effect + property bool animateSperm: true + property real spermAmplitudeMultiplier: sperm ? 0.5 : 0 + property real spermFrequency: 6 + property real spermFps: 60 + + Behavior on spermAmplitudeMultiplier { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Behavior on value { + animation: Appearance?.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + background: Item { + implicitHeight: valueBarHeight + implicitWidth: valueBarWidth + } + + contentItem: Item { + anchors.fill: parent + + Canvas { + id: wavyFill + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + } + height: parent.height * 6 + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + + var progress = root.visualPosition; + var fillWidth = progress * width; + var amplitude = parent.height * root.spermAmplitudeMultiplier; + var frequency = root.spermFrequency; + var phase = Date.now() / 400.0; + var centerY = height / 2; + + ctx.strokeStyle = root.highlightColor; + ctx.lineWidth = parent.height; + ctx.lineCap = "round"; + ctx.beginPath(); + for (var x = ctx.lineWidth / 2; x <= fillWidth; x += 1) { + var waveY = centerY + amplitude * Math.sin(frequency * 2 * Math.PI * x / width + phase); + if (x === 0) + ctx.moveTo(x, waveY); + else + ctx.lineTo(x, waveY); + } + ctx.stroke(); + } + Connections { + target: root + function onValueChanged() { wavyFill.requestPaint(); } + function onHighlightColorChanged() { wavyFill.requestPaint(); } + } + Timer { + interval: 1000 / root.spermFps + running: root.animateSperm + repeat: root.sperm + onTriggered: wavyFill.requestPaint() + } + } + Rectangle { // Right remaining part fill + anchors.right: parent.right + width: (1 - root.visualPosition) * parent.width - valueBarGap + height: parent.height + radius: Appearance?.rounding.full ?? 9999 + color: root.trackColor + } + Rectangle { // Stop point + anchors.right: parent.right + width: valueBarGap + height: valueBarGap + radius: Appearance?.rounding.full ?? 9999 + color: root.highlightColor + } + } +} \ No newline at end of file diff --git a/ii/modules/common/widgets/StyledRadioButton.qml b/ii/modules/common/widgets/StyledRadioButton.qml new file mode 100644 index 0000000..a6a63b7 --- /dev/null +++ b/ii/modules/common/widgets/StyledRadioButton.qml @@ -0,0 +1,87 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Widgets +import Quickshell.Services.Pipewire + +RadioButton { + id: root + implicitHeight: contentItem.implicitHeight + 4 * 2 + property string description + property color activeColor: Appearance?.colors.colPrimary ?? "#685496" + property color inactiveColor: Appearance?.m3colors.m3onSurfaceVariant ?? "#45464F" + + PointingHandInteraction {} + + indicator: Item{} + + contentItem: RowLayout { + id: contentItem + Layout.fillWidth: true + spacing: 12 + Rectangle { + id: radio + Layout.fillWidth: false + Layout.alignment: Qt.AlignVCenter + width: 20 + height: 20 + radius: Appearance?.rounding.full + border.color: checked ? root.activeColor : root.inactiveColor + border.width: 2 + color: "transparent" + + // Checked indicator + Rectangle { + anchors.centerIn: parent + width: checked ? 10 : 4 + height: checked ? 10 : 4 + radius: Appearance?.rounding.full + color: Appearance?.colors.colPrimary + opacity: checked ? 1 : 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on width { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + + } + + // Hover + Rectangle { + anchors.centerIn: parent + width: root.hovered ? 40 : 20 + height: root.hovered ? 40 : 20 + radius: Appearance?.rounding.full + color: Appearance?.m3colors.m3onSurface + opacity: root.hovered ? 0.1 : 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on width { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance?.animation.elementMove.numberAnimation.createObject(this) + } + } + } + + StyledText { + text: root.description + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + wrapMode: Text.Wrap + color: Appearance?.m3colors.m3onSurface + } + } +} \ No newline at end of file diff --git a/ii/modules/common/widgets/StyledRectangularShadow.qml b/ii/modules/common/widgets/StyledRectangularShadow.qml new file mode 100644 index 0000000..a3c842c --- /dev/null +++ b/ii/modules/common/widgets/StyledRectangularShadow.qml @@ -0,0 +1,14 @@ +import QtQuick +import QtQuick.Effects +import qs.modules.common + +RectangularShadow { + required property var target + anchors.fill: target + radius: target.radius + blur: 0.9 * Appearance.sizes.elevationMargin + offset: Qt.vector2d(0.0, 1.0) + spread: 1 + color: Appearance.colors.colShadow + cached: true +} diff --git a/ii/modules/common/widgets/StyledScrollBar.qml b/ii/modules/common/widgets/StyledScrollBar.qml new file mode 100644 index 0000000..7b677c4 --- /dev/null +++ b/ii/modules/common/widgets/StyledScrollBar.qml @@ -0,0 +1,28 @@ +import QtQuick +import QtQuick.Controls +import qs.modules.common +import qs.modules.common.functions + +ScrollBar { + id: root + + policy: ScrollBar.AsNeeded + topPadding: Appearance.rounding.normal + bottomPadding: Appearance.rounding.normal + + contentItem: Rectangle { + implicitWidth: 4 + implicitHeight: root.visualSize + radius: width / 2 + color: Appearance.colors.colOnSurfaceVariant + + opacity: root.policy === ScrollBar.AlwaysOn || (root.active && root.size < 1.0) ? 0.5 : 0 + Behavior on opacity { + NumberAnimation { + duration: 350 + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + } +} diff --git a/ii/modules/common/widgets/StyledSlider.qml b/ii/modules/common/widgets/StyledSlider.qml new file mode 100644 index 0000000..e940f1a --- /dev/null +++ b/ii/modules/common/widgets/StyledSlider.qml @@ -0,0 +1,155 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Widgets + +/** + * Material 3 slider. See https://m3.material.io/components/sliders/overview + * It doesn't exactly match the spec because it does not make sense to have stuff on a computer that fucking huge. + * Should be at 3/4 scale... + */ + +Slider { + id: root + + property list stopIndicatorValues: [1] + enum Configuration { + XS = 12, + S = 18, + M = 30, + L = 42, + XL = 72 + } + + property var configuration: StyledSlider.Configuration.S + + property real handleDefaultWidth: 3 + property real handlePressedWidth: 1.5 + + property color highlightColor: Appearance.colors.colPrimary + property color trackColor: Appearance.colors.colSecondaryContainer + property color handleColor: Appearance.m3colors.m3onSecondaryContainer + property color dotColor: Appearance.m3colors.m3onSecondaryContainer + property color dotColorHighlighted: Appearance.m3colors.m3onPrimary + property real unsharpenRadius: Appearance.rounding.unsharpen + property real trackWidth: configuration + property real trackRadius: trackWidth >= StyledSlider.Configuration.XL ? 21 + : trackWidth >= StyledSlider.Configuration.L ? 12 + : trackWidth >= StyledSlider.Configuration.M ? 9 + : 6 + property real handleHeight: Math.max(33, trackWidth + 9) + property real handleWidth: root.pressed ? handlePressedWidth : handleDefaultWidth + property real handleMargins: 4 + onHandleMarginsChanged: { + console.log("Handle margins changed to", handleMargins); + } + property real trackDotSize: 3 + property string tooltipContent: `${Math.round(value * 100)}%` + + leftPadding: handleMargins + rightPadding: handleMargins + property real effectiveDraggingWidth: width - leftPadding - rightPadding + + Layout.fillWidth: true + from: 0 + to: 1 + + Behavior on value { // This makes the adjusted value (like volume) shift smoothly + SmoothedAnimation { + velocity: Appearance.animation.elementMoveFast.velocity + } + } + + Behavior on handleMargins { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + component TrackDot: Rectangle { + required property real value + anchors.verticalCenter: parent.verticalCenter + x: root.handleMargins + (value * root.effectiveDraggingWidth) - (root.trackDotSize / 2) + width: root.trackDotSize + height: root.trackDotSize + radius: Appearance.rounding.full + color: value > root.visualPosition ? root.dotColor : root.dotColorHighlighted + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + + MouseArea { + anchors.fill: parent + onPressed: (mouse) => mouse.accepted = false + cursorShape: root.pressed ? Qt.ClosedHandCursor : Qt.PointingHandCursor + } + + background: Item { + anchors.verticalCenter: parent.verticalCenter + width: parent.width + implicitHeight: trackWidth + + // Fill left + Rectangle { + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + } + width: root.handleMargins + (root.visualPosition * root.effectiveDraggingWidth) - (root.handleWidth / 2 + root.handleMargins) + height: trackWidth + color: root.highlightColor + topLeftRadius: root.trackRadius + bottomLeftRadius: root.trackRadius + topRightRadius: root.unsharpenRadius + bottomRightRadius: root.unsharpenRadius + } + + // Fill right + Rectangle { + anchors { + verticalCenter: parent.verticalCenter + right: parent.right + } + width: root.handleMargins + ((1 - root.visualPosition) * root.effectiveDraggingWidth) - (root.handleWidth / 2 + root.handleMargins) + height: trackWidth + color: root.trackColor + topRightRadius: root.trackRadius + bottomRightRadius: root.trackRadius + topLeftRadius: root.unsharpenRadius + bottomLeftRadius: root.unsharpenRadius + } + + // Stop indicators + Repeater { + model: root.stopIndicatorValues + TrackDot { + required property real modelData + value: modelData + anchors.verticalCenter: parent.verticalCenter + } + } + } + + handle: Rectangle { + id: handle + + implicitWidth: root.handleWidth + implicitHeight: root.handleHeight + x: root.handleMargins + (root.visualPosition * root.effectiveDraggingWidth) - (root.handleWidth / 2) + anchors.verticalCenter: parent.verticalCenter + radius: Appearance.rounding.full + color: root.handleColor + + Behavior on implicitWidth { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + StyledToolTip { + extraVisibleCondition: root.pressed + content: root.tooltipContent + } + } +} \ No newline at end of file diff --git a/ii/modules/common/widgets/StyledSpinBox.qml b/ii/modules/common/widgets/StyledSpinBox.qml new file mode 100644 index 0000000..c11f241 --- /dev/null +++ b/ii/modules/common/widgets/StyledSpinBox.qml @@ -0,0 +1,92 @@ +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import QtQuick.Controls + +/** + * Material 3 styled SpinBox component. + */ +SpinBox { + id: root + + property real baseHeight: 35 + property real radius: Appearance.rounding.small + property real innerButtonRadius: Appearance.rounding.unsharpen + editable: true + + background: Rectangle { + color: Appearance.colors.colLayer2 + radius: root.radius + } + + contentItem: Item { + implicitHeight: root.baseHeight + implicitWidth: Math.max(labelText.implicitWidth, 40) + + StyledTextInput { + id: labelText + anchors.centerIn: parent + text: root.value // displayText would make the numbers weird like 1,000 instead of 1000 + color: Appearance.colors.colOnLayer2 + font.pixelSize: Appearance.font.pixelSize.small + validator: root.validator + onTextChanged: { + root.value = parseFloat(text); + } + } + } + + down.indicator: Rectangle { + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + } + implicitHeight: root.baseHeight + implicitWidth: root.baseHeight + topLeftRadius: root.radius + bottomLeftRadius: root.radius + topRightRadius: root.innerButtonRadius + bottomRightRadius: root.innerButtonRadius + + color: root.down.pressed ? Appearance.colors.colLayer2Active : + root.down.hovered ? Appearance.colors.colLayer2Hover : + ColorUtils.transparentize(Appearance.colors.colLayer2) + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + MaterialSymbol { + anchors.centerIn: parent + text: "remove" + iconSize: 20 + color: Appearance.colors.colOnLayer2 + } + } + + up.indicator: Rectangle { + anchors { + verticalCenter: parent.verticalCenter + right: parent.right + } + implicitHeight: root.baseHeight + implicitWidth: root.baseHeight + topRightRadius: root.radius + bottomRightRadius: root.radius + topLeftRadius: root.innerButtonRadius + bottomLeftRadius: root.innerButtonRadius + + color: root.up.pressed ? Appearance.colors.colLayer2Active : + root.up.hovered ? Appearance.colors.colLayer2Hover : + ColorUtils.transparentize(Appearance.colors.colLayer2) + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + MaterialSymbol { + anchors.centerIn: parent + text: "add" + iconSize: 20 + color: Appearance.colors.colOnLayer2 + } + } +} diff --git a/ii/modules/common/widgets/StyledSwitch.qml b/ii/modules/common/widgets/StyledSwitch.qml new file mode 100644 index 0000000..f16e213 --- /dev/null +++ b/ii/modules/common/widgets/StyledSwitch.qml @@ -0,0 +1,60 @@ +import qs.modules.common +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Qt5Compat.GraphicalEffects + +/** + * Material 3 switch. See https://m3.material.io/components/switch/overview + */ +Switch { + id: root + property real scale: 0.6 // Default in m3 spec is huge af + implicitHeight: 32 * root.scale + implicitWidth: 52 * root.scale + property color activeColor: Appearance?.colors.colPrimary ?? "#685496" + property color inactiveColor: Appearance?.colors.colSurfaceContainerHighest ?? "#45464F" + + PointingHandInteraction {} + + // Custom track styling + background: Rectangle { + width: parent.width + height: parent.height + radius: Appearance?.rounding.full ?? 9999 + color: root.checked ? root.activeColor : root.inactiveColor + border.width: 2 * root.scale + border.color: root.checked ? root.activeColor : Appearance.m3colors.m3outline + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + Behavior on border.color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + + // Custom thumb styling + indicator: Rectangle { + width: (root.pressed || root.down) ? (28 * root.scale) : root.checked ? (24 * root.scale) : (16 * root.scale) + height: (root.pressed || root.down) ? (28 * root.scale) : root.checked ? (24 * root.scale) : (16 * root.scale) + radius: Appearance.rounding.full + color: root.checked ? Appearance.m3colors.m3onPrimary : Appearance.m3colors.m3outline + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: root.checked ? ((root.pressed || root.down) ? (22 * root.scale) : 24 * root.scale) : ((root.pressed || root.down) ? (2 * root.scale) : 8 * root.scale) + + Behavior on anchors.leftMargin { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on width { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } +} diff --git a/ii/modules/common/widgets/StyledText.qml b/ii/modules/common/widgets/StyledText.qml new file mode 100644 index 0000000..4f07d67 --- /dev/null +++ b/ii/modules/common/widgets/StyledText.qml @@ -0,0 +1,88 @@ +import qs.modules.common +import QtQuick +import QtQuick.Layouts + +Text { + id: root + property bool animateChange: false + property real animationDistanceX: 0 + property real animationDistanceY: 6 + + renderType: Text.NativeRendering + verticalAlignment: Text.AlignVCenter + font { + hintingPreference: Font.PreferFullHinting + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + } + color: Appearance?.m3colors.m3onBackground ?? "black" + linkColor: Appearance?.m3colors.m3primary + + component Anim: NumberAnimation { + target: root + duration: 300 / 2 + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + + Component.onCompleted: { + textAnimationBehavior.originalX = root.x; + textAnimationBehavior.originalY = root.y; + } + + Behavior on text { + id: textAnimationBehavior + property real originalX: root.x + property real originalY: root.y + enabled: root.animateChange + + SequentialAnimation { + alwaysRunToEnd: true + ParallelAnimation { + Anim { + property: "x" + to: textAnimationBehavior.originalX - root.animationDistanceX + easing.type: Easing.InSine + } + Anim { + property: "y" + to: textAnimationBehavior.originalY - root.animationDistanceY + easing.type: Easing.InSine + } + Anim { + property: "opacity" + to: 0 + easing.type: Easing.InSine + } + } + PropertyAction {} // Tie the text update to this point (we don't want it to happen during the first slide+fade) + PropertyAction { + target: root + property: "x" + value: textAnimationBehavior.originalX + root.animationDistanceX + } + PropertyAction { + target: root + property: "y" + value: textAnimationBehavior.originalY + root.animationDistanceY + } + ParallelAnimation { + Anim { + property: "x" + to: textAnimationBehavior.originalX + easing.type: Easing.OutSine + } + Anim { + property: "y" + to: textAnimationBehavior.originalY + easing.type: Easing.OutSine + } + Anim { + property: "opacity" + to: 1 + easing.type: Easing.OutSine + } + } + } + } +} diff --git a/ii/modules/common/widgets/StyledTextArea.qml b/ii/modules/common/widgets/StyledTextArea.qml new file mode 100644 index 0000000..e0abba3 --- /dev/null +++ b/ii/modules/common/widgets/StyledTextArea.qml @@ -0,0 +1,18 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls + +/** + * Does not include visual layout, but includes the easily neglected colors. + */ +TextArea { + renderType: Text.NativeRendering + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + placeholderTextColor: Appearance.m3colors.m3outline + font { + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } +} diff --git a/ii/modules/common/widgets/StyledTextInput.qml b/ii/modules/common/widgets/StyledTextInput.qml new file mode 100644 index 0000000..ff98af7 --- /dev/null +++ b/ii/modules/common/widgets/StyledTextInput.qml @@ -0,0 +1,18 @@ +import qs.modules.common +import QtQuick +import QtQuick.Controls + +/** + * Does not include visual layout, but includes the easily neglected colors. + */ +TextInput { + color: Appearance.colors.colOnLayer1 + renderType: Text.NativeRendering + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + font { + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } +} diff --git a/ii/modules/common/widgets/StyledToolTip.qml b/ii/modules/common/widgets/StyledToolTip.qml new file mode 100644 index 0000000..813c9ed --- /dev/null +++ b/ii/modules/common/widgets/StyledToolTip.qml @@ -0,0 +1,60 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ToolTip { + id: root + property string content + property bool extraVisibleCondition: true + property bool alternativeVisibleCondition: false + property bool internalVisibleCondition: { + const ans = (extraVisibleCondition && (parent.hovered === undefined || parent?.hovered)) || alternativeVisibleCondition + return ans + } + verticalPadding: 5 + horizontalPadding: 10 + opacity: internalVisibleCondition ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + background: null + + contentItem: Item { + id: contentItemBackground + implicitWidth: tooltipTextObject.width + 2 * root.horizontalPadding + implicitHeight: tooltipTextObject.height + 2 * root.verticalPadding + + Rectangle { + id: backgroundRectangle + anchors.bottom: contentItemBackground.bottom + anchors.horizontalCenter: contentItemBackground.horizontalCenter + color: Appearance?.colors.colTooltip ?? "#3C4043" + radius: Appearance?.rounding.verysmall ?? 7 + width: internalVisibleCondition ? (tooltipTextObject.width + 2 * padding) : 0 + height: internalVisibleCondition ? (tooltipTextObject.height + 2 * padding) : 0 + clip: true + + Behavior on width { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance?.animation.elementMoveFast.numberAnimation.createObject(this) + } + + StyledText { + id: tooltipTextObject + anchors.centerIn: parent + text: content + font.pixelSize: Appearance?.font.pixelSize.smaller ?? 14 + font.hintingPreference: Font.PreferNoHinting // Prevent shaky text + color: Appearance?.colors.colOnTooltip ?? "#FFFFFF" + wrapMode: Text.Wrap + } + } + } +} \ No newline at end of file diff --git a/ii/modules/common/widgets/Toolbar.qml b/ii/modules/common/widgets/Toolbar.qml new file mode 100644 index 0000000..aa07f82 --- /dev/null +++ b/ii/modules/common/widgets/Toolbar.qml @@ -0,0 +1,41 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.widgets + +/** + * Material 3 expressive style toolbar. + * https://m3.material.io/components/toolbars + */ +Item { + id: root + + property real padding: 8 + property alias colBackground: background.color + property alias spacing: toolbarLayout.spacing + default property alias data: toolbarLayout.data + implicitWidth: background.implicitWidth + implicitHeight: background.implicitHeight + + StyledRectangularShadow { + target: background + } + + Rectangle { + id: background + anchors.centerIn: parent + color: Appearance.m3colors.m3surfaceContainer // Needs to be opaque + implicitHeight: Math.max(toolbarLayout.implicitHeight + root.padding * 2, 56) + implicitWidth: toolbarLayout.implicitWidth + root.padding * 2 + radius: Appearance.rounding.full + + RowLayout { + id: toolbarLayout + spacing: 4 + anchors { + fill: parent + margins: root.padding + } + } + } +} diff --git a/ii/modules/common/widgets/ToolbarButton.qml b/ii/modules/common/widgets/ToolbarButton.qml new file mode 100644 index 0000000..04b3f5f --- /dev/null +++ b/ii/modules/common/widgets/ToolbarButton.qml @@ -0,0 +1,8 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common + +RippleButton { + Layout.fillHeight: true + buttonRadius: Appearance.rounding.full +} diff --git a/ii/modules/common/widgets/ToolbarTextField.qml b/ii/modules/common/widgets/ToolbarTextField.qml new file mode 100644 index 0000000..bab455e --- /dev/null +++ b/ii/modules/common/widgets/ToolbarTextField.qml @@ -0,0 +1,28 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import qs.modules.common +import qs.modules.common.widgets + +TextField { + id: filterField + + property alias colBackground: background.color + + Layout.fillHeight: true + implicitWidth: 200 + padding: 10 + + placeholderTextColor: Appearance.colors.colSubtext + color: Appearance.colors.colOnLayer1 + font.pixelSize: Appearance.font.pixelSize.small + renderType: Text.NativeRendering + selectedTextColor: Appearance.colors.colOnSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + + background: Rectangle { + id: background + color: Appearance.colors.colLayer1 + radius: Appearance.rounding.full + } +} diff --git a/ii/modules/common/widgets/VerticalButtonGroup.qml b/ii/modules/common/widgets/VerticalButtonGroup.qml new file mode 100644 index 0000000..b1ca845 --- /dev/null +++ b/ii/modules/common/widgets/VerticalButtonGroup.qml @@ -0,0 +1,45 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +/** + * A container that supports GroupButton children for bounciness. + * See https://m3.material.io/components/button-groups/overview + */ +Rectangle { + id: root + default property alias content: columnLayout.data + property real spacing: 5 + property real padding: 0 + property int clickIndex: columnLayout.clickIndex + + property real contentHeight: { + let total = 0; + for (let i = 0; i < columnLayout.children.length; ++i) { + const child = columnLayout.children[i]; + total += child.baseHeight ?? child.implicitHeight ?? child.height; + } + return total + columnLayout.spacing * (columnLayout.children.length - 1); + } + + topLeftRadius: columnLayout.children.length > 0 ? (columnLayout.children[0].radius + padding) : + Appearance?.rounding?.small + topRightRadius: topLeftRadius + bottomLeftRadius: columnLayout.children.length > 0 ? (columnLayout.children[columnLayout.children.length - 1].radius + padding) : + Appearance?.rounding?.small + bottomRightRadius: bottomLeftRadius + + color: "transparent" + height: root.contentHeight + padding * 2 + implicitWidth: columnLayout.implicitWidth + padding * 2 + implicitHeight: root.contentHeight + padding * 2 + + children: [ColumnLayout { + id: columnLayout + anchors.fill: parent + anchors.margins: root.padding + spacing: root.spacing + property int clickIndex: -1 + }] +} diff --git a/ii/modules/common/widgets/VibrantToolbarButton.qml b/ii/modules/common/widgets/VibrantToolbarButton.qml new file mode 100644 index 0000000..c6e58f3 --- /dev/null +++ b/ii/modules/common/widgets/VibrantToolbarButton.qml @@ -0,0 +1,10 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.functions + +ToolbarButton { + colBackground: ColorUtils.transparentize(Appearance.colors.colPrimaryContainer) + colBackgroundHover: Appearance.colors.colPrimaryContainerHover + colRipple: Appearance.colors.colPrimaryContainerActive +} diff --git a/ii/modules/common/widgets/WaveVisualizer.qml b/ii/modules/common/widgets/WaveVisualizer.qml new file mode 100644 index 0000000..64559c1 --- /dev/null +++ b/ii/modules/common/widgets/WaveVisualizer.qml @@ -0,0 +1,73 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Effects + +Canvas { // Visualizer + id: root + property list points + property list smoothPoints + property real maxVisualizerValue: 1000 + property int smoothing: 2 + property bool live: true + property color color: Appearance.m3colors.m3primary + + onPointsChanged: () => { + root.requestPaint() + } + + anchors.fill: parent + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + + var points = root.points; + var maxVal = root.maxVisualizerValue || 1; + var h = height; + var w = width; + var n = points.length; + if (n < 2) return; + + // Smoothing: simple moving average (optional) + var smoothWindow = root.smoothing; // adjust for more/less smoothing + root.smoothPoints = []; + for (var i = 0; i < n; ++i) { + var sum = 0, count = 0; + for (var j = -smoothWindow; j <= smoothWindow; ++j) { + var idx = Math.max(0, Math.min(n - 1, i + j)); + sum += points[idx]; + count++; + } + root.smoothPoints.push(sum / count); + } + if (!root.live) root.smoothPoints.fill(0); // If not playing, show no points + + ctx.beginPath(); + ctx.moveTo(0, h); + for (var i = 0; i < n; ++i) { + var x = i * w / (n - 1); + var y = h - (root.smoothPoints[i] / maxVal) * h; + ctx.lineTo(x, y); + } + ctx.lineTo(w, h); + ctx.closePath(); + + ctx.fillStyle = Qt.rgba( + root.color.r, + root.color.g, + root.color.b, + 0.15 + ); + ctx.fill(); + } + + layer.enabled: true + layer.effect: MultiEffect { // Blur a bit to obscure away the points + source: root + saturation: 0.2 + blurEnabled: true + blurMax: 7 + blur: 1 + } +} \ No newline at end of file diff --git a/ii/modules/common/widgets/WindowDialog.qml b/ii/modules/common/widgets/WindowDialog.qml new file mode 100644 index 0000000..92f6353 --- /dev/null +++ b/ii/modules/common/widgets/WindowDialog.qml @@ -0,0 +1,90 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +Rectangle { + id: root + + property bool show: false + default property alias data: contentColumn.data + property real backgroundHeight: 600 + property real backgroundAnimationMovementDistance: 60 + + signal dismiss() + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Escape) { + root.dismiss(); + event.accepted = true; + } + } + + color: root.show ? Appearance.colors.colScrim : ColorUtils.transparentize(Appearance.colors.colScrim) + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + visible: dialogBackground.implicitHeight > 0 + + onShowChanged: { + dialogBackgroundHeightAnimation.easing.bezierCurve = (show ? Appearance.animationCurves.emphasizedDecel : Appearance.animationCurves.emphasizedAccel) + dialogBackground.implicitHeight = show ? backgroundHeight : 0 + } + + radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1 + + MouseArea { // Clicking outside the dialog should dismiss + anchors.fill: parent + acceptedButtons: Qt.AllButtons + hoverEnabled: true + onPressed: root.dismiss() + } + + Rectangle { + id: dialogBackground + anchors.horizontalCenter: parent.horizontalCenter + radius: Appearance.rounding.large + color: Appearance.m3colors.m3surfaceContainerHigh // Use opaque version of layer3 + + property real targetY: root.height / 2 - root.backgroundHeight / 2 + y: root.show ? targetY : (targetY - root.backgroundAnimationMovementDistance) + implicitWidth: 350 + implicitHeight: 0 + Behavior on implicitHeight { + NumberAnimation { + id: dialogBackgroundHeightAnimation + duration: Appearance.animation.elementMoveFast.duration + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.animationCurves.emphasizedDecel + } + } + Behavior on y { + NumberAnimation { + duration: dialogBackgroundHeightAnimation.duration + easing.type: dialogBackgroundHeightAnimation.easing.type + easing.bezierCurve: dialogBackgroundHeightAnimation.easing.bezierCurve + } + } + + MouseArea { // So clicking inside the dialog won't dismiss + anchors.fill: parent + acceptedButtons: Qt.AllButtons + hoverEnabled: true + } + + ColumnLayout { + id: contentColumn + anchors { + fill: parent + margins: dialogBackground.radius + } + spacing: 16 + opacity: root.show ? 1 : 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + } + } +} diff --git a/ii/modules/common/widgets/WindowDialogButtonRow.qml b/ii/modules/common/widgets/WindowDialogButtonRow.qml new file mode 100644 index 0000000..0672e96 --- /dev/null +++ b/ii/modules/common/widgets/WindowDialogButtonRow.qml @@ -0,0 +1,15 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +RowLayout { + id: root + spacing: 4 + + // These shouldn't be needed but it would be a terrible waste of space to follow the spec + Layout.margins: -8 + Layout.topMargin: 0 +} diff --git a/ii/modules/common/widgets/WindowDialogSeparator.qml b/ii/modules/common/widgets/WindowDialogSeparator.qml new file mode 100644 index 0000000..52707e5 --- /dev/null +++ b/ii/modules/common/widgets/WindowDialogSeparator.qml @@ -0,0 +1,16 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +Rectangle { + implicitHeight: 1 + color: Appearance.colors.colOutline + Layout.fillWidth: true + Layout.leftMargin: -Appearance.rounding.large + Layout.rightMargin: -Appearance.rounding.large + Layout.topMargin: -8 + Layout.bottomMargin: -8 +} diff --git a/ii/modules/common/widgets/WindowDialogTitle.qml b/ii/modules/common/widgets/WindowDialogTitle.qml new file mode 100644 index 0000000..a450030 --- /dev/null +++ b/ii/modules/common/widgets/WindowDialogTitle.qml @@ -0,0 +1,13 @@ +import QtQuick +import Quickshell +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +StyledText { + text: "Dialog Title" + font { + pixelSize: Appearance.font.pixelSize.title + family: Appearance.font.family.title + } +} diff --git a/ii/modules/common/widgets/notification_utils.js b/ii/modules/common/widgets/notification_utils.js new file mode 100644 index 0000000..9b15105 --- /dev/null +++ b/ii/modules/common/widgets/notification_utils.js @@ -0,0 +1,77 @@ + +/** + * @param { string } summary + * @returns { string } + */ +function findSuitableMaterialSymbol(summary = "") { + const defaultType = 'chat'; + if(summary.length === 0) return defaultType; + + const keywordsToTypes = { + 'reboot': 'restart_alt', + 'recording': 'screen_record', + 'battery': 'power', + 'power': 'power', + 'screenshot': 'screenshot_monitor', + 'welcome': 'waving_hand', + 'time': 'scheduleb', + 'installed': 'download', + 'configuration reloaded': 'reset_wrench', + 'config': 'reset_wrench', + 'update': 'update', + 'ai response': 'neurology', + 'control': 'settings', + 'upscale': 'compare', + 'install': 'deployed_code_update', + 'startswith:file': 'folder_copy', // Declarative startsWith check + }; + + const lowerSummary = summary.toLowerCase(); + + for (const [keyword, type] of Object.entries(keywordsToTypes)) { + if (keyword.startsWith('startswith:')) { + const startsWithKeyword = keyword.replace('startswith:', ''); + if (lowerSummary.startsWith(startsWithKeyword)) { + return type; + } + } else if (lowerSummary.includes(keyword)) { + return type; + } + } + + return defaultType; +} + +/** + * @param { number | string | Date } timestamp + * @returns { string } + */ +const getFriendlyNotifTimeString = (timestamp) => { + if (!timestamp) return ''; + const messageTime = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - messageTime.getTime(); + + // Less than 1 minute + if (diffMs < 60000) + return 'Now'; + + // Same day - show relative time + if (messageTime.toDateString() === now.toDateString()) { + const diffMinutes = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + + if (diffHours > 0) { + return `${diffHours}h`; + } else { + return `${diffMinutes}m`; + } + } + + // Yesterday + if (messageTime.toDateString() === new Date(now.getTime() - 86400000).toDateString()) + return 'Yesterday'; + + // Older dates + return Qt.formatDateTime(messageTime, "MMMM dd"); +}; \ No newline at end of file diff --git a/ii/modules/dock/Dock.qml b/ii/modules/dock/Dock.qml new file mode 100644 index 0000000..c611155 --- /dev/null +++ b/ii/modules/dock/Dock.qml @@ -0,0 +1,148 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { // Scope + id: root + property bool pinned: Config.options?.dock.pinnedOnStartup ?? false + + Variants { + // For each monitor + model: Quickshell.screens + + PanelWindow { + id: dockRoot + // Window + required property var modelData + screen: modelData + visible: !GlobalStates.screenLocked + + property bool reveal: root.pinned || (Config.options?.dock.hoverToReveal && dockMouseArea.containsMouse) || dockApps.requestDockShow || (!ToplevelManager.activeToplevel?.activated) + + anchors { + bottom: true + left: true + right: true + } + + exclusiveZone: root.pinned ? implicitHeight - (Appearance.sizes.hyprlandGapsOut) - (Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut) : 0 + + implicitWidth: dockBackground.implicitWidth + WlrLayershell.namespace: "quickshell:dock" + color: "transparent" + + implicitHeight: (Config.options?.dock.height ?? 70) + Appearance.sizes.elevationMargin + Appearance.sizes.hyprlandGapsOut + + mask: Region { + item: dockMouseArea + } + + MouseArea { + id: dockMouseArea + height: parent.height + anchors { + top: parent.top + topMargin: dockRoot.reveal ? 0 : Config.options?.dock.hoverToReveal ? (dockRoot.implicitHeight - Config.options.dock.hoverRegionHeight) : (dockRoot.implicitHeight + 1) + horizontalCenter: parent.horizontalCenter + } + implicitWidth: dockHoverRegion.implicitWidth + Appearance.sizes.elevationMargin * 2 + hoverEnabled: true + + Behavior on anchors.topMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Item { + id: dockHoverRegion + anchors.fill: parent + implicitWidth: dockBackground.implicitWidth + + Item { // Wrapper for the dock background + id: dockBackground + anchors { + top: parent.top + bottom: parent.bottom + horizontalCenter: parent.horizontalCenter + } + + implicitWidth: dockRow.implicitWidth + 5 * 2 + height: parent.height - Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut + + StyledRectangularShadow { + target: dockVisualBackground + } + Rectangle { // The real rectangle that is visible + id: dockVisualBackground + property real margin: Appearance.sizes.elevationMargin + anchors.fill: parent + anchors.topMargin: Appearance.sizes.elevationMargin + anchors.bottomMargin: Appearance.sizes.hyprlandGapsOut + color: Appearance.colors.colLayer0 + border.width: 1 + border.color: Appearance.colors.colLayer0Border + radius: Appearance.rounding.large + } + + RowLayout { + id: dockRow + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + spacing: 3 + property real padding: 5 + + VerticalButtonGroup { + Layout.topMargin: Appearance.sizes.hyprlandGapsOut // why does this work + GroupButton { + // Pin button + baseWidth: 35 + baseHeight: 35 + clickedWidth: baseWidth + clickedHeight: baseHeight + 20 + buttonRadius: Appearance.rounding.normal + toggled: root.pinned + onClicked: root.pinned = !root.pinned + contentItem: MaterialSymbol { + text: "keep" + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + color: root.pinned ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer0 + } + } + } + DockSeparator {} + DockApps { + id: dockApps + buttonPadding: dockRow.padding + } + DockSeparator {} + DockButton { + Layout.fillHeight: true + onClicked: GlobalStates.overviewOpen = !GlobalStates.overviewOpen + topInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding + bottomInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding + contentItem: MaterialSymbol { + anchors.fill: parent + horizontalAlignment: Text.AlignHCenter + font.pixelSize: parent.width / 2 + text: "apps" + color: Appearance.colors.colOnLayer0 + } + } + } + } + } + } + } + } +} diff --git a/ii/modules/dock/DockAppButton.qml b/ii/modules/dock/DockAppButton.qml new file mode 100644 index 0000000..1ebbffa --- /dev/null +++ b/ii/modules/dock/DockAppButton.qml @@ -0,0 +1,139 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets + +DockButton { + id: root + property var appToplevel + property var appListRoot + property int lastFocused: -1 + property real iconSize: 35 + property real countDotWidth: 10 + property real countDotHeight: 4 + property bool appIsActive: appToplevel.toplevels.find(t => (t.activated == true)) !== undefined + + property bool isSeparator: appToplevel.appId === "SEPARATOR" + property var desktopEntry: DesktopEntries.byId(appToplevel.appId) + enabled: !isSeparator + implicitWidth: isSeparator ? 1 : implicitHeight - topInset - bottomInset + + Loader { + active: isSeparator + anchors { + fill: parent + topMargin: dockVisualBackground.margin + dockRow.padding + Appearance.rounding.normal + bottomMargin: dockVisualBackground.margin + dockRow.padding + Appearance.rounding.normal + } + sourceComponent: DockSeparator {} + } + + Loader { + anchors.fill: parent + active: appToplevel.toplevels.length > 0 + sourceComponent: MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + onEntered: { + appListRoot.lastHoveredButton = root + appListRoot.buttonHovered = true + lastFocused = appToplevel.toplevels.length - 1 + } + onExited: { + if (appListRoot.lastHoveredButton === root) { + appListRoot.buttonHovered = false + } + } + } + } + + onClicked: { + if (appToplevel.toplevels.length === 0) { + root.desktopEntry?.execute(); + return; + } + lastFocused = (lastFocused + 1) % appToplevel.toplevels.length + appToplevel.toplevels[lastFocused].activate() + } + + middleClickAction: () => { + root.desktopEntry?.execute(); + } + + altAction: () => { + if (Config.options.dock.pinnedApps.indexOf(appToplevel.appId) !== -1) { + Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.filter(id => id !== appToplevel.appId) + } else { + Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.concat([appToplevel.appId]) + } + } + + contentItem: Loader { + active: !isSeparator + sourceComponent: Item { + anchors.centerIn: parent + + Loader { + id: iconImageLoader + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + } + active: !root.isSeparator + sourceComponent: IconImage { + source: Quickshell.iconPath(AppSearch.guessIcon(appToplevel.appId), "image-missing") + implicitSize: root.iconSize + } + } + + Loader { + active: Config.options.dock.monochromeIcons + anchors.fill: iconImageLoader + sourceComponent: Item { + Desaturate { + id: desaturatedIcon + visible: false // There's already color overlay + anchors.fill: parent + source: iconImageLoader + desaturation: 0.8 + } + ColorOverlay { + anchors.fill: desaturatedIcon + source: desaturatedIcon + color: ColorUtils.transparentize(Appearance.colors.colPrimary, 0.9) + } + } + } + + RowLayout { + spacing: 3 + anchors { + top: iconImageLoader.bottom + topMargin: 2 + horizontalCenter: parent.horizontalCenter + } + Repeater { + model: Math.min(appToplevel.toplevels.length, 3) + delegate: Rectangle { + required property int index + radius: Appearance.rounding.full + implicitWidth: (appToplevel.toplevels.length <= 3) ? + root.countDotWidth : root.countDotHeight // Circles when too many + implicitHeight: root.countDotHeight + color: appIsActive ? Appearance.colors.colPrimary : ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.4) + } + } + } + } + } +} diff --git a/ii/modules/dock/DockApps.qml b/ii/modules/dock/DockApps.qml new file mode 100644 index 0000000..4c11863 --- /dev/null +++ b/ii/modules/dock/DockApps.qml @@ -0,0 +1,267 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland + +Item { + id: root + property real maxWindowPreviewHeight: 200 + property real maxWindowPreviewWidth: 300 + property real windowControlsHeight: 30 + property real buttonPadding: 5 + + property Item lastHoveredButton + property bool buttonHovered: false + property bool requestDockShow: previewPopup.show + + Layout.fillHeight: true + Layout.topMargin: Appearance.sizes.hyprlandGapsOut // why does this work + implicitWidth: listView.implicitWidth + + StyledListView { + id: listView + spacing: 2 + orientation: ListView.Horizontal + anchors { + top: parent.top + bottom: parent.bottom + } + implicitWidth: contentWidth + + Behavior on implicitWidth { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + model: ScriptModel { + objectProp: "appId" + values: { + var map = new Map(); + + // Pinned apps + const pinnedApps = Config.options?.dock.pinnedApps ?? []; + for (const appId of pinnedApps) { + if (!map.has(appId.toLowerCase())) map.set(appId.toLowerCase(), ({ + pinned: true, + toplevels: [] + })); + } + + // Separator + if (pinnedApps.length > 0) { + map.set("SEPARATOR", { pinned: false, toplevels: [] }); + } + + // Ignored apps + const ignoredRegexStrings = Config.options?.dock.ignoredAppRegexes ?? []; + const ignoredRegexes = ignoredRegexStrings.map(pattern => new RegExp(pattern, "i")); + // Open windows + for (const toplevel of ToplevelManager.toplevels.values) { + if (ignoredRegexes.some(re => re.test(toplevel.appId))) continue; + if (!map.has(toplevel.appId.toLowerCase())) map.set(toplevel.appId.toLowerCase(), ({ + pinned: false, + toplevels: [] + })); + map.get(toplevel.appId.toLowerCase()).toplevels.push(toplevel); + } + + var values = []; + + for (const [key, value] of map) { + values.push({ appId: key, toplevels: value.toplevels, pinned: value.pinned }); + } + + return values; + } + } + delegate: DockAppButton { + required property var modelData + appToplevel: modelData + appListRoot: root + + topInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding + bottomInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding + } + } + + PopupWindow { + id: previewPopup + property var appTopLevel: root.lastHoveredButton?.appToplevel + property bool allPreviewsReady: false + Connections { + target: root + function onLastHoveredButtonChanged() { + previewPopup.allPreviewsReady = false; // Reset readiness when the hovered button changes + } + } + function updatePreviewReadiness() { + for(var i = 0; i < previewRowLayout.children.length; i++) { + const view = previewRowLayout.children[i]; + if (view.hasContent === false) { + allPreviewsReady = false; + return; + } + } + allPreviewsReady = true; + } + property bool shouldShow: { + const hoverConditions = (popupMouseArea.containsMouse || root.buttonHovered) + return hoverConditions && allPreviewsReady; + } + property bool show: false + + onShouldShowChanged: { + if (shouldShow) { + // show = true; + updateTimer.restart(); + } else { + updateTimer.restart(); + } + } + Timer { + id: updateTimer + interval: 100 + onTriggered: { + previewPopup.show = previewPopup.shouldShow + } + } + anchor { + window: root.QsWindow.window + adjustment: PopupAdjustment.None + gravity: Edges.Top | Edges.Right + edges: Edges.Top | Edges.Left + + } + visible: popupBackground.visible + color: "transparent" + implicitWidth: root.QsWindow.window?.width ?? 1 + implicitHeight: popupMouseArea.implicitHeight + root.windowControlsHeight + Appearance.sizes.elevationMargin * 2 + + MouseArea { + id: popupMouseArea + anchors.bottom: parent.bottom + implicitWidth: popupBackground.implicitWidth + Appearance.sizes.elevationMargin * 2 + implicitHeight: root.maxWindowPreviewHeight + root.windowControlsHeight + Appearance.sizes.elevationMargin * 2 + hoverEnabled: true + x: { + const itemCenter = root.QsWindow?.mapFromItem(root.lastHoveredButton, root.lastHoveredButton?.width / 2, 0); + return itemCenter.x - width / 2 + } + StyledRectangularShadow { + target: popupBackground + opacity: previewPopup.show ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + Rectangle { + id: popupBackground + property real padding: 5 + opacity: previewPopup.show ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + clip: true + color: Appearance.colors.colSurfaceContainer + radius: Appearance.rounding.normal + anchors.bottom: parent.bottom + anchors.bottomMargin: Appearance.sizes.elevationMargin + anchors.horizontalCenter: parent.horizontalCenter + implicitHeight: previewRowLayout.implicitHeight + padding * 2 + implicitWidth: previewRowLayout.implicitWidth + padding * 2 + Behavior on implicitWidth { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + RowLayout { + id: previewRowLayout + anchors.centerIn: parent + Repeater { + model: ScriptModel { + values: previewPopup.appTopLevel?.toplevels ?? [] + } + RippleButton { + id: windowButton + required property var modelData + padding: 0 + middleClickAction: () => { + windowButton.modelData?.close(); + } + onClicked: { + windowButton.modelData?.activate(); + } + contentItem: ColumnLayout { + implicitWidth: screencopyView.implicitWidth + implicitHeight: screencopyView.implicitHeight + + ButtonGroup { + contentWidth: parent.width - anchors.margins * 2 + WrapperRectangle { + Layout.fillWidth: true + color: ColorUtils.transparentize(Appearance.colors.colSurfaceContainer) + radius: Appearance.rounding.small + margin: 5 + StyledText { + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.small + text: windowButton.modelData?.title + elide: Text.ElideRight + color: Appearance.m3colors.m3onSurface + } + } + GroupButton { + id: closeButton + colBackground: ColorUtils.transparentize(Appearance.colors.colSurfaceContainer) + baseWidth: windowControlsHeight + baseHeight: windowControlsHeight + buttonRadius: Appearance.rounding.full + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "close" + iconSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onSurface + } + onClicked: { + windowButton.modelData?.close(); + } + } + } + ScreencopyView { + id: screencopyView + captureSource: previewPopup ? windowButton.modelData : null + live: true + paintCursor: true + constraintSize: Qt.size(root.maxWindowPreviewWidth, root.maxWindowPreviewHeight) + onHasContentChanged: { + previewPopup.updatePreviewReadiness(); + } + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: screencopyView.width + height: screencopyView.height + radius: Appearance.rounding.small + } + } + } + } + } + } + } + } + } + } +} diff --git a/ii/modules/dock/DockButton.qml b/ii/modules/dock/DockButton.qml new file mode 100644 index 0000000..8d366bc --- /dev/null +++ b/ii/modules/dock/DockButton.qml @@ -0,0 +1,15 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +RippleButton { + Layout.fillHeight: true + Layout.topMargin: Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut + implicitWidth: implicitHeight - topInset - bottomInset + buttonRadius: Appearance.rounding.normal + + background.implicitHeight: 50 +} diff --git a/ii/modules/dock/DockSeparator.qml b/ii/modules/dock/DockSeparator.qml new file mode 100644 index 0000000..419b0fe --- /dev/null +++ b/ii/modules/dock/DockSeparator.qml @@ -0,0 +1,13 @@ +import qs +import qs.modules.common +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + Layout.topMargin: Appearance.sizes.elevationMargin + dockRow.padding + Appearance.rounding.normal + Layout.bottomMargin: Appearance.sizes.hyprlandGapsOut + dockRow.padding + Appearance.rounding.normal + Layout.fillHeight: true + implicitWidth: 1 + color: Appearance.colors.colOutlineVariant +} diff --git a/ii/modules/lock/Lock.qml b/ii/modules/lock/Lock.qml new file mode 100644 index 0000000..2024c19 --- /dev/null +++ b/ii/modules/lock/Lock.qml @@ -0,0 +1,102 @@ +import qs +import qs.modules.common +import qs.modules.common.functions +import qs.modules.lock +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + // This stores all the information shared between the lock surfaces on each screen. + // https://github.com/quickshell-mirror/quickshell-examples/tree/master/lockscreen + LockContext { + id: lockContext + + onUnlocked: { + // Unlock the screen before exiting, or the compositor will display a + // fallback lock you can't interact with. + GlobalStates.screenLocked = false; + + // Refocus last focused window on unlock (hack) + Quickshell.execDetached(["bash", "-c", `sleep 0.2; hyprctl --batch "dispatch togglespecialworkspace; dispatch togglespecialworkspace"`]) + } + } + + WlSessionLock { + id: lock + locked: GlobalStates.screenLocked + + WlSessionLockSurface { + color: "transparent" + Loader { + active: GlobalStates.screenLocked + anchors.fill: parent + opacity: active ? 1 : 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + sourceComponent: LockSurface { + context: lockContext + } + } + } + } + + // Blur layer hack + Variants { + model: Quickshell.screens + + LazyLoader { + id: blurLayerLoader + required property var modelData + active: GlobalStates.screenLocked + component: PanelWindow { + screen: blurLayerLoader.modelData + WlrLayershell.namespace: "quickshell:lockWindowPusher" + color: "transparent" + anchors { + top: true + left: true + right: true + } + // implicitHeight: lockContext.currentText == "" ? 1 : screen.height + implicitHeight: 1 + exclusiveZone: screen.height * 3 // For some reason if we don't multiply by some number it would look really weird + } + } + } + + IpcHandler { + target: "lock" + + function activate(): void { + GlobalStates.screenLocked = true; + } + function focus(): void { + lockContext.shouldReFocus(); + } + } + + GlobalShortcut { + name: "lock" + description: "Locks the screen" + + onPressed: { + GlobalStates.screenLocked = true; + } + } + + GlobalShortcut { + name: "lockFocus" + description: "Re-focuses the lock screen. This is because Hyprland after waking up for whatever reason" + + "decides to keyboard-unfocus the lock screen" + + onPressed: { + // console.log("I BEG FOR PLEAS REFOCUZ") + lockContext.shouldReFocus(); + } + } +} diff --git a/ii/modules/lock/LockContext.qml b/ii/modules/lock/LockContext.qml new file mode 100644 index 0000000..976aa9c --- /dev/null +++ b/ii/modules/lock/LockContext.qml @@ -0,0 +1,67 @@ +import qs +import QtQuick +import Quickshell +import Quickshell.Services.Pam + +Scope { + id: root + signal shouldReFocus() + signal unlocked() + signal failed() + + // These properties are in the context and not individual lock surfaces + // so all surfaces can share the same state. + property string currentText: "" + property bool unlockInProgress: false + property bool showFailure: false + + function resetClearTimer() { + passwordClearTimer.restart(); + } + + Timer { + id: passwordClearTimer + interval: 10000 + onTriggered: { + root.currentText = ""; + } + } + + onCurrentTextChanged: { + if (currentText.length > 0) { + showFailure = false; + GlobalStates.screenUnlockFailed = false; + } + GlobalStates.screenLockContainsCharacters = currentText.length > 0; + passwordClearTimer.restart(); + } + + function tryUnlock() { + root.unlockInProgress = true; + pam.start(); + } + + PamContext { + id: pam + + // pam_unix will ask for a response for the password prompt + onPamMessage: { + if (this.responseRequired) { + this.respond(root.currentText); + } + } + + // pam_unix won't send any important messages so all we need is the completion status. + onCompleted: result => { + if (result == PamResult.Success) { + root.unlocked(); + } else { + root.showFailure = true; + GlobalStates.screenUnlockFailed = true; + } + + root.currentText = ""; + root.unlockInProgress = false; + } + } +} diff --git a/ii/modules/lock/LockSurface.qml b/ii/modules/lock/LockSurface.qml new file mode 100644 index 0000000..db9e0d3 --- /dev/null +++ b/ii/modules/lock/LockSurface.qml @@ -0,0 +1,285 @@ +import QtQuick +import QtQuick.Layouts +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import qs.modules.bar as Bar +import Quickshell.Services.SystemTray + +MouseArea { + id: root + required property LockContext context + property bool active: false + property bool showInputField: active || context.currentText.length > 0 + + // Force focus on entry + function forceFieldFocus() { + passwordBox.forceActiveFocus(); + } + Connections { + target: context + function onShouldReFocus() { + forceFieldFocus(); + } + } + hoverEnabled: true + acceptedButtons: Qt.LeftButton + onPressed: mouse => { + forceFieldFocus(); + } + onPositionChanged: mouse => { + forceFieldFocus(); + } + + // Toolbar appearing animation + property real toolbarScale: 0.9 + property real toolbarOpacity: 0 + Behavior on toolbarScale { + NumberAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial + } + } + Behavior on toolbarOpacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + // Init + Component.onCompleted: { + forceFieldFocus(); + toolbarScale = 1; + toolbarOpacity = 1; + } + + // Key presses + Keys.onPressed: event => { + root.context.resetClearTimer(); + if (event.key === Qt.Key_Escape) { // Esc to clear + root.context.currentText = ""; + } + forceFieldFocus(); + } + + // RippleButton { + // anchors { + // top: parent.top + // left: parent.left + // leftMargin: 10 + // topMargin: 10 + // } + // implicitHeight: 40 + // colBackground: Appearance.colors.colLayer2 + // onClicked: context.unlocked() + // contentItem: StyledText { + // text: "[[ DEBUG BYPASS ]]" + // } + // } + + // Main toolbar: password box + Toolbar { + id: mainIsland + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: 20 + } + Behavior on anchors.bottomMargin { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + scale: root.toolbarScale + opacity: root.toolbarOpacity + + ToolbarTextField { + id: passwordBox + placeholderText: GlobalStates.screenUnlockFailed ? Translation.tr("Incorrect password") : Translation.tr("Enter password") + + // Style + clip: true + font.pixelSize: Appearance.font.pixelSize.small + + // Password + enabled: !root.context.unlockInProgress + echoMode: TextInput.Password + inputMethodHints: Qt.ImhSensitiveData + + // Synchronizing (across monitors) and unlocking + onTextChanged: root.context.currentText = this.text + onAccepted: root.context.tryUnlock() + Connections { + target: root.context + function onCurrentTextChanged() { + passwordBox.text = root.context.currentText; + } + } + + Keys.onPressed: event => { + root.context.resetClearTimer(); + } + } + + ToolbarButton { + id: confirmButton + implicitWidth: height + toggled: true + enabled: !root.context.unlockInProgress + colBackgroundToggled: Appearance.colors.colPrimary + + onClicked: root.context.tryUnlock() + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + iconSize: 24 + text: "arrow_right_alt" + color: confirmButton.enabled ? Appearance.colors.colOnPrimary : Appearance.colors.colSubtext + } + } + } + + // Left toolbar + Toolbar { + id: leftIsland + anchors { + right: mainIsland.left + top: mainIsland.top + bottom: mainIsland.bottom + rightMargin: 10 + } + scale: root.toolbarScale + opacity: root.toolbarOpacity + + // Username + RowLayout { + spacing: 6 + Layout.leftMargin: 8 + Layout.fillHeight: true + + MaterialSymbol { + id: userIcon + Layout.alignment: Qt.AlignVCenter + fill: 1 + text: "account_circle" + iconSize: Appearance.font.pixelSize.huge + color: Appearance.colors.colOnSurfaceVariant + } + StyledText { + Layout.alignment: Qt.AlignVCenter + text: SystemInfo.username + color: Appearance.colors.colOnSurfaceVariant + } + } + + // Keyboard layout (Xkb) + Loader { + Layout.leftMargin: 8 + Layout.rightMargin: 8 + Layout.fillHeight: true + + active: true + visible: active + + sourceComponent: RowLayout { + spacing: 8 + + MaterialSymbol { + id: keyboardIcon + Layout.alignment: Qt.AlignVCenter + fill: 1 + text: "keyboard_alt" + iconSize: Appearance.font.pixelSize.huge + color: Appearance.colors.colOnSurfaceVariant + } + StyledText { + text: HyprlandXkb.currentLayoutCode + color: Appearance.colors.colOnSurfaceVariant + animateChange: true + } + } + } + + // Keyboard layout (Fcitx) + Bar.SysTray { + Layout.rightMargin: 10 + Layout.alignment: Qt.AlignVCenter + showSeparator: false + showOverflowMenu: false + pinnedItems: SystemTray.items.values.filter(i => i.id == "Fcitx") + visible: pinnedItems.length > 0 + } + } + + // Right toolbar + Toolbar { + id: rightIsland + anchors { + left: mainIsland.right + top: mainIsland.top + bottom: mainIsland.bottom + leftMargin: 10 + } + + scale: root.toolbarScale + opacity: root.toolbarOpacity + + RowLayout { + spacing: 6 + Layout.fillHeight: true + Layout.leftMargin: 10 + Layout.rightMargin: 10 + + MaterialSymbol { + id: boltIcon + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: -2 + Layout.rightMargin: -2 + fill: 1 + text: Battery.isCharging ? "bolt" : "battery_android_full" + iconSize: Appearance.font.pixelSize.huge + animateChange: true + color: (Battery.isLow && !Battery.isCharging) ? Appearance.colors.colError : Appearance.colors.colOnSurfaceVariant + } + StyledText { + Layout.alignment: Qt.AlignVCenter + text: Math.round(Battery.percentage * 100) + color: (Battery.isLow && !Battery.isCharging) ? Appearance.colors.colError : Appearance.colors.colOnSurfaceVariant + } + } + + ToolbarButton { + id: sleepButton + implicitWidth: height + + onClicked: Session.suspend() + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + iconSize: 24 + text: "dark_mode" + color: Appearance.colors.colOnSurfaceVariant + } + } + + ToolbarButton { + id: powerButton + implicitWidth: height + + onClicked: Session.poweroff() + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + iconSize: 24 + text: "power_settings_new" + color: Appearance.colors.colOnSurfaceVariant + } + } + } +} diff --git a/ii/modules/mediaControls/MediaControls.qml b/ii/modules/mediaControls/MediaControls.qml new file mode 100644 index 0000000..b574207 --- /dev/null +++ b/ii/modules/mediaControls/MediaControls.qml @@ -0,0 +1,244 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs +import qs.modules.common.functions +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + property bool visible: false + readonly property MprisPlayer activePlayer: MprisController.activePlayer + readonly property var realPlayers: Mpris.players.values.filter(player => isRealPlayer(player)) + readonly property var meaningfulPlayers: filterDuplicatePlayers(realPlayers) + readonly property real osdWidth: Appearance.sizes.osdWidth + readonly property real widgetWidth: Appearance.sizes.mediaControlsWidth + readonly property real widgetHeight: Appearance.sizes.mediaControlsHeight + property real contentPadding: 13 + property real popupRounding: Appearance.rounding.screenRounding - Appearance.sizes.elevationMargin + 1 + property real artRounding: Appearance.rounding.verysmall + property list visualizerPoints: [] + + property bool hasPlasmaIntegration: false + Process { + id: plasmaIntegrationAvailabilityCheckProc + running: true + command: ["bash", "-c", "command -v plasma-browser-integration-host"] + onExited: (exitCode, exitStatus) => { + root.hasPlasmaIntegration = (exitCode === 0); + } + } + function isRealPlayer(player) { + if (!Config.options.media.filterDuplicatePlayers) { + return true; + } + return ( + // Remove unecessary native buses from browsers if there's plasma integration + !(hasPlasmaIntegration && player.dbusName.startsWith('org.mpris.MediaPlayer2.firefox')) && !(hasPlasmaIntegration && player.dbusName.startsWith('org.mpris.MediaPlayer2.chromium')) && + // playerctld just copies other buses and we don't need duplicates + !player.dbusName?.startsWith('org.mpris.MediaPlayer2.playerctld') && + // Non-instance mpd bus + !(player.dbusName?.endsWith('.mpd') && !player.dbusName.endsWith('MediaPlayer2.mpd'))); + } + function filterDuplicatePlayers(players) { + let filtered = []; + let used = new Set(); + + for (let i = 0; i < players.length; ++i) { + if (used.has(i)) + continue; + let p1 = players[i]; + let group = [i]; + + // Find duplicates by trackTitle prefix + for (let j = i + 1; j < players.length; ++j) { + let p2 = players[j]; + if (p1.trackTitle && p2.trackTitle && (p1.trackTitle.includes(p2.trackTitle) || p2.trackTitle.includes(p1.trackTitle)) || (p1.position - p2.position <= 2 && p1.length - p2.length <= 2)) { + group.push(j); + } + } + + // Pick the one with non-empty trackArtUrl, or fallback to the first + let chosenIdx = group.find(idx => players[idx].trackArtUrl && players[idx].trackArtUrl.length > 0); + if (chosenIdx === undefined) + chosenIdx = group[0]; + + filtered.push(players[chosenIdx]); + group.forEach(idx => used.add(idx)); + } + return filtered; + } + + Process { + id: cavaProc + running: mediaControlsLoader.active + onRunningChanged: { + if (!cavaProc.running) { + root.visualizerPoints = []; + } + } + command: ["cava", "-p", `${FileUtils.trimFileProtocol(Directories.scriptPath)}/cava/raw_output_config.txt`] + stdout: SplitParser { + onRead: data => { + // Parse `;`-separated values into the visualizerPoints array + let points = data.split(";").map(p => parseFloat(p.trim())).filter(p => !isNaN(p)); + root.visualizerPoints = points; + } + } + } + + Loader { + id: mediaControlsLoader + active: GlobalStates.mediaControlsOpen + onActiveChanged: { + if (!mediaControlsLoader.active && Mpris.players.values.filter(player => isRealPlayer(player)).length === 0) { + GlobalStates.mediaControlsOpen = false; + } + } + + sourceComponent: PanelWindow { + id: mediaControlsRoot + visible: true + + exclusionMode: ExclusionMode.Ignore + exclusiveZone: 0 + implicitWidth: root.widgetWidth + implicitHeight: playerColumnLayout.implicitHeight + color: "transparent" + WlrLayershell.namespace: "quickshell:mediaControls" + + anchors { + top: !Config.options.bar.bottom || Config.options.bar.vertical + bottom: Config.options.bar.bottom && !Config.options.bar.vertical + left: !(Config.options.bar.vertical && Config.options.bar.bottom) + right: Config.options.bar.vertical && Config.options.bar.bottom + } + margins { + top: Config.options.bar.vertical ? ((mediaControlsRoot.screen.height / 2) - widgetHeight * 1.5) : Appearance.sizes.barHeight + bottom: Appearance.sizes.barHeight + left: Config.options.bar.vertical ? Appearance.sizes.barHeight : ((mediaControlsRoot.screen.width / 2) - (osdWidth / 2) - widgetWidth) + right: Appearance.sizes.barHeight + } + + mask: Region { + item: playerColumnLayout + } + + HyprlandFocusGrab { + windows: [mediaControlsRoot] + active: mediaControlsLoader.active + onCleared: () => { + if (!active) { + GlobalStates.mediaControlsOpen = false; + } + } + } + + ColumnLayout { + id: playerColumnLayout + anchors.fill: parent + spacing: -Appearance.sizes.elevationMargin // Shadow overlap okay + + Repeater { + model: ScriptModel { + values: root.meaningfulPlayers + } + delegate: PlayerControl { + required property MprisPlayer modelData + player: modelData + visualizerPoints: root.visualizerPoints + implicitWidth: widgetWidth + implicitHeight: widgetHeight + radius: root.popupRounding + } + } + + Item { // No player placeholder + Layout.fillWidth: true + visible: root.meaningfulPlayers.length === 0 + implicitWidth: placeholderBackground.implicitWidth + Appearance.sizes.elevationMargin + implicitHeight: placeholderBackground.implicitHeight + Appearance.sizes.elevationMargin + + StyledRectangularShadow { + target: placeholderBackground + } + + Rectangle { + id: placeholderBackground + anchors.centerIn: parent + color: Appearance.colors.colLayer0 + radius: root.popupRounding + property real padding: 20 + implicitWidth: placeholderLayout.implicitWidth + padding * 2 + implicitHeight: placeholderLayout.implicitHeight + padding * 2 + + ColumnLayout { + id: placeholderLayout + anchors.centerIn: parent + + StyledText { + text: Translation.tr("No active player") + font.pixelSize: Appearance.font.pixelSize.large + } + StyledText { + color: Appearance.colors.colSubtext + text: Translation.tr("Make sure your player has MPRIS support\nor try turning off duplicate player filtering") + font.pixelSize: Appearance.font.pixelSize.small + } + } + } + } + } + } + } + + IpcHandler { + target: "mediaControls" + + function toggle(): void { + mediaControlsLoader.active = !mediaControlsLoader.active; + if (mediaControlsLoader.active) + Notifications.timeoutAll(); + } + + function close(): void { + mediaControlsLoader.active = false; + } + + function open(): void { + mediaControlsLoader.active = true; + Notifications.timeoutAll(); + } + } + + GlobalShortcut { + name: "mediaControlsToggle" + description: "Toggles media controls on press" + + onPressed: { + GlobalStates.mediaControlsOpen = !GlobalStates.mediaControlsOpen; + } + } + GlobalShortcut { + name: "mediaControlsOpen" + description: "Opens media controls on press" + + onPressed: { + GlobalStates.mediaControlsOpen = true; + } + } + GlobalShortcut { + name: "mediaControlsClose" + description: "Closes media controls on press" + + onPressed: { + GlobalStates.mediaControlsOpen = false; + } + } +} diff --git a/ii/modules/mediaControls/PlayerControl.qml b/ii/modules/mediaControls/PlayerControl.qml new file mode 100644 index 0000000..d83b857 --- /dev/null +++ b/ii/modules/mediaControls/PlayerControl.qml @@ -0,0 +1,301 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris + +Item { // Player instance + id: playerController + required property MprisPlayer player + property var artUrl: player?.trackArtUrl + property string artDownloadLocation: Directories.coverArt + property string artFileName: Qt.md5(artUrl) + ".jpg" + property string artFilePath: `${artDownloadLocation}/${artFileName}` + property color artDominantColor: ColorUtils.mix((colorQuantizer?.colors[0] ?? Appearance.colors.colPrimary), Appearance.colors.colPrimaryContainer, 0.8) || Appearance.m3colors.m3secondaryContainer + property bool downloaded: false + property list visualizerPoints: [] + property real maxVisualizerValue: 1000 // Max value in the data points + property int visualizerSmoothing: 2 // Number of points to average for smoothing + property real radius + + component TrackChangeButton: RippleButton { + implicitWidth: 24 + implicitHeight: 24 + + property var iconName + colBackground: ColorUtils.transparentize(blendedColors.colSecondaryContainer, 1) + colBackgroundHover: blendedColors.colSecondaryContainerHover + colRipple: blendedColors.colSecondaryContainerActive + + contentItem: MaterialSymbol { + iconSize: Appearance.font.pixelSize.huge + fill: 1 + horizontalAlignment: Text.AlignHCenter + color: blendedColors.colOnSecondaryContainer + text: iconName + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + + Timer { // Force update for prevision + running: playerController.player?.playbackState == MprisPlaybackState.Playing + interval: 1000 + repeat: true + onTriggered: { + playerController.player.positionChanged() + } + } + + onArtUrlChanged: { + if (playerController.artUrl.length == 0) { + playerController.artDominantColor = Appearance.m3colors.m3secondaryContainer + return; + } + // console.log("PlayerControl: Art URL changed to", playerController.artUrl) + // console.log("Download cmd:", coverArtDownloader.command.join(" ")) + playerController.downloaded = false + coverArtDownloader.running = true + } + + Process { // Cover art downloader + id: coverArtDownloader + property string targetFile: playerController.artUrl + command: [ "bash", "-c", `[ -f ${artFilePath} ] || curl -sSL '${targetFile}' -o '${artFilePath}'` ] + onExited: (exitCode, exitStatus) => { + playerController.downloaded = true + } + } + + ColorQuantizer { + id: colorQuantizer + source: playerController.downloaded ? Qt.resolvedUrl(artFilePath) : "" + depth: 0 // 2^0 = 1 color + rescaleSize: 1 // Rescale to 1x1 pixel for faster processing + } + + property bool backgroundIsDark: artDominantColor.hslLightness < 0.5 + property QtObject blendedColors: QtObject { + property color colLayer0: ColorUtils.mix(Appearance.colors.colLayer0, artDominantColor, (backgroundIsDark && Appearance.m3colors.darkmode) ? 0.6 : 0.5) + property color colLayer1: ColorUtils.mix(Appearance.colors.colLayer1, artDominantColor, 0.5) + property color colOnLayer0: ColorUtils.mix(Appearance.colors.colOnLayer0, artDominantColor, 0.5) + property color colOnLayer1: ColorUtils.mix(Appearance.colors.colOnLayer1, artDominantColor, 0.5) + property color colSubtext: ColorUtils.mix(Appearance.colors.colOnLayer1, artDominantColor, 0.5) + property color colPrimary: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimary, artDominantColor), artDominantColor, 0.5) + property color colPrimaryHover: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimaryHover, artDominantColor), artDominantColor, 0.3) + property color colPrimaryActive: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.colors.colPrimaryActive, artDominantColor), artDominantColor, 0.3) + property color colSecondaryContainer: ColorUtils.mix(Appearance.m3colors.m3secondaryContainer, artDominantColor, 0.15) + property color colSecondaryContainerHover: ColorUtils.mix(Appearance.colors.colSecondaryContainerHover, artDominantColor, 0.3) + property color colSecondaryContainerActive: ColorUtils.mix(Appearance.colors.colSecondaryContainerActive, artDominantColor, 0.5) + property color colOnPrimary: ColorUtils.mix(ColorUtils.adaptToAccent(Appearance.m3colors.m3onPrimary, artDominantColor), artDominantColor, 0.5) + property color colOnSecondaryContainer: ColorUtils.mix(Appearance.m3colors.m3onSecondaryContainer, artDominantColor, 0.5) + + } + + StyledRectangularShadow { + target: background + } + Rectangle { // Background + id: background + anchors.fill: parent + anchors.margins: Appearance.sizes.elevationMargin + color: blendedColors.colLayer0 + radius: playerController.radius + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: background.width + height: background.height + radius: background.radius + } + } + + Image { + id: blurredArt + anchors.fill: parent + source: playerController.downloaded ? Qt.resolvedUrl(artFilePath) : "" + sourceSize.width: background.width + sourceSize.height: background.height + fillMode: Image.PreserveAspectCrop + cache: false + antialiasing: true + asynchronous: true + + layer.enabled: true + layer.effect: MultiEffect { + source: blurredArt + saturation: 0.2 + blurEnabled: true + blurMax: 100 + blur: 1 + } + + Rectangle { + anchors.fill: parent + color: ColorUtils.transparentize(blendedColors.colLayer0, 0.3) + radius: playerController.radius + } + } + + WaveVisualizer { + id: visualizerCanvas + anchors.fill: parent + live: playerController.player?.isPlaying + points: playerController.visualizerPoints + maxVisualizerValue: playerController.maxVisualizerValue + smoothing: playerController.visualizerSmoothing + color: blendedColors.colPrimary + } + + RowLayout { + anchors.fill: parent + anchors.margins: root.contentPadding + spacing: 15 + + Rectangle { // Art background + id: artBackground + Layout.fillHeight: true + implicitWidth: height + radius: root.artRounding + color: ColorUtils.transparentize(blendedColors.colLayer1, 0.5) + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: artBackground.width + height: artBackground.height + radius: artBackground.radius + } + } + + Image { // Art image + id: mediaArt + property int size: parent.height + anchors.fill: parent + + source: playerController.downloaded ? Qt.resolvedUrl(artFilePath) : "" + fillMode: Image.PreserveAspectCrop + cache: false + antialiasing: true + asynchronous: true + + width: size + height: size + sourceSize.width: size + sourceSize.height: size + } + } + + ColumnLayout { // Info & controls + Layout.fillHeight: true + spacing: 2 + + StyledText { + id: trackTitle + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.large + color: blendedColors.colOnLayer0 + elide: Text.ElideRight + text: StringUtils.cleanMusicTitle(playerController.player?.trackTitle) || "Untitled" + animateChange: true + animationDistanceX: 6 + animationDistanceY: 0 + } + StyledText { + id: trackArtist + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.smaller + color: blendedColors.colSubtext + elide: Text.ElideRight + text: playerController.player?.trackArtist + animateChange: true + animationDistanceX: 6 + animationDistanceY: 0 + } + Item { Layout.fillHeight: true } + Item { + Layout.fillWidth: true + implicitHeight: trackTime.implicitHeight + sliderRow.implicitHeight + + StyledText { + id: trackTime + anchors.bottom: sliderRow.top + anchors.bottomMargin: 5 + anchors.left: parent.left + font.pixelSize: Appearance.font.pixelSize.small + color: blendedColors.colSubtext + elide: Text.ElideRight + text: `${StringUtils.friendlyTimeForSeconds(playerController.player?.position)} / ${StringUtils.friendlyTimeForSeconds(playerController.player?.length)}` + } + RowLayout { + id: sliderRow + anchors { + bottom: parent.bottom + left: parent.left + right: parent.right + } + TrackChangeButton { + iconName: "skip_previous" + onClicked: playerController.player?.previous() + } + Item { + id: progressBarContainer + Layout.fillWidth: true + implicitHeight: progressBar.implicitHeight + + StyledProgressBar { + id: progressBar + anchors.fill: parent + highlightColor: blendedColors.colPrimary + trackColor: blendedColors.colSecondaryContainer + value: playerController.player?.position / playerController.player?.length + sperm: playerController.player?.isPlaying + } + } + TrackChangeButton { + iconName: "skip_next" + onClicked: playerController.player?.next() + } + } + + RippleButton { + id: playPauseButton + anchors.right: parent.right + anchors.bottom: sliderRow.top + anchors.bottomMargin: 5 + property real size: 44 + implicitWidth: size + implicitHeight: size + onClicked: playerController.player.togglePlaying(); + + buttonRadius: playerController.player?.isPlaying ? Appearance?.rounding.normal : size / 2 + colBackground: playerController.player?.isPlaying ? blendedColors.colPrimary : blendedColors.colSecondaryContainer + colBackgroundHover: playerController.player?.isPlaying ? blendedColors.colPrimaryHover : blendedColors.colSecondaryContainerHover + colRipple: playerController.player?.isPlaying ? blendedColors.colPrimaryActive : blendedColors.colSecondaryContainerActive + + contentItem: MaterialSymbol { + iconSize: Appearance.font.pixelSize.huge + fill: 1 + horizontalAlignment: Text.AlignHCenter + color: playerController.player?.isPlaying ? blendedColors.colOnPrimary : blendedColors.colOnSecondaryContainer + text: playerController.player?.isPlaying ? "pause" : "play_arrow" + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/ii/modules/notificationPopup/NotificationPopup.qml b/ii/modules/notificationPopup/NotificationPopup.qml new file mode 100644 index 0000000..d954cbf --- /dev/null +++ b/ii/modules/notificationPopup/NotificationPopup.qml @@ -0,0 +1,50 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: notificationPopup + + PanelWindow { + id: root + visible: (Notifications.popupList.length > 0) && !GlobalStates.screenLocked + screen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) ?? null + + WlrLayershell.namespace: "quickshell:notificationPopup" + WlrLayershell.layer: WlrLayer.Overlay + exclusiveZone: 0 + + anchors { + top: true + right: true + bottom: true + } + + mask: Region { + item: listview.contentItem + } + + color: "transparent" + implicitWidth: Appearance.sizes.notificationPopupWidth + + NotificationListView { + id: listview + anchors { + top: parent.top + bottom: parent.bottom + right: parent.right + rightMargin: 4 + topMargin: 4 + } + implicitWidth: parent.width - Appearance.sizes.elevationMargin * 2 + popup: true + } + } +} diff --git a/ii/modules/onScreenDisplay/OnScreenDisplayBrightness.qml b/ii/modules/onScreenDisplay/OnScreenDisplayBrightness.qml new file mode 100644 index 0000000..13f78b6 --- /dev/null +++ b/ii/modules/onScreenDisplay/OnScreenDisplayBrightness.qml @@ -0,0 +1,157 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import Quickshell.Wayland + +Scope { + id: root + property var focusedScreen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) + property var brightnessMonitor: Brightness.getMonitorForScreen(focusedScreen) + + function triggerOsd() { + GlobalStates.osdBrightnessOpen = true + osdTimeout.restart() + } + + Timer { + id: osdTimeout + interval: Config.options.osd.timeout + repeat: false + running: false + onTriggered: { + GlobalStates.osdBrightnessOpen = false + } + } + + Connections { + target: Audio.sink?.audio ?? null + function onVolumeChanged() { + if (!Audio.ready) return + GlobalStates.osdBrightnessOpen = false + } + } + + Connections { + target: Brightness + function onBrightnessChanged() { + if (!root.brightnessMonitor.ready) return + root.triggerOsd() + } + } + + Loader { + id: osdLoader + active: GlobalStates.osdBrightnessOpen + + sourceComponent: PanelWindow { + id: osdRoot + color: "transparent" + + Connections { + target: root + function onFocusedScreenChanged() { + osdRoot.screen = root.focusedScreen + } + } + + WlrLayershell.namespace: "quickshell:onScreenDisplay" + WlrLayershell.layer: WlrLayer.Overlay + anchors { + top: !Config.options.bar.bottom + bottom: Config.options.bar.bottom + } + mask: Region { + item: osdValuesWrapper + } + + exclusionMode: ExclusionMode.Ignore + exclusiveZone: 0 + margins { + top: Appearance.sizes.barHeight + bottom: Appearance.sizes.barHeight + } + + implicitWidth: columnLayout.implicitWidth + implicitHeight: columnLayout.implicitHeight + visible: osdLoader.active + + ColumnLayout { + id: columnLayout + anchors.horizontalCenter: parent.horizontalCenter + Item { + id: osdValuesWrapper + // Extra space for shadow + implicitHeight: osdValues.implicitHeight + Appearance.sizes.elevationMargin * 2 + implicitWidth: osdValues.implicitWidth + clip: true + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: GlobalStates.osdBrightnessOpen = false + } + + Behavior on implicitHeight { + NumberAnimation { + duration: Appearance.animation.menuDecel.duration + easing.type: Appearance.animation.menuDecel.type + } + } + + OsdValueIndicator { + id: osdValues + anchors.fill: parent + anchors.margins: Appearance.sizes.elevationMargin + value: root.brightnessMonitor?.brightness ?? 50 + icon: "light_mode" + rotateIcon: true + scaleIcon: true + name: Translation.tr("Brightness") + } + } + } + + } + } + + IpcHandler { + target: "osdBrightness" + + function trigger() { + root.triggerOsd() + } + + function hide() { + GlobalStates.osdBrightnessOpen = false + } + + function toggle() { + GlobalStates.osdBrightnessOpen = !GlobalStates.osdBrightnessOpen + } + } + + GlobalShortcut { + name: "osdBrightnessTrigger" + description: "Triggers brightness OSD on press" + + onPressed: { + root.triggerOsd() + } + } + GlobalShortcut { + name: "osdBrightnessHide" + description: "Hides brightness OSD on press" + + onPressed: { + GlobalStates.osdBrightnessOpen = false + } + } + +} diff --git a/ii/modules/onScreenDisplay/OnScreenDisplayVolume.qml b/ii/modules/onScreenDisplay/OnScreenDisplayVolume.qml new file mode 100644 index 0000000..51207d6 --- /dev/null +++ b/ii/modules/onScreenDisplay/OnScreenDisplayVolume.qml @@ -0,0 +1,208 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + property string protectionMessage: "" + property var focusedScreen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) + + function triggerOsd() { + GlobalStates.osdVolumeOpen = true + osdTimeout.restart() + } + + Timer { + id: osdTimeout + interval: Config.options.osd.timeout + repeat: false + running: false + onTriggered: { + GlobalStates.osdVolumeOpen = false + root.protectionMessage = "" + } + } + + Connections { + target: Brightness + function onBrightnessChanged() { + GlobalStates.osdVolumeOpen = false + } + } + + Connections { // Listen to volume changes + target: Audio.sink?.audio ?? null + function onVolumeChanged() { + if (!Audio.ready) return + root.triggerOsd() + } + function onMutedChanged() { + if (!Audio.ready) return + root.triggerOsd() + } + } + + Connections { // Listen to protection triggers + target: Audio + function onSinkProtectionTriggered(reason) { + root.protectionMessage = reason; + root.triggerOsd() + } + } + + Loader { + id: osdLoader + active: GlobalStates.osdVolumeOpen + + sourceComponent: PanelWindow { + id: osdRoot + color: "transparent" + + Connections { + target: root + function onFocusedScreenChanged() { + osdRoot.screen = root.focusedScreen + } + } + + WlrLayershell.namespace: "quickshell:onScreenDisplay" + WlrLayershell.layer: WlrLayer.Overlay + anchors { + top: !Config.options.bar.bottom + bottom: Config.options.bar.bottom + } + mask: Region { + item: osdValuesWrapper + } + + exclusionMode: ExclusionMode.Ignore + exclusiveZone: 0 + margins { + top: Appearance.sizes.barHeight + bottom: Appearance.sizes.barHeight + } + + implicitWidth: columnLayout.implicitWidth + implicitHeight: columnLayout.implicitHeight + visible: osdLoader.active + + ColumnLayout { + id: columnLayout + anchors.horizontalCenter: parent.horizontalCenter + Item { + id: osdValuesWrapper + // Extra space for shadow + implicitHeight: contentColumnLayout.implicitHeight + Appearance.sizes.elevationMargin * 2 + implicitWidth: contentColumnLayout.implicitWidth + clip: true + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: GlobalStates.osdVolumeOpen = false + } + + ColumnLayout { + id: contentColumnLayout + anchors { + top: parent.top + left: parent.left + right: parent.right + leftMargin: Appearance.sizes.elevationMargin + rightMargin: Appearance.sizes.elevationMargin + } + spacing: 0 + + OsdValueIndicator { + id: osdValues + Layout.fillWidth: true + value: Audio.sink?.audio.volume ?? 0 + icon: Audio.sink?.audio.muted ? "volume_off" : "volume_up" + name: Translation.tr("Volume") + } + + Item { + id: protectionMessageWrapper + implicitHeight: protectionMessageBackground.implicitHeight + implicitWidth: protectionMessageBackground.implicitWidth + Layout.alignment: Qt.AlignHCenter + opacity: root.protectionMessage !== "" ? 1 : 0 + + StyledRectangularShadow { + target: protectionMessageBackground + } + Rectangle { + id: protectionMessageBackground + anchors.centerIn: parent + color: Appearance.m3colors.m3error + property real padding: 10 + implicitHeight: protectionMessageRowLayout.implicitHeight + padding * 2 + implicitWidth: protectionMessageRowLayout.implicitWidth + padding * 2 + radius: Appearance.rounding.normal + + RowLayout { + id: protectionMessageRowLayout + anchors.centerIn: parent + MaterialSymbol { + id: protectionMessageIcon + text: "dangerous" + iconSize: Appearance.font.pixelSize.hugeass + color: Appearance.m3colors.m3onError + } + StyledText { + id: protectionMessageTextWidget + horizontalAlignment: Text.AlignHCenter + color: Appearance.m3colors.m3onError + wrapMode: Text.Wrap + text: root.protectionMessage + } + } + } + } + } + } + } + } + } + + IpcHandler { + target: "osdVolume" + + function trigger() { + root.triggerOsd() + } + + function hide() { + GlobalStates.osdVolumeOpen = false + } + + function toggle() { + GlobalStates.osdVolumeOpen = !GlobalStates.osdVolumeOpen + } + } + GlobalShortcut { + name: "osdVolumeTrigger" + description: "Triggers volume OSD on press" + + onPressed: { + root.triggerOsd() + } + } + GlobalShortcut { + name: "osdVolumeHide" + description: "Hides volume OSD on press" + + onPressed: { + GlobalStates.osdVolumeOpen = false + } + } + +} diff --git a/ii/modules/onScreenDisplay/OsdValueIndicator.qml b/ii/modules/onScreenDisplay/OsdValueIndicator.qml new file mode 100644 index 0000000..6edd24a --- /dev/null +++ b/ii/modules/onScreenDisplay/OsdValueIndicator.qml @@ -0,0 +1,104 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +// import Qt5Compat.GraphicalEffects + +Item { + id: root + required property real value + required property string icon + required property string name + property bool rotateIcon: false + property bool scaleIcon: false + + property real valueIndicatorVerticalPadding: 9 + property real valueIndicatorLeftPadding: 10 + property real valueIndicatorRightPadding: 20 // An icon is circle ish, a column isn't, hence the extra padding + + Layout.margins: Appearance.sizes.elevationMargin + implicitWidth: Appearance.sizes.osdWidth + implicitHeight: valueIndicator.implicitHeight + + StyledRectangularShadow { + target: valueIndicator + } + WrapperRectangle { + id: valueIndicator + anchors.fill: parent + radius: Appearance.rounding.full + color: Appearance.colors.colLayer0 + implicitWidth: valueRow.implicitWidth + + RowLayout { // Icon on the left, stuff on the right + id: valueRow + Layout.margins: 10 + anchors.fill: parent + spacing: 10 + + Item { + implicitWidth: 30 + implicitHeight: 30 + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: valueIndicatorLeftPadding + Layout.topMargin: valueIndicatorVerticalPadding + Layout.bottomMargin: valueIndicatorVerticalPadding + MaterialSymbol { // Icon + anchors { + centerIn: parent + alignWhenCentered: !root.rotateIcon + } + color: Appearance.colors.colOnLayer0 + renderType: Text.QtRendering + + text: root.icon + iconSize: 20 + 10 * (root.scaleIcon ? value : 1) + rotation: 180 * (root.rotateIcon ? value : 0) + + Behavior on iconSize { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on rotation { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + } + } + ColumnLayout { // Stuff + Layout.alignment: Qt.AlignVCenter + Layout.rightMargin: valueIndicatorRightPadding + spacing: 5 + + RowLayout { // Name fill left, value on the right end + Layout.leftMargin: valueProgressBar.height / 2 // Align text with progressbar radius curve's left end + Layout.rightMargin: valueProgressBar.height / 2 // Align text with progressbar radius curve's left end + + StyledText { + color: Appearance.colors.colOnLayer0 + font.pixelSize: Appearance.font.pixelSize.small + Layout.fillWidth: true + text: root.name + } + + StyledText { + color: Appearance.colors.colOnLayer0 + font.pixelSize: Appearance.font.pixelSize.small + Layout.fillWidth: false + text: Math.round(root.value * 100) + } + } + + StyledProgressBar { + id: valueProgressBar + Layout.fillWidth: true + value: root.value + } + } + } + } +} \ No newline at end of file diff --git a/ii/modules/onScreenKeyboard/OnScreenKeyboard.qml b/ii/modules/onScreenKeyboard/OnScreenKeyboard.qml new file mode 100644 index 0000000..eae543c --- /dev/null +++ b/ii/modules/onScreenKeyboard/OnScreenKeyboard.qml @@ -0,0 +1,166 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { // Scope + id: root + property bool pinned: Config.options?.osk.pinnedOnStartup ?? false + + component OskControlButton: GroupButton { // Pin button + baseWidth: 40 + baseHeight: 40 + clickedWidth: baseWidth + clickedHeight: baseHeight + 20 + buttonRadius: Appearance.rounding.normal + } + + Loader { + id: oskLoader + active: GlobalStates.oskOpen + onActiveChanged: { + if (!oskLoader.active) { + Ydotool.releaseAllKeys(); + } + } + + sourceComponent: PanelWindow { // Window + id: oskRoot + visible: oskLoader.active && !GlobalStates.screenLocked + + anchors { + bottom: true + left: true + right: true + } + + function hide() { + oskLoader.active = false + } + exclusiveZone: root.pinned ? implicitHeight - Appearance.sizes.hyprlandGapsOut : 0 + implicitWidth: oskBackground.width + Appearance.sizes.elevationMargin * 2 + implicitHeight: oskBackground.height + Appearance.sizes.elevationMargin * 2 + WlrLayershell.namespace: "quickshell:osk" + WlrLayershell.layer: WlrLayer.Overlay + // Hyprland 0.49: Focus is always exclusive and setting this breaks mouse focus grab + // WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + color: "transparent" + + mask: Region { + item: oskBackground + } + + + // Background + StyledRectangularShadow { + target: oskBackground + } + Rectangle { + id: oskBackground + anchors.centerIn: parent + color: Appearance.colors.colLayer0 + radius: Appearance.rounding.windowRounding + property real padding: 10 + implicitWidth: oskRowLayout.implicitWidth + padding * 2 + implicitHeight: oskRowLayout.implicitHeight + padding * 2 + + Keys.onPressed: (event) => { // Esc to close + if (event.key === Qt.Key_Escape) { + oskRoot.hide() + } + } + + RowLayout { + id: oskRowLayout + anchors.centerIn: parent + spacing: 5 + VerticalButtonGroup { + OskControlButton { // Pin button + toggled: root.pinned + onClicked: root.pinned = !root.pinned + contentItem: MaterialSymbol { + text: "keep" + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + color: root.pinned ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer0 + } + } + OskControlButton { + onClicked: () => { + oskRoot.hide() + } + contentItem: MaterialSymbol { + horizontalAlignment: Text.AlignHCenter + text: "keyboard_hide" + iconSize: Appearance.font.pixelSize.larger + } + } + } + Rectangle { + Layout.topMargin: 20 + Layout.bottomMargin: 20 + Layout.fillHeight: true + implicitWidth: 1 + color: Appearance.colors.colOutlineVariant + } + OskContent { + id: oskContent + Layout.fillWidth: true + } + } + } + + } + } + + IpcHandler { + target: "osk" + + function toggle(): void { + GlobalStates.oskOpen = !GlobalStates.oskOpen; + } + + function close(): void { + GlobalStates.oskOpen = false + } + + function open(): void { + GlobalStates.oskOpen = true + } + } + + GlobalShortcut { + name: "oskToggle" + description: "Toggles on screen keyboard on press" + + onPressed: { + GlobalStates.oskOpen = !GlobalStates.oskOpen; + } + } + + GlobalShortcut { + name: "oskOpen" + description: "Opens on screen keyboard on press" + + onPressed: { + GlobalStates.oskOpen = true + } + } + + GlobalShortcut { + name: "oskClose" + description: "Closes on screen keyboard on press" + + onPressed: { + GlobalStates.oskOpen = false + } + } + +} diff --git a/ii/modules/onScreenKeyboard/OskContent.qml b/ii/modules/onScreenKeyboard/OskContent.qml new file mode 100644 index 0000000..df37969 --- /dev/null +++ b/ii/modules/onScreenKeyboard/OskContent.qml @@ -0,0 +1,49 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import "layouts.js" as Layouts +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import Quickshell.Hyprland + +Item { + id: root + property var layouts: Layouts.byName + property var activeLayoutName: (layouts.hasOwnProperty(Config.options?.osk.layout)) + ? Config.options?.osk.layout + : Layouts.defaultLayout + property var currentLayout: layouts[activeLayoutName] + + implicitWidth: keyRows.implicitWidth + implicitHeight: keyRows.implicitHeight + + ColumnLayout { + id: keyRows + anchors.fill: parent + spacing: 5 + + Repeater { + model: root.currentLayout.keys + + delegate: RowLayout { + id: keyRow + required property var modelData + spacing: 5 + + Repeater { + model: modelData + // A normal key looks like this: {label: "a", labelShift: "A", shape: "normal", keycode: 30, type: "normal"} + delegate: OskKey { + required property var modelData + keyData: modelData + } + } + } + } + } +} diff --git a/ii/modules/onScreenKeyboard/OskKey.qml b/ii/modules/onScreenKeyboard/OskKey.qml new file mode 100644 index 0000000..5ae53b9 --- /dev/null +++ b/ii/modules/onScreenKeyboard/OskKey.qml @@ -0,0 +1,121 @@ +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 var keyData + property string key: keyData.label + property string type: keyData.keytype + property var keycode: keyData.keycode + property string shape: keyData.shape + property bool isShift: Ydotool.shiftKeys.includes(keycode) + property bool isBackspace: (key.toLowerCase() == "backspace") + property bool isEnter: (key.toLowerCase() == "enter" || key.toLowerCase() == "return") + property real baseWidth: 45 + property real baseHeight: 45 + property var widthMultiplier: ({ + "normal": 1, + "fn": 1, + "tab": 1.6, + "caps": 1.9, + "shift": 2.5, + "control": 1.3 + }) + property var heightMultiplier: ({ + "normal": 1, + "fn": 0.7, + "tab": 1, + "caps": 1, + "shift": 1, + "control": 1 + }) + toggled: isShift ? Ydotool.shiftMode : false + + enabled: shape != "empty" + colBackground: shape == "empty" ? ColorUtils.transparentize(Appearance.colors.colLayer1) : Appearance.colors.colLayer1 + buttonRadius: Appearance.rounding.small + implicitWidth: baseWidth * widthMultiplier[shape] || baseWidth + implicitHeight: baseHeight * heightMultiplier[shape] || baseHeight + Layout.fillWidth: shape == "space" || shape == "expand" + + Connections { + target: Ydotool + enabled: isShift + function onShiftModeChanged() { + if (Ydotool.shiftMode == 0) { + capsLockTimer.hasStarted = false; + } + } + } + + Timer { + id: capsLockTimer + property bool hasStarted: false + property bool canCaps: false + interval: 300 + function startWaiting() { + hasStarted = true; + canCaps = true; + start(); + } + onTriggered: { + canCaps = false; + } + } + + downAction: () => { + Ydotool.press(root.keycode); + if (isShift && Ydotool.shiftMode == 0) Ydotool.shiftMode = 1; + } + releaseAction: () => { + if (root.type == "normal") { + Ydotool.release(root.keycode); + if (Ydotool.shiftMode == 1) { + Ydotool.releaseShiftKeys() + } + } else if (isShift) { + if (Ydotool.shiftMode == 1) { + if (!capsLockTimer.hasStarted) { + capsLockTimer.startWaiting(); + } else { + if (capsLockTimer.canCaps) { + Ydotool.shiftMode = 2; // Caps lock mode + } else { + Ydotool.releaseShiftKeys() + } + } + } else if (Ydotool.shiftMode == 2) { + Ydotool.releaseShiftKeys(); + } + } else if (root.type == "modkey") { + root.toggled = !root.toggled; + if (!root.toggled) { + if (isShift) { + Ydotool.releaseShiftKeys(); + } else { + Ydotool.release(root.keycode); + } + } + } + + } + + contentItem: StyledText { + id: keyText + anchors.fill: parent + font.family: (isBackspace || isEnter) ? Appearance.font.family.iconMaterial : Appearance.font.family.main + font.pixelSize: root.shape == "fn" ? Appearance.font.pixelSize.small : + (isBackspace || isEnter) ? Appearance.font.pixelSize.huge : + Appearance.font.pixelSize.large + horizontalAlignment: Text.AlignHCenter + color: root.toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer1 + text: root.isBackspace ? "backspace" : root.isEnter ? "subdirectory_arrow_left" : + Ydotool.shiftMode == 2 ? (root.keyData.labelCaps || root.keyData.labelShift || root.keyData.label) : + Ydotool.shiftMode == 1 ? (root.keyData.labelShift || root.keyData.label) : + root.keyData.label + } +} diff --git a/ii/modules/onScreenKeyboard/layouts.js b/ii/modules/onScreenKeyboard/layouts.js new file mode 100644 index 0000000..949dd45 --- /dev/null +++ b/ii/modules/onScreenKeyboard/layouts.js @@ -0,0 +1,312 @@ +// We're going to use ydotool +// See /usr/include/linux/input-event-codes.h for keycodes + +const defaultLayout = "English (US)"; +const byName = { + "English (US)": { + name_short: "US", + description: "QWERTY - Full", + comment: "Like physical keyboard", + // A key looks like this: { k: "a", ks: "A", t: "normal" } (key, key-shift, type) + // key types are: normal, tab, caps, shift, control, fn (normal w/ half height), space, expand + // keys: [ + // [{ k: "Esc", t: "fn" }, { k: "F1", t: "fn" }, { k: "F2", t: "fn" }, { k: "F3", t: "fn" }, { k: "F4", t: "fn" }, { k: "F5", t: "fn" }, { k: "F6", t: "fn" }, { k: "F7", t: "fn" }, { k: "F8", t: "fn" }, { k: "F9", t: "fn" }, { k: "F10", t: "fn" }, { k: "F11", t: "fn" }, { k: "F12", t: "fn" }, { k: "PrtSc", t: "fn" }, { k: "Del", t: "fn" }], + // [{ k: "`", ks: "~", t: "normal" }, { k: "1", ks: "!", t: "normal" }, { k: "2", ks: "@", t: "normal" }, { k: "3", ks: "#", t: "normal" }, { k: "4", ks: "$", t: "normal" }, { k: "5", ks: "%", t: "normal" }, { k: "6", ks: "^", t: "normal" }, { k: "7", ks: "&", t: "normal" }, { k: "8", ks: "*", t: "normal" }, { k: "9", ks: "(", t: "normal" }, { k: "0", ks: ")", t: "normal" }, { k: "-", ks: "_", t: "normal" }, { k: "=", ks: "+", t: "normal" }, { k: "Backspace", t: "shift" }], + // [{ k: "Tab", t: "tab" }, { k: "q", ks: "Q", t: "normal" }, { k: "w", ks: "W", t: "normal" }, { k: "e", ks: "E", t: "normal" }, { k: "r", ks: "R", t: "normal" }, { k: "t", ks: "T", t: "normal" }, { k: "y", ks: "Y", t: "normal" }, { k: "u", ks: "U", t: "normal" }, { k: "i", ks: "I", t: "normal" }, { k: "o", ks: "O", t: "normal" }, { k: "p", ks: "P", t: "normal" }, { k: "[", ks: "{", t: "normal" }, { k: "]", ks: "}", t: "normal" }, { k: "\\", ks: "|", t: "expand" }], + // [{ k: "Caps", t: "caps" }, { k: "a", ks: "A", t: "normal" }, { k: "s", ks: "S", t: "normal" }, { k: "d", ks: "D", t: "normal" }, { k: "f", ks: "F", t: "normal" }, { k: "g", ks: "G", t: "normal" }, { k: "h", ks: "H", t: "normal" }, { k: "j", ks: "J", t: "normal" }, { k: "k", ks: "K", t: "normal" }, { k: "l", ks: "L", t: "normal" }, { k: ";", ks: ":", t: "normal" }, { k: "'", ks: '"', t: "normal" }, { k: "Enter", t: "expand" }], + // [{ k: "Shift", t: "shift" }, { k: "z", ks: "Z", t: "normal" }, { k: "x", ks: "X", t: "normal" }, { k: "c", ks: "C", t: "normal" }, { k: "v", ks: "V", t: "normal" }, { k: "b", ks: "B", t: "normal" }, { k: "n", ks: "N", t: "normal" }, { k: "m", ks: "M", t: "normal" }, { k: ",", ks: "<", t: "normal" }, { k: ".", ks: ">", t: "normal" }, { k: "/", ks: "?", t: "normal" }, { k: "Shift", t: "expand" }], + // [{ k: "Ctrl", t: "control" }, { k: "Fn", t: "normal" }, { k: "Win", t: "normal" }, { k: "Alt", t: "normal" }, { k: "Space", t: "space" }, { k: "Alt", t: "normal" }, { k: "Menu", t: "normal" }, { k: "Ctrl", t: "control" }] + // ] + // A normal key looks like this: {label: "a", labelShift: "A", shape: "normal", keycode: 30, type: "normal"} + // A modkey looks like this: {label: "Ctrl", shape: "control", keycode: 29, type: "modkey"} + // key types are: normal, tab, caps, shift, control, fn (normal w/ half height), space, expand + keys: [ + [ + { keytype: "normal", label: "Esc", shape: "fn", keycode: 1 }, + { keytype: "normal", label: "F1", shape: "fn", keycode: 59 }, + { keytype: "normal", label: "F2", shape: "fn", keycode: 60 }, + { keytype: "normal", label: "F3", shape: "fn", keycode: 61 }, + { keytype: "normal", label: "F4", shape: "fn", keycode: 62 }, + { keytype: "normal", label: "F5", shape: "fn", keycode: 63 }, + { keytype: "normal", label: "F6", shape: "fn", keycode: 64 }, + { keytype: "normal", label: "F7", shape: "fn", keycode: 65 }, + { keytype: "normal", label: "F8", shape: "fn", keycode: 66 }, + { keytype: "normal", label: "F9", shape: "fn", keycode: 67 }, + { keytype: "normal", label: "F10", shape: "fn", keycode: 68 }, + { keytype: "normal", label: "F11", shape: "fn", keycode: 87 }, + { keytype: "normal", label: "F12", shape: "fn", keycode: 88 }, + { keytype: "normal", label: "PrtSc", shape: "fn", keycode: 99 }, + { keytype: "normal", label: "Del", shape: "fn", keycode: 111 } + ], + [ + { keytype: "normal", label: "`", labelShift: "~", shape: "normal", keycode: 41 }, + { keytype: "normal", label: "1", labelShift: "!", shape: "normal", keycode: 2 }, + { keytype: "normal", label: "2", labelShift: "@", shape: "normal", keycode: 3 }, + { keytype: "normal", label: "3", labelShift: "#", shape: "normal", keycode: 4 }, + { keytype: "normal", label: "4", labelShift: "$", shape: "normal", keycode: 5 }, + { keytype: "normal", label: "5", labelShift: "%", shape: "normal", keycode: 6 }, + { keytype: "normal", label: "6", labelShift: "^", shape: "normal", keycode: 7 }, + { keytype: "normal", label: "7", labelShift: "&", shape: "normal", keycode: 8 }, + { keytype: "normal", label: "8", labelShift: "*", shape: "normal", keycode: 9 }, + { keytype: "normal", label: "9", labelShift: "(", shape: "normal", keycode: 10 }, + { keytype: "normal", label: "0", labelShift: ")", shape: "normal", keycode: 11 }, + { keytype: "normal", label: "-", labelShift: "_", shape: "normal", keycode: 12 }, + { keytype: "normal", label: "=", labelShift: "+", shape: "normal", keycode: 13 }, + { keytype: "normal", label: "Backspace", shape: "expand", keycode: 14 } + ], + [ + { keytype: "normal", label: "Tab", shape: "tab", keycode: 15 }, + { keytype: "normal", label: "q", labelShift: "Q", shape: "normal", keycode: 16 }, + { keytype: "normal", label: "w", labelShift: "W", shape: "normal", keycode: 17 }, + { keytype: "normal", label: "e", labelShift: "E", shape: "normal", keycode: 18 }, + { keytype: "normal", label: "r", labelShift: "R", shape: "normal", keycode: 19 }, + { keytype: "normal", label: "t", labelShift: "T", shape: "normal", keycode: 20 }, + { keytype: "normal", label: "y", labelShift: "Y", shape: "normal", keycode: 21 }, + { keytype: "normal", label: "u", labelShift: "U", shape: "normal", keycode: 22 }, + { keytype: "normal", label: "i", labelShift: "I", shape: "normal", keycode: 23 }, + { keytype: "normal", label: "o", labelShift: "O", shape: "normal", keycode: 24 }, + { keytype: "normal", label: "p", labelShift: "P", shape: "normal", keycode: 25 }, + { keytype: "normal", label: "[", labelShift: "{", shape: "normal", keycode: 26 }, + { keytype: "normal", label: "]", labelShift: "}", shape: "normal", keycode: 27 }, + { keytype: "normal", label: "\\", labelShift: "|", shape: "expand", keycode: 43 } + ], + [ + //{ keytype: "normal", label: "Caps", shape: "caps", keycode: 58 }, // not needed as double-pressing shift does that + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "normal", label: "a", labelShift: "A", shape: "normal", keycode: 30 }, + { keytype: "normal", label: "s", labelShift: "S", shape: "normal", keycode: 31 }, + { keytype: "normal", label: "d", labelShift: "D", shape: "normal", keycode: 32 }, + { keytype: "normal", label: "f", labelShift: "F", shape: "normal", keycode: 33 }, + { keytype: "normal", label: "g", labelShift: "G", shape: "normal", keycode: 34 }, + { keytype: "normal", label: "h", labelShift: "H", shape: "normal", keycode: 35 }, + { keytype: "normal", label: "j", labelShift: "J", shape: "normal", keycode: 36 }, + { keytype: "normal", label: "k", labelShift: "K", shape: "normal", keycode: 37 }, + { keytype: "normal", label: "l", labelShift: "L", shape: "normal", keycode: 38 }, + { keytype: "normal", label: ";", labelShift: ":", shape: "normal", keycode: 39 }, + { keytype: "normal", label: "'", labelShift: '"', shape: "normal", keycode: 40 }, + { keytype: "normal", label: "Enter", shape: "expand", keycode: 28 } + ], + [ + { keytype: "modkey", label: "Shift", labelShift: "Shift", labelCaps: "Caps", shape: "shift", keycode: 42 }, + { keytype: "normal", label: "z", labelShift: "Z", shape: "normal", keycode: 44 }, + { keytype: "normal", label: "x", labelShift: "X", shape: "normal", keycode: 45 }, + { keytype: "normal", label: "c", labelShift: "C", shape: "normal", keycode: 46 }, + { keytype: "normal", label: "v", labelShift: "V", shape: "normal", keycode: 47 }, + { keytype: "normal", label: "b", labelShift: "B", shape: "normal", keycode: 48 }, + { keytype: "normal", label: "n", labelShift: "N", shape: "normal", keycode: 49 }, + { keytype: "normal", label: "m", labelShift: "M", shape: "normal", keycode: 50 }, + { keytype: "normal", label: ",", labelShift: "<", shape: "normal", keycode: 51 }, + { keytype: "normal", label: ".", labelShift: ">", shape: "normal", keycode: 52 }, + { keytype: "normal", label: "/", labelShift: "?", shape: "normal", keycode: 53 }, + { keytype: "modkey", label: "Shift", labelShift: "Shift", labelCaps: "Caps", shape: "expand", keycode: 54 } // optional + ], + [ + { keytype: "modkey", label: "Ctrl", shape: "control", keycode: 29 }, + // { label: "Super", shape: "normal", keycode: 125 }, // dangerous + { keytype: "modkey", label: "Alt", shape: "normal", keycode: 56 }, + { keytype: "normal", label: "Space", shape: "space", keycode: 57 }, + { keytype: "modkey", label: "Alt", shape: "normal", keycode: 100 }, + // { label: "Super", shape: "normal", keycode: 126 }, // dangerous + { keytype: "normal", label: "Menu", shape: "normal", keycode: 139 }, + { keytype: "modkey", label: "Ctrl", shape: "control", keycode: 97 } + ] + ] + }, + "German": { + name_short: "DE", + description: "QWERTZ - Full", + comment: "Keyboard layout commonly used in German-speaking countries", + keys: [ + [ + { keytype: "normal", label: "Esc", shape: "fn", keycode: 1 }, + { keytype: "normal", label: "F1", shape: "fn", keycode: 59 }, + { keytype: "normal", label: "F2", shape: "fn", keycode: 60 }, + { keytype: "normal", label: "F3", shape: "fn", keycode: 61 }, + { keytype: "normal", label: "F4", shape: "fn", keycode: 62 }, + { keytype: "normal", label: "F5", shape: "fn", keycode: 63 }, + { keytype: "normal", label: "F6", shape: "fn", keycode: 64 }, + { keytype: "normal", label: "F7", shape: "fn", keycode: 65 }, + { keytype: "normal", label: "F8", shape: "fn", keycode: 66 }, + { keytype: "normal", label: "F9", shape: "fn", keycode: 67 }, + { keytype: "normal", label: "F10", shape: "fn", keycode: 68 }, + { keytype: "normal", label: "F11", shape: "fn", keycode: 87 }, + { keytype: "normal", label: "F12", shape: "fn", keycode: 88 }, + { keytype: "normal", label: "Druck", shape: "fn", keycode: 99 }, + { keytype: "normal", label: "Entf", shape: "fn", keycode: 111 } + ], + [ + { keytype: "normal", label: "^", labelShift: "°", labelAlt: "′", shape: "normal", keycode: 41 }, + { keytype: "normal", label: "1", labelShift: "!", labelAlt: "¹", shape: "normal", keycode: 2 }, + { keytype: "normal", label: "2", labelShift: "\"", labelAlt: "²", shape: "normal", keycode: 3 }, + { keytype: "normal", label: "3", labelShift: "§", labelAlt: "³", shape: "normal", keycode: 4 }, + { keytype: "normal", label: "4", labelShift: "$", labelAlt: "¼", shape: "normal", keycode: 5 }, + { keytype: "normal", label: "5", labelShift: "%", labelAlt: "½", shape: "normal", keycode: 6 }, + { keytype: "normal", label: "6", labelShift: "&", labelAlt: "¬", shape: "normal", keycode: 7 }, + { keytype: "normal", label: "7", labelShift: "/", labelAlt: "{", shape: "normal", keycode: 8 }, + { keytype: "normal", label: "8", labelShift: "(", labelAlt: "[", shape: "normal", keycode: 9 }, + { keytype: "normal", label: "9", labelShift: ")", labelAlt: "]", shape: "normal", keycode: 10 }, + { keytype: "normal", label: "0", labelShift: "=", labelAlt: "}", shape: "normal", keycode: 11 }, + { keytype: "normal", label: "ß", labelShift: "?", labelAlt: "\\", shape: "normal", keycode: 12 }, + { keytype: "normal", label: "´", labelShift: "`", labelAlt: "¸", shape: "normal", keycode: 13 }, + { keytype: "normal", label: "⟵", shape: "expand", keycode: 14 } + ], + [ + { keytype: "normal", label: "Tab ⇆", shape: "tab", keycode: 15 }, + { keytype: "normal", label: "q", labelShift: "Q", labelAlt: "@", shape: "normal", keycode: 16 }, + { keytype: "normal", label: "w", labelShift: "W", labelAlt: "ſ", shape: "normal", keycode: 17 }, + { keytype: "normal", label: "e", labelShift: "E", labelAlt: "€", shape: "normal", keycode: 18 }, + { keytype: "normal", label: "r", labelShift: "R", labelAlt: "¶", shape: "normal", keycode: 19 }, + { keytype: "normal", label: "t", labelShift: "T", labelAlt: "ŧ", shape: "normal", keycode: 20 }, + { keytype: "normal", label: "z", labelShift: "Z", labelAlt: "←", shape: "normal", keycode: 21 }, + { keytype: "normal", label: "u", labelShift: "U", labelAlt: "↓", shape: "normal", keycode: 22 }, + { keytype: "normal", label: "i", labelShift: "I", labelAlt: "→", shape: "normal", keycode: 23 }, + { keytype: "normal", label: "o", labelShift: "O", labelAlt: "ø", shape: "normal", keycode: 24 }, + { keytype: "normal", label: "p", labelShift: "P", labelAlt: "þ", shape: "normal", keycode: 25 }, + { keytype: "normal", label: "ü", labelShift: "Ü", labelAlt: "¨", shape: "normal", keycode: 26 }, + { keytype: "normal", label: "+", labelShift: "*", labelAlt: "~", shape: "normal", keycode: 27 }, + { keytype: "normal", label: "↵", shape: "expand", keycode: 28 } + ], + [ + //{ keytype: "normal", label: "Umschalt ⇩", shape: "caps", keycode: 58 }, + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "normal", label: "a", labelShift: "A", labelAlt: "æ", shape: "normal", keycode: 30 }, + { keytype: "normal", label: "s", labelShift: "S", labelAlt: "ſ", shape: "normal", keycode: 31 }, + { keytype: "normal", label: "d", labelShift: "D", labelAlt: "ð", shape: "normal", keycode: 32 }, + { keytype: "normal", label: "f", labelShift: "F", labelAlt: "đ", shape: "normal", keycode: 33 }, + { keytype: "normal", label: "g", labelShift: "G", labelAlt: "ŋ", shape: "normal", keycode: 34 }, + { keytype: "normal", label: "h", labelShift: "H", labelAlt: "ħ", shape: "normal", keycode: 35 }, + { keytype: "normal", label: "j", labelShift: "J", labelAlt: "", shape: "normal", keycode: 36 }, + { keytype: "normal", label: "k", labelShift: "K", labelAlt: "ĸ", shape: "normal", keycode: 37 }, + { keytype: "normal", label: "l", labelShift: "L", labelAlt: "ł", shape: "normal", keycode: 38 }, + { keytype: "normal", label: "ö", labelShift: "Ö", labelAlt: "˝", shape: "normal", keycode: 39 }, + { keytype: "normal", label: "ä", labelShift: 'Ä', labelAlt: "^", shape: "normal", keycode: 40 }, + { keytype: "normal", label: "#", labelShift: '\'', labelAlt: "’", shape: "normal", keycode: 43 }, + { keytype: "spacer", label: "", shape: "empty" }, + //{ keytype: "normal", label: "↵", shape: "expand", keycode: 28 } + ], + [ + { keytype: "modkey", label: "Shift", labelShift: "Shift ⇧", labelCaps: "Locked ⇩", shape: "shift", keycode: 42 }, + { keytype: "normal", label: "<", labelShift: ">", labelAlt: "|", shape: "normal", keycode: 86 }, + { keytype: "normal", label: "y", labelShift: "Y", labelAlt: "»", shape: "normal", keycode: 44 }, + { keytype: "normal", label: "x", labelShift: "X", labelAlt: "«", shape: "normal", keycode: 45 }, + { keytype: "normal", label: "c", labelShift: "C", labelAlt: "¢", shape: "normal", keycode: 46 }, + { keytype: "normal", label: "v", labelShift: "V", labelAlt: "„", shape: "normal", keycode: 47 }, + { keytype: "normal", label: "b", labelShift: "B", labelAlt: "“", shape: "normal", keycode: 48 }, + { keytype: "normal", label: "n", labelShift: "N", labelAlt: "”", shape: "normal", keycode: 49 }, + { keytype: "normal", label: "m", labelShift: "M", labelAlt: "µ", shape: "normal", keycode: 50 }, + { keytype: "normal", label: ",", labelShift: ";", labelAlt: "·", shape: "normal", keycode: 51 }, + { keytype: "normal", label: ".", labelShift: ":", labelAlt: "…", shape: "normal", keycode: 52 }, + { keytype: "normal", label: "-", labelShift: "_", labelAlt: "–", shape: "normal", keycode: 53 }, + { keytype: "modkey", label: "Shift", labelShift: "Shift ⇧", labelCaps: "Locked ⇩", shape: "expand", keycode: 54 }, // optional + ], + [ + { keytype: "modkey", label: "Strg", shape: "control", keycode: 29 }, + //{ keytype: "normal", label: "", shape: "normal", keycode: 125 }, // dangerous + { keytype: "modkey", label: "Alt", shape: "normal", keycode: 56 }, + { keytype: "normal", label: "Leertaste", shape: "space", keycode: 57 }, + { keytype: "modkey", label: "Alt Gr", shape: "normal", keycode: 100 }, + // { label: "Super", shape: "normal", keycode: 126 }, // dangerous + //{ keytype: "normal", label: "Menu", shape: "normal", keycode: 139 }, // doesn't work? + { keytype: "modkey", label: "Strg", shape: "control", keycode: 97 }, + { keytype: "normal", label: "⇦", shape: "normal", keycode: 105 }, + { keytype: "normal", label: "⇨", shape: "normal", keycode: 106 }, + ] + ] + }, + "Russian": { + name_short: "RU", + description: "ЙЦУКЕН - Full", + comment: "Standard Russian keyboard layout", + keys: [ + [ + { keytype: "normal", label: "Esc", shape: "fn", keycode: 1 }, + { keytype: "normal", label: "F1", shape: "fn", keycode: 59 }, + { keytype: "normal", label: "F2", shape: "fn", keycode: 60 }, + { keytype: "normal", label: "F3", shape: "fn", keycode: 61 }, + { keytype: "normal", label: "F4", shape: "fn", keycode: 62 }, + { keytype: "normal", label: "F5", shape: "fn", keycode: 63 }, + { keytype: "normal", label: "F6", shape: "fn", keycode: 64 }, + { keytype: "normal", label: "F7", shape: "fn", keycode: 65 }, + { keytype: "normal", label: "F8", shape: "fn", keycode: 66 }, + { keytype: "normal", label: "F9", shape: "fn", keycode: 67 }, + { keytype: "normal", label: "F10", shape: "fn", keycode: 68 }, + { keytype: "normal", label: "F11", shape: "fn", keycode: 87 }, + { keytype: "normal", label: "F12", shape: "fn", keycode: 88 }, + { keytype: "normal", label: "PrtSc", shape: "fn", keycode: 99 }, + { keytype: "normal", label: "Del", shape: "fn", keycode: 111 } + ], + [ + { keytype: "normal", label: "ё", labelShift: "Ё", shape: "normal", keycode: 41 }, + { keytype: "normal", label: "1", labelShift: "!", shape: "normal", keycode: 2 }, + { keytype: "normal", label: "2", labelShift: "\"", shape: "normal", keycode: 3 }, + { keytype: "normal", label: "3", labelShift: "№", shape: "normal", keycode: 4 }, + { keytype: "normal", label: "4", labelShift: ";", shape: "normal", keycode: 5 }, + { keytype: "normal", label: "5", labelShift: "%", shape: "normal", keycode: 6 }, + { keytype: "normal", label: "6", labelShift: ":", shape: "normal", keycode: 7 }, + { keytype: "normal", label: "7", labelShift: "?", shape: "normal", keycode: 8 }, + { keytype: "normal", label: "8", labelShift: "*", shape: "normal", keycode: 9 }, + { keytype: "normal", label: "9", labelShift: "(", shape: "normal", keycode: 10 }, + { keytype: "normal", label: "0", labelShift: ")", shape: "normal", keycode: 11 }, + { keytype: "normal", label: "-", labelShift: "_", shape: "normal", keycode: 12 }, + { keytype: "normal", label: "=", labelShift: "+", shape: "normal", keycode: 13 }, + { keytype: "normal", label: "Backspace", shape: "expand", keycode: 14 } + ], + [ + { keytype: "normal", label: "Tab", shape: "tab", keycode: 15 }, + { keytype: "normal", label: "й", labelShift: "Й", shape: "normal", keycode: 16 }, + { keytype: "normal", label: "ц", labelShift: "Ц", shape: "normal", keycode: 17 }, + { keytype: "normal", label: "у", labelShift: "У", shape: "normal", keycode: 18 }, + { keytype: "normal", label: "к", labelShift: "К", shape: "normal", keycode: 19 }, + { keytype: "normal", label: "е", labelShift: "Е", shape: "normal", keycode: 20 }, + { keytype: "normal", label: "н", labelShift: "Н", shape: "normal", keycode: 21 }, + { keytype: "normal", label: "г", labelShift: "Г", shape: "normal", keycode: 22 }, + { keytype: "normal", label: "ш", labelShift: "Ш", shape: "normal", keycode: 23 }, + { keytype: "normal", label: "щ", labelShift: "Щ", shape: "normal", keycode: 24 }, + { keytype: "normal", label: "з", labelShift: "З", shape: "normal", keycode: 25 }, + { keytype: "normal", label: "х", labelShift: "Х", shape: "normal", keycode: 26 }, + { keytype: "normal", label: "ъ", labelShift: "Ъ", shape: "normal", keycode: 27 }, + { keytype: "normal", label: "\\", labelShift: "/", shape: "expand", keycode: 43 } + ], + [ + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "spacer", label: "", shape: "empty" }, + { keytype: "normal", label: "ф", labelShift: "Ф", shape: "normal", keycode: 30 }, + { keytype: "normal", label: "ы", labelShift: "Ы", shape: "normal", keycode: 31 }, + { keytype: "normal", label: "в", labelShift: "В", shape: "normal", keycode: 32 }, + { keytype: "normal", label: "а", labelShift: "А", shape: "normal", keycode: 33 }, + { keytype: "normal", label: "п", labelShift: "П", shape: "normal", keycode: 34 }, + { keytype: "normal", label: "р", labelShift: "Р", shape: "normal", keycode: 35 }, + { keytype: "normal", label: "о", labelShift: "О", shape: "normal", keycode: 36 }, + { keytype: "normal", label: "л", labelShift: "Л", shape: "normal", keycode: 37 }, + { keytype: "normal", label: "д", labelShift: "Д", shape: "normal", keycode: 38 }, + { keytype: "normal", label: "ж", labelShift: "Ж", shape: "normal", keycode: 39 }, + { keytype: "normal", label: "э", labelShift: "Э", shape: "normal", keycode: 40 }, + { keytype: "normal", label: "Enter", shape: "expand", keycode: 28 } + ], + [ + { keytype: "modkey", label: "Shift", shape: "shift", keycode: 42 }, + { keytype: "normal", label: "я", labelShift: "Я", shape: "normal", keycode: 44 }, + { keytype: "normal", label: "ч", labelShift: "Ч", shape: "normal", keycode: 45 }, + { keytype: "normal", label: "с", labelShift: "С", shape: "normal", keycode: 46 }, + { keytype: "normal", label: "м", labelShift: "М", shape: "normal", keycode: 47 }, + { keytype: "normal", label: "и", labelShift: "И", shape: "normal", keycode: 48 }, + { keytype: "normal", label: "т", labelShift: "Т", shape: "normal", keycode: 49 }, + { keytype: "normal", label: "ь", labelShift: "Ь", shape: "normal", keycode: 50 }, + { keytype: "normal", label: "б", labelShift: "Б", shape: "normal", keycode: 51 }, + { keytype: "normal", label: "ю", labelShift: "Ю", shape: "normal", keycode: 52 }, + { keytype: "normal", label: ".", labelShift: ",", shape: "normal", keycode: 53 }, + { keytype: "modkey", label: "Shift", shape: "expand", keycode: 54 } + ], + [ + { keytype: "modkey", label: "Ctrl", shape: "control", keycode: 29 }, + { keytype: "modkey", label: "Alt", shape: "normal", keycode: 56 }, + { keytype: "normal", label: "Space", shape: "space", keycode: 57 }, + { keytype: "modkey", label: "Alt", shape: "normal", keycode: 100 }, + { keytype: "normal", label: "Menu", shape: "normal", keycode: 139 }, + { keytype: "modkey", label: "Ctrl", shape: "control", keycode: 97 } + ] + ] + } +} diff --git a/ii/modules/overview/Overview.qml b/ii/modules/overview/Overview.qml new file mode 100644 index 0000000..844aea4 --- /dev/null +++ b/ii/modules/overview/Overview.qml @@ -0,0 +1,241 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: overviewScope + property bool dontAutoCancelSearch: false + Variants { + id: overviewVariants + model: Quickshell.screens + PanelWindow { + id: root + required property var modelData + property string searchingText: "" + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.screen) + property bool monitorIsFocused: (Hyprland.focusedMonitor?.id == monitor?.id) + screen: modelData + visible: GlobalStates.overviewOpen + + WlrLayershell.namespace: "quickshell:overview" + WlrLayershell.layer: WlrLayer.Overlay + // WlrLayershell.keyboardFocus: GlobalStates.overviewOpen ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + color: "transparent" + + mask: Region { + item: GlobalStates.overviewOpen ? columnLayout : null + } + // HyprlandWindow.visibleMask: Region { // Buggy with scaled monitors + // item: GlobalStates.overviewOpen ? columnLayout : null + // } + + anchors { + top: true + bottom: true + left: !(Config?.options.overview.enable ?? true) + right: !(Config?.options.overview.enable ?? true) + } + + HyprlandFocusGrab { + id: grab + windows: [root] + property bool canBeActive: root.monitorIsFocused + active: false + onCleared: () => { + if (!active) + GlobalStates.overviewOpen = false; + } + } + + Connections { + target: GlobalStates + function onOverviewOpenChanged() { + if (!GlobalStates.overviewOpen) { + searchWidget.disableExpandAnimation(); + overviewScope.dontAutoCancelSearch = false; + } else { + if (!overviewScope.dontAutoCancelSearch) { + searchWidget.cancelSearch(); + } + delayedGrabTimer.start(); + } + } + } + + Timer { + id: delayedGrabTimer + interval: Config.options.hacks.arbitraryRaceConditionDelay + repeat: false + onTriggered: { + if (!grab.canBeActive) + return; + grab.active = GlobalStates.overviewOpen; + } + } + + implicitWidth: columnLayout.implicitWidth + implicitHeight: columnLayout.implicitHeight + + function setSearchingText(text) { + searchWidget.setSearchingText(text); + searchWidget.focusFirstItem(); + } + + ColumnLayout { + id: columnLayout + visible: GlobalStates.overviewOpen + anchors { + horizontalCenter: parent.horizontalCenter + top: parent.top + } + + Keys.onPressed: event => { + if (event.key === Qt.Key_Escape) { + GlobalStates.overviewOpen = false; + } else if (event.key === Qt.Key_Left) { + if (!root.searchingText) + Hyprland.dispatch("workspace r-1"); + } else if (event.key === Qt.Key_Right) { + if (!root.searchingText) + Hyprland.dispatch("workspace r+1"); + } + } + + SearchWidget { + id: searchWidget + Layout.alignment: Qt.AlignHCenter + onSearchingTextChanged: text => { + root.searchingText = searchingText; + } + } + + Loader { + id: overviewLoader + active: GlobalStates.overviewOpen && (Config?.options.overview.enable ?? true) + sourceComponent: OverviewWidget { + panelWindow: root + visible: (root.searchingText == "") + } + } + } + } + } + + function toggleClipboard() { + if (GlobalStates.overviewOpen && overviewScope.dontAutoCancelSearch) { + GlobalStates.overviewOpen = false; + return; + } + for (let i = 0; i < overviewVariants.instances.length; i++) { + let panelWindow = overviewVariants.instances[i]; + if (panelWindow.modelData.name == Hyprland.focusedMonitor.name) { + overviewScope.dontAutoCancelSearch = true; + panelWindow.setSearchingText(Config.options.search.prefix.clipboard); + GlobalStates.overviewOpen = true; + return; + } + } + } + + function toggleEmojis() { + if (GlobalStates.overviewOpen && overviewScope.dontAutoCancelSearch) { + GlobalStates.overviewOpen = false; + return; + } + for (let i = 0; i < overviewVariants.instances.length; i++) { + let panelWindow = overviewVariants.instances[i]; + if (panelWindow.modelData.name == Hyprland.focusedMonitor.name) { + overviewScope.dontAutoCancelSearch = true; + panelWindow.setSearchingText(Config.options.search.prefix.emojis); + GlobalStates.overviewOpen = true; + return; + } + } + } + + IpcHandler { + target: "overview" + + function toggle() { + GlobalStates.overviewOpen = !GlobalStates.overviewOpen; + } + function close() { + GlobalStates.overviewOpen = false; + } + function open() { + GlobalStates.overviewOpen = true; + } + function toggleReleaseInterrupt() { + GlobalStates.superReleaseMightTrigger = false; + } + function clipboardToggle() { + overviewScope.toggleClipboard(); + } + } + + GlobalShortcut { + name: "overviewToggle" + description: "Toggles overview on press" + + onPressed: { + GlobalStates.overviewOpen = !GlobalStates.overviewOpen; + } + } + GlobalShortcut { + name: "overviewClose" + description: "Closes overview" + + onPressed: { + GlobalStates.overviewOpen = false; + } + } + GlobalShortcut { + name: "overviewToggleRelease" + description: "Toggles overview on release" + + onPressed: { + GlobalStates.superReleaseMightTrigger = true; + } + + onReleased: { + if (!GlobalStates.superReleaseMightTrigger) { + GlobalStates.superReleaseMightTrigger = true; + return; + } + GlobalStates.overviewOpen = !GlobalStates.overviewOpen; + } + } + GlobalShortcut { + name: "overviewToggleReleaseInterrupt" + description: "Interrupts possibility of overview being toggled on release. " + "This is necessary because GlobalShortcut.onReleased in quickshell triggers whether or not you press something else while holding the key. " + "To make sure this works consistently, use binditn = MODKEYS, catchall in an automatically triggered submap that includes everything." + + onPressed: { + GlobalStates.superReleaseMightTrigger = false; + } + } + GlobalShortcut { + name: "overviewClipboardToggle" + description: "Toggle clipboard query on overview widget" + + onPressed: { + overviewScope.toggleClipboard(); + } + } + + GlobalShortcut { + name: "overviewEmojiToggle" + description: "Toggle emoji query on overview widget" + + onPressed: { + overviewScope.toggleEmojis(); + } + } +} diff --git a/ii/modules/overview/OverviewWidget.qml b/ii/modules/overview/OverviewWidget.qml new file mode 100644 index 0000000..2510a56 --- /dev/null +++ b/ii/modules/overview/OverviewWidget.qml @@ -0,0 +1,267 @@ +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 Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Item { + id: root + required property var panelWindow + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(panelWindow.screen) + readonly property var toplevels: ToplevelManager.toplevels + readonly property int workspacesShown: Config.options.overview.rows * Config.options.overview.columns + readonly property int workspaceGroup: Math.floor((monitor.activeWorkspace?.id - 1) / workspacesShown) + property bool monitorIsFocused: (Hyprland.focusedMonitor?.name == monitor.name) + property var windows: HyprlandData.windowList + property var windowByAddress: HyprlandData.windowByAddress + property var windowAddresses: HyprlandData.addresses + property var monitorData: HyprlandData.monitors.find(m => m.id === root.monitor?.id) + property real scale: Config.options.overview.scale + property color activeBorderColor: Appearance.colors.colSecondary + + property real workspaceImplicitWidth: (monitorData?.transform % 2 === 1) ? + ((monitor.height - monitorData?.reserved[0] - monitorData?.reserved[2]) * root.scale / monitor.scale) : + ((monitor.width - monitorData?.reserved[0] - monitorData?.reserved[2]) * root.scale / monitor.scale) + property real workspaceImplicitHeight: (monitorData?.transform % 2 === 1) ? + ((monitor.width - monitorData?.reserved[1] - monitorData?.reserved[3]) * root.scale / monitor.scale) : + ((monitor.height - monitorData?.reserved[1] - monitorData?.reserved[3]) * root.scale / monitor.scale) + + property real workspaceNumberMargin: 80 + property real workspaceNumberSize: Math.min(workspaceImplicitHeight, workspaceImplicitWidth) * monitor.scale + property int workspaceZ: 0 + property int windowZ: 1 + property int windowDraggingZ: 99999 + property real workspaceSpacing: 5 + + property int draggingFromWorkspace: -1 + property int draggingTargetWorkspace: -1 + + implicitWidth: overviewBackground.implicitWidth + Appearance.sizes.elevationMargin * 2 + implicitHeight: overviewBackground.implicitHeight + Appearance.sizes.elevationMargin * 2 + + property Component windowComponent: OverviewWindow {} + property list windowWidgets: [] + + StyledRectangularShadow { + target: overviewBackground + } + Rectangle { // Background + id: overviewBackground + property real padding: 10 + anchors.fill: parent + anchors.margins: Appearance.sizes.elevationMargin + + implicitWidth: workspaceColumnLayout.implicitWidth + padding * 2 + implicitHeight: workspaceColumnLayout.implicitHeight + padding * 2 + radius: Appearance.rounding.screenRounding * root.scale + padding + color: Appearance.colors.colLayer0 + border.width: 1 + border.color: Appearance.colors.colLayer0Border + + ColumnLayout { // Workspaces + id: workspaceColumnLayout + + z: root.workspaceZ + anchors.centerIn: parent + spacing: workspaceSpacing + Repeater { + model: Config.options.overview.rows + delegate: RowLayout { + id: row + property int rowIndex: index + spacing: workspaceSpacing + + Repeater { // Workspace repeater + model: Config.options.overview.columns + Rectangle { // Workspace + id: workspace + property int colIndex: index + property int workspaceValue: root.workspaceGroup * workspacesShown + rowIndex * Config.options.overview.columns + colIndex + 1 + property color defaultWorkspaceColor: Appearance.colors.colLayer1 // TODO: reconsider this color for a cleaner look + property color hoveredWorkspaceColor: ColorUtils.mix(defaultWorkspaceColor, Appearance.colors.colLayer1Hover, 0.1) + property color hoveredBorderColor: Appearance.colors.colLayer2Hover + property bool hoveredWhileDragging: false + + implicitWidth: root.workspaceImplicitWidth + implicitHeight: root.workspaceImplicitHeight + color: hoveredWhileDragging ? hoveredWorkspaceColor : defaultWorkspaceColor + radius: Appearance.rounding.screenRounding * root.scale + border.width: 2 + border.color: hoveredWhileDragging ? hoveredBorderColor : "transparent" + + StyledText { + anchors.centerIn: parent + text: workspaceValue + font.pixelSize: root.workspaceNumberSize * root.scale + font.weight: Font.DemiBold + color: ColorUtils.transparentize(Appearance.colors.colOnLayer1, 0.8) + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + MouseArea { + id: workspaceArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onClicked: { + if (root.draggingTargetWorkspace === -1) { + GlobalStates.overviewOpen = false + Hyprland.dispatch(`workspace ${workspaceValue}`) + } + } + } + + DropArea { + anchors.fill: parent + onEntered: { + root.draggingTargetWorkspace = workspaceValue + if (root.draggingFromWorkspace == root.draggingTargetWorkspace) return; + hoveredWhileDragging = true + } + onExited: { + hoveredWhileDragging = false + if (root.draggingTargetWorkspace == workspaceValue) root.draggingTargetWorkspace = -1 + } + } + + } + } + } + } + } + + Item { // Windows & focused workspace indicator + id: windowSpace + anchors.centerIn: parent + implicitWidth: workspaceColumnLayout.implicitWidth + implicitHeight: workspaceColumnLayout.implicitHeight + + Repeater { // Window repeater + model: ScriptModel { + values: { + // console.log(JSON.stringify(ToplevelManager.toplevels.values.map(t => t), null, 2)) + return ToplevelManager.toplevels.values.filter((toplevel) => { + const address = `0x${toplevel.HyprlandToplevel.address}` + var win = windowByAddress[address] + const inWorkspaceGroup = (root.workspaceGroup * root.workspacesShown < win?.workspace?.id && win?.workspace?.id <= (root.workspaceGroup + 1) * root.workspacesShown) + return inWorkspaceGroup; + }) + } + } + delegate: OverviewWindow { + id: window + required property var modelData + property int monitorId: windowData?.monitor + property var monitor: HyprlandData.monitors[monitorId] + property var address: `0x${modelData.HyprlandToplevel.address}` + windowData: windowByAddress[address] + toplevel: modelData + monitorData: HyprlandData.monitors[monitorId] + scale: root.scale + availableWorkspaceWidth: root.workspaceImplicitWidth + availableWorkspaceHeight: root.workspaceImplicitHeight + widgetMonitorId: root.monitor.id + + property bool atInitPosition: (initX == x && initY == y) + + property int workspaceColIndex: (windowData?.workspace.id - 1) % Config.options.overview.columns + property int workspaceRowIndex: Math.floor((windowData?.workspace.id - 1) % root.workspacesShown / Config.options.overview.columns) + xOffset: (root.workspaceImplicitWidth + workspaceSpacing) * workspaceColIndex + yOffset: (root.workspaceImplicitHeight + workspaceSpacing) * workspaceRowIndex + + Timer { + id: updateWindowPosition + interval: Config.options.hacks.arbitraryRaceConditionDelay + repeat: false + running: false + onTriggered: { + window.x = Math.round(Math.max((windowData?.at[0] - (monitor?.x ?? 0) - monitorData?.reserved[0]) * root.scale, 0) + xOffset) + window.y = Math.round(Math.max((windowData?.at[1] - (monitor?.y ?? 0) - monitorData?.reserved[1]) * root.scale, 0) + yOffset) + } + } + + z: atInitPosition ? root.windowZ : root.windowDraggingZ + Drag.hotSpot.x: targetWindowWidth / 2 + Drag.hotSpot.y: targetWindowHeight / 2 + MouseArea { + id: dragArea + anchors.fill: parent + hoverEnabled: true + onEntered: hovered = true // For hover color change + onExited: hovered = false // For hover color change + acceptedButtons: Qt.LeftButton | Qt.MiddleButton + drag.target: parent + onPressed: (mouse) => { + root.draggingFromWorkspace = windowData?.workspace.id + window.pressed = true + window.Drag.active = true + window.Drag.source = window + window.Drag.hotSpot.x = mouse.x + window.Drag.hotSpot.y = mouse.y + // console.log(`[OverviewWindow] Dragging window ${windowData?.address} from position (${window.x}, ${window.y})`) + } + onReleased: { + const targetWorkspace = root.draggingTargetWorkspace + window.pressed = false + window.Drag.active = false + root.draggingFromWorkspace = -1 + if (targetWorkspace !== -1 && targetWorkspace !== windowData?.workspace.id) { + Hyprland.dispatch(`movetoworkspacesilent ${targetWorkspace}, address:${window.windowData?.address}`) + updateWindowPosition.restart() + } + else { + window.x = window.initX + window.y = window.initY + } + } + onClicked: (event) => { + if (!windowData) return; + + if (event.button === Qt.LeftButton) { + GlobalStates.overviewOpen = false + Hyprland.dispatch(`focuswindow address:${windowData.address}`) + event.accepted = true + } else if (event.button === Qt.MiddleButton) { + Hyprland.dispatch(`closewindow address:${windowData.address}`) + event.accepted = true + } + } + + StyledToolTip { + extraVisibleCondition: false + alternativeVisibleCondition: dragArea.containsMouse && !window.Drag.active + content: `${windowData.title}\n[${windowData.class}] ${windowData.xwayland ? "[XWayland] " : ""}\n` + } + } + } + } + + Rectangle { // Focused workspace indicator + id: focusedWorkspaceIndicator + property int activeWorkspaceInGroup: monitor.activeWorkspace?.id - (root.workspaceGroup * root.workspacesShown) + property int activeWorkspaceRowIndex: Math.floor((activeWorkspaceInGroup - 1) / Config.options.overview.columns) + property int activeWorkspaceColIndex: (activeWorkspaceInGroup - 1) % Config.options.overview.columns + x: (root.workspaceImplicitWidth + workspaceSpacing) * activeWorkspaceColIndex + y: (root.workspaceImplicitHeight + workspaceSpacing) * activeWorkspaceRowIndex + z: root.windowZ + width: root.workspaceImplicitWidth + height: root.workspaceImplicitHeight + color: "transparent" + radius: Appearance.rounding.screenRounding * root.scale + border.width: 2 + border.color: root.activeBorderColor + Behavior on x { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on y { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + } + } +} diff --git a/ii/modules/overview/OverviewWindow.qml b/ii/modules/overview/OverviewWindow.qml new file mode 100644 index 0000000..856096f --- /dev/null +++ b/ii/modules/overview/OverviewWindow.qml @@ -0,0 +1,116 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland + +Item { // Window + id: root + property var toplevel + property var windowData + property var monitorData + property var scale + property var availableWorkspaceWidth + property var availableWorkspaceHeight + property bool restrictToWorkspace: true + property real initX: Math.max((windowData?.at[0] - (monitorData?.x ?? 0) - monitorData?.reserved[0]) * root.scale, 0) + xOffset + property real initY: Math.max((windowData?.at[1] - (monitorData?.y ?? 0) - monitorData?.reserved[1]) * root.scale, 0) + yOffset + property real xOffset: 0 + property real yOffset: 0 + property int widgetMonitorId: 0 + + property var targetWindowWidth: windowData?.size[0] * scale + property var targetWindowHeight: windowData?.size[1] * scale + property bool hovered: false + property bool pressed: false + + property var iconToWindowRatio: 0.35 + property var xwaylandIndicatorToIconRatio: 0.35 + property var iconToWindowRatioCompact: 0.6 + property var iconPath: Quickshell.iconPath(AppSearch.guessIcon(windowData?.class), "image-missing") + property bool compactMode: Appearance.font.pixelSize.smaller * 4 > targetWindowHeight || Appearance.font.pixelSize.smaller * 4 > targetWindowWidth + + property bool indicateXWayland: windowData?.xwayland ?? false + + x: initX + y: initY + width: windowData?.size[0] * root.scale + height: windowData?.size[1] * root.scale + opacity: windowData.monitor == widgetMonitorId ? 1 : 0.4 + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: root.width + height: root.height + radius: Appearance.rounding.windowRounding * root.scale + } + } + + Behavior on x { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on y { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on width { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + ScreencopyView { + id: windowPreview + anchors.fill: parent + captureSource: GlobalStates.overviewOpen ? root.toplevel : null + live: true + + // Color overlay for interactions + Rectangle { + anchors.fill: parent + radius: Appearance.rounding.windowRounding * root.scale + color: pressed ? ColorUtils.transparentize(Appearance.colors.colLayer2Active, 0.5) : + hovered ? ColorUtils.transparentize(Appearance.colors.colLayer2Hover, 0.7) : + ColorUtils.transparentize(Appearance.colors.colLayer2) + border.color : ColorUtils.transparentize(Appearance.m3colors.m3outline, 0.7) + border.width : 1 + } + + ColumnLayout { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + spacing: Appearance.font.pixelSize.smaller * 0.5 + + Image { + id: windowIcon + property var iconSize: { + // console.log("-=-=-", root.toplevel.title, "-=-=-") + // console.log("Target window size:", targetWindowWidth, targetWindowHeight) + // console.log("Icon ratio:", root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio) + // console.log("Scale:", root.monitorData.scale) + // console.log("Final:", Math.min(targetWindowWidth, targetWindowHeight) * (root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio) / root.monitorData.scale) + return Math.min(targetWindowWidth, targetWindowHeight) * (root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio) / root.monitorData.scale; + } + // mipmap: true + Layout.alignment: Qt.AlignHCenter + source: root.iconPath + width: iconSize + height: iconSize + sourceSize: Qt.size(iconSize, iconSize) + + Behavior on width { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + } + } + } +} \ No newline at end of file diff --git a/ii/modules/overview/SearchItem.qml b/ii/modules/overview/SearchItem.qml new file mode 100644 index 0000000..2e0afbf --- /dev/null +++ b/ii/modules/overview/SearchItem.qml @@ -0,0 +1,276 @@ +// pragma NativeMethodBehavior: AcceptThisObject +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 Quickshell +import Quickshell.Widgets +import Quickshell.Hyprland + +RippleButton { + id: root + property var entry + property string query + property bool entryShown: entry?.shown ?? true + property string itemType: entry?.type ?? Translation.tr("App") + property string itemName: entry?.name + property string itemIcon: entry?.icon ?? "" + property var itemExecute: entry?.execute + property string fontType: entry?.fontType ?? "main" + property string itemClickActionName: entry?.clickActionName + property string bigText: entry?.bigText ?? "" + property string materialSymbol: entry?.materialSymbol ?? "" + property string cliphistRawString: entry?.cliphistRawString ?? "" + + visible: root.entryShown + property int horizontalMargin: 10 + property int buttonHorizontalPadding: 10 + property int buttonVerticalPadding: 6 + property bool keyboardDown: false + + implicitHeight: rowLayout.implicitHeight + root.buttonVerticalPadding * 2 + implicitWidth: rowLayout.implicitWidth + root.buttonHorizontalPadding * 2 + buttonRadius: Appearance.rounding.normal + colBackground: (root.down || root.keyboardDown) ? Appearance.colors.colSecondaryContainerActive : + ((root.hovered || root.focus) ? Appearance.colors.colSecondaryContainer : + ColorUtils.transparentize(Appearance.colors.colSecondaryContainer, 1)) + colBackgroundHover: Appearance.colors.colSecondaryContainer + colRipple: Appearance.colors.colSecondaryContainerActive + + property string highlightPrefix: `` + property string highlightSuffix: `` + function highlightContent(content, query) { + if (!query || query.length === 0 || content == query || fontType === "monospace") + return StringUtils.escapeHtml(content); + + let contentLower = content.toLowerCase(); + let queryLower = query.toLowerCase(); + + let result = ""; + let lastIndex = 0; + let qIndex = 0; + + for (let i = 0; i < content.length && qIndex < query.length; i++) { + if (contentLower[i] === queryLower[qIndex]) { + // Add non-highlighted part (escaped) + if (i > lastIndex) + result += StringUtils.escapeHtml(content.slice(lastIndex, i)); + // Add highlighted character (escaped) + result += root.highlightPrefix + StringUtils.escapeHtml(content[i]) + root.highlightSuffix; + lastIndex = i + 1; + qIndex++; + } + } + // Add the rest of the string (escaped) + if (lastIndex < content.length) + result += StringUtils.escapeHtml(content.slice(lastIndex)); + + return result; + } + property string displayContent: highlightContent(root.itemName, root.query) + + property list urls: { + if (!root.itemName) return []; + // Regular expression to match URLs + const urlRegex = /https?:\/\/[^\s<>"{}|\\^`[\]]+/gi; + const matches = root.itemName?.match(urlRegex) + ?.filter(url => !url.includes("…")) // Elided = invalid + return matches ? matches : []; + } + + PointingHandInteraction {} + + background { + anchors.fill: root + anchors.leftMargin: root.horizontalMargin + anchors.rightMargin: root.horizontalMargin + } + + onClicked: { + GlobalStates.overviewOpen = false + root.itemExecute() + } + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.keyboardDown = true + root.clicked() + event.accepted = true; + } + } + Keys.onReleased: (event) => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.keyboardDown = false + event.accepted = true; + } + } + + RowLayout { + id: rowLayout + spacing: iconLoader.sourceComponent === null ? 0 : 10 + anchors.fill: parent + anchors.leftMargin: root.horizontalMargin + root.buttonHorizontalPadding + anchors.rightMargin: root.horizontalMargin + root.buttonHorizontalPadding + + // Icon + Loader { + id: iconLoader + active: true + sourceComponent: root.materialSymbol !== "" ? materialSymbolComponent : + root.bigText ? bigTextComponent : + root.itemIcon !== "" ? iconImageComponent : + null + } + + Component { + id: iconImageComponent + IconImage { + source: Quickshell.iconPath(root.itemIcon, "image-missing") + width: 35 + height: 35 + } + } + + Component { + id: materialSymbolComponent + MaterialSymbol { + text: root.materialSymbol + iconSize: 30 + color: Appearance.m3colors.m3onSurface + } + } + + Component { + id: bigTextComponent + StyledText { + text: root.bigText + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.m3colors.m3onSurface + } + } + + // Main text + ColumnLayout { + id: contentColumn + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 0 + StyledText { + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colSubtext + visible: root.itemType && root.itemType != Translation.tr("App") + text: root.itemType + } + RowLayout { + Loader { // Checkmark for copied clipboard entry + visible: itemName == Quickshell.clipboardText && root.cliphistRawString + active: itemName == Quickshell.clipboardText && root.cliphistRawString + sourceComponent: Rectangle { + implicitWidth: activeText.implicitHeight + implicitHeight: activeText.implicitHeight + radius: Appearance.rounding.full + color: Appearance.colors.colPrimary + MaterialSymbol { + id: activeText + anchors.centerIn: parent + text: "check" + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onPrimary + } + } + } + Repeater { // Favicons for links + model: root.query == root.itemName ? [] : root.urls + Favicon { + required property var modelData + size: parent.height + url: modelData + } + } + StyledText { // Item name/content + Layout.fillWidth: true + id: nameText + textFormat: Text.StyledText // RichText also works, but StyledText ensures elide work + font.pixelSize: Appearance.font.pixelSize.small + font.family: Appearance.font.family[root.fontType] + color: Appearance.m3colors.m3onSurface + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + text: `${root.displayContent}` + } + } + Loader { // Clipboard image preview + active: root.cliphistRawString && Cliphist.entryIsImage(root.cliphistRawString) + sourceComponent: CliphistImage { + Layout.fillWidth: true + entry: root.cliphistRawString + maxWidth: contentColumn.width + maxHeight: 140 + } + } + } + + // Action text + StyledText { + Layout.fillWidth: false + visible: (root.hovered || root.focus) + id: clickAction + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colSubtext + horizontalAlignment: Text.AlignRight + text: root.itemClickActionName + } + + RowLayout { + Layout.alignment: Qt.AlignTop + Layout.topMargin: root.buttonVerticalPadding + Layout.bottomMargin: -root.buttonVerticalPadding // Why is this necessary? Good question. + spacing: 4 + Repeater { + model: (root.entry.actions ?? []).slice(0, 4) + delegate: RippleButton { + id: actionButton + required property var modelData + property string iconName: modelData.icon + property string materialIconName: modelData.materialIcon + implicitHeight: 34 + implicitWidth: 34 + + colBackgroundHover: Appearance.colors.colSecondaryContainerHover + colRipple: Appearance.colors.colSecondaryContainerActive + + contentItem: Item { + id: actionContentItem + anchors.centerIn: parent + Loader { + anchors.centerIn: parent + active: !(actionButton.iconName && actionButton.iconName !== "") || actionButton.materialIconName + sourceComponent: MaterialSymbol { + text: actionButton.materialIconName || "video_settings" + font.pixelSize: Appearance.font.pixelSize.hugeass + color: Appearance.m3colors.m3onSurface + } + } + Loader { + anchors.centerIn: parent + active: !actionButton.materialIconName && actionButton.iconName && actionButton.iconName !== "" + sourceComponent: IconImage { + source: Quickshell.iconPath(actionButton.iconName) + implicitSize: 20 + } + } + } + + onClicked: modelData.execute() + + StyledToolTip { + content: modelData.name + } + } + } + } + + } +} diff --git a/ii/modules/overview/SearchWidget.qml b/ii/modules/overview/SearchWidget.qml new file mode 100644 index 0000000..85b7d58 --- /dev/null +++ b/ii/modules/overview/SearchWidget.qml @@ -0,0 +1,464 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io + +Item { // Wrapper + id: root + readonly property string xdgConfigHome: Directories.config + property string searchingText: "" + property bool showResults: searchingText != "" + property real searchBarHeight: searchBar.height + Appearance.sizes.elevationMargin * 2 + implicitWidth: searchWidgetContent.implicitWidth + Appearance.sizes.elevationMargin * 2 + implicitHeight: searchWidgetContent.implicitHeight + Appearance.sizes.elevationMargin * 2 + + property string mathResult: "" + + function disableExpandAnimation() { + searchWidthBehavior.enabled = false; + } + + function cancelSearch() { + searchInput.selectAll(); + root.searchingText = ""; + searchWidthBehavior.enabled = true; + } + + function setSearchingText(text) { + searchInput.text = text; + root.searchingText = text; + } + + property var searchActions: [ + { + action: "accentcolor", + execute: args => { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--noswitch", "--color", ...(args != '' ? [`${args}`] : [])]); + } + }, + { + action: "dark", + execute: () => { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", "dark", "--noswitch"]); + } + }, + { + action: "konachanwallpaper", + execute: () => { + Quickshell.execDetached([Quickshell.shellPath("scripts/colors/random_konachan_wall.sh")]); + } + }, + { + action: "light", + execute: () => { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", "light", "--noswitch"]); + } + }, + { + action: "superpaste", + execute: args => { + if (!/^(\d+)/.test(args.trim())) { // Invalid if doesn't start with numbers + Quickshell.execDetached([ + "notify-send", + Translation.tr("Superpaste"), + Translation.tr("Usage: %1superpaste NUM_OF_ENTRIES[i]\nSupply i when you want images\nExamples:\n%1superpaste 4i for the last 4 images\n%1superpaste 7 for the last 7 entries").arg(Config.options.search.prefix.action), + "-a", "Shell" + ]); + return; + } + const syntaxMatch = /^(?:(\d+)(i)?)/.exec(args.trim()); + const count = syntaxMatch[1] ? parseInt(syntaxMatch[1]) : 1; + const isImage = !!syntaxMatch[2]; + Cliphist.superpaste(count, isImage); + } + }, + { + action: "todo", + execute: args => { + Todo.addTask(args); + } + }, + { + action: "wallpaper", + execute: () => { + GlobalStates.wallpaperSelectorOpen = true; + } + }, + ] + + function focusFirstItem() { + appResults.currentIndex = 0; + } + + Timer { + id: nonAppResultsTimer + interval: Config.options.search.nonAppResultDelay + onTriggered: { + let expr = root.searchingText; + if (expr.startsWith(Config.options.search.prefix.math)) { + expr = expr.slice(Config.options.search.prefix.math.length); + } + mathProcess.calculateExpression(expr); + } + } + + Process { + id: mathProcess + property list baseCommand: ["qalc", "-t"] + function calculateExpression(expression) { + mathProcess.running = false; + mathProcess.command = baseCommand.concat(expression); + mathProcess.running = true; + } + stdout: SplitParser { + onRead: data => { + root.mathResult = data; + root.focusFirstItem(); + } + } + } + + Keys.onPressed: event => { + // Prevent Esc and Backspace from registering + if (event.key === Qt.Key_Escape) + return; + + // Handle Backspace: focus and delete character if not focused + if (event.key === Qt.Key_Backspace) { + if (!searchInput.activeFocus) { + searchInput.forceActiveFocus(); + if (event.modifiers & Qt.ControlModifier) { + // Delete word before cursor + let text = searchInput.text; + let pos = searchInput.cursorPosition; + if (pos > 0) { + // Find the start of the previous word + let left = text.slice(0, pos); + let match = left.match(/(\s*\S+)\s*$/); + let deleteLen = match ? match[0].length : 1; + searchInput.text = text.slice(0, pos - deleteLen) + text.slice(pos); + searchInput.cursorPosition = pos - deleteLen; + } + } else { + // Delete character before cursor if any + if (searchInput.cursorPosition > 0) { + searchInput.text = searchInput.text.slice(0, searchInput.cursorPosition - 1) + searchInput.text.slice(searchInput.cursorPosition); + searchInput.cursorPosition -= 1; + } + } + // Always move cursor to end after programmatic edit + searchInput.cursorPosition = searchInput.text.length; + event.accepted = true; + } + // If already focused, let TextField handle it + return; + } + + // Only handle visible printable characters (ignore control chars, arrows, etc.) + if (event.text && event.text.length === 1 && event.key !== Qt.Key_Enter && event.key !== Qt.Key_Return && event.text.charCodeAt(0) >= 0x20) // ignore control chars like Backspace, Tab, etc. + { + if (!searchInput.activeFocus) { + searchInput.forceActiveFocus(); + // Insert the character at the cursor position + searchInput.text = searchInput.text.slice(0, searchInput.cursorPosition) + event.text + searchInput.text.slice(searchInput.cursorPosition); + searchInput.cursorPosition += 1; + event.accepted = true; + } + } + } + + StyledRectangularShadow { + target: searchWidgetContent + } + Rectangle { // Background + id: searchWidgetContent + anchors.centerIn: parent + implicitWidth: columnLayout.implicitWidth + implicitHeight: columnLayout.implicitHeight + radius: Appearance.rounding.large + color: Appearance.colors.colLayer0 + border.width: 1 + border.color: Appearance.colors.colLayer0Border + + ColumnLayout { + id: columnLayout + anchors.centerIn: parent + spacing: 0 + + // clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: searchWidgetContent.width + height: searchWidgetContent.width + radius: searchWidgetContent.radius + } + } + + RowLayout { + id: searchBar + spacing: 5 + MaterialSymbol { + id: searchIcon + Layout.leftMargin: 15 + iconSize: Appearance.font.pixelSize.huge + color: Appearance.m3colors.m3onSurface + text: root.searchingText.startsWith(Config.options.search.prefix.clipboard) ? 'content_paste_search' : 'search' + } + TextField { // Search box + id: searchInput + + focus: GlobalStates.overviewOpen + Layout.rightMargin: 15 + padding: 15 + renderType: Text.NativeRendering + font { + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + hintingPreference: Font.PreferFullHinting + } + color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + placeholderText: Translation.tr("Search, calculate or run") + placeholderTextColor: Appearance.m3colors.m3outline + implicitWidth: root.searchingText == "" ? Appearance.sizes.searchWidthCollapsed : Appearance.sizes.searchWidth + + Behavior on implicitWidth { + id: searchWidthBehavior + enabled: false + NumberAnimation { + duration: 300 + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + + onTextChanged: root.searchingText = text + + onAccepted: { + if (appResults.count > 0) { + // Get the first visible delegate and trigger its click + let firstItem = appResults.itemAtIndex(0); + if (firstItem && firstItem.clicked) { + firstItem.clicked(); + } + } + } + + background: null + + cursorDelegate: Rectangle { + width: 1 + color: searchInput.activeFocus ? Appearance.colors.colPrimary : "transparent" + radius: 1 + } + } + } + + Rectangle { + // Separator + visible: root.showResults + Layout.fillWidth: true + height: 1 + color: Appearance.colors.colOutlineVariant + } + + ListView { // App results + id: appResults + visible: root.showResults + Layout.fillWidth: true + implicitHeight: Math.min(600, appResults.contentHeight + topMargin + bottomMargin) + clip: true + topMargin: 10 + bottomMargin: 10 + spacing: 2 + KeyNavigation.up: searchBar + highlightMoveDuration: 100 + + onFocusChanged: { + if (focus) + appResults.currentIndex = 1; + } + + Connections { + target: root + function onSearchingTextChanged() { + if (appResults.count > 0) + appResults.currentIndex = 0; + } + } + + model: ScriptModel { + id: model + onValuesChanged: { + root.focusFirstItem(); + } + values: { + // Search results are handled here + ////////////////// Skip? ////////////////// + if (root.searchingText == "") + return []; + + ///////////// Special cases /////////////// + if (root.searchingText.startsWith(Config.options.search.prefix.clipboard)) { + // Clipboard + const searchString = root.searchingText.slice(Config.options.search.prefix.clipboard.length); + return Cliphist.fuzzyQuery(searchString).map(entry => { + return { + cliphistRawString: entry, + name: StringUtils.cleanCliphistEntry(entry), + clickActionName: "", + type: `#${entry.match(/^\s*(\S+)/)?.[1] || ""}`, + execute: () => { + Cliphist.copy(entry) + }, + actions: [ + { + name: "Copy", + materialIcon: "content_copy", + execute: () => { + Cliphist.copy(entry); + } + }, + { + name: "Delete", + materialIcon: "delete", + execute: () => { + Cliphist.deleteEntry(entry); + } + } + ] + }; + }).filter(Boolean); + } + else if (root.searchingText.startsWith(Config.options.search.prefix.emojis)) { + // Clipboard + const searchString = root.searchingText.slice(Config.options.search.prefix.emojis.length); + return Emojis.fuzzyQuery(searchString).map(entry => { + return { + cliphistRawString: entry, + bigText: entry.match(/^\s*(\S+)/)?.[1] || "", + name: entry.replace(/^\s*\S+\s+/, ""), + clickActionName: "", + type: "Emoji", + execute: () => { + Quickshell.clipboardText = entry.match(/^\s*(\S+)/)?.[1]; + } + }; + }).filter(Boolean); + } + + ////////////////// Init /////////////////// + nonAppResultsTimer.restart(); + const mathResultObject = { + name: root.mathResult, + clickActionName: Translation.tr("Copy"), + type: Translation.tr("Math result"), + fontType: "monospace", + materialSymbol: 'calculate', + execute: () => { + Quickshell.clipboardText = root.mathResult; + } + }; + const commandResultObject = { + name: searchingText.replace("file://", ""), + clickActionName: Translation.tr("Run"), + type: Translation.tr("Run command"), + fontType: "monospace", + materialSymbol: 'terminal', + execute: () => { + let cleanedCommand = root.searchingText.replace("file://", ""); + if (cleanedCommand.startsWith(Config.options.search.prefix.shellCommand)) { + cleanedCommand = cleanedCommand.slice(Config.options.search.prefix.shellCommand.length); + } + Quickshell.execDetached(["bash", "-c", searchingText.startsWith('sudo') ? `${Config.options.apps.terminal} fish -C '${cleanedCommand}'` : cleanedCommand]); + } + }; + const webSearchResultObject = { + name: root.searchingText, + clickActionName: Translation.tr("Search"), + type: Translation.tr("Search the web"), + materialSymbol: 'travel_explore', + execute: () => { + let query = root.searchingText; + if (query.startsWith(Config.options.search.prefix.webSearch)) { + query = query.slice(Config.options.search.prefix.webSearch.length); + } + let url = Config.options.search.engineBaseUrl + query; + for (let site of Config.options.search.excludedSites) { + url += ` -site:${site}`; + } + Qt.openUrlExternally(url); + } + } + const launcherActionObjects = root.searchActions.map(action => { + const actionString = `${Config.options.search.prefix.action}${action.action}`; + if (actionString.startsWith(root.searchingText) || root.searchingText.startsWith(actionString)) { + return { + name: root.searchingText.startsWith(actionString) ? root.searchingText : actionString, + clickActionName: Translation.tr("Run"), + type: Translation.tr("Action"), + materialSymbol: 'settings_suggest', + execute: () => { + action.execute(root.searchingText.split(" ").slice(1).join(" ")); + } + }; + } + return null; + }).filter(Boolean); + + //////// Prioritized by prefix ///////// + let result = []; + const startsWithNumber = /^\d/.test(root.searchingText); + const startsWithMathPrefix = root.searchingText.startsWith(Config.options.search.prefix.math); + const startsWithShellCommandPrefix = root.searchingText.startsWith(Config.options.search.prefix.shellCommand); + const startsWithWebSearchPrefix = root.searchingText.startsWith(Config.options.search.prefix.webSearch); + if (startsWithNumber || startsWithMathPrefix) { + result.push(mathResultObject); + } else if (startsWithShellCommandPrefix) { + result.push(commandResultObject); + } else if (startsWithWebSearchPrefix) { + result.push(webSearchResultObject); + } + + //////////////// Apps ////////////////// + result = result.concat(AppSearch.fuzzyQuery(root.searchingText).map(entry => { + entry.clickActionName = Translation.tr("Launch"); + entry.type = Translation.tr("App"); + return entry; + })); + + ////////// Launcher actions //////////// + result = result.concat(launcherActionObjects); + + /// Math result, command, web search /// + if (Config.options.search.prefix.showDefaultActionsWithoutPrefix) { + if (!startsWithShellCommandPrefix) result.push(commandResultObject); + if (!startsWithNumber && !startsWithMathPrefix) result.push(mathResultObject); + if (!startsWithWebSearchPrefix) result.push(webSearchResultObject); + } + + return result; + } + } + + delegate: SearchItem { + // The selectable item for each search result + required property var modelData + anchors.left: parent?.left + anchors.right: parent?.right + entry: modelData + query: root.searchingText.startsWith(Config.options.search.prefix.clipboard) ? root.searchingText.slice(Config.options.search.prefix.clipboard.length) : root.searchingText + } + } + } + } +} diff --git a/ii/modules/screenCorners/ScreenCorners.qml b/ii/modules/screenCorners/ScreenCorners.qml new file mode 100644 index 0000000..f62794f --- /dev/null +++ b/ii/modules/screenCorners/ScreenCorners.qml @@ -0,0 +1,154 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: screenCorners + readonly property Toplevel activeWindow: ToplevelManager.activeToplevel + property var actionForCorner: ({ + [RoundCorner.CornerEnum.TopLeft]: () => GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen, + [RoundCorner.CornerEnum.BottomLeft]: () => GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen, + [RoundCorner.CornerEnum.TopRight]: () => GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen, + [RoundCorner.CornerEnum.BottomRight]: () => GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen + }) + + component CornerPanelWindow: PanelWindow { + id: cornerPanelWindow + property var screen: QsWindow.window?.screen + property var brightnessMonitor: Brightness.getMonitorForScreen(screen) + property bool fullscreen + visible: (Config.options.appearance.fakeScreenRounding === 1 || (Config.options.appearance.fakeScreenRounding === 2 && !fullscreen)) + property var corner + + exclusionMode: ExclusionMode.Ignore + mask: Region { + item: sidebarCornerOpenInteractionLoader.active ? sidebarCornerOpenInteractionLoader : null + } + WlrLayershell.namespace: "quickshell:screenCorners" + WlrLayershell.layer: WlrLayer.Overlay + color: "transparent" + + anchors { + top: cornerWidget.isTopLeft || cornerWidget.isTopRight + left: cornerWidget.isBottomLeft || cornerWidget.isTopLeft + bottom: cornerWidget.isBottomLeft || cornerWidget.isBottomRight + right: cornerWidget.isTopRight || cornerWidget.isBottomRight + } + + implicitWidth: cornerWidget.implicitWidth + implicitHeight: cornerWidget.implicitHeight + + RoundCorner { + id: cornerWidget + corner: cornerPanelWindow.corner + implicitSize: Appearance.rounding.screenRounding + implicitHeight: Math.max(implicitSize, sidebarCornerOpenInteractionLoader.implicitHeight) + implicitWidth: Math.max(implicitSize, sidebarCornerOpenInteractionLoader.implicitWidth) + + Loader { + id: sidebarCornerOpenInteractionLoader + active: (!cornerPanelWindow.fullscreen && Config.options.sidebar.cornerOpen.enable && (Config.options.sidebar.cornerOpen.bottom == cornerWidget.isBottom)) + anchors { + top: (cornerWidget.isTopLeft || cornerWidget.isTopRight) ? parent.top : undefined + bottom: (cornerWidget.isBottomLeft || cornerWidget.isBottomRight) ? parent.bottom : undefined + left: (cornerWidget.isLeft) ? parent.left : undefined + right: (cornerWidget.isTopRight || cornerWidget.isBottomRight) ? parent.right : undefined + } + + sourceComponent: FocusedScrollMouseArea { + implicitWidth: Config.options.sidebar.cornerOpen.cornerRegionWidth + implicitHeight: Config.options.sidebar.cornerOpen.cornerRegionHeight + hoverEnabled: true + onEntered: { + if (Config.options.sidebar.cornerOpen.clickless) + screenCorners.actionForCorner[cornerPanelWindow.corner](); + } + onPressed: { + screenCorners.actionForCorner[cornerPanelWindow.corner](); + } + onScrollDown: { + if (!Config.options.sidebar.cornerOpen.valueScroll) + return; + if (cornerWidget.isLeft) + cornerPanelWindow.brightnessMonitor.setBrightness(cornerPanelWindow.brightnessMonitor.brightness - 0.05); + else { + const currentVolume = Audio.value; + const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2; + Audio.sink.audio.volume -= step; + } + } + onScrollUp: { + if (!Config.options.sidebar.cornerOpen.valueScroll) + return; + if (cornerWidget.isLeft) + cornerPanelWindow.brightnessMonitor.setBrightness(cornerPanelWindow.brightnessMonitor.brightness + 0.05); + else { + const currentVolume = Audio.value; + const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2; + Audio.sink.audio.volume = Math.min(1, Audio.sink.audio.volume + step); + } + } + onMovedAway: { + if (!Config.options.sidebar.cornerOpen.valueScroll) + return; + if (cornerWidget.isLeft) + GlobalStates.osdBrightnessOpen = false; + else + GlobalStates.osdVolumeOpen = false; + } + + Loader { + active: Config.options.sidebar.cornerOpen.visualize + anchors.fill: parent + sourceComponent: Rectangle { + color: Appearance.colors.colPrimary + } + } + } + } + } + } + + Variants { + model: Quickshell.screens + + Scope { + id: monitorScope + required property var modelData + property HyprlandMonitor monitor: Hyprland.monitorFor(modelData) + + // Hide when fullscreen + property list workspacesForMonitor: Hyprland.workspaces.values.filter(workspace => workspace.monitor && workspace.monitor.name == monitor.name) + property var activeWorkspaceWithFullscreen: workspacesForMonitor.filter(workspace => ((workspace.toplevels.values.filter(window => window.wayland?.fullscreen)[0] != undefined) && workspace.active))[0] + property bool fullscreen: activeWorkspaceWithFullscreen != undefined + + CornerPanelWindow { + screen: modelData + corner: RoundCorner.CornerEnum.TopLeft + fullscreen: monitorScope.fullscreen + } + CornerPanelWindow { + screen: modelData + corner: RoundCorner.CornerEnum.TopRight + fullscreen: monitorScope.fullscreen + } + CornerPanelWindow { + screen: modelData + corner: RoundCorner.CornerEnum.BottomLeft + fullscreen: monitorScope.fullscreen + } + CornerPanelWindow { + screen: modelData + corner: RoundCorner.CornerEnum.BottomRight + fullscreen: monitorScope.fullscreen + } + } + } +} diff --git a/ii/modules/sessionScreen/SessionActionButton.qml b/ii/modules/sessionScreen/SessionActionButton.qml new file mode 100644 index 0000000..199f2ab --- /dev/null +++ b/ii/modules/sessionScreen/SessionActionButton.qml @@ -0,0 +1,58 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +RippleButton { + id: button + + property string buttonIcon + property string buttonText + property bool keyboardDown: false + property real size: 120 + + buttonRadius: (button.focus || button.down) ? size / 2 : Appearance.rounding.verylarge + colBackground: button.keyboardDown ? Appearance.colors.colSecondaryContainerActive : + button.focus ? Appearance.colors.colPrimary : + Appearance.colors.colSecondaryContainer + colBackgroundHover: Appearance.colors.colPrimary + colRipple: Appearance.colors.colPrimaryActive + property color colText: (button.down || button.keyboardDown || button.focus || button.hovered) ? + Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer0 + + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + background.implicitHeight: size + background.implicitWidth: size + + Behavior on buttonRadius { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + keyboardDown = true + button.clicked() + event.accepted = true; + } + } + Keys.onReleased: (event) => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + keyboardDown = false + event.accepted = true; + } + } + + contentItem: MaterialSymbol { + id: icon + anchors.fill: parent + color: button.colText + horizontalAlignment: Text.AlignHCenter + iconSize: 45 + text: buttonIcon + } + + StyledToolTip { + content: buttonText + } + +} diff --git a/ii/modules/sessionScreen/SessionScreen.qml b/ii/modules/sessionScreen/SessionScreen.qml new file mode 100644 index 0000000..d5b0756 --- /dev/null +++ b/ii/modules/sessionScreen/SessionScreen.qml @@ -0,0 +1,311 @@ +import qs.modules.common +import qs +import qs.services +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + property var focusedScreen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) + property bool packageManagerRunning: false + property bool downloadRunning: false + + component DescriptionLabel: Rectangle { + id: descriptionLabel + property string text + property color textColor: Appearance.colors.colOnTooltip + color: Appearance.colors.colTooltip + clip: true + radius: Appearance.rounding.normal + implicitHeight: descriptionLabelText.implicitHeight + 10 * 2 + implicitWidth: descriptionLabelText.implicitWidth + 15 * 2 + + Behavior on implicitWidth { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + StyledText { + id: descriptionLabelText + anchors.centerIn: parent + color: descriptionLabel.textColor + text: descriptionLabel.text + } + } + + function detectRunningStuff() { + packageManagerRunning = false; + downloadRunning = false; + detectPackageManagerProc.running = false; + detectPackageManagerProc.running = true; + detectDownloadProc.running = false; + detectDownloadProc.running = true; + } + + Process { + id: detectPackageManagerProc + command: ["pidof", "pacman", "yay", "paru", "dnf", "zypper", "apt", "apx", "xbps", "flatpak", "snap", "apk", + "yum", "epsi", "pikman"] + onExited: (exitCode, exitStatus) => { + root.packageManagerRunning = (exitCode === 0); + } + } + + Process { + id: detectDownloadProc + command: ["bash", "-c", "pidof curl wget aria2c yt-dlp || ls ~/Downloads | grep -E '\.crdownload$|\.part$'"] + onExited: (exitCode, exitStatus) => { + root.downloadRunning = (exitCode === 0); + } + } + + Loader { + id: sessionLoader + active: GlobalStates.sessionOpen + onActiveChanged: { + if (sessionLoader.active) root.detectRunningStuff(); + } + + Connections { + target: GlobalStates + function onScreenLockedChanged() { + if (GlobalStates.screenLocked) { + GlobalStates.sessionOpen = false; + } + } + } + + sourceComponent: PanelWindow { // Session menu + id: sessionRoot + visible: sessionLoader.active + property string subtitle + + function hide() { + GlobalStates.sessionOpen = false; + } + + exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "quickshell:session" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + color: ColorUtils.transparentize(Appearance.m3colors.m3background, 0.3) + + anchors { + top: true + left: true + right: true + } + + implicitWidth: root.focusedScreen?.width ?? 0 + implicitHeight: root.focusedScreen?.height ?? 0 + + MouseArea { + id: sessionMouseArea + anchors.fill: parent + onClicked: { + sessionRoot.hide() + } + } + + ColumnLayout { // Content column + id: contentColumn + anchors.centerIn: parent + spacing: 15 + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Escape) { + sessionRoot.hide(); + } + } + + ColumnLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 0 + StyledText { // Title + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + font.family: Appearance.font.family.title + font.pixelSize: Appearance.font.pixelSize.title + font.weight: Font.DemiBold + text: Translation.tr("Session") + } + + StyledText { // Small instruction + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.normal + text: Translation.tr("Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel") + } + } + + GridLayout { + columns: 4 + columnSpacing: 15 + rowSpacing: 15 + + SessionActionButton { + id: sessionLock + focus: sessionRoot.visible + buttonIcon: "lock" + buttonText: Translation.tr("Lock") + onClicked: { Session.lock(); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.right: sessionSleep + KeyNavigation.down: sessionHibernate + } + SessionActionButton { + id: sessionSleep + buttonIcon: "dark_mode" + buttonText: Translation.tr("Sleep") + onClicked: { Session.suspend(); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.left: sessionLock + KeyNavigation.right: sessionLogout + KeyNavigation.down: sessionShutdown + } + SessionActionButton { + id: sessionLogout + buttonIcon: "logout" + buttonText: Translation.tr("Logout") + onClicked: { Session.logout(); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.left: sessionSleep + KeyNavigation.right: sessionTaskManager + KeyNavigation.down: sessionReboot + } + SessionActionButton { + id: sessionTaskManager + buttonIcon: "browse_activity" + buttonText: Translation.tr("Task Manager") + onClicked: { Session.launchTaskManager(); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.left: sessionLogout + KeyNavigation.down: sessionFirmwareReboot + } + + SessionActionButton { + id: sessionHibernate + buttonIcon: "downloading" + buttonText: Translation.tr("Hibernate") + onClicked: { Session.hibernate(); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.up: sessionLock + KeyNavigation.right: sessionShutdown + } + SessionActionButton { + id: sessionShutdown + buttonIcon: "power_settings_new" + buttonText: Translation.tr("Shutdown") + onClicked: { Session.poweroff(); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.left: sessionHibernate + KeyNavigation.right: sessionReboot + KeyNavigation.up: sessionSleep + } + SessionActionButton { + id: sessionReboot + buttonIcon: "restart_alt" + buttonText: Translation.tr("Reboot") + onClicked: { Session.reboot(); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.left: sessionShutdown + KeyNavigation.right: sessionFirmwareReboot + KeyNavigation.up: sessionLogout + } + SessionActionButton { + id: sessionFirmwareReboot + buttonIcon: "settings_applications" + buttonText: Translation.tr("Reboot to firmware settings") + onClicked: { Session.rebootToFirmware(); sessionRoot.hide() } + onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } + KeyNavigation.up: sessionTaskManager + KeyNavigation.left: sessionReboot + } + } + + DescriptionLabel { + Layout.alignment: Qt.AlignHCenter + text: sessionRoot.subtitle + } + } + + RowLayout { + anchors { + top: contentColumn.bottom + topMargin: 10 + horizontalCenter: contentColumn.horizontalCenter + } + spacing: 10 + + Loader { + active: root.packageManagerRunning + visible: active + sourceComponent: DescriptionLabel { + text: Translation.tr("Your package manager is running") + textColor: Appearance.m3colors.m3onErrorContainer + color: Appearance.m3colors.m3errorContainer + } + } + Loader { + active: root.downloadRunning + visible: active + sourceComponent: DescriptionLabel { + text: Translation.tr("There might be a download in progress") + textColor: Appearance.m3colors.m3onErrorContainer + color: Appearance.m3colors.m3errorContainer + } + } + } + } + } + + IpcHandler { + target: "session" + + function toggle(): void { + GlobalStates.sessionOpen = !GlobalStates.sessionOpen; + } + + function close(): void { + GlobalStates.sessionOpen = false + } + + function open(): void { + GlobalStates.sessionOpen = true + } + } + + GlobalShortcut { + name: "sessionToggle" + description: "Toggles session screen on press" + + onPressed: { + GlobalStates.sessionOpen = !GlobalStates.sessionOpen; + } + } + + GlobalShortcut { + name: "sessionOpen" + description: "Opens session screen on press" + + onPressed: { + GlobalStates.sessionOpen = true + } + } + + GlobalShortcut { + name: "sessionClose" + description: "Closes session screen on press" + + onPressed: { + GlobalStates.sessionOpen = false + } + } + +} diff --git a/ii/modules/settings/About.qml b/ii/modules/settings/About.qml new file mode 100644 index 0000000..7c5dbab --- /dev/null +++ b/ii/modules/settings/About.qml @@ -0,0 +1,151 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + icon: "box" + title: Translation.tr("Distro") + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 20 + Layout.topMargin: 10 + Layout.bottomMargin: 10 + IconImage { + implicitSize: 80 + source: Quickshell.iconPath(SystemInfo.logo) + } + ColumnLayout { + Layout.alignment: Qt.AlignVCenter + // spacing: 10 + StyledText { + text: SystemInfo.distroName + font.pixelSize: Appearance.font.pixelSize.title + } + StyledText { + font.pixelSize: Appearance.font.pixelSize.normal + text: SystemInfo.homeUrl + textFormat: Text.MarkdownText + onLinkActivated: (link) => { + Qt.openUrlExternally(link) + } + PointingHandLinkHover {} + } + } + } + + Flow { + Layout.fillWidth: true + spacing: 5 + + RippleButtonWithIcon { + materialIcon: "auto_stories" + mainText: Translation.tr("Documentation") + onClicked: { + Qt.openUrlExternally(SystemInfo.documentationUrl) + } + } + RippleButtonWithIcon { + materialIcon: "support" + mainText: Translation.tr("Help & Support") + onClicked: { + Qt.openUrlExternally(SystemInfo.supportUrl) + } + } + RippleButtonWithIcon { + materialIcon: "bug_report" + mainText: Translation.tr("Report a Bug") + onClicked: { + Qt.openUrlExternally(SystemInfo.bugReportUrl) + } + } + RippleButtonWithIcon { + materialIcon: "policy" + materialIconFill: false + mainText: Translation.tr("Privacy Policy") + onClicked: { + Qt.openUrlExternally(SystemInfo.privacyPolicyUrl) + } + } + + } + + } + ContentSection { + icon: "folder_managed" + title: Translation.tr("Dotfiles") + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 20 + Layout.topMargin: 10 + Layout.bottomMargin: 10 + IconImage { + implicitSize: 80 + source: Quickshell.iconPath("illogical-impulse") + } + ColumnLayout { + Layout.alignment: Qt.AlignVCenter + // spacing: 10 + StyledText { + text: Translation.tr("illogical-impulse") + font.pixelSize: Appearance.font.pixelSize.title + } + StyledText { + text: "https://github.com/end-4/dots-hyprland" + font.pixelSize: Appearance.font.pixelSize.normal + textFormat: Text.MarkdownText + onLinkActivated: (link) => { + Qt.openUrlExternally(link) + } + PointingHandLinkHover {} + } + } + } + + Flow { + Layout.fillWidth: true + spacing: 5 + + RippleButtonWithIcon { + materialIcon: "auto_stories" + mainText: Translation.tr("Documentation") + onClicked: { + Qt.openUrlExternally("https://end-4.github.io/dots-hyprland-wiki/en/ii-qs/02usage/") + } + } + RippleButtonWithIcon { + materialIcon: "adjust" + materialIconFill: false + mainText: Translation.tr("Issues") + onClicked: { + Qt.openUrlExternally("https://github.com/end-4/dots-hyprland/issues") + } + } + RippleButtonWithIcon { + materialIcon: "forum" + mainText: Translation.tr("Discussions") + onClicked: { + Qt.openUrlExternally("https://github.com/end-4/dots-hyprland/discussions") + } + } + RippleButtonWithIcon { + materialIcon: "favorite" + mainText: Translation.tr("Donate") + onClicked: { + Qt.openUrlExternally("https://github.com/sponsors/end-4") + } + } + + + } + } +} diff --git a/ii/modules/settings/AdvancedConfig.qml b/ii/modules/settings/AdvancedConfig.qml new file mode 100644 index 0000000..9a8aa2a --- /dev/null +++ b/ii/modules/settings/AdvancedConfig.qml @@ -0,0 +1,94 @@ +import QtQuick +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + icon: "colors" + title: Translation.tr("Color generation") + + ConfigSwitch { + buttonIcon: "hardware" + text: Translation.tr("Shell & utilities") + checked: Config.options.appearance.wallpaperTheming.enableAppsAndShell + onCheckedChanged: { + Config.options.appearance.wallpaperTheming.enableAppsAndShell = checked; + } + } + ConfigSwitch { + buttonIcon: "tv_options_input_settings" + text: Translation.tr("Qt apps") + checked: Config.options.appearance.wallpaperTheming.enableQtApps + onCheckedChanged: { + Config.options.appearance.wallpaperTheming.enableQtApps = checked; + } + StyledToolTip { + content: Translation.tr("Shell & utilities theming must also be enabled") + } + } + ConfigSwitch { + buttonIcon: "terminal" + text: Translation.tr("Terminal") + checked: Config.options.appearance.wallpaperTheming.enableTerminal + onCheckedChanged: { + Config.options.appearance.wallpaperTheming.enableTerminal = checked; + } + StyledToolTip { + content: Translation.tr("Shell & utilities theming must also be enabled") + } + } + ConfigRow { + uniform: true + ConfigSwitch { + buttonIcon: "dark_mode" + text: Translation.tr("Force dark mode in terminal") + checked: Config.options.appearance.wallpaperTheming.terminalGenerationProps.forceDarkMode + onCheckedChanged: { + Config.options.appearance.wallpaperTheming.terminalGenerationProps.forceDarkMode= checked; + } + StyledToolTip { + content: Translation.tr("Ignored if terminal theming is not enabled") + } + } + } + + ConfigSpinBox { + icon: "invert_colors" + text: Translation.tr("Terminal: Harmony (%)") + value: Config.options.appearance.wallpaperTheming.terminalGenerationProps.harmony * 100 + from: 0 + to: 100 + stepSize: 10 + onValueChanged: { + Config.options.appearance.wallpaperTheming.terminalGenerationProps.harmony = value / 100; + } + } + ConfigSpinBox { + icon: "gradient" + text: Translation.tr("Terminal: Harmonize threshold") + value: Config.options.appearance.wallpaperTheming.terminalGenerationProps.harmonizeThreshold + from: 0 + to: 100 + stepSize: 10 + onValueChanged: { + Config.options.appearance.wallpaperTheming.terminalGenerationProps.harmonizeThreshold = value; + } + } + ConfigSpinBox { + icon: "format_color_text" + text: Translation.tr("Terminal: Foreground boost (%)") + value: Config.options.appearance.wallpaperTheming.terminalGenerationProps.termFgBoost * 100 + from: 0 + to: 100 + stepSize: 10 + onValueChanged: { + Config.options.appearance.wallpaperTheming.terminalGenerationProps.termFgBoost = value / 100; + } + } + } + +} diff --git a/ii/modules/settings/BarConfig.qml b/ii/modules/settings/BarConfig.qml new file mode 100644 index 0000000..f9f64d1 --- /dev/null +++ b/ii/modules/settings/BarConfig.qml @@ -0,0 +1,286 @@ +import QtQuick +import QtQuick.Layouts +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + icon: "spoke" + title: Translation.tr("Positioning") + + ConfigRow { + ContentSubsection { + title: Translation.tr("Bar position") + Layout.fillWidth: true + + ConfigSelectionArray { + currentValue: (Config.options.bar.bottom ? 1 : 0) | (Config.options.bar.vertical ? 2 : 0) + onSelected: newValue => { + Config.options.bar.bottom = (newValue & 1) !== 0; + Config.options.bar.vertical = (newValue & 2) !== 0; + } + options: [ + { + displayName: Translation.tr("Top"), + icon: "arrow_upward", + value: 0 // bottom: false, vertical: false + }, + { + displayName: Translation.tr("Left"), + icon: "arrow_back", + value: 2 // bottom: false, vertical: true + }, + { + displayName: Translation.tr("Bottom"), + icon: "arrow_downward", + value: 1 // bottom: true, vertical: false + }, + { + displayName: Translation.tr("Right"), + icon: "arrow_forward", + value: 3 // bottom: true, vertical: true + } + ] + } + } + ContentSubsection { + title: Translation.tr("Automatically hide") + Layout.fillWidth: false + + ConfigSelectionArray { + currentValue: Config.options.bar.autoHide.enable + onSelected: newValue => { + Config.options.bar.autoHide.enable = newValue; // Update local copy + } + options: [ + { + displayName: Translation.tr("No"), + icon: "close", + value: false + }, + { + displayName: Translation.tr("Yes"), + icon: "check", + value: true + } + ] + } + } + } + + ConfigRow { + + ContentSubsection { + title: Translation.tr("Corner style") + Layout.fillWidth: true + + ConfigSelectionArray { + currentValue: Config.options.bar.cornerStyle + onSelected: newValue => { + Config.options.bar.cornerStyle = newValue; // Update local copy + } + options: [ + { + displayName: Translation.tr("Hug"), + icon: "line_curve", + value: 0 + }, + { + displayName: Translation.tr("Float"), + icon: "page_header", + value: 1 + }, + { + displayName: Translation.tr("Rect"), + icon: "toolbar", + value: 2 + } + ] + } + } + + ContentSubsection { + title: Translation.tr("Group style") + Layout.fillWidth: false + + ConfigSelectionArray { + currentValue: Config.options.bar.borderless + onSelected: newValue => { + Config.options.bar.borderless = newValue; // Update local copy + } + options: [ + { + displayName: Translation.tr("Pills"), + icon: "location_chip", + value: false + }, + { + displayName: Translation.tr("Line-separated"), + icon: "split_scene", + value: true + } + ] + } + } + } + } + + ContentSection { + icon: "workspaces" + title: Translation.tr("Workspaces") + + ConfigSwitch { + buttonIcon: "counter_1" + text: Translation.tr('Always show numbers') + checked: Config.options.bar.workspaces.alwaysShowNumbers + onCheckedChanged: { + Config.options.bar.workspaces.alwaysShowNumbers = checked; + } + } + + ConfigSwitch { + buttonIcon: "award_star" + text: Translation.tr('Show app icons') + checked: Config.options.bar.workspaces.showAppIcons + onCheckedChanged: { + Config.options.bar.workspaces.showAppIcons = checked; + } + } + + ConfigSwitch { + buttonIcon: "colors" + text: Translation.tr('Tint app icons') + checked: Config.options.bar.workspaces.monochromeIcons + onCheckedChanged: { + Config.options.bar.workspaces.monochromeIcons = checked; + } + } + + ConfigSpinBox { + icon: "view_column" + text: Translation.tr("Workspaces shown") + value: Config.options.bar.workspaces.shown + from: 1 + to: 30 + stepSize: 1 + onValueChanged: { + Config.options.bar.workspaces.shown = value; + } + } + + ConfigSpinBox { + icon: "touch_long" + text: Translation.tr("Number show delay when pressing Super (ms)") + value: Config.options.bar.workspaces.showNumberDelay + from: 0 + to: 1000 + stepSize: 50 + onValueChanged: { + Config.options.bar.workspaces.showNumberDelay = value; + } + } + } + + ContentSection { + icon: "widgets" + title: Translation.tr("Utility buttons") + + ConfigRow { + uniform: true + ConfigSwitch { + buttonIcon: "content_cut" + text: Translation.tr("Screen snip") + checked: Config.options.bar.utilButtons.showScreenSnip + onCheckedChanged: { + Config.options.bar.utilButtons.showScreenSnip = checked; + } + } + ConfigSwitch { + buttonIcon: "colorize" + text: Translation.tr("Color picker") + checked: Config.options.bar.utilButtons.showColorPicker + onCheckedChanged: { + Config.options.bar.utilButtons.showColorPicker = checked; + } + } + } + ConfigRow { + uniform: true + ConfigSwitch { + buttonIcon: "keyboard" + text: Translation.tr("Keyboard toggle") + checked: Config.options.bar.utilButtons.showKeyboardToggle + onCheckedChanged: { + Config.options.bar.utilButtons.showKeyboardToggle = checked; + } + } + ConfigSwitch { + buttonIcon: "mic" + text: Translation.tr("Mic toggle") + checked: Config.options.bar.utilButtons.showMicToggle + onCheckedChanged: { + Config.options.bar.utilButtons.showMicToggle = checked; + } + } + } + ConfigRow { + uniform: true + ConfigSwitch { + buttonIcon: "dark_mode" + text: Translation.tr("Dark/Light toggle") + checked: Config.options.bar.utilButtons.showDarkModeToggle + onCheckedChanged: { + Config.options.bar.utilButtons.showDarkModeToggle = checked; + } + } + ConfigSwitch { + buttonIcon: "speed" + text: Translation.tr("Performance Profile toggle") + checked: Config.options.bar.utilButtons.showPerformanceProfileToggle + onCheckedChanged: { + Config.options.bar.utilButtons.showPerformanceProfileToggle = checked; + } + } + } + } + + ContentSection { + icon: "shelf_auto_hide" + title: Translation.tr("Tray") + + ConfigSwitch { + buttonIcon: "keep" + text: Translation.tr('Make icons pinned by default') + checked: Config.options.bar.tray.invertPinnedItems + onCheckedChanged: { + Config.options.bar.tray.invertPinnedItems = checked; + } + } + + ConfigSwitch { + buttonIcon: "colors" + text: Translation.tr('Tint icons') + checked: Config.options.bar.tray.monochromeIcons + onCheckedChanged: { + Config.options.bar.tray.monochromeIcons = checked; + } + } + } + + ContentSection { + icon: "cloud" + title: Translation.tr("Weather") + ConfigSwitch { + buttonIcon: "check" + text: Translation.tr("Enable") + checked: Config.options.bar.weather.enable + onCheckedChanged: { + Config.options.bar.weather.enable = checked; + } + } + } +} diff --git a/ii/modules/settings/GeneralConfig.qml b/ii/modules/settings/GeneralConfig.qml new file mode 100644 index 0000000..e520dab --- /dev/null +++ b/ii/modules/settings/GeneralConfig.qml @@ -0,0 +1,238 @@ +import QtQuick +import Quickshell +import QtQuick.Layouts +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + icon: "volume_up" + title: Translation.tr("Audio") + + ConfigSwitch { + text: Translation.tr("Earbang protection") + checked: Config.options.audio.protection.enable + onCheckedChanged: { + Config.options.audio.protection.enable = checked; + } + StyledToolTip { + content: Translation.tr("Prevents abrupt increments and restricts volume limit") + } + } + ConfigRow { + // uniform: true + ConfigSpinBox { + text: Translation.tr("Max allowed increase") + value: Config.options.audio.protection.maxAllowedIncrease + from: 0 + to: 100 + stepSize: 2 + onValueChanged: { + Config.options.audio.protection.maxAllowedIncrease = value; + } + } + ConfigSpinBox { + text: Translation.tr("Volume limit") + value: Config.options.audio.protection.maxAllowed + from: 0 + to: 154 // pavucontrol allows up to 153% + stepSize: 2 + onValueChanged: { + Config.options.audio.protection.maxAllowed = value; + } + } + } + } + + ContentSection { + icon: "battery_android_full" + title: Translation.tr("Battery") + + ConfigRow { + uniform: true + ConfigSpinBox { + text: Translation.tr("Low warning") + value: Config.options.battery.low + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.low = value; + } + } + ConfigSpinBox { + text: Translation.tr("Critical warning") + value: Config.options.battery.critical + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.critical = value; + } + } + } + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Automatic suspend") + checked: Config.options.battery.automaticSuspend + onCheckedChanged: { + Config.options.battery.automaticSuspend = checked; + } + StyledToolTip { + content: Translation.tr("Automatically suspends the system when battery is low") + } + } + ConfigSpinBox { + text: Translation.tr("Suspend at") + value: Config.options.battery.suspend + from: 0 + to: 100 + stepSize: 5 + onValueChanged: { + Config.options.battery.suspend = value; + } + } + } + } + + ContentSection { + icon: "language" + title: Translation.tr("Language") + + ContentSubsection { + title: Translation.tr("Interface Language") + tooltip: Translation.tr("Select the language for the user interface.\n\"Auto\" will use your system's locale.") + + ConfigSelectionArray { + id: languageSelector + currentValue: Config.options.language.ui + onSelected: newValue => { + Config.options.language.ui = newValue; + } + options: [ + { + displayName: Translation.tr("Auto (System)"), + value: "auto" + }, + ...Translation.availableLanguages.map(lang => { + return { + displayName: lang.replace('_', '-'), + value: lang + }; + }) + ] + } + } + } + + ContentSection { + icon: "rule" + title: Translation.tr("Policies") + + ConfigRow { + ColumnLayout { + // Weeb policy + ContentSubsectionLabel { + text: Translation.tr("Weeb") + } + ConfigSelectionArray { + currentValue: Config.options.policies.weeb + onSelected: newValue => { + Config.options.policies.weeb = newValue; + } + options: [ + { + displayName: Translation.tr("No"), + icon: "close", + value: 0 + }, + { + displayName: Translation.tr("Yes"), + icon: "check", + value: 1 + }, + { + displayName: Translation.tr("Closet"), + icon: "ev_shadow", + value: 2 + } + ] + } + } + + ColumnLayout { + // AI policy + ContentSubsectionLabel { + text: Translation.tr("AI") + } + ConfigSelectionArray { + currentValue: Config.options.policies.ai + onSelected: newValue => { + Config.options.policies.ai = newValue; + } + options: [ + { + displayName: Translation.tr("No"), + icon: "close", + value: 0 + }, + { + displayName: Translation.tr("Yes"), + icon: "check", + value: 1 + }, + { + displayName: Translation.tr("Local only"), + icon: "sync_saved_locally", + value: 2 + } + ] + } + } + } + } + + ContentSection { + icon: "nest_clock_farsight_analog" + title: Translation.tr("Time") + + ContentSubsection { + title: Translation.tr("Format") + tooltip: "" + + ConfigSelectionArray { + currentValue: Config.options.time.format + onSelected: newValue => { + if (newValue === "hh:mm") { + Quickshell.execDetached(["bash", "-c", `sed -i 's/\\TIME12\\b/TIME/' '${FileUtils.trimFileProtocol(Directories.config)}/hypr/hyprlock.conf'`]); + } else { + Quickshell.execDetached(["bash", "-c", `sed -i 's/\\TIME\\b/TIME12/' '${FileUtils.trimFileProtocol(Directories.config)}/hypr/hyprlock.conf'`]); + } + + Config.options.time.format = newValue; + + } + options: [ + { + displayName: Translation.tr("24h"), + value: "hh:mm" + }, + { + displayName: Translation.tr("12h am/pm"), + value: "h:mm ap" + }, + { + displayName: Translation.tr("12h AM/PM"), + value: "h:mm AP" + }, + ] + } + } + } +} diff --git a/ii/modules/settings/InterfaceConfig.qml b/ii/modules/settings/InterfaceConfig.qml new file mode 100644 index 0000000..57074a3 --- /dev/null +++ b/ii/modules/settings/InterfaceConfig.qml @@ -0,0 +1,276 @@ +import QtQuick +import QtQuick.Layouts +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets + +ContentPage { + forceWidth: true + + ContentSection { + icon: "wallpaper" + title: Translation.tr("Background") + + ConfigSwitch { + text: Translation.tr("Show clock") + checked: Config.options.background.showClock + onCheckedChanged: { + Config.options.background.showClock = checked; + } + } + + ContentSubsection { + title: Translation.tr("Wallpaper parallax") + + ConfigSwitch { + text: Translation.tr("Vertical") + checked: Config.options.background.parallax.vertical + onCheckedChanged: { + Config.options.background.parallax.vertical = checked; + } + } + + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Depends on workspace") + checked: Config.options.background.parallax.enableWorkspace + onCheckedChanged: { + Config.options.background.parallax.enableWorkspace = checked; + } + } + ConfigSwitch { + text: Translation.tr("Depends on sidebars") + checked: Config.options.background.parallax.enableSidebar + onCheckedChanged: { + Config.options.background.parallax.enableSidebar = checked; + } + } + } + ConfigSpinBox { + text: Translation.tr("Preferred wallpaper zoom (%)") + value: Config.options.background.parallax.workspaceZoom * 100 + from: 100 + to: 150 + stepSize: 1 + onValueChanged: { + Config.options.background.parallax.workspaceZoom = value / 100; + } + } + } + } + + ContentSection { + icon: "call_to_action" + title: Translation.tr("Dock") + + ConfigSwitch { + text: Translation.tr("Enable") + checked: Config.options.dock.enable + onCheckedChanged: { + Config.options.dock.enable = checked; + } + } + + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Hover to reveal") + checked: Config.options.dock.hoverToReveal + onCheckedChanged: { + Config.options.dock.hoverToReveal = checked; + } + } + ConfigSwitch { + text: Translation.tr("Pinned on startup") + checked: Config.options.dock.pinnedOnStartup + onCheckedChanged: { + Config.options.dock.pinnedOnStartup = checked; + } + } + } + ConfigSwitch { + text: Translation.tr("Tint app icons") + checked: Config.options.dock.monochromeIcons + onCheckedChanged: { + Config.options.dock.monochromeIcons = checked; + } + } + } + + ContentSection { + icon: "side_navigation" + title: Translation.tr("Sidebars") + + ConfigSwitch { + text: Translation.tr('Keep right sidebar loaded') + checked: Config.options.sidebar.keepRightSidebarLoaded + onCheckedChanged: { + Config.options.sidebar.keepRightSidebarLoaded = checked; + } + StyledToolTip { + content: Translation.tr("When enabled keeps the content of the right sidebar loaded to reduce the delay when opening,\nat the cost of around 15MB of consistent RAM usage. Delay significance depends on your system's performance.\nUsing a custom kernel like linux-cachyos might help") + } + } + + ContentSubsection { + title: Translation.tr("Corner open") + tooltip: Translation.tr("Allows you to open sidebars by clicking or hovering screen corners regardless of bar position") + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Enable") + checked: Config.options.sidebar.cornerOpen.enable + onCheckedChanged: { + Config.options.sidebar.cornerOpen.enable = checked; + } + } + ConfigSwitch { + text: Translation.tr("Hover to trigger") + checked: Config.options.sidebar.cornerOpen.clickless + onCheckedChanged: { + Config.options.sidebar.cornerOpen.clickless = checked; + } + + StyledToolTip { + content: Translation.tr("When this is off you'll have to click") + } + } + } + ConfigRow { + uniform: true + ConfigSwitch { + text: Translation.tr("Place at bottom") + checked: Config.options.sidebar.cornerOpen.bottom + onCheckedChanged: { + Config.options.sidebar.cornerOpen.bottom = checked; + } + + StyledToolTip { + content: Translation.tr("Place the corners to trigger at the bottom") + } + } + ConfigSwitch { + text: Translation.tr("Value scroll") + checked: Config.options.sidebar.cornerOpen.valueScroll + onCheckedChanged: { + Config.options.sidebar.cornerOpen.valueScroll = checked; + } + + StyledToolTip { + content: Translation.tr("Brightness and volume") + } + } + } + ConfigSwitch { + text: Translation.tr("Visualize region") + checked: Config.options.sidebar.cornerOpen.visualize + onCheckedChanged: { + Config.options.sidebar.cornerOpen.visualize = checked; + } + } + ConfigRow { + ConfigSpinBox { + text: Translation.tr("Region width") + value: Config.options.sidebar.cornerOpen.cornerRegionWidth + from: 1 + to: 300 + stepSize: 1 + onValueChanged: { + Config.options.sidebar.cornerOpen.cornerRegionWidth = value; + } + } + ConfigSpinBox { + text: Translation.tr("Region height") + value: Config.options.sidebar.cornerOpen.cornerRegionHeight + from: 1 + to: 300 + stepSize: 1 + onValueChanged: { + Config.options.sidebar.cornerOpen.cornerRegionHeight = value; + } + } + } + } + } + + ContentSection { + icon: "voting_chip" + title: Translation.tr("On-screen display") + + ConfigSpinBox { + text: Translation.tr("Timeout (ms)") + value: Config.options.osd.timeout + from: 100 + to: 3000 + stepSize: 100 + onValueChanged: { + Config.options.osd.timeout = value; + } + } + } + + ContentSection { + icon: "overview_key" + title: Translation.tr("Overview") + + ConfigSwitch { + text: Translation.tr("Enable") + checked: Config.options.overview.enable + onCheckedChanged: { + Config.options.overview.enable = checked; + } + } + ConfigSpinBox { + text: Translation.tr("Scale (%)") + value: Config.options.overview.scale * 100 + from: 1 + to: 100 + stepSize: 1 + onValueChanged: { + Config.options.overview.scale = value / 100; + } + } + ConfigRow { + uniform: true + ConfigSpinBox { + text: Translation.tr("Rows") + value: Config.options.overview.rows + from: 1 + to: 20 + stepSize: 1 + onValueChanged: { + Config.options.overview.rows = value; + } + } + ConfigSpinBox { + text: Translation.tr("Columns") + value: Config.options.overview.columns + from: 1 + to: 20 + stepSize: 1 + onValueChanged: { + Config.options.overview.columns = value; + } + } + } + } + + ContentSection { + icon: "screenshot_frame_2" + title: Translation.tr("Screenshot tool") + + ConfigSwitch { + text: Translation.tr('Show regions of potential interest') + checked: Config.options.screenshotTool.showContentRegions + onCheckedChanged: { + Config.options.screenshotTool.showContentRegions = checked; + } + StyledToolTip { + content: Translation.tr("Such regions could be images or parts of the screen that have some containment.\nMight not always be accurate.\nThis is done with an image processing algorithm run locally and no AI is used.") + } + } + } + +} diff --git a/ii/modules/settings/QuickConfig.qml b/ii/modules/settings/QuickConfig.qml new file mode 100644 index 0000000..d9a0a09 --- /dev/null +++ b/ii/modules/settings/QuickConfig.qml @@ -0,0 +1,319 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Io +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +ContentPage { + forceWidth: true + + Process { + id: konachanWallProc + property string status: "" + command: ["bash", "-c", FileUtils.trimFileProtocol(`${Directories.scriptPath}/colors/random_konachan_wall.sh`)] + stdout: SplitParser { + onRead: data => { + konachanWallProc.status = data.trim(); + } + } + } + + component SmallLightDarkPreferenceButton: RippleButton { + id: smallLightDarkPreferenceButton + required property bool dark + property color colText: toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnLayer2 + padding: 5 + Layout.fillWidth: true + toggled: Appearance.m3colors.darkmode === dark + colBackground: Appearance.colors.colLayer2 + onClicked: { + Quickshell.execDetached(["bash", "-c", `${Directories.wallpaperSwitchScriptPath} --mode ${dark ? "dark" : "light"} --noswitch`]); + } + contentItem: Item { + anchors.centerIn: parent + ColumnLayout { + anchors.centerIn: parent + spacing: 0 + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 30 + text: dark ? "dark_mode" : "light_mode" + color: smallLightDarkPreferenceButton.colText + } + StyledText { + Layout.alignment: Qt.AlignHCenter + text: dark ? Translation.tr("Dark") : Translation.tr("Light") + font.pixelSize: Appearance.font.pixelSize.smaller + color: smallLightDarkPreferenceButton.colText + } + } + } + } + + // Wallpaper selection + ContentSection { + icon: "format_paint" + title: Translation.tr("Wallpaper & Colors") + Layout.fillWidth: true + + RowLayout { + Layout.fillWidth: true + + Item { + implicitWidth: 340 + implicitHeight: 200 + + StyledImage { + id: wallpaperPreview + anchors.fill: parent + sourceSize.width: parent.implicitWidth + sourceSize.height: parent.implicitHeight + fillMode: Image.PreserveAspectCrop + source: Config.options.background.wallpaperPath + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: 360 + height: 200 + radius: Appearance.rounding.normal + } + } + } + } + + ColumnLayout { + RippleButtonWithIcon { + id: rndWallBtn + visible: Config.options.policies.weeb === 1 + Layout.fillWidth: true + buttonRadius: Appearance.rounding.small + materialIcon: "wallpaper" + mainText: konachanWallProc.running ? Translation.tr("Be patient...") : Translation.tr("Random: Konachan") + onClicked: { + konachanWallProc.running = true; + } + StyledToolTip { + content: Translation.tr("Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers") + } + } + RippleButtonWithIcon { + Layout.fillWidth: true + materialIcon: "wallpaper" + StyledToolTip { + content: Translation.tr("Pick wallpaper image on your system") + } + onClicked: { + Quickshell.execDetached(`${Directories.wallpaperSwitchScriptPath}`); + } + mainContentComponent: Component { + RowLayout { + spacing: 10 + StyledText { + font.pixelSize: Appearance.font.pixelSize.small + text: Translation.tr("Choose file") + color: Appearance.colors.colOnSecondaryContainer + } + RowLayout { + spacing: 3 + KeyboardKey { + key: "Ctrl" + } + KeyboardKey { + key: "󰖳" + } + StyledText { + Layout.alignment: Qt.AlignVCenter + text: "+" + } + KeyboardKey { + key: "T" + } + } + } + } + } + RowLayout { + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + Layout.fillHeight: true + uniformCellSizes: true + + SmallLightDarkPreferenceButton { + Layout.fillHeight: true + dark: false + } + SmallLightDarkPreferenceButton { + Layout.fillHeight: true + dark: true + } + } + + ConfigSwitch { + text: Translation.tr("Transparency") + checked: Config.options.appearance.transparency.enable + onCheckedChanged: { + Config.options.appearance.transparency.enable = checked; + } + StyledToolTip { + content: Translation.tr("Might look ass. Unsupported.") + } + } + } + } + + ConfigSelectionArray { + currentValue: Config.options.appearance.palette.type + onSelected: newValue => { + Config.options.appearance.palette.type = newValue; + Quickshell.execDetached(["bash", "-c", `${Directories.wallpaperSwitchScriptPath} --noswitch`]); + } + options: [ + { + "value": "auto", + "displayName": Translation.tr("Auto") + }, + { + "value": "scheme-content", + "displayName": Translation.tr("Content") + }, + { + "value": "scheme-expressive", + "displayName": Translation.tr("Expressive") + }, + { + "value": "scheme-fidelity", + "displayName": Translation.tr("Fidelity") + }, + { + "value": "scheme-fruit-salad", + "displayName": Translation.tr("Fruit Salad") + }, + { + "value": "scheme-monochrome", + "displayName": Translation.tr("Monochrome") + }, + { + "value": "scheme-neutral", + "displayName": Translation.tr("Neutral") + }, + { + "value": "scheme-rainbow", + "displayName": Translation.tr("Rainbow") + }, + { + "value": "scheme-tonal-spot", + "displayName": Translation.tr("Tonal Spot") + } + ] + } + } + + ContentSection { + icon: "screenshot_monitor" + title: Translation.tr("Bar & screen") + + ConfigRow { + ContentSubsection { + title: Translation.tr("Bar position") + ConfigSelectionArray { + currentValue: (Config.options.bar.bottom ? 1 : 0) | (Config.options.bar.vertical ? 2 : 0) + onSelected: newValue => { + Config.options.bar.bottom = (newValue & 1) !== 0; + Config.options.bar.vertical = (newValue & 2) !== 0; + } + options: [ + { + displayName: Translation.tr("Top"), + icon: "arrow_upward", + value: 0 // bottom: false, vertical: false + }, + { + displayName: Translation.tr("Left"), + icon: "arrow_back", + value: 2 // bottom: false, vertical: true + }, + { + displayName: Translation.tr("Bottom"), + icon: "arrow_downward", + value: 1 // bottom: true, vertical: false + }, + { + displayName: Translation.tr("Right"), + icon: "arrow_forward", + value: 3 // bottom: true, vertical: true + } + ] + } + } + ContentSubsection { + title: Translation.tr("Bar style") + + ConfigSelectionArray { + currentValue: Config.options.bar.cornerStyle + onSelected: newValue => { + Config.options.bar.cornerStyle = newValue; // Update local copy + } + options: [ + { + displayName: Translation.tr("Hug"), + icon: "line_curve", + value: 0 + }, + { + displayName: Translation.tr("Float"), + icon: "page_header", + value: 1 + }, + { + displayName: Translation.tr("Rect"), + icon: "toolbar", + value: 2 + } + ] + } + } + } + + ConfigRow { + ContentSubsection { + title: Translation.tr("Screen round corner") + + ConfigSelectionArray { + currentValue: Config.options.appearance.fakeScreenRounding + onSelected: newValue => { + Config.options.appearance.fakeScreenRounding = newValue; + } + options: [ + { + displayName: Translation.tr("No"), + icon: "close", + value: 0 + }, + { + displayName: Translation.tr("Yes"), + icon: "check", + value: 1 + }, + { + displayName: Translation.tr("When not fullscreen"), + icon: "fullscreen_exit", + value: 2 + } + ] + } + } + + } + } + + NoticeBox { + Layout.fillWidth: true + text: Translation.tr('Not all options are available in this app. You should also check the config file by hitting the "Config file" button on the topleft corner or opening %1 manually.').arg(Directories.shellConfigPath) + } +} diff --git a/ii/modules/settings/ServicesConfig.qml b/ii/modules/settings/ServicesConfig.qml new file mode 100644 index 0000000..f28eab3 --- /dev/null +++ b/ii/modules/settings/ServicesConfig.qml @@ -0,0 +1,153 @@ +import QtQuick +import QtQuick.Layouts +import qs +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import Quickshell + +ContentPage { + forceWidth: true + + ContentSection { + icon: "neurology" + title: Translation.tr("AI") + + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("System prompt") + text: Config.options.ai.systemPrompt + wrapMode: TextEdit.Wrap + onTextChanged: { + Qt.callLater(() => { + Config.options.ai.systemPrompt = text; + }); + } + } + } + + ContentSection { + icon: "cell_tower" + title: Translation.tr("Networking") + + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("User agent (for services that require it)") + text: Config.options.networking.userAgent + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.networking.userAgent = text; + } + } + } + + ContentSection { + icon: "memory" + title: Translation.tr("Resources") + + ConfigSpinBox { + text: Translation.tr("Polling interval (ms)") + value: Config.options.resources.updateInterval + from: 100 + to: 10000 + stepSize: 100 + onValueChanged: { + Config.options.resources.updateInterval = value; + } + } + } + + ContentSection { + icon: "search" + title: Translation.tr("Search") + + ConfigSwitch { + text: Translation.tr("Use Levenshtein distance-based algorithm instead of fuzzy") + checked: Config.options.search.sloppy + onCheckedChanged: { + Config.options.search.sloppy = checked; + } + StyledToolTip { + content: Translation.tr("Could be better if you make a ton of typos,\nbut results can be weird and might not work with acronyms\n(e.g. \"GIMP\" might not give you the paint program)") + } + } + + ContentSubsection { + title: Translation.tr("Prefixes") + ConfigRow { + uniform: true + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Action") + text: Config.options.search.prefix.action + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.action = text; + } + } + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Clipboard") + text: Config.options.search.prefix.clipboard + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.clipboard = text; + } + } + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Emojis") + text: Config.options.search.prefix.emojis + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.emojis = text; + } + } + } + + ConfigRow { + uniform: true + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Math") + text: Config.options.search.prefix.math + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.math = text; + } + } + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Shell command") + text: Config.options.search.prefix.shellCommand + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.shellCommand = text; + } + } + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Web search") + text: Config.options.search.prefix.webSearch + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.prefix.webSearch = text; + } + } + } + } + ContentSubsection { + title: Translation.tr("Web search") + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Base URL") + text: Config.options.search.engineBaseUrl + wrapMode: TextEdit.Wrap + onTextChanged: { + Config.options.search.engineBaseUrl = text; + } + } + } + } +} diff --git a/ii/modules/sidebarLeft/AiChat.qml b/ii/modules/sidebarLeft/AiChat.qml new file mode 100644 index 0000000..2d747fa --- /dev/null +++ b/ii/modules/sidebarLeft/AiChat.qml @@ -0,0 +1,821 @@ +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 QtQuick.Dialogs +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +Item { + id: root + function openAttachDialog() { + // Prefer a native floating dialog on Hyprland for proper centering + if (Hyprland.focusedMonitor) { + // Trigger zenity file picker, then center the focused window shortly after + centerTimer.restart(); + externalFilePicker.running = true; + } else { + attachFileDialog.open(); + } + } + + Process { + id: externalFilePicker + command: ["bash", "-lc", "zenity --file-selection --title='Select a file to attach' || true"] + stdout: StdioCollector { + onStreamFinished: { + const path = text.trim(); + if (path.length > 0) Ai.attachFile(path); + } + } + } + + Timer { + id: centerTimer + interval: 120 + repeat: false + onTriggered: centerFocused.running = true + } + + Process { + id: centerFocused + command: ["bash", "-lc", "hyprctl dispatch focuswindow title:'Select a file to attach'; if hyprctl activewindow -j | grep -q '\"title\": \"Select a file to attach\"'; then hyprctl dispatch setfloating active; hyprctl dispatch centerwindow; fi"] + } + FileDialog { + id: attachFileDialog + title: Translation.tr("Select a file to attach") + onAccepted: { + if (attachFileDialog.selectedFile && attachFileDialog.selectedFile.toString().length > 0) { + Ai.attachFile(attachFileDialog.selectedFile.toString()); + } + } + } + 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: "attach", + description: Translation.tr("Attach a file. Only works with Gemini."), + execute: (args) => { + Ai.attachFile(args.join(" ").trim()); + } + }, + { + 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(` + +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! + +## ✏️ Markdown test +### Formatting + +- *Italic*, \`Monospace\`, **Bold**, [Link](https://example.com) +- Arch lincox icon + +### 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 +// 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); + } + } + + Process { + id: decodeImageAndAttachProc + property string imageDecodePath: Directories.cliphistDecode + property string imageDecodeFileName: "image" + property string imageDecodeFilePath: `${imageDecodePath}/${imageDecodeFileName}` + function handleEntry(entry: string) { + imageDecodeFileName = parseInt(entry.match(/^(\d+)\t/)[1]) + decodeImageAndAttachProc.exec(["bash", "-c", + `[ -f ${imageDecodeFilePath} ] || echo '${StringUtils.shellSingleQuoteEscape(entry)}' | ${Cliphist.cliphistBinary} decode > '${imageDecodeFilePath}'` + ]) + } + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + Ai.attachFile(imageDecodeFilePath); + } else { + console.error("[AiChat] Failed to decode image in clipboard content") + } + } + } + + 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 + animateChange: true + } + } + + 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 + + touchpadScrollFactor: Config.options.interactions.scrolling.touchpadScrollFactor * 1.4 + mouseScrollFactor: Config.options.interactions.scrolling.mouseScrollFactor * 1.4 + + 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 + + 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 spacing: 5 + Layout.fillWidth: true + radius: Appearance.rounding.small + color: Appearance.colors.colLayer1 + implicitHeight: Math.max(inputFieldRowLayout.implicitHeight + inputFieldRowLayout.anchors.topMargin + + commandButtonsRow.implicitHeight + commandButtonsRow.anchors.bottomMargin + spacing, 45) + + (attachedFileIndicator.implicitHeight + spacing + attachedFileIndicator.anchors.topMargin) + clip: true + border.color: Appearance.colors.colOutlineVariant + border.width: 1 + + Behavior on implicitHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + AttachedFileIndicator { + id: attachedFileIndicator + anchors { + top: parent.top + left: parent.left + right: parent.right + margins: visible ? 5 : 0 + } + filePath: Ai.pendingFilePath + onRemove: Ai.attachFile("") + } + + RowLayout { // Input field and send button + id: inputFieldRowLayout + anchors { + top: attachedFileIndicator.bottom + left: parent.left + right: parent.right + 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 + } + } else if ((event.modifiers & Qt.ControlModifier) && event.key === Qt.Key_V) { // Intercept Ctrl+V to handle image pasting + // Try image paste first + const currentClipboardEntry = Cliphist.entries[0] + if (/^\d+\t\[\[.*binary data.*\d+x\d+.*\]\]$/.test(currentClipboardEntry)) { // First entry = currently copied entry = image? + decodeImageAndAttachProc.handleEntry(currentClipboardEntry) + event.accepted = true; + return; + } + event.accepted = false; // No image, let text pasting proceed + } else if (event.key === Qt.Key_Escape) { // Esc to detach file + if (Ai.pendingFilePath.length > 0) { + Ai.attachFile(""); + event.accepted = true; + } else { + event.accepted = false; + } + } + } + } + + 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 } + + RippleButton { // Attach file button + id: attachButton + Layout.alignment: Qt.AlignVCenter + implicitWidth: 36 + implicitHeight: 36 + buttonRadius: Appearance.rounding.small + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + color: Appearance.m3colors.m3onPrimary + text: "attach_file" + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: openAttachDialog() + } + StyledToolTip { + content: Translation.tr("Attach a file") + } + } + + 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 = "" + } + } + } + } + } + } + + } + + } + +} \ No newline at end of file diff --git a/ii/modules/sidebarLeft/Anime.qml b/ii/modules/sidebarLeft/Anime.qml new file mode 100644 index 0000000..9e7bfb4 --- /dev/null +++ b/ii/modules/sidebarLeft/Anime.qml @@ -0,0 +1,583 @@ +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: Config + function onReadyChanged() { + if (Config.options.policies.weeb !== 0) { + Quickshell.execDetached(["bash", "-c", `mkdir -p '${root.downloadPath}' && mkdir -p '${root.nsfwPath}'`]) + } + } + } + + 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 + + touchpadScrollFactor: Config.options.interactions.scrolling.touchpadScrollFactor * 1.4 + mouseScrollFactor: Config.options.interactions.scrolling.mouseScrollFactor * 1.4 + + property int lastResponseLength: 0 + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + 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 = "" + } + } + } + } + } + } + + } + } +} diff --git a/ii/modules/sidebarLeft/ApiCommandButton.qml b/ii/modules/sidebarLeft/ApiCommandButton.qml new file mode 100644 index 0000000..efbde15 --- /dev/null +++ b/ii/modules/sidebarLeft/ApiCommandButton.qml @@ -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 + } +} \ No newline at end of file diff --git a/ii/modules/sidebarLeft/ApiInputBoxIndicator.qml b/ii/modules/sidebarLeft/ApiInputBoxIndicator.qml new file mode 100644 index 0000000..878fba2 --- /dev/null +++ b/ii/modules/sidebarLeft/ApiInputBoxIndicator.qml @@ -0,0 +1,48 @@ +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 + animateChange: true + } + } + + 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 + } + } + } +} diff --git a/ii/modules/sidebarLeft/DescriptionBox.qml b/ii/modules/sidebarLeft/DescriptionBox.qml new file mode 100644 index 0000000..5287ecc --- /dev/null +++ b/ii/modules/sidebarLeft/DescriptionBox.qml @@ -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 + } + } + } +} \ No newline at end of file diff --git a/ii/modules/sidebarLeft/SidebarLeft.qml b/ii/modules/sidebarLeft/SidebarLeft.qml new file mode 100644 index 0000000..0aed7b7 --- /dev/null +++ b/ii/modules/sidebarLeft/SidebarLeft.qml @@ -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; + } + } + +} diff --git a/ii/modules/sidebarLeft/SidebarLeftContent.qml b/ii/modules/sidebarLeft/SidebarLeftContent.qml new file mode 100644 index 0000000..fc60618 --- /dev/null +++ b/ii/modules/sidebarLeft/SidebarLeftContent.qml @@ -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 {} + } + + } +} \ No newline at end of file diff --git a/ii/modules/sidebarLeft/Translator.qml b/ii/modules/sidebarLeft/Translator.qml new file mode 100644 index 0000000..e9e3d6f --- /dev/null +++ b/ii/modules/sidebarLeft/Translator.qml @@ -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 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 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 + } + } + } +} diff --git a/ii/modules/sidebarLeft/aiChat/AiMessage.qml b/ii/modules/sidebarLeft/aiChat/AiMessage.qml new file mode 100644 index 0000000..87bfba7 --- /dev/null +++ b/ii/modules/sidebarLeft/aiChat/AiMessage.qml @@ -0,0 +1,311 @@ +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 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") + } + } + } + } + + Loader { + Layout.fillWidth: true + active: root.messageData?.localFilePath && root.messageData?.localFilePath.length > 0 + sourceComponent: AttachedFileIndicator { + filePath: root.messageData?.localFilePath + canRemove: false + } + } + + 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 + } + } + } + + } +} + diff --git a/ii/modules/sidebarLeft/aiChat/AiMessageControlButton.qml b/ii/modules/sidebarLeft/aiChat/AiMessageControlButton.qml new file mode 100644 index 0000000..64fc772 --- /dev/null +++ b/ii/modules/sidebarLeft/aiChat/AiMessageControlButton.qml @@ -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) + } + } +} diff --git a/ii/modules/sidebarLeft/aiChat/AnnotationSourceButton.qml b/ii/modules/sidebarLeft/aiChat/AnnotationSourceButton.qml new file mode 100644 index 0000000..bd75a5d --- /dev/null +++ b/ii/modules/sidebarLeft/aiChat/AnnotationSourceButton.qml @@ -0,0 +1,52 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import qs.services +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 + } + } + } +} diff --git a/ii/modules/sidebarLeft/aiChat/AttachedFileIndicator.qml b/ii/modules/sidebarLeft/aiChat/AttachedFileIndicator.qml new file mode 100644 index 0000000..a0a3e14 --- /dev/null +++ b/ii/modules/sidebarLeft/aiChat/AttachedFileIndicator.qml @@ -0,0 +1,160 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell.Io +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services + +Rectangle { + id: root + + signal remove() + property bool canRemove: true + property string filePath: "" + property string mimeType: "" + property real maxHeight: 200 + property real imageWidth: -1 + property real imageHeight: -1 + property real scale: Math.min(root.maxHeight / imageHeight, root.width / imageWidth) + onFilePathChanged: refresh() + visible: filePath !== "" + + function refresh() { + root.mimeType = ""; + root.imageWidth = -1; + root.imageHeight = -1; + fileTypeProc.exec(["file", "-b", "--mime-type", filePath]); + } + + Process { + id: fileTypeProc + command: ["file", "-b", "--mime-type", filePath] + stdout: StdioCollector { + onStreamFinished: { + root.mimeType = this.text; + if (root.mimeType.startsWith("image/")) + imageSizeProc.exec(["identify", "-format", "%wx%h", filePath]); + } + } + } + + Process { + id: imageSizeProc + command: ["identify", "-format", "%wx%h", filePath] + stdout: StdioCollector { + onStreamFinished: { + const dimensions = this.text.split("x"); + root.imageWidth = parseInt(dimensions[0]); + root.imageHeight = parseInt(dimensions[1]); + } + } + } + + // Styles/widgets + property real horizontalPadding: 10 + property real verticalPadding: 10 + radius: Appearance.rounding.small - anchors.margins + color: Appearance.colors.colLayer2 + implicitHeight: visible ? (contentItem.implicitHeight + verticalPadding * 2) : 0 + + ColumnLayout { + id: contentItem + anchors { + fill: parent + leftMargin: root.horizontalPadding + rightMargin: root.horizontalPadding + topMargin: root.verticalPadding + bottomMargin: root.verticalPadding + } + + RowLayout { + MaterialSymbol { + Layout.alignment: Qt.AlignTop + text: { + if (root.mimeType.startsWith("image/")) + return "image"; + if (root.mimeType.startsWith("audio/")) + return "music_note"; + if (root.mimeType.startsWith("video/")) + return "movie"; + if (root.mimeType === "application/pdf") + return "picture_as_pdf"; + if (root.mimeType.startsWith("text/")) + return "description"; + return "file_present"; + } + iconSize: Appearance.font.pixelSize.hugeass + } + + StyledText { + Layout.fillWidth: true + Layout.topMargin: 4 + text: root.filePath + font.pixelSize: Appearance.font.pixelSize.smaller + font.family: Appearance.font.family.monospace + wrapMode: Text.Wrap + } + + RippleButton { + visible: root.canRemove + Layout.alignment: Qt.AlignTop + buttonRadius: Appearance.rounding.full + colBackground: Appearance.colors.colLayer2 + implicitHeight: 28 + implicitWidth: 28 + contentItem: MaterialSymbol { + anchors.centerIn: parent + text: "close" + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnSurfaceVariant + } + + onClicked: root.remove() + } + } + + Loader { + id: imagePreviewLoader + visible: (root.imageWidth != -1) && (root.imageHeight != -1) + Layout.alignment: Qt.AlignHCenter + sourceComponent: Item { + implicitHeight: root.imageHeight * root.scale + implicitWidth: imagePreview.implicitWidth + Image { + id: imagePreview + anchors.fill: parent + source: Qt.resolvedUrl(root.filePath) + fillMode: Image.PreserveAspectFit + antialiasing: true + asynchronous: true + width: root.imageWidth * root.scale + height: root.imageHeight * root.scale + sourceSize.width: root.imageWidth * root.scale + sourceSize.height: root.imageHeight * root.scale + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: imagePreview.width + height: imagePreview.height + radius: Appearance.rounding.normal + } + } + + Rectangle { + anchors.fill: parent + color: "transparent" + border.width: 1 + border.color: Appearance.colors.colOutlineVariant + radius: Appearance.rounding.normal + } + } + } + } + } +} diff --git a/ii/modules/sidebarLeft/aiChat/MessageCodeBlock.qml b/ii/modules/sidebarLeft/aiChat/MessageCodeBlock.qml new file mode 100644 index 0000000..f8b0bac --- /dev/null +++ b/ii/modules/sidebarLeft/aiChat/MessageCodeBlock.qml @@ -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 + // } + // } + } + } +} \ No newline at end of file diff --git a/ii/modules/sidebarLeft/aiChat/MessageTextBlock.qml b/ii/modules/sidebarLeft/aiChat/MessageTextBlock.qml new file mode 100644 index 0000000..d0d9d64 --- /dev/null +++ b/ii/modules/sidebarLeft/aiChat/MessageTextBlock.qml @@ -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 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 + } + } +} diff --git a/ii/modules/sidebarLeft/aiChat/MessageThinkBlock.qml b/ii/modules/sidebarLeft/aiChat/MessageThinkBlock.qml new file mode 100644 index 0000000..326c26d --- /dev/null +++ b/ii/modules/sidebarLeft/aiChat/MessageThinkBlock.qml @@ -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 + } + } + } + } +} \ No newline at end of file diff --git a/ii/modules/sidebarLeft/aiChat/SearchQueryButton.qml b/ii/modules/sidebarLeft/aiChat/SearchQueryButton.qml new file mode 100644 index 0000000..4ad60ac --- /dev/null +++ b/ii/modules/sidebarLeft/aiChat/SearchQueryButton.qml @@ -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 + } + } + } +} diff --git a/ii/modules/sidebarLeft/anime/BooruImage.qml b/ii/modules/sidebarLeft/anime/BooruImage.qml new file mode 100644 index 0000000..b2eef29 --- /dev/null +++ b/ii/modules/sidebarLeft/anime/BooruImage.qml @@ -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: false + 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 + fillMode: Image.PreserveAspectFit + source: modelData.preview_url + sourceSize.width: root.rowHeight * modelData.aspect_ratio + sourceSize.height: root.rowHeight + + visible: opacity > 0 + opacity: status === Image.Ready ? 1 : 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this) + } + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: root.rowHeight * modelData.aspect_ratio + height: root.rowHeight + radius: imageRadius + } + } + } + + 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'` + ]) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/ii/modules/sidebarLeft/anime/BooruResponse.qml b/ii/modules/sidebarLeft/anime/BooruResponse.qml new file mode 100644 index 0000000..9e021e5 --- /dev/null +++ b/ii/modules/sidebarLeft/anime/BooruResponse.qml @@ -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" + } + } + } + } + } +} \ No newline at end of file diff --git a/ii/modules/sidebarLeft/translator/LanguageSelectorButton.qml b/ii/modules/sidebarLeft/translator/LanguageSelectorButton.qml new file mode 100644 index 0000000..f23e3b8 --- /dev/null +++ b/ii/modules/sidebarLeft/translator/LanguageSelectorButton.qml @@ -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 + } + } + } +} diff --git a/ii/modules/sidebarLeft/translator/TextCanvas.qml b/ii/modules/sidebarLeft/translator/TextCanvas.qml new file mode 100644 index 0000000..c29265c --- /dev/null +++ b/ii/modules/sidebarLeft/translator/TextCanvas.qml @@ -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 + } + } + } +} \ No newline at end of file diff --git a/ii/modules/sidebarRight/BottomWidgetGroup.qml b/ii/modules/sidebarRight/BottomWidgetGroup.qml new file mode 100644 index 0000000..cd5385c --- /dev/null +++ b/ii/modules/sidebarRight/BottomWidgetGroup.qml @@ -0,0 +1,252 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs +import qs.services +import "./calendar" +import "./todo" +import "./pomodoro" +import QtQuick +import QtQuick.Layouts + +Rectangle { + id: root + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + clip: true + implicitHeight: collapsed ? collapsedBottomWidgetGroupRow.implicitHeight : bottomWidgetGroupRow.implicitHeight + property int selectedTab: Persistent.states.sidebar.bottomGroup.tab + property bool collapsed: Persistent.states.sidebar.bottomGroup.collapsed + property var tabs: [ + {"type": "calendar", "name": Translation.tr("Calendar"), "icon": "calendar_month", "widget": calendarWidget}, + {"type": "todo", "name": Translation.tr("To Do"), "icon": "done_outline", "widget": todoWidget}, + {"type": "timer", "name": Translation.tr("Timer"), "icon": "schedule", "widget": pomodoroWidget}, + ] + + Behavior on implicitHeight { + NumberAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + + function setCollapsed(state) { + Persistent.states.sidebar.bottomGroup.collapsed = state + if (collapsed) { + bottomWidgetGroupRow.opacity = 0 + } + else { + collapsedBottomWidgetGroupRow.opacity = 0 + } + collapseCleanFadeTimer.start() + } + + Timer { + id: collapseCleanFadeTimer + interval: Appearance.animation.elementMove.duration / 2 + repeat: false + onTriggered: { + if(collapsed) collapsedBottomWidgetGroupRow.opacity = 1 + else bottomWidgetGroupRow.opacity = 1 + } + } + + Keys.onPressed: (event) => { + if ((event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) + && event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_PageDown) { + root.selectedTab = Math.min(root.selectedTab + 1, root.tabs.length - 1) + } else if (event.key === Qt.Key_PageUp) { + root.selectedTab = Math.max(root.selectedTab - 1, 0) + } + event.accepted = true; + } + } + + // The thing when collapsed + RowLayout { + id: collapsedBottomWidgetGroupRow + opacity: collapsed ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + NumberAnimation { + id: collapsedBottomWidgetGroupRowFade + duration: Appearance.animation.elementMove.duration / 2 + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + + spacing: 15 + + CalendarHeaderButton { + Layout.margins: 10 + Layout.rightMargin: 0 + forceCircle: true + onClicked: { + root.setCollapsed(false) + } + contentItem: MaterialSymbol { + text: "keyboard_arrow_up" + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: Appearance.colors.colOnLayer1 + } + } + + StyledText { + property int remainingTasks: Todo.list.filter(task => !task.done).length; + Layout.margins: 10 + Layout.leftMargin: 0 + // text: `${DateTime.collapsedCalendarFormat} • ${remainingTasks} task${remainingTasks > 1 ? "s" : ""}` + text: Translation.tr("%1 • %2 tasks").arg(DateTime.collapsedCalendarFormat).arg(remainingTasks) + font.pixelSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer1 + } + } + + // The thing when expanded + RowLayout { + id: bottomWidgetGroupRow + + opacity: collapsed ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { + NumberAnimation { + id: bottomWidgetGroupRowFade + duration: Appearance.animation.elementMove.duration / 2 + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + + anchors.fill: parent + height: tabStack.height + spacing: 10 + + // Navigation rail + Item { + Layout.fillHeight: true + Layout.fillWidth: false + Layout.leftMargin: 10 + Layout.topMargin: 10 + width: tabBar.width + // Navigation rail buttons + NavigationRailTabArray { + id: tabBar + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 5 + currentIndex: root.selectedTab + expanded: false + Repeater { + model: root.tabs + NavigationRailButton { + showToggledHighlight: false + toggled: root.selectedTab == index + buttonText: modelData.name + buttonIcon: modelData.icon + onClicked: { + root.selectedTab = index + Persistent.states.sidebar.bottomGroup.tab = index + } + } + } + } + // Collapse button + CalendarHeaderButton { + anchors.left: parent.left + anchors.top: parent.top + forceCircle: true + onClicked: { + root.setCollapsed(true) + } + contentItem: MaterialSymbol { + text: "keyboard_arrow_down" + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: Appearance.colors.colOnLayer1 + } + } + } + + // Content area + StackLayout { + id: tabStack + Layout.fillWidth: true + // Take the highest one, because the TODO list has no implicit height. This way the heigth of the calendar is used when it's initially loaded with the TODO list + height: Math.max(...tabStack.children.map(child => child.tabLoader?.implicitHeight || 0)) // TODO: make this less stupid + Layout.alignment: Qt.AlignVCenter + property int realIndex: root.selectedTab + property int animationDuration: Appearance.animation.elementMoveFast.duration * 1.5 + currentIndex: root.selectedTab + + // Switch the tab on halfway of the anim duration + Connections { + target: root + function onSelectedTabChanged() { + delayedStackSwitch.start() + tabStack.realIndex = root.selectedTab + } + } + Timer { + id: delayedStackSwitch + interval: tabStack.animationDuration / 2 + repeat: false + onTriggered: { + tabStack.currentIndex = root.selectedTab + } + } + + Repeater { + model: tabs + Item { // TODO: make behavior on y also act for the item that's switched to + id: tabItem + property int tabIndex: index + property string tabType: modelData.type + property int animDistance: 5 + property var tabLoader: tabLoader + // Opacity: show up only when being animated to + opacity: (tabStack.currentIndex === tabItem.tabIndex && tabStack.realIndex === tabItem.tabIndex) ? 1 : 0 + // Y: starts animating when user selects a different tab + y: (tabStack.realIndex === tabItem.tabIndex) ? 0 : (tabStack.realIndex < tabItem.tabIndex) ? animDistance : -animDistance + Behavior on opacity { NumberAnimation { duration: tabStack.animationDuration / 2; easing.type: Easing.OutCubic } } + Behavior on y { NumberAnimation { duration: tabStack.animationDuration; easing.type: Easing.OutExpo } } + Loader { + id: tabLoader + anchors.fill: parent + sourceComponent: modelData.widget + focus: root.selectedTab === tabItem.tabIndex + } + } + } + } + } + + // Calendar component + Component { + id: calendarWidget + + CalendarWidget { + anchors.centerIn: parent + } + } + + // To Do component + Component { + id: todoWidget + TodoWidget { + anchors.fill: parent + anchors.margins: 5 + } + } + + // Pomodoro component + Component { + id: pomodoroWidget + PomodoroWidget { + anchors.fill: parent + anchors.margins: 5 + } + } +} \ No newline at end of file diff --git a/ii/modules/sidebarRight/CenterWidgetGroup.qml b/ii/modules/sidebarRight/CenterWidgetGroup.qml new file mode 100644 index 0000000..dccf894 --- /dev/null +++ b/ii/modules/sidebarRight/CenterWidgetGroup.qml @@ -0,0 +1,83 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import "./notifications" +import "./volumeMixer" +import qs +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: root + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + + property int selectedTab: 0 + property var tabButtonList: [ + {"icon": "notifications", "name": Translation.tr("Notifications")}, + {"icon": "volume_up", "name": Translation.tr("Audio")} + ] + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) { + if (event.key === Qt.Key_PageDown) { + root.selectedTab = Math.min(root.selectedTab + 1, root.tabButtonList.length - 1) + } else if (event.key === Qt.Key_PageUp) { + root.selectedTab = Math.max(root.selectedTab - 1, 0) + } + event.accepted = true; + } + if (event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_Tab) { + root.selectedTab = (root.selectedTab + 1) % root.tabButtonList.length + } else if (event.key === Qt.Key_Backtab) { + root.selectedTab = (root.selectedTab - 1 + root.tabButtonList.length) % root.tabButtonList.length + } + event.accepted = true; + } + } + + ColumnLayout { + anchors.margins: 5 + anchors.fill: parent + spacing: 0 + + PrimaryTabBar { + id: tabBar + tabButtonList: root.tabButtonList + externalTrackedTab: root.selectedTab + + function onCurrentIndexChanged(currentIndex) { + root.selectedTab = currentIndex + } + } + + SwipeView { + id: swipeView + Layout.topMargin: 5 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + currentIndex: root.selectedTab + 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 + } + } + + NotificationList {} + VolumeMixer {} + } + } +} \ No newline at end of file diff --git a/ii/modules/sidebarRight/SidebarRight.qml b/ii/modules/sidebarRight/SidebarRight.qml new file mode 100644 index 0000000..4147c3a --- /dev/null +++ b/ii/modules/sidebarRight/SidebarRight.qml @@ -0,0 +1,118 @@ +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 Qt5Compat.GraphicalEffects +import Quickshell.Io +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + property int sidebarWidth: Appearance.sizes.sidebarWidth + + PanelWindow { + id: sidebarRoot + visible: GlobalStates.sidebarRightOpen + + function hide() { + GlobalStates.sidebarRightOpen = false + } + + exclusiveZone: 0 + implicitWidth: sidebarWidth + WlrLayershell.namespace: "quickshell:sidebarRight" + // Hyprland 0.49: Focus is always exclusive and setting this breaks mouse focus grab + // WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + color: "transparent" + + anchors { + top: true + right: true + bottom: true + } + + HyprlandFocusGrab { + id: grab + windows: [ sidebarRoot ] + active: GlobalStates.sidebarRightOpen + onCleared: () => { + if (!active) sidebarRoot.hide() + } + } + + Loader { + id: sidebarContentLoader + active: GlobalStates.sidebarRightOpen || Config?.options.sidebar.keepRightSidebarLoaded + anchors { + fill: parent + margins: Appearance.sizes.hyprlandGapsOut + leftMargin: Appearance.sizes.elevationMargin + } + width: sidebarWidth - Appearance.sizes.hyprlandGapsOut - Appearance.sizes.elevationMargin + height: parent.height - Appearance.sizes.hyprlandGapsOut * 2 + + focus: GlobalStates.sidebarRightOpen + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Escape) { + sidebarRoot.hide(); + } + } + + sourceComponent: SidebarRightContent {} + } + + + } + + IpcHandler { + target: "sidebarRight" + + function toggle(): void { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + if(GlobalStates.sidebarRightOpen) Notifications.timeoutAll(); + } + + function close(): void { + GlobalStates.sidebarRightOpen = false; + } + + function open(): void { + GlobalStates.sidebarRightOpen = true; + Notifications.timeoutAll(); + } + } + + GlobalShortcut { + name: "sidebarRightToggle" + description: "Toggles right sidebar on press" + + onPressed: { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + if(GlobalStates.sidebarRightOpen) Notifications.timeoutAll(); + } + } + GlobalShortcut { + name: "sidebarRightOpen" + description: "Opens right sidebar on press" + + onPressed: { + GlobalStates.sidebarRightOpen = true; + Notifications.timeoutAll(); + } + } + GlobalShortcut { + name: "sidebarRightClose" + description: "Closes right sidebar on press" + + onPressed: { + GlobalStates.sidebarRightOpen = false; + } + } + +} diff --git a/ii/modules/sidebarRight/SidebarRightContent.qml b/ii/modules/sidebarRight/SidebarRightContent.qml new file mode 100644 index 0000000..636d577 --- /dev/null +++ b/ii/modules/sidebarRight/SidebarRightContent.qml @@ -0,0 +1,210 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import "./quickToggles/" +import "./wifiNetworks/" +import "./bluetoothDevices/" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Hyprland + +Item { + id: root + property int sidebarWidth: Appearance.sizes.sidebarWidth + property int sidebarPadding: 12 + property string settingsQmlPath: Quickshell.shellPath("settings.qml") + property bool showWifiDialog: false + property bool showBluetoothDialog: false + + Connections { + target: GlobalStates + function onSidebarRightOpenChanged() { + if (!GlobalStates.sidebarRightOpen) { + root.showWifiDialog = false; + root.showBluetoothDialog = false; + } + } + } + + implicitHeight: sidebarRightBackground.implicitHeight + implicitWidth: sidebarRightBackground.implicitWidth + + StyledRectangularShadow { + target: sidebarRightBackground + } + Rectangle { + id: sidebarRightBackground + + anchors.fill: parent + implicitHeight: parent.height - Appearance.sizes.hyprlandGapsOut * 2 + implicitWidth: sidebarWidth - Appearance.sizes.hyprlandGapsOut * 2 + color: Appearance.colors.colLayer0 + border.width: 1 + border.color: Appearance.colors.colLayer0Border + radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1 + + ColumnLayout { + anchors.fill: parent + anchors.margins: sidebarPadding + spacing: sidebarPadding + + RowLayout { + Layout.fillHeight: false + spacing: 10 + Layout.margins: 10 + Layout.topMargin: 5 + Layout.bottomMargin: 0 + + CustomIcon { + id: distroIcon + width: 25 + height: 25 + source: SystemInfo.distroIcon + colorize: true + color: Appearance.colors.colOnLayer0 + } + + StyledText { + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colOnLayer0 + text: Translation.tr("Up %1").arg(DateTime.uptime) + textFormat: Text.MarkdownText + } + + Item { + Layout.fillWidth: true + } + + ButtonGroup { + QuickToggleButton { + toggled: false + buttonIcon: "restart_alt" + onClicked: { + Hyprland.dispatch("reload"); + Quickshell.reload(true); + } + StyledToolTip { + content: Translation.tr("Reload Hyprland & Quickshell") + } + } + QuickToggleButton { + toggled: false + buttonIcon: "settings" + onClicked: { + GlobalStates.sidebarRightOpen = false; + Quickshell.execDetached(["qs", "-p", root.settingsQmlPath]); + } + StyledToolTip { + content: Translation.tr("Settings") + } + } + QuickToggleButton { + toggled: false + buttonIcon: "power_settings_new" + onClicked: { + GlobalStates.sessionOpen = true; + } + StyledToolTip { + content: Translation.tr("Session") + } + } + } + } + + ButtonGroup { + Layout.alignment: Qt.AlignHCenter + spacing: 5 + padding: 5 + color: Appearance.colors.colLayer1 + + NetworkToggle { + altAction: () => { + Network.enableWifi(); + Network.rescanWifi(); + root.showWifiDialog = true; + } + } + BluetoothToggle { + altAction: () => { + Bluetooth.defaultAdapter.enabled = true; + Bluetooth.defaultAdapter.discovering = true; + root.showBluetoothDialog = true; + } + } + NightLight {} + GameMode {} + IdleInhibitor {} + EasyEffectsToggle {} + CloudflareWarp {} + } + + CenterWidgetGroup { + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: true + Layout.fillWidth: true + } + + BottomWidgetGroup { + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: false + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + } + } + } + + onShowWifiDialogChanged: if (showWifiDialog) wifiDialogLoader.active = true; + Loader { + id: wifiDialogLoader + anchors.fill: parent + + active: root.showWifiDialog || item.visible + onActiveChanged: { + if (active) { + item.show = true; + item.forceActiveFocus(); + } + } + + sourceComponent: WifiDialog { + onDismiss: { + show = false + root.showWifiDialog = false + } + onVisibleChanged: { + if (!visible && !root.showWifiDialog) wifiDialogLoader.active = false; + } + } + } + + onShowBluetoothDialogChanged: { + if (showBluetoothDialog) bluetoothDialogLoader.active = true; + else Bluetooth.defaultAdapter.discovering = false; + } + Loader { + id: bluetoothDialogLoader + anchors.fill: parent + + active: root.showBluetoothDialog || item.visible + onActiveChanged: { + if (active) { + item.show = true; + item.forceActiveFocus(); + } + } + + sourceComponent: BluetoothDialog { + onDismiss: { + show = false + root.showBluetoothDialog = false + } + onVisibleChanged: { + if (!visible && !root.showBluetoothDialog) bluetoothDialogLoader.active = false; + } + } + } +} diff --git a/ii/modules/sidebarRight/bluetoothDevices/BluetoothDeviceItem.qml b/ii/modules/sidebarRight/bluetoothDevices/BluetoothDeviceItem.qml new file mode 100644 index 0000000..c2d281e --- /dev/null +++ b/ii/modules/sidebarRight/bluetoothDevices/BluetoothDeviceItem.qml @@ -0,0 +1,113 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts + +DialogListItem { + id: root + required property var device + property bool expanded: false + pointingHandCursor: !expanded + + onClicked: expanded = !expanded + altAction: () => expanded = !expanded + + component ActionButton: DialogButton { + colBackground: Appearance.colors.colPrimary + colBackgroundHover: Appearance.colors.colPrimaryHover + colRipple: Appearance.colors.colPrimaryActive + colText: Appearance.colors.colOnPrimary + } + + contentItem: ColumnLayout { + anchors { + fill: parent + topMargin: root.verticalPadding + leftMargin: root.horizontalPadding + rightMargin: root.horizontalPadding + } + spacing: 0 + + RowLayout { + // Name + spacing: 10 + + MaterialSymbol { + iconSize: Appearance.font.pixelSize.larger + text: Icons.getBluetoothDeviceMaterialSymbol(root.device?.icon || "") + color: Appearance.colors.colOnSurfaceVariant + } + + ColumnLayout { + spacing: 2 + Layout.fillWidth: true + StyledText { + Layout.fillWidth: true + color: Appearance.colors.colOnSurfaceVariant + elide: Text.ElideRight + text: root.device?.name || Translation.tr("Unknown device") + } + StyledText { + visible: (root.device?.connected || root.device?.paired) ?? false + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colSubtext + elide: Text.ElideRight + text: { + if (!root.device?.paired) return ""; + let statusText = root.device?.connected ? Translation.tr("Connected") : Translation.tr("Paired"); + if (!root.device?.batteryAvailable) return statusText; + statusText += ` • ${Math.round(root.device?.battery * 100)}%`; + return statusText; + } + } + } + + MaterialSymbol { + text: "keyboard_arrow_down" + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer3 + rotation: root.expanded ? 180 : 0 + Behavior on rotation { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + } + + RowLayout { + visible: root.expanded + Layout.topMargin: 8 + Item { + Layout.fillWidth: true + } + ActionButton { + buttonText: root.device?.connected ? Translation.tr("Disconnect") : Translation.tr("Connect") + + onClicked: { + if (root.device?.connected) { + root.device.disconnect(); + } else { + root.device.connect(); + } + } + } + ActionButton { + visible: root.device?.paired ?? false + colBackground: Appearance.colors.colError + colBackgroundHover: Appearance.colors.colErrorHover + colRipple: Appearance.colors.colErrorActive + colText: Appearance.colors.colOnError + + buttonText: Translation.tr("Forget") + onClicked: { + root.device?.forget(); + } + } + } + Item { + Layout.fillHeight: true + } + } +} diff --git a/ii/modules/sidebarRight/bluetoothDevices/BluetoothDialog.qml b/ii/modules/sidebarRight/bluetoothDevices/BluetoothDialog.qml new file mode 100644 index 0000000..b9ddcaa --- /dev/null +++ b/ii/modules/sidebarRight/bluetoothDevices/BluetoothDialog.qml @@ -0,0 +1,76 @@ +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 Qt5Compat.GraphicalEffects +import Quickshell.Io +import Quickshell.Bluetooth +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +WindowDialog { + id: root + + WindowDialogTitle { + text: Translation.tr("Bluetooth devices") + } + WindowDialogSeparator { + visible: !(Bluetooth.defaultAdapter?.discovering ?? false) + } + StyledIndeterminateProgressBar { + visible: Bluetooth.defaultAdapter?.discovering ?? false + Layout.fillWidth: true + Layout.topMargin: -8 + Layout.bottomMargin: -8 + Layout.leftMargin: -Appearance.rounding.large + Layout.rightMargin: -Appearance.rounding.large + } + StyledListView { + Layout.fillHeight: true + Layout.fillWidth: true + Layout.topMargin: -15 + Layout.bottomMargin: -16 + Layout.leftMargin: -Appearance.rounding.large + Layout.rightMargin: -Appearance.rounding.large + + clip: true + spacing: 0 + animateAppearance: false + + model: ScriptModel { + values: [...Bluetooth.devices.values].sort((a, b) => (b.connected - a.connected) || (b.paired - a.paired)) + } + delegate: BluetoothDeviceItem { + required property BluetoothDevice modelData + device: modelData + anchors { + left: parent?.left + right: parent?.right + } + } + } + WindowDialogSeparator {} + WindowDialogButtonRow { + DialogButton { + buttonText: Translation.tr("Details") + onClicked: { + Quickshell.execDetached(["bash", "-c", `${Config.options.apps.bluetooth}`]); + GlobalStates.sidebarRightOpen = false; + } + } + + Item { + Layout.fillWidth: true + } + + DialogButton { + buttonText: Translation.tr("Done") + onClicked: root.dismiss() + } + } +} diff --git a/ii/modules/sidebarRight/calendar/CalendarDayButton.qml b/ii/modules/sidebarRight/calendar/CalendarDayButton.qml new file mode 100644 index 0000000..ab1aca5 --- /dev/null +++ b/ii/modules/sidebarRight/calendar/CalendarDayButton.qml @@ -0,0 +1,34 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +RippleButton { + id: button + property string day + property int isToday + property bool bold + + Layout.fillWidth: false + Layout.fillHeight: false + implicitWidth: 38; + implicitHeight: 38; + + toggled: (isToday == 1) + buttonRadius: Appearance.rounding.small + + contentItem: StyledText { + anchors.fill: parent + text: day + horizontalAlignment: Text.AlignHCenter + font.weight: bold ? Font.DemiBold : Font.Normal + color: (isToday == 1) ? Appearance.m3colors.m3onPrimary : + (isToday == 0) ? Appearance.colors.colOnLayer1 : + Appearance.colors.colOutlineVariant + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } +} + diff --git a/ii/modules/sidebarRight/calendar/CalendarHeaderButton.qml b/ii/modules/sidebarRight/calendar/CalendarHeaderButton.qml new file mode 100644 index 0000000..6b5e5aa --- /dev/null +++ b/ii/modules/sidebarRight/calendar/CalendarHeaderButton.qml @@ -0,0 +1,36 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +RippleButton { + id: button + property string buttonText: "" + property string tooltipText: "" + property bool forceCircle: false + + implicitHeight: 30 + implicitWidth: forceCircle ? implicitHeight : (contentItem.implicitWidth + 10 * 2) + Behavior on implicitWidth { + SmoothedAnimation { + velocity: Appearance.animation.elementMove.velocity + } + } + + background.anchors.fill: button + buttonRadius: Appearance.rounding.full + colBackground: Appearance.colors.colLayer2 + colBackgroundHover: Appearance.colors.colLayer2Hover + colRipple: Appearance.colors.colLayer2Active + + contentItem: StyledText { + text: buttonText + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer1 + } + + StyledToolTip { + content: tooltipText + extraVisibleCondition: tooltipText.length > 0 + } +} \ No newline at end of file diff --git a/ii/modules/sidebarRight/calendar/CalendarWidget.qml b/ii/modules/sidebarRight/calendar/CalendarWidget.qml new file mode 100644 index 0000000..3af804e --- /dev/null +++ b/ii/modules/sidebarRight/calendar/CalendarWidget.qml @@ -0,0 +1,123 @@ +import qs.modules.common +import qs +import qs.modules.common.widgets +import "./calendar_layout.js" as CalendarLayout +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + // Layout.topMargin: 10 + anchors.topMargin: 10 + property int monthShift: 0 + property var viewingDate: CalendarLayout.getDateInXMonthsTime(monthShift) + property var calendarLayout: CalendarLayout.getCalendarLayout(viewingDate, monthShift === 0) + width: calendarColumn.width + implicitHeight: calendarColumn.height + 10 * 2 + + Keys.onPressed: (event) => { + if ((event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) + && event.modifiers === Qt.NoModifier) { + if (event.key === Qt.Key_PageDown) { + monthShift++; + } else if (event.key === Qt.Key_PageUp) { + monthShift--; + } + event.accepted = true; + } + } + MouseArea { + anchors.fill: parent + onWheel: (event) => { + if (event.angleDelta.y > 0) { + monthShift--; + } else if (event.angleDelta.y < 0) { + monthShift++; + } + } + } + + ColumnLayout { + id: calendarColumn + anchors.centerIn: parent + spacing: 5 + + // Calendar header + RowLayout { + Layout.fillWidth: true + spacing: 5 + CalendarHeaderButton { + clip: true + buttonText: `${monthShift != 0 ? "• " : ""}${viewingDate.toLocaleDateString(Qt.locale(), "MMMM yyyy")}` + tooltipText: (monthShift === 0) ? "" : Translation.tr("Jump to current month") + onClicked: { + monthShift = 0; + } + } + Item { + Layout.fillWidth: true + Layout.fillHeight: false + } + CalendarHeaderButton { + forceCircle: true + onClicked: { + monthShift--; + } + contentItem: MaterialSymbol { + text: "chevron_left" + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: Appearance.colors.colOnLayer1 + } + } + CalendarHeaderButton { + forceCircle: true + onClicked: { + monthShift++; + } + contentItem: MaterialSymbol { + text: "chevron_right" + iconSize: Appearance.font.pixelSize.larger + horizontalAlignment: Text.AlignHCenter + color: Appearance.colors.colOnLayer1 + } + } + } + + // Week days row + RowLayout { + id: weekDaysRow + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: false + spacing: 5 + Repeater { + model: CalendarLayout.weekDays + delegate: CalendarDayButton { + day: Translation.tr(modelData.day) + isToday: modelData.today + bold: true + enabled: false + } + } + } + + // Real week rows + Repeater { + id: calendarRows + // model: calendarLayout + model: 6 + delegate: RowLayout { + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: false + spacing: 5 + Repeater { + model: Array(7).fill(modelData) + delegate: CalendarDayButton { + day: calendarLayout[modelData][index].day + isToday: calendarLayout[modelData][index].today + } + } + } + } + } +} \ No newline at end of file diff --git a/ii/modules/sidebarRight/calendar/calendar_layout.js b/ii/modules/sidebarRight/calendar/calendar_layout.js new file mode 100644 index 0000000..7f750b4 --- /dev/null +++ b/ii/modules/sidebarRight/calendar/calendar_layout.js @@ -0,0 +1,115 @@ +const weekDays = [ // MONDAY IS THE FIRST DAY OF THE WEEK :HESRIGHTYOUKNOW: + { day: 'Mo', today: 0 }, + { day: 'Tu', today: 0 }, + { day: 'We', today: 0 }, + { day: 'Th', today: 0 }, + { day: 'Fr', today: 0 }, + { day: 'Sa', today: 0 }, + { day: 'Su', today: 0 }, +] + +function checkLeapYear(year) { + return ( + year % 400 == 0 || + (year % 4 == 0 && year % 100 != 0)); +} + +function getMonthDays(month, year) { + const leapYear = checkLeapYear(year); + if ((month <= 7 && month % 2 == 1) || (month >= 8 && month % 2 == 0)) return 31; + if (month == 2 && leapYear) return 29; + if (month == 2 && !leapYear) return 28; + return 30; +} + +function getNextMonthDays(month, year) { + const leapYear = checkLeapYear(year); + if (month == 1 && leapYear) return 29; + if (month == 1 && !leapYear) return 28; + if (month == 12) return 31; + if ((month <= 7 && month % 2 == 1) || (month >= 8 && month % 2 == 0)) return 30; + return 31; +} + +function getPrevMonthDays(month, year) { + const leapYear = checkLeapYear(year); + if (month == 3 && leapYear) return 29; + if (month == 3 && !leapYear) return 28; + if (month == 1) return 31; + if ((month <= 7 && month % 2 == 1) || (month >= 8 && month % 2 == 0)) return 30; + return 31; +} + +function getDateInXMonthsTime(x) { + var currentDate = new Date(); // Get the current date + if (x == 0) return currentDate; // If x is 0, return the current date + + var targetMonth = currentDate.getMonth() + x; // Calculate the target month + var targetYear = currentDate.getFullYear(); // Get the current year + + // Adjust the year and month if necessary + targetYear += Math.floor(targetMonth / 12); + targetMonth = (targetMonth % 12 + 12) % 12; + + // Create a new date object with the target year and month + var targetDate = new Date(targetYear, targetMonth, 1); + + // Set the day to the last day of the month to get the desired date + // targetDate.setDate(0); + + return targetDate; +} + +function getCalendarLayout(dateObject, highlight) { + if (!dateObject) dateObject = new Date(); + const weekday = (dateObject.getDay() + 6) % 7; // MONDAY IS THE FIRST DAY OF THE WEEK + const day = dateObject.getDate(); + const month = dateObject.getMonth() + 1; + const year = dateObject.getFullYear(); + const weekdayOfMonthFirst = (weekday + 35 - (day - 1)) % 7; + const daysInMonth = getMonthDays(month, year); + const daysInNextMonth = getNextMonthDays(month, year); + const daysInPrevMonth = getPrevMonthDays(month, year); + + // Fill + var monthDiff = (weekdayOfMonthFirst == 0 ? 0 : -1); + var toFill, dim; + if(weekdayOfMonthFirst == 0) { + toFill = 1; + dim = daysInMonth; + } + else { + toFill = (daysInPrevMonth - (weekdayOfMonthFirst - 1)); + dim = daysInPrevMonth; + } + var calendar = [...Array(6)].map(() => Array(7)); + var i = 0, j = 0; + while (i < 6 && j < 7) { + calendar[i][j] = { + "day": toFill, + "today": ((toFill == day && monthDiff == 0 && highlight) ? 1 : ( + monthDiff == 0 ? 0 : + -1 + )) + }; + // Increment + toFill++; + if (toFill > dim) { // Next month? + monthDiff++; + if (monthDiff == 0) + dim = daysInMonth; + else if (monthDiff == 1) + dim = daysInNextMonth; + toFill = 1; + } + // Next tile + j++; + if (j == 7) { + j = 0; + i++; + } + + } + return calendar; +} + diff --git a/ii/modules/sidebarRight/notifications/NotificationList.qml b/ii/modules/sidebarRight/notifications/NotificationList.qml new file mode 100644 index 0000000..882829b --- /dev/null +++ b/ii/modules/sidebarRight/notifications/NotificationList.qml @@ -0,0 +1,118 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: root + + NotificationListView { // Scrollable window + id: listview + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: statusRow.top + anchors.bottomMargin: 5 + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: listview.width + height: listview.height + radius: Appearance.rounding.normal + } + } + + popup: false + } + + // Placeholder when list is empty + Item { + anchors.fill: listview + + visible: opacity > 0 + opacity: (Notifications.list.length === 0) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.menuDecel.duration + easing.type: Appearance.animation.menuDecel.type + } + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 55 + color: Appearance.m3colors.m3outline + text: "notifications_active" + } + StyledText { + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: Translation.tr("No notifications") + } + } + } + + Item { + id: statusRow + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + + Layout.fillWidth: true + implicitHeight: Math.max( + controls.implicitHeight, + statusText.implicitHeight + ) + + StyledText { + id: statusText + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + horizontalAlignment: Text.AlignHCenter + text: Translation.tr("%1 notifications").arg(Notifications.list.length) + + opacity: Notifications.list.length > 0 ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + + ButtonGroup { + id: controls + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.rightMargin: 5 + + NotificationStatusButton { + buttonIcon: "notifications_paused" + buttonText: Translation.tr("Silent") + toggled: Notifications.silent + onClicked: () => { + Notifications.silent = !Notifications.silent; + } + } + NotificationStatusButton { + buttonIcon: "clear_all" + buttonText: Translation.tr("Clear") + onClicked: () => { + Notifications.discardAllNotifications() + } + } + } + } +} \ No newline at end of file diff --git a/ii/modules/sidebarRight/notifications/NotificationStatusButton.qml b/ii/modules/sidebarRight/notifications/NotificationStatusButton.qml new file mode 100644 index 0000000..d6001a3 --- /dev/null +++ b/ii/modules/sidebarRight/notifications/NotificationStatusButton.qml @@ -0,0 +1,43 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts + +GroupButton { + id: button + property string buttonText: "" + property string buttonIcon: "" + + baseWidth: content.implicitWidth + 10 * 2 + baseHeight: 30 + + buttonRadius: baseHeight / 2 + buttonRadiusPressed: Appearance.rounding.small + colBackground: Appearance.colors.colLayer2 + colBackgroundHover: Appearance.colors.colLayer2Hover + colBackgroundActive: Appearance.colors.colLayer2Active + property color colText: toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer1 + + contentItem: Item { + id: content + anchors.fill: parent + implicitWidth: contentRowLayout.implicitWidth + implicitHeight: contentRowLayout.implicitHeight + RowLayout { + id: contentRowLayout + anchors.centerIn: parent + spacing: 5 + MaterialSymbol { + text: buttonIcon + iconSize: Appearance.font.pixelSize.large + color: button.colText + } + StyledText { + text: buttonText + font.pixelSize: Appearance.font.pixelSize.small + color: button.colText + } + } + } + +} \ No newline at end of file diff --git a/ii/modules/sidebarRight/pomodoro/PomodoroTimer.qml b/ii/modules/sidebarRight/pomodoro/PomodoroTimer.qml new file mode 100644 index 0000000..a7a5cc4 --- /dev/null +++ b/ii/modules/sidebarRight/pomodoro/PomodoroTimer.qml @@ -0,0 +1,115 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +Item { + id: root + + implicitHeight: contentColumn.implicitHeight + implicitWidth: contentColumn.implicitWidth + + ColumnLayout { + id: contentColumn + anchors.fill: parent + spacing: 0 + + // The Pomodoro timer circle + CircularProgress { + Layout.alignment: Qt.AlignHCenter + lineWidth: 8 + value: { + return TimerService.pomodoroSecondsLeft / TimerService.pomodoroLapDuration; + } + implicitSize: 200 + enableAnimation: true + + ColumnLayout { + anchors.centerIn: parent + spacing: 0 + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: { + let minutes = Math.floor(TimerService.pomodoroSecondsLeft / 60).toString().padStart(2, '0'); + let seconds = Math.floor(TimerService.pomodoroSecondsLeft % 60).toString().padStart(2, '0'); + return `${minutes}:${seconds}`; + } + font.pixelSize: 40 + color: Appearance.m3colors.m3onSurface + } + StyledText { + Layout.alignment: Qt.AlignHCenter + text: TimerService.pomodoroLongBreak ? Translation.tr("Long break") : TimerService.pomodoroBreak ? Translation.tr("Break") : Translation.tr("Focus") + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colSubtext + } + } + + Rectangle { + radius: Appearance.rounding.full + color: Appearance.colors.colLayer2 + + anchors { + right: parent.right + bottom: parent.bottom + } + implicitWidth: 36 + implicitHeight: implicitWidth + + StyledText { + id: cycleText + anchors.centerIn: parent + color: Appearance.colors.colOnLayer2 + text: TimerService.pomodoroCycle + 1 + } + } + } + + // The Start/Stop and Reset buttons + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 10 + + RippleButton { + contentItem: StyledText { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: TimerService.pomodoroRunning ? Translation.tr("Pause") : (TimerService.pomodoroSecondsLeft === TimerService.focusTime) ? Translation.tr("Start") : Translation.tr("Resume") + color: TimerService.pomodoroRunning ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnPrimary + } + implicitHeight: 35 + implicitWidth: 90 + font.pixelSize: Appearance.font.pixelSize.larger + onClicked: TimerService.togglePomodoro() + colBackground: TimerService.pomodoroRunning ? Appearance.colors.colSecondaryContainer : Appearance.colors.colPrimary + colBackgroundHover: TimerService.pomodoroRunning ? Appearance.colors.colSecondaryContainer : Appearance.colors.colPrimary + } + + RippleButton { + implicitHeight: 35 + implicitWidth: 90 + + onClicked: TimerService.resetPomodoro() + enabled: (TimerService.pomodoroSecondsLeft < TimerService.pomodoroLapDuration) || TimerService.pomodoroCycle > 0 || TimerService.pomodoroBreak + + font.pixelSize: Appearance.font.pixelSize.larger + colBackground: Appearance.colors.colErrorContainer + colBackgroundHover: Appearance.colors.colErrorContainerHover + colRipple: Appearance.colors.colErrorContainerActive + + contentItem: StyledText { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: Translation.tr("Reset") + color: Appearance.colors.colOnErrorContainer + } + } + } + } +} diff --git a/ii/modules/sidebarRight/pomodoro/PomodoroWidget.qml b/ii/modules/sidebarRight/pomodoro/PomodoroWidget.qml new file mode 100644 index 0000000..729d458 --- /dev/null +++ b/ii/modules/sidebarRight/pomodoro/PomodoroWidget.qml @@ -0,0 +1,144 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: root + property int currentTab: 0 + property var tabButtonList: [ + {"name": Translation.tr("Pomodoro"), "icon": "search_activity"}, + {"name": Translation.tr("Stopwatch"), "icon": "timer"} + ] + + // These are keybinds for stopwatch and pomodoro + Keys.onPressed: (event) => { + if ((event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) && event.modifiers === Qt.NoModifier) { // Switch tabs + if (event.key === Qt.Key_PageDown) { + currentTab = Math.min(currentTab + 1, root.tabButtonList.length - 1) + } else if (event.key === Qt.Key_PageUp) { + currentTab = Math.max(currentTab - 1, 0) + } + event.accepted = true + } else if (event.key === Qt.Key_Space || event.key === Qt.Key_S) { // Pause/resume with Space or S + if (currentTab === 0) { + TimerService.togglePomodoro() + } else { + TimerService.toggleStopwatch() + } + event.accepted = true + } else if (event.key === Qt.Key_R) { // Reset with R + if (currentTab === 0) { + TimerService.resetPomodoro() + } else { + TimerService.stopwatchReset() + } + event.accepted = true + } else if (event.key === Qt.Key_L) { // Record lap with L + TimerService.stopwatchRecordLap() + event.accepted = true + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + TabBar { + id: tabBar + Layout.fillWidth: true + currentIndex: currentTab + onCurrentIndexChanged: currentTab = currentIndex + + background: Item { + WheelHandler { + onWheel: (event) => { + if (event.angleDelta.y < 0) + tabBar.currentIndex = Math.min(tabBar.currentIndex + 1, root.tabButtonList.length - 1) + else if (event.angleDelta.y > 0) + tabBar.currentIndex = Math.max(tabBar.currentIndex - 1, 0) + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + } + + Repeater { + model: root.tabButtonList + delegate: SecondaryTabButton { + selected: (index == currentTab) + buttonText: modelData.name + buttonIcon: modelData.icon + } + } + } + + Item { // Tab indicator + id: tabIndicator + Layout.fillWidth: true + height: 3 + property bool enableIndicatorAnimation: false + Connections { + target: root + function onCurrentTabChanged() { + tabIndicator.enableIndicatorAnimation = true + } + } + + Rectangle { + id: indicator + property int tabCount: root.tabButtonList.length + property real fullTabSize: root.width / tabCount; + property real targetWidth: tabBar.contentItem.children[0].children[tabBar.currentIndex].tabContentWidth + + implicitWidth: targetWidth + anchors { + top: parent.top + bottom: parent.bottom + } + + x: tabBar.currentIndex * fullTabSize + (fullTabSize - targetWidth) / 2 + + color: Appearance.colors.colPrimary + radius: Appearance.rounding.full + + Behavior on x { + enabled: tabIndicator.enableIndicatorAnimation + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + Behavior on implicitWidth { + enabled: tabIndicator.enableIndicatorAnimation + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + } + + Rectangle { // Tabbar bottom border + id: tabBarBottomBorder + Layout.fillWidth: true + height: 1 + color: Appearance.colors.colOutlineVariant + } + + SwipeView { + id: swipeView + Layout.topMargin: 10 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + clip: true + currentIndex: currentTab + onCurrentIndexChanged: { + tabIndicator.enableIndicatorAnimation = true + currentTab = currentIndex + } + + // Tabs + PomodoroTimer {} + Stopwatch {} + } + } +} diff --git a/ii/modules/sidebarRight/pomodoro/Stopwatch.qml b/ii/modules/sidebarRight/pomodoro/Stopwatch.qml new file mode 100644 index 0000000..ffc7065 --- /dev/null +++ b/ii/modules/sidebarRight/pomodoro/Stopwatch.qml @@ -0,0 +1,208 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +Item { + id: stopwatchTab + Layout.fillWidth: true + Layout.fillHeight: true + + Item { + anchors { + fill: parent + topMargin: 8 + leftMargin: 16 + rightMargin: 16 + } + + RowLayout { // Elapsed + id: elapsedIndicator + + anchors { + top: undefined + verticalCenter: parent.verticalCenter + left: controlButtons.left + leftMargin: 6 + } + + states: State { + name: "hasLaps" + when: TimerService.stopwatchLaps.length > 0 + AnchorChanges { + target: elapsedIndicator + anchors.top: parent.top + anchors.verticalCenter: undefined + anchors.left: controlButtons.left + } + } + + transitions: Transition { + AnchorAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + spacing: 0 + StyledText { + // Layout.preferredWidth: elapsedIndicator.width * 0.6 // Prevent shakiness + font.pixelSize: 40 + color: Appearance.m3colors.m3onSurface + text: { + let totalSeconds = Math.floor(TimerService.stopwatchTime) / 100 + let minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0') + let seconds = Math.floor(totalSeconds % 60).toString().padStart(2, '0') + return `${minutes}:${seconds}` + } + } + StyledText { + Layout.fillWidth: true + font.pixelSize: 40 + color: Appearance.colors.colSubtext + text: { + return `:${(Math.floor(TimerService.stopwatchTime) % 100).toString().padStart(2, '0')}` + } + } + } + + // Laps + StyledListView { + id: lapsList + anchors { + top: elapsedIndicator.bottom + bottom: controlButtons.top + left: parent.left + right: parent.right + topMargin: 16 + bottomMargin: 16 + } + spacing: 4 + clip: true + popin: true + + model: ScriptModel { + values: TimerService.stopwatchLaps.map((v, i, arr) => arr[arr.length - 1 - i]) + } + + delegate: Rectangle { + id: lapItem + required property int index + required property var modelData + property var horizontalPadding: 10 + property var verticalPadding: 6 + width: lapsList.width + implicitHeight: lapRow.implicitHeight + verticalPadding * 2 + implicitWidth: lapRow.implicitWidth + horizontalPadding * 2 + color: Appearance.colors.colLayer2 + radius: Appearance.rounding.small + + RowLayout { + id: lapRow + anchors { + fill: parent + leftMargin: lapItem.horizontalPadding + rightMargin: lapItem.horizontalPadding + topMargin: lapItem.verticalPadding + bottomMargin: lapItem.verticalPadding + } + + StyledText { + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + text: `${TimerService.stopwatchLaps.length - lapItem.index}.` + } + + StyledText { + font.pixelSize: Appearance.font.pixelSize.small + text: { + const lapTime = lapItem.modelData + const _10ms = (Math.floor(lapTime) % 100).toString().padStart(2, '0') + const totalSeconds = Math.floor(lapTime) / 100 + const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0') + const seconds = Math.floor(totalSeconds % 60).toString().padStart(2, '0') + return `${minutes}:${seconds}.${_10ms}` + } + } + + Item { Layout.fillWidth: true } + + StyledText { + font.pixelSize: Appearance.font.pixelSize.smaller + color: Appearance.colors.colPrimary + text: { + const originalIndex = TimerService.stopwatchLaps.length - lapItem.index - 1 + const lastTime = originalIndex > 0 ? TimerService.stopwatchLaps[originalIndex - 1] : 0 + const lapTime = lapItem.modelData - lastTime + const _10ms = (Math.floor(lapTime) % 100).toString().padStart(2, '0') + const totalSeconds = Math.floor(lapTime) / 100 + const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0') + const seconds = Math.floor(totalSeconds % 60).toString().padStart(2, '0') + return `+${minutes == "00" ? "" : minutes + ":"}${seconds}.${_10ms}` + } + } + } + } + } + + RowLayout { + id: controlButtons + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: 6 + } + spacing: 4 + + RippleButton { + Layout.preferredHeight: 35 + Layout.preferredWidth: 90 + font.pixelSize: Appearance.font.pixelSize.larger + + onClicked: { + TimerService.toggleStopwatch() + } + + colBackground: TimerService.stopwatchRunning ? Appearance.colors.colSecondaryContainer : Appearance.colors.colPrimary + colBackgroundHover: TimerService.stopwatchRunning ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colPrimaryHover + colRipple: TimerService.stopwatchRunning ? Appearance.colors.colSecondaryContainerActive : Appearance.colors.colPrimaryActive + + contentItem: StyledText { + horizontalAlignment: Text.AlignHCenter + color: TimerService.stopwatchRunning ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnPrimary + text: TimerService.stopwatchRunning ? Translation.tr("Pause") : TimerService.stopwatchTime === 0 ? Translation.tr("Start") : Translation.tr("Resume") + } + } + + RippleButton { + implicitHeight: 35 + implicitWidth: 90 + font.pixelSize: Appearance.font.pixelSize.larger + + onClicked: { + if (TimerService.stopwatchRunning) + TimerService.stopwatchRecordLap() + else + TimerService.stopwatchReset() + } + enabled: TimerService.stopwatchTime > 0 || Persistent.states.timer.stopwatch.laps.length > 0 + + colBackground: TimerService.stopwatchRunning ? Appearance.colors.colLayer2 : Appearance.colors.colErrorContainer + colBackgroundHover: TimerService.stopwatchRunning ? Appearance.colors.colLayer2Hover : Appearance.colors.colErrorContainerHover + colRipple: TimerService.stopwatchRunning ? Appearance.colors.colLayer2Active : Appearance.colors.colErrorContainerActive + + contentItem: StyledText { + horizontalAlignment: Text.AlignHCenter + text: TimerService.stopwatchRunning ? Translation.tr("Lap") : Translation.tr("Reset") + color: TimerService.stopwatchRunning ? Appearance.colors.colOnLayer2 : Appearance.colors.colOnErrorContainer + } + } + } + } +} \ No newline at end of file diff --git a/ii/modules/sidebarRight/quickToggles/BluetoothToggle.qml b/ii/modules/sidebarRight/quickToggles/BluetoothToggle.qml new file mode 100644 index 0000000..1a4c053 --- /dev/null +++ b/ii/modules/sidebarRight/quickToggles/BluetoothToggle.qml @@ -0,0 +1,29 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Io +import Quickshell.Hyprland + +QuickToggleButton { + id: root + toggled: BluetoothStatus.enabled + buttonIcon: BluetoothStatus.connected ? "bluetooth_connected" : BluetoothStatus.enabled ? "bluetooth" : "bluetooth_disabled" + onClicked: { + Bluetooth.defaultAdapter.enabled = !Bluetooth.defaultAdapter?.enabled + } + altAction: () => { + Quickshell.execDetached(["bash", "-c", `${Config.options.apps.bluetooth}`]) + GlobalStates.sidebarRightOpen = false + } + StyledToolTip { + content: Translation.tr("%1 | Right-click to configure").arg( + (BluetoothStatus.firstActiveDevice?.name ?? Translation.tr("Bluetooth")) + + (BluetoothStatus.activeDeviceCount > 1 ? ` +${BluetoothStatus.activeDeviceCount - 1}` : "") + ) + } +} diff --git a/ii/modules/sidebarRight/quickToggles/CloudflareWarp.qml b/ii/modules/sidebarRight/quickToggles/CloudflareWarp.qml new file mode 100644 index 0000000..39416ab --- /dev/null +++ b/ii/modules/sidebarRight/quickToggles/CloudflareWarp.qml @@ -0,0 +1,92 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs +import QtQuick +import Quickshell.Io +import Quickshell + +QuickToggleButton { + id: root + toggled: false + visible: false + + contentItem: CustomIcon { + id: distroIcon + source: 'cloudflare-dns-symbolic' + + anchors.centerIn: parent + width: 16 + height: 16 + colorize: true + color: root.toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer1 + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + + onClicked: { + if (toggled) { + root.toggled = false + Quickshell.execDetached(["warp-cli", "disconnect"]) + } else { + root.toggled = true + Quickshell.execDetached(["warp-cli", "connect"]) + } + } + + Process { + id: connectProc + command: ["warp-cli", "connect"] + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + Quickshell.execDetached(["notify-send", + Translation.tr("Cloudflare WARP"), + Translation.tr("Connection failed. Please inspect manually with the warp-cli command") + , "-a", "Shell" + ]) + } + } + } + + Process { + id: registrationProc + command: ["warp-cli", "registration", "new"] + onExited: (exitCode, exitStatus) => { + console.log("Warp registration exited with code and status:", exitCode, exitStatus) + if (exitCode === 0) { + connectProc.running = true + } else { + Quickshell.execDetached(["notify-send", + Translation.tr("Cloudflare WARP"), + Translation.tr("Registration failed. Please inspect manually with the warp-cli command"), + "-a", "Shell" + ]) + } + } + } + + Process { + id: fetchActiveState + running: true + command: ["bash", "-c", "warp-cli status"] + stdout: StdioCollector { + id: warpStatusCollector + onStreamFinished: { + if (warpStatusCollector.text.length > 0) { + root.visible = true + } + if (warpStatusCollector.text.includes("Unable")) { + registrationProc.running = true + } else if (warpStatusCollector.text.includes("Connected")) { + root.toggled = true + } else if (warpStatusCollector.text.includes("Disconnected")) { + root.toggled = false + } + } + } + } + StyledToolTip { + content: Translation.tr("Cloudflare WARP (1.1.1.1)") + } +} diff --git a/ii/modules/sidebarRight/quickToggles/EasyEffectsToggle.qml b/ii/modules/sidebarRight/quickToggles/EasyEffectsToggle.qml new file mode 100644 index 0000000..10b8660 --- /dev/null +++ b/ii/modules/sidebarRight/quickToggles/EasyEffectsToggle.qml @@ -0,0 +1,31 @@ +import qs.modules.common.widgets +import qs +import qs.services +import QtQuick +import Quickshell.Io +import Quickshell +import Quickshell.Hyprland + +QuickToggleButton { + id: root + toggled: EasyEffects.active + visible: EasyEffects.available + buttonIcon: "instant_mix" + + Component.onCompleted: { + EasyEffects.fetchActiveState() + } + + onClicked: { + EasyEffects.toggle() + } + + altAction: () => { + Quickshell.execDetached(["easyeffects"]) + GlobalStates.sidebarRightOpen = false + } + + StyledToolTip { + content: Translation.tr("EasyEffects | Right-click to configure") + } +} diff --git a/ii/modules/sidebarRight/quickToggles/GameMode.qml b/ii/modules/sidebarRight/quickToggles/GameMode.qml new file mode 100644 index 0000000..1907080 --- /dev/null +++ b/ii/modules/sidebarRight/quickToggles/GameMode.qml @@ -0,0 +1,31 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs +import Quickshell +import Quickshell.Io + +QuickToggleButton { + id: root + buttonIcon: "gamepad" + toggled: toggled + + onClicked: { + root.toggled = !root.toggled + if (root.toggled) { + Quickshell.execDetached(["bash", "-c", `hyprctl --batch "keyword animations:enabled 0; keyword decoration:shadow:enabled 0; keyword decoration:blur:enabled 0; keyword general:gaps_in 0; keyword general:gaps_out 0; keyword general:border_size 1; keyword decoration:rounding 0; keyword general:allow_tearing 1"`]) + } else { + Quickshell.execDetached(["hyprctl", "reload"]) + } + } + Process { + id: fetchActiveState + running: true + command: ["bash", "-c", `test "$(hyprctl getoption animations:enabled -j | jq ".int")" -ne 0`] + onExited: (exitCode, exitStatus) => { + root.toggled = exitCode !== 0 // Inverted because enabled = nonzero exit + } + } + StyledToolTip { + content: Translation.tr("Game mode") + } +} \ No newline at end of file diff --git a/ii/modules/sidebarRight/quickToggles/IdleInhibitor.qml b/ii/modules/sidebarRight/quickToggles/IdleInhibitor.qml new file mode 100644 index 0000000..949842d --- /dev/null +++ b/ii/modules/sidebarRight/quickToggles/IdleInhibitor.qml @@ -0,0 +1,31 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs +import Quickshell.Io +import Quickshell + +QuickToggleButton { + id: root + toggled: false + buttonIcon: "coffee" + onClicked: { + if (toggled) { + root.toggled = false + Quickshell.execDetached(["pkill", "wayland-idle"]) // pkill doesn't accept too long names + } else { + root.toggled = true + Quickshell.execDetached([`${Directories.scriptPath}/wayland-idle-inhibitor.py`]) + } + } + Process { + id: fetchActiveState + running: true + command: ["pidof", "wayland-idle-inhibitor.py"] + onExited: (exitCode, exitStatus) => { + root.toggled = exitCode === 0 + } + } + StyledToolTip { + content: Translation.tr("Keep system awake") + } +} diff --git a/ii/modules/sidebarRight/quickToggles/NetworkToggle.qml b/ii/modules/sidebarRight/quickToggles/NetworkToggle.qml new file mode 100644 index 0000000..cb9e0d8 --- /dev/null +++ b/ii/modules/sidebarRight/quickToggles/NetworkToggle.qml @@ -0,0 +1,23 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import "../" +import qs +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +QuickToggleButton { + toggled: Network.wifiEnabled + buttonIcon: Network.materialSymbol + onClicked: Network.toggleWifi() + altAction: () => { + Quickshell.execDetached(["bash", "-c", `${Network.ethernet ? Config.options.apps.networkEthernet : Config.options.apps.network}`]) + GlobalStates.sidebarRightOpen = false + } + StyledToolTip { + content: Translation.tr("%1 | Right-click to configure").arg(Network.networkName) + } +} diff --git a/ii/modules/sidebarRight/quickToggles/NightLight.qml b/ii/modules/sidebarRight/quickToggles/NightLight.qml new file mode 100644 index 0000000..f026512 --- /dev/null +++ b/ii/modules/sidebarRight/quickToggles/NightLight.qml @@ -0,0 +1,28 @@ +import QtQuick +import qs.modules.common +import qs.modules.common.widgets +import qs +import qs.services +import Quickshell.Io + +QuickToggleButton { + id: nightLightButton + property bool enabled: Hyprsunset.active + toggled: enabled + buttonIcon: Config.options.light.night.automatic ? "night_sight_auto" : "bedtime" + onClicked: { + Hyprsunset.toggle() + } + + altAction: () => { + Config.options.light.night.automatic = !Config.options.light.night.automatic + } + + Component.onCompleted: { + Hyprsunset.fetchState() + } + + StyledToolTip { + content: Translation.tr("Night Light | Right-click to toggle Auto mode") + } +} diff --git a/ii/modules/sidebarRight/quickToggles/QuickToggleButton.qml b/ii/modules/sidebarRight/quickToggles/QuickToggleButton.qml new file mode 100644 index 0000000..25a53de --- /dev/null +++ b/ii/modules/sidebarRight/quickToggles/QuickToggleButton.qml @@ -0,0 +1,29 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +GroupButton { + id: button + property string buttonIcon + baseWidth: 40 + baseHeight: 40 + clickedWidth: baseWidth + 20 + toggled: false + buttonRadius: (altAction && toggled) ? Appearance?.rounding.normal : Math.min(baseHeight, baseWidth) / 2 + buttonRadiusPressed: Appearance?.rounding?.small + + contentItem: MaterialSymbol { + anchors.centerIn: parent + iconSize: 20 + fill: toggled ? 1 : 0 + color: toggled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer1 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: buttonIcon + + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + } + +} diff --git a/ii/modules/sidebarRight/todo/TaskList.qml b/ii/modules/sidebarRight/todo/TaskList.qml new file mode 100644 index 0000000..4f6a430 --- /dev/null +++ b/ii/modules/sidebarRight/todo/TaskList.qml @@ -0,0 +1,180 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +Item { + id: root + required property var taskList; + property string emptyPlaceholderIcon + property string emptyPlaceholderText + property int todoListItemSpacing: 5 + property int todoListItemPadding: 8 + property int listBottomPadding: 80 + + StyledFlickable { + id: flickable + anchors.fill: parent + contentHeight: columnLayout.height + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: flickable.width + height: flickable.height + radius: Appearance.rounding.small + } + } + + ColumnLayout { + id: columnLayout + width: parent.width + spacing: 0 + Repeater { + model: ScriptModel { + values: taskList + } + delegate: Item { + id: todoItem + property bool pendingDoneToggle: false + property bool pendingDelete: false + property bool enableHeightAnimation: false + + Layout.fillWidth: true + implicitHeight: todoItemRectangle.implicitHeight + todoListItemSpacing + height: implicitHeight + clip: true + + Behavior on implicitHeight { + enabled: enableHeightAnimation + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + function startAction() { + enableHeightAnimation = true + todoItem.implicitHeight = 0 + actionTimer.start() + } + + Timer { + id: actionTimer + interval: Appearance.animation.elementMoveFast.duration + repeat: false + onTriggered: { + if (todoItem.pendingDelete) { + Todo.deleteItem(modelData.originalIndex) + } else if (todoItem.pendingDoneToggle) { + if (!modelData.done) Todo.markDone(modelData.originalIndex) + else Todo.markUnfinished(modelData.originalIndex) + } + } + } + + Rectangle { + id: todoItemRectangle + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + implicitHeight: todoContentRowLayout.implicitHeight + color: Appearance.colors.colLayer2 + radius: Appearance.rounding.small + ColumnLayout { + id: todoContentRowLayout + anchors.left: parent.left + anchors.right: parent.right + + StyledText { + Layout.fillWidth: true // Needed for wrapping + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.topMargin: todoListItemPadding + id: todoContentText + text: modelData.content + wrapMode: Text.Wrap + } + RowLayout { + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.bottomMargin: todoListItemPadding + Item { + Layout.fillWidth: true + } + TodoItemActionButton { + Layout.fillWidth: false + onClicked: { + todoItem.pendingDoneToggle = true + todoItem.startAction() + } + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: modelData.done ? "remove_done" : "check" + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer1 + } + } + TodoItemActionButton { + Layout.fillWidth: false + onClicked: { + todoItem.pendingDelete = true + todoItem.startAction() + } + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "delete_forever" + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer1 + } + } + } + } + } + } + + } + // Bottom padding + Item { + implicitHeight: listBottomPadding + } + } + } + + Item { // Placeholder when list is empty + visible: opacity > 0 + opacity: taskList.length === 0 ? 1 : 0 + anchors.fill: parent + + Behavior on opacity { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 55 + color: Appearance.m3colors.m3outline + text: emptyPlaceholderIcon + } + StyledText { + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: emptyPlaceholderText + } + } + } +} \ No newline at end of file diff --git a/ii/modules/sidebarRight/todo/TodoItemActionButton.qml b/ii/modules/sidebarRight/todo/TodoItemActionButton.qml new file mode 100644 index 0000000..b0a6e7b --- /dev/null +++ b/ii/modules/sidebarRight/todo/TodoItemActionButton.qml @@ -0,0 +1,32 @@ +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +RippleButton { + id: button + property string buttonText: "" + property string tooltipText: "" + + implicitHeight: 30 + implicitWidth: implicitHeight + + Behavior on implicitWidth { + SmoothedAnimation { + velocity: Appearance.animation.elementMove.velocity + } + } + + buttonRadius: Appearance.rounding.small + + contentItem: StyledText { + text: buttonText + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer1 + } + + StyledToolTip { + content: tooltipText + extraVisibleCondition: tooltipText.length > 0 + } +} \ No newline at end of file diff --git a/ii/modules/sidebarRight/todo/TodoWidget.qml b/ii/modules/sidebarRight/todo/TodoWidget.qml new file mode 100644 index 0000000..50c659a --- /dev/null +++ b/ii/modules/sidebarRight/todo/TodoWidget.qml @@ -0,0 +1,294 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: root + property int currentTab: 0 + property var tabButtonList: [{"icon": "checklist", "name": Translation.tr("Unfinished")}, {"name": Translation.tr("Done"), "icon": "check_circle"}] + property bool showAddDialog: false + property int dialogMargins: 20 + property int fabSize: 48 + property int fabMargins: 14 + + Keys.onPressed: (event) => { + if ((event.key === Qt.Key_PageDown || event.key === Qt.Key_PageUp) && event.modifiers === Qt.NoModifier) { + if (event.key === Qt.Key_PageDown) { + currentTab = Math.min(currentTab + 1, root.tabButtonList.length - 1) + } else if (event.key === Qt.Key_PageUp) { + currentTab = Math.max(currentTab - 1, 0) + } + event.accepted = true; + } + // Open add dialog on "N" (any modifiers) + else if (event.key === Qt.Key_N) { + root.showAddDialog = true + event.accepted = true; + } + // Close dialog on Esc if open + else if (event.key === Qt.Key_Escape && root.showAddDialog) { + root.showAddDialog = false + event.accepted = true; + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + TabBar { + id: tabBar + Layout.fillWidth: true + currentIndex: currentTab + onCurrentIndexChanged: currentTab = currentIndex + + background: Item { + WheelHandler { + onWheel: (event) => { + if (event.angleDelta.y < 0) + tabBar.currentIndex = Math.min(tabBar.currentIndex + 1, root.tabButtonList.length - 1) + else if (event.angleDelta.y > 0) + tabBar.currentIndex = Math.max(tabBar.currentIndex - 1, 0) + } + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } + } + + Repeater { + model: root.tabButtonList + delegate: SecondaryTabButton { + selected: (index == currentTab) + buttonText: modelData.name + buttonIcon: modelData.icon + } + } + } + + Item { // Tab indicator + id: tabIndicator + Layout.fillWidth: true + height: 3 + property bool enableIndicatorAnimation: false + Connections { + target: root + function onCurrentTabChanged() { + tabIndicator.enableIndicatorAnimation = true + } + } + + Rectangle { + id: indicator + property int tabCount: root.tabButtonList.length + property real fullTabSize: root.width / tabCount; + property real targetWidth: tabBar.contentItem.children[0].children[tabBar.currentIndex].tabContentWidth + + implicitWidth: targetWidth + anchors { + top: parent.top + bottom: parent.bottom + } + + x: tabBar.currentIndex * fullTabSize + (fullTabSize - targetWidth) / 2 + + color: Appearance.colors.colPrimary + radius: Appearance.rounding.full + + Behavior on x { + enabled: tabIndicator.enableIndicatorAnimation + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + Behavior on implicitWidth { + enabled: tabIndicator.enableIndicatorAnimation + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + } + + Rectangle { // Tabbar bottom border + id: tabBarBottomBorder + Layout.fillWidth: true + height: 1 + color: Appearance.colors.colOutlineVariant + } + + SwipeView { + id: swipeView + Layout.topMargin: 10 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + clip: true + currentIndex: currentTab + onCurrentIndexChanged: { + tabIndicator.enableIndicatorAnimation = true + currentTab = currentIndex + } + + // To Do tab + TaskList { + listBottomPadding: root.fabSize + root.fabMargins * 2 + emptyPlaceholderIcon: "check_circle" + emptyPlaceholderText: Translation.tr("Nothing here!") + taskList: Todo.list + .map(function(item, i) { return Object.assign({}, item, {originalIndex: i}); }) + .filter(function(item) { return !item.done; }) + } + TaskList { + listBottomPadding: root.fabSize + root.fabMargins * 2 + emptyPlaceholderIcon: "checklist" + emptyPlaceholderText: Translation.tr("Finished tasks will go here") + taskList: Todo.list + .map(function(item, i) { return Object.assign({}, item, {originalIndex: i}); }) + .filter(function(item) { return item.done; }) + } + + } + } + + // + FAB + StyledRectangularShadow { + target: fabButton + radius: fabButton.buttonRadius + blur: 0.6 * Appearance.sizes.elevationMargin + } + FloatingActionButton { + id: fabButton + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: root.fabMargins + anchors.bottomMargin: root.fabMargins + + onClicked: root.showAddDialog = true + + contentItem: MaterialSymbol { + text: "add" + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.huge + color: Appearance.m3colors.m3onPrimaryContainer + } + } + + Item { + anchors.fill: parent + z: 9999 + + visible: opacity > 0 + opacity: root.showAddDialog ? 1 : 0 + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + onVisibleChanged: { + if (!visible) { + todoInput.text = "" + fabButton.focus = true + } + } + + Rectangle { // Scrim + anchors.fill: parent + radius: Appearance.rounding.small + color: Appearance.colors.colScrim + MouseArea { + hoverEnabled: true + anchors.fill: parent + preventStealing: true + propagateComposedEvents: false + } + } + + Rectangle { // The dialog + id: dialog + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: root.dialogMargins + implicitHeight: dialogColumnLayout.implicitHeight + + color: Appearance.colors.colSurfaceContainerHigh + radius: Appearance.rounding.normal + + function addTask() { + if (todoInput.text.length > 0) { + Todo.addTask(todoInput.text) + todoInput.text = "" + root.showAddDialog = false + root.currentTab = 0 // Show unfinished tasks + } + } + + ColumnLayout { + id: dialogColumnLayout + anchors.fill: parent + spacing: 16 + + StyledText { + Layout.topMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.alignment: Qt.AlignLeft + color: Appearance.m3colors.m3onSurface + font.pixelSize: Appearance.font.pixelSize.larger + text: Translation.tr("Add task") + } + + TextField { + id: todoInput + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + padding: 10 + color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant + renderType: Text.NativeRendering + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.colors.colSecondaryContainer + placeholderText: Translation.tr("Task description") + placeholderTextColor: Appearance.m3colors.m3outline + focus: root.showAddDialog + onAccepted: dialog.addTask() + + background: Rectangle { + anchors.fill: parent + radius: Appearance.rounding.verysmall + border.width: 2 + border.color: todoInput.activeFocus ? Appearance.colors.colPrimary : Appearance.m3colors.m3outline + color: "transparent" + } + + cursorDelegate: Rectangle { + width: 1 + color: todoInput.activeFocus ? Appearance.colors.colPrimary : "transparent" + radius: 1 + } + } + + RowLayout { + Layout.bottomMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.alignment: Qt.AlignRight + spacing: 5 + + DialogButton { + buttonText: Translation.tr("Cancel") + onClicked: root.showAddDialog = false + } + DialogButton { + buttonText: Translation.tr("Add") + enabled: todoInput.text.length > 0 + onClicked: dialog.addTask() + } + } + } + } + } +} diff --git a/ii/modules/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml b/ii/modules/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml new file mode 100644 index 0000000..c8ac2df --- /dev/null +++ b/ii/modules/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml @@ -0,0 +1,56 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Pipewire + +RippleButton { + id: button + required property bool input + + buttonRadius: Appearance.rounding.small + colBackground: Appearance.colors.colLayer2 + colBackgroundHover: Appearance.colors.colLayer2Hover + colRipple: Appearance.colors.colLayer2Active + + implicitHeight: contentItem.implicitHeight + 6 * 2 + implicitWidth: contentItem.implicitWidth + 6 * 2 + + contentItem: RowLayout { + anchors.fill: parent + anchors.margins: 5 + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: false + Layout.leftMargin: 5 + color: Appearance.colors.colOnLayer2 + iconSize: Appearance.font.pixelSize.hugeass + text: input ? "mic_external_on" : "media_output" + } + + ColumnLayout { + Layout.fillWidth: true + Layout.rightMargin: 5 + spacing: 0 + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pixelSize: Appearance.font.pixelSize.normal + text: input ? Translation.tr("Input") : Translation.tr("Output") + color: Appearance.colors.colOnLayer2 + } + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pixelSize: Appearance.font.pixelSize.smaller + text: (input ? Pipewire.defaultAudioSource?.description : Pipewire.defaultAudioSink?.description) ?? Translation.tr("Unknown") + color: Appearance.m3colors.m3outline + animateChange: true + } + } + } +} \ No newline at end of file diff --git a/ii/modules/sidebarRight/volumeMixer/VolumeMixer.qml b/ii/modules/sidebarRight/volumeMixer/VolumeMixer.qml new file mode 100644 index 0000000..13bebf9 --- /dev/null +++ b/ii/modules/sidebarRight/volumeMixer/VolumeMixer.qml @@ -0,0 +1,276 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire + + +Item { + id: root + property bool showDeviceSelector: false + property bool deviceSelectorInput + property int dialogMargins: 16 + property PwNode selectedDevice + readonly property list appPwNodes: Pipewire.nodes.values.filter((node) => { + // return node.type == "21" // Alternative, not as clean + return node.isSink && node.isStream + }) + + function showDeviceSelectorDialog(input: bool) { + root.selectedDevice = null + root.showDeviceSelector = true + root.deviceSelectorInput = input + } + + Keys.onPressed: (event) => { + // Close dialog on pressing Esc if open + if (event.key === Qt.Key_Escape && root.showDeviceSelector) { + root.showDeviceSelector = false + event.accepted = true; + } + } + + ColumnLayout { + anchors.fill: parent + Item { + Layout.fillWidth: true + Layout.fillHeight: true + StyledListView { + id: listView + model: root.appPwNodes + clip: true + anchors { + fill: parent + topMargin: 10 + bottomMargin: 10 + } + spacing: 6 + + delegate: VolumeMixerEntry { + // Layout.fillWidth: true + anchors { + left: parent.left + right: parent.right + leftMargin: 10 + rightMargin: 10 + } + required property var modelData + node: modelData + } + } + + // Placeholder when list is empty + Item { + anchors.fill: listView + + visible: opacity > 0 + opacity: (root.appPwNodes.length === 0) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.menuDecel.duration + easing.type: Appearance.animation.menuDecel.type + } + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + iconSize: 55 + color: Appearance.m3colors.m3outline + text: "brand_awareness" + } + StyledText { + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: Translation.tr("No audio source") + } + } + } + } + + // Device selector + RowLayout { + id: deviceSelectorRowLayout + Layout.fillWidth: true + Layout.fillHeight: false + uniformCellSizes: true + + AudioDeviceSelectorButton { + Layout.fillWidth: true + input: false + onClicked: root.showDeviceSelectorDialog(input) + } + AudioDeviceSelectorButton { + Layout.fillWidth: true + input: true + onClicked: root.showDeviceSelectorDialog(input) + } + } + } + + // Device selector dialog + Item { + anchors.fill: parent + z: 9999 + + visible: opacity > 0 + opacity: root.showDeviceSelector ? 1 : 0 + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + Rectangle { // Scrim + id: scrimOverlay + anchors.fill: parent + radius: Appearance.rounding.small + color: Appearance.colors.colScrim + MouseArea { + hoverEnabled: true + anchors.fill: parent + preventStealing: true + propagateComposedEvents: false + } + } + + Rectangle { // The dialog + id: dialog + color: Appearance.colors.colSurfaceContainerHigh + radius: Appearance.rounding.normal + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 30 + implicitHeight: dialogColumnLayout.implicitHeight + + ColumnLayout { + id: dialogColumnLayout + anchors.fill: parent + spacing: 16 + + StyledText { + id: dialogTitle + Layout.topMargin: dialogMargins + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + Layout.alignment: Qt.AlignLeft + color: Appearance.m3colors.m3onSurface + font.pixelSize: Appearance.font.pixelSize.larger + text: root.deviceSelectorInput ? Translation.tr("Select input device") : Translation.tr("Select output device") + } + + Rectangle { + color: Appearance.m3colors.m3outline + implicitHeight: 1 + Layout.fillWidth: true + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + } + + StyledFlickable { + id: dialogFlickable + Layout.fillWidth: true + clip: true + implicitHeight: Math.min(scrimOverlay.height - dialogMargins * 8 - dialogTitle.height - dialogButtonsRowLayout.height, devicesColumnLayout.implicitHeight) + + contentHeight: devicesColumnLayout.implicitHeight + + ColumnLayout { + id: devicesColumnLayout + anchors.fill: parent + Layout.fillWidth: true + spacing: 0 + + Repeater { + model: ScriptModel { + values: Pipewire.nodes.values.filter(node => { + return !node.isStream && node.isSink !== root.deviceSelectorInput && node.audio + }) + } + + // This could and should be refractored, but all data becomes null when passed wtf + delegate: StyledRadioButton { + id: radioButton + required property var modelData + Layout.leftMargin: root.dialogMargins + Layout.rightMargin: root.dialogMargins + Layout.fillWidth: true + + description: modelData.description + checked: modelData.id === Pipewire.defaultAudioSink?.id + + Connections { + target: root + function onShowDeviceSelectorChanged() { + if(!root.showDeviceSelector) return; + radioButton.checked = (modelData.id === Pipewire.defaultAudioSink?.id) + } + } + + onCheckedChanged: { + if (checked) { + root.selectedDevice = modelData + } + } + } + } + Item { + implicitHeight: dialogMargins + } + } + } + + Rectangle { + color: Appearance.m3colors.m3outline + implicitHeight: 1 + Layout.fillWidth: true + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + } + + RowLayout { + id: dialogButtonsRowLayout + Layout.bottomMargin: dialogMargins + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + Layout.alignment: Qt.AlignRight + + DialogButton { + buttonText: Translation.tr("Cancel") + onClicked: { + root.showDeviceSelector = false + } + } + DialogButton { + buttonText: Translation.tr("OK") + onClicked: { + root.showDeviceSelector = false + if (root.selectedDevice) { + if (root.deviceSelectorInput) { + Pipewire.preferredDefaultAudioSource = root.selectedDevice + } else { + Pipewire.preferredDefaultAudioSink = root.selectedDevice + } + } + } + } + } + } + } + } + +} \ No newline at end of file diff --git a/ii/modules/sidebarRight/volumeMixer/VolumeMixerEntry.qml b/ii/modules/sidebarRight/volumeMixer/VolumeMixerEntry.qml new file mode 100644 index 0000000..5ee3980 --- /dev/null +++ b/ii/modules/sidebarRight/volumeMixer/VolumeMixerEntry.qml @@ -0,0 +1,63 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire + +Item { + id: root + required property PwNode node + PwObjectTracker { + objects: [node] + } + + implicitHeight: rowLayout.implicitHeight + + RowLayout { + id: rowLayout + anchors.fill: parent + spacing: 6 + + Image { + property real size: slider.height * 0.9 + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + visible: source != "" + sourceSize.width: size + sourceSize.height: size + source: { + let icon; + icon = AppSearch.guessIcon(root.node.properties["application.icon-name"]); + if (AppSearch.iconExists(icon)) + return Quickshell.iconPath(icon, "image-missing"); + icon = AppSearch.guessIcon(root.node.properties["node.name"]); + return Quickshell.iconPath(icon, "image-missing"); + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: -4 + + StyledText { + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + elide: Text.ElideRight + text: { + // application.name -> description -> name + const app = root.node.properties["application.name"] ?? (root.node.description != "" ? root.node.description : root.node.name); + const media = root.node.properties["media.name"]; + return media != undefined ? `${app} • ${media}` : app; + } + } + + StyledSlider { + id: slider + value: root.node.audio.volume + onValueChanged: root.node.audio.volume = value + } + } + } +} diff --git a/ii/modules/sidebarRight/wifiNetworks/WifiDialog.qml b/ii/modules/sidebarRight/wifiNetworks/WifiDialog.qml new file mode 100644 index 0000000..cb7686c --- /dev/null +++ b/ii/modules/sidebarRight/wifiNetworks/WifiDialog.qml @@ -0,0 +1,75 @@ +import qs +import qs.services +import qs.services.network +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts +import Quickshell + +WindowDialog { + id: root + + WindowDialogTitle { + text: Translation.tr("Connect to Wi-Fi") + } + WindowDialogSeparator { + visible: !Network.wifiScanning + } + StyledIndeterminateProgressBar { + visible: Network.wifiScanning + Layout.fillWidth: true + Layout.topMargin: -8 + Layout.bottomMargin: -8 + Layout.leftMargin: -Appearance.rounding.large + Layout.rightMargin: -Appearance.rounding.large + } + ListView { + Layout.fillHeight: true + Layout.fillWidth: true + Layout.topMargin: -15 + Layout.bottomMargin: -16 + Layout.leftMargin: -Appearance.rounding.large + Layout.rightMargin: -Appearance.rounding.large + + clip: true + spacing: 0 + + model: ScriptModel { + values: [...Network.wifiNetworks].sort((a, b) => { + if (a.active && !b.active) + return -1; + if (!a.active && b.active) + return 1; + return b.strength - a.strength; + }) + } + delegate: WifiNetworkItem { + required property WifiAccessPoint modelData + wifiNetwork: modelData + anchors { + left: parent?.left + right: parent?.right + } + } + } + WindowDialogSeparator {} + WindowDialogButtonRow { + DialogButton { + buttonText: Translation.tr("Details") + onClicked: { + Quickshell.execDetached(["bash", "-c", `${Network.ethernet ? Config.options.apps.networkEthernet : Config.options.apps.network}`]); + GlobalStates.sidebarRightOpen = false; + } + } + + Item { + Layout.fillWidth: true + } + + DialogButton { + buttonText: Translation.tr("Done") + onClicked: root.dismiss() + } + } +} \ No newline at end of file diff --git a/ii/modules/sidebarRight/wifiNetworks/WifiNetworkItem.qml b/ii/modules/sidebarRight/wifiNetworks/WifiNetworkItem.qml new file mode 100644 index 0000000..42d9e9d --- /dev/null +++ b/ii/modules/sidebarRight/wifiNetworks/WifiNetworkItem.qml @@ -0,0 +1,117 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs.services.network +import QtQuick +import QtQuick.Layouts + +DialogListItem { + id: root + required property WifiAccessPoint wifiNetwork + + active: (wifiNetwork?.askingPassword || wifiNetwork?.active) ?? false + onClicked: { + Network.connectToWifiNetwork(wifiNetwork); + } + + contentItem: ColumnLayout { + anchors { + fill: parent + topMargin: root.verticalPadding + bottomMargin: root.verticalPadding + leftMargin: root.horizontalPadding + rightMargin: root.horizontalPadding + } + spacing: 0 + + RowLayout { + // Name + spacing: 10 + MaterialSymbol { + iconSize: Appearance.font.pixelSize.larger + property int strength: root.wifiNetwork?.strength ?? 0 + text: strength > 80 ? "signal_wifi_4_bar" : strength > 60 ? "network_wifi_3_bar" : strength > 40 ? "network_wifi_2_bar" : strength > 20 ? "network_wifi_1_bar" : "signal_wifi_0_bar" + color: Appearance.colors.colOnSurfaceVariant + } + StyledText { + Layout.fillWidth: true + color: Appearance.colors.colOnSurfaceVariant + elide: Text.ElideRight + text: root.wifiNetwork?.ssid ?? Translation.tr("Unknown") + } + MaterialSymbol { + visible: (root.wifiNetwork?.isSecure || root.wifiNetwork?.active) ?? false + text: root.wifiNetwork?.active ? "check" : Network.wifiConnectTarget === root.wifiNetwork ? "settings_ethernet" : "lock" + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnSurfaceVariant + } + } + + ColumnLayout { // Password + id: passwordPrompt + Layout.topMargin: 8 + visible: root.wifiNetwork?.askingPassword ?? false + + MaterialTextField { + id: passwordField + Layout.fillWidth: true + placeholderText: Translation.tr("Password") + + // Password + echoMode: TextInput.Password + inputMethodHints: Qt.ImhSensitiveData + + onAccepted: { + Network.changePassword(root.wifiNetwork, passwordField.text); + } + } + + RowLayout { + Layout.fillWidth: true + + Item { + Layout.fillWidth: true + } + + DialogButton { + buttonText: Translation.tr("Cancel") + onClicked: { + root.wifiNetwork.askingPassword = false; + } + } + + DialogButton { + buttonText: Translation.tr("Connect") + onClicked: { + Network.changePassword(root.wifiNetwork, passwordField.text); + } + } + } + } + + ColumnLayout { // Public wifi login page + id: publicWifiPortal + Layout.topMargin: 8 + visible: (root.wifiNetwork?.active && (root.wifiNetwork?.security ?? "").trim().length === 0) ?? false + + RowLayout { + DialogButton { + Layout.fillWidth: true + buttonText: Translation.tr("Open network portal") + colBackground: Appearance.colors.colLayer4 + colBackgroundHover: Appearance.colors.colLayer4Hover + colRipple: Appearance.colors.colLayer4Active + onClicked: { + Network.openPublicWifiPortal() + GlobalStates.sidebarRightOpen = false + } + } + } + } + + Item { + Layout.fillHeight: true + } + } +} diff --git a/ii/modules/verticalBar/BatteryIndicator.qml b/ii/modules/verticalBar/BatteryIndicator.qml new file mode 100644 index 0000000..27d7048 --- /dev/null +++ b/ii/modules/verticalBar/BatteryIndicator.qml @@ -0,0 +1,64 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts +import "./../bar" as Bar + +MouseArea { + id: root + property bool borderless: Config.options.bar.borderless + readonly property var chargeState: Battery.chargeState + readonly property bool isCharging: Battery.isCharging + readonly property bool isPluggedIn: Battery.isPluggedIn + readonly property real percentage: Battery.percentage + readonly property bool isLow: percentage <= Config.options.battery.low / 100 + + implicitHeight: batteryProgress.implicitHeight + hoverEnabled: true + + ClippedProgressBar { + id: batteryProgress + anchors.centerIn: parent + vertical: true + valueBarWidth: 21 + valueBarHeight: 40 + value: percentage + highlightColor: (isLow && !isCharging) ? Appearance.m3colors.m3error : Appearance.colors.colOnSecondaryContainer + + font { + pixelSize: text.length > 2 ? 11 : 13 + weight: text.length > 2 ? Font.Medium : Font.DemiBold + } + + textMask: Item { + anchors.centerIn: parent + width: batteryProgress.valueBarWidth + height: batteryProgress.valueBarHeight + + ColumnLayout { + anchors.centerIn: parent + spacing: 0 + + MaterialSymbol { + id: boltIcon + Layout.alignment: Qt.AlignHCenter + fill: 1 + text: isCharging ? "bolt" : "battery_android_full" + iconSize: Appearance.font.pixelSize.normal + animateChange: true + } + StyledText { + Layout.alignment: Qt.AlignHCenter + font: batteryProgress.font + text: batteryProgress.text + } + } + } + } + + Bar.BatteryPopup { + id: batteryPopup + hoverTarget: root + } +} diff --git a/ii/modules/verticalBar/Resource.qml b/ii/modules/verticalBar/Resource.qml new file mode 100644 index 0000000..b505535 --- /dev/null +++ b/ii/modules/verticalBar/Resource.qml @@ -0,0 +1,41 @@ +import qs +import qs.modules.common +import qs.modules.common.functions +import qs.modules.common.widgets +import QtQuick + +Item { + id: root + required property string iconName + required property double percentage + property int warningThreshold: 100 + implicitHeight: resourceProgress.implicitHeight + implicitWidth: Appearance.sizes.verticalBarWidth + + property bool warning: percentage * 100 >= warningThreshold + + ClippedFilledCircularProgress { + id: resourceProgress + anchors.centerIn: parent + value: percentage + enableAnimation: false + colPrimary: root.warning ? Appearance.colors.colError : Appearance.colors.colOnSecondaryContainer + accountForLightBleeding: !root.warning + + MaterialSymbol { + font.weight: Font.Medium + fill: 1 + text: root.iconName + iconSize: 13 + color: Appearance.colors.colOnSecondaryContainer + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + enabled: root.visible + } +} diff --git a/ii/modules/verticalBar/Resources.qml b/ii/modules/verticalBar/Resources.qml new file mode 100644 index 0000000..e4bba01 --- /dev/null +++ b/ii/modules/verticalBar/Resources.qml @@ -0,0 +1,45 @@ +import qs.services +import qs.modules.common +import QtQuick +import QtQuick.Layouts +import "../bar" as Bar + +MouseArea { + id: root + property bool alwaysShowAllResources: false + implicitHeight: columnLayout.implicitHeight + implicitWidth: columnLayout.implicitWidth + hoverEnabled: true + + ColumnLayout { + id: columnLayout + spacing: 10 + anchors.fill: parent + + Resource { + Layout.alignment: Qt.AlignHCenter + iconName: "memory" + percentage: ResourceUsage.memoryUsedPercentage + warningThreshold: Config.options.bar.resources.memoryWarningThreshold + } + + Resource { + Layout.alignment: Qt.AlignHCenter + iconName: "swap_horiz" + percentage: ResourceUsage.swapUsedPercentage + warningThreshold: Config.options.bar.resources.swapWarningThreshold + } + + Resource { + Layout.alignment: Qt.AlignHCenter + iconName: "planner_review" + percentage: ResourceUsage.cpuUsage + warningThreshold: Config.options.bar.resources.cpuWarningThreshold + } + + } + + Bar.ResourcesPopup { + hoverTarget: root + } +} diff --git a/ii/modules/verticalBar/VerticalBar.qml b/ii/modules/verticalBar/VerticalBar.qml new file mode 100644 index 0000000..d23eb43 --- /dev/null +++ b/ii/modules/verticalBar/VerticalBar.qml @@ -0,0 +1,245 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland +import Quickshell.Services.UPower +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +Scope { + id: bar + property bool showBarBackground: Config.options.bar.showBackground + + Variants { + // For each monitor + model: { + const screens = Quickshell.screens; + const list = Config.options.bar.screenList; + if (!list || list.length === 0) + return screens; + return screens.filter(screen => list.includes(screen.name)); + } + LazyLoader { + id: barLoader + active: GlobalStates.barOpen && !GlobalStates.screenLocked + required property ShellScreen modelData + component: PanelWindow { // Bar window + id: barRoot + screen: barLoader.modelData + + property var brightnessMonitor: Brightness.getMonitorForScreen(barLoader.modelData) + + Timer { + id: showBarTimer + interval: (Config?.options.bar.autoHide.showWhenPressingSuper.delay ?? 100) + repeat: false + onTriggered: { + barRoot.superShow = true + } + } + Connections { + target: GlobalStates + function onSuperDownChanged() { + if (!Config?.options.bar.autoHide.showWhenPressingSuper.enable) return; + if (GlobalStates.superDown) showBarTimer.restart(); + else { + showBarTimer.stop(); + barRoot.superShow = false; + } + } + } + property bool superShow: false + property bool mustShow: hoverRegion.containsMouse || superShow + exclusionMode: ExclusionMode.Ignore + exclusiveZone: (Config?.options.bar.autoHide.enable && (!mustShow || !Config?.options.bar.autoHide.pushWindows)) ? 0 : + Appearance.sizes.baseVerticalBarWidth + (Config.options.bar.cornerStyle === 1 ? Appearance.sizes.hyprlandGapsOut : 0) + WlrLayershell.namespace: "quickshell:verticalBar" + // WlrLayershell.layer: WlrLayer.Overlay // TODO enable this when bar can hide when fullscreen + implicitWidth: Appearance.sizes.verticalBarWidth + Appearance.rounding.screenRounding + mask: Region { + item: hoverMaskRegion + } + color: "transparent" + + anchors { + left: !Config.options.bar.bottom + right: Config.options.bar.bottom + top: true + bottom: true + } + + MouseArea { + id: hoverRegion + hoverEnabled: true + anchors.fill: parent + + Item { + id: hoverMaskRegion + anchors { + fill: barContent + leftMargin: -1 + rightMargin: -1 + } + } + + VerticalBarContent { + id: barContent + + implicitWidth: Appearance.sizes.verticalBarWidth + anchors { + top: parent.top + bottom: parent.bottom + left: parent.left + right: undefined + leftMargin: (Config?.options.bar.autoHide.enable && !mustShow) ? -Appearance.sizes.verticalBarWidth : 0 + rightMargin: 0 + } + Behavior on anchors.leftMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on anchors.rightMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + states: State { + name: "right" + when: Config.options.bar.bottom + AnchorChanges { + target: barContent + anchors { + top: parent.top + bottom: parent.bottom + left: undefined + right: parent.right + } + } + PropertyChanges { + target: barContent + anchors.topMargin: 0 + anchors.rightMargin: (Config?.options.bar.autoHide.enable && !mustShow) ? -Appearance.sizes.barHeight : 0 + } + } + } + + // Round decorators + Loader { + id: roundDecorators + anchors { + top: parent.top + bottom: parent.bottom + left: barContent.right + right: undefined + } + width: Appearance.rounding.screenRounding + active: showBarBackground && Config.options.bar.cornerStyle === 0 // Hug + + states: State { + name: "right" + when: Config.options.bar.bottom + AnchorChanges { + target: roundDecorators + anchors { + top: parent.top + bottom: parent.bottom + left: undefined + right: barContent.left + } + } + } + + sourceComponent: Item { + implicitHeight: Appearance.rounding.screenRounding + RoundCorner { + id: topCorner + anchors { + left: parent.left + right: parent.right + top: parent.top + } + + implicitSize: Appearance.rounding.screenRounding + color: showBarBackground ? Appearance.colors.colLayer0 : "transparent" + + corner: RoundCorner.CornerEnum.TopLeft + states: State { + name: "bottom" + when: Config.options.bar.bottom + PropertyChanges { + topCorner.corner: RoundCorner.CornerEnum.TopRight + } + } + } + RoundCorner { + id: bottomCorner + anchors { + bottom: parent.bottom + left: !Config.options.bar.bottom ? parent.left : undefined + right: Config.options.bar.bottom ? parent.right : undefined + } + implicitSize: Appearance.rounding.screenRounding + color: showBarBackground ? Appearance.colors.colLayer0 : "transparent" + + corner: RoundCorner.CornerEnum.BottomLeft + states: State { + name: "bottom" + when: Config.options.bar.bottom + PropertyChanges { + bottomCorner.corner: RoundCorner.CornerEnum.BottomRight + } + } + } + } + } + } + } + } + } + + IpcHandler { + target: "bar" + + function toggle(): void { + GlobalStates.barOpen = !GlobalStates.barOpen + } + + function close(): void { + GlobalStates.barOpen = false + } + + function open(): void { + GlobalStates.barOpen = true + } + } + + GlobalShortcut { + name: "barToggle" + description: "Toggles bar on press" + + onPressed: { + GlobalStates.barOpen = !GlobalStates.barOpen; + } + } + + GlobalShortcut { + name: "barOpen" + description: "Opens bar on press" + + onPressed: { + GlobalStates.barOpen = true; + } + } + + GlobalShortcut { + name: "barClose" + description: "Closes bar on press" + + onPressed: { + GlobalStates.barOpen = false; + } + } +} diff --git a/ii/modules/verticalBar/VerticalBarContent.qml b/ii/modules/verticalBar/VerticalBarContent.qml new file mode 100644 index 0000000..bfcd0c6 --- /dev/null +++ b/ii/modules/verticalBar/VerticalBarContent.qml @@ -0,0 +1,298 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Services.UPower +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import "../bar" as Bar + +Item { // Bar content region + id: root + + property var screen: root.QsWindow.window?.screen + property var brightnessMonitor: Brightness.getMonitorForScreen(screen) + + component HorizontalBarSeparator: Rectangle { + Layout.leftMargin: Appearance.sizes.baseBarHeight / 3 + Layout.rightMargin: Appearance.sizes.baseBarHeight / 3 + Layout.fillWidth: true + implicitHeight: 1 + color: Appearance.colors.colOutlineVariant + } + + // Background shadow + Loader { + active: Config.options.bar.showBackground && Config.options.bar.cornerStyle === 1 + anchors.fill: barBackground + sourceComponent: StyledRectangularShadow { + anchors.fill: undefined // The loader's anchors act on this, and this should not have any anchor + target: barBackground + } + } + // Background + Rectangle { + id: barBackground + anchors { + fill: parent + margins: Config.options.bar.cornerStyle === 1 ? (Appearance.sizes.hyprlandGapsOut) : 0 // idk why but +1 is needed + } + color: Config.options.bar.showBackground ? Appearance.colors.colLayer0 : "transparent" + radius: Config.options.bar.cornerStyle === 1 ? Appearance.rounding.windowRounding : 0 + border.width: Config.options.bar.cornerStyle === 1 ? 1 : 0 + border.color: Appearance.colors.colLayer0Border + } + + FocusedScrollMouseArea { // Top section | scroll to change brightness + id: barTopSectionMouseArea + anchors.top: parent.top + implicitHeight: topSectionColumnLayout.implicitHeight + implicitWidth: Appearance.sizes.baseVerticalBarWidth + height: (root.height - middleSection.height) / 2 + width: Appearance.sizes.verticalBarWidth + + onScrollDown: root.brightnessMonitor.setBrightness(root.brightnessMonitor.brightness - 0.05) + onScrollUp: root.brightnessMonitor.setBrightness(root.brightnessMonitor.brightness + 0.05) + onMovedAway: GlobalStates.osdBrightnessOpen = false + onPressed: event => { + if (event.button === Qt.LeftButton) + GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen; + } + + ColumnLayout { // Content + id: topSectionColumnLayout + anchors.fill: parent + spacing: 10 + + Bar.LeftSidebarButton { // Left sidebar button + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: (Appearance.sizes.baseVerticalBarWidth - implicitWidth) / 2 + Appearance.sizes.hyprlandGapsOut + colBackground: barTopSectionMouseArea.hovered ? Appearance.colors.colLayer1Hover : ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1) + } + + Item { + Layout.fillHeight: true + } + + } + } + + ColumnLayout { // Middle section + id: middleSection + anchors.centerIn: parent + spacing: 4 + + Bar.BarGroup { + vertical: true + padding: 8 + Resources { + Layout.fillWidth: true + Layout.fillHeight: false + } + + HorizontalBarSeparator {} + + VerticalMedia { + Layout.fillWidth: true + Layout.fillHeight: false + } + } + + HorizontalBarSeparator { + visible: Config.options?.bar.borderless + } + + Bar.BarGroup { + id: middleCenterGroup + vertical: true + padding: 6 + + Bar.Workspaces { + id: workspacesWidget + vertical: true + MouseArea { + // Right-click to toggle overview + anchors.fill: parent + acceptedButtons: Qt.RightButton + + onPressed: event => { + if (event.button === Qt.RightButton) { + GlobalStates.overviewOpen = !GlobalStates.overviewOpen; + } + } + } + } + } + + HorizontalBarSeparator { + visible: Config.options?.bar.borderless + } + + Bar.BarGroup { + vertical: true + padding: 8 + + VerticalClockWidget { + Layout.fillWidth: true + Layout.fillHeight: false + } + + HorizontalBarSeparator {} + + VerticalDateWidget { + Layout.fillWidth: true + Layout.fillHeight: false + } + + HorizontalBarSeparator { + visible: UPower.displayDevice.isLaptopBattery + } + + BatteryIndicator { + visible: UPower.displayDevice.isLaptopBattery + Layout.fillWidth: true + Layout.fillHeight: false + } + + + + } + } + + FocusedScrollMouseArea { // Bottom section | scroll to change volume + id: barBottomSectionMouseArea + + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + } + implicitWidth: Appearance.sizes.baseVerticalBarWidth + implicitHeight: bottomSectionColumnLayout.implicitHeight + + onScrollDown: { + const currentVolume = Audio.value; + const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2; + Audio.sink.audio.volume -= step; + } + onScrollUp: { + const currentVolume = Audio.value; + const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2; + Audio.sink.audio.volume = Math.min(1, Audio.sink.audio.volume + step); + } + onMovedAway: GlobalStates.osdVolumeOpen = false; + onPressed: event => { + if (event.button === Qt.LeftButton) { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + } + } + + ColumnLayout { + id: bottomSectionColumnLayout + anchors.fill: parent + spacing: 4 + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + } + + Bar.SysTray { + vertical: true + Layout.fillWidth: true + Layout.fillHeight: false + invertSide: Config?.options.bar.bottom + } + + RippleButton { // Right sidebar button + id: rightSidebarButton + + Layout.alignment: Qt.AlignBottom | Qt.AlignHCenter + Layout.bottomMargin: Appearance.rounding.screenRounding + Layout.fillHeight: false + + implicitHeight: indicatorsColumnLayout.implicitHeight + 4 * 2 + implicitWidth: indicatorsColumnLayout.implicitWidth + 6 * 2 + + buttonRadius: Appearance.rounding.full + colBackground: barBottomSectionMouseArea.hovered ? Appearance.colors.colLayer1Hover : ColorUtils.transparentize(Appearance.colors.colLayer1Hover, 1) + colBackgroundHover: Appearance.colors.colLayer1Hover + colRipple: Appearance.colors.colLayer1Active + colBackgroundToggled: Appearance.colors.colSecondaryContainer + colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover + colRippleToggled: Appearance.colors.colSecondaryContainerActive + toggled: GlobalStates.sidebarRightOpen + property color colText: toggled ? Appearance.m3colors.m3onSecondaryContainer : Appearance.colors.colOnLayer0 + + Behavior on colText { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + onPressed: { + GlobalStates.sidebarRightOpen = !GlobalStates.sidebarRightOpen; + } + + ColumnLayout { + id: indicatorsColumnLayout + anchors.centerIn: parent + property real realSpacing: 6 + spacing: 0 + + Revealer { + vertical: true + reveal: Audio.sink?.audio?.muted ?? false + Layout.fillWidth: true + Layout.bottomMargin: reveal ? indicatorsColumnLayout.realSpacing : 0 + Behavior on Layout.bottomMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + MaterialSymbol { + text: "volume_off" + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + } + Revealer { + vertical: true + reveal: Audio.source?.audio?.muted ?? false + Layout.fillWidth: true + Layout.bottomMargin: reveal ? indicatorsColumnLayout.realSpacing : 0 + Behavior on Layout.topMargin { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + MaterialSymbol { + text: "mic_off" + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + } + Loader { + active: HyprlandXkb.layoutCodes.length > 1 + visible: active + Layout.bottomMargin: indicatorsColumnLayout.realSpacing + sourceComponent: StyledText { + text: HyprlandXkb.currentLayoutCode + font.pixelSize: Appearance.font.pixelSize.small + color: rightSidebarButton.colText + animateChange: true + } + } + MaterialSymbol { + Layout.bottomMargin: indicatorsColumnLayout.realSpacing + text: Network.materialSymbol + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + MaterialSymbol { + text: BluetoothStatus.connected ? "bluetooth_connected" : BluetoothStatus.enabled ? "bluetooth" : "bluetooth_disabled" + iconSize: Appearance.font.pixelSize.larger + color: rightSidebarButton.colText + } + } + } + } + } +} diff --git a/ii/modules/verticalBar/VerticalClockWidget.qml b/ii/modules/verticalBar/VerticalClockWidget.qml new file mode 100644 index 0000000..1c19f28 --- /dev/null +++ b/ii/modules/verticalBar/VerticalClockWidget.qml @@ -0,0 +1,43 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts +import "../bar" as Bar + +Item { + id: root + property bool borderless: Config.options.bar.borderless + implicitHeight: clockColumn.implicitHeight + implicitWidth: Appearance.sizes.verticalBarWidth + + ColumnLayout { + id: clockColumn + anchors.centerIn: parent + spacing: 0 + + Repeater { + model: DateTime.time.split(/[: ]/) + delegate: StyledText { + required property string modelData + Layout.alignment: Qt.AlignHCenter + font.pixelSize: modelData.match(/am|pm/i) ? + Appearance.font.pixelSize.smaller // Smaller "am"/"pm" text + : Appearance.font.pixelSize.large + color: Appearance.colors.colOnLayer1 + text: modelData.padStart(2, "0") + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + + Bar.ClockWidgetTooltip { + hoverTarget: mouseArea + } + } +} diff --git a/ii/modules/verticalBar/VerticalDateWidget.qml b/ii/modules/verticalBar/VerticalDateWidget.qml new file mode 100644 index 0000000..8ff9386 --- /dev/null +++ b/ii/modules/verticalBar/VerticalDateWidget.qml @@ -0,0 +1,64 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Shapes +import QtQuick.Layouts +import "../bar" as Bar + +Item { // Full hitbox + id: root + + implicitHeight: content.implicitHeight + implicitWidth: Appearance.sizes.verticalBarWidth + property var dayOfMonth: DateTime.shortDate.split(/[-\/]/)[0] // What if 🍔murica🦅? good question + property var monthOfYear: DateTime.shortDate.split(/[-\/]/)[1] + + Item { // Boundaries for date numbers + id: content + anchors.centerIn: parent + implicitWidth: 24 + implicitHeight: 30 + + Shape { + id: diagonalLine + property real padding: 4 + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + strokeWidth: 1.2 + strokeColor: Appearance.colors.colSubtext + fillColor: "transparent" + startX: content.width - diagonalLine.padding + startY: diagonalLine.padding + PathLine { + x: diagonalLine.padding + y: content.height - diagonalLine.padding + } + } + } + + StyledText { + id: dayText + anchors { + top: parent.top + left: parent.left + } + font.pixelSize: 13 + color: Appearance.colors.colOnLayer1 + text: dayOfMonth + } + + StyledText { + id: monthText + anchors { + bottom: parent.bottom + right: parent.right + } + font.pixelSize: 13 + color: Appearance.colors.colOnLayer1 + text: monthOfYear + } + } +} diff --git a/ii/modules/verticalBar/VerticalMedia.qml b/ii/modules/verticalBar/VerticalMedia.qml new file mode 100644 index 0000000..f64792f --- /dev/null +++ b/ii/modules/verticalBar/VerticalMedia.qml @@ -0,0 +1,102 @@ +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import qs +import qs.modules.common.functions + +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Mpris + +import "../bar" as Bar + +MouseArea { + id: root + property bool borderless: Config.options.bar.borderless + readonly property MprisPlayer activePlayer: MprisController.activePlayer + readonly property string cleanedTitle: StringUtils.cleanMusicTitle(activePlayer?.trackTitle) || Translation.tr("No media") + + Layout.fillHeight: true + implicitHeight: mediaCircProg.implicitHeight + implicitWidth: Appearance.sizes.verticalBarWidth + + Timer { + running: activePlayer?.playbackState == MprisPlaybackState.Playing + interval: 1000 + repeat: true + onTriggered: activePlayer.positionChanged() + } + + acceptedButtons: Qt.MiddleButton | Qt.BackButton | Qt.ForwardButton | Qt.RightButton | Qt.LeftButton + hoverEnabled: true + onPressed: (event) => { + if (event.button === Qt.MiddleButton) { + activePlayer.togglePlaying(); + } else if (event.button === Qt.BackButton) { + activePlayer.previous(); + } else if (event.button === Qt.ForwardButton || event.button === Qt.RightButton) { + activePlayer.next(); + } else if (event.button === Qt.LeftButton) { + GlobalStates.mediaControlsOpen = !GlobalStates.mediaControlsOpen + } + } + + ClippedFilledCircularProgress { + id: mediaCircProg + anchors.centerIn: parent + implicitSize: 20 + + lineWidth: Appearance.rounding.unsharpen + value: activePlayer?.position / activePlayer?.length + colPrimary: Appearance.colors.colOnSecondaryContainer + enableAnimation: false + + Item { + anchors.centerIn: parent + width: mediaCircProg.implicitSize + height: mediaCircProg.implicitSize + + MaterialSymbol { + anchors.centerIn: parent + fill: 1 + text: activePlayer?.isPlaying ? "pause" : "music_note" + iconSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3onSecondaryContainer + } + } + } + + Bar.StyledPopup { + hoverTarget: root + active: GlobalStates.mediaControlsOpen ? false : root.containsMouse + + ColumnLayout { + anchors.centerIn: parent + RowLayout { + spacing: 5 + + MaterialSymbol { + fill: 0 + font.weight: Font.Medium + text: "music_note" + iconSize: Appearance.font.pixelSize.large + color: Appearance.colors.colOnSurfaceVariant + } + + StyledText { + text: "Media" + font { + weight: Font.Medium + pixelSize: Appearance.font.pixelSize.normal + } + color: Appearance.colors.colOnSurfaceVariant + } + } + + StyledText { + text: `${cleanedTitle}${activePlayer?.trackArtist ? '\n' + activePlayer.trackArtist : ''}` + } + } + } + +} diff --git a/ii/modules/wallpaperSelector/WallpaperDirectoryItem.qml b/ii/modules/wallpaperSelector/WallpaperDirectoryItem.qml new file mode 100644 index 0000000..cf196da --- /dev/null +++ b/ii/modules/wallpaperSelector/WallpaperDirectoryItem.qml @@ -0,0 +1,131 @@ +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 Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Io + +MouseArea { + id: root + required property var fileModelData + property bool isDirectory: fileModelData.fileIsDir + property bool useThumbnail: Images.isValidImageByName(fileModelData.fileName) + + property alias colBackground: background.color + property alias colText: wallpaperItemName.color + property alias radius: background.radius + property alias margins: background.anchors.margins + property alias padding: wallpaperItemColumnLayout.anchors.margins + margins: Appearance.sizes.wallpaperSelectorItemMargins + padding: Appearance.sizes.wallpaperSelectorItemPadding + + signal activated() + + hoverEnabled: true + onClicked: root.activated() + + Rectangle { + id: background + anchors.fill: parent + radius: Appearance.rounding.normal + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + + ColumnLayout { + id: wallpaperItemColumnLayout + anchors.fill: parent + spacing: 4 + + Item { + id: wallpaperItemImageContainer + Layout.fillHeight: true + Layout.fillWidth: true + + Loader { + id: thumbnailShadowLoader + active: thumbnailImageLoader.active && thumbnailImageLoader.item.status === Image.Ready + anchors.fill: thumbnailImageLoader + sourceComponent: StyledRectangularShadow { + target: thumbnailImageLoader + anchors.fill: undefined + radius: Appearance.rounding.small + } + } + + Loader { + id: thumbnailImageLoader + anchors.fill: parent + active: root.useThumbnail + sourceComponent: ThumbnailImage { + id: thumbnailImage + generateThumbnail: false + sourcePath: fileModelData.filePath + + cache: false + fillMode: Image.PreserveAspectCrop + clip: true + sourceSize.width: wallpaperItemColumnLayout.width + sourceSize.height: wallpaperItemColumnLayout.height - wallpaperItemColumnLayout.spacing - wallpaperItemName.height + + Connections { + target: Wallpapers + function onThumbnailGenerated(directory) { + if (thumbnailImage.status !== Image.Error) return; + if (FileUtils.parentDirectory(thumbnailImage.sourcePath) !== directory) return; + thumbnailImage.source = ""; + thumbnailImage.source = thumbnailImage.thumbnailPath; + } + function onThumbnailGeneratedFile(filePath) { + if (thumbnailImage.status !== Image.Error) return; + if (Qt.resolvedUrl(thumbnailImage.sourcePath) !== Qt.resolvedUrl(filePath)) return; + thumbnailImage.source = ""; + thumbnailImage.source = thumbnailImage.thumbnailPath; + } + } + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: wallpaperItemImageContainer.width + height: wallpaperItemImageContainer.height + radius: Appearance.rounding.small + } + } + } + } + + Loader { + id: iconLoader + active: !root.useThumbnail + anchors.fill: parent + sourceComponent: DirectoryIcon { + fileModelData: root.fileModelData + sourceSize.width: wallpaperItemColumnLayout.width + sourceSize.height: wallpaperItemColumnLayout.height - wallpaperItemColumnLayout.spacing - wallpaperItemName.height + } + } + } + + StyledText { + id: wallpaperItemName + Layout.fillWidth: true + Layout.leftMargin: 10 + Layout.rightMargin: 10 + + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + font.pixelSize: Appearance.font.pixelSize.smaller + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + text: fileModelData.fileName + } + } + } +} diff --git a/ii/modules/wallpaperSelector/WallpaperSelector.qml b/ii/modules/wallpaperSelector/WallpaperSelector.qml new file mode 100644 index 0000000..0984c85 --- /dev/null +++ b/ii/modules/wallpaperSelector/WallpaperSelector.qml @@ -0,0 +1,75 @@ +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 Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + + Loader { + id: wallpaperSelectorLoader + active: GlobalStates.wallpaperSelectorOpen + + sourceComponent: PanelWindow { + id: panelWindow + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(panelWindow.screen) + property bool monitorIsFocused: (Hyprland.focusedMonitor?.id == monitor?.id) + + exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "quickshell:wallpaperSelector" + WlrLayershell.layer: WlrLayer.Overlay + color: "transparent" + + anchors.top: true + margins { + top: Config?.options.bar.vertical ? Appearance.sizes.hyprlandGapsOut : Appearance.sizes.barHeight + Appearance.sizes.hyprlandGapsOut + } + + mask: Region { + item: content + } + + implicitHeight: Appearance.sizes.wallpaperSelectorHeight + implicitWidth: Appearance.sizes.wallpaperSelectorWidth + + HyprlandFocusGrab { // Click outside to close + id: grab + windows: [ panelWindow ] + active: wallpaperSelectorLoader.active + onCleared: () => { + if (!active) GlobalStates.wallpaperSelectorOpen = false; + } + } + + WallpaperSelectorContent { + id: content + anchors { + fill: parent + } + } + } + } + + IpcHandler { + target: "wallpaperSelector" + + function toggle(): void { + GlobalStates.wallpaperSelectorOpen = !GlobalStates.wallpaperSelectorOpen + } + } + + GlobalShortcut { + name: "wallpaperSelectorToggle" + description: "Toggle wallpaper selector" + onPressed: { + GlobalStates.wallpaperSelectorOpen = !GlobalStates.wallpaperSelectorOpen; + } + } +} diff --git a/ii/modules/wallpaperSelector/WallpaperSelectorContent.qml b/ii/modules/wallpaperSelector/WallpaperSelectorContent.qml new file mode 100644 index 0000000..eef284c --- /dev/null +++ b/ii/modules/wallpaperSelector/WallpaperSelectorContent.qml @@ -0,0 +1,389 @@ +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 Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Io + +Item { + id: root + property int columns: 4 + property real previewCellAspectRatio: 4 / 3 + property bool useDarkMode: Appearance.m3colors.darkmode + + function updateThumbnails() { + const totalImageMargin = (Appearance.sizes.wallpaperSelectorItemMargins + Appearance.sizes.wallpaperSelectorItemPadding) * 2 + const thumbnailSizeName = Images.thumbnailSizeNameForDimensions(grid.cellWidth - totalImageMargin, grid.cellHeight - totalImageMargin) + Wallpapers.generateThumbnail(thumbnailSizeName) + } + + Connections { + target: Wallpapers + function onDirectoryChanged() { + root.updateThumbnails() + } + } + + function handleFilePasting(event) { + const currentClipboardEntry = Cliphist.entries[0] + if (/^\d+\tfile:\/\/\S+/.test(currentClipboardEntry)) { + const url = StringUtils.cleanCliphistEntry(currentClipboardEntry); + Wallpapers.setDirectory(FileUtils.trimFileProtocol(decodeURIComponent(url))); + event.accepted = true; + } else { + event.accepted = false; // No image, let text pasting proceed + } + } + + Keys.onPressed: event => { + if (event.key === Qt.Key_Escape) { + GlobalStates.wallpaperSelectorOpen = false; + event.accepted = true; + } else if ((event.modifiers & Qt.ControlModifier) && event.key === Qt.Key_V) { // Intercept Ctrl+V to handle "paste to go to" in pickers + root.handleFilePasting(event); + } else if (event.modifiers & Qt.AltModifier && event.key === Qt.Key_Up) { + Wallpapers.setDirectory(FileUtils.parentDirectory(Wallpapers.directory)); + event.accepted = true; + } else if (event.key === Qt.Key_Left) { + grid.moveSelection(-1); + event.accepted = true; + } else if (event.key === Qt.Key_Right) { + grid.moveSelection(1); + event.accepted = true; + } else if (event.key === Qt.Key_Up) { + grid.moveSelection(-grid.columns); + event.accepted = true; + } else if (event.key === Qt.Key_Down) { + grid.moveSelection(grid.columns); + event.accepted = true; + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + grid.activateCurrent(); + event.accepted = true; + } else if (event.key === Qt.Key_Backspace) { + if (filterField.text.length > 0) { + filterField.text = filterField.text.substring(0, filterField.text.length - 1); + } + filterField.forceActiveFocus(); + event.accepted = true; + } else if (event.modifiers & Qt.ControlModifier && event.key === Qt.Key_L) { + addressBar.focusBreadcrumb(); + event.accepted = true; + } else if (event.key === Qt.Key_Slash) { + filterField.forceActiveFocus(); + event.accepted = true; + } else { + if (event.text.length > 0) { + filterField.text += event.text; + filterField.cursorPosition = filterField.text.length; + filterField.forceActiveFocus(); + } + event.accepted = true; + } + } + + implicitHeight: mainLayout.implicitHeight + implicitWidth: mainLayout.implicitWidth + + StyledRectangularShadow { + target: wallpaperGridBackground + } + Rectangle { + id: wallpaperGridBackground + anchors { + fill: parent + margins: Appearance.sizes.elevationMargin + } + focus: true + border.width: 1 + border.color: Appearance.colors.colLayer0Border + color: Appearance.colors.colLayer0 + radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1 + + property int calculatedRows: Math.ceil(grid.count / grid.columns) + + implicitWidth: gridColumnLayout.implicitWidth + implicitHeight: gridColumnLayout.implicitHeight + + RowLayout { + id: mainLayout + anchors.fill: parent + spacing: -4 + + Rectangle { + Layout.fillHeight: true + Layout.margins: 4 + implicitWidth: quickDirColumnLayout.implicitWidth + implicitHeight: quickDirColumnLayout.implicitHeight + color: Appearance.colors.colLayer1 + radius: wallpaperGridBackground.radius - Layout.margins + + ColumnLayout { + id: quickDirColumnLayout + anchors.fill: parent + spacing: 0 + + StyledText { + Layout.margins: 12 + font { + pixelSize: Appearance.font.pixelSize.normal + weight: Font.Medium + } + text: Translation.tr("Pick a wallpaper") + } + ListView { + // Quick dirs + Layout.fillHeight: true + Layout.margins: 4 + implicitWidth: 140 + clip: true + model: [ + { icon: "home", name: "Home", path: Directories.home }, + { icon: "docs", name: "Documents", path: Directories.documents }, + { icon: "download", name: "Downloads", path: Directories.downloads }, + { icon: "image", name: "Pictures", path: Directories.pictures }, + { icon: "movie", name: "Videos", path: Directories.videos }, + { icon: "", name: "---", path: "INTENTIONALLY_INVALID_DIR" }, + { icon: "wallpaper", name: "Wallpapers", path: `${Directories.pictures}/Wallpapers` }, + { icon: "favorite", name: "Homework", path: `${Directories.pictures}/homework` }, + ] + delegate: RippleButton { + id: quickDirButton + required property var modelData + anchors { + left: parent.left + right: parent.right + } + onClicked: Wallpapers.setDirectory(quickDirButton.modelData.path) + enabled: modelData.icon.length > 0 + toggled: Wallpapers.directory === FileUtils.trimFileProtocol(modelData.path) + colBackgroundToggled: Appearance.colors.colSecondaryContainer + colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover + colRippleToggled: Appearance.colors.colSecondaryContainerActive + + contentItem: RowLayout { + MaterialSymbol { + color: quickDirButton.toggled ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1 + iconSize: Appearance.font.pixelSize.larger + text: quickDirButton.modelData.icon + } + StyledText { + Layout.fillWidth: true + horizontalAlignment: Text.AlignLeft + color: quickDirButton.toggled ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1 + text: quickDirButton.modelData.name + } + } + } + } + } + } + + ColumnLayout { + id: gridColumnLayout + Layout.fillWidth: true + Layout.fillHeight: true + + AddressBar { + id: addressBar + Layout.margins: 4 + Layout.fillWidth: true + Layout.fillHeight: false + directory: Wallpapers.directory + onNavigateToDirectory: path => { + Wallpapers.setDirectory(path.length == 0 ? "/" : path); + } + radius: wallpaperGridBackground.radius - Layout.margins + } + + Item { + id: gridDisplayRegion + Layout.fillWidth: true + Layout.fillHeight: true + + StyledIndeterminateProgressBar { + id: indeterminateProgressBar + visible: Wallpapers.thumbnailGenerationRunning && value == 0 + anchors { + bottom: parent.top + left: parent.left + right: parent.right + leftMargin: 4 + rightMargin: 4 + } + } + + StyledProgressBar { + visible: Wallpapers.thumbnailGenerationRunning && value > 0 + value: Wallpapers.thumbnailGenerationProgress + anchors.fill: indeterminateProgressBar + } + + GridView { + id: grid + visible: Wallpapers.folderModel.count > 0 + + readonly property int columns: root.columns + readonly property int rows: Math.max(1, Math.ceil(count / columns)) + property int currentIndex: 0 + + anchors.fill: parent + cellWidth: width / root.columns + cellHeight: cellWidth / root.previewCellAspectRatio + interactive: true + clip: true + keyNavigationWraps: true + boundsBehavior: Flickable.StopAtBounds + bottomMargin: extraOptions.implicitHeight + ScrollBar.vertical: StyledScrollBar {} + + Component.onCompleted: { + root.updateThumbnails() + } + + function moveSelection(delta) { + currentIndex = Math.max(0, Math.min(grid.model.count - 1, currentIndex + delta)); + positionViewAtIndex(currentIndex, GridView.Contain); + } + + function activateCurrent() { + const filePath = grid.model.get(currentIndex, "filePath") + Wallpapers.select(filePath, root.useDarkMode); + filterField.text = ""; + } + + model: Wallpapers.folderModel + onModelChanged: currentIndex = 0 + delegate: WallpaperDirectoryItem { + required property var modelData + required property int index + fileModelData: modelData + width: grid.cellWidth + height: grid.cellHeight + colBackground: (index === grid?.currentIndex || containsMouse) ? Appearance.colors.colPrimary : (fileModelData.filePath === Config.options.background.wallpaperPath) ? Appearance.colors.colSecondaryContainer : ColorUtils.transparentize(Appearance.colors.colPrimaryContainer) + colText: (index === grid.currentIndex || containsMouse) ? Appearance.colors.colOnPrimary : (fileModelData.filePath === Config.options.background.wallpaperPath) ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer0 + + onEntered: { + grid.currentIndex = index; + } + + onActivated: { + Wallpapers.select(fileModelData.filePath, root.useDarkMode); + filterField.text = ""; + } + } + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: gridDisplayRegion.width + height: gridDisplayRegion.height + radius: wallpaperGridBackground.radius + } + } + } + + Toolbar { + id: extraOptions + anchors { + bottom: parent.bottom + horizontalCenter: parent.horizontalCenter + bottomMargin: 8 + } + + ToolbarButton { + implicitWidth: height + onClicked: { + Wallpapers.openFallbackPicker(root.useDarkMode); + GlobalStates.wallpaperSelectorOpen = false; + } + contentItem: MaterialSymbol { + text: "open_in_new" + iconSize: Appearance.font.pixelSize.larger + } + StyledToolTip { + content: Translation.tr("Use the system file picker instead") + } + } + + ToolbarButton { + implicitWidth: height + onClicked: root.useDarkMode = !root.useDarkMode + contentItem: MaterialSymbol { + text: root.useDarkMode ? "dark_mode" : "light_mode" + iconSize: Appearance.font.pixelSize.larger + } + StyledToolTip { + content: Translation.tr("Click to toggle light/dark mode (applied when wallpaper is chosen)") + } + } + + ToolbarTextField { + id: filterField + placeholderText: focus ? Translation.tr("Search wallpapers") : Translation.tr("Hit \"/\" to search") + + // Style + clip: true + font.pixelSize: Appearance.font.pixelSize.small + + // Search + onTextChanged: { + Wallpapers.searchQuery = text; + } + + Keys.onPressed: event => { + if ((event.modifiers & Qt.ControlModifier) && event.key === Qt.Key_V) { // Intercept Ctrl+V to handle "paste to go to" in pickers + root.handleFilePasting(event); + return; + } + else if (text.length !== 0) { + // No filtering, just navigate grid + if (event.key === Qt.Key_Down) { + grid.moveSelection(grid.columns); + event.accepted = true; + return; + } + if (event.key === Qt.Key_Up) { + grid.moveSelection(-grid.columns); + event.accepted = true; + return; + } + } + event.accepted = false; + } + } + + ToolbarButton { + onClicked: { + GlobalStates.wallpaperSelectorOpen = false; + } + contentItem: StyledText { + text: "Cancel" + } + } + } + } + } + } + } + + Connections { + target: GlobalStates + function onWallpaperSelectorOpenChanged() { + if (GlobalStates.wallpaperSelectorOpen && monitorIsFocused) { + filterField.forceActiveFocus(); + } + } + } + + Connections { + target: Wallpapers + function onChanged() { + GlobalStates.wallpaperSelectorOpen = false; + } + } +} diff --git a/ii/screenshot.qml b/ii/screenshot.qml new file mode 100644 index 0000000..a8e1020 --- /dev/null +++ b/ii/screenshot.qml @@ -0,0 +1,553 @@ +//@ pragma UseQApplication +//@ pragma Env QS_NO_RELOAD_POPUP=1 +//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic +//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 + +// Adjust this to make it smaller or larger +//@ pragma Env QT_SCALE_FACTOR=1 + +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 Quickshell.Io +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland + +ShellRoot { + id: root + property string screenshotDir: Directories.screenshotTemp + property color overlayColor: "#77111111" + property color genericContentColor: Qt.alpha(root.overlayColor, 0.9) + property color genericContentForeground: "#ddffffff" + property color selectionBorderColor: "#ddf1f1f1" + property color selectionFillColor: "#33ffffff" + property color windowBorderColor: "#dda0c0da" + property color windowFillColor: "#22a0c0da" + property color imageBorderColor: "#ddf1d1ff" + property color imageFillColor: "#33f1d1ff" + property color onBorderColor: "#ff000000" + property real standardRounding: 4 + readonly property var windows: HyprlandData.windowList + readonly property var layers: HyprlandData.layers + readonly property real falsePositivePreventionRatio: 0.5 + + // Force initialization of some singletons + Component.onCompleted: { + MaterialThemeLoader.reapplyTheme(); + } + + component TargetRegion: Rectangle { + id: regionRect + property bool showIcon: false + property bool targeted: false + property color borderColor + property color fillColor: "transparent" + property string text: "" + property real textPadding: 10 + z: 2 + color: fillColor + border.color: borderColor + border.width: targeted ? 3 : 1 + radius: root.standardRounding + + Rectangle { + id: regionLabelBackground + property real verticalPadding: 5 + property real horizontalPadding: 10 + radius: 10 + color: root.genericContentColor + border.width: 1 + border.color: Appearance.m3colors.m3outlineVariant + anchors { + top: parent.top + left: parent.left + topMargin: regionRect.textPadding + leftMargin: regionRect.textPadding + } + implicitWidth: regionInfoRow.implicitWidth + horizontalPadding * 2 + implicitHeight: regionInfoRow.implicitHeight + verticalPadding * 2 + RowLayout { + id: regionInfoRow + anchors.centerIn: parent + spacing: 8 + + Loader { + id: regionIconLoader + active: regionRect.showIcon + visible: active + sourceComponent: IconImage { + implicitSize: Appearance.font.pixelSize.larger + source: Quickshell.iconPath(AppSearch.guessIcon(regionRect.text), "image-missing") + } + } + + StyledText { + id: regionText + text: regionRect.text + color: root.genericContentForeground + } + } + } + } + + Variants { + model: Quickshell.screens + + PanelWindow { + id: panelWindow + required property var modelData + readonly property HyprlandMonitor hyprlandMonitor: Hyprland.monitorFor(modelData) + readonly property real monitorScale: hyprlandMonitor.scale + readonly property real monitorOffsetX: hyprlandMonitor.x + readonly property real monitorOffsetY: hyprlandMonitor.y + property int activeWorkspaceId: hyprlandMonitor.activeWorkspace?.id ?? 0 + property string screenshotPath: `${root.screenshotDir}/image-${modelData.name}` + property real dragStartX: 0 + property real dragStartY: 0 + property real draggingX: 0 + property real draggingY: 0 + property real dragDiffX: 0 + property real dragDiffY: 0 + property bool draggedAway: (dragDiffX !== 0 || dragDiffY !== 0) + property bool dragging: false + property var mouseButton: null + property var imageRegions: [] + readonly property list windowRegions: filterWindowRegionsByLayers( + root.windows.filter(w => w.workspace.id === panelWindow.activeWorkspaceId), + panelWindow.layerRegions + ).map(window => { + return { + at: [window.at[0] - panelWindow.monitorOffsetX, window.at[1] - panelWindow.monitorOffsetY], + size: [window.size[0], window.size[1]], + class: window.class, + title: window.title, + } + }) + readonly property list layerRegions: { + const layersOfThisMonitor = root.layers[panelWindow.hyprlandMonitor.name] + const topLayers = layersOfThisMonitor.levels["2"] + const nonBarTopLayers = topLayers + .filter(layer => !(layer.namespace.includes(":bar") || layer.namespace.includes(":verticalBar") || layer.namespace.includes(":dock"))) + .map(layer => { + return { + at: [layer.x, layer.y], + size: [layer.w, layer.h], + namespace: layer.namespace, + } + }) + const offsetAdjustedLayers = nonBarTopLayers.map(layer => { + return { + at: [layer.at[0] - panelWindow.monitorOffsetX, layer.at[1] - panelWindow.monitorOffsetY], + size: layer.size, + namespace: layer.namespace, + } + }); + return offsetAdjustedLayers; + } + + property real targetedRegionX: -1 + property real targetedRegionY: -1 + property real targetedRegionWidth: 0 + property real targetedRegionHeight: 0 + + function intersectionOverUnion(regionA, regionB) { + // region: { at: [x, y], size: [w, h] } + const ax1 = regionA.at[0], ay1 = regionA.at[1]; + const ax2 = ax1 + regionA.size[0], ay2 = ay1 + regionA.size[1]; + const bx1 = regionB.at[0], by1 = regionB.at[1]; + const bx2 = bx1 + regionB.size[0], by2 = by1 + regionB.size[1]; + + const interX1 = Math.max(ax1, bx1); + const interY1 = Math.max(ay1, by1); + const interX2 = Math.min(ax2, bx2); + const interY2 = Math.min(ay2, by2); + + const interArea = Math.max(0, interX2 - interX1) * Math.max(0, interY2 - interY1); + const areaA = (ax2 - ax1) * (ay2 - ay1); + const areaB = (bx2 - bx1) * (by2 - by1); + const unionArea = areaA + areaB - interArea; + + return unionArea > 0 ? interArea / unionArea : 0; + } + + function filterOverlappingImageRegions(regions) { + let keep = []; + let removed = new Set(); + for (let i = 0; i < regions.length; ++i) { + if (removed.has(i)) continue; + let regionA = regions[i]; + for (let j = i + 1; j < regions.length; ++j) { + if (removed.has(j)) continue; + let regionB = regions[j]; + if (intersectionOverUnion(regionA, regionB) > 0) { + // Compare areas + let areaA = regionA.size[0] * regionA.size[1]; + let areaB = regionB.size[0] * regionB.size[1]; + if (areaA <= areaB) { + removed.add(j); + } else { + removed.add(i); + } + } + } + } + for (let i = 0; i < regions.length; ++i) { + if (!removed.has(i)) keep.push(regions[i]); + } + return keep; + } + + function filterWindowRegionsByLayers(windowRegions, layerRegions) { + return windowRegions.filter(windowRegion => { + for (let i = 0; i < layerRegions.length; ++i) { + if (intersectionOverUnion(windowRegion, layerRegions[i]) > 0) + return false; + } + return true; + }); + } + + function filterImageRegions(regions, windowRegions, threshold = 0.1) { + // Remove image regions that overlap too much with any window region + let filtered = regions.filter(region => { + for (let i = 0; i < windowRegions.length; ++i) { + if (intersectionOverUnion(region, windowRegions[i]) > threshold) + return false; + } + return true; + }); + // Remove overlapping image regions, keep only the smaller one + return filterOverlappingImageRegions(filtered); + } + + function updateTargetedRegion(x, y) { + // Image regions + const clickedRegion = panelWindow.imageRegions.find(region => { + return region.at[0] <= x && x <= region.at[0] + region.size[0] && region.at[1] <= y && y <= region.at[1] + region.size[1]; + }); + if (clickedRegion) { + panelWindow.targetedRegionX = clickedRegion.at[0]; + panelWindow.targetedRegionY = clickedRegion.at[1]; + panelWindow.targetedRegionWidth = clickedRegion.size[0]; + panelWindow.targetedRegionHeight = clickedRegion.size[1]; + return; + } + + // Layer regions + const clickedLayer = panelWindow.layerRegions.find(region => { + return region.at[0] <= x && x <= region.at[0] + region.size[0] && region.at[1] <= y && y <= region.at[1] + region.size[1]; + }); + if (clickedLayer) { + panelWindow.targetedRegionX = clickedLayer.at[0]; + panelWindow.targetedRegionY = clickedLayer.at[1]; + panelWindow.targetedRegionWidth = clickedLayer.size[0]; + panelWindow.targetedRegionHeight = clickedLayer.size[1]; + return; + } + + // Window regions + const clickedWindow = panelWindow.windowRegions.find(region => { + return region.at[0] <= x && x <= region.at[0] + region.size[0] && region.at[1] <= y && y <= region.at[1] + region.size[1]; + }); + if (clickedWindow) { + panelWindow.targetedRegionX = clickedWindow.at[0]; + panelWindow.targetedRegionY = clickedWindow.at[1]; + panelWindow.targetedRegionWidth = clickedWindow.size[0]; + panelWindow.targetedRegionHeight = clickedWindow.size[1]; + return; + } + + panelWindow.targetedRegionX = -1; + panelWindow.targetedRegionY = -1; + panelWindow.targetedRegionWidth = 0; + panelWindow.targetedRegionHeight = 0; + } + + property real regionWidth: Math.abs(draggingX - dragStartX) + property real regionHeight: Math.abs(draggingY - dragStartY) + property real regionX: Math.min(dragStartX, draggingX) + property real regionY: Math.min(dragStartY, draggingY) + + visible: false + screen: modelData + WlrLayershell.namespace: "quickshell:screenshot" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + exclusionMode: ExclusionMode.Ignore + anchors { + left: true + right: true + top: true + bottom: true + } + + Process { + id: screenshotProcess + running: true + command: ["bash", "-c", `mkdir -p '${StringUtils.shellSingleQuoteEscape(root.screenshotDir)}' && grim -o '${StringUtils.shellSingleQuoteEscape(modelData.name)}' '${StringUtils.shellSingleQuoteEscape(panelWindow.screenshotPath)}'`] + onExited: (exitCode, exitStatus) => { + panelWindow.visible = true; + imageDetectionProcess.running = true; + } + } + + Process { + id: imageDetectionProcess + command: ["bash", "-c", `${Directories.scriptPath}/images/find_regions.py ` + + `--hyprctl ` + + `--image '${StringUtils.shellSingleQuoteEscape(panelWindow.screenshotPath)}' ` + + `--max-width ${Math.round(panelWindow.screen.width * root.falsePositivePreventionRatio)} ` + + `--max-height ${Math.round(panelWindow.screen.height * root.falsePositivePreventionRatio)} `] + stdout: StdioCollector { + id: imageDimensionCollector + onStreamFinished: { + imageRegions = filterImageRegions( + JSON.parse(imageDimensionCollector.text), + panelWindow.windowRegions + ); + } + } + } + + Process { + id: snipProc + function snip() { + if (panelWindow.regionWidth <= 0 || panelWindow.regionHeight <= 0) { + console.warn("Invalid region size, skipping snip."); + Qt.quit(); + } + snipProc.startDetached(); + Qt.quit(); + } + command: ["bash", "-c", + `magick ${StringUtils.shellSingleQuoteEscape(panelWindow.screenshotPath)} ` + + `-crop ${panelWindow.regionWidth * panelWindow.monitorScale}x${panelWindow.regionHeight * panelWindow.monitorScale}+${panelWindow.regionX * panelWindow.monitorScale}+${panelWindow.regionY * panelWindow.monitorScale} - ` + + `| ${panelWindow.mouseButton === Qt.LeftButton ? "wl-copy" : "swappy -f -"}`] + } + + ScreencopyView { + anchors.fill: parent + live: false + captureSource: modelData + + focus: panelWindow.visible + Keys.onPressed: (event) => { // Esc to close + if (event.key === Qt.Key_Escape) { + Qt.quit(); + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.CrossCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: true + + // Controls + onPressed: mouse => { + panelWindow.dragStartX = mouse.x; + panelWindow.dragStartY = mouse.y; + panelWindow.draggingX = mouse.x; + panelWindow.draggingY = mouse.y; + panelWindow.dragging = true; + panelWindow.mouseButton = mouse.button; + } + onReleased: mouse => { + // Detect if it was a click + + // Image regions + if (panelWindow.draggingX === panelWindow.dragStartX && panelWindow.draggingY === panelWindow.dragStartY) { + if (panelWindow.targetedRegionX >= 0 && panelWindow.targetedRegionY >= 0) { + panelWindow.regionX = panelWindow.targetedRegionX; + panelWindow.regionY = panelWindow.targetedRegionY; + panelWindow.regionWidth = panelWindow.targetedRegionWidth; + panelWindow.regionHeight = panelWindow.targetedRegionHeight; + } + } + snipProc.snip(); + } + onPositionChanged: mouse => { + if (panelWindow.dragging) { + panelWindow.draggingX = mouse.x; + panelWindow.draggingY = mouse.y; + panelWindow.dragDiffX = mouse.x - panelWindow.dragStartX; + panelWindow.dragDiffY = mouse.y - panelWindow.dragStartY; + } + panelWindow.updateTargetedRegion(mouse.x, mouse.y); + } + + // Overlay to darken screen + Rectangle { // Base + id: overlayRect + z: 0 + anchors.fill: parent + color: root.overlayColor + layer.enabled: true + } + Rectangle { + // TODO: Make this mask the base instead of just overlaying a border + z: 1 + anchors { + left: parent.left + top: parent.top + leftMargin: panelWindow.regionX + topMargin: panelWindow.regionY + } + width: panelWindow.regionWidth + height: panelWindow.regionHeight + color: "transparent" + border.color: root.selectionBorderColor + border.width: 2 + radius: root.standardRounding + } + + // Instructions + Rectangle { + anchors { + top: parent.top + horizontalCenter: parent.horizontalCenter + topMargin: (Appearance.sizes.barHeight - implicitHeight) / 2 + } + + opacity: panelWindow.dragging ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + color: root.genericContentColor + radius: 10 + border.width: 1 + border.color: Appearance.m3colors.m3outlineVariant + implicitWidth: instructionsRow.implicitWidth + 10 * 2 + implicitHeight: instructionsRow.implicitHeight + 5 * 2 + + RowLayout { + id: instructionsRow + anchors.centerIn: parent + Item { + Layout.fillHeight: true + implicitWidth: screenshotRegionIcon.implicitWidth + MaterialSymbol { + id: screenshotRegionIcon + anchors.centerIn: parent + iconSize: Appearance.font.pixelSize.larger + text: "screenshot_region" + color: root.genericContentForeground + } + } + StyledText { + text: Translation.tr("Drag or click a region • LMB: Copy • RMB: Edit") + color: root.genericContentForeground + } + } + } + + // Window regions + Repeater { + model: ScriptModel { + values: panelWindow.windowRegions + } + delegate: TargetRegion { + z: 2 + required property var modelData + showIcon: true + targeted: !panelWindow.draggedAway && + (panelWindow.targetedRegionX === modelData.at[0] + && panelWindow.targetedRegionY === modelData.at[1] + && panelWindow.targetedRegionWidth === modelData.size[0] + && panelWindow.targetedRegionHeight === modelData.size[1]) + + opacity: panelWindow.draggedAway ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + x: modelData.at[0] + y: modelData.at[1] + width: modelData.size[0] + height: modelData.size[1] + borderColor: root.windowBorderColor + fillColor: targeted ? root.windowFillColor : "transparent" + border.width: targeted ? 4 : 2 + text: `${modelData.class}` + radius: Appearance.rounding.windowRounding + } + } + + // Layer regions + Repeater { + model: ScriptModel { + values: panelWindow.layerRegions + } + delegate: TargetRegion { + z: 3 + required property var modelData + targeted: !panelWindow.draggedAway && + (panelWindow.targetedRegionX === modelData.at[0] + && panelWindow.targetedRegionY === modelData.at[1] + && panelWindow.targetedRegionWidth === modelData.size[0] + && panelWindow.targetedRegionHeight === modelData.size[1]) + + opacity: panelWindow.draggedAway ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + x: modelData.at[0] + y: modelData.at[1] + width: modelData.size[0] + height: modelData.size[1] + borderColor: root.windowBorderColor + fillColor: targeted ? root.windowFillColor : "transparent" + border.width: targeted ? 4 : 2 + text: `${modelData.namespace}` + radius: Appearance.rounding.windowRounding + } + } + + // Image regions + Repeater { + model: ScriptModel { + values: Config.options.screenshotTool.showContentRegions ? panelWindow.imageRegions : [] + } + delegate: TargetRegion { + z: 4 + required property var modelData + targeted: !panelWindow.draggedAway && + (panelWindow.targetedRegionX === modelData.at[0] + && panelWindow.targetedRegionY === modelData.at[1] + && panelWindow.targetedRegionWidth === modelData.size[0] + && panelWindow.targetedRegionHeight === modelData.size[1]) + + opacity: panelWindow.draggedAway ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + x: modelData.at[0] + y: modelData.at[1] + width: modelData.size[0] + height: modelData.size[1] + borderColor: root.imageBorderColor + fillColor: targeted ? root.imageFillColor : "transparent" + border.width: targeted ? 4 : 2 + text: "Content region" + } + } + } + } + } + } +} diff --git a/ii/scripts/ai/show-installed-ollama-models.sh b/ii/scripts/ai/show-installed-ollama-models.sh new file mode 100755 index 0000000..e56ac76 --- /dev/null +++ b/ii/scripts/ai/show-installed-ollama-models.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Get the list, skip the header, and extract the first column (model names) +model_names=$(ollama list | tail -n +2 | awk '{print $1}') + +# Build a JSON array +json_array="[" +for name in $model_names; do + json_array+="\"$name\"," +done + +# Remove trailing comma and close the array +json_array="${json_array%,}]" + +# Output the JSON array +echo "$json_array" diff --git a/ii/scripts/cava/raw_output_config.txt b/ii/scripts/cava/raw_output_config.txt new file mode 100644 index 0000000..7760e4e --- /dev/null +++ b/ii/scripts/cava/raw_output_config.txt @@ -0,0 +1,17 @@ +[general] +mode = waves +framerate = 60 +autosens = 1 +bars = 50 + +[output] +method = raw +raw_target = /dev/stdout +data_format = ascii +channels = mono +mono_option = average + +[smoothing] +noise_reduction = 20 + + diff --git a/ii/scripts/colors/applycolor.sh b/ii/scripts/colors/applycolor.sh new file mode 100755 index 0000000..ddb93bd --- /dev/null +++ b/ii/scripts/colors/applycolor.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +QUICKSHELL_CONFIG_NAME="ii" +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" +CONFIG_DIR="$XDG_CONFIG_HOME/quickshell/$QUICKSHELL_CONFIG_NAME" +CACHE_DIR="$XDG_CACHE_HOME/quickshell" +STATE_DIR="$XDG_STATE_HOME/quickshell" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +term_alpha=100 #Set this to < 100 make all your terminals transparent +# sleep 0 # idk i wanted some delay or colors dont get applied properly +if [ ! -d "$STATE_DIR"/user/generated ]; then + mkdir -p "$STATE_DIR"/user/generated +fi +cd "$CONFIG_DIR" || exit + +colornames='' +colorstrings='' +colorlist=() +colorvalues=() + +colornames=$(cat $STATE_DIR/user/generated/material_colors.scss | cut -d: -f1) +colorstrings=$(cat $STATE_DIR/user/generated/material_colors.scss | cut -d: -f2 | cut -d ' ' -f2 | cut -d ";" -f1) +IFS=$'\n' +colorlist=($colornames) # Array of color names +colorvalues=($colorstrings) # Array of color values + +apply_term() { + # Check if terminal escape sequence template exists + if [ ! -f "$SCRIPT_DIR/terminal/sequences.txt" ]; then + echo "Template file not found for Terminal. Skipping that." + return + fi + # Copy template + mkdir -p "$STATE_DIR"/user/generated/terminal + cp "$SCRIPT_DIR/terminal/sequences.txt" "$STATE_DIR"/user/generated/terminal/sequences.txt + # Apply colors + for i in "${!colorlist[@]}"; do + sed -i "s/${colorlist[$i]} #/${colorvalues[$i]#\#}/g" "$STATE_DIR"/user/generated/terminal/sequences.txt + done + + sed -i "s/\$alpha/$term_alpha/g" "$STATE_DIR/user/generated/terminal/sequences.txt" + + for file in /dev/pts/*; do + if [[ $file =~ ^/dev/pts/[0-9]+$ ]]; then + { + cat "$STATE_DIR"/user/generated/terminal/sequences.txt >"$file" + } & disown || true + fi + done +} + +apply_qt() { + sh "$CONFIG_DIR/scripts/kvantum/materialQT.sh" # generate kvantum theme + python "$CONFIG_DIR/scripts/kvantum/changeAdwColors.py" # apply config colors +} + +# Check if terminal theming is enabled in config +CONFIG_FILE="$XDG_CONFIG_HOME/illogical-impulse/config.json" +if [ -f "$CONFIG_FILE" ]; then + enable_terminal=$(jq -r '.appearance.wallpaperTheming.enableTerminal' "$CONFIG_FILE") + if [ "$enable_terminal" = "true" ]; then + apply_term & + fi +else + echo "Config file not found at $CONFIG_FILE. Applying terminal theming by default." + apply_term & +fi + +# apply_qt & # Qt theming is already handled by kde-material-colors diff --git a/ii/scripts/colors/generate_colors_material.py b/ii/scripts/colors/generate_colors_material.py new file mode 100755 index 0000000..db6b166 --- /dev/null +++ b/ii/scripts/colors/generate_colors_material.py @@ -0,0 +1,181 @@ +#!/usr/bin/env -S\_/bin/sh\_-c\_"source\_\$(eval\_echo\_\$ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate&&exec\_python\_-E\_"\$0"\_"\$@"" +import argparse +import math +import json +from PIL import Image +from materialyoucolor.quantize import QuantizeCelebi +from materialyoucolor.score.score import Score +from materialyoucolor.hct import Hct +from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors +from materialyoucolor.utils.color_utils import (rgba_from_argb, argb_from_rgb, argb_from_rgba) +from materialyoucolor.utils.math_utils import (sanitize_degrees_double, difference_degrees, rotation_direction) + +parser = argparse.ArgumentParser(description='Color generation script') +parser.add_argument('--path', type=str, default=None, help='generate colorscheme from image') +parser.add_argument('--size', type=int , default=128 , help='bitmap image size') +parser.add_argument('--color', type=str, default=None, help='generate colorscheme from color') +parser.add_argument('--mode', type=str, choices=['dark', 'light'], default='dark', help='dark or light mode') +parser.add_argument('--scheme', type=str, default='vibrant', help='material scheme to use') +parser.add_argument('--smart', action='store_true', default=False, help='decide scheme type based on image color') +parser.add_argument('--transparency', type=str, choices=['opaque', 'transparent'], default='opaque', help='enable transparency') +parser.add_argument('--termscheme', type=str, default=None, help='JSON file containg the terminal scheme for generating term colors') +parser.add_argument('--harmony', type=float , default=0.8, help='(0-1) Color hue shift towards accent') +parser.add_argument('--harmonize_threshold', type=float , default=100, help='(0-180) Max threshold angle to limit color hue shift') +parser.add_argument('--term_fg_boost', type=float , default=0.35, help='Make terminal foreground more different from the background') +parser.add_argument('--blend_bg_fg', action='store_true', default=False, help='Shift terminal background or foreground towards accent') +parser.add_argument('--cache', type=str, default=None, help='file path to store the generated color') +parser.add_argument('--debug', action='store_true', default=False, help='debug mode') +args = parser.parse_args() + +rgba_to_hex = lambda rgba: "#{:02X}{:02X}{:02X}".format(rgba[0], rgba[1], rgba[2]) +argb_to_hex = lambda argb: "#{:02X}{:02X}{:02X}".format(*map(round, rgba_from_argb(argb))) +hex_to_argb = lambda hex_code: argb_from_rgb(int(hex_code[1:3], 16), int(hex_code[3:5], 16), int(hex_code[5:], 16)) +display_color = lambda rgba : "\x1B[38;2;{};{};{}m{}\x1B[0m".format(rgba[0], rgba[1], rgba[2], "\x1b[7m \x1b[7m") + +def calculate_optimal_size (width: int, height: int, bitmap_size: int) -> (int, int): + image_area = width * height; + bitmap_area = bitmap_size ** 2 + scale = math.sqrt(bitmap_area/image_area) if image_area > bitmap_area else 1 + new_width = round(width * scale) + new_height = round(height * scale) + if new_width == 0: + new_width = 1 + if new_height == 0: + new_height = 1 + return new_width, new_height + +def harmonize (design_color: int, source_color: int, threshold: float = 35, harmony: float = 0.5) -> int: + from_hct = Hct.from_int(design_color) + to_hct = Hct.from_int(source_color) + difference_degrees_ = difference_degrees(from_hct.hue, to_hct.hue) + rotation_degrees = min(difference_degrees_ * harmony, threshold) + output_hue = sanitize_degrees_double( + from_hct.hue + rotation_degrees * rotation_direction(from_hct.hue, to_hct.hue) + ) + return Hct.from_hct(output_hue, from_hct.chroma, from_hct.tone).to_int() + +def boost_chroma_tone (argb: int, chroma: float = 1, tone: float = 1) -> int: + hct = Hct.from_int(argb) + return Hct.from_hct(hct.hue, hct.chroma * chroma, hct.tone * tone).to_int() + +darkmode = (args.mode == 'dark') +transparent = (args.transparency == 'transparent') + +if args.path is not None: + image = Image.open(args.path) + + if image.format == "GIF": + image.seek(1) + + if image.mode in ["L", "P"]: + image = image.convert('RGB') + wsize, hsize = image.size + wsize_new, hsize_new = calculate_optimal_size(wsize, hsize, args.size) + if wsize_new < wsize or hsize_new < hsize: + image = image.resize((wsize_new, hsize_new), Image.Resampling.BICUBIC) + colors = QuantizeCelebi(list(image.getdata()), 128) + argb = Score.score(colors)[0] + + if args.cache is not None: + with open(args.cache, 'w') as file: + file.write(argb_to_hex(argb)) + hct = Hct.from_int(argb) + if(args.smart): + if(hct.chroma < 20): + args.scheme = 'neutral' +elif args.color is not None: + argb = hex_to_argb(args.color) + hct = Hct.from_int(argb) + +if args.scheme == 'scheme-fruit-salad': + from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad as Scheme +elif args.scheme == 'scheme-expressive': + from materialyoucolor.scheme.scheme_expressive import SchemeExpressive as Scheme +elif args.scheme == 'scheme-monochrome': + from materialyoucolor.scheme.scheme_monochrome import SchemeMonochrome as Scheme +elif args.scheme == 'scheme-rainbow': + from materialyoucolor.scheme.scheme_rainbow import SchemeRainbow as Scheme +elif args.scheme == 'scheme-tonal-spot': + from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot as Scheme +elif args.scheme == 'scheme-neutral': + from materialyoucolor.scheme.scheme_neutral import SchemeNeutral as Scheme +elif args.scheme == 'scheme-fidelity': + from materialyoucolor.scheme.scheme_fidelity import SchemeFidelity as Scheme +elif args.scheme == 'scheme-content': + from materialyoucolor.scheme.scheme_content import SchemeContent as Scheme +elif args.scheme == 'scheme-vibrant': + from materialyoucolor.scheme.scheme_vibrant import SchemeVibrant as Scheme +else: + from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot as Scheme +# Generate +scheme = Scheme(hct, darkmode, 0.0) + +material_colors = {} +term_colors = {} + +for color in vars(MaterialDynamicColors).keys(): + color_name = getattr(MaterialDynamicColors, color) + if hasattr(color_name, "get_hct"): + rgba = color_name.get_hct(scheme).to_rgba() + material_colors[color] = rgba_to_hex(rgba) + +# Extended material +if darkmode == True: + material_colors['success'] = '#B5CCBA' + material_colors['onSuccess'] = '#213528' + material_colors['successContainer'] = '#374B3E' + material_colors['onSuccessContainer'] = '#D1E9D6' +else: + material_colors['success'] = '#4F6354' + material_colors['onSuccess'] = '#FFFFFF' + material_colors['successContainer'] = '#D1E8D5' + material_colors['onSuccessContainer'] = '#0C1F13' + +# Terminal Colors +if args.termscheme is not None: + with open(args.termscheme, 'r') as f: + json_termscheme = f.read() + term_source_colors = json.loads(json_termscheme)['dark' if darkmode else 'light'] + + primary_color_argb = hex_to_argb(material_colors['primary_paletteKeyColor']) + for color, val in term_source_colors.items(): + if(args.scheme == 'monochrome') : + term_colors[color] = val + continue + if args.blend_bg_fg and color == "term0": + harmonized = boost_chroma_tone(hex_to_argb(material_colors['surfaceContainerLow']), 1.2, 0.95) + elif args.blend_bg_fg and color == "term15": + harmonized = boost_chroma_tone(hex_to_argb(material_colors['onSurface']), 3, 1) + else: + harmonized = harmonize(hex_to_argb(val), primary_color_argb, args.harmonize_threshold, args.harmony) + harmonized = boost_chroma_tone(harmonized, 1, 1 + (args.term_fg_boost * (1 if darkmode else -1))) + term_colors[color] = argb_to_hex(harmonized) + +if args.debug == False: + print(f"$darkmode: {darkmode};") + print(f"$transparent: {transparent};") + for color, code in material_colors.items(): + print(f"${color}: {code};") + for color, code in term_colors.items(): + print(f"${color}: {code};") +else: + if args.path is not None: + print('\n--------------Image properties-----------------') + print(f"Image size: {wsize} x {hsize}") + print(f"Resized image: {wsize_new} x {hsize_new}") + print('\n---------------Selected color------------------') + print(f"Dark mode: {darkmode}") + print(f"Scheme: {args.scheme}") + print(f"Accent color: {display_color(rgba_from_argb(argb))} {argb_to_hex(argb)}") + print(f"HCT: {hct.hue:.2f} {hct.chroma:.2f} {hct.tone:.2f}") + print('\n---------------Material colors-----------------') + for color, code in material_colors.items(): + rgba = rgba_from_argb(hex_to_argb(code)) + print(f"{color.ljust(32)} : {display_color(rgba)} {code}") + print('\n----------Harmonize terminal colors------------') + for color, code in term_colors.items(): + rgba = rgba_from_argb(hex_to_argb(code)) + code_source = term_source_colors[color] + rgba_source = rgba_from_argb(hex_to_argb(code_source)) + print(f"{color.ljust(6)} : {display_color(rgba_source)} {code_source} --> {display_color(rgba)} {code}") + print('-----------------------------------------------') diff --git a/ii/scripts/colors/random_konachan_wall.sh b/ii/scripts/colors/random_konachan_wall.sh new file mode 100755 index 0000000..a4685da --- /dev/null +++ b/ii/scripts/colors/random_konachan_wall.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +get_pictures_dir() { + if command -v xdg-user-dir &> /dev/null; then + xdg-user-dir PICTURES + return + fi + + local config_file="${XDG_CONFIG_HOME:-$HOME/.config}/user-dirs.dirs" + if [ -f "$config_file" ]; then + local pictures_path + pictures_path=$(source "$config_file" >/dev/null 2>&1; echo "$XDG_PICTURES_DIR") + echo "${pictures_path/#\$HOME/$HOME}" + return + fi + + echo "$HOME/Pictures" +} + +QUICKSHELL_CONFIG_NAME="ii" +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" +PICTURES_DIR=$(get_pictures_dir) +CONFIG_DIR="$XDG_CONFIG_HOME/quickshell/$QUICKSHELL_CONFIG_NAME" +CACHE_DIR="$XDG_CACHE_HOME/quickshell" +STATE_DIR="$XDG_STATE_HOME/quickshell" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +mkdir -p "$PICTURES_DIR/Wallpapers" +page=$((1 + RANDOM % 1000)); +response=$(curl "https://konachan.net/post.json?tags=rating%3Asafe&limit=1&page=$page") +link=$(echo "$response" | jq '.[0].file_url' -r); +ext=$(echo "$link" | awk -F. '{print $NF}') +downloadPath="$PICTURES_DIR/Wallpapers/konachan_random_image.$ext" +illogicalImpulseConfigPath="$HOME/.config/illogical-impulse/config.json" +currentWallpaperPath=$(jq -r '.background.wallpaperPath' $illogicalImpulseConfigPath) +if [ "$downloadPath" == "$currentWallpaperPath" ]; then + downloadPath="$PICTURES_DIR/Wallpapers/konachan_random_image-1.$ext" +fi +curl "$link" -o "$downloadPath" +"$SCRIPT_DIR/switchwall.sh" --image "$downloadPath" diff --git a/ii/scripts/colors/scheme_for_image.py b/ii/scripts/colors/scheme_for_image.py new file mode 100755 index 0000000..8aa0ccb --- /dev/null +++ b/ii/scripts/colors/scheme_for_image.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +import sys +import cv2 +import numpy as np + +# Allowed scheme types +SCHEMES = [ + "scheme-content", + "scheme-expressive", + "scheme-fidelity", + "scheme-fruit-salad", + "scheme-monochrome", + "scheme-neutral", + "scheme-rainbow", + "scheme-tonal-spot" +] + +def image_colorfulness(image): + # Based on Hasler and Süsstrunk's colorfulness metric + (B, G, R) = cv2.split(image.astype("float")) + rg = np.absolute(R - G) + yb = np.absolute(0.5 * (R + G) - B) + std_rg = np.std(rg) + std_yb = np.std(yb) + mean_rg = np.mean(rg) + mean_yb = np.mean(yb) + colorfulness = np.sqrt(std_rg ** 2 + std_yb ** 2) + (0.3 * np.sqrt(mean_rg ** 2 + mean_yb ** 2)) + return colorfulness + +# scheme-content respects the image's colors very well, but it might +# look too saturated, so we only use it for not very colorful images to be safe +def pick_scheme(colorfulness): + if colorfulness < 10: + # return "scheme-monochrome" + return "scheme-content" + elif colorfulness < 20: + return "scheme-content" + elif colorfulness < 50: + return "scheme-neutral" + else: + return "scheme-tonal-spot" + +def main(): + colorfulness_mode = False + args = sys.argv[1:] + if '--colorfulness' in args: + colorfulness_mode = True + args.remove('--colorfulness') + if len(args) < 1: + print("scheme-tonal-spot") + sys.exit(1) + img_path = args[0] + img = cv2.imread(img_path) + if img is None: + print("scheme-tonal-spot") + sys.exit(1) + colorfulness = image_colorfulness(img) + if colorfulness_mode: + print(f"{colorfulness}") + else: + scheme = pick_scheme(colorfulness) + print(scheme) + +if __name__ == "__main__": + main() diff --git a/ii/scripts/colors/switchwall.sh b/ii/scripts/colors/switchwall.sh new file mode 100755 index 0000000..8b6514f --- /dev/null +++ b/ii/scripts/colors/switchwall.sh @@ -0,0 +1,421 @@ +#!/usr/bin/env bash + +QUICKSHELL_CONFIG_NAME="ii" +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" +CONFIG_DIR="$XDG_CONFIG_HOME/quickshell/$QUICKSHELL_CONFIG_NAME" +CACHE_DIR="$XDG_CACHE_HOME/quickshell" +STATE_DIR="$XDG_STATE_HOME/quickshell" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SHELL_CONFIG_FILE="$XDG_CONFIG_HOME/illogical-impulse/config.json" +MATUGEN_DIR="$XDG_CONFIG_HOME/matugen" +terminalscheme="$SCRIPT_DIR/terminal/scheme-base.json" + +handle_kde_material_you_colors() { + # Check if Qt app theming is enabled in config + if [ -f "$SHELL_CONFIG_FILE" ]; then + enable_qt_apps=$(jq -r '.appearance.wallpaperTheming.enableQtApps' "$SHELL_CONFIG_FILE") + if [ "$enable_qt_apps" == "false" ]; then + return + fi + fi + + # Map $type_flag to allowed scheme variants for kde-material-you-colors-wrapper.sh + local kde_scheme_variant="" + case "$type_flag" in + scheme-content|scheme-expressive|scheme-fidelity|scheme-fruit-salad|scheme-monochrome|scheme-neutral|scheme-rainbow|scheme-tonal-spot) + kde_scheme_variant="$type_flag" + ;; + *) + kde_scheme_variant="scheme-tonal-spot" # default + ;; + esac + "$XDG_CONFIG_HOME"/matugen/templates/kde/kde-material-you-colors-wrapper.sh --scheme-variant "$kde_scheme_variant" +} + +pre_process() { + local mode_flag="$1" + # Set GNOME color-scheme if mode_flag is dark or light + if [[ "$mode_flag" == "dark" ]]; then + gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' + gsettings set org.gnome.desktop.interface gtk-theme 'adw-gtk3-dark' + elif [[ "$mode_flag" == "light" ]]; then + gsettings set org.gnome.desktop.interface color-scheme 'prefer-light' + gsettings set org.gnome.desktop.interface gtk-theme 'adw-gtk3' + fi + + if [ ! -d "$CACHE_DIR"/user/generated ]; then + mkdir -p "$CACHE_DIR"/user/generated + fi +} + +post_process() { + local screen_width="$1" + local screen_height="$2" + local wallpaper_path="$3" + + + handle_kde_material_you_colors & + + # Determine the largest region on the wallpaper that's sufficiently un-busy to put widgets in + # if [ ! -f "$MATUGEN_DIR/scripts/least_busy_region.py" ]; then + # echo "Error: least_busy_region.py script not found in $MATUGEN_DIR/scripts/" + # else + # "$MATUGEN_DIR/scripts/least_busy_region.py" \ + # --screen-width "$screen_width" --screen-height "$screen_height" \ + # --width 300 --height 200 \ + # "$wallpaper_path" > "$STATE_DIR"/user/generated/wallpaper/least_busy_region.json + # fi +} + +check_and_prompt_upscale() { + local img="$1" + min_width_desired="$(hyprctl monitors -j | jq '([.[].width] | max)' | xargs)" # max monitor width + min_height_desired="$(hyprctl monitors -j | jq '([.[].height] | max)' | xargs)" # max monitor height + + if command -v identify &>/dev/null && [ -f "$img" ]; then + local img_width img_height + if is_video "$img"; then # Not check resolution for videos, just let em pass + img_width=$min_width_desired + img_height=$min_height_desired + else + img_width=$(identify -format "%w" "$img" 2>/dev/null) + img_height=$(identify -format "%h" "$img" 2>/dev/null) + fi + if [[ "$img_width" -lt "$min_width_desired" || "$img_height" -lt "$min_height_desired" ]]; then + action=$(notify-send "Upscale?" \ + "Image resolution (${img_width}x${img_height}) is lower than screen resolution (${min_width_desired}x${min_height_desired})" \ + -A "open_upscayl=Open Upscayl"\ + -a "Wallpaper switcher") + if [[ "$action" == "open_upscayl" ]]; then + if command -v upscayl &>/dev/null; then + nohup upscayl > /dev/null 2>&1 & + else + action2=$(notify-send \ + -a "Wallpaper switcher" \ + -c "im.error" \ + -A "install_upscayl=Install Upscayl (Arch)" \ + "Install Upscayl?" \ + "yay -S upscayl-bin") + if [[ "$action2" == "install_upscayl" ]]; then + kitty -1 yay -S upscayl-bin + if command -v upscayl &>/dev/null; then + nohup upscayl > /dev/null 2>&1 & + fi + fi + fi + fi + fi + fi +} + +CUSTOM_DIR="$XDG_CONFIG_HOME/hypr/custom" +RESTORE_SCRIPT_DIR="$CUSTOM_DIR/scripts" +RESTORE_SCRIPT="$RESTORE_SCRIPT_DIR/__restore_video_wallpaper.sh" +THUMBNAIL_DIR="$RESTORE_SCRIPT_DIR/mpvpaper_thumbnails" +VIDEO_OPTS="no-audio loop hwdec=auto scale=bilinear interpolation=no video-sync=display-resample panscan=1.0 video-scale-x=1.0 video-scale-y=1.0 video-align-x=0.5 video-align-y=0.5 load-scripts=no" + +is_video() { + local extension="${1##*.}" + [[ "$extension" == "mp4" || "$extension" == "webm" || "$extension" == "mkv" || "$extension" == "avi" || "$extension" == "mov" ]] && return 0 || return 1 +} + +kill_existing_mpvpaper() { + pkill -f -9 mpvpaper || true +} + +create_restore_script() { + local video_path=$1 + cat > "$RESTORE_SCRIPT.tmp" << EOF +#!/bin/bash +# Generated by switchwall.sh - Don't modify it by yourself. +# Time: $(date) + +pkill -f -9 mpvpaper + +for monitor in \$(hyprctl monitors -j | jq -r '.[] | .name'); do + mpvpaper -o "$VIDEO_OPTS" "\$monitor" "$video_path" & + sleep 0.1 +done +EOF + mv "$RESTORE_SCRIPT.tmp" "$RESTORE_SCRIPT" + chmod +x "$RESTORE_SCRIPT" +} + +remove_restore() { + cat > "$RESTORE_SCRIPT.tmp" << EOF +#!/bin/bash +# The content of this script will be generated by switchwall.sh - Don't modify it by yourself. +EOF + mv "$RESTORE_SCRIPT.tmp" "$RESTORE_SCRIPT" +} + +set_wallpaper_path() { + local path="$1" + if [ -f "$SHELL_CONFIG_FILE" ]; then + jq --arg path "$path" '.background.wallpaperPath = $path' "$SHELL_CONFIG_FILE" > "$SHELL_CONFIG_FILE.tmp" && mv "$SHELL_CONFIG_FILE.tmp" "$SHELL_CONFIG_FILE" + fi +} + +set_thumbnail_path() { + local path="$1" + if [ -f "$SHELL_CONFIG_FILE" ]; then + jq --arg path "$path" '.background.thumbnailPath = $path' "$SHELL_CONFIG_FILE" > "$SHELL_CONFIG_FILE.tmp" && mv "$SHELL_CONFIG_FILE.tmp" "$SHELL_CONFIG_FILE" + fi +} + +switch() { + imgpath="$1" + mode_flag="$2" + type_flag="$3" + color_flag="$4" + color="$5" + read scale screenx screeny screensizey < <(hyprctl monitors -j | jq '.[] | select(.focused) | .scale, .x, .y, .height' | xargs) + cursorposx=$(hyprctl cursorpos -j | jq '.x' 2>/dev/null) || cursorposx=960 + cursorposx=$(bc <<< "scale=0; ($cursorposx - $screenx) * $scale / 1") + cursorposy=$(hyprctl cursorpos -j | jq '.y' 2>/dev/null) || cursorposy=540 + cursorposy=$(bc <<< "scale=0; ($cursorposy - $screeny) * $scale / 1") + cursorposy_inverted=$((screensizey - cursorposy)) + + if [[ "$color_flag" == "1" ]]; then + matugen_args=(color hex "$color") + generate_colors_material_args=(--color "$color") + else + if [[ -z "$imgpath" ]]; then + echo 'Aborted' + exit 0 + fi + + check_and_prompt_upscale "$imgpath" & + kill_existing_mpvpaper + + if is_video "$imgpath"; then + mkdir -p "$THUMBNAIL_DIR" + + missing_deps=() + if ! command -v mpvpaper &> /dev/null; then + missing_deps+=("mpvpaper") + fi + if ! command -v ffmpeg &> /dev/null; then + missing_deps+=("ffmpeg") + fi + if [ ${#missing_deps[@]} -gt 0 ]; then + echo "Missing deps: ${missing_deps[*]}" + echo "Arch: sudo pacman -S ${missing_deps[*]}" + action=$(notify-send \ + -a "Wallpaper switcher" \ + -c "im.error" \ + -A "install_arch=Install (Arch)" \ + "Can't switch to video wallpaper" \ + "Missing dependencies: ${missing_deps[*]}") + if [[ "$action" == "install_arch" ]]; then + kitty -1 sudo pacman -S "${missing_deps[*]}" + if command -v mpvpaper &>/dev/null && command -v ffmpeg &>/dev/null; then + notify-send 'Wallpaper switcher' 'Alright, try again!' -a "Wallpaper switcher" + fi + fi + exit 0 + fi + + # Set wallpaper path + set_wallpaper_path "$imgpath" + + # Set video wallpaper + local video_path="$imgpath" + monitors=$(hyprctl monitors -j | jq -r '.[] | .name') + for monitor in $monitors; do + mpvpaper -o "$VIDEO_OPTS" "$monitor" "$video_path" & + sleep 0.1 + done + + # Extract first frame for color generation + thumbnail="$THUMBNAIL_DIR/$(basename "$imgpath").jpg" + ffmpeg -y -i "$imgpath" -vframes 1 "$thumbnail" 2>/dev/null + + # Set thumbnail path + set_thumbnail_path "$thumbnail" + + if [ -f "$thumbnail" ]; then + matugen_args=(image "$thumbnail") + generate_colors_material_args=(--path "$thumbnail") + create_restore_script "$video_path" + else + echo "Cannot create image to colorgen" + remove_restore + exit 1 + fi + else + matugen_args=(image "$imgpath") + generate_colors_material_args=(--path "$imgpath") + # Update wallpaper path in config + set_wallpaper_path "$imgpath" + remove_restore + fi + fi + + # Determine mode if not set + if [[ -z "$mode_flag" ]]; then + current_mode=$(gsettings get org.gnome.desktop.interface color-scheme 2>/dev/null | tr -d "'") + if [[ "$current_mode" == "prefer-dark" ]]; then + mode_flag="dark" + else + mode_flag="light" + fi + fi + + # enforce dark mode for terminal + if [[ -n "$mode_flag" ]]; then + matugen_args+=(--mode "$mode_flag") + if [[ $(jq -r '.appearance.wallpaperTheming.terminalGenerationProps.forceDarkMode' "$SHELL_CONFIG_FILE") == "true" ]]; then + generate_colors_material_args+=(--mode "dark") + else + generate_colors_material_args+=(--mode "$mode_flag") + fi + fi + [[ -n "$type_flag" ]] && matugen_args+=(--type "$type_flag") && generate_colors_material_args+=(--scheme "$type_flag") + generate_colors_material_args+=(--termscheme "$terminalscheme" --blend_bg_fg) + generate_colors_material_args+=(--cache "$STATE_DIR/user/generated/color.txt") + + pre_process "$mode_flag" + + # Check if app and shell theming is enabled in config + if [ -f "$SHELL_CONFIG_FILE" ]; then + enable_apps_shell=$(jq -r '.appearance.wallpaperTheming.enableAppsAndShell' "$SHELL_CONFIG_FILE") + if [ "$enable_apps_shell" == "false" ]; then + echo "App and shell theming disabled, skipping matugen and color generation" + return + fi + fi + + # Set harmony and related properties + if [ -f "$SHELL_CONFIG_FILE" ]; then + harmony=$(jq -r '.appearance.wallpaperTheming.terminalGenerationProps.harmony' "$SHELL_CONFIG_FILE") + harmonize_threshold=$(jq -r '.appearance.wallpaperTheming.terminalGenerationProps.harmonizeThreshold' "$SHELL_CONFIG_FILE") + term_fg_boost=$(jq -r '.appearance.wallpaperTheming.terminalGenerationProps.termFgBoost' "$SHELL_CONFIG_FILE") + [[ "$harmony" != "null" && -n "$harmony" ]] && generate_colors_material_args+=(--harmony "$harmony") + [[ "$harmonize_threshold" != "null" && -n "$harmonize_threshold" ]] && generate_colors_material_args+=(--harmonize_threshold "$harmonize_threshold") + [[ "$term_fg_boost" != "null" && -n "$term_fg_boost" ]] && generate_colors_material_args+=(--term_fg_boost "$term_fg_boost") + fi + + matugen "${matugen_args[@]}" + source "$(eval echo $ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate" + python3 "$SCRIPT_DIR/generate_colors_material.py" "${generate_colors_material_args[@]}" \ + > "$STATE_DIR"/user/generated/material_colors.scss + "$SCRIPT_DIR"/applycolor.sh + deactivate + + # Pass screen width, height, and wallpaper path to post_process + max_width_desired="$(hyprctl monitors -j | jq '([.[].width] | min)' | xargs)" + max_height_desired="$(hyprctl monitors -j | jq '([.[].height] | min)' | xargs)" + post_process "$max_width_desired" "$max_height_desired" "$imgpath" +} + +main() { + imgpath="" + mode_flag="" + type_flag="" + color_flag="" + color="" + noswitch_flag="" + + get_type_from_config() { + jq -r '.appearance.palette.type' "$SHELL_CONFIG_FILE" 2>/dev/null || echo "auto" + } + + detect_scheme_type_from_image() { + local img="$1" + "$SCRIPT_DIR"/scheme_for_image.py "$img" 2>/dev/null | tr -d '\n' + } + + while [[ $# -gt 0 ]]; do + case "$1" in + --mode) + mode_flag="$2" + shift 2 + ;; + --type) + type_flag="$2" + shift 2 + ;; + --color) + color_flag="1" + if [[ "$2" =~ ^#?[A-Fa-f0-9]{6}$ ]]; then + color="$2" + shift 2 + else + color=$(hyprpicker --no-fancy) + shift + fi + ;; + --image) + imgpath="$2" + shift 2 + ;; + --noswitch) + noswitch_flag="1" + imgpath=$(jq -r '.background.wallpaperPath' "$SHELL_CONFIG_FILE" 2>/dev/null || echo "") + shift + ;; + *) + if [[ -z "$imgpath" ]]; then + imgpath="$1" + fi + shift + ;; + esac + done + + # If type_flag is not set, get it from config + if [[ -z "$type_flag" ]]; then + type_flag="$(get_type_from_config)" + fi + + # Validate type_flag (allow 'auto' as well) + allowed_types=(scheme-content scheme-expressive scheme-fidelity scheme-fruit-salad scheme-monochrome scheme-neutral scheme-rainbow scheme-tonal-spot auto) + valid_type=0 + for t in "${allowed_types[@]}"; do + if [[ "$type_flag" == "$t" ]]; then + valid_type=1 + break + fi + done + if [[ $valid_type -eq 0 ]]; then + echo "[switchwall.sh] Warning: Invalid type '$type_flag', defaulting to 'auto'" >&2 + type_flag="auto" + fi + + # Only prompt for wallpaper if not using --color and not using --noswitch and no imgpath set + if [[ -z "$imgpath" && -z "$color_flag" && -z "$noswitch_flag" ]]; then + cd "$(xdg-user-dir PICTURES)/Wallpapers/showcase" 2>/dev/null || cd "$(xdg-user-dir PICTURES)/Wallpapers" 2>/dev/null || cd "$(xdg-user-dir PICTURES)" || return 1 + imgpath="$(kdialog --getopenfilename . --title 'Choose wallpaper')" + fi + + # If type_flag is 'auto', detect scheme type from image (after imgpath is set) + if [[ "$type_flag" == "auto" ]]; then + if [[ -n "$imgpath" && -f "$imgpath" ]]; then + detected_type="$(detect_scheme_type_from_image "$imgpath")" + # Only use detected_type if it's valid + valid_detected=0 + for t in "${allowed_types[@]}"; do + if [[ "$detected_type" == "$t" && "$detected_type" != "auto" ]]; then + valid_detected=1 + break + fi + done + if [[ $valid_detected -eq 1 ]]; then + type_flag="$detected_type" + else + echo "[switchwall] Warning: Could not auto-detect a valid scheme, defaulting to 'scheme-tonal-spot'" >&2 + type_flag="scheme-tonal-spot" + fi + else + echo "[switchwall] Warning: No image to auto-detect scheme from, defaulting to 'scheme-tonal-spot'" >&2 + type_flag="scheme-tonal-spot" + fi + fi + + switch "$imgpath" "$mode_flag" "$type_flag" "$color_flag" "$color" +} + +main "$@" diff --git a/ii/scripts/colors/terminal/scheme-base.json b/ii/scripts/colors/terminal/scheme-base.json new file mode 100644 index 0000000..e4b78e7 --- /dev/null +++ b/ii/scripts/colors/terminal/scheme-base.json @@ -0,0 +1,38 @@ +{ + "dark": { + "term0" : "#282828", + "term1" : "#CC241D", + "term2" : "#98971A", + "term3" : "#D79921", + "term4" : "#458588", + "term5" : "#B16286", + "term6" : "#689D6A", + "term7" : "#A89984", + "term8" : "#928374", + "term9" : "#FB4934", + "term10" : "#B8BB26", + "term11" : "#FABD2F", + "term12" : "#83A598", + "term13" : "#D3869B", + "term14" : "#8EC07C", + "term15" : "#EBDBB2" + }, + "light": { + "term0" : "#FDF9F3", + "term1" : "#FF6188", + "term2" : "#A9DC76", + "term3" : "#FC9867", + "term4" : "#FFD866", + "term5" : "#F47FD4", + "term6" : "#78DCE8", + "term7" : "#333034", + "term8" : "#121212", + "term9" : "#FF6188", + "term10" : "#A9DC76", + "term11" : "#FC9867", + "term12" : "#FFD866", + "term13" : "#F47FD4", + "term14" : "#78DCE8", + "term15" : "#333034" + } +} diff --git a/ii/scripts/colors/terminal/sequences.txt b/ii/scripts/colors/terminal/sequences.txt new file mode 100644 index 0000000..9745958 --- /dev/null +++ b/ii/scripts/colors/terminal/sequences.txt @@ -0,0 +1 @@ +]4;0;#$term0 #\]1;0;#$term0 #\]4;1;#$term1 #\]4;2;#$term2 #\]4;3;#$term3 #\]4;4;#$term4 #\]4;5;#$term5 #\]4;6;#$term6 #\]4;7;#$term7 #\]4;8;#$term8 #\]4;9;#$term9 #\]4;10;#$term10 #\]4;11;#$term11 #\]4;12;#$term12 #\]4;13;#$term13 #\]4;14;#$term14 #\]4;15;#$term15 #\]10;#$term7 #\]11;[100]#$term0 #\]12;#$term7 #\]13;#$term7 #\]17;#$term7 #\]19;#$term0 #\]4;232;#$term7 #\]4;256;#$term7 #\]708;[100]#$term0 #\]11;#$term0 #\ \ No newline at end of file diff --git a/ii/scripts/hyprland/get_keybinds.py b/ii/scripts/hyprland/get_keybinds.py new file mode 100755 index 0000000..559ba8a --- /dev/null +++ b/ii/scripts/hyprland/get_keybinds.py @@ -0,0 +1,222 @@ +#!/usr/bin/env -S\_/bin/sh\_-c\_"source\_\$(eval\_echo\_\$ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate&&exec\_python\_-E\_"\$0"\_"\$@"" +import argparse +import re +import os +from os.path import expandvars as os_expandvars +from typing import Dict, List + +TITLE_REGEX = "#+!" +HIDE_COMMENT = "[hidden]" +MOD_SEPARATORS = ['+', ' '] +COMMENT_BIND_PATTERN = "#/#" + +parser = argparse.ArgumentParser(description='Hyprland keybind reader') +parser.add_argument('--path', type=str, default="$HOME/.config/hypr/hyprland.conf", help='path to keybind file (sourcing isn\'t supported)') +args = parser.parse_args() +content_lines = [] +reading_line = 0 + +# Little Parser made for hyprland keybindings conf file +Variables: Dict[str, str] = {} + + +class KeyBinding(dict): + def __init__(self, mods, key, dispatcher, params, comment) -> None: + self["mods"] = mods + self["key"] = key + self["dispatcher"] = dispatcher + self["params"] = params + self["comment"] = comment + +class Section(dict): + def __init__(self, children, keybinds, name) -> None: + self["children"] = children + self["keybinds"] = keybinds + self["name"] = name + + +def read_content(path: str) -> str: + if (not os.access(os.path.expanduser(os.path.expandvars(path)), os.R_OK)): + return ("error") + with open(os.path.expanduser(os.path.expandvars(path)), "r") as file: + return file.read() + + +def autogenerate_comment(dispatcher: str, params: str = "") -> str: + match dispatcher: + + case "resizewindow": + return "Resize window" + + case "movewindow": + if(params == ""): + return "Move window" + else: + return "Window: move in {} direction".format({ + "l": "left", + "r": "right", + "u": "up", + "d": "down", + }.get(params, "null")) + + case "pin": + return "Window: pin (show on all workspaces)" + + case "splitratio": + return "Window split ratio {}".format(params) + + case "togglefloating": + return "Float/unfloat window" + + case "resizeactive": + return "Resize window by {}".format(params) + + case "killactive": + return "Close window" + + case "fullscreen": + return "Toggle {}".format( + { + "0": "fullscreen", + "1": "maximization", + "2": "fullscreen on Hyprland's side", + }.get(params, "null") + ) + + case "fakefullscreen": + return "Toggle fake fullscreen" + + case "workspace": + if params == "+1": + return "Workspace: focus right" + elif params == "-1": + return "Workspace: focus left" + return "Focus workspace {}".format(params) + + case "movefocus": + return "Window: move focus {}".format( + { + "l": "left", + "r": "right", + "u": "up", + "d": "down", + }.get(params, "null") + ) + + case "swapwindow": + return "Window: swap in {} direction".format( + { + "l": "left", + "r": "right", + "u": "up", + "d": "down", + }.get(params, "null") + ) + + case "movetoworkspace": + if params == "+1": + return "Window: move to right workspace (non-silent)" + elif params == "-1": + return "Window: move to left workspace (non-silent)" + return "Window: move to workspace {} (non-silent)".format(params) + + case "movetoworkspacesilent": + if params == "+1": + return "Window: move to right workspace" + elif params == "-1": + return "Window: move to right workspace" + return "Window: move to workspace {}".format(params) + + case "togglespecialworkspace": + return "Workspace: toggle special" + + case "exec": + return "Execute: {}".format(params) + + case _: + return "" + +def get_keybind_at_line(line_number, line_start = 0): + global content_lines + line = content_lines[line_number] + _, keys = line.split("=", 1) + keys, *comment = keys.split("#", 1) + + mods, key, dispatcher, *params = list(map(str.strip, keys.split(",", 4))) + params = "".join(map(str.strip, params)) + + # Remove empty spaces + comment = list(map(str.strip, comment)) + # Add comment if it exists, else generate it + if comment: + comment = comment[0] + if comment.startswith("[hidden]"): + return None + else: + comment = autogenerate_comment(dispatcher, params) + + if mods: + modstring = mods + MOD_SEPARATORS[0] # Add separator at end to ensure last mod is read + mods = [] + p = 0 + for index, char in enumerate(modstring): + if(char in MOD_SEPARATORS): + if(index - p > 1): + mods.append(modstring[p:index]) + p = index+1 + else: + mods = [] + + return KeyBinding(mods, key, dispatcher, params, comment) + +def get_binds_recursive(current_content, scope): + global content_lines + global reading_line + # print("get_binds_recursive({0}, {1}) [@L{2}]".format(current_content, scope, reading_line + 1)) + while reading_line < len(content_lines): # TODO: Adjust condition + line = content_lines[reading_line] + heading_search_result = re.search(TITLE_REGEX, line) + # print("Read line {0}: {1}\tisHeading: {2}".format(reading_line + 1, content_lines[reading_line], "[{0}, {1}, {2}]".format(heading_search_result.start(), heading_search_result.start() == 0, ((heading_search_result != None) and (heading_search_result.start() == 0))) if heading_search_result != None else "No")) + if ((heading_search_result != None) and (heading_search_result.start() == 0)): # Found title + # Determine scope + heading_scope = line.find('!') + # Lower? Return + if(heading_scope <= scope): + reading_line -= 1 + return current_content + + section_name = line[(heading_scope+1):].strip() + # print("[[ Found h{0} at line {1} ]] {2}".format(heading_scope, reading_line+1, content_lines[reading_line])) + reading_line += 1 + current_content["children"].append(get_binds_recursive(Section([], [], section_name), heading_scope)) + + elif line.startswith(COMMENT_BIND_PATTERN): + keybind = get_keybind_at_line(reading_line, line_start=len(COMMENT_BIND_PATTERN)) + if(keybind != None): + current_content["keybinds"].append(keybind) + + elif line == "" or not line.lstrip().startswith("bind"): # Comment, ignore + pass + + else: # Normal keybind + keybind = get_keybind_at_line(reading_line) + if(keybind != None): + current_content["keybinds"].append(keybind) + + reading_line += 1 + + return current_content; + +def parse_keys(path: str) -> Dict[str, List[KeyBinding]]: + global content_lines + content_lines = read_content(path).splitlines() + if content_lines[0] == "error": + return "error" + return get_binds_recursive(Section([], [], ""), 0) + + +if __name__ == "__main__": + import json + + ParsedKeys = parse_keys(args.path) + print(json.dumps(ParsedKeys)) diff --git a/ii/scripts/images/find_regions.py b/ii/scripts/images/find_regions.py new file mode 100755 index 0000000..fe68a4d --- /dev/null +++ b/ii/scripts/images/find_regions.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 + +import argparse +import cv2 +import json +import numpy as np +import sys + +DEFAULT_IMAGE_PATH = '/tmp/quickshell/media/screenshot/image' + +def iou(boxA, boxB): + # Compute intersection over union for two boxes + xA = max(boxA['x'], boxB['x']) + yA = max(boxA['y'], boxB['y']) + xB = min(boxA['x'] + boxA['width'], boxB['x'] + boxB['width']) + yB = min(boxA['y'] + boxA['height'], boxB['y'] + boxB['height']) + interW = max(0, xB - xA) + interH = max(0, yB - yA) + interArea = interW * interH + boxAArea = boxA['width'] * boxA['height'] + boxBArea = boxB['width'] * boxB['height'] + iou = interArea / float(boxAArea + boxBArea - interArea) if (boxAArea + boxBArea - interArea) > 0 else 0 + return iou + +def non_max_suppression(regions, iou_threshold=0.7): + # Sort by area (largest first) + regions = sorted(regions, key=lambda r: r['width'] * r['height'], reverse=True) + keep = [] + while regions: + current = regions.pop(0) + keep.append(current) + regions = [r for r in regions if iou(current, r) < iou_threshold] + return keep + +def find_regions(image_path, min_width, min_height, max_width=None, max_height=None, quality=False, k=150, min_size=20, sigma=0.8, resize_factor=1.0): + image = cv2.imread(image_path) + if image is None: + print(f'Error: Could not load image {image_path}', file=sys.stderr) + sys.exit(1) + orig_h, orig_w = image.shape[:2] + if resize_factor != 1.0: + image = cv2.resize(image, (int(orig_w * resize_factor), int(orig_h * resize_factor)), interpolation=cv2.INTER_AREA) + ss = cv2.ximgproc.segmentation.createSelectiveSearchSegmentation() + ss.setBaseImage(image) + if quality: + ss.switchToSelectiveSearchQuality(k, min_size, sigma) + else: + ss.switchToSelectiveSearchFast(k, min_size, sigma) + rects = ss.process() + regions = [] + for (x, y, w, h) in rects: + # Scale regions back to original image size if resized + if resize_factor != 1.0: + x = int(x / resize_factor) + y = int(y / resize_factor) + w = int(w / resize_factor) + h = int(h / resize_factor) + # Filter out region that is exactly the same size as the original image + if w == orig_w and h == orig_h and x == 0 and y == 0: + continue + if w > min_width and h > min_height: + if (max_width is None or w < max_width) and (max_height is None or h < max_height): + regions.append({'x': int(x), 'y': int(y), 'width': int(w), 'height': int(h)}) + # Remove duplicates/overlaps + regions = non_max_suppression(regions, iou_threshold=0.7) + return regions, cv2.imread(image_path) # Return original image for drawing + +def draw_regions(image, regions, output_path): + for region in regions: + if 'x' in region: + x, y, w, h = region['x'], region['y'], region['width'], region['height'] + elif 'at' in region and 'size' in region: + x, y = region['at'] + w, h = region['size'] + else: + continue + cv2.rectangle(image, (x, y), (x + w, y + h), (0, 0, 255), 2) + cv2.imwrite(output_path, image) + +def main(): + parser = argparse.ArgumentParser(description='Find regions of interest in an image using selective search.') + parser.add_argument('-i', '--image', default=DEFAULT_IMAGE_PATH, help='Path to input image') + parser.add_argument('-do', '--debug-output', help='Path to save debug image with rectangles') + parser.add_argument('--min-width', type=int, default=200, help='Minimum width of detected region') + parser.add_argument('--min-height', type=int, default=100, help='Minimum height of detected region') + parser.add_argument('--max-width', type=int, help='Maximum width of detected region') + parser.add_argument('--max-height', type=int, help='Maximum height of detected region') + parser.add_argument('--single', action='store_true', help='Only output the most likely (largest) region') + parser.add_argument('--quality', action='store_true', help='Use quality mode for selective search (slower, less sensitive)') + parser.add_argument('--k', type=int, default=3000, help='Segmentation parameter k (default: 150)') + parser.add_argument('--min-size', type=int, default=50, help='Segmentation parameter min_size (default: 20)') + parser.add_argument('--sigma', type=float, default=0.6, help='Segmentation parameter sigma (default: 0.8)') + parser.add_argument('--resize-factor', type=float, default=0.1, help='Resize factor for input image before processing (default: 1.0, e.g. 0.5 for half size)') + parser.add_argument('--hyprctl', action='store_true', help='Mimics hyprctl\'s window output, like {"at": [x, y], "size": [w, h]}') + args = parser.parse_args() + + regions, image = find_regions( + args.image, + min_width=args.min_width, + min_height=args.min_height, + max_width=args.max_width, + max_height=args.max_height, + quality=args.quality, + k=args.k, + min_size=args.min_size, + sigma=args.sigma, + resize_factor=args.resize_factor + ) + if args.single and regions: + largest = max(regions, key=lambda r: r['width'] * r['height']) + regions = [largest] + if args.hyprctl: + regions = [{"at": [r['x'], r['y']], "size": [r['width'], r['height']]} for r in regions] + print(json.dumps(regions)) + if args.debug_output: + draw_regions(image, regions, args.debug_output) + +if __name__ == '__main__': + main() + diff --git a/ii/scripts/images/least_busy_region.py b/ii/scripts/images/least_busy_region.py new file mode 100755 index 0000000..858ddc5 --- /dev/null +++ b/ii/scripts/images/least_busy_region.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +# Disclaimer: This script was ai-generated and went through minimal revision. + +import os +os.environ["OPENCV_LOG_LEVEL"] = "SILENT" +import cv2 +import numpy as np +import argparse +import json + +def center_crop(img, target_w, target_h): + h, w = img.shape[:2] + if w == target_w and h == target_h: + return img + x1 = max(0, (w - target_w) // 2) + y1 = max(0, (h - target_h) // 2) + x2 = x1 + target_w + y2 = y1 + target_h + return img[y1:y2, x1:x2] + +def find_least_busy_region(image_path, region_width=300, region_height=200, screen_width=None, screen_height=None, verbose=False, stride=2, screen_mode="fill", horizontal_padding=50, vertical_padding=50): + img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) + if img is None: + raise FileNotFoundError(f"Image not found: {image_path}") + orig_h, orig_w = img.shape + scale = 1.0 + if screen_width is not None and screen_height is not None: + scale_w = screen_width / orig_w + scale_h = screen_height / orig_h + if screen_mode == "fill": + scale = max(scale_w, scale_h) + else: + scale = min(scale_w, scale_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + if verbose: + print(f"Scaling image from {orig_w}x{orig_h} to {new_w}x{new_h} (scale: {scale:.3f}, mode: {screen_mode})") + img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + img = center_crop(img, screen_width, screen_height) + if verbose: + print(f"Cropped image to {screen_width}x{screen_height}") + else: + if verbose: + print(f"Using original image size: {orig_w}x{orig_h}") + arr = img.astype(np.float64) + h, w = arr.shape + # Validate & adjust stride + stride = max(1, int(stride) if stride else 1) + # Adjust region size if it does not fit given padding + if horizontal_padding * 2 >= w or vertical_padding * 2 >= h: + # Reduce padding to fit at least a 1x1 region + horizontal_padding = max(0, min(horizontal_padding, (w - 1) // 2)) + vertical_padding = max(0, min(vertical_padding, (h - 1) // 2)) + max_region_w = w - 2 * horizontal_padding + max_region_h = h - 2 * vertical_padding + if max_region_w <= 0 or max_region_h <= 0: + raise ValueError("Image too small for the specified padding.") + if region_width > max_region_w: + if verbose: + print(f"Requested region_width {region_width} too large; clamping to {max_region_w}") + region_width = max_region_w + if region_height > max_region_h: + if verbose: + print(f"Requested region_height {region_height} too large; clamping to {max_region_h}") + region_height = max_region_h + # Use OpenCV's integral for fast computation + integral = cv2.integral(arr, sdepth=cv2.CV_64F)[1:,1:] + integral_sq = cv2.integral(arr**2, sdepth=cv2.CV_64F)[1:,1:] + def region_sum(ii, x1, y1, x2, y2): + # Assume bounds have been checked before calling + total = ii[y2, x2] + if x1 > 0: + total -= ii[y2, x1-1] + if y1 > 0: + total -= ii[y1-1, x2] + if x1 > 0 and y1 > 0: + total += ii[y1-1, x1-1] + return total + min_var = None + min_coords = (horizontal_padding, vertical_padding) + area = region_width * region_height + x_start = horizontal_padding + y_start = vertical_padding + x_end = w - region_width - horizontal_padding + 1 + y_end = h - region_height - vertical_padding + 1 + if x_end < x_start: + x_end = x_start + if y_end < y_start: + y_end = y_start + for y in range(y_start, y_end + 1, stride): + for x in range(x_start, x_end + 1, stride): + x1, y1 = x, y + x2, y2 = x + region_width - 1, y + region_height - 1 + if x2 >= w or y2 >= h: + continue # Skip out-of-bounds window + s = region_sum(integral, x1, y1, x2, y2) + s2 = region_sum(integral_sq, x1, y1, x2, y2) + mean = s / area + var = (s2 / area) - (mean ** 2) + if (min_var is None) or (var < min_var): + min_var = var + min_coords = (x, y) + return min_coords, min_var + +def find_largest_region(image_path, screen_width=None, screen_height=None, verbose=False, stride=2, screen_mode="fill", threshold=100.0, aspect_ratio=1.0, horizontal_padding=50, vertical_padding=50): + img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) + if img is None: + raise FileNotFoundError(f"Image not found: {image_path}") + orig_h, orig_w = img.shape + # ...existing scaling logic... + scale = 1.0 + if screen_width is not None and screen_height is not None: + scale_w = screen_width / orig_w + scale_h = screen_height / orig_h + if screen_mode == "fill": + scale = max(scale_w, scale_h) + else: + scale = min(scale_w, scale_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + if verbose: + print(f"Scaling image from {orig_w}x{orig_h} to {new_w}x{new_h} (scale: {scale:.3f}, mode: {screen_mode})") + img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + img = center_crop(img, screen_width, screen_height) + if verbose: + print(f"Cropped image to {screen_width}x{screen_height}") + else: + if verbose: + print(f"Using original image size: {orig_w}x{orig_h}") + arr = img.astype(np.float64) + h, w = arr.shape + stride = max(1, int(stride) if stride else 1) + threshold = max(0.0, float(threshold)) + # Adjust padding if image too small + if horizontal_padding * 2 >= w or vertical_padding * 2 >= h: + horizontal_padding = max(0, min(horizontal_padding, (w - 1) // 2)) + vertical_padding = max(0, min(vertical_padding, (h - 1) // 2)) + # Use OpenCV's integral for fast computation + integral = cv2.integral(arr, sdepth=cv2.CV_64F)[1:,1:] + integral_sq = cv2.integral(arr**2, sdepth=cv2.CV_64F)[1:,1:] + def region_sum(ii, x1, y1, x2, y2): + total = ii[y2, x2] + if x1 > 0: + total -= ii[y2, x1-1] + if y1 > 0: + total -= ii[y1-1, x2] + if x1 > 0 and y1 > 0: + total += ii[y1-1, x1-1] + return total + min_size = 10 + # Determine maximum feasible size respecting padding + effective_w = w - 2 * horizontal_padding + effective_h = h - 2 * vertical_padding + if effective_w <= 0 or effective_h <= 0: + return None, (0, 0), None + # Largest square-ish dimension given aspect ratio and effective space + if aspect_ratio >= 1.0: + max_size = min(effective_h, int(effective_w / aspect_ratio)) + else: + max_size = min(int(effective_h * aspect_ratio), effective_w) + if max_size < min_size: + min_size = 1 + max_size = max(1, max_size) + best = None + while min_size <= max_size: + mid = (min_size + max_size) // 2 + if aspect_ratio >= 1.0: + region_h = mid + region_w = int(round(mid * aspect_ratio)) + else: + region_w = mid + region_h = int(round(mid / aspect_ratio if aspect_ratio != 0 else mid)) + if region_w <= 0 or region_h <= 0: + break + if region_w > effective_w or region_h > effective_h: + max_size = mid - 1 + continue + found = False + x_start = horizontal_padding + y_start = vertical_padding + x_end = w - region_w - horizontal_padding + y_end = h - region_h - vertical_padding + for y in range(y_start, y_end + 1, stride): + for x in range(x_start, x_end + 1, stride): + x1, y1 = x, y + x2, y2 = x + region_w - 1, y + region_h - 1 + if x2 >= w or y2 >= h: + continue + s = region_sum(integral, x1, y1, x2, y2) + s2 = region_sum(integral_sq, x1, y1, x2, y2) + area = region_w * region_h + mean = s / area + var = (s2 / area) - (mean ** 2) + if var <= threshold: + found = True + best = (x, y, region_w, region_h, var) + break + if found: + break + if found: + min_size = mid + 1 + else: + max_size = mid - 1 + if best: + x, y, region_w, region_h, var = best + center_x = x + region_w // 2 + center_y = y + region_h // 2 + return (center_x, center_y), (region_w, region_h), var + else: + return None, (0, 0), None + +def draw_region(image_path, coords, region_width=300, region_height=200, output_path='output.png', screen_width=None, screen_height=None, screen_mode="fill"): + img = cv2.imread(image_path) + if img is None: + raise FileNotFoundError(f"Image not found: {image_path}") + orig_h, orig_w = img.shape[:2] + if screen_width is not None and screen_height is not None: + scale_w = screen_width / orig_w + scale_h = screen_height / orig_h + if screen_mode == "fill": + scale = max(scale_w, scale_h) + else: + scale = min(scale_w, scale_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + img = center_crop(img, screen_width, screen_height) + x, y = coords + cv2.rectangle(img, (x, y), (x+region_width-1, y+region_height-1), (0,0,255), 3) + cv2.imwrite(output_path, img) + # print removed for quieter operation + +def draw_largest_region(image_path, center, size, output_path='output.png', screen_width=None, screen_height=None, screen_mode="fill"): + img = cv2.imread(image_path) + if img is None: + raise FileNotFoundError(f"Image not found: {image_path}") + orig_h, orig_w = img.shape[:2] + if screen_width is not None and screen_height is not None: + scale_w = screen_width / orig_w + scale_h = screen_height / orig_h + if screen_mode == "fill": + scale = max(scale_w, scale_h) + else: + scale = min(scale_w, scale_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + img = center_crop(img, screen_width, screen_height) + cx, cy = center + region_w, region_h = size + x1 = cx - region_w // 2 + y1 = cy - region_h // 2 + x2 = cx + region_w // 2 - 1 + y2 = cy + region_h // 2 - 1 + cv2.rectangle(img, (x1, y1), (x2, y2), (255,0,0), 3) + cv2.imwrite(output_path, img) + # print removed for quieter operation + +def get_dominant_color(image_path, x, y, w, h, screen_width=None, screen_height=None, screen_mode="fill"): + img = cv2.imread(image_path) + if img is None: + raise FileNotFoundError(f"Image not found: {image_path}") + orig_h, orig_w = img.shape[:2] + if screen_width is not None and screen_height is not None: + scale_w = screen_width / orig_w + scale_h = screen_height / orig_h + if screen_mode == "fill": + scale = max(scale_w, scale_h) + else: + scale = min(scale_w, scale_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + img = center_crop(img, screen_width, screen_height) + # Ensure region is within bounds + x = max(0, x) + y = max(0, y) + w = max(1, min(w, img.shape[1] - x)) + h = max(1, min(h, img.shape[0] - y)) + region = img[y:y+h, x:x+w] + if region.size == 0 or region.shape[0] == 0 or region.shape[1] == 0: + return [0, 0, 0] + region = region.reshape((-1, 3)) + # Filter out black pixels (optional, improves accuracy for some images) + non_black = region[np.any(region > 10, axis=1)] + if non_black.shape[0] == 0: + non_black = region + region = np.float32(non_black) + if region.shape[0] < 3: + return [int(x) for x in np.mean(region, axis=0)] + # K-means to find dominant color + criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0) + K = min(3, region.shape[0]) + _, labels, centers = cv2.kmeans(region, K, None, criteria, 10, cv2.KMEANS_RANDOM_CENTERS) + counts = np.bincount(labels.flatten()) + dominant = centers[np.argmax(counts)] + return [int(x) for x in dominant] + +def main(): + parser = argparse.ArgumentParser(description="Find least busy region in an image and output a JSON. Made for determining a suitable position for a wallpaper widget.") + parser.add_argument("image_path", help="Path to the input image") + parser.add_argument("--width", type=int, default=300, help="Region width") + parser.add_argument("--height", type=int, default=200, help="Region height") + parser.add_argument("-v", "--visual-output", action="store_true", help="Output image with rectangle") + parser.add_argument("--screen-width", type=int, default=1920, help="Screen width for wallpaper scaling") + parser.add_argument("--screen-height", type=int, default=1080, help="Screen height for wallpaper scaling") + parser.add_argument("--stride", type=int, default=10, help="Step size for sliding window (higher is faster, less precise)") + parser.add_argument("--screen-mode", choices=["fill", "fit"], default="fill", help="Wallpaper scaling mode: 'fill' (default) or 'fit'") + parser.add_argument("--verbose", action="store_true", help="Print verbose output") + parser.add_argument("-l", "--largest-region", action="store_true", help="Find the largest region under the variance threshold and output its center") + parser.add_argument("-t", "--variance-threshold", type=float, default=1000.0, help="Variance threshold for largest region mode") + parser.add_argument("--aspect-ratio", type=float, default=1.78, help="Aspect ratio (width/height) for largest region mode") + parser.add_argument("--horizontal-padding", "-hp", type=int, default=50, help="Minimum horizontal distance from region to image edge") + parser.add_argument("--vertical-padding", "-vp", type=int, default=50, help="Minimum vertical distance from region to image edge") + args = parser.parse_args() + + if args.largest_region: + center, size, var = find_largest_region( + args.image_path, + screen_width=args.screen_width, + screen_height=args.screen_height, + verbose=args.verbose, + stride=args.stride, + screen_mode=args.screen_mode, + threshold=args.variance_threshold, + aspect_ratio=args.aspect_ratio, + horizontal_padding=args.horizontal_padding, + vertical_padding=args.vertical_padding + ) + if center: + if args.visual_output: + draw_largest_region(args.image_path, center, size, screen_width=args.screen_width, screen_height=args.screen_height, screen_mode=args.screen_mode) + # Extract dominant color + cx, cy = center + region_w, region_h = size + x1 = cx - region_w // 2 + y1 = cy - region_h // 2 + dominant_color = get_dominant_color( + args.image_path, x1, y1, region_w, region_h, + screen_width=args.screen_width, screen_height=args.screen_height, screen_mode=args.screen_mode + ) + dominant_color_hex = '#{:02x}{:02x}{:02x}'.format(*dominant_color) + print(json.dumps({ + "center_x": center[0], + "center_y": center[1], + "width": size[0], + "height": size[1], + "variance": var, + "dominant_color": dominant_color_hex + })) + else: + print(json.dumps({"error": "No region found under the threshold."})) + return + + coords, variance = find_least_busy_region( + args.image_path, + region_width=args.width, + region_height=args.height, + screen_width=args.screen_width, + screen_height=args.screen_height, + verbose=args.verbose, + stride=args.stride, + screen_mode=args.screen_mode, + horizontal_padding=args.horizontal_padding, + vertical_padding=args.vertical_padding + ) + if args.visual_output: + draw_region(args.image_path, coords, region_width=args.width, region_height=args.height, screen_width=args.screen_width, screen_height=args.screen_height, screen_mode=args.screen_mode) + # Output JSON with center point + center_x = coords[0] + args.width // 2 + center_y = coords[1] + args.height // 2 + dominant_color = get_dominant_color( + args.image_path, coords[0], coords[1], args.width, args.height, + screen_width=args.screen_width, screen_height=args.screen_height, screen_mode=args.screen_mode + ) + dominant_color_hex = '#{:02x}{:02x}{:02x}'.format(*dominant_color) + print(json.dumps({ + "center_x": center_x, + "center_y": center_y, + "width": args.width, + "height": args.height, + "variance": variance, + "dominant_color": dominant_color_hex + })) + +if __name__ == "__main__": + main() + diff --git a/ii/scripts/kvantum/adwsvg.py b/ii/scripts/kvantum/adwsvg.py new file mode 100644 index 0000000..10ce1d1 --- /dev/null +++ b/ii/scripts/kvantum/adwsvg.py @@ -0,0 +1,79 @@ +import re +import os + +def read_scss(file_path): + """Reads an SCSS file and returns a dictionary of color variables.""" + colors = {} + with open(file_path, 'r') as file: + for line in file: + match = re.match(r'\$(\w+):\s*(#[0-9A-Fa-f]{6});', line.strip()) + if match: + variable_name, color = match.groups() + colors[variable_name] = color + return colors + +def update_svg_colors(svg_path, old_to_new_colors, output_path): + """ + Updates the colors in an SVG file based on the provided color map. + + :param svg_path: Path to the SVG file. + :param old_to_new_colors: Dictionary mapping old colors to new colors. + :param output_path: Path to save the updated SVG file. + """ + # Read the SVG content + with open(svg_path, 'r') as file: + svg_content = file.read() + + # Replace old colors with new colors + for old_color, new_color in old_to_new_colors.items(): + svg_content = re.sub(old_color, new_color, svg_content, flags=re.IGNORECASE) + + # Write the updated SVG content to the output file + with open(output_path, 'w') as file: + file.write(svg_content) + + print(f"SVG colors have been updated and saved to {output_path}!") + +def main(): + xdg_config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) + xdg_state_home = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) + + scss_file = os.path.join(xdg_state_home, "quickshell", "user", "generated", "material_colors.scss") + svg_path = os.path.join(xdg_config_home, "Kvantum", "Colloid", "Colloid.svg") + output_path = os.path.join(xdg_config_home, "Kvantum", "MaterialAdw", "MaterialAdw.svg") + + # Read colors from the SCSS file + color_data = read_scss(scss_file) + + # Specify the old colors and map them to new colors from the SCSS file + old_to_new_colors = { + #'#cccccc': color_data['surfaceDim'], # Map old SVG color to new SCSS color + #'#666666': color_data['surfaceDim'], + '#3c84f7': color_data['primary'], + #'#5a5a5a': color_data['neutral_paletteKeyColor'], + '#000000': color_data['shadow'], + '#f04a50': color_data['error'], + '#4285f4': color_data['primaryFixedDim'], + '#f2f2f2': color_data['background'], + #'#dfdfdf': color_data['surfaceContainerLow'], + '#ffffff': color_data['background'], + '#1e1e1e': color_data['onPrimaryFixed'], + #'#b6b6b6': color_data['surfaceContainer'], + '#333': color_data['inverseSurface'], + '#212121': color_data['onSecondaryFixed'], + '#5b9bf8': color_data['secondaryContainer'], + '#26272a': color_data['term7'], + #'#b3b3b3': color_data['surfaceBright'], + #'#b74aff': color_data['tertiary'], + #'#989898': color_data['surfaceContainerHighest'], + #'#c1c1c1': color_data['surfaceContainerHigh'], + '#444444': color_data['onBackground'], + '#333333': color_data['onPrimaryFixed'], + } + + # Update the SVG colors + update_svg_colors(svg_path, old_to_new_colors, output_path) + +if __name__ == "__main__": + main() + diff --git a/ii/scripts/kvantum/adwsvgDark.py b/ii/scripts/kvantum/adwsvgDark.py new file mode 100644 index 0000000..9fb0977 --- /dev/null +++ b/ii/scripts/kvantum/adwsvgDark.py @@ -0,0 +1,87 @@ +import re +import os + +def read_scss(file_path): + """Reads an SCSS file and returns a dictionary of color variables.""" + colors = {} + with open(file_path, 'r') as file: + for line in file: + match = re.match(r'\$(\w+):\s*(#[0-9A-Fa-f]{6});', line.strip()) + if match: + variable_name, color = match.groups() + colors[variable_name] = color + return colors + +def update_svg_colors(svg_path, old_to_new_colors, output_path): + """ + Updates the colors in an SVG file based on the provided color map. + + :param svg_path: Path to the SVG file. + :param old_to_new_colors: Dictionary mapping old colors to new colors. + :param output_path: Path to save the updated SVG file. + """ + # Read the SVG content + with open(svg_path, 'r') as file: + svg_content = file.read() + + # Replace old colors with new colors + for old_color, new_color in old_to_new_colors.items(): + svg_content = re.sub(old_color, new_color, svg_content, flags=re.IGNORECASE) + + # Write the updated SVG content to the output file + with open(output_path, 'w') as file: + file.write(svg_content) + + print(f"SVG colors have been updated and saved to {output_path}!") + +def main(): + xdg_config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) + xdg_state_home = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) + + scss_file = os.path.join(xdg_state_home, "quickshell", "user", "generated", "material_colors.scss") + svg_path = os.path.join(xdg_config_home, "Kvantum", "Colloid", "ColloidDark.svg") + output_path = os.path.join(xdg_config_home, "Kvantum", "MaterialAdw", "MaterialAdw.svg") + + # Read colors from the SCSS file + color_data = read_scss(scss_file) + + # Specify the old colors and map them to new colors from the SCSS file + old_to_new_colors = { + #'#525252': color_data['surfaceDim'], # Map old SVG color to new SCSS color + #'#666666': color_data['surfaceDim'], + '#31363b': color_data['background'], + #'#eff0f1': color_data['neutral_paletteKeyColor'], + '#000000': color_data['shadow'], + '#5b9bf8': color_data['primary'], + '#93cee9': color_data['onSecondaryContainer'], + '#3daee9': color_data['secondary'], + #'#fff': color_data['term10'], + #'#5a5a5a': color_data['surfaceVariant'], + #'#acb1bc': color_data['onPrimaryFixed'], + '#ffffff': color_data['term11'], + '#5a616e': color_data['surfaceVariant'], + '#f04a50': color_data['error'], + '#4285f4': color_data['secondary'], + '#242424': color_data['background'], + '#2c2c2c': color_data['background'], + #'#dfdfdf': color_data['onSurfaceVariant'], + #'#646464': color_data['surfaceContainerHighest'], + #'#989898': color_data['surfaceContainerHigh'], + #'#c1c1c1': color_data['primaryFixedDim'], + '#1e1e1e': color_data['background'], + '#3c3c3c': color_data['background'], + '#26272a': color_data['surfaceBright'], + '#000000': color_data['shadow'], + '#b74aff': color_data['tertiary'], + #'#b6b6b6': color_data['onSurfaceVariant'], + '#1a1a1a': color_data['background'], + '#333': color_data['term0'], + '#212121': color_data['background'], + } + + # Update the SVG colors + update_svg_colors(svg_path, old_to_new_colors, output_path) + +if __name__ == "__main__": + main() + diff --git a/ii/scripts/kvantum/changeAdwColors.py b/ii/scripts/kvantum/changeAdwColors.py new file mode 100644 index 0000000..26d067a --- /dev/null +++ b/ii/scripts/kvantum/changeAdwColors.py @@ -0,0 +1,71 @@ +import re +import os + +def get_colors_from_scss(scss_file): + colors = {} + with open(scss_file, 'r') as file: + for line in file: + match = re.match(r'\$(\w+):\s*(#[0-9A-Fa-f]{6});', line) + if match: + colors[match.group(1)] = match.group(2) + return colors + +def update_config_colors(config_file, colors, mappings): + with open(config_file, 'r') as file: + config_content = file.read() + + for key, variable in mappings.items(): + if variable in colors: + color = colors[variable] + pattern = rf'({key}=)#?\w+\b' + new_line = f'\\1{color}' + if re.search(pattern, config_content): + config_content = re.sub(pattern, new_line, config_content) + else: + config_content += f"\n{key}={color}" + + with open(config_file, 'w') as file: + file.write(config_content) + +if __name__ == "__main__": + xdg_config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) + xdg_state_home = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) + + config_file = os.path.join(xdg_config_home, "Kvantum", "MaterialAdw", "MaterialAdw.kvconfig") + scss_file = os.path.join(xdg_state_home, "quickshell", "user", "generated", "material_colors.scss") + + # Define your mappings here + mappings = { + 'window.color': 'background', + 'base.color': 'background', + 'alt.base.color': 'background', + 'button.color': 'surfaceContainer', + 'light.color': 'surfaceContainerLow', + 'mid.light.color': 'surfaceContainer', + 'dark.color': 'surfaceContainerHighest', + 'mid.color': 'surfaceContainerHigh', + 'highlight.color': 'primary', + 'inactive.highlight.color': 'primary', + 'text.color': 'onBackground', + 'window.text.color': 'onBackground', + 'button.text.color': 'onBackground', + 'disabled.text.color': 'onBackground', + 'tooltip.text.color': 'onBackground', + 'highlight.text.color': 'onSurface', + 'link.color': 'tertiary', + 'link.visited.color': 'tertiaryFixed', + 'progress.indicator.text.color': 'onBackground', + 'text.normal.color': 'onBackground', + 'text.focus.color': 'onBackground', + 'text.press.color': 'onsecondarycontainer', + 'text.toggle.color': 'onsecondarycontainer', + 'text.disabled.color': 'surfaceDim', + + + # Add more mappings as needed + } + + colors = get_colors_from_scss(scss_file) + update_config_colors(config_file, colors, mappings) + print("Config colors updated successfully!") + diff --git a/ii/scripts/kvantum/materialQT.sh b/ii/scripts/kvantum/materialQT.sh new file mode 100755 index 0000000..a049c55 --- /dev/null +++ b/ii/scripts/kvantum/materialQT.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +QUICKSHELL_CONFIG_NAME="ii" +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" +CONFIG_DIR="$XDG_CONFIG_HOME/quickshell/$QUICKSHELL_CONFIG_NAME" +CACHE_DIR="$XDG_CACHE_HOME/quickshell" +STATE_DIR="$XDG_STATE_HOME/quickshell" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +get_light_dark() { + current_mode=$(gsettings get org.gnome.desktop.interface color-scheme 2>/dev/null | tr -d "'") + if [[ "$current_mode" == "prefer-dark" ]]; then + echo "dark" + else + echo "light" + fi +} + +apply_qt() { + # Check if the theme exists + FOLDER_PATH="$XDG_CONFIG_HOME/Kvantum/Colloid/" + + if [ ! -d "$FOLDER_PATH" ]; then + # Send a notification + notify-send "Colloid-kde theme required" " The folder '$FOLDER_PATH' does not exist." + exit 1 # Exit the function if the folder does not exist + fi + + lightdark=$(get_light_dark) + if [ "$lightdark" = "light" ]; then + # apply ligght colors + cp "$XDG_CONFIG_HOME/Kvantum/Colloid/Colloid.kvconfig" "$XDG_CONFIG_HOME/Kvantum/MaterialAdw/MaterialAdw.kvconfig" + python "$CONFIG_DIR/scripts/kvantum/adwsvg.py" + + else + #apply dark colors + cp "$XDG_CONFIG_HOME/Kvantum/Colloid/ColloidDark.kvconfig" "$XDG_CONFIG_HOME/Kvantum/MaterialAdw/MaterialAdw.kvconfig" + python "$CONFIG_DIR/scripts/kvantum/adwsvgDark.py" + fi +} + +apply_qt diff --git a/ii/scripts/thumbnails/generate-thumbnails-magick.sh b/ii/scripts/thumbnails/generate-thumbnails-magick.sh new file mode 100755 index 0000000..a5c8581 --- /dev/null +++ b/ii/scripts/thumbnails/generate-thumbnails-magick.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash + +# Generate thumbnails for files using ImageMagick, following Freedesktop spec +# Usage: +# ./generate-thumbnails-magick.sh --file +# ./generate-thumbnails-magick.sh --directory + +set -e + +# Thumbnail sizes mapping +get_thumbnail_size() { + case "$1" in + normal) echo 128 ;; + large) echo 256 ;; + x-large) echo 512 ;; + xx-large) echo 1024 ;; + *) echo 128 ;; + esac +} + +usage() { + echo "Usage: $0 --file | --directory " + exit 1 +} + +md5() { + # Calculate md5 hash of the file's absolute path + echo -n "$1" | md5sum | awk '{print $1}' +} + +urlencode() { + # Percent-encode a string for use in a URI, but do not encode slashes + local str="$1" + local encoded="" + local c + for ((i=0; i<${#str}; i++)); do + c="${str:$i:1}" + case "$c" in + [a-zA-Z0-9.~_-]|/) encoded+="$c" ;; + *) printf -v hex '%%%02X' "'${c}'"; encoded+="$hex" ;; + esac + done + echo "$encoded" +} + +generate_thumbnail() { + local src="$1" + local abs_path + abs_path="$(realpath "$src")" + # Skip files with multiple frames (GIFs, videos, etc.) + case "${abs_path,,}" in + *.gif|*.mp4|*.webm|*.mkv|*.avi|*.mov) + return + ;; + esac + local encoded_path + encoded_path="$(urlencode "$abs_path")" + local uri + uri="file://$encoded_path" + local hash + hash="$(md5 "$uri")" + local out="$CACHE_DIR/$hash.png" + mkdir -p "$CACHE_DIR" + if [ -f "$out" ]; then + return + fi + magick "$abs_path" -resize "${THUMBNAIL_SIZE}x${THUMBNAIL_SIZE}" "$out" +} + +# Parse arguments +SIZE_NAME="normal" +MODE="" +TARGET="" +while [[ $# -gt 0 ]]; do + case "$1" in + --file|-f) + MODE="file" + TARGET="$2" + shift 2 + ;; + --directory|-d) + MODE="dir" + TARGET="$2" + shift 2 + ;; + --size|-s) + SIZE_NAME="$2" + shift 2 + ;; + *) + usage + ;; + esac + # Only one mode allowed + [[ -n "$MODE" ]] && break +done + +THUMBNAIL_SIZE="$(get_thumbnail_size "$SIZE_NAME")" +CACHE_DIR="$HOME/.cache/thumbnails/$SIZE_NAME" + +if [ -z "$MODE" ] || [ -z "$TARGET" ]; then + usage +fi + +case "$MODE" in + file) + if [ ! -f "$TARGET" ]; then + echo "File not found: $TARGET" + exit 2 + fi + generate_thumbnail "$TARGET" + ;; + dir) + if [ ! -d "$TARGET" ]; then + echo "Directory not found: $TARGET" + exit 2 + fi + for f in "$TARGET"/*; do + [ -f "$f" ] || continue + generate_thumbnail "$f" & + done + wait + ;; + *) + usage + ;; +esac + diff --git a/ii/scripts/thumbnails/thumbgen.py b/ii/scripts/thumbnails/thumbgen.py new file mode 100755 index 0000000..4d6326a --- /dev/null +++ b/ii/scripts/thumbnails/thumbgen.py @@ -0,0 +1,119 @@ +#!/usr/bin/env -S\_/bin/sh\_-c\_"source\_\$(eval\_echo\_\$ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate&&exec\_python\_-E\_"\$0"\_"\$@"" + +# From https://github.com/difference-engine/thumbnail-generator-ubuntu (MIT License) +# Since the script is small and the maintainers seem inactive to accept my PR (#11) I decided to just copy it over. +# When it gets merged and the python package gets updated we can just use it + +import os +import sys +from multiprocessing import Pool +from pathlib import Path +from typing import List, Union + +import click +import gi +from loguru import logger +from tqdm import tqdm + +gi.require_version("GnomeDesktop", "3.0") +from gi.repository import Gio, GnomeDesktop # isort:skip + +thumbnail_size_map = { + "normal": GnomeDesktop.DesktopThumbnailSize.NORMAL, + "large": GnomeDesktop.DesktopThumbnailSize.LARGE, + "x-large": GnomeDesktop.DesktopThumbnailSize.XLARGE, + "xx-large": GnomeDesktop.DesktopThumbnailSize.XXLARGE, +} + +factory = None +logger.remove() +logger.add(sys.stdout, level="INFO") +logger.add("/tmp/thumbgen.log", level="DEBUG", rotation="100 MB") + +def make_thumbnail(fpath: str) -> bool: + mtime = os.path.getmtime(fpath) + # Use Gio to determine the URI and mime type + f = Gio.file_new_for_path(str(fpath)) + uri = f.get_uri() + info = f.query_info("standard::content-type", Gio.FileQueryInfoFlags.NONE, None) + mime_type = info.get_content_type() + + if factory.lookup(uri, mtime) is not None: + logger.debug("FRESH {}".format(uri)) + return False + + if not factory.can_thumbnail(uri, mime_type, mtime): + logger.debug("UNSUPPORTED {}".format(uri)) + return False + + thumbnail = factory.generate_thumbnail(uri, mime_type) + if thumbnail is None: + logger.debug("ERROR {}".format(uri)) + return False + + logger.debug("OK {}".format(uri)) + factory.save_thumbnail(thumbnail, uri, mtime) + return True + + +@logger.catch() +def thumbnail_folder(*, dir_path: Path, workers: int, only_images: bool, recursive: bool, machine_progress: bool = False) -> None: + all_files = get_all_files(dir_path=dir_path, recursive=recursive) + if only_images: + all_files = get_all_images(all_files=all_files) + all_files = [str(fpath) for fpath in all_files] + if machine_progress: + completed = 0 + total = len(all_files) + with Pool(processes=workers) as p: + for result in p.imap(make_thumbnail, all_files): + completed += 1 + print(f"PROGRESS {completed}/{total} FILE {all_files[completed-1]}") + sys.stdout.flush() + else: + with Pool(processes=workers) as p: + list(tqdm(p.imap(make_thumbnail, all_files), total=len(all_files))) + + +def get_all_images(*, all_files: List[Path]) -> List[Path]: + img_suffixes = [".jpg", ".jpeg", ".png", ".gif"] + all_images = [fpath for fpath in all_files if fpath.suffix in img_suffixes] + print("Found {} images".format(len(all_images))) + return all_images + + +def get_all_files(*, dir_path: Path, recursive: bool) -> List[Path]: + if not (dir_path.exists() and dir_path.is_dir()): + raise ValueError("{} doesn't exist or isn't a valid directory!".format(dir_path.resolve())) + if recursive: + all_files = dir_path.rglob("*") + else: + all_files = dir_path.glob("*") + all_files = [fpath for fpath in all_files if fpath.is_file()] + print("Found {} files in the directory: {}".format(len(all_files), dir_path.resolve())) + return all_files + +@click.command() +@click.option( + "-d", "--img_dirs", required=True, help='directories to generate thumbnails seperated by space, eg: "dir1/dir2 dir3"' +) +@click.option( + "-s", "--size", default="normal", type=click.Choice(["normal", "large", "x-large", "xx-large"]), help="Thumbnail size: normal, large, x-large, xx-large" +) +@click.option("-w", "--workers", default=1, help="no of cpus to use for processing") +@click.option( + "-i", "--only_images", is_flag=True, default=False, help="Whether to only look for images to be thumbnailed" +) +@click.option("-r", "--recursive", is_flag=True, default=False, help="Whether to recursively look for files") +@click.option("--machine_progress", is_flag=True, default=False, help="Print machine-readable progress lines instead of a progress bar") +def main(img_dirs: str, size: str, workers: str, only_images: bool, recursive: bool, machine_progress: bool) -> None: + img_dirs = [Path(img_dir) for img_dir in img_dirs.split()] + global factory + factory = GnomeDesktop.DesktopThumbnailFactory.new(thumbnail_size_map[size]) + for img_dir in img_dirs: + thumbnail_folder(dir_path=img_dir, workers=workers, only_images=only_images, recursive=recursive, machine_progress=machine_progress) + print("Thumbnail Generation Completed!") + + +if __name__ == "__main__": + main() diff --git a/ii/scripts/wayland-idle-inhibitor.py b/ii/scripts/wayland-idle-inhibitor.py new file mode 100755 index 0000000..9bdaabb --- /dev/null +++ b/ii/scripts/wayland-idle-inhibitor.py @@ -0,0 +1,86 @@ +#!/usr/bin/env -S\_/bin/sh\_-xc\_"source\_\$(eval\_echo\_\$ILLOGICAL_IMPULSE_VIRTUAL_ENV)/bin/activate&&exec\_python\_-E\_"\$0"\_"\$@"" + +# From https://github.com/stwa/wayland-idle-inhibitor +# License: WTFPL Version 2 + +import sys +from dataclasses import dataclass +from signal import SIGINT, SIGTERM, signal +from threading import Event +import setproctitle + +from pywayland.client.display import Display +from pywayland.protocol.idle_inhibit_unstable_v1.zwp_idle_inhibit_manager_v1 import ( + ZwpIdleInhibitManagerV1, +) +from pywayland.protocol.wayland.wl_compositor import WlCompositor +from pywayland.protocol.wayland.wl_registry import WlRegistryProxy +from pywayland.protocol.wayland.wl_surface import WlSurface + + +@dataclass +class GlobalRegistry: + surface: WlSurface | None = None + inhibit_manager: ZwpIdleInhibitManagerV1 | None = None + + +def handle_registry_global( + wl_registry: WlRegistryProxy, id_num: int, iface_name: str, version: int +) -> None: + global_registry: GlobalRegistry = wl_registry.user_data or GlobalRegistry() + + if iface_name == "wl_compositor": + compositor = wl_registry.bind(id_num, WlCompositor, version) + global_registry.surface = compositor.create_surface() # type: ignore + elif iface_name == "zwp_idle_inhibit_manager_v1": + global_registry.inhibit_manager = wl_registry.bind( + id_num, ZwpIdleInhibitManagerV1, version + ) + + +def main() -> None: + done = Event() + signal(SIGINT, lambda _, __: done.set()) + signal(SIGTERM, lambda _, __: done.set()) + + global_registry = GlobalRegistry() + + display = Display() + display.connect() + + registry = display.get_registry() # type: ignore + registry.user_data = global_registry + registry.dispatcher["global"] = handle_registry_global + + def shutdown() -> None: + display.dispatch() + display.roundtrip() + display.disconnect() + + display.dispatch() + display.roundtrip() + + if global_registry.surface is None or global_registry.inhibit_manager is None: + print("Wayland seems not to support idle_inhibit_unstable_v1 protocol.") + shutdown() + sys.exit(1) + + inhibitor = global_registry.inhibit_manager.create_inhibitor( # type: ignore + global_registry.surface + ) + + display.dispatch() + display.roundtrip() + + print("Inhibiting idle...") + done.wait() + print("Shutting down...") + + inhibitor.destroy() + + shutdown() + + +if __name__ == "__main__": + setproctitle.setproctitle("wayland-idle-inhibitor.py") + main() diff --git a/ii/services/Ai.qml b/ii/services/Ai.qml new file mode 100644 index 0000000..058896d --- /dev/null +++ b/ii/services/Ai.qml @@ -0,0 +1,985 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common.functions as CF +import qs.modules.common +import qs +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import QtQuick +import "./ai/" + +/** + * Basic service to handle LLM chats. Supports Google's and OpenAI's API formats. + * Supports Gemini and OpenAI models. + * Limitations: + * - For now functions only work with Gemini API format + */ +Singleton { + id: root + + property Component aiMessageComponent: AiMessageData {} + property Component aiModelComponent: AiModel {} + property Component geminiApiStrategy: GeminiApiStrategy {} + property Component openaiApiStrategy: OpenAiApiStrategy {} + property Component mistralApiStrategy: MistralApiStrategy {} + readonly property string interfaceRole: "interface" + readonly property string apiKeyEnvVarName: "API_KEY" + + signal responseFinished() + + property string systemPrompt: { + let prompt = Config.options?.ai?.systemPrompt ?? ""; + for (let key in root.promptSubstitutions) { + // prompt = prompt.replaceAll(key, root.promptSubstitutions[key]); + // QML/JS doesn't support replaceAll, so use split/join + prompt = prompt.split(key).join(root.promptSubstitutions[key]); + } + return prompt; + } + // property var messages: [] + property var messageIDs: [] + property var messageByID: ({}) + readonly property var apiKeys: KeyringStorage.keyringData?.apiKeys ?? {} + readonly property var apiKeysLoaded: KeyringStorage.loaded + readonly property bool currentModelHasApiKey: { + const model = models[currentModelId]; + if (!model || !model.requires_key) return true; + if (!apiKeysLoaded) return false; + const key = apiKeys[model.key_id]; + return (key?.length > 0); + } + property var postResponseHook + property real temperature: Persistent.states?.ai?.temperature ?? 0.5 + property QtObject tokenCount: QtObject { + property int input: -1 + property int output: -1 + property int total: -1 + } + + function idForMessage(message) { + // Generate a unique ID using timestamp and random value + return Date.now().toString(36) + Math.random().toString(36).substr(2, 8); + } + + function safeModelName(modelName) { + return modelName.replace(/:/g, "_").replace(/ /g, "-").replace(/\//g, "-") + } + + property list defaultPrompts: [] + property list userPrompts: [] + property list promptFiles: [...defaultPrompts, ...userPrompts] + property list savedChats: [] + + property var promptSubstitutions: { + "{DISTRO}": SystemInfo.distroName, + "{DATETIME}": `${DateTime.time}, ${DateTime.collapsedCalendarFormat}`, + "{WINDOWCLASS}": ToplevelManager.activeToplevel?.appId ?? "Unknown", + "{DE}": `${SystemInfo.desktopEnvironment} (${SystemInfo.windowingSystem})` + } + + // Gemini: https://ai.google.dev/gemini-api/docs/function-calling + // OpenAI: https://platform.openai.com/docs/guides/function-calling + property string currentTool: Config?.options.ai.tool ?? "search" + property var tools: { + "gemini": { + "functions": [{"functionDeclarations": [ + { + "name": "switch_to_search_mode", + "description": "Search the web", + }, + { + "name": "get_shell_config", + "description": "Get the desktop shell config file contents", + }, + { + "name": "set_shell_config", + "description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.", + "parameters": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.", + }, + "value": { + "type": "string", + "description": "The value to set, e.g. `true`" + } + }, + "required": ["key", "value"] + } + }, + { + "name": "run_shell_command", + "description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run", + }, + }, + "required": ["command"] + } + }, + ]}], + "search": [{ + "google_search": {} + }], + "none": [] + }, + "openai": { + "functions": [ + { + "type": "function", + "function": { + "name": "get_shell_config", + "description": "Get the desktop shell config file contents", + "parameters": { + "type": "object", + "properties": {} + } + }, + }, + { + "type": "function", + "function": { + "name": "set_shell_config", + "description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.", + "parameters": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.", + }, + "value": { + "type": "string", + "description": "The value to set, e.g. `true`" + } + }, + "required": ["key", "value"] + } + } + }, + { + "type": "function", + "function": { + "name": "run_shell_command", + "description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run", + }, + }, + "required": ["command"] + } + }, + }, + ], + "search": [], + "none": [], + }, + "mistral": { + "functions": [ + { + "type": "function", + "function": { + "name": "get_shell_config", + "description": "Get the desktop shell config file contents", + "parameters": { + "type": "object", + "properties": {} + } + }, + }, + { + "type": "function", + "function": { + "name": "set_shell_config", + "description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.", + "parameters": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.", + }, + "value": { + "type": "string", + "description": "The value to set, e.g. `true`" + } + }, + "required": ["key", "value"] + } + } + }, + { + "type": "function", + "function": { + "name": "run_shell_command", + "description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run", + }, + }, + "required": ["command"] + } + }, + }, + ], + "search": [], + "none": [], + } + } + property list availableTools: Object.keys(root.tools[models[currentModelId]?.api_format]) + property var toolDescriptions: { + "functions": Translation.tr("Commands, edit configs, search.\nTakes an extra turn to switch to search mode if that's needed"), + "search": Translation.tr("Gives the model search capabilities (immediately)"), + "none": Translation.tr("Disable tools") + } + + // Model properties: + // - name: Name of the model + // - icon: Icon name of the model + // - description: Description of the model + // - endpoint: Endpoint of the model + // - model: Model name of the model + // - requires_key: Whether the model requires an API key + // - key_id: The identifier of the API key. Use the same identifier for models that can be accessed with the same key. + // - key_get_link: Link to get an API key + // - key_get_description: Description of pricing and how to get an API key + // - api_format: The API format of the model. Can be "openai" or "gemini". Default is "openai". + // - extraParams: Extra parameters to be passed to the model. This is a JSON object. + property var models: Config.options.policies.ai === 2 ? {} : { + "gemini-2.0-flash": aiModelComponent.createObject(this, { + "name": "Gemini 2.0 Flash", + "icon": "google-gemini-symbolic", + "description": Translation.tr("Online | Google's model\nFast, can perform searches for up-to-date information"), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent", + "model": "gemini-2.0-flash", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + }), + "gemini-2.5-flash": aiModelComponent.createObject(this, { + "name": "Gemini 2.5 Flash", + "icon": "google-gemini-symbolic", + "description": Translation.tr("Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers"), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent", + "model": "gemini-2.5-flash", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + }), + "gemini-2.5-flash-pro": aiModelComponent.createObject(this, { + "name": "Gemini 2.5 Pro", + "icon": "google-gemini-symbolic", + "description": Translation.tr("Online | Google's model\nGoogle's state-of-the-art multipurpose model that excels at coding and complex reasoning tasks."), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent", + "model": "gemini-2.5-pro", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + }), + "gemini-2.5-flash-lite": aiModelComponent.createObject(this, { + "name": "Gemini 2.5 Flash-Lite", + "icon": "google-gemini-symbolic", + "description": Translation.tr("Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput."), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:streamGenerateContent", + "model": "gemini-2.5-flash-lite", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"), + "api_format": "gemini", + }), + "mistral-medium-3": aiModelComponent.createObject(this, { + "name": "Mistral Medium 3", + "icon": "mistral-symbolic", + "description": Translation.tr("Online | %1's model | Delivers fast, responsive and well-formatted answers. Disadvantages: not very eager to do stuff; might make up unknown function calls").arg("Mistral"), + "homepage": "https://mistral.ai/news/mistral-medium-3", + "endpoint": "https://api.mistral.ai/v1/chat/completions", + "model": "mistral-medium-2505", + "requires_key": true, + "key_id": "mistral", + "key_get_link": "https://console.mistral.ai/api-keys", + "key_get_description": Translation.tr("**Instructions**: Log into Mistral account, go to Keys on the sidebar, click Create new key"), + "api_format": "mistral", + }), + "github-gpt-5-nano": aiModelComponent.createObject(this, { + "name": "GPT-5 Nano (GH Models)", + "icon": "github-symbolic", + "api_format": "openai", + "description": Translation.tr("Online via %1 | %2's model").arg("GitHub Models").arg("OpenAI"), + "homepage": "https://github.com/marketplace/models", + "endpoint": "https://models.inference.ai.azure.com/chat/completions", + "model": "gpt-5-nano", + "requires_key": true, + "key_id": "github", + "key_get_link": "https://github.com/settings/tokens", + "key_get_description": Translation.tr("**Pricing**: Free tier available with limited rates. See https://docs.github.com/en/billing/concepts/product-billing/github-models\n\n**Instructions**: Generate a GitHub personal access token with Models permission, then set as API key here\n\n**Note**: To use this you will have to set the temperature parameter to 1"), + }), + "openrouter-gpt-5": aiModelComponent.createObject(this, { + "name": "GPT-5", + "icon": "openai-symbolic", + "api_format": "openai", + "description": Translation.tr("Online via %1 | %2's model").arg("OpenRouter").arg("OpenAI"), + "homepage": "https://openrouter.ai/openai/gpt-5", + "endpoint": "https://openrouter.ai/api/v1/chat/completions", + "model": "openai/gpt-5", + "requires_key": true, + "key_id": "openrouter", + "key_get_link": "https://openrouter.ai/settings/keys", + "key_get_description": Translation.tr("**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key"), + }), + "openrouter-deepseek-r1": aiModelComponent.createObject(this, { + "name": "DeepSeek R1", + "icon": "deepseek-symbolic", + "description": Translation.tr("Online via %1 | %2's model").arg("OpenRouter").arg("DeepSeek"), + "homepage": "https://openrouter.ai/deepseek/deepseek-r1:free", + "endpoint": "https://openrouter.ai/api/v1/chat/completions", + "model": "deepseek/deepseek-r1:free", + "requires_key": true, + "key_id": "openrouter", + "key_get_link": "https://openrouter.ai/settings/keys", + "key_get_description": Translation.tr("**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key"), + }), + } + property var modelList: Object.keys(root.models) + property var currentModelId: Persistent.states?.ai?.model || modelList[0] + + property var apiStrategies: { + "openai": openaiApiStrategy.createObject(this), + "gemini": geminiApiStrategy.createObject(this), + "mistral": mistralApiStrategy.createObject(this), + } + property ApiStrategy currentApiStrategy: apiStrategies[models[currentModelId]?.api_format || "openai"] + + Connections { + target: Config + function onReadyChanged() { + if (!Config.ready) return; + (Config?.options.ai?.extraModels ?? []).forEach(model => { + const safeModelName = root.safeModelName(model["model"]); + root.addModel(safeModelName, model) + }); + } + } + + property string requestScriptFilePath: "/tmp/quickshell/ai/request.sh" + property string pendingFilePath: "" + + Component.onCompleted: { + setModel(currentModelId, false, false); // Do necessary setup for model + } + + function guessModelLogo(model) { + if (model.includes("llama")) return "ollama-symbolic"; + if (model.includes("gemma")) return "google-gemini-symbolic"; + if (model.includes("deepseek")) return "deepseek-symbolic"; + if (/^phi\d*:/i.test(model)) return "microsoft-symbolic"; + return "ollama-symbolic"; + } + + function guessModelName(model) { + const replaced = model.replace(/-/g, ' ').replace(/:/g, ' '); + let words = replaced.split(' '); + words[words.length - 1] = words[words.length - 1].replace(/(\d+)b$/, (_, num) => `${num}B`) + words = words.map((word) => { + return (word.charAt(0).toUpperCase() + word.slice(1)) + }); + if (words[words.length - 1] === "Latest") words.pop(); + else words[words.length - 1] = `(${words[words.length - 1]})`; // Surround the last word with square brackets + const result = words.join(' '); + return result; + } + + function addModel(modelName, data) { + root.models[modelName] = aiModelComponent.createObject(this, data); + } + + Process { + id: getOllamaModels + running: true + command: ["bash", "-c", `${Directories.scriptPath}/ai/show-installed-ollama-models.sh`.replace(/file:\/\//, "")] + stdout: SplitParser { + onRead: data => { + try { + if (data.length === 0) return; + const dataJson = JSON.parse(data); + root.modelList = [...root.modelList, ...dataJson]; + dataJson.forEach(model => { + const safeModelName = root.safeModelName(model); + root.addModel(safeModelName, { + "name": guessModelName(model), + "icon": guessModelLogo(model), + "description": Translation.tr("Local Ollama model | %1").arg(model), + "homepage": `https://ollama.com/library/${model}`, + "endpoint": "http://localhost:11434/v1/chat/completions", + "model": model, + "requires_key": false, + }) + }); + + root.modelList = Object.keys(root.models); + + } catch (e) { + console.log("Could not fetch Ollama models:", e); + } + } + } + } + + Process { + id: getDefaultPrompts + running: true + command: ["ls", "-1", Directories.defaultAiPrompts] + stdout: StdioCollector { + onStreamFinished: { + if (text.length === 0) return; + root.defaultPrompts = text.split("\n") + .filter(fileName => fileName.endsWith(".md") || fileName.endsWith(".txt")) + .map(fileName => `${Directories.defaultAiPrompts}/${fileName}`) + } + } + } + + Process { + id: getUserPrompts + running: true + command: ["ls", "-1", Directories.userAiPrompts] + stdout: StdioCollector { + onStreamFinished: { + if (text.length === 0) return; + root.userPrompts = text.split("\n") + .filter(fileName => fileName.endsWith(".md") || fileName.endsWith(".txt")) + .map(fileName => `${Directories.userAiPrompts}/${fileName}`) + } + } + } + + Process { + id: getSavedChats + running: true + command: ["ls", "-1", Directories.aiChats] + stdout: StdioCollector { + onStreamFinished: { + if (text.length === 0) return; + root.savedChats = text.split("\n") + .filter(fileName => fileName.endsWith(".json")) + .map(fileName => `${Directories.aiChats}/${fileName}`) + } + } + } + + FileView { + id: promptLoader + watchChanges: false; + onLoadedChanged: { + if (!promptLoader.loaded) return; + Config.options.ai.systemPrompt = promptLoader.text(); + root.addMessage(Translation.tr("Loaded the following system prompt\n\n---\n\n%1").arg(Config.options.ai.systemPrompt), root.interfaceRole); + } + } + + function printPrompt() { + root.addMessage(Translation.tr("The current system prompt is\n\n---\n\n%1").arg(Config.options.ai.systemPrompt), root.interfaceRole); + } + + function loadPrompt(filePath) { + promptLoader.path = "" // Unload + promptLoader.path = filePath; // Load + promptLoader.reload(); + } + + function addMessage(message, role) { + if (message.length === 0) return; + const aiMessage = aiMessageComponent.createObject(root, { + "role": role, + "content": message, + "rawContent": message, + "thinking": false, + "done": true, + }); + const id = idForMessage(aiMessage); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = aiMessage; + } + + function removeMessage(index) { + if (index < 0 || index >= messageIDs.length) return; + const id = root.messageIDs[index]; + root.messageIDs.splice(index, 1); + root.messageIDs = [...root.messageIDs]; + delete root.messageByID[id]; + } + + function addApiKeyAdvice(model) { + root.addMessage( + Translation.tr('To set an API key, pass it with the %4 command\n\nTo view the key, pass "get" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3') + .arg(model.name).arg(model.key_get_link).arg(model.key_get_description ?? Translation.tr("No further instruction provided")).arg("/key"), + Ai.interfaceRole + ); + } + + function getModel() { + return models[currentModelId]; + } + + function setModel(modelId, feedback = true, setPersistentState = true) { + if (!modelId) modelId = "" + modelId = modelId.toLowerCase() + if (modelList.indexOf(modelId) !== -1) { + const model = models[modelId] + // Fetch API keys if needed + if (model?.requires_key) KeyringStorage.fetchKeyringData(); + // See if policy prevents online models + if (Config.options.policies.ai === 2 && !model.endpoint.includes("localhost")) { + root.addMessage( + Translation.tr("Online models disallowed\n\nControlled by `policies.ai` config option"), + root.interfaceRole + ); + return; + } + if (setPersistentState) Persistent.states.ai.model = modelId; + if (feedback) root.addMessage(Translation.tr("Model set to %1").arg(model.name), root.interfaceRole); + if (model.requires_key) { + // If key not there show advice + if (root.apiKeysLoaded && (!root.apiKeys[model.key_id] || root.apiKeys[model.key_id].length === 0)) { + root.addApiKeyAdvice(model) + } + } + } else { + if (feedback) root.addMessage(Translation.tr("Invalid model. Supported: \n```\n") + modelList.join("\n```\n```\n"), Ai.interfaceRole) + "\n```" + } + } + + function setTool(tool) { + if (!root.tools[models[currentModelId]?.api_format] || !(tool in root.tools[models[currentModelId]?.api_format])) { + root.addMessage(Translation.tr("Invalid tool. Supported tools:\n- %1").arg(root.availableTools.join("\n- ")), root.interfaceRole); + return false; + } + Config.options.ai.tool = tool; + return true; + } + + function getTemperature() { + return root.temperature; + } + + function setTemperature(value) { + if (value == NaN || value < 0 || value > 2) { + root.addMessage(Translation.tr("Temperature must be between 0 and 2"), Ai.interfaceRole); + return; + } + Persistent.states.ai.temperature = value; + root.temperature = value; + root.addMessage(Translation.tr("Temperature set to %1").arg(value), Ai.interfaceRole); + } + + function setApiKey(key) { + const model = models[currentModelId]; + if (!model.requires_key) { + root.addMessage(Translation.tr("%1 does not require an API key").arg(model.name), Ai.interfaceRole); + return; + } + if (!key || key.length === 0) { + const model = models[currentModelId]; + root.addApiKeyAdvice(model) + return; + } + KeyringStorage.setNestedField(["apiKeys", model.key_id], key.trim()); + root.addMessage(Translation.tr("API key set for %1").arg(model.name), Ai.interfaceRole); + } + + function printApiKey() { + const model = models[currentModelId]; + if (model.requires_key) { + const key = root.apiKeys[model.key_id]; + if (key) { + root.addMessage(Translation.tr("API key:\n\n```txt\n%1\n```").arg(key), Ai.interfaceRole); + } else { + root.addMessage(Translation.tr("No API key set for %1").arg(model.name), Ai.interfaceRole); + } + } else { + root.addMessage(Translation.tr("%1 does not require an API key").arg(model.name), Ai.interfaceRole); + } + } + + function printTemperature() { + root.addMessage(Translation.tr("Temperature: %1").arg(root.temperature), Ai.interfaceRole); + } + + function clearMessages() { + root.messageIDs = []; + root.messageByID = ({}); + root.tokenCount.input = -1; + root.tokenCount.output = -1; + root.tokenCount.total = -1; + } + + FileView { + id: requesterScriptFile + } + + Process { + id: requester + property list baseCommand: ["bash"] + property AiMessageData message + property ApiStrategy currentStrategy + + function markDone() { + requester.message.done = true; + if (root.postResponseHook) { + root.postResponseHook(); + root.postResponseHook = null; // Reset hook after use + } + root.saveChat("lastSession") + root.responseFinished() + } + + function makeRequest() { + const model = models[currentModelId]; + requester.currentStrategy = root.currentApiStrategy; + requester.currentStrategy.reset(); // Reset strategy state + + /* Put API key in environment variable */ + if (model.requires_key) requester.environment[`${root.apiKeyEnvVarName}`] = root.apiKeys ? (root.apiKeys[model.key_id] ?? "") : "" + + /* Build endpoint, request data */ + const endpoint = root.currentApiStrategy.buildEndpoint(model); + const messageArray = root.messageIDs.map(id => root.messageByID[id]); + const filteredMessageArray = messageArray.filter(message => message.role !== Ai.interfaceRole); + const data = root.currentApiStrategy.buildRequestData(model, filteredMessageArray, root.systemPrompt, root.temperature, root.tools[model.api_format][root.currentTool], root.pendingFilePath); + // console.log("[Ai] Request data: ", JSON.stringify(data, null, 2)); + + let requestHeaders = { + "Content-Type": "application/json", + } + + /* Create local message object */ + requester.message = root.aiMessageComponent.createObject(root, { + "role": "assistant", + "model": currentModelId, + "content": "", + "rawContent": "", + "thinking": true, + "done": false, + }); + const id = idForMessage(requester.message); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = requester.message; + + /* Build header string for curl */ + let headerString = Object.entries(requestHeaders) + .filter(([k, v]) => v && v.length > 0) + .map(([k, v]) => `-H '${k}: ${v}'`) + .join(' '); + + // console.log("Request headers: ", JSON.stringify(requestHeaders)); + // console.log("Header string: ", headerString); + + /* Get authorization header from strategy */ + const authHeader = requester.currentStrategy.buildAuthorizationHeader(root.apiKeyEnvVarName); + + /* Script shebang */ + const scriptShebang = "#!/usr/bin/env bash\n"; + + /* If no stored key for OpenRouter, source it from OPENROUTER_API_KEY */ + let scriptEnvSetup = "" + if (model.requires_key) { + const storedKey = root.apiKeys ? (root.apiKeys[model.key_id] ?? "") : ""; + if ((!storedKey || storedKey.length === 0) && model.key_id === "openrouter") { + scriptEnvSetup = `export ${root.apiKeyEnvVarName}=$OPENROUTER_API_KEY\n`; + } + } + + /* Create extra setup when there's an attached file */ + let scriptFileSetupContent = "" + if (root.pendingFilePath && root.pendingFilePath.length > 0) { + requester.message.localFilePath = root.pendingFilePath; + scriptFileSetupContent = requester.currentStrategy.buildScriptFileSetup(root.pendingFilePath); + root.pendingFilePath = "" + } + + /* Create command string */ + let scriptRequestContent = "" + // Build request body into a temp file to avoid argv length limits + scriptRequestContent += `mkdir -p /tmp/quickshell/ai` + "\n" + scriptRequestContent += `DATA_FILE="/tmp/quickshell/ai/request-$(date +%s%N)-$RANDOM.json"` + "\n" + scriptRequestContent += `cat > "$DATA_FILE" <<__QS_JSON__` + "\n" + scriptRequestContent += JSON.stringify(data) + "\n" + scriptRequestContent += `__QS_JSON__` + "\n" + scriptRequestContent += `curl -sS --fail-with-body --no-buffer "${endpoint}"` + + ` ${headerString}` + + (authHeader ? ` ${authHeader}` : "") + + ` --data-binary @"$DATA_FILE" 2>&1` + "\n" + scriptRequestContent += `rm -f "$DATA_FILE"` + "\n" + + /* Send the request */ + const scriptContent = requester.currentStrategy.finalizeScriptContent(scriptShebang + scriptEnvSetup + scriptFileSetupContent + scriptRequestContent) + const shellScriptPath = CF.FileUtils.trimFileProtocol(root.requestScriptFilePath) + requesterScriptFile.path = Qt.resolvedUrl(shellScriptPath) + requesterScriptFile.setText(scriptContent) + requester.command = baseCommand.concat([shellScriptPath]); + requester.running = true + } + + stdout: SplitParser { + onRead: data => { + if (data.length === 0) return; + if (requester.message.thinking) requester.message.thinking = false; + // console.log("[Ai] Raw response line: ", data); + + // Handle response line + try { + const result = requester.currentStrategy.parseResponseLine(data, requester.message); + // console.log("[Ai] Parsed response result: ", JSON.stringify(result, null, 2)); + + if (result.functionCall) { + requester.message.functionCall = result.functionCall; + root.handleFunctionCall(result.functionCall.name, result.functionCall.args, requester.message); + } + if (result.tokenUsage) { + root.tokenCount.input = result.tokenUsage.input; + root.tokenCount.output = result.tokenUsage.output; + root.tokenCount.total = result.tokenUsage.total; + } + if (result.finished) { + requester.markDone(); + } + + } catch (e) { + console.log("[AI] Could not parse response: ", e); + requester.message.rawContent += data; + requester.message.content += data; + } + } + } + + onExited: (exitCode, exitStatus) => { + const result = requester.currentStrategy.onRequestFinished(requester.message); + + if (result.finished) { + requester.markDone(); + } else if (!requester.message.done) { + requester.markDone(); + } + + // Handle error responses + if (requester.message.content.includes("API key not valid")) { + root.addApiKeyAdvice(models[requester.message.model]); + } + if (exitCode !== 0 && (!requester.message.content || requester.message.content.length === 0)) { + root.addMessage(Translation.tr("Request failed (exit %1). Check your API key/model or request body.").arg(exitCode), root.interfaceRole); + } + } + } + + function sendUserMessage(message) { + if (message.length === 0) return; + root.addMessage(message, "user"); + requester.makeRequest(); + } + + function attachFile(filePath: string) { + root.pendingFilePath = CF.FileUtils.trimFileProtocol(filePath); + } + + function createFunctionOutputMessage(name, output, includeOutputInChat = true) { + return aiMessageComponent.createObject(root, { + "role": "user", + "content": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n\n" + output + "\n") : ""}`, + "rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n\n" + output + "\n") : ""}`, + "functionName": name, + "functionResponse": output, + "thinking": false, + "done": true, + // "visibleToUser": false, + }); + } + + function addFunctionOutputMessage(name, output) { + const aiMessage = createFunctionOutputMessage(name, output); + const id = idForMessage(aiMessage); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = aiMessage; + } + + function rejectCommand(message: AiMessageData) { + if (!message.functionPending) return; + message.functionPending = false; // User decided, no more "thinking" + addFunctionOutputMessage(message.functionName, Translation.tr("Command rejected by user")) + } + + function approveCommand(message: AiMessageData) { + if (!message.functionPending) return; + message.functionPending = false; // User decided, no more "thinking" + + const responseMessage = createFunctionOutputMessage(message.functionName, "", false); + const id = idForMessage(responseMessage); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = responseMessage; + + commandExecutionProc.message = responseMessage; + commandExecutionProc.baseMessageContent = responseMessage.content; + commandExecutionProc.shellCommand = message.functionCall.args.command; + commandExecutionProc.running = true; // Start the command execution + } + + Process { + id: commandExecutionProc + property string shellCommand: "" + property AiMessageData message + property string baseMessageContent: "" + command: ["bash", "-c", shellCommand] + stdout: SplitParser { + onRead: (output) => { + commandExecutionProc.message.functionResponse += output + "\n\n"; + const updatedContent = commandExecutionProc.baseMessageContent + `\n\n\n${commandExecutionProc.message.functionResponse}\n`; + commandExecutionProc.message.rawContent = updatedContent; + commandExecutionProc.message.content = updatedContent; + } + } + onExited: (exitCode, exitStatus) => { + commandExecutionProc.message.functionResponse += `[[ Command exited with code ${exitCode} (${exitStatus}) ]]\n`; + requester.makeRequest(); // Continue + } + } + + function handleFunctionCall(name, args: var, message: AiMessageData) { + if (name === "switch_to_search_mode") { + const modelId = root.currentModelId; + root.currentTool = "search" + root.postResponseHook = () => { root.currentTool = "functions" } + addFunctionOutputMessage(name, Translation.tr("Switched to search mode. Continue with the user's request.")) + requester.makeRequest(); + } else if (name === "get_shell_config") { + const configJson = CF.ObjectUtils.toPlainObject(Config.options) + addFunctionOutputMessage(name, JSON.stringify(configJson)); + requester.makeRequest(); + } else if (name === "set_shell_config") { + if (!args.key || !args.value) { + addFunctionOutputMessage(name, Translation.tr("Invalid arguments. Must provide `key` and `value`.")); + return; + } + const key = args.key; + const value = args.value; + Config.setNestedValue(key, value); + } else if (name === "run_shell_command") { + if (!args.command || args.command.length === 0) { + addFunctionOutputMessage(name, Translation.tr("Invalid arguments. Must provide `command`.")); + return; + } + const contentToAppend = `\n\n**Command execution request**\n\n\`\`\`command\n${args.command}\n\`\`\``; + message.rawContent += contentToAppend; + message.content += contentToAppend; + message.functionPending = true; // Use thinking to indicate the command is waiting for approval + } + else root.addMessage(Translation.tr("Unknown function call: %1").arg(name), "assistant"); + } + + function chatToJson() { + return root.messageIDs.map(id => { + const message = root.messageByID[id] + return ({ + "role": message.role, + "rawContent": message.rawContent, + "fileMimeType": message.fileMimeType, + "fileUri": message.fileUri, + "localFilePath": message.localFilePath, + "model": message.model, + "thinking": false, + "done": true, + "annotations": message.annotations, + "annotationSources": message.annotationSources, + "functionName": message.functionName, + "functionCall": message.functionCall, + "functionResponse": message.functionResponse, + "visibleToUser": message.visibleToUser, + }) + }) + } + + FileView { + id: chatSaveFile + property string chatName: "" + path: chatName.length > 0 ? `${Directories.aiChats}/${chatName}.json` : "" + blockLoading: true // Prevent race conditions + } + + /** + * Saves chat to a JSON list of message objects. + * @param chatName name of the chat + */ + function saveChat(chatName) { + chatSaveFile.chatName = chatName.trim() + const saveContent = JSON.stringify(root.chatToJson()) + chatSaveFile.setText(saveContent) + getSavedChats.running = true; + } + + /** + * Loads chat from a JSON list of message objects. + * @param chatName name of the chat + */ + function loadChat(chatName) { + try { + chatSaveFile.chatName = chatName.trim() + chatSaveFile.reload() + const saveContent = chatSaveFile.text() + // console.log(saveContent) + const saveData = JSON.parse(saveContent) + root.clearMessages() + root.messageIDs = saveData.map((_, i) => { + return i + }) + // console.log(JSON.stringify(messageIDs)) + for (let i = 0; i < saveData.length; i++) { + const message = saveData[i]; + root.messageByID[i] = root.aiMessageComponent.createObject(root, { + "role": message.role, + "rawContent": message.rawContent, + "content": message.rawContent, + "fileMimeType": message.fileMimeType, + "fileUri": message.fileUri, + "localFilePath": message.localFilePath, + "model": message.model, + "thinking": message.thinking, + "done": message.done, + "annotations": message.annotations, + "annotationSources": message.annotationSources, + "functionName": message.functionName, + "functionCall": message.functionCall, + "functionResponse": message.functionResponse, + "visibleToUser": message.visibleToUser, + }); + } + } catch (e) { + console.log("[AI] Could not load chat: ", e); + } finally { + getSavedChats.running = true; + } + } +} diff --git a/ii/services/AppSearch.qml b/ii/services/AppSearch.qml new file mode 100644 index 0000000..44d0912 --- /dev/null +++ b/ii/services/AppSearch.qml @@ -0,0 +1,148 @@ +pragma Singleton + +import qs.modules.common +import qs.modules.common.functions +import Quickshell + +/** + * - Eases fuzzy searching for applications by name + * - Guesses icon name for window class name + */ +Singleton { + id: root + property bool sloppySearch: Config.options?.search.sloppy ?? false + property real scoreThreshold: 0.2 + property var substitutions: ({ + "code-url-handler": "visual-studio-code", + "Code": "visual-studio-code", + "gnome-tweaks": "org.gnome.tweaks", + "pavucontrol-qt": "pavucontrol", + "wps": "wps-office2019-kprometheus", + "wpsoffice": "wps-office2019-kprometheus", + "footclient": "foot", + "zen": "zen-browser", + "brave-browser": "brave-desktop" + }) + property var regexSubstitutions: [ + { + "regex": /^steam_app_(\d+)$/, + "replace": "steam_icon_$1" + }, + { + "regex": /Minecraft.*/, + "replace": "minecraft" + }, + { + "regex": /.*polkit.*/, + "replace": "system-lock-screen" + }, + { + "regex": /gcr.prompter/, + "replace": "system-lock-screen" + } + ] + + readonly property list list: Array.from(DesktopEntries.applications.values) + .sort((a, b) => a.name.localeCompare(b.name)) + + readonly property var preppedNames: list.map(a => ({ + name: Fuzzy.prepare(`${a.name} `), + entry: a + })) + + readonly property var preppedIcons: list.map(a => ({ + name: Fuzzy.prepare(`${a.icon} `), + entry: a + })) + + function fuzzyQuery(search: string): var { // Idk why list doesn't work + if (root.sloppySearch) { + const results = list.map(obj => ({ + entry: obj, + score: Levendist.computeScore(obj.name.toLowerCase(), search.toLowerCase()) + })).filter(item => item.score > root.scoreThreshold) + .sort((a, b) => b.score - a.score) + return results + .map(item => item.entry) + } + + return Fuzzy.go(search, preppedNames, { + all: true, + key: "name" + }).map(r => { + return r.obj.entry + }); + } + + function iconExists(iconName) { + if (!iconName || iconName.length == 0) return false; + return (Quickshell.iconPath(iconName, true).length > 0) + && !iconName.includes("image-missing"); + } + + function getReverseDomainNameAppName(str) { + return str.split('.').slice(-1)[0] + } + + function getKebabNormalizedAppName(str) { + return str.toLowerCase().replace(/\s+/g, "-"); + } + + function guessIcon(str) { + if (!str || str.length == 0) return "image-missing"; + + // Normal substitutions + if (substitutions[str]) return substitutions[str]; + if (substitutions[str.toLowerCase()]) return substitutions[str.toLowerCase()]; + + // Regex substitutions + for (let i = 0; i < regexSubstitutions.length; i++) { + const substitution = regexSubstitutions[i]; + const replacedName = str.replace( + substitution.regex, + substitution.replace, + ); + if (replacedName != str) return replacedName; + } + + // Icon exists -> return as is + if (iconExists(str)) return str; + + + // Simple guesses + const lowercased = str.toLowerCase(); + if (iconExists(lowercased)) return lowercased; + + const reverseDomainNameAppName = getReverseDomainNameAppName(str); + if (iconExists(reverseDomainNameAppName)) return reverseDomainNameAppName; + + const lowercasedDomainNameAppName = reverseDomainNameAppName.toLowerCase(); + if (iconExists(lowercasedDomainNameAppName)) return lowercasedDomainNameAppName; + + const kebabNormalizedGuess = getKebabNormalizedAppName(str); + if (iconExists(kebabNormalizedGuess)) return kebabNormalizedGuess; + + + // Search in desktop entries + const iconSearchResults = Fuzzy.go(str, preppedIcons, { + all: true, + key: "name" + }).map(r => { + return r.obj.entry + }); + if (iconSearchResults.length > 0) { + const guess = iconSearchResults[0].icon + if (iconExists(guess)) return guess; + } + + const nameSearchResults = root.fuzzyQuery(str); + if (nameSearchResults.length > 0) { + const guess = nameSearchResults[0].icon + if (iconExists(guess)) return guess; + } + + + // Give up + return str; + } +} diff --git a/ii/services/Audio.qml b/ii/services/Audio.qml new file mode 100644 index 0000000..0651ebc --- /dev/null +++ b/ii/services/Audio.qml @@ -0,0 +1,54 @@ +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire +pragma Singleton +pragma ComponentBehavior: Bound + +/** + * A nice wrapper for default Pipewire audio sink and source. + */ +Singleton { + id: root + + property bool ready: Pipewire.defaultAudioSink?.ready ?? false + property PwNode sink: Pipewire.defaultAudioSink + property PwNode source: Pipewire.defaultAudioSource + + signal sinkProtectionTriggered(string reason); + + PwObjectTracker { + objects: [sink, source] + } + + Connections { // Protection against sudden volume changes + target: sink?.audio ?? null + property bool lastReady: false + property real lastVolume: 0 + function onVolumeChanged() { + if (!Config.options.audio.protection.enable) return; + if (!lastReady) { + lastVolume = sink.audio.volume; + lastReady = true; + return; + } + const newVolume = sink.audio.volume; + const maxAllowedIncrease = Config.options.audio.protection.maxAllowedIncrease / 100; + const maxAllowed = Config.options.audio.protection.maxAllowed / 100; + + if (newVolume - lastVolume > maxAllowedIncrease) { + sink.audio.volume = lastVolume; + root.sinkProtectionTriggered("Illegal increment"); + } else if (newVolume > maxAllowed) { + root.sinkProtectionTriggered("Exceeded max allowed"); + sink.audio.volume = Math.min(lastVolume, maxAllowed); + } + if (sink.ready && (isNaN(sink.audio.volume) || sink.audio.volume === undefined || sink.audio.volume === null)) { + sink.audio.volume = 0; + } + lastVolume = sink.audio.volume; + } + + } + +} diff --git a/ii/services/Battery.qml b/ii/services/Battery.qml new file mode 100644 index 0000000..aa2f322 --- /dev/null +++ b/ii/services/Battery.qml @@ -0,0 +1,56 @@ +pragma Singleton + +import qs +import qs.modules.common +import Quickshell +import Quickshell.Services.UPower +import QtQuick +import Quickshell.Io + +Singleton { + property bool available: UPower.displayDevice.isLaptopBattery + property var chargeState: UPower.displayDevice.state + property bool isCharging: chargeState == UPowerDeviceState.Charging + property bool isPluggedIn: isCharging || chargeState == UPowerDeviceState.PendingCharge + property real percentage: UPower.displayDevice?.percentage ?? 1 + readonly property bool allowAutomaticSuspend: Config.options.battery.automaticSuspend + + property bool isLow: available && (percentage <= Config.options.battery.low / 100) + property bool isCritical: available && (percentage <= Config.options.battery.critical / 100) + property bool isSuspending: available && (percentage <= Config.options.battery.suspend / 100) + + property bool isLowAndNotCharging: isLow && !isCharging + property bool isCriticalAndNotCharging: isCritical && !isCharging + property bool isSuspendingAndNotCharging: allowAutomaticSuspend && isSuspending && !isCharging + + property real energyRate: UPower.displayDevice.changeRate + property real timeToEmpty: UPower.displayDevice.timeToEmpty + property real timeToFull: UPower.displayDevice.timeToFull + + onIsLowAndNotChargingChanged: { + if (available && isLowAndNotCharging) Quickshell.execDetached([ + "notify-send", + Translation.tr("Low battery"), + Translation.tr("Consider plugging in your device"), + "-u", "critical", + "-a", "Shell" + ]) + } + + onIsCriticalAndNotChargingChanged: { + if (available && isCriticalAndNotCharging) Quickshell.execDetached([ + "notify-send", + Translation.tr("Critically low battery"), + Translation.tr("Please charge!\nAutomatic suspend triggers at %1").arg(Config.options.battery.suspend), + "-u", "critical", + "-a", "Shell" + ]); + + } + + onIsSuspendingAndNotChargingChanged: { + if (available && isSuspendingAndNotCharging) { + Quickshell.execDetached(["bash", "-c", `systemctl suspend || loginctl suspend`]); + } + } +} diff --git a/ii/services/BluetoothStatus.qml b/ii/services/BluetoothStatus.qml new file mode 100644 index 0000000..73978d6 --- /dev/null +++ b/ii/services/BluetoothStatus.qml @@ -0,0 +1,19 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Io +import QtQuick + +/** + * Network service with nmcli. + */ +Singleton { + id: root + + readonly property bool enabled: Bluetooth.defaultAdapter?.enabled ?? false + readonly property BluetoothDevice firstActiveDevice: Bluetooth.defaultAdapter?.devices.values.find(device => device.connected) ?? null + readonly property int activeDeviceCount: Bluetooth.defaultAdapter?.devices.values.filter(device => device.connected).length ?? 0 + readonly property bool connected: Bluetooth.devices.values.some(d => d.connected) +} diff --git a/ii/services/Booru.qml b/ii/services/Booru.qml new file mode 100644 index 0000000..07fbd21 --- /dev/null +++ b/ii/services/Booru.qml @@ -0,0 +1,469 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs +import Quickshell; +import QtQuick; + +/** + * A service for interacting with various booru APIs. + */ +Singleton { + id: root + property Component booruResponseDataComponent: BooruResponseData {} + + signal tagSuggestion(string query, var suggestions) + signal responseFinished() + + property string failMessage: Translation.tr("That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number") + property var responses: [] + property int runningRequests: 0 + property var defaultUserAgent: Config.options?.networking?.userAgent || "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: Object.keys(providers).filter(provider => provider !== "system" && providers[provider].api) + property var providers: { + "system": { "name": Translation.tr("System") }, + "yandere": { + "name": "yande.re", + "url": "https://yande.re", + "api": "https://yande.re/post.json", + "description": Translation.tr("All-rounder | Good quality, decent quantity"), + "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, + "rating": item.rating, + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_url, + "sample_url": item.sample_url ?? item.file_url, + "file_url": item.file_url, + "file_ext": item.file_ext, + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://yande.re/tag.json?order=count&name={{query}}*", + "tagMapFunc": (response) => { + return response.map(item => { + return { + "name": item.name, + "count": item.count + } + }) + } + }, + "konachan": { + "name": "Konachan", + "url": "https://konachan.net", + "api": "https://konachan.net/post.json", + "description": Translation.tr("For desktop wallpapers | Good quality"), + "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, + "rating": item.rating, + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_url, + "sample_url": item.sample_url ?? item.file_url, + "file_url": item.file_url, + "file_ext": item.file_ext, + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://konachan.net/tag.json?order=count&name={{query}}*", + "tagMapFunc": (response) => { + return response.map(item => { + return { + "name": item.name, + "count": item.count + } + }) + } + }, + "zerochan": { + "name": "Zerochan", + "url": "https://www.zerochan.net", + "api": "https://www.zerochan.net/?json", + "description": Translation.tr("Clean stuff | Excellent quality, no NSFW"), + "mapFunc": (response) => { + response = response.items + 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) ?? item.thumbnail, + "character": item.tag + } + }) + } + }, + "danbooru": { + "name": "Danbooru", + "url": "https://danbooru.donmai.us", + "api": "https://danbooru.donmai.us/posts.json", + "description": Translation.tr("The popular one | Best quantity, but quality can vary wildly"), + "mapFunc": (response) => { + 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, + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_file_url, + "sample_url": item.file_url ?? item.large_file_url, + "file_url": item.large_file_url, + "file_ext": item.file_ext, + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://danbooru.donmai.us/tags.json?search[name_matches]={{query}}*", + "tagMapFunc": (response) => { + return response.map(item => { + return { + "name": item.name, + "count": item.post_count + } + }) + } + + }, + "gelbooru": { + "name": "Gelbooru", + "url": "https://gelbooru.com", + "api": "https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1", + "description": Translation.tr("The hentai one | Great quantity, a lot of NSFW, quality varies wildly"), + "mapFunc": (response) => { + response = response.post + 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), + "is_nsfw": (item.rating != 's'), + "md5": item.md5, + "preview_url": item.preview_url, + "sample_url": item.sample_url ?? item.file_url, + "file_url": item.file_url, + "file_ext": item.file_url.split('.').pop(), + "source": getWorkingImageSource(item.source) ?? item.file_url, + } + }) + }, + "tagSearchTemplate": "https://gelbooru.com/index.php?page=dapi&s=tag&q=index&json=1&orderby=count&name_pattern={{query}}%", + "tagMapFunc": (response) => { + return response.tag.map(item => { + return { + "name": item.name, + "count": item.count + } + }) + } + }, + "waifu.im": { + "name": "waifu.im", + "url": "https://waifu.im", + "api": "https://api.waifu.im/search", + "description": Translation.tr("Waifus only | Excellent quality, limited quantity"), + "mapFunc": (response) => { + response = response.images + return response.map(item => { + return { + "id": item.image_id, + "width": item.width, + "height": item.height, + "aspect_ratio": item.width / item.height, + "tags": item.tags.map(tag => {return tag.name}).join(" "), + "rating": item.is_nsfw ? "e" : "s", + "is_nsfw": item.is_nsfw, + "md5": item.md5, + "preview_url": item.sample_url ?? item.url, // preview_url just says access denied (maybe i fucked up and sent too many requests idk) + "sample_url": item.url, + "file_url": item.url, + "file_ext": item.extension, + "source": getWorkingImageSource(item.source) ?? item.url, + } + }) + }, + "tagSearchTemplate": "https://api.waifu.im/tags", + "tagMapFunc": (response) => { + return [...response.versatile.map(item => {return {"name": item}}), + ...response.nsfw.map(item => {return {"name": item}})] + } + }, + "t.alcy.cc": { + "name": "Alcy", + "url": "https://t.alcy.cc", + "api": "https://t.alcy.cc/", + "description": Translation.tr("Large images | God tier quality, no NSFW."), + "fixedTags": [ + { + "name": "ycy", + "count": "General" + }, + { + "name": "moez", + "count": "Moe" + }, + { + "name": "ysz", + "count": "Genshin Impact" + }, + { + "name": "fj", + "count": "Landscape" + }, + { + "name": "bd", + "count": "Girl on white background" + }, + { + "name": "xhl", + "count": "Shiggy" + }, + ], + "manualParseFunc": (responseText) => { + // Alcy just returns image links, each on a new line + const lines = responseText.trim().split('\n'); + return lines.map(line => { + return { + "id": Qt.md5(line), + // Alcy doesn't provide dimensions and images are often of god resolution + "width": 1000, + "height": 1000, + "aspect_ratio": 1, + "tags": "[no tags]", + "rating": "s", + "is_nsfw": false, + "md5": Qt.md5(line), + "preview_url": line, + "sample_url": line, + "file_url": line, + "file_ext": line.split('.').pop(), + "source": "", + } + }); + }, + } + } + property var currentProvider: Persistent.states.booru.provider + + function 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)$/, '')}`; + } + return url; + } + + function setProvider(provider) { + provider = provider.toLowerCase() + if (providerList.indexOf(provider) !== -1) { + Persistent.states.booru.provider = provider + root.addSystemMessage(Translation.tr("Provider set to ") + providers[provider].name + + (provider == "zerochan" ? Translation.tr(". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!") : "")) + } else { + root.addSystemMessage(Translation.tr("Invalid API provider. Supported: \n- ") + providerList.join("\n- ")) + } + } + + function clearResponses() { + responses = [] + } + + function addSystemMessage(message) { + responses = [...responses, root.booruResponseDataComponent.createObject(null, { + "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 url = baseUrl + var tagString = tags.join(" ") + if (!nsfw && !(["zerochan", "waifu.im", "t.alcy.cc"].includes(currentProvider))) { + if (currentProvider == "gelbooru") + tagString += " rating:general"; + else + tagString += " rating:safe"; + } + var params = [] + // 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 if (currentProvider === "waifu.im") { + var tagsArray = tagString.split(" "); + tagsArray.forEach(tag => { + params.push("included_tags=" + encodeURIComponent(tag)); + }); + params.push("limit=" + Math.min(limit, 30)) // Only admin can do > 30 + params.push("is_nsfw=" + (nsfw ? "null" : "false")) // null is random + } + else if (currentProvider === "t.alcy.cc") { + url += tagString + params.push("json") + params.push("quantity=" + limit) + } + else { + params.push("tags=" + encodeURIComponent(tagString)) + params.push("limit=" + limit) + if (currentProvider == "gelbooru") { + params.push("pid=" + page) + } + else { + params.push("page=" + page) + } + } + if (baseUrl.indexOf("?") === -1) { + url += "?" + params.join("&") + } else { + url += "&" + params.join("&") + } + return url + } + + function makeRequest(tags, nsfw=false, limit=20, page=1) { + var url = constructRequestUrl(tags, nsfw, limit, page) + console.log("[Booru] Making request to " + url) + + const newResponse = root.booruResponseDataComponent.createObject(null, { + "provider": currentProvider, + "tags": tags, + "page": page, + "images": [], + "message": "" + }) + + var xhr = new XMLHttpRequest() + xhr.open("GET", url) + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { + try { + // console.log("[Booru] Raw response: " + xhr.responseText) + const provider = providers[currentProvider] + let response; + if (provider.manualParseFunc) { + response = provider.manualParseFunc(xhr.responseText) + } else { + response = JSON.parse(xhr.responseText) + response = provider.mapFunc(response) + } + // console.log("[Booru] Mapped response: " + JSON.stringify(response)) + newResponse.images = response + newResponse.message = response.length > 0 ? "" : root.failMessage + + } catch (e) { + console.log("[Booru] Failed to parse response: " + e) + newResponse.message = root.failMessage + } finally { + root.runningRequests--; + root.responses = [...root.responses, newResponse] + } + } + else if (xhr.readyState === XMLHttpRequest.DONE) { + console.log("[Booru] Request failed with status: " + xhr.status) + } + root.responseFinished() + } + + try { + // Required for danbooru + if (currentProvider == "danbooru") { + xhr.setRequestHeader("User-Agent", defaultUserAgent) + } + else if (currentProvider == "zerochan") { + const userAgent = Config.options?.sidebar?.booru?.zerochan?.username ? `Desktop sidebar booru viewer - username: ${Config.options.sidebar.booru.zerochan.username}` : defaultUserAgent + xhr.setRequestHeader("User-Agent", userAgent) + } + root.runningRequests++; + xhr.send() + } catch (error) { + console.log("Could not set User-Agent:", error) + } + } + + property var currentTagRequest: null + function triggerTagSearch(query) { + if (currentTagRequest) { + currentTagRequest.abort(); + } + + var provider = providers[currentProvider] + if (provider.fixedTags) { + root.tagSuggestion(query, provider.fixedTags) + return provider.fixedTags; + } else if (!provider.tagSearchTemplate) { + return + } + var url = provider.tagSearchTemplate.replace("{{query}}", encodeURIComponent(query)) + + var xhr = new XMLHttpRequest() + currentTagRequest = xhr + xhr.open("GET", url) + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { + currentTagRequest = null + try { + // console.log("[Booru] Raw response: " + xhr.responseText) + var response = JSON.parse(xhr.responseText) + response = provider.tagMapFunc(response) + // console.log("[Booru] Mapped response: " + JSON.stringify(response)) + root.tagSuggestion(query, response) + } catch (e) { + console.log("[Booru] Failed to parse response: " + e) + } + } + else if (xhr.readyState === XMLHttpRequest.DONE) { + console.log("[Booru] Request failed with status: " + xhr.status) + } + } + + try { + // Required for danbooru + if (currentProvider == "danbooru") { + xhr.setRequestHeader("User-Agent", defaultUserAgent) + } + xhr.send() + } catch (error) { + console.log("Could not set User-Agent:", error) + } + } +} + diff --git a/ii/services/BooruResponseData.qml b/ii/services/BooruResponseData.qml new file mode 100644 index 0000000..2a61ff6 --- /dev/null +++ b/ii/services/BooruResponseData.qml @@ -0,0 +1,13 @@ +import qs.modules.common +import QtQuick; + +/** + * A booru response. + */ +QtObject { + property string provider + property var tags + property var page + property var images + property string message +} diff --git a/ii/services/Brightness.qml b/ii/services/Brightness.qml new file mode 100644 index 0000000..74ef562 --- /dev/null +++ b/ii/services/Brightness.qml @@ -0,0 +1,152 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +// From https://github.com/caelestia-dots/shell with modifications. +// License: GPLv3 + +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import QtQuick + +/** + * For managing brightness of monitors. Supports both brightnessctl and ddcutil. + */ +Singleton { + id: root + + signal brightnessChanged() + + property var ddcMonitors: [] + readonly property list monitors: Quickshell.screens.map(screen => monitorComp.createObject(root, { + screen + })) + + function getMonitorForScreen(screen: ShellScreen): var { + return monitors.find(m => m.screen === screen); + } + + function increaseBrightness(): void { + const focusedName = Hyprland.focusedMonitor.name; + const monitor = monitors.find(m => focusedName === m.screen.name); + if (monitor) + monitor.setBrightness(monitor.brightness + 0.05); + } + + function decreaseBrightness(): void { + const focusedName = Hyprland.focusedMonitor.name; + const monitor = monitors.find(m => focusedName === m.screen.name); + if (monitor) + monitor.setBrightness(monitor.brightness - 0.05); + } + + reloadableId: "brightness" + + onMonitorsChanged: { + ddcMonitors = []; + ddcProc.running = true; + } + + Process { + id: ddcProc + + command: ["ddcutil", "detect", "--brief"] + stdout: SplitParser { + splitMarker: "\n\n" + onRead: data => { + if (data.startsWith("Display ")) { + const lines = data.split("\n").map(l => l.trim()); + root.ddcMonitors.push({ + model: lines.find(l => l.startsWith("Monitor:")).split(":")[2], + busNum: lines.find(l => l.startsWith("I2C bus:")).split("/dev/i2c-")[1] + }); + } + } + } + onExited: root.ddcMonitorsChanged() + } + + Process { + id: setProc + } + + component BrightnessMonitor: QtObject { + id: monitor + + required property ShellScreen screen + readonly property bool isDdc: root.ddcMonitors.some(m => m.model === screen.model) + readonly property string busNum: root.ddcMonitors.find(m => m.model === screen.model)?.busNum ?? "" + property real brightness + property bool ready: false + + onBrightnessChanged: { + if (monitor.ready) { + root.brightnessChanged(); + } + } + + function initialize() { + monitor.ready = false; + initProc.command = isDdc ? ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"] : ["sh", "-c", `echo "a b c $(brightnessctl g) $(brightnessctl m)"`]; + initProc.running = true; + } + + readonly property Process initProc: Process { + stdout: SplitParser { + onRead: data => { + const [, , , current, max] = data.split(" "); + monitor.brightness = parseInt(current) / parseInt(max); + monitor.ready = true; + } + } + } + + function setBrightness(value: real): void { + value = Math.max(0.01, Math.min(1, value)); + const rounded = Math.round(value * 100); + if (Math.round(brightness * 100) === rounded) + return; + brightness = value; + setProc.command = isDdc ? ["ddcutil", "-b", busNum, "setvcp", "10", rounded] : ["brightnessctl", "s", `${rounded}%`, "--quiet"]; + setProc.startDetached(); + } + + Component.onCompleted: { + initialize(); + } + + onBusNumChanged: { + initialize(); + } + } + + Component { + id: monitorComp + + BrightnessMonitor {} + } + + IpcHandler { + target: "brightness" + + function increment() { + onPressed: root.increaseBrightness() + } + + function decrement() { + onPressed: root.decreaseBrightness() + } + } + + GlobalShortcut { + name: "brightnessIncrease" + description: "Increase brightness" + onPressed: root.increaseBrightness() + } + + GlobalShortcut { + name: "brightnessDecrease" + description: "Decrease brightness" + onPressed: root.decreaseBrightness() + } +} diff --git a/ii/services/Cliphist.qml b/ii/services/Cliphist.qml new file mode 100644 index 0000000..32b2a02 --- /dev/null +++ b/ii/services/Cliphist.qml @@ -0,0 +1,142 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + // property string cliphistBinary: FileUtils.trimFileProtocol(`${Directories.home}/.cargo/bin/stash`) + property string cliphistBinary: "cliphist" + property real pasteDelay: 0.05 + property string pressPasteCommand: "ydotool key -d 1 29:1 47:1 47:0 29:0" + property bool sloppySearch: Config.options?.search.sloppy ?? false + property real scoreThreshold: 0.2 + property list entries: [] + readonly property var preparedEntries: entries.map(a => ({ + name: Fuzzy.prepare(`${a.replace(/^\s*\S+\s+/, "")}`), + entry: a + })) + function fuzzyQuery(search: string): var { + if (root.sloppySearch) { + const results = entries.slice(0, 100).map(str => ({ + entry: str, + score: Levendist.computeTextMatchScore(str.toLowerCase(), search.toLowerCase()) + })).filter(item => item.score > root.scoreThreshold) + .sort((a, b) => b.score - a.score) + return results + .map(item => item.entry) + } + + return Fuzzy.go(search, preparedEntries, { + all: true, + key: "name" + }).map(r => { + return r.obj.entry + }); + } + + function entryIsImage(entry) { + return !!(/^\d+\t\[\[.*binary data.*\d+x\d+.*\]\]$/.test(entry)) + } + + function refresh() { + readProc.buffer = [] + readProc.running = true + } + + function copy(entry) { + if (root.cliphistBinary.includes("cliphist")) // Classic cliphist + Quickshell.execDetached(["bash", "-c", `printf '${StringUtils.shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy`]); + else { // Stash + const entryNumber = entry.split("\t")[0]; + Quickshell.execDetached(["bash", "-c", `${root.cliphistBinary} decode ${entryNumber} | wl-copy`]); + } + } + + function paste(entry) { + if (root.cliphistBinary.includes("cliphist")) // Classic cliphist + Quickshell.execDetached(["bash", "-c", `printf '${StringUtils.shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy && wl-paste`]); + else { // Stash + const entryNumber = entry.split("\t")[0]; + Quickshell.execDetached(["bash", "-c", `${root.cliphistBinary} decode ${entryNumber} | wl-copy; ${root.pressPasteCommand}`]); + } + } + + function superpaste(count, isImage = false) { + // Find entries + const targetEntries = entries.filter(entry => { + if (!isImage) return true; + return entryIsImage(entry); + }).slice(0, count) + const pasteCommands = [...targetEntries].reverse().map(entry => `printf '${StringUtils.shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy && sleep ${root.pasteDelay} && ${root.pressPasteCommand}`) + // Act + Quickshell.execDetached(["bash", "-c", pasteCommands.join(` && sleep ${root.pasteDelay} && `)]); + } + + Process { + id: deleteProc + property string entry: "" + command: ["bash", "-c", `echo '${StringUtils.shellSingleQuoteEscape(deleteProc.entry)}' | ${root.cliphistBinary} delete`] + function deleteEntry(entry) { + deleteProc.entry = entry; + deleteProc.running = true; + deleteProc.entry = ""; + } + onExited: (exitCode, exitStatus) => { + root.refresh(); + } + } + + function deleteEntry(entry) { + deleteProc.deleteEntry(entry); + } + + Connections { + target: Quickshell + function onClipboardTextChanged() { + delayedUpdateTimer.restart() + } + } + + Timer { + id: delayedUpdateTimer + interval: Config.options.hacks.arbitraryRaceConditionDelay + repeat: false + onTriggered: { + root.refresh() + } + } + + Process { + id: readProc + property list buffer: [] + + command: [root.cliphistBinary, "list"] + + stdout: SplitParser { + onRead: (line) => { + readProc.buffer.push(line) + } + } + + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + root.entries = readProc.buffer + } else { + console.error("[Cliphist] Failed to refresh with code", exitCode, "and status", exitStatus) + } + } + } + + IpcHandler { + target: "cliphistService" + + function update(): void { + root.refresh() + } + } +} diff --git a/ii/services/ConflictKiller.qml b/ii/services/ConflictKiller.qml new file mode 100644 index 0000000..fc94e33 --- /dev/null +++ b/ii/services/ConflictKiller.qml @@ -0,0 +1,49 @@ +pragma Singleton + +import qs +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + + property string killDialogQmlPath: FileUtils.trimFileProtocol(Quickshell.shellPath("killDialog.qml")) + + function load() { + // dummy to force init + } + + Connections { + target: Config + function onReadyChanged() { + if (Config.ready) checkConflictsProc.running = true + } + } + + Process { + id: checkConflictsProc + command: ["bash", "-c", `echo "$(pidof kded6);$(pidof mako dunst)"`] + stdout: StdioCollector { + onStreamFinished: { + const output = this.text; + const conflictingTrays = output.split(";")[0].trim().length > 0; + const conflictingNotifications = output.split(";")[1].trim().length > 0; + var openDialog = false; + if (conflictingTrays) { + if (!Config.options.conflictKiller.autoKillTrays) openDialog = true; + else Quickshell.execDetached(["killall", "kded6"]) + } + if (conflictingNotifications) { + if (!Config.options.conflictKiller.autoKillNotificationDaemons) openDialog = true; + else Quickshell.execDetached(["killall", "mako", "dunst"]) + } + if (openDialog) { + Quickshell.execDetached(["qs", "-p", root.killDialogQmlPath]) + } + } + } + } +} diff --git a/ii/services/DateTime.qml b/ii/services/DateTime.qml new file mode 100644 index 0000000..69bfb45 --- /dev/null +++ b/ii/services/DateTime.qml @@ -0,0 +1,53 @@ +import qs +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Io +pragma Singleton +pragma ComponentBehavior: Bound + +/** + * A nice wrapper for date and time strings. + */ +Singleton { + property var clock: SystemClock { + id: clock + precision: GlobalStates.screenLocked ? SystemClock.Seconds : SystemClock.Minutes // Hack to ensure clock is correct after waking up from suspend + } + property string time: Qt.locale().toString(clock.date, Config.options?.time.format ?? "hh:mm") + property string shortDate: Qt.locale().toString(clock.date, Config.options?.time.shortDateFormat ?? "dd/MM") + property string date: Qt.locale().toString(clock.date, Config.options?.time.dateFormat ?? "dddd, dd/MM") + property string collapsedCalendarFormat: Qt.locale().toString(clock.date, "dd MMMM yyyy") + property string uptime: "0h, 0m" + + Timer { + interval: 10 + running: true + repeat: true + onTriggered: { + fileUptime.reload() + const textUptime = fileUptime.text() + const uptimeSeconds = Number(textUptime.split(" ")[0] ?? 0) + + // Convert seconds to days, hours, and minutes + const days = Math.floor(uptimeSeconds / 86400) + const hours = Math.floor((uptimeSeconds % 86400) / 3600) + const minutes = Math.floor((uptimeSeconds % 3600) / 60) + + // Build the formatted uptime string + let formatted = "" + if (days > 0) formatted += `${days}d` + if (hours > 0) formatted += `${formatted ? ", " : ""}${hours}h` + if (minutes > 0 || !formatted) formatted += `${formatted ? ", " : ""}${minutes}m` + uptime = formatted + interval = Config.options?.resources?.updateInterval ?? 3000 + } + } + + FileView { + id: fileUptime + + path: "/proc/uptime" + } + +} diff --git a/ii/services/EasyEffects.qml b/ii/services/EasyEffects.qml new file mode 100644 index 0000000..8767a9d --- /dev/null +++ b/ii/services/EasyEffects.qml @@ -0,0 +1,61 @@ +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Pipewire +pragma Singleton +pragma ComponentBehavior: Bound + +/** + * Handles EasyEffects active state and presets. + */ +Singleton { + id: root + + property bool available: false + property bool active: false + + function fetchAvailability() { + fetchAvailabilityProc.running = true + } + + function fetchActiveState() { + fetchActiveStateProc.running = true + } + + function disable() { + root.active = false + Quickshell.execDetached(["pkill", "easyeffects"]) + } + + function enable() { + root.active = true + Quickshell.execDetached(["easyeffects", "--gapplication-service"]) + } + + function toggle() { + if (root.active) { + root.disable() + } else { + root.enable() + } + } + + Process { + id: fetchAvailabilityProc + running: true + command: ["bash", "-c", "command -v easyeffects"] + onExited: (exitCode, exitStatus) => { + root.available = exitCode === 0 + } + } + + Process { + id: fetchActiveStateProc + running: true + command: ["pidof", "easyeffects"] + onExited: (exitCode, exitStatus) => { + root.active = exitCode === 0 + } + } +} diff --git a/ii/services/Emojis.qml b/ii/services/Emojis.qml new file mode 100644 index 0000000..436401b --- /dev/null +++ b/ii/services/Emojis.qml @@ -0,0 +1,64 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import Quickshell +import Quickshell.Io + +/** + * Emojis. + */ +Singleton { + id: root + property string emojiScriptPath: `${Directories.config}/hypr/hyprland/scripts/fuzzel-emoji.sh` + property string lineBeforeData: "### DATA ###" + property list list + readonly property var preparedEntries: list.map(a => ({ + name: Fuzzy.prepare(`${a}`), + entry: a + })) + function fuzzyQuery(search: string): var { + if (root.sloppySearch) { + const results = entries.slice(0, 100).map(str => ({ + entry: str, + score: Levendist.computeTextMatchScore(str.toLowerCase(), search.toLowerCase()) + })).filter(item => item.score > root.scoreThreshold) + .sort((a, b) => b.score - a.score) + return results + .map(item => item.entry) + } + + return Fuzzy.go(search, preparedEntries, { + all: true, + key: "name" + }).map(r => { + return r.obj.entry + }); + } + + function load() { + emojiFileView.reload() + } + + function updateEmojis(fileContent) { + const lines = fileContent.split("\n") + const dataIndex = lines.indexOf(root.lineBeforeData) + if (dataIndex === -1) { + console.warn("No data section found in emoji script file.") + return + } + const emojis = lines.slice(dataIndex + 1).filter(line => line.trim() !== "") + root.list = emojis.map(line => line.trim()) + } + + FileView { + id: emojiFileView + path: Qt.resolvedUrl(root.emojiScriptPath) + onLoadedChanged: { + const fileContent = emojiFileView.text() + root.updateEmojis(fileContent) + } + } +} diff --git a/ii/services/FirstRunExperience.qml b/ii/services/FirstRunExperience.qml new file mode 100644 index 0000000..f23cce5 --- /dev/null +++ b/ii/services/FirstRunExperience.qml @@ -0,0 +1,43 @@ +pragma Singleton + +import qs.modules.common +import qs.modules.common.functions +import Quickshell +import Quickshell.Io + +Singleton { + id: root + property string firstRunFilePath: `${Directories.state}/user/first_run.txt` + property string firstRunFileContent: "This file is just here to confirm you've been greeted :>" + property string firstRunNotifSummary: "Welcome!" + property string firstRunNotifBody: "Hit Super+/ for a list of keybinds" + property string defaultWallpaperPath: FileUtils.trimFileProtocol(`${Directories.assetsPath}/images/default_wallpaper.png`) + property string welcomeQmlPath: FileUtils.trimFileProtocol(Quickshell.shellPath("welcome.qml")) + + function load() { + firstRunFileView.reload() + } + + function enableNextTime() { + Quickshell.execDetached(["rm", "-f", root.firstRunFilePath]) + } + function disableNextTime() { + Quickshell.execDetached(["bash", "-c", `echo '${root.firstRunFileContent}' > '${root.firstRunFilePath}'`]) + } + + function handleFirstRun() { + Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, root.defaultWallpaperPath]) + Quickshell.execDetached(["bash", "-c", `qs -p '${root.welcomeQmlPath}'`]) + } + + FileView { + id: firstRunFileView + path: Qt.resolvedUrl(firstRunFilePath) + onLoadFailed: (error) => { + if (error == FileViewError.FileNotFound) { + firstRunFileView.setText(root.firstRunFileContent) + root.handleFirstRun() + } + } + } +} diff --git a/ii/services/HyprlandData.qml b/ii/services/HyprlandData.qml new file mode 100644 index 0000000..abbaaf5 --- /dev/null +++ b/ii/services/HyprlandData.qml @@ -0,0 +1,138 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +/** + * Provides access to some Hyprland data not available in Quickshell.Hyprland. + */ +Singleton { + id: root + property var windowList: [] + property var addresses: [] + property var windowByAddress: ({}) + property var workspaces: [] + property var workspaceIds: [] + property var workspaceById: ({}) + property var activeWorkspace: null + property var monitors: [] + property var layers: ({}) + + function updateWindowList() { + getClients.running = true; + } + + function updateLayers() { + getLayers.running = true; + } + + function updateMonitors() { + getMonitors.running = true; + } + + function updateWorkspaces() { + getWorkspaces.running = true; + getActiveWorkspace.running = true; + } + + function updateAll() { + updateWindowList(); + updateMonitors(); + updateLayers(); + updateWorkspaces(); + } + + function biggestWindowForWorkspace(workspaceId) { + const windowsInThisWorkspace = HyprlandData.windowList.filter(w => w.workspace.id == workspaceId); + return windowsInThisWorkspace.reduce((maxWin, win) => { + const maxArea = (maxWin?.size?.[0] ?? 0) * (maxWin?.size?.[1] ?? 0); + const winArea = (win?.size?.[0] ?? 0) * (win?.size?.[1] ?? 0); + return winArea > maxArea ? win : maxWin; + }, null); + } + + Component.onCompleted: { + updateAll(); + } + + Connections { + target: Hyprland + + function onRawEvent(event) { + // console.log("Hyprland raw event:", event.name); + updateAll() + } + } + + Process { + id: getClients + command: ["hyprctl", "clients", "-j"] + stdout: StdioCollector { + id: clientsCollector + onStreamFinished: { + root.windowList = JSON.parse(clientsCollector.text) + let tempWinByAddress = {}; + for (var i = 0; i < root.windowList.length; ++i) { + var win = root.windowList[i]; + tempWinByAddress[win.address] = win; + } + root.windowByAddress = tempWinByAddress; + root.addresses = root.windowList.map(win => win.address); + } + } + } + + Process { + id: getMonitors + command: ["hyprctl", "monitors", "-j"] + stdout: StdioCollector { + id: monitorsCollector + onStreamFinished: { + root.monitors = JSON.parse(monitorsCollector.text); + } + } + } + + Process { + id: getLayers + command: ["hyprctl", "layers", "-j"] + stdout: StdioCollector { + id: layersCollector + onStreamFinished: { + root.layers = JSON.parse(layersCollector.text); + } + } + } + + Process { + id: getWorkspaces + command: ["hyprctl", "workspaces", "-j"] + stdout: StdioCollector { + id: workspacesCollector + onStreamFinished: { + root.workspaces = JSON.parse(workspacesCollector.text); + let tempWorkspaceById = {}; + for (var i = 0; i < root.workspaces.length; ++i) { + var ws = root.workspaces[i]; + tempWorkspaceById[ws.id] = ws; + } + root.workspaceById = tempWorkspaceById; + root.workspaceIds = root.workspaces.map(ws => ws.id); + } + } + } + + Process { + id: getActiveWorkspace + command: ["hyprctl", "activeworkspace", "-j"] + stdout: StdioCollector { + id: activeWorkspaceCollector + onStreamFinished: { + root.activeWorkspace = JSON.parse(activeWorkspaceCollector.text); + } + } + } +} diff --git a/ii/services/HyprlandKeybinds.qml b/ii/services/HyprlandKeybinds.qml new file mode 100644 index 0000000..3381926 --- /dev/null +++ b/ii/services/HyprlandKeybinds.qml @@ -0,0 +1,72 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +/** + * A service that provides access to Hyprland keybinds. + * Uses the `get_keybinds.py` script to parse comments in config files in a certain format and convert to JSON. + */ +Singleton { + id: root + property string keybindParserPath: FileUtils.trimFileProtocol(`${Directories.scriptPath}/hyprland/get_keybinds.py`) + property string defaultKeybindConfigPath: FileUtils.trimFileProtocol(`${Directories.config}/hypr/hyprland/keybinds.conf`) + property string userKeybindConfigPath: FileUtils.trimFileProtocol(`${Directories.config}/hypr/custom/keybinds.conf`) + property var defaultKeybinds: {"children": []} + property var userKeybinds: {"children": []} + property var keybinds: ({ + children: [ + ...(defaultKeybinds.children ?? []), + ...(userKeybinds.children ?? []), + ] + }) + + Connections { + target: Hyprland + + function onRawEvent(event) { + if (event.name == "configreloaded") { + getDefaultKeybinds.running = true + getUserKeybinds.running = true + } + } + } + + Process { + id: getDefaultKeybinds + running: true + command: [root.keybindParserPath, "--path", root.defaultKeybindConfigPath] + + stdout: SplitParser { + onRead: data => { + try { + root.defaultKeybinds = JSON.parse(data) + } catch (e) { + console.error("[CheatsheetKeybinds] Error parsing keybinds:", e) + } + } + } + } + + Process { + id: getUserKeybinds + running: true + command: [root.keybindParserPath, "--path", root.userKeybindConfigPath] + + stdout: SplitParser { + onRead: data => { + try { + root.userKeybinds = JSON.parse(data) + } catch (e) { + console.error("[CheatsheetKeybinds] Error parsing keybinds:", e) + } + } + } + } +} + diff --git a/ii/services/HyprlandXkb.qml b/ii/services/HyprlandXkb.qml new file mode 100644 index 0000000..cace0d2 --- /dev/null +++ b/ii/services/HyprlandXkb.qml @@ -0,0 +1,108 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import qs.modules.common + +/** + * Exposes the active Hyprland Xkb keyboard layout name and code for indicators. + */ +Singleton { + id: root + // You can read these + property list layoutCodes: [] + property var cachedLayoutCodes: ({}) + property string currentLayoutName: "" + property string currentLayoutCode: "" + // For the service + property var baseLayoutFilePath: "/usr/share/X11/xkb/rules/base.lst" + property bool needsLayoutRefresh: false + + // Update the layout code according to the layout name (Hyprland gives the name not the code) + onCurrentLayoutNameChanged: root.updateLayoutCode() + function updateLayoutCode() { + if (cachedLayoutCodes.hasOwnProperty(currentLayoutName)) { + root.currentLayoutCode = cachedLayoutCodes[currentLayoutName]; + } else { + getLayoutProc.running = true; + } + } + + // Get the layout code from the base.lst file by grabbing the line with the current layout name + Process { + id: getLayoutProc + command: ["cat", root.baseLayoutFilePath] + + stdout: StdioCollector { + id: layoutCollector + + onStreamFinished: { + const lines = layoutCollector.text.split("\n"); + const targetDescription = root.currentLayoutName; + const foundLine = lines.find(line => { + // Skip comment lines and empty lines + if (!line.trim() || line.trim().startsWith('!')) + return false; + + // Match: key + whitespace + description + const match = line.match(/^\s*(\S+)\s+(.+)$/); + if (match && match[2] === targetDescription) { + root.cachedLayoutCodes[match[2]] = match[1]; + root.currentLayoutCode = match[1]; + return true; + } + }); + // console.log("[HyprlandXkb] Found line:", foundLine); + // console.log("[HyprlandXkb] Layout:", root.currentLayoutName, "| Code:", root.currentLayoutCode); + // console.log("[HyprlandXkb] Cached layout codes:", JSON.stringify(root.cachedLayoutCodes, null, 2)); + } + } + } + + // Find out available layouts and current active layout. Should only be necessary on init + Process { + id: fetchLayoutsProc + running: true + command: ["hyprctl", "-j", "devices"] + + stdout: StdioCollector { + id: devicesCollector + onStreamFinished: { + const parsedOutput = JSON.parse(devicesCollector.text); + const hyprlandKeyboard = parsedOutput["keyboards"].find(kb => kb.main === true); + root.layoutCodes = hyprlandKeyboard["layout"].split(","); + root.currentLayoutName = hyprlandKeyboard["active_keymap"]; + // console.log("[HyprlandXkb] Fetched | Layouts (multiple: " + (root.layouts.length > 1) + "): " + // + root.layouts.join(", ") + " | Active: " + root.currentLayoutName); + } + } + } + + // Update the layout name when it changes + Connections { + target: Hyprland + function onRawEvent(event) { + if (event.name === "activelayout") { + if (root.needsLayoutRefresh) { + root.needsLayoutRefresh = false; + fetchLayoutsProc.running = true; + } + + // If there's only one layout, the updated layout is always the same + if (root.layoutCodes.length <= 1) return; + + // Update when layout might have changed + const dataString = event.data; + root.currentLayoutName = dataString.split(",")[1]; + + // Update layout for on-screen keyboard (osk) + Config.options.osk.layout = root.currentLayoutName; + } else if (event.name == "configreloaded") { + // Mark layout code list to be updated when config is reloaded + root.needsLayoutRefresh = true; + } + } + } +} diff --git a/ii/services/Hyprsunset.qml b/ii/services/Hyprsunset.qml new file mode 100644 index 0000000..3f33aa5 --- /dev/null +++ b/ii/services/Hyprsunset.qml @@ -0,0 +1,114 @@ +pragma Singleton + +import QtQuick +import qs.modules.common +import Quickshell +import Quickshell.Io + +/** + * Simple hyprsunset service with automatic mode. + * In theory we don't need this because hyprsunset has a config file, but it somehow doesn't work. + * It should also be possible to control it via hyprctl, but it doesn't work consistently either so we're just killing and launching. + */ +Singleton { + id: root + property var manualActive + property string from: Config.options?.light?.night?.from ?? "19:00" + property string to: Config.options?.light?.night?.to ?? "06:30" + property bool automatic: Config.options?.light?.night?.automatic && (Config?.ready ?? true) + property int colorTemperature: Config.options?.light?.night?.colorTemperature ?? 5000 + property bool shouldBeOn + property bool firstEvaluation: true + property bool active: false + + property int fromHour: Number(from.split(":")[0]) + property int fromMinute: Number(from.split(":")[1]) + property int toHour: Number(to.split(":")[0]) + property int toMinute: Number(to.split(":")[1]) + + property int clockHour: DateTime.clock.hours + property int clockMinute: DateTime.clock.minutes + + + onClockMinuteChanged: reEvaluate() + onAutomaticChanged: { + root.manualActive = undefined; + root.firstEvaluation = true; + reEvaluate(); + } + function reEvaluate() { + const t = clockHour * 60 + clockMinute; + const from = fromHour * 60 + fromMinute; + const to = toHour * 60 + toMinute; + + if (from < to) { + root.shouldBeOn = t >= from && t <= to; + } else { + // Wrapped around midnight + root.shouldBeOn = t >= from || t <= to; + } + if (firstEvaluation) { + firstEvaluation = false; + root.ensureState(); + } + } + + onShouldBeOnChanged: ensureState() + function ensureState() { + // console.log("[Hyprsunset] Ensuring state:", root.shouldBeOn, "Automatic mode:", root.automatic); + if (!root.automatic || root.manualActive !== undefined) + return; + if (root.shouldBeOn) { + root.enable(); + } else { + root.disable(); + } + } + + function load() { } // Dummy to force init + + function enable() { + root.active = true; + // console.log("[Hyprsunset] Enabling"); + Quickshell.execDetached(["bash", "-c", `pidof hyprsunset || hyprsunset --temperature ${root.colorTemperature}`]); + } + + function disable() { + root.active = false; + // console.log("[Hyprsunset] Disabling"); + Quickshell.execDetached(["bash", "-c", `pkill hyprsunset`]); + } + + function fetchState() { + fetchProc.running = true; + } + + Process { + id: fetchProc + running: true + command: ["bash", "-c", "hyprctl hyprsunset temperature"] + stdout: StdioCollector { + id: stateCollector + onStreamFinished: { + const output = stateCollector.text.trim(); + if (output.length == 0 || output.startsWith("Couldn't")) + root.active = false; + else + root.active = (output != "6500"); + // console.log("[Hyprsunset] Fetched state:", output, "->", root.active); + } + } + } + + function toggle() { + if (root.manualActive === undefined) + root.manualActive = root.active; + + root.manualActive = !root.manualActive; + if (root.manualActive) { + root.enable(); + } else { + root.disable(); + } + } +} diff --git a/ii/services/KeyringStorage.qml b/ii/services/KeyringStorage.qml new file mode 100644 index 0000000..ce6b8eb --- /dev/null +++ b/ii/services/KeyringStorage.qml @@ -0,0 +1,118 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import qs +import qs.modules.common +import qs.modules.common.functions +import Quickshell; +import Quickshell.Io; +import QtQuick; + +/** + * For storing sensitive data in the keyring. + * Use this for small data only, since it stores a JSON of the contents directly and doesn't use a database. + */ +Singleton { + id: root + + property bool loaded: false + property var keyringData: ({}) + + property var properties: { + "application": "illogical-impulse", + "explanation": Translation.tr("For storing API keys and other sensitive information"), + } + property var propertiesAsArgs: Object.keys(root.properties).reduce( + function(arr, key) { + return arr.concat([key, root.properties[key]]); + }, [] + ) + property string keyringLabel: Translation.tr("%1 Safe Storage").arg("illogical-impulse") + + function setNestedField(path, value) { + if (!root.keyringData) root.keyringData = {}; + let keys = path; + let obj = root.keyringData; + let parents = [obj]; + + // Traverse and collect parent objects + for (let i = 0; i < keys.length - 1; ++i) { + if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") { + obj[keys[i]] = {}; + } + obj = obj[keys[i]]; + parents.push(obj); + } + + // Set the value at the innermost key + obj[keys[keys.length - 1]] = value; + + // Reassign each parent object from the bottom up to trigger change notifications + for (let i = keys.length - 2; i >= 0; --i) { + let parent = parents[i]; + let key = keys[i]; + // Shallow clone to change object identity (spread replaced with Object.assign) + parent[key] = Object.assign({}, parent[key]); + } + + // Finally, reassign root.keyringData to trigger top-level change + root.keyringData = Object.assign({}, root.keyringData); + + saveKeyringData(); + } + + function fetchKeyringData() { + // console.log("[KeyringStorage] Fetching keyring data..."); + // console.log("[KeyringStorage] getData command:'" + getData.command.join("' '") + "'"); + getData.running = true; + } + + function saveKeyringData() { + saveData.stdinEnabled = true; + saveData.running = true; + } + + Process { + id: saveData + command: [ + "secret-tool", "store", "--label=" + keyringLabel, + ...propertiesAsArgs, + ] + onRunningChanged: { + if (saveData.running) { + // console.log("[KeyringStorage] Saving with command: '" + saveData.command.join("' '") + "'"); + saveData.write(JSON.stringify(root.keyringData)); + stdinEnabled = false // End input stream + } + } + } + + Process { + id: getData + command: [ // We need to use echo for a newline so splitparser does parse + "bash", "-c", `echo $(secret-tool lookup 'application' 'illogical-impulse')`, + ] + stdout: SplitParser { + onRead: data => { + if(data.length === 0) return; + try { + root.keyringData = JSON.parse(data); + // console.log("[KeyringStorage] Keyring data fetched:", JSON.stringify(root.keyringData)); + } catch (e) { + console.error("[KeyringStorage] Failed to get keyring data, reinitializing."); + root.keyringData = {}; + saveKeyringData() + } + } + } + onExited: (exitCode, exitStatus) => { + // console.log("[KeyringStorage] Keyring data fetch process exited with code:", exitCode); + if (exitCode !== 0) { + console.error("[KeyringStorage] Failed to get keyring data, reinitializing."); + root.keyringData = {}; + saveKeyringData() + } + root.loaded = true; + } + } + +} diff --git a/ii/services/LatexRenderer.qml b/ii/services/LatexRenderer.qml new file mode 100644 index 0000000..5baf336 --- /dev/null +++ b/ii/services/LatexRenderer.qml @@ -0,0 +1,83 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common.functions +import qs.modules.common +import QtQuick +import Quickshell + +/** + * Renders LaTeX snippets with MicroTeX. + * For every request: + * 1. Hash it + * 2. Check if the hash is already processed + * 3. If not, render it with MicroTeX and mark as processed + */ +Singleton { + id: root + + readonly property var renderPadding: 4 // This is to prevent cutoff in the rendered images + + property list processedHashes: [] + property var processedExpressions: ({}) + property var renderedImagePaths: ({}) + property string microtexBinaryDir: "/opt/MicroTeX" + property string microtexBinaryName: "LaTeX" + property string latexOutputPath: Directories.latexOutput + + signal renderFinished(string hash, string imagePath) + + /** + * Requests rendering of a LaTeX expression. + * Returns the [hash, isNew] + */ + function requestRender(expression) { + // 1. Hash it and initialize necessary variables + const hash = Qt.md5(expression) + const imagePath = `${latexOutputPath}/${hash}.svg` + + // 2. Check if the hash is already processed + if (processedHashes.includes(hash)) { + // console.log("Already processed: " + hash) + renderFinished(hash, imagePath) + return [hash, false] + } else { + root.processedHashes.push(hash) + root.processedExpressions[hash] = expression + // console.log("Rendering expression: " + expression) + } + + // 3. If not, render it with MicroTeX and mark as processed + // console.log(`[LatexRenderer] Rendering expression: ${expression} with hash: ${hash}`) + // console.log(` to file: ${imagePath}`) + // console.log(` with command: cd ${microtexBinaryDir} && ./${microtexBinaryName} -headless -input=${StringUtils.shellSingleQuoteEscape(expression)} -output=${imagePath} -textsize=${Appearance.font.pixelSize.normal} -padding=${renderPadding} -background=${Appearance.m3colors.m3tertiary} -foreground=${Appearance.m3colors.m3onTertiary} -maxwidth=0.85`) + const processQml = ` + import Quickshell.Io + Process { + id: microtexProcess${hash} + running: true + command: [ "bash", "-c", + "cd ${root.microtexBinaryDir} && ./${root.microtexBinaryName} -headless '-input=${StringUtils.shellSingleQuoteEscape(StringUtils.escapeBackslashes(expression))}' " + + "'-output=${imagePath}' " + + "'-textsize=${Appearance.font.pixelSize.normal}' " + + "'-padding=${renderPadding}' " + // + "'-background=${Appearance.m3colors.m3tertiary}' " + + "'-foreground=${Appearance.colors.colOnLayer1}' " + + "-maxwidth=0.85 " + ] + // stdout: SplitParser { + // onRead: data => { console.log("MicroTeX: " + data) } + // } + onExited: (exitCode, exitStatus) => { + // console.log("[LatexRenderer] MicroTeX process exited with code: " + exitCode + ", status: " + exitStatus) + renderedImagePaths["${hash}"] = "${imagePath}" + root.renderFinished("${hash}", "${imagePath}") + microtexProcess${hash}.destroy() + } + } + ` + // console.log("MicroTeX: " + processQml) + Qt.createQmlObject(processQml, root, `MicroTeXProcess_${hash}`) + return [hash, true] + } +} \ No newline at end of file diff --git a/ii/services/MaterialThemeLoader.qml b/ii/services/MaterialThemeLoader.qml new file mode 100644 index 0000000..8872c47 --- /dev/null +++ b/ii/services/MaterialThemeLoader.qml @@ -0,0 +1,58 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Io + +/** + * Automatically reloads generated material colors. + * It is necessary to run reapplyTheme() on startup because Singletons are lazily loaded. + */ +Singleton { + id: root + property string filePath: Directories.generatedMaterialThemePath + + function reapplyTheme() { + themeFileView.reload() + } + + function applyColors(fileContent) { + const json = JSON.parse(fileContent) + for (const key in json) { + if (json.hasOwnProperty(key)) { + // Convert snake_case to CamelCase + const camelCaseKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase()) + const m3Key = `m3${camelCaseKey}` + Appearance.m3colors[m3Key] = json[key] + } + } + + Appearance.m3colors.darkmode = (Appearance.m3colors.m3background.hslLightness < 0.5) + } + + Timer { + id: delayedFileRead + interval: Config.options?.hacks?.arbitraryRaceConditionDelay ?? 100 + repeat: false + running: false + onTriggered: { + root.applyColors(themeFileView.text()) + } + } + + FileView { + id: themeFileView + path: Qt.resolvedUrl(root.filePath) + watchChanges: true + onFileChanged: { + this.reload() + delayedFileRead.start() + } + onLoadedChanged: { + const fileContent = themeFileView.text() + root.applyColors(fileContent) + } + } +} diff --git a/ii/services/MprisController.qml b/ii/services/MprisController.qml new file mode 100644 index 0000000..60923a6 --- /dev/null +++ b/ii/services/MprisController.qml @@ -0,0 +1,165 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +// From https://git.outfoxxed.me/outfoxxed/nixnew +// It does not have a license, but the author is okay with redistribution. + +import qs +import QtQml.Models +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris + +/** + * A service that provides easy access to the active Mpris player. + */ +Singleton { + id: root; + property MprisPlayer trackedPlayer: null; + property MprisPlayer activePlayer: trackedPlayer ?? Mpris.players.values[0] ?? null; + signal trackChanged(reverse: bool); + + property bool __reverse: false; + + property var activeTrack; + + Instantiator { + model: Mpris.players; + + Connections { + required property MprisPlayer modelData; + target: modelData; + + Component.onCompleted: { + if (root.trackedPlayer == null || modelData.isPlaying) { + root.trackedPlayer = modelData; + } + } + + Component.onDestruction: { + if (root.trackedPlayer == null || !root.trackedPlayer.isPlaying) { + for (const player of Mpris.players.values) { + if (player.playbackState.isPlaying) { + root.trackedPlayer = player; + break; + } + } + + if (trackedPlayer == null && Mpris.players.values.length != 0) { + trackedPlayer = Mpris.players.values[0]; + } + } + } + + function onPlaybackStateChanged() { + if (root.trackedPlayer !== modelData) root.trackedPlayer = modelData; + } + } + } + + Connections { + target: activePlayer + + function onPostTrackChanged() { + root.updateTrack(); + } + + function onTrackArtUrlChanged() { + // console.log("arturl:", activePlayer.trackArtUrl) + // root.updateTrack(); + if (root.activePlayer.uniqueId == root.activeTrack.uniqueId && root.activePlayer.trackArtUrl != root.activeTrack.artUrl) { + // cantata likes to send cover updates *BEFORE* updating the track info. + // as such, art url changes shouldn't be able to break the reverse animation + const r = root.__reverse; + root.updateTrack(); + root.__reverse = r; + + } + } + } + + onActivePlayerChanged: this.updateTrack(); + + function updateTrack() { + //console.log(`update: ${this.activePlayer?.trackTitle ?? ""} : ${this.activePlayer?.trackArtists}`) + this.activeTrack = { + uniqueId: this.activePlayer?.uniqueId ?? 0, + artUrl: this.activePlayer?.trackArtUrl ?? "", + title: this.activePlayer?.trackTitle || Translation.tr("Unknown Title"), + artist: this.activePlayer?.trackArtist || Translation.tr("Unknown Artist"), + album: this.activePlayer?.trackAlbum || Translation.tr("Unknown Album"), + }; + + this.trackChanged(__reverse); + this.__reverse = false; + } + + property bool isPlaying: this.activePlayer && this.activePlayer.isPlaying; + property bool canTogglePlaying: this.activePlayer?.canTogglePlaying ?? false; + function togglePlaying() { + if (this.canTogglePlaying) this.activePlayer.togglePlaying(); + } + + property bool canGoPrevious: this.activePlayer?.canGoPrevious ?? false; + function previous() { + if (this.canGoPrevious) { + this.__reverse = true; + this.activePlayer.previous(); + } + } + + property bool canGoNext: this.activePlayer?.canGoNext ?? false; + function next() { + if (this.canGoNext) { + this.__reverse = false; + this.activePlayer.next(); + } + } + + property bool canChangeVolume: this.activePlayer && this.activePlayer.volumeSupported && this.activePlayer.canControl; + + property bool loopSupported: this.activePlayer && this.activePlayer.loopSupported && this.activePlayer.canControl; + property var loopState: this.activePlayer?.loopState ?? MprisLoopState.None; + function setLoopState(loopState: var) { + if (this.loopSupported) { + this.activePlayer.loopState = loopState; + } + } + + property bool shuffleSupported: this.activePlayer && this.activePlayer.shuffleSupported && this.activePlayer.canControl; + property bool hasShuffle: this.activePlayer?.shuffle ?? false; + function setShuffle(shuffle: bool) { + if (this.shuffleSupported) { + this.activePlayer.shuffle = shuffle; + } + } + + function setActivePlayer(player: MprisPlayer) { + const targetPlayer = player ?? Mpris.players[0]; + console.log(`[Mpris] Active player ${targetPlayer} << ${activePlayer}`) + + if (targetPlayer && this.activePlayer) { + this.__reverse = Mpris.players.indexOf(targetPlayer) < Mpris.players.indexOf(this.activePlayer); + } else { + // always animate forward if going to null + this.__reverse = false; + } + + this.trackedPlayer = targetPlayer; + } + + IpcHandler { + target: "mpris" + + function pauseAll(): void { + for (const player of Mpris.players.values) { + if (player.canPause) player.pause(); + } + } + + function playPause(): void { root.togglePlaying(); } + function previous(): void { root.previous(); } + function next(): void { root.next(); } + } +} diff --git a/ii/services/Network.qml b/ii/services/Network.qml new file mode 100644 index 0000000..ebca2a6 --- /dev/null +++ b/ii/services/Network.qml @@ -0,0 +1,292 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +// Took many bits from https://github.com/caelestia-dots/shell (GPLv3) + +import Quickshell +import Quickshell.Io +import QtQuick +import "./network" + +/** + * Network service with nmcli. + */ +Singleton { + id: root + + property bool wifi: true + property bool ethernet: false + + property bool wifiEnabled: false + property bool wifiScanning: false + property bool wifiConnecting: connectProc.running + property WifiAccessPoint wifiConnectTarget + readonly property list wifiNetworks: [] + readonly property WifiAccessPoint active: wifiNetworks.find(n => n.active) ?? null + + property string networkName: "" + property int networkStrength + property string materialSymbol: ethernet ? "lan" : + wifiEnabled ? ( + Network.networkStrength > 80 ? "signal_wifi_4_bar" : + Network.networkStrength > 60 ? "network_wifi_3_bar" : + Network.networkStrength > 40 ? "network_wifi_2_bar" : + Network.networkStrength > 20 ? "network_wifi_1_bar" : + "signal_wifi_0_bar" + ) : "signal_wifi_off" + + // Control + function enableWifi(enabled = true): void { + const cmd = enabled ? "on" : "off"; + enableWifiProc.exec(["nmcli", "radio", "wifi", cmd]); + } + + function toggleWifi(): void { + enableWifi(!wifiEnabled); + } + + function rescanWifi(): void { + wifiScanning = true; + rescanProcess.running = true; + } + + function connectToWifiNetwork(accessPoint: WifiAccessPoint): void { + accessPoint.askingPassword = false; + root.wifiConnectTarget = accessPoint; + // We use this instead of `nmcli connection up SSID` because this also creates a connection profile + connectProc.exec(["nmcli", "dev", "wifi", "connect", accessPoint.ssid]) + + } + + function disconnectWifiNetwork(): void { + if (active) disconnectProc.exec(["nmcli", "connection", "down", active.ssid]); + } + + function openPublicWifiPortal() { + Quickshell.execDetached(["xdg-open", "https://nmcheck.gnome.org/"]) // From some StackExchange thread, seems to work + } + + function changePassword(network: WifiAccessPoint, password: string, username = ""): void { + // TODO: enterprise wifi with username + network.askingPassword = false; + changePasswordProc.exec({ + "environment": { + "PASSWORD": password + }, + "command": ["bash", "-c", `nmcli connection modify ${network.ssid} wifi-sec.psk "$PASSWORD"`] + }) + } + + Process { + id: enableWifiProc + } + + Process { + id: connectProc + environment: ({ + LANG: "C", + LC_ALL: "C" + }) + stdout: SplitParser { + onRead: line => { + // print(line) + getNetworks.running = true + } + } + stderr: SplitParser { + onRead: line => { + // print("err:", line) + if (line.includes("Secrets were required")) { + root.wifiConnectTarget.askingPassword = true + } + } + } + onExited: (exitCode, exitStatus) => { + root.wifiConnectTarget.askingPassword = (exitCode !== 0) + root.wifiConnectTarget = null + } + } + + Process { + id: disconnectProc + stdout: SplitParser { + onRead: getNetworks.running = true + } + } + + Process { + id: changePasswordProc + onExited: { // Re-attempt connection after changing password + connectProc.running = false + connectProc.running = true + } + } + + Process { + id: rescanProcess + command: ["nmcli", "dev", "wifi", "list", "--rescan", "yes"] + stdout: SplitParser { + onRead: { + wifiScanning = false; + getNetworks.running = true; + } + } + } + + // Status update + function update() { + updateConnectionType.startCheck(); + wifiStatusProcess.running = true + updateNetworkName.running = true; + updateNetworkStrength.running = true; + } + + Process { + id: subscriber + running: true + command: ["nmcli", "monitor"] + stdout: SplitParser { + onRead: root.update() + } + } + + Process { + id: updateConnectionType + property string buffer + command: ["sh", "-c", "nmcli -t -f NAME,TYPE,DEVICE c show --active"] + running: true + function startCheck() { + buffer = ""; + updateConnectionType.running = true; + } + stdout: SplitParser { + onRead: data => { + updateConnectionType.buffer += data + "\n"; + } + } + onExited: (exitCode, exitStatus) => { + const lines = updateConnectionType.buffer.trim().split('\n'); + let hasEthernet = false; + let hasWifi = false; + lines.forEach(line => { + if (line.includes("ethernet")) + hasEthernet = true; + else if (line.includes("wireless")) + hasWifi = true; + }); + root.ethernet = hasEthernet; + root.wifi = hasWifi; + } + } + + Process { + id: updateNetworkName + command: ["sh", "-c", "nmcli -t -f NAME c show --active | head -1"] + running: true + stdout: SplitParser { + onRead: data => { + root.networkName = data; + } + } + } + + Process { + id: updateNetworkStrength + running: true + command: ["sh", "-c", "nmcli -f IN-USE,SIGNAL,SSID device wifi | awk '/^\*/{if (NR!=1) {print $2}}'"] + stdout: SplitParser { + onRead: data => { + root.networkStrength = parseInt(data); + } + } + } + + Process { + id: wifiStatusProcess + command: ["nmcli", "radio", "wifi"] + Component.onCompleted: running = true + environment: ({ + LANG: "C", + LC_ALL: "C" + }) + stdout: StdioCollector { + onStreamFinished: { + root.wifiEnabled = text.trim() === "enabled"; + } + } + } + + Process { + id: getNetworks + running: true + command: ["nmcli", "-g", "ACTIVE,SIGNAL,FREQ,SSID,BSSID,SECURITY", "d", "w"] + environment: ({ + LANG: "C", + LC_ALL: "C" + }) + stdout: StdioCollector { + onStreamFinished: { + const PLACEHOLDER = "STRINGWHICHHOPEFULLYWONTBEUSED"; + const rep = new RegExp("\\\\:", "g"); + const rep2 = new RegExp(PLACEHOLDER, "g"); + + const allNetworks = text.trim().split("\n").map(n => { + const net = n.replace(rep, PLACEHOLDER).split(":"); + return { + active: net[0] === "yes", + strength: parseInt(net[1]), + frequency: parseInt(net[2]), + ssid: net[3], + bssid: net[4]?.replace(rep2, ":") ?? "", + security: net[5] || "" + }; + }).filter(n => n.ssid && n.ssid.length > 0); + + // Group networks by SSID and prioritize connected ones + const networkMap = new Map(); + for (const network of allNetworks) { + const existing = networkMap.get(network.ssid); + if (!existing) { + networkMap.set(network.ssid, network); + } else { + // Prioritize active/connected networks + if (network.active && !existing.active) { + networkMap.set(network.ssid, network); + } else if (!network.active && !existing.active) { + // If both are inactive, keep the one with better signal + if (network.strength > existing.strength) { + networkMap.set(network.ssid, network); + } + } + // If existing is active and new is not, keep existing + } + } + + const wifiNetworks = Array.from(networkMap.values()); + + const rNetworks = root.wifiNetworks; + + const destroyed = rNetworks.filter(rn => !wifiNetworks.find(n => n.frequency === rn.frequency && n.ssid === rn.ssid && n.bssid === rn.bssid)); + for (const network of destroyed) + rNetworks.splice(rNetworks.indexOf(network), 1).forEach(n => n.destroy()); + + for (const network of wifiNetworks) { + const match = rNetworks.find(n => n.frequency === network.frequency && n.ssid === network.ssid && n.bssid === network.bssid); + if (match) { + match.lastIpcObject = network; + } else { + rNetworks.push(apComp.createObject(root, { + lastIpcObject: network + })); + } + } + } + } + } + + Component { + id: apComp + + WifiAccessPoint {} + } +} diff --git a/ii/services/Notifications.qml b/ii/services/Notifications.qml new file mode 100644 index 0000000..f1f2796 --- /dev/null +++ b/ii/services/Notifications.qml @@ -0,0 +1,295 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Notifications + +/** + * Provides extra features not in Quickshell.Services.Notifications: + * - Persistent storage + * - Popup notifications, with timeout + * - Notification groups by app + */ +Singleton { + id: root + component Notif: QtObject { + id: wrapper + required property int notificationId // Could just be `id` but it conflicts with the default prop in QtObject + property Notification notification + property list actions: notification?.actions.map((action) => ({ + "identifier": action.identifier, + "text": action.text, + })) ?? [] + property bool popup: false + property string appIcon: notification?.appIcon ?? "" + property string appName: notification?.appName ?? "" + property string body: notification?.body ?? "" + property string image: notification?.image ?? "" + property string summary: notification?.summary ?? "" + property double time + property string urgency: notification?.urgency.toString() ?? "normal" + property Timer timer + + onNotificationChanged: { + if (notification === null) { + root.discardNotification(notificationId); + } + } + } + + function notifToJSON(notif) { + return { + "notificationId": notif.notificationId, + "actions": notif.actions, + "appIcon": notif.appIcon, + "appName": notif.appName, + "body": notif.body, + "image": notif.image, + "summary": notif.summary, + "time": notif.time, + "urgency": notif.urgency, + } + } + function notifToString(notif) { + return JSON.stringify(notifToJSON(notif), null, 2); + } + + component NotifTimer: Timer { + required property int notificationId + interval: 5000 + running: true + onTriggered: () => { + root.timeoutNotification(notificationId); + destroy() + } + } + + property bool silent: false + property var filePath: Directories.notificationsPath + property list list: [] + property var popupList: list.filter((notif) => notif.popup); + property bool popupInhibited: (GlobalStates?.sidebarRightOpen ?? false) || silent + property var latestTimeForApp: ({}) + Component { + id: notifComponent + Notif {} + } + Component { + id: notifTimerComponent + NotifTimer {} + } + + function stringifyList(list) { + return JSON.stringify(list.map((notif) => notifToJSON(notif)), null, 2); + } + + onListChanged: { + // Update latest time for each app + root.list.forEach((notif) => { + if (!root.latestTimeForApp[notif.appName] || notif.time > root.latestTimeForApp[notif.appName]) { + root.latestTimeForApp[notif.appName] = Math.max(root.latestTimeForApp[notif.appName] || 0, notif.time); + } + }); + // Remove apps that no longer have notifications + Object.keys(root.latestTimeForApp).forEach((appName) => { + if (!root.list.some((notif) => notif.appName === appName)) { + delete root.latestTimeForApp[appName]; + } + }); + } + + function appNameListForGroups(groups) { + return Object.keys(groups).sort((a, b) => { + // Sort by time, descending + return groups[b].time - groups[a].time; + }); + } + + function groupsForList(list) { + const groups = {}; + list.forEach((notif) => { + if (!groups[notif.appName]) { + groups[notif.appName] = { + appName: notif.appName, + appIcon: notif.appIcon, + notifications: [], + time: 0 + }; + } + groups[notif.appName].notifications.push(notif); + // Always set to the latest time in the group + groups[notif.appName].time = latestTimeForApp[notif.appName] || notif.time; + }); + return groups; + } + + property var groupsByAppName: groupsForList(root.list) + property var popupGroupsByAppName: groupsForList(root.popupList) + property var appNameList: appNameListForGroups(root.groupsByAppName) + property var popupAppNameList: appNameListForGroups(root.popupGroupsByAppName) + + // Quickshell's notification IDs starts at 1 on each run, while saved notifications + // can already contain higher IDs. This is for avoiding id collisions + property int idOffset + signal initDone(); + signal notify(notification: var); + signal discard(id: int); + signal discardAll(); + signal timeout(id: var); + + NotificationServer { + id: notifServer + // actionIconsSupported: true + actionsSupported: true + bodyHyperlinksSupported: true + bodyImagesSupported: true + bodyMarkupSupported: true + bodySupported: true + imageSupported: true + keepOnReload: false + persistenceSupported: true + + onNotification: (notification) => { + notification.tracked = true + const newNotifObject = notifComponent.createObject(root, { + "notificationId": notification.id + root.idOffset, + "notification": notification, + "time": Date.now(), + }); + root.list = [...root.list, newNotifObject]; + + // Popup + if (!root.popupInhibited) { + newNotifObject.popup = true; + if (notification.expireTimeout != 0) { + newNotifObject.timer = notifTimerComponent.createObject(root, { + "notificationId": newNotifObject.notificationId, + "interval": notification.expireTimeout < 0 ? 5000 : notification.expireTimeout, + }); + } + } + + root.notify(newNotifObject); + // console.log(notifToString(newNotifObject)); + notifFileView.setText(stringifyList(root.list)); + } + } + + function discardNotification(id) { + console.log("[Notifications] Discarding notification with ID: " + id); + const index = root.list.findIndex((notif) => notif.notificationId === id); + const notifServerIndex = notifServer.trackedNotifications.values.findIndex((notif) => notif.id + root.idOffset === id); + if (index !== -1) { + root.list.splice(index, 1); + notifFileView.setText(stringifyList(root.list)); + triggerListChange() + } + if (notifServerIndex !== -1) { + notifServer.trackedNotifications.values[notifServerIndex].dismiss() + } + root.discard(id); // Emit signal + } + + function discardAllNotifications() { + root.list = [] + triggerListChange() + notifFileView.setText(stringifyList(root.list)); + notifServer.trackedNotifications.values.forEach((notif) => { + notif.dismiss() + }) + root.discardAll(); + } + + function cancelTimeout(id) { + const index = root.list.findIndex((notif) => notif.notificationId === id); + if (root.list[index] != null) + root.list[index].timer.stop(); + } + + function timeoutNotification(id) { + const index = root.list.findIndex((notif) => notif.notificationId === id); + if (root.list[index] != null) + root.list[index].popup = false; + root.timeout(id); + } + + function timeoutAll() { + root.popupList.forEach((notif) => { + root.timeout(notif.notificationId); + }) + root.popupList.forEach((notif) => { + notif.popup = false; + }); + } + + function attemptInvokeAction(id, notifIdentifier) { + console.log("[Notifications] Attempting to invoke action with identifier: " + notifIdentifier + " for notification ID: " + id); + const notifServerIndex = notifServer.trackedNotifications.values.findIndex((notif) => notif.id + root.idOffset === id); + console.log("Notification server index: " + notifServerIndex); + if (notifServerIndex !== -1) { + const notifServerNotif = notifServer.trackedNotifications.values[notifServerIndex]; + const action = notifServerNotif.actions.find((action) => action.identifier === notifIdentifier); + console.log("Action found: " + JSON.stringify(action)); + action.invoke() + } + else { + console.log("Notification not found in server: " + id) + } + root.discardNotification(id); + } + + function triggerListChange() { + root.list = root.list.slice(0) + } + + function refresh() { + notifFileView.reload() + } + + Component.onCompleted: { + refresh() + } + + FileView { + id: notifFileView + path: Qt.resolvedUrl(filePath) + onLoaded: { + const fileContents = notifFileView.text() + root.list = JSON.parse(fileContents).map((notif) => { + return notifComponent.createObject(root, { + "notificationId": notif.notificationId, + "actions": [], // Notification actions are meaningless if they're not tracked by the server or the sender is dead + "appIcon": notif.appIcon, + "appName": notif.appName, + "body": notif.body, + "image": notif.image, + "summary": notif.summary, + "time": notif.time, + "urgency": notif.urgency, + }); + }); + // Find largest notificationId + let maxId = 0 + root.list.forEach((notif) => { + maxId = Math.max(maxId, notif.notificationId) + }) + + console.log("[Notifications] File loaded") + root.idOffset = maxId + root.initDone() + } + onLoadFailed: (error) => { + if(error == FileViewError.FileNotFound) { + console.log("[Notifications] File not found, creating new file.") + root.list = [] + notifFileView.setText(stringifyList(root.list)); + } else { + console.log("[Notifications] Error loading file: " + error) + } + } + } +} diff --git a/ii/services/ResourceUsage.qml b/ii/services/ResourceUsage.qml new file mode 100644 index 0000000..6505284 --- /dev/null +++ b/ii/services/ResourceUsage.qml @@ -0,0 +1,62 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import QtQuick +import Quickshell +import Quickshell.Io + +/** + * Simple polled resource usage service with RAM, Swap, and CPU usage. + */ +Singleton { + property double memoryTotal: 1 + property double memoryFree: 1 + property double memoryUsed: memoryTotal - memoryFree + property double memoryUsedPercentage: memoryUsed / memoryTotal + property double swapTotal: 1 + property double swapFree: 1 + property double swapUsed: swapTotal - swapFree + property double swapUsedPercentage: swapTotal > 0 ? (swapUsed / swapTotal) : 0 + property double cpuUsage: 0 + property var previousCpuStats + + Timer { + interval: 1 + running: true + repeat: true + onTriggered: { + // Reload files + fileMeminfo.reload() + fileStat.reload() + + // Parse memory and swap usage + const textMeminfo = fileMeminfo.text() + memoryTotal = Number(textMeminfo.match(/MemTotal: *(\d+)/)?.[1] ?? 1) + memoryFree = Number(textMeminfo.match(/MemAvailable: *(\d+)/)?.[1] ?? 0) + swapTotal = Number(textMeminfo.match(/SwapTotal: *(\d+)/)?.[1] ?? 1) + swapFree = Number(textMeminfo.match(/SwapFree: *(\d+)/)?.[1] ?? 0) + + // Parse CPU usage + const textStat = fileStat.text() + const cpuLine = textStat.match(/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/) + if (cpuLine) { + const stats = cpuLine.slice(1).map(Number) + const total = stats.reduce((a, b) => a + b, 0) + const idle = stats[3] + + if (previousCpuStats) { + const totalDiff = total - previousCpuStats.total + const idleDiff = idle - previousCpuStats.idle + cpuUsage = totalDiff > 0 ? (1 - idleDiff / totalDiff) : 0 + } + + previousCpuStats = { total, idle } + } + interval = Config.options?.resources?.updateInterval ?? 3000 + } + } + + FileView { id: fileMeminfo; path: "/proc/meminfo" } + FileView { id: fileStat; path: "/proc/stat" } +} diff --git a/ii/services/SystemInfo.qml b/ii/services/SystemInfo.qml new file mode 100644 index 0000000..a8da8e1 --- /dev/null +++ b/ii/services/SystemInfo.qml @@ -0,0 +1,114 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io + +/** + * Provides some system info: distro, username. + */ +Singleton { + id: root + property string distroName: "Unknown" + property string distroId: "unknown" + property string distroIcon: "linux-symbolic" + property string username: "user" + property string homeUrl: "" + property string documentationUrl: "" + property string supportUrl: "" + property string bugReportUrl: "" + property string privacyPolicyUrl: "" + property string logo: "" + property string desktopEnvironment: "" + property string windowingSystem: "" + + Timer { + triggeredOnStart: true + interval: 1 + running: true + repeat: false + onTriggered: { + getUsername.running = true + fileOsRelease.reload() + const textOsRelease = fileOsRelease.text() + + // Extract the friendly name (PRETTY_NAME field, fallback to NAME) + const prettyNameMatch = textOsRelease.match(/^PRETTY_NAME="(.+?)"/m) + const nameMatch = textOsRelease.match(/^NAME="(.+?)"/m) + distroName = prettyNameMatch ? prettyNameMatch[1] : (nameMatch ? nameMatch[1].replace(/Linux/i, "").trim() : "Unknown") + + // Extract the ID + const idMatch = textOsRelease.match(/^ID="?(.+?)"?$/m) + distroId = idMatch ? idMatch[1] : "unknown" + + // Extract additional URLs and logo + const homeUrlMatch = textOsRelease.match(/^HOME_URL="(.+?)"/m) + homeUrl = homeUrlMatch ? homeUrlMatch[1] : "" + const documentationUrlMatch = textOsRelease.match(/^DOCUMENTATION_URL="(.+?)"/m) + documentationUrl = documentationUrlMatch ? documentationUrlMatch[1] : "" + const supportUrlMatch = textOsRelease.match(/^SUPPORT_URL="(.+?)"/m) + supportUrl = supportUrlMatch ? supportUrlMatch[1] : "" + const bugReportUrlMatch = textOsRelease.match(/^BUG_REPORT_URL="(.+?)"/m) + bugReportUrl = bugReportUrlMatch ? bugReportUrlMatch[1] : "" + const privacyPolicyUrlMatch = textOsRelease.match(/^PRIVACY_POLICY_URL="(.+?)"/m) + privacyPolicyUrl = privacyPolicyUrlMatch ? privacyPolicyUrlMatch[1] : "" + const logoFieldMatch = textOsRelease.match(/^LOGO="?(.+?)"?$/m) + logo = logoFieldMatch ? logoFieldMatch[1] : "" + + // Update the distroIcon property based on distroId + switch (distroId) { + case "arch": distroIcon = "arch-symbolic"; break; + case "endeavouros": distroIcon = "endeavouros-symbolic"; break; + case "cachyos": distroIcon = "cachyos-symbolic"; break; + case "nixos": distroIcon = "nixos-symbolic"; break; + case "fedora": distroIcon = "fedora-symbolic"; break; + case "linuxmint": + case "ubuntu": + case "zorin": + case "popos": distroIcon = "ubuntu-symbolic"; break; + case "debian": + case "raspbian": + case "kali": distroIcon = "debian-symbolic"; break; + default: distroIcon = "linux-symbolic"; break; + } + if (textOsRelease.toLowerCase().includes("nyarch")) { + distroIcon = "nyarch-symbolic" + } + + if (logo.trim().length === 0) { + logo = distroIcon + } + + } + } + + Process { + id: getUsername + command: ["whoami"] + stdout: SplitParser { + onRead: data => { + root.username = data.trim() + } + } + } + + Process { + id: getDesktopEnvironment + running: true + command: ["bash", "-c", "echo $XDG_CURRENT_DESKTOP,$WAYLAND_DISPLAY"] + stdout: StdioCollector { + id: deCollector + onStreamFinished: { + const [desktop, wayland] = deCollector.text.split(",") + root.desktopEnvironment = desktop.trim() + root.windowingSystem = wayland.trim().length > 0 ? "Wayland" : "X11" // Are there others? 🤔 + } + } + } + + FileView { + id: fileOsRelease + path: "/etc/os-release" + } +} \ No newline at end of file diff --git a/ii/services/TimerService.qml b/ii/services/TimerService.qml new file mode 100644 index 0000000..e6c8906 --- /dev/null +++ b/ii/services/TimerService.qml @@ -0,0 +1,142 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs +import qs.modules.common + +import Quickshell +import Quickshell.Io +import QtQuick + +/** + * Simple Pomodoro time manager. + */ +Singleton { + id: root + + property int focusTime: Config.options.time.pomodoro.focus + property int breakTime: Config.options.time.pomodoro.breakTime + property int longBreakTime: Config.options.time.pomodoro.longBreak + property int cyclesBeforeLongBreak: Config.options.time.pomodoro.cyclesBeforeLongBreak + property string alertSound: Config.options.time.pomodoro.alertSound + + property bool pomodoroRunning: Persistent.states.timer.pomodoro.running + property bool pomodoroBreak: Persistent.states.timer.pomodoro.isBreak + property bool pomodoroLongBreak: Persistent.states.timer.pomodoro.isBreak && (pomodoroCycle + 1 == cyclesBeforeLongBreak); + property int pomodoroLapDuration: pomodoroLongBreak ? longBreakTime : pomodoroBreak ? breakTime : focusTime // This is a binding that's to be kept + property int pomodoroSecondsLeft: pomodoroLapDuration // Reasonable init value, to be changed + property int pomodoroCycle: Persistent.states.timer.pomodoro.cycle + + property bool stopwatchRunning: Persistent.states.timer.stopwatch.running + property int stopwatchTime: 0 + property int stopwatchStart: Persistent.states.timer.stopwatch.start + property var stopwatchLaps: Persistent.states.timer.stopwatch.laps + + // General + Component.onCompleted: { + if (!stopwatchRunning) + stopwatchReset(); + } + + function getCurrentTimeInSeconds() { // Pomodoro uses Seconds + return Math.floor(Date.now() / 1000); + } + + function getCurrentTimeIn10ms() { // Stopwatch uses 10ms + return Math.floor(Date.now() / 10); + } + + // Pomodoro + function refreshPomodoro() { + // Work <-> break ? + if (getCurrentTimeInSeconds() >= Persistent.states.timer.pomodoro.start + pomodoroLapDuration) { + // Reset counts + Persistent.states.timer.pomodoro.isBreak = !Persistent.states.timer.pomodoro.isBreak; + Persistent.states.timer.pomodoro.start = getCurrentTimeInSeconds(); + + // Send notification + let notificationMessage; + if (Persistent.states.timer.pomodoro.isBreak && (pomodoroCycle + 1 == cyclesBeforeLongBreak)) { + notificationMessage = Translation.tr(`🌿 Long break: %1 minutes`).arg(Math.floor(longBreakTime / 60)); + } else if (Persistent.states.timer.pomodoro.isBreak) { + notificationMessage = Translation.tr(`☕ Break: %1 minutes`).arg(Math.floor(breakTime / 60)); + } else { + notificationMessage = Translation.tr(`🔴 Focus: %1 minutes`).arg(Math.floor(focusTime / 60)); + } + + Quickshell.execDetached(["notify-send", "Pomodoro", notificationMessage, "-a", "Shell"]); + if (alertSound) + Quickshell.execDetached(["ffplay", "-nodisp", "-autoexit", alertSound]); + + if (!pomodoroBreak) { + Persistent.states.timer.pomodoro.cycle = (Persistent.states.timer.pomodoro.cycle + 1) % root.cyclesBeforeLongBreak; + } + } + + pomodoroSecondsLeft = pomodoroLapDuration - (getCurrentTimeInSeconds() - Persistent.states.timer.pomodoro.start); + } + + Timer { + id: pomodoroTimer + interval: 200 + running: root.pomodoroRunning + repeat: true + onTriggered: refreshPomodoro() + } + + function togglePomodoro() { + Persistent.states.timer.pomodoro.running = !pomodoroRunning; + if (Persistent.states.timer.pomodoro.running) { + // Start/Resume + Persistent.states.timer.pomodoro.start = getCurrentTimeInSeconds() + pomodoroSecondsLeft - pomodoroLapDuration; + } + } + + function resetPomodoro() { + Persistent.states.timer.pomodoro.running = false; + Persistent.states.timer.pomodoro.isBreak = false; + Persistent.states.timer.pomodoro.start = getCurrentTimeInSeconds(); + Persistent.states.timer.pomodoro.cycle = 0; + refreshPomodoro(); + } + + // Stopwatch + function refreshStopwatch() { // Stopwatch stores time in 10ms + stopwatchTime = getCurrentTimeIn10ms() - stopwatchStart; + } + + Timer { + id: stopwatchTimer + interval: 10 + running: root.stopwatchRunning + repeat: true + onTriggered: refreshStopwatch() + } + + function toggleStopwatch() { + if (root.stopwatchRunning) + stopwatchPause(); + else + stopwatchResume(); + } + + function stopwatchPause() { + Persistent.states.timer.stopwatch.running = false; + } + + function stopwatchResume() { + if (stopwatchTime === 0) Persistent.states.timer.stopwatch.laps = []; + Persistent.states.timer.stopwatch.running = true; + Persistent.states.timer.stopwatch.start = getCurrentTimeIn10ms() - stopwatchTime; + } + + function stopwatchReset() { + stopwatchTime = 0; + Persistent.states.timer.stopwatch.laps = []; + Persistent.states.timer.stopwatch.running = false; + } + + function stopwatchRecordLap() { + Persistent.states.timer.stopwatch.laps.push(stopwatchTime); + } +} diff --git a/ii/services/Todo.qml b/ii/services/Todo.qml new file mode 100644 index 0000000..93227cb --- /dev/null +++ b/ii/services/Todo.qml @@ -0,0 +1,87 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common +import Quickshell; +import Quickshell.Io; +import QtQuick; + +/** + * Simple to-do list manager. + * Each item is an object with "content" and "done" properties. + */ +Singleton { + id: root + property var filePath: Directories.todoPath + property var list: [] + + function addItem(item) { + list.push(item) + // Reassign to trigger onListChanged + root.list = list.slice(0) + todoFileView.setText(JSON.stringify(root.list)) + } + + function addTask(desc) { + const item = { + "content": desc, + "done": false, + } + addItem(item) + } + + function markDone(index) { + if (index >= 0 && index < list.length) { + list[index].done = true + // Reassign to trigger onListChanged + root.list = list.slice(0) + todoFileView.setText(JSON.stringify(root.list)) + } + } + + function markUnfinished(index) { + if (index >= 0 && index < list.length) { + list[index].done = false + // Reassign to trigger onListChanged + root.list = list.slice(0) + todoFileView.setText(JSON.stringify(root.list)) + } + } + + function deleteItem(index) { + if (index >= 0 && index < list.length) { + list.splice(index, 1) + // Reassign to trigger onListChanged + root.list = list.slice(0) + todoFileView.setText(JSON.stringify(root.list)) + } + } + + function refresh() { + todoFileView.reload() + } + + Component.onCompleted: { + refresh() + } + + FileView { + id: todoFileView + path: Qt.resolvedUrl(root.filePath) + onLoaded: { + const fileContents = todoFileView.text() + root.list = JSON.parse(fileContents) + console.log("[To Do] File loaded") + } + onLoadFailed: (error) => { + if(error == FileViewError.FileNotFound) { + console.log("[To Do] File not found, creating new file.") + root.list = [] + todoFileView.setText(JSON.stringify(root.list)) + } else { + console.log("[To Do] Error loading file: " + error) + } + } + } +} + diff --git a/ii/services/Wallpapers.qml b/ii/services/Wallpapers.qml new file mode 100644 index 0000000..910e9ea --- /dev/null +++ b/ii/services/Wallpapers.qml @@ -0,0 +1,160 @@ +import qs.modules.common +import qs.modules.common.functions +import QtQuick +import Qt.labs.folderlistmodel +import Quickshell +import Quickshell.Io +pragma Singleton +pragma ComponentBehavior: Bound + +/** + * Provides a list of wallpapers and an "apply" action that calls the existing + * switchwall.sh script. Pretty much a limited file browsing service. + */ +Singleton { + id: root + + property string thumbgenScriptPath: `${FileUtils.trimFileProtocol(Directories.scriptPath)}/thumbnails/thumbgen.py` + property string generateThumbnailsMagicScriptPath: `${FileUtils.trimFileProtocol(Directories.scriptPath)}/thumbnails/generate-thumbnails-magick.sh` + property string directory: FileUtils.trimFileProtocol(`${Directories.pictures}/Wallpapers`) + property alias folderModel: folderModel // Expose for direct binding when needed + property string searchQuery: "" + readonly property list extensions: [ // TODO: add videos + "jpg", "jpeg", "png", "webp", "avif", "bmp", "svg" + ] + property list wallpapers: [] // List of absolute file paths (without file://) + readonly property bool thumbnailGenerationRunning: thumbgenProc.running + property real thumbnailGenerationProgress: 0 + + signal changed() + signal thumbnailGenerated(directory: string) + signal thumbnailGeneratedFile(filePath: string) + + // Executions + Process { + id: applyProc + } + + function openFallbackPicker(darkMode = Appearance.m3colors.darkmode) { + applyProc.exec([ + Directories.wallpaperSwitchScriptPath, + "--mode", (darkMode ? "dark" : "light") + ]) + } + + function apply(path, darkMode = Appearance.m3colors.darkmode) { + if (!path || path.length === 0) return + applyProc.exec([ + Directories.wallpaperSwitchScriptPath, + "--image", path, + "--mode", (darkMode ? "dark" : "light") + ]) + root.changed() + } + + Process { + id: selectProc + property string filePath: "" + property bool darkMode: Appearance.m3colors.darkmode + function select(filePath, darkMode = Appearance.m3colors.darkmode) { + selectProc.filePath = filePath + selectProc.darkMode = darkMode + selectProc.exec(["test", "-d", FileUtils.trimFileProtocol(filePath)]) + } + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + setDirectory(selectProc.filePath); + return; + } + root.apply(selectProc.filePath, selectProc.darkMode); + } + } + + function select(filePath, darkMode = Appearance.m3colors.darkmode) { + selectProc.select(filePath, darkMode); + } + + Process { + id: validateDirProc + property string nicePath: "" + function setDirectoryIfValid(path) { + validateDirProc.nicePath = FileUtils.trimFileProtocol(path).replace(/\/+$/, "") + if (/^\/*$/.test(validateDirProc.nicePath)) validateDirProc.nicePath = "/"; + validateDirProc.exec([ + "bash", "-c", + `if [ -d "${validateDirProc.nicePath}" ]; then echo dir; elif [ -f "${validateDirProc.nicePath}" ]; then echo file; else echo invalid; fi` + ]) + } + stdout: StdioCollector { + onStreamFinished: { + const result = text.trim() + if (result === "dir") { + root.directory = validateDirProc.nicePath + } else if (result === "file") { + root.directory = FileUtils.parentDirectory(validateDirProc.nicePath) + } else { + // Ignore + } + } + } + } + function setDirectory(path) { + validateDirProc.setDirectoryIfValid(path) + } + + // Folder model + FolderListModel { + id: folderModel + folder: Qt.resolvedUrl(root.directory) + caseSensitive: false + nameFilters: root.extensions.map(ext => `*${searchQuery.split(" ").filter(s => s.length > 0).map(s => `*${s}*`)}*.${ext}`) + showDirs: true + showDotAndDotDot: false + showOnlyReadable: true + sortField: FolderListModel.Time + sortReversed: false + onCountChanged: { + root.wallpapers = [] + for (let i = 0; i < folderModel.count; i++) { + const path = folderModel.get(i, "filePath") || FileUtils.trimFileProtocol(folderModel.get(i, "fileURL")) + if (path && path.length) root.wallpapers.push(path) + } + } + } + + // Thumbnail generation + function generateThumbnail(size: string) { + if (!["normal", "large", "x-large", "xx-large"].includes(size)) throw new Error("Invalid thumbnail size"); + thumbgenProc.directory = root.directory + thumbgenProc.running = false + thumbgenProc.command = [ + "bash", "-c", + `${thumbgenScriptPath} --size ${size} --machine_progress -d ${root.directory} || ${generateThumbnailsMagicScriptPath} --size ${size} -d ${root.directory}`, + ] + root.thumbnailGenerationProgress = 0 + thumbgenProc.running = true + } + Process { + id: thumbgenProc + property string directory + stdout: SplitParser { + onRead: data => { + // print("thumb gen proc:", data) + let match = data.match(/PROGRESS (\d+)\/(\d+)/) + if (match) { + const completed = parseInt(match[1]) + const total = parseInt(match[2]) + root.thumbnailGenerationProgress = completed / total + } + match = data.match(/FILE (.+)/) + if (match) { + const filePath = match[1] + root.thumbnailGeneratedFile(filePath) + } + } + } + onExited: (exitCode, exitStatus) => { + root.thumbnailGenerated(thumbgenProc.directory) + } + } +} diff --git a/ii/services/Weather.qml b/ii/services/Weather.qml new file mode 100644 index 0000000..bf6be89 --- /dev/null +++ b/ii/services/Weather.qml @@ -0,0 +1,158 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import QtQuick +import QtPositioning + +import qs.modules.common + +Singleton { + id: root + // 10 minute + readonly property int fetchInterval: Config.options.bar.weather.fetchInterval * 60 * 1000 + readonly property string city: Config.options.bar.weather.city + readonly property bool useUSCS: Config.options.bar.weather.useUSCS + property bool gpsActive: Config.options.bar.weather.enableGPS + + property var location: ({ + valid: false, + lat: 0, + lon: 0 + }) + + property var data: ({ + uv: 0, + humidity: 0, + sunrise: 0, + sunset: 0, + windDir: 0, + wCode: 0, + city: 0, + wind: 0, + precip: 0, + visib: 0, + press: 0, + temp: 0, + tempFeelsLike: 0 + }) + + function refineData(data) { + let temp = {}; + temp.uv = data?.current?.uvIndex || 0; + temp.humidity = (data?.current?.humidity || 0) + "%"; + temp.sunrise = data?.astronomy?.sunrise || "0.0"; + temp.sunset = data?.astronomy?.sunset || "0.0"; + temp.windDir = data?.current?.winddir16Point || "N"; + temp.wCode = data?.current?.weatherCode || "113"; + temp.city = data?.location?.areaName[0]?.value || "City"; + temp.temp = ""; + temp.tempFeelsLike = ""; + if (root.useUSCS) { + temp.wind = (data?.current?.windspeedMiles || 0) + " mph"; + temp.precip = (data?.current?.precipInches || 0) + " in"; + temp.visib = (data?.current?.visibilityMiles || 0) + " m"; + temp.press = (data?.current?.pressureInches || 0) + " psi"; + temp.temp += (data?.current?.temp_F || 0); + temp.tempFeelsLike += (data?.current?.FeelsLikeF || 0); + temp.temp += "°F"; + temp.tempFeelsLike += "°F"; + } else { + temp.wind = (data?.current?.windspeedKmph || 0) + " km/h"; + temp.precip = (data?.current?.precipMM || 0) + " mm"; + temp.visib = (data?.current?.visibility || 0) + " km"; + temp.press = (data?.current?.pressure || 0) + " hPa"; + temp.temp += (data?.current?.temp_C || 0); + temp.tempFeelsLike += (data?.current?.FeelsLikeC || 0); + temp.temp += "°C"; + temp.tempFeelsLike += "°C"; + } + root.data = temp; + } + + function getData() { + let command = "curl -s wttr.in"; + + if (root.gpsActive && root.location.valid) { + command += `/${root.location.lat},${root.location.long}`; + } else { + command += `/${formatCityName(root.city)}`; + } + + // format as json + command += "?format=j1"; + command += " | "; + // only take the current weather, location, asytronmy data + command += "jq '{current: .current_condition[0], location: .nearest_area[0], astronomy: .weather[0].astronomy[0]}'"; + fetcher.command[2] = command; + fetcher.running = true; + } + + function formatCityName(cityName) { + return cityName.trim().split(/\s+/).join('+'); + } + + Component.onCompleted: { + if (!root.gpsActive) return; + console.info("[WeatherService] Starting the GPS service."); + positionSource.start(); + } + + Process { + id: fetcher + command: ["bash", "-c", ""] + stdout: StdioCollector { + onStreamFinished: { + if (text.length === 0) + return; + try { + const parsedData = JSON.parse(text); + root.refineData(parsedData); + // console.info(`[ data: ${JSON.stringify(parsedData)}`); + } catch (e) { + console.error(`[WeatherService] ${e.message}`); + } + } + } + } + + PositionSource { + id: positionSource + updateInterval: root.fetchInterval + + onPositionChanged: { + // update the location if the given location is valid + // if it fails getting the location, use the last valid location + if (position.latitudeValid && position.longitudeValid) { + root.location.lat = position.coordinate.latitude; + root.location.long = position.coordinate.longitude; + root.location.valid = true; + // console.info(`📍 Location: ${position.coordinate.latitude}, ${position.coordinate.longitude}`); + root.getData(); + // if can't get initialized with valid location deactivate the GPS + } else { + root.gpsActive = root.location.valid ? true : false; + console.error("[WeatherService] Failed to get the GPS location."); + } + } + + onValidityChanged: { + if (!positionSource.valid) { + positionSource.stop(); + root.location.valid = false; + root.gpsActive = false; + Quickshell.execDetached(["notify-send", Translation.tr("Weather Service"), Translation.tr("Cannot find a GPS service. Using the fallback method instead."), "-a", "Shell"]); + console.error("[WeatherService] Could not aquire a valid backend plugin."); + } + } + } + + Timer { + running: !root.gpsActive + repeat: true + interval: root.fetchInterval + triggeredOnStart: !root.gpsActive + onTriggered: root.getData() + } +} diff --git a/ii/services/Ydotool.qml b/ii/services/Ydotool.qml new file mode 100644 index 0000000..f25b093 --- /dev/null +++ b/ii/services/Ydotool.qml @@ -0,0 +1,47 @@ +pragma Singleton + +import qs.modules.common +import Quickshell + +Singleton { + id: root + property int shiftMode: 0 // 0: off, 1: on, 2: lock + property list shiftKeys: [42, 54] // Keycodes for Shift keys (left and right) + property list altKeys: [56, 100] // Keycodes for Alt keys (left and right) + property list ctrlKeys: [29, 97] // Keycodes for Ctrl keys (left and right) + + function releaseAllKeys() { + const keycodes = Array.from(Array(249).keys()); + Quickshell.execDetached([ + "ydotool", + "key", "--key-delay", "0", + ...keycodes.map(keycode => `${keycode}:0`) + ]) + root.shiftMode = 0; // Reset shift mode + } + + function releaseShiftKeys() { + Quickshell.execDetached([ + "ydotool", + "key", "--key-delay", "0", + ...root.shiftKeys.map(keycode => `${keycode}:0`) + ]) + root.shiftMode = 0; // Reset shift mode + } + + function press(keycode) { + Quickshell.execDetached([ + "ydotool", + "key", "--key-delay", "0", + `${keycode}:1` + ]); + } + + function release(keycode) { + Quickshell.execDetached([ + "ydotool", + "key", "--key-delay", "0", + `${keycode}:0` + ]); + } +} diff --git a/ii/services/ai/AiMessageData.qml b/ii/services/ai/AiMessageData.qml new file mode 100644 index 0000000..f715b28 --- /dev/null +++ b/ii/services/ai/AiMessageData.qml @@ -0,0 +1,24 @@ +import QtQuick; + +/** + * Represents a message in an AI conversation. (Kind of) follows the OpenAI API message structure. + */ +QtObject { + property string role + property string content + property string rawContent + property string fileMimeType + property string fileUri + property string localFilePath + property string model + property bool thinking: true + property bool done: false + property var annotations: [] + property var annotationSources: [] + property list searchQueries: [] + property string functionName + property var functionCall + property string functionResponse + property bool functionPending: false + property bool visibleToUser: true +} diff --git a/ii/services/ai/AiModel.qml b/ii/services/ai/AiModel.qml new file mode 100644 index 0000000..7cf9852 --- /dev/null +++ b/ii/services/ai/AiModel.qml @@ -0,0 +1,32 @@ +import QtQuick; + +/** + * An AI model representation. + * - name: Friendly name of the model + * - icon: Icon name of the model + * - description: Description of the model + * - endpoint: Endpoint of the model + * - model: Model code (like gpt-4.1 or gemini-2.5-flash) + * - requires_key: Whether the model requires an API key + * - key_id: The identifier of the API key. Use the same identifier for models that can be accessed with the same key. + * - key_get_link: Link to get an API key + * - key_get_description: Description of pricing and how to get an API key + * - api_format: The API format of the model. Can be "openai" or "gemini". Default is "openai". + * - extraParams: Extra parameters to be passed to the model. This is a JSON object. + */ + +QtObject { + property string name + property string icon + property string description + property string homepage + property string endpoint + property string model + property bool requires_key: true + property string key_id + property string key_get_link + property string key_get_description + property string api_format: "openai" + property var tools + property var extraParams: ({}) +} diff --git a/ii/services/ai/ApiStrategy.qml b/ii/services/ai/ApiStrategy.qml new file mode 100644 index 0000000..87f0af6 --- /dev/null +++ b/ii/services/ai/ApiStrategy.qml @@ -0,0 +1,12 @@ +import QtQuick + +QtObject { + function buildEndpoint(model: AiModel): string { throw new Error("Not implemented") } + function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list, filePath: string) { throw new Error("Not implemented") } + function buildAuthorizationHeader(apiKeyEnvVarName: string): string { throw new Error("Not implemented") } + function parseResponseLine(line: string, message: AiMessageData) { throw new Error("Not implemented") } + function onRequestFinished(message: AiMessageData): var { return {} } // Default: no special handling + function reset() { } // Reset any internal state if needed + function buildScriptFileSetup(filePath) { return "" } // Default: no setup + function finalizeScriptContent(scriptContent: string): string { return scriptContent } // Optionally modify/finalize script +} diff --git a/ii/services/ai/GeminiApiStrategy.qml b/ii/services/ai/GeminiApiStrategy.qml new file mode 100644 index 0000000..b610c3d --- /dev/null +++ b/ii/services/ai/GeminiApiStrategy.qml @@ -0,0 +1,241 @@ +import QtQuick +import qs.modules.common.functions as CF + +ApiStrategy { + readonly property string apiKeyEnvVarName: "API_KEY" + readonly property string fileUriVarName: "file_uri" + readonly property string fileMimeTypeVarName: "MIME_TYPE" + readonly property string fileUriSubstitutionString: "{{ fileUriVarName }}" + readonly property string fileMimeTypeSubstitutionString: "{{ fileMimeTypeVarName }}" + property string buffer: "" + + function buildEndpoint(model: AiModel): string { + const result = model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}` + // console.log("[AI] Endpoint: " + result); + return result; + } + + function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list, filePath: string) { + let contents = messages.map(message => { + // console.log("[AI] Building request data for message:", JSON.stringify(message, null, 2)); + const geminiApiRoleName = (message.role === "assistant") ? "model" : message.role; + const usingSearch = tools[0]?.google_search !== undefined + if (!usingSearch && message.functionCall != undefined && message.functionName.length > 0) { + return { + "role": geminiApiRoleName, + "parts": [{ + functionCall: { + "name": message.functionName, + } + }] + } + } + if (!usingSearch && message.functionResponse != undefined && message.functionName.length > 0) { + return { + "role": geminiApiRoleName, + "parts": [{ + functionResponse: { + "name": message.functionName, + "response": { "content": message.functionResponse } + } + }] + } + } + return { + "role": geminiApiRoleName, + "parts": [ + { text: message.rawContent }, + ...(message.fileUri && message.fileUri.length > 0 ? [{ + "file_data": { + "mime_type": message.fileMimeType, + "file_uri": message.fileUri + } + }] : []) + ] + } + }) + if (filePath && filePath.length > 0) { + const trimmedFilePath = CF.FileUtils.trimFileProtocol(filePath); + // Add file_data part to the last message's parts array + contents[contents.length - 1].parts.unshift({ + file_data: { + mime_type: fileMimeTypeSubstitutionString, + file_uri: fileUriSubstitutionString + } + }); + } + let baseData = { + "contents": contents, + "tools": tools, + "system_instruction": { + "parts": [{ text: systemPrompt }] + }, + "generationConfig": { + "temperature": temperature, + }, + }; + // print("Gemini API call payload:", JSON.stringify(baseData, null, 2)); + return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData; + } + + function buildAuthorizationHeader(apiKeyEnvVarName: string): string { + // Gemini doesn't use Authorization header, key is in URL + return ""; + } + + function parseResponseLine(line, message) { + if (line.startsWith("[")) { + buffer += line.slice(1).trim(); + } else if (line === "]") { + buffer += line.slice(0, -1).trim(); + return parseBuffer(message); + } else if (line.startsWith(",")) { + return parseBuffer(message); + } else { + buffer += line.trim(); + } + return {}; + } + + function parseBuffer(message) { + // console.log("[Ai] Gemini buffer: ", buffer); + let finished = false; + try { + if (buffer.length === 0) return {}; + const dataJson = JSON.parse(buffer); + + // Uploaded file + if (dataJson.uploadedFile) { + message.fileUri = dataJson.uploadedFile.uri; + message.fileMimeType = dataJson.uploadedFile.mimeType; + return ({}) + } + + // No candidates? + if (!dataJson.candidates) return {}; + + // Finished? + if (dataJson.candidates[0]?.finishReason) { + finished = true; + } + + // Function call handling + if (dataJson.candidates[0]?.content?.parts[0]?.functionCall) { + const functionCall = dataJson.candidates[0]?.content?.parts[0]?.functionCall; + message.functionName = functionCall.name; + message.functionCall = functionCall.name; + const newContent = `\n\n[[ Function: ${functionCall.name}(${JSON.stringify(functionCall.args, null, 2)}) ]]\n` + message.rawContent += newContent; + message.content += newContent; + return { functionCall: { name: functionCall.name, args: functionCall.args }, finished: finished }; + } + + // Normal text response + const responseContent = dataJson.candidates[0]?.content?.parts[0]?.text + message.rawContent += responseContent; + message.content += responseContent; + + // Handle annotations and metadata + const annotationSources = dataJson.candidates[0]?.groundingMetadata?.groundingChunks?.map(chunk => { + return { + "type": "url_citation", + "text": chunk?.web?.title, + "url": chunk?.web?.uri, + } + }) ?? []; + + const annotations = dataJson.candidates[0]?.groundingMetadata?.groundingSupports?.map(citation => { + return { + "type": "url_citation", + "start_index": citation.segment?.startIndex, + "end_index": citation.segment?.endIndex, + "text": citation?.segment.text, + "url": annotationSources[citation.groundingChunkIndices[0]]?.url, + "sources": citation.groundingChunkIndices + } + }); + message.annotationSources = annotationSources; + message.annotations = annotations; + message.searchQueries = dataJson.candidates[0]?.groundingMetadata?.webSearchQueries ?? []; + + // Usage metadata + if (dataJson.usageMetadata) { + return { + tokenUsage: { + input: dataJson.usageMetadata.promptTokenCount ?? -1, + output: dataJson.usageMetadata.candidatesTokenCount ?? -1, + total: dataJson.usageMetadata.totalTokenCount ?? -1 + }, + finished: finished + }; + } + + } catch (e) { + console.log("[AI] Gemini: Could not parse buffer: ", e); + message.rawContent += buffer; + message.content += buffer; + } finally { + buffer = ""; + } + return { finished: finished }; + } + + function onRequestFinished(message) { + return parseBuffer(message); + } + + function reset() { + buffer = ""; + } + + function buildScriptFileSetup(filePath) { + const trimmedFilePath = CF.FileUtils.trimFileProtocol(filePath); + let content = "" + + // print("file path:", filePath) + // print("trimmed file path:", trimmedFilePath) + // print("escaped file path:", CF.StringUtils.shellSingleQuoteEscape(trimmedFilePath)) + + content += `IMAGE_PATH='${CF.StringUtils.shellSingleQuoteEscape(trimmedFilePath)}'\n`; + content += `${fileMimeTypeVarName}=$(file -b --mime-type "$IMAGE_PATH")\n`; + content += 'NUM_BYTES=$(wc -c < "${IMAGE_PATH}")\n'; + content += 'tmp_header_file="/tmp/quickshell/ai/upload-header.tmp"\n'; + content += 'tmp_file_info_file="/tmp/quickshell/ai/file-info.json.tmp"\n'; + + // Initial resumable request defining metadata. + // The upload url is in the response headers dump them to a file. + content += 'curl "https://generativelanguage.googleapis.com/upload/v1beta/files"' + + ` -H "x-goog-api-key: \$${apiKeyEnvVarName}"` + + ' -D $tmp_header_file' + + ' -H "X-Goog-Upload-Protocol: resumable"' + + ' -H "X-Goog-Upload-Command: start"' + + ' -H "X-Goog-Upload-Header-Content-Length: ${NUM_BYTES}"' + + ` -H "X-Goog-Upload-Header-Content-Type: \${${fileMimeTypeVarName}}"` + + ' -H "Content-Type: application/json"' + + ` -d "{'file': {'display_name': 'Image'}}" 2> /dev/null` + + '\n'; + + // Get file upload header + content += 'upload_url=$(grep -i "x-goog-upload-url: " "${tmp_header_file}" | cut -d" " -f2 | tr -d "\r")\n'; + content += 'rm "${tmp_header_file}"\n'; + + // Upload the actual file + content += 'curl "${upload_url}"' + + ` -H "x-goog-api-key: \$${apiKeyEnvVarName}"` + + ' -H "Content-Length: ${NUM_BYTES}"' + + ' -H "X-Goog-Upload-Offset: 0"' + + ' -H "X-Goog-Upload-Command: upload, finalize"' + + ' --data-binary "@${IMAGE_PATH}" 2> /dev/null > "${tmp_file_info_file}"' + + '\n'; + + content += `${fileUriVarName}=$(jq -r ".file.uri" "$tmp_file_info_file")\n` + content += `printf "{\\"uploadedFile\\": {\\"uri\\": \\"$${fileUriVarName}\\", \\"mimeType\\": \\"$${fileMimeTypeVarName}\\"}}\\n,\\n"\n` + + return content + } + + function finalizeScriptContent(scriptContent: string): string { + return scriptContent.replace(fileMimeTypeSubstitutionString, `'"\$${fileMimeTypeVarName}"'`) + .replace(fileUriSubstitutionString, `'"\$${fileUriVarName}"'`); + } +} diff --git a/ii/services/ai/MistralApiStrategy.qml b/ii/services/ai/MistralApiStrategy.qml new file mode 100644 index 0000000..3978111 --- /dev/null +++ b/ii/services/ai/MistralApiStrategy.qml @@ -0,0 +1,142 @@ +import QtQuick + +ApiStrategy { + property bool isReasoning: false + + function buildEndpoint(model: AiModel): string { + // console.log("[AI] Endpoint: " + model.endpoint); + return model.endpoint; + } + + function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list, filePath: string) { + let baseData = { + "model": model.model, + "messages": [ + {role: "system", content: systemPrompt}, + ...messages.map(message => { + const hasFunctionCall = message.functionCall != undefined && message.functionName.length > 0 + let messageData = { + "role": message.role, + "content": message.rawContent, + } + if (hasFunctionCall) { + if (message.functionResponse?.length > 0) { + messageData.name = message.functionName; // Does the func call also need this name? or just the func output? + messageData.role = "tool"; + messageData.content = message.functionResponse; + messageData.tool_call_id = message.functionCall.id + } + } + return messageData + }), + ], + "stream": true, + "temperature": temperature, + "tools": tools, + }; + // console.log("[AI] Request data: ", JSON.stringify(baseData, null, 2)); + return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData; + } + + function buildAuthorizationHeader(apiKeyEnvVarName: string): string { + return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`; + } + + function parseResponseLine(line, message) { + // Remove 'data: ' prefix if present and trim whitespace + let cleanData = line.trim(); + if (cleanData.startsWith("data:")) { + cleanData = cleanData.slice(5).trim(); + } + + // Handle special cases + if (!cleanData || cleanData.startsWith(":")) return {}; + if (cleanData === "[DONE]") { + return { finished: true }; + } + + // Real stuff + try { + const dataJson = JSON.parse(cleanData); + if (dataJson.error) { + const err = dataJson.error; + const errMsg = `[Error] ${err.type ?? ""} ${err.code ?? ""} ${err.param ? "(" + err.param + ")" : ""}\n${err.message ?? JSON.stringify(err)}`; + message.rawContent += `\n\n${errMsg}\n`; + message.content += `\n\n${errMsg}\n`; + return { finished: true }; + } + let newContent = ""; + + const responseContent = dataJson.choices[0]?.delta?.content || dataJson.message?.content; + const responseReasoning = dataJson.choices[0]?.delta?.reasoning || dataJson.choices[0]?.delta?.reasoning_content; + + // Function call + if (dataJson.choices[0]?.delta?.tool_calls) { + const functionCall = dataJson.choices[0].delta.tool_calls[0]; + const functionName = functionCall.function.name; + const functionArgs = JSON.parse(functionCall.function.arguments) || {}; // Args are given as string??? + const functionId = functionCall.id; + const newContent = `\n\n[[ Function: ${functionName}(${JSON.stringify(functionArgs, null, 2)}) ]]\n`; + message.rawContent += newContent; + message.content += newContent; + message.functionName = functionName; + message.functionCall = functionName; + return { functionCall: { name: functionName, args: functionArgs, id: functionId } }; + } + + // Thinking? + if (responseContent && responseContent.length > 0) { + if (isReasoning) { + isReasoning = false; + const endBlock = "\n\n
\n\n"; + message.content += endBlock; + message.rawContent += endBlock; + } + newContent = responseContent; + } else if (responseReasoning && responseReasoning.length > 0) { + if (!isReasoning) { + isReasoning = true; + const startBlock = "\n\n\n\n"; + message.rawContent += startBlock; + message.content += startBlock; + } + newContent = responseReasoning; + } + + // Text + message.content += newContent; + message.rawContent += newContent; + + // Usage metadata + if (dataJson.usage) { + return { + tokenUsage: { + input: dataJson.usage.prompt_tokens ?? -1, + output: dataJson.usage.completion_tokens ?? -1, + total: dataJson.usage.total_tokens ?? -1 + } + }; + } + + if (`dataJson`.done) { + return { finished: true }; + } + + } catch (e) { + console.log("[AI] Mistral: Could not parse line: ", e); + message.rawContent += line; + message.content += line; + } + + return {}; + } + + function onRequestFinished(message) { + return {}; + } + + function reset() { + isReasoning = false; + } + +} diff --git a/ii/services/ai/OpenAiApiStrategy.qml b/ii/services/ai/OpenAiApiStrategy.qml new file mode 100644 index 0000000..2a3081c --- /dev/null +++ b/ii/services/ai/OpenAiApiStrategy.qml @@ -0,0 +1,187 @@ +import QtQuick +import qs.modules.common.functions as CF + +ApiStrategy { + property bool isReasoning: false + readonly property string fileMimeTypeVarName: "MIME_TYPE" + readonly property string base64VarName: "FILE_B64" + readonly property string filePathVarName: "FILE_PATH" + readonly property string base64Placeholder: "{{ FILE_B64 }}" + readonly property string mimePlaceholder: "{{ MIME_TYPE }}" + readonly property string dataUrlPlaceholder: "{{ DATA_URL }}" + readonly property string filePartPlaceholder: "{{ FILE_PART_JSON }}" + + function buildEndpoint(model: AiModel): string { + // console.log("[AI] Endpoint: " + model.endpoint); + return model.endpoint; + } + + function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list, filePath: string) { + // Map messages; if a file is attached and it's an image, include an image_url content part with data URL + const hasFile = !!(filePath && filePath.length > 0); + const isOpenRouter = (model.endpoint || "").indexOf("openrouter.ai") !== -1; + const mappedMessages = [ + { role: "system", content: systemPrompt }, + ...messages.map((message, idx, arr) => { + const isLastUser = (idx === arr.length - 1) && message.role === "user"; + if (isLastUser && hasFile) { + // Defer building the right content part (image or text) to the shell prelude + // by inserting a placeholder JSON object here. + return { + role: message.role, + content: [ + { type: "text", text: message.rawContent }, + filePartPlaceholder + ] + }; + } + return { role: message.role, content: message.rawContent }; + }) + ]; + + let baseData = { + "model": model.model, + "messages": mappedMessages, + "stream": true, + "tools": tools, + "temperature": temperature, + }; + return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData; + } + + function buildAuthorizationHeader(apiKeyEnvVarName: string): string { + return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`; + } + + function parseResponseLine(line, message) { + // Remove 'data: ' prefix if present and trim whitespace + let cleanData = line.trim(); + if (cleanData.startsWith("data:")) { + cleanData = cleanData.slice(5).trim(); + } + + // console.log("[AI] OpenAI: Data:", cleanData); + + // Handle special cases + if (!cleanData) return {}; + if (cleanData.startsWith(":")) { + // Some providers emit comment lines starting with ':'; show them as debug + // const dbg = cleanData.slice(1).trim(); + // if (dbg.length > 0) { + // message.rawContent += `\n\n[debug] ${dbg}\n`; + // message.content += `\n\n[debug] ${dbg}\n`; + // } + return {}; + } + if (cleanData === "[DONE]") { + return { finished: true }; + } + + // Real stuff + try { + const dataJson = JSON.parse(cleanData); + if (dataJson.error) { + const err = dataJson.error; + const errMsg = `[Error] ${err.type ?? ""} ${err.code ?? ""} ${err.param ? "(" + err.param + ")" : ""}\n${err.message ?? JSON.stringify(err)}`; + message.rawContent += `\n\n${errMsg}\n`; + message.content += `\n\n${errMsg}\n`; + return { finished: true }; + } + let newContent = ""; + + const responseContent = dataJson.choices[0]?.delta?.content || dataJson.message?.content; + const responseReasoning = dataJson.choices[0]?.delta?.reasoning || dataJson.choices[0]?.delta?.reasoning_content; + + if (responseContent && responseContent.length > 0) { + if (isReasoning) { + isReasoning = false; + const endBlock = "\n\n\n\n"; + message.content += endBlock; + message.rawContent += endBlock; + } + newContent = responseContent; + } else if (responseReasoning && responseReasoning.length > 0) { + if (!isReasoning) { + isReasoning = true; + const startBlock = "\n\n\n\n"; + message.rawContent += startBlock; + message.content += startBlock; + } + newContent = responseReasoning; + } + + message.content += newContent; + message.rawContent += newContent; + + // Usage metadata + if (dataJson.usage) { + return { + tokenUsage: { + input: dataJson.usage.prompt_tokens ?? -1, + output: dataJson.usage.completion_tokens ?? -1, + total: dataJson.usage.total_tokens ?? -1 + } + }; + } + + if (dataJson.done) { + return { finished: true }; + } + + } catch (e) { + console.log("[AI] OpenAI: Could not parse line: ", e); + message.rawContent += line; + message.content += line; + } + + return {}; + } + + function onRequestFinished(message) { + // OpenAI format doesn't need special finish handling + return {}; + } + + function reset() { + isReasoning = false; + } + + function buildScriptFileSetup(filePath) { + if (!filePath || filePath.length === 0) return ""; + let content = ""; + content += `${filePathVarName}='${CF.StringUtils.shellSingleQuoteEscape(filePath)}'\n`; + content += `${fileMimeTypeVarName}=$(file -b --mime-type "\$${filePathVarName}")\n`; + // Try GNU coreutils base64 with no wrap; fallback to generic base64 and strip newlines + content += `${base64VarName}=$(base64 -w 0 "\$${filePathVarName}" 2>/dev/null || base64 "\$${filePathVarName}" | tr -d '\n')\n`; + // Build FILE_PART_JSON depending on MIME: image -> image_url with data URL; text/docs -> inline text + content += 'if [[ "$' + fileMimeTypeVarName + '" == image/* ]]; then\n'; + content += " FILE_PART_JSON=$(printf '{\"type\":\"image_url\",\"image_url\":{\"url\":\"data:%s;base64,%s\"}}' \"$" + fileMimeTypeVarName + "\" \"$" + base64VarName + "\")\n"; + content += 'else\n'; + // Try to produce text content + content += ' if [[ "$' + fileMimeTypeVarName + '" == text/* ]] || [[ "$' + fileMimeTypeVarName + '" == application/json ]] || [[ "$' + fileMimeTypeVarName + '" == application/xml ]] || [[ "$' + fileMimeTypeVarName + '" =~ \+json$ ]] || [[ "$' + fileMimeTypeVarName + '" =~ \+xml$ ]]; then\n'; + content += " FILE_TEXT_JSON=$(jq -Rsa . \"$" + filePathVarName + "\")\n"; + content += ' elif [[ "$' + fileMimeTypeVarName + '" == application/vnd.openxmlformats-officedocument.wordprocessingml.document ]]; then\n'; + content += ' if command -v pandoc >/dev/null 2>&1; then\n'; + content += " FILE_TEXT_JSON=$(pandoc -t plain \"$" + filePathVarName + "\" 2>/dev/null | jq -Rsa .)\n"; + content += ' else\n'; + content += " FILE_TEXT_JSON=$(printf \"DOCX attachment provided but pandoc not found. Install pandoc to extract text\\nFile: %s\" \"$" + filePathVarName + "\" | jq -Rsa .)\n"; + content += ' fi\n'; + content += ' else\n'; + content += " FILE_TEXT_JSON=$(printf \"Attached file (%s) included as base64 (first 8KB):\\n%s\" \"$" + fileMimeTypeVarName + "\" \"$(printf %s \\\"$" + base64VarName + "\\\" | cut -c1-8192)\" | jq -Rsa .)\n"; + content += ' fi\n'; + content += " FILE_PART_JSON=$(printf '{\"type\":\"text\",\"text\":%s}' \"$FILE_TEXT_JSON\")\n"; + content += 'fi\n'; + return content; + } + + function finalizeScriptContent(scriptContent) { + // Replace the QUOTED placeholder ("{{ FILE_PART_JSON }}") with FILE_PART_JSON (raw JSON object) + const quotedPartPlaceholder = `\"${filePartPlaceholder}\"`; + let result = scriptContent.replace(quotedPartPlaceholder, `\$FILE_PART_JSON`); + // Also support older placeholder if present + const quotedDataUrlPlaceholder = `\"${dataUrlPlaceholder}\"`; + result = result.replace(quotedDataUrlPlaceholder, `\"data:\\$${fileMimeTypeVarName};base64,\\$${base64VarName}\"`); + return result; + } + +} diff --git a/ii/services/network/WifiAccessPoint.qml b/ii/services/network/WifiAccessPoint.qml new file mode 100644 index 0000000..55ee811 --- /dev/null +++ b/ii/services/network/WifiAccessPoint.qml @@ -0,0 +1,14 @@ +import QtQuick + +QtObject { + required property var lastIpcObject + readonly property string ssid: lastIpcObject.ssid + readonly property string bssid: lastIpcObject.bssid + readonly property int strength: lastIpcObject.strength + readonly property int frequency: lastIpcObject.frequency + readonly property bool active: lastIpcObject.active + readonly property string security: lastIpcObject.security + readonly property bool isSecure: security.length > 0 + + property bool askingPassword: false +} diff --git a/ii/settings.qml b/ii/settings.qml new file mode 100644 index 0000000..0f52b23 --- /dev/null +++ b/ii/settings.qml @@ -0,0 +1,282 @@ +//@ pragma UseQApplication +//@ pragma Env QS_NO_RELOAD_POPUP=1 +//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic +//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 + +// Adjust this to make the app smaller or larger +//@ pragma Env QT_SCALE_FACTOR=1 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions as CF + +ApplicationWindow { + id: root + property string firstRunFilePath: CF.FileUtils.trimFileProtocol(`${Directories.state}/user/first_run.txt`) + property string firstRunFileContent: "This file is just here to confirm you've been greeted :>" + property real contentPadding: 8 + property bool showNextTime: false + property var pages: [ + { + name: Translation.tr("Quick"), + icon: "instant_mix", + component: "modules/settings/QuickConfig.qml" + }, + { + name: Translation.tr("General"), + icon: "browse", + iconRotation: 180, + component: "modules/settings/GeneralConfig.qml" + }, + { + name: Translation.tr("Bar"), + icon: "toast", + iconRotation: 180, + component: "modules/settings/BarConfig.qml" + }, + { + name: Translation.tr("Interface"), + icon: "bottom_app_bar", + component: "modules/settings/InterfaceConfig.qml" + }, + { + name: Translation.tr("Services"), + icon: "settings", + component: "modules/settings/ServicesConfig.qml" + }, + { + name: Translation.tr("Advanced"), + icon: "construction", + component: "modules/settings/AdvancedConfig.qml" + }, + { + name: Translation.tr("About"), + icon: "info", + component: "modules/settings/About.qml" + } + ] + property int currentPage: 0 + + visible: true + onClosing: Qt.quit() + title: "illogical-impulse Settings" + + Component.onCompleted: { + MaterialThemeLoader.reapplyTheme() + } + + minimumWidth: 600 + minimumHeight: 400 + width: 1100 + height: 750 + color: Appearance.m3colors.m3background + + ColumnLayout { + anchors { + fill: parent + margins: contentPadding + } + + Keys.onPressed: (event) => { + if (event.modifiers === Qt.ControlModifier) { + if (event.key === Qt.Key_PageDown) { + root.currentPage = Math.min(root.currentPage + 1, root.pages.length - 1) + event.accepted = true; + } + else if (event.key === Qt.Key_PageUp) { + root.currentPage = Math.max(root.currentPage - 1, 0) + event.accepted = true; + } + else if (event.key === Qt.Key_Tab) { + root.currentPage = (root.currentPage + 1) % root.pages.length; + event.accepted = true; + } + else if (event.key === Qt.Key_Backtab) { + root.currentPage = (root.currentPage - 1 + root.pages.length) % root.pages.length; + event.accepted = true; + } + } + } + + Item { // Titlebar + visible: Config.options?.windows.showTitlebar + Layout.fillWidth: true + Layout.fillHeight: false + implicitHeight: Math.max(titleText.implicitHeight, windowControlsRow.implicitHeight) + StyledText { + id: titleText + anchors { + left: Config.options.windows.centerTitle ? undefined : parent.left + horizontalCenter: Config.options.windows.centerTitle ? parent.horizontalCenter : undefined + verticalCenter: parent.verticalCenter + leftMargin: 12 + } + color: Appearance.colors.colOnLayer0 + text: Translation.tr("Settings") + font.pixelSize: Appearance.font.pixelSize.title + font.family: Appearance.font.family.title + } + RowLayout { // Window controls row + id: windowControlsRow + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + RippleButton { + buttonRadius: Appearance.rounding.full + implicitWidth: 35 + implicitHeight: 35 + onClicked: root.close() + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "close" + iconSize: 20 + } + } + } + } + + RowLayout { // Window content with navigation rail and content pane + Layout.fillWidth: true + Layout.fillHeight: true + spacing: contentPadding + Item { + id: navRailWrapper + Layout.fillHeight: true + Layout.margins: 5 + implicitWidth: navRail.expanded ? 150 : fab.baseSize + Behavior on implicitWidth { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + NavigationRail { // Window content with navigation rail and content pane + id: navRail + anchors { + left: parent.left + top: parent.top + bottom: parent.bottom + } + spacing: 10 + expanded: root.width > 900 + + NavigationRailExpandButton { + focus: root.visible + } + + FloatingActionButton { + id: fab + iconText: "edit" + buttonText: Translation.tr("Config file") + expanded: navRail.expanded + onClicked: { + Qt.openUrlExternally(`${Directories.config}/illogical-impulse/config.json`); + } + + StyledToolTip { + content: Translation.tr("Open the shell config file.\nIf the button doesn't work or doesn't open in your favorite editor,\nyou can manually open ~/.config/illogical-impulse/config.json") + } + } + + NavigationRailTabArray { + currentIndex: root.currentPage + expanded: navRail.expanded + Repeater { + model: root.pages + NavigationRailButton { + required property var index + required property var modelData + toggled: root.currentPage === index + onClicked: root.currentPage = index; + expanded: navRail.expanded + buttonIcon: modelData.icon + buttonIconRotation: modelData.iconRotation || 0 + buttonText: modelData.name + showToggledHighlight: false + } + } + } + + Item { + Layout.fillHeight: true + } + } + } + Rectangle { // Content container + Layout.fillWidth: true + Layout.fillHeight: true + color: Appearance.m3colors.m3surfaceContainerLow + radius: Appearance.rounding.windowRounding - root.contentPadding + + Loader { + id: pageLoader + anchors.fill: parent + opacity: 1.0 + + active: Config.ready + Component.onCompleted: { + source = root.pages[0].component + } + + Connections { + target: root + function onCurrentPageChanged() { + if (pageLoader.sourceComponent !== root.pages[root.currentPage].component) { + switchAnim.complete(); + switchAnim.start(); + } + } + } + + SequentialAnimation { + id: switchAnim + + NumberAnimation { + target: pageLoader + properties: "opacity" + from: 1 + to: 0 + duration: 100 + easing.type: Appearance.animation.elementMoveExit.type + easing.bezierCurve: Appearance.animationCurves.emphasizedFirstHalf + } + ParallelAnimation { + PropertyAction { + target: pageLoader + property: "source" + value: root.pages[root.currentPage].component + } + PropertyAction { + target: pageLoader + property: "anchors.topMargin" + value: 20 + } + } + ParallelAnimation { + NumberAnimation { + target: pageLoader + properties: "opacity" + from: 0 + to: 1 + duration: 200 + easing.type: Appearance.animation.elementMoveEnter.type + easing.bezierCurve: Appearance.animationCurves.emphasizedLastHalf + } + NumberAnimation { + target: pageLoader + properties: "anchors.topMargin" + to: 0 + duration: 200 + easing.type: Appearance.animation.elementMoveEnter.type + easing.bezierCurve: Appearance.animationCurves.emphasizedLastHalf + } + } + } + } + } + } + } +} diff --git a/ii/shell.qml b/ii/shell.qml new file mode 100644 index 0000000..f2062cc --- /dev/null +++ b/ii/shell.qml @@ -0,0 +1,83 @@ +//@ pragma UseQApplication +//@ pragma Env QS_NO_RELOAD_POPUP=1 +//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic +//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 + +// Adjust this to make the shell smaller or larger +//@ pragma Env QT_SCALE_FACTOR=1 + + +import "./modules/common/" +import "./modules/background/" +import "./modules/bar/" +import "./modules/cheatsheet/" +import "./modules/dock/" +import "./modules/lock/" +import "./modules/mediaControls/" +import "./modules/notificationPopup/" +import "./modules/onScreenDisplay/" +import "./modules/onScreenKeyboard/" +import "./modules/overview/" +import "./modules/screenCorners/" +import "./modules/sessionScreen/" +import "./modules/sidebarLeft/" +import "./modules/sidebarRight/" +import "./modules/verticalBar/" +import "./modules/wallpaperSelector/" + +import QtQuick +import QtQuick.Window +import Quickshell +import "./services/" + +ShellRoot { + // Enable/disable modules here. False = not loaded at all, so rest assured + // no unnecessary stuff will take up memory if you decide to only use, say, the overview. + property bool enableBar: true + property bool enableBackground: true + property bool enableCheatsheet: true + property bool enableDock: true + property bool enableLock: true + property bool enableMediaControls: true + property bool enableNotificationPopup: true + property bool enableOnScreenDisplayBrightness: true + property bool enableOnScreenDisplayVolume: true + property bool enableOnScreenKeyboard: true + property bool enableOverview: true + property bool enableReloadPopup: true + property bool enableScreenCorners: true + property bool enableSessionScreen: true + property bool enableSidebarLeft: true + property bool enableSidebarRight: true + property bool enableVerticalBar: true + property bool enableWallpaperSelector: true + + // Force initialization of some singletons + Component.onCompleted: { + MaterialThemeLoader.reapplyTheme() + Hyprsunset.load() + FirstRunExperience.load() + ConflictKiller.load() + Cliphist.refresh() + } + + LazyLoader { active: enableBar && Config.ready && !Config.options.bar.vertical; component: Bar {} } + LazyLoader { active: enableBackground; component: Background {} } + LazyLoader { active: enableCheatsheet; component: Cheatsheet {} } + LazyLoader { active: enableDock && Config.options.dock.enable; component: Dock {} } + LazyLoader { active: enableLock; component: Lock {} } + LazyLoader { active: enableMediaControls; component: MediaControls {} } + LazyLoader { active: enableNotificationPopup; component: NotificationPopup {} } + LazyLoader { active: enableOnScreenDisplayBrightness; component: OnScreenDisplayBrightness {} } + LazyLoader { active: enableOnScreenDisplayVolume; component: OnScreenDisplayVolume {} } + LazyLoader { active: enableOnScreenKeyboard; component: OnScreenKeyboard {} } + LazyLoader { active: enableOverview; component: Overview {} } + LazyLoader { active: enableReloadPopup; component: ReloadPopup {} } + LazyLoader { active: enableScreenCorners; component: ScreenCorners {} } + LazyLoader { active: enableSessionScreen; component: SessionScreen {} } + LazyLoader { active: enableSidebarLeft; component: SidebarLeft {} } + LazyLoader { active: enableSidebarRight; component: SidebarRight {} } + LazyLoader { active: enableVerticalBar && Config.ready && Config.options.bar.vertical; component: VerticalBar {} } + LazyLoader { active: enableWallpaperSelector; component: WallpaperSelector {} } +} + diff --git a/ii/welcome.qml b/ii/welcome.qml new file mode 100644 index 0000000..fefdc79 --- /dev/null +++ b/ii/welcome.qml @@ -0,0 +1,447 @@ +//@ pragma UseQApplication +//@ pragma Env QS_NO_RELOAD_POPUP=1 +//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic +//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 + +// Adjust this to make the app smaller or larger +//@ pragma Env QT_SCALE_FACTOR=1 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import Quickshell +import Quickshell.Io +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions + +ApplicationWindow { + id: root + property string firstRunFilePath: FileUtils.trimFileProtocol(`${Directories.state}/user/first_run.txt`) + property string firstRunFileContent: "This file is just here to confirm you've been greeted :>" + property real contentPadding: 8 + property bool showNextTime: false + visible: true + onClosing: { + Quickshell.execDetached(["notify-send", Translation.tr("Welcome app"), Translation.tr("Enjoy! You can reopen the welcome app any time with Super+Shift+Alt+/. To open the settings app, hit Super+I"), "-a", "Shell"]); + Qt.quit(); + } + title: Translation.tr("illogical-impulse Welcome") + + Component.onCompleted: { + MaterialThemeLoader.reapplyTheme(); + } + + minimumWidth: 600 + minimumHeight: 400 + width: 900 + height: 650 + color: Appearance.m3colors.m3background + + Process { + id: konachanWallProc + property string status: "" + command: ["bash", "-c", Quickshell.shellPath("scripts/colors/random_konachan_wall.sh")] + stdout: SplitParser { + onRead: data => { + console.log(`Konachan wall proc output: ${data}`); + konachanWallProc.status = data.trim(); + } + } + } + + ColumnLayout { + anchors { + fill: parent + margins: contentPadding + } + + Item { + // Titlebar + visible: Config.options?.windows.showTitlebar + Layout.fillWidth: true + implicitHeight: Math.max(welcomeText.implicitHeight, windowControlsRow.implicitHeight) + StyledText { + id: welcomeText + anchors { + left: Config.options.windows.centerTitle ? undefined : parent.left + horizontalCenter: Config.options.windows.centerTitle ? parent.horizontalCenter : undefined + verticalCenter: parent.verticalCenter + leftMargin: 12 + } + color: Appearance.colors.colOnLayer0 + text: Translation.tr("Hi there! First things first...") + font.pixelSize: Appearance.font.pixelSize.title + font.family: Appearance.font.family.title + } + RowLayout { // Window controls row + id: windowControlsRow + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + StyledText { + font.pixelSize: Appearance.font.pixelSize.smaller + text: Translation.tr("Show next time") + } + StyledSwitch { + id: showNextTimeSwitch + checked: root.showNextTime + scale: 0.6 + Layout.alignment: Qt.AlignVCenter + onCheckedChanged: { + if (checked) { + Quickshell.execDetached(["rm", root.firstRunFilePath]); + } else { + Quickshell.execDetached(["bash", "-c", `echo '${StringUtils.shellSingleQuoteEscape(root.firstRunFileContent)}' > '${StringUtils.shellSingleQuoteEscape(root.firstRunFilePath)}'`]); + } + } + } + RippleButton { + buttonRadius: Appearance.rounding.full + implicitWidth: 35 + implicitHeight: 35 + onClicked: root.close() + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "close" + iconSize: 20 + } + } + } + } + + Rectangle { + // Content container + color: Appearance.m3colors.m3surfaceContainerLow + radius: Appearance.rounding.windowRounding - root.contentPadding + implicitHeight: contentColumn.implicitHeight + implicitWidth: contentColumn.implicitWidth + Layout.fillWidth: true + Layout.fillHeight: true + + ContentPage { + id: contentColumn + anchors.fill: parent + + ContentSection { + Layout.fillWidth: true + icon: "language" + title: Translation.tr("Language") + + ConfigSelectionArray { + id: languageSelector + currentValue: Config.options.language.ui + onSelected: newValue => { + Config.options.language.ui = newValue; + } + options: [ + { + displayName: Translation.tr("Auto (System)"), + value: "auto" + }, + ...Translation.availableLanguages.map(lang => { + return { + displayName: lang.replace('_', '-'), + value: lang + }; + })] + } + } + + ContentSection { + icon: "screenshot_monitor" + title: Translation.tr("Bar") + + ConfigRow { + ContentSubsection { + title: Translation.tr("Bar position") + ConfigSelectionArray { + currentValue: (Config.options.bar.bottom ? 1 : 0) | (Config.options.bar.vertical ? 2 : 0) + onSelected: newValue => { + Config.options.bar.bottom = (newValue & 1) !== 0; + Config.options.bar.vertical = (newValue & 2) !== 0; + } + options: [ + { + displayName: Translation.tr("Top"), + icon: "arrow_upward", + value: 0 // bottom: false, vertical: false + }, + { + displayName: Translation.tr("Left"), + icon: "arrow_back", + value: 2 // bottom: false, vertical: true + }, + { + displayName: Translation.tr("Bottom"), + icon: "arrow_downward", + value: 1 // bottom: true, vertical: false + }, + { + displayName: Translation.tr("Right"), + icon: "arrow_forward", + value: 3 // bottom: true, vertical: true + } + ] + } + } + ContentSubsection { + title: Translation.tr("Bar style") + + ConfigSelectionArray { + currentValue: Config.options.bar.cornerStyle + onSelected: newValue => { + Config.options.bar.cornerStyle = newValue; // Update local copy + } + options: [ + { + displayName: Translation.tr("Hug"), + icon: "line_curve", + value: 0 + }, + { + displayName: Translation.tr("Float"), + icon: "page_header", + value: 1 + }, + { + displayName: Translation.tr("Rect"), + icon: "toolbar", + value: 2 + } + ] + } + } + } + } + + ContentSection { + icon: "format_paint" + title: Translation.tr("Style & wallpaper") + + ButtonGroup { + Layout.alignment: Qt.AlignHCenter + LightDarkPreferenceButton { + dark: false + } + LightDarkPreferenceButton { + dark: true + } + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + RippleButtonWithIcon { + id: rndWallBtn + visible: Config.options.policies.weeb === 1 + Layout.alignment: Qt.AlignHCenter + buttonRadius: Appearance.rounding.small + materialIcon: "wallpaper" + mainText: konachanWallProc.running ? Translation.tr("Be patient...") : Translation.tr("Random: Konachan") + onClicked: { + console.log(konachanWallProc.command.join(" ")); + konachanWallProc.running = true; + } + StyledToolTip { + content: Translation.tr("Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers") + } + } + RippleButtonWithIcon { + materialIcon: "wallpaper" + StyledToolTip { + content: Translation.tr("Pick wallpaper image on your system") + } + onClicked: { + Quickshell.execDetached([`${Directories.wallpaperSwitchScriptPath}`]); + } + mainContentComponent: Component { + RowLayout { + spacing: 10 + StyledText { + font.pixelSize: Appearance.font.pixelSize.small + text: Translation.tr("Choose file") + color: Appearance.colors.colOnSecondaryContainer + } + RowLayout { + spacing: 3 + KeyboardKey { + key: "Ctrl" + } + KeyboardKey { + key: "󰖳" + } + StyledText { + Layout.alignment: Qt.AlignVCenter + text: "+" + } + KeyboardKey { + key: "T" + } + } + } + } + } + } + + NoticeBox { + Layout.fillWidth: true + text: Translation.tr("Change any time later with /dark, /light, /wallpaper in the launcher\nIf the shell's colors aren't changing:\n 1. Open the right sidebar with Super+N\n 2. Click \"Reload Hyprland & Quickshell\" in the top-right corner") + } + } + + ContentSection { + icon: "rule" + title: Translation.tr("Policies") + + ConfigRow { + Layout.fillWidth: true + + ContentSubsection { + title: "Weeb" + + ConfigSelectionArray { + currentValue: Config.options.policies.weeb + onSelected: newValue => { + Config.options.policies.weeb = newValue; + } + options: [ + { + displayName: Translation.tr("No"), + icon: "close", + value: 0 + }, + { + displayName: Translation.tr("Yes"), + icon: "check", + value: 1 + }, + { + displayName: Translation.tr("Closet"), + icon: "ev_shadow", + value: 2 + } + ] + } + } + + ContentSubsection { + title: "AI" + + ConfigSelectionArray { + currentValue: Config.options.policies.ai + onSelected: newValue => { + Config.options.policies.ai = newValue; + } + options: [ + { + displayName: Translation.tr("No"), + icon: "close", + value: 0 + }, + { + displayName: Translation.tr("Yes"), + icon: "check", + value: 1 + }, + { + displayName: Translation.tr("Local only"), + icon: "sync_saved_locally", + value: 2 + } + ] + } + } + } + } + + ContentSection { + icon: "info" + title: Translation.tr("Info") + + Flow { + Layout.fillWidth: true + spacing: 5 + + RippleButtonWithIcon { + materialIcon: "keyboard_alt" + onClicked: { + Quickshell.execDetached(["qs", "-p", Quickshell.shellPath(""), "ipc", "call", "cheatsheet", "toggle"]); + } + mainContentComponent: Component { + RowLayout { + spacing: 10 + StyledText { + font.pixelSize: Appearance.font.pixelSize.small + text: Translation.tr("Keybinds") + color: Appearance.colors.colOnSecondaryContainer + } + RowLayout { + spacing: 3 + KeyboardKey { + key: "󰖳" + } + StyledText { + Layout.alignment: Qt.AlignVCenter + text: "+" + } + KeyboardKey { + key: "/" + } + } + } + } + } + + RippleButtonWithIcon { + materialIcon: "help" + mainText: Translation.tr("Usage") + onClicked: { + Qt.openUrlExternally("https://end-4.github.io/dots-hyprland-wiki/en/ii-qs/02usage/"); + } + } + RippleButtonWithIcon { + materialIcon: "construction" + mainText: Translation.tr("Configuration") + onClicked: { + Qt.openUrlExternally("https://end-4.github.io/dots-hyprland-wiki/en/ii-qs/03config/"); + } + } + } + } + + ContentSection { + icon: "monitoring" + title: Translation.tr("Useless buttons") + + Flow { + Layout.fillWidth: true + spacing: 5 + + RippleButtonWithIcon { + nerdIcon: "󰊤" + mainText: Translation.tr("GitHub") + onClicked: { + Qt.openUrlExternally("https://github.com/end-4/dots-hyprland"); + } + } + RippleButtonWithIcon { + materialIcon: "favorite" + mainText: "Funny number" + onClicked: { + Qt.openUrlExternally("https://github.com/sponsors/end-4"); + } + } + } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + } + } + } + } +} diff --git a/translations/en_US.json b/translations/en_US.json new file mode 100644 index 0000000..d588990 --- /dev/null +++ b/translations/en_US.json @@ -0,0 +1,399 @@ +{ + "Mo": "Mo/*keep*/", + "Tu": "Tu/*keep*/", + "We": "We/*keep*/", + "Th": "Th/*keep*/", + "Fr": "Fr/*keep*/", + "Sa": "Sa/*keep*/", + "Su": "Su/*keep*/", + "%1 characters": "%1 characters", + "**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key": "**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key", + "**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key": "**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key", + ". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!": ". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!", + "No further instruction provided": "No further instruction provided", + "Action": "Action", + "Add": "Add", + "Add task": "Add task", + "All-rounder | Good quality, decent quantity": "All-rounder | Good quality, decent quantity", + "Allow NSFW": "Allow NSFW", + "Allow NSFW content": "Allow NSFW content", + "Anime": "Anime", + "Anime boorus": "Anime boorus", + "App": "App", + "Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel": "Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel", + "Bluetooth": "Bluetooth", + "Brightness": "Brightness", + "Cancel": "Cancel", + "Cheat sheet": "Cheat sheet", + "Choose model": "Choose model", + "Clean stuff | Excellent quality, no NSFW": "Clean stuff | Excellent quality, no NSFW", + "Clear": "Clear", + "Clear chat history": "Clear chat history", + "Clear the current list of images": "Clear the current list of images", + "Close": "Close", + "Copy": "Copy", + "Copy code": "Copy code", + "Delete": "Delete", + "Desktop": "Desktop", + "Disable NSFW content": "Disable NSFW content", + "Done": "Done", + "Download": "Download", + "Edit": "Edit", + "Enter text to translate...": "Enter text to translate...", + "Finished tasks will go here": "Finished tasks will go here", + "For desktop wallpapers | Good quality": "For desktop wallpapers | Good quality", + "For storing API keys and other sensitive information": "For storing API keys and other sensitive information", + "Game mode": "Game mode", + "Get the next page of results": "Get the next page of results", + "Hibernate": "Hibernate", + "Input": "Input", + "Intelligence": "Intelligence", + "Interface": "Interface", + "Invalid arguments. Must provide `key` and `value`.": "Invalid arguments. Must provide `key` and `value`.", + "Jump to current month": "Jump to current month", + "Keep system awake": "Keep system awake", + "Large images | God tier quality, no NSFW.": "Large images | God tier quality, no NSFW.", + "Large language models": "Large language models", + "Launch": "Launch", + "Lock": "Lock", + "Logout": "Logout", + "Markdown test": "Markdown test", + "Math result": "Math result", + "No audio source": "No audio source", + "No media": "No media", + "No notifications": "No notifications", + "Not visible to model": "Not visible to model", + "Nothing here!": "Nothing here!", + "Notifications": "Notifications", + "OK": "OK", + "Open file link": "Open file link", + "Output": "Output", + "Reboot": "Reboot", + "Reboot to firmware settings": "Reboot to firmware settings", + "Reload Hyprland & Quickshell": "Reload Hyprland & Quickshell", + "Run": "Run", + "Run command": "Run command", + "Save": "Save", + "Save to Downloads": "Save to Downloads", + "Search": "Search", + "Search the web": "Search the web", + "Search, calculate or run": "Search, calculate or run", + "Select Language": "Select Language", + "Session": "Session", + "Set API key": "Set API key", + "Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5.": "Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5.", + "Set the current API provider": "Set the current API provider", + "Shutdown": "Shutdown", + "Silent": "Silent", + "Sleep": "Sleep", + "System": "System", + "Task Manager": "Task Manager", + "Task description": "Task description", + "Temperature must be between 0 and 2": "Temperature must be between 0 and 2", + "The hentai one | Great quantity, a lot of NSFW, quality varies wildly": "The hentai one | Great quantity, a lot of NSFW, quality varies wildly", + "The popular one | Best quantity, but quality can vary wildly": "The popular one | Best quantity, but quality can vary wildly", + "Thinking": "Thinking", + "Translation goes here...": "Translation goes here...", + "Translator": "Translator", + "Unfinished": "Unfinished", + "Unknown": "Unknown", + "Unknown Album": "Unknown Album", + "Unknown Artist": "Unknown Artist", + "Unknown Title": "Unknown Title", + "View Markdown source": "View Markdown source", + "Volume": "Volume", + "Volume mixer": "Volume mixer", + "Waifus only | Excellent quality, limited quantity": "Waifus only | Excellent quality, limited quantity", + "Waiting for response...": "Waiting for response...", + "Workspace": "Workspace", + "Invalid API provider. Supported: \n-": "Invalid API provider. Supported: \n-", + "Unknown command:": "Unknown command:", + "Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window": "Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window", + "Provider set to": "Provider set to", + "Invalid model. Supported: \n```": "Invalid model. Supported: \n```", + "That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number": "That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number", + "Switched to search mode. Continue with the user's request.": "Switched to search mode. Continue with the user's request.", + "Settings": "Settings", + "Save chat": "Save chat", + "Load chat": "Load chat", + "or": "or", + "Set the system prompt for the model.": "Set the system prompt for the model.", + "To Do": "To Do", + "Calendar": "Calendar", + "Advanced": "Advanced", + "About": "About", + "Services": "Services", + "Style": "Style", + "Colors & Wallpaper": "Colors & Wallpaper", + "Light": "Light", + "Dark": "Dark", + "Material palette": "Material palette", + "Fidelity": "Fidelity", + "Fruit Salad": "Fruit Salad", + "Alternatively use /dark, /light, /img in the launcher": "Alternatively use /dark, /light, /img in the launcher", + "Fake screen rounding": "Fake screen rounding", + "When not fullscreen": "When not fullscreen", + "Choose file": "Choose file", + "Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers": "Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers", + "Be patient...": "Be patient...", + "Decorations & Effects": "Decorations & Effects", + "Tonal Spot": "Tonal Spot", + "Shell windows": "Shell windows", + "Auto": "Auto", + "Wallpaper": "Wallpaper", + "Content": "Content", + "Title bar": "Title bar", + "Transparency": "Transparency", + "Expressive": "Expressive", + "Yes": "Yes", + "Enable": "Enable", + "Rainbow": "Rainbow", + "Might look ass. Unsupported.": "Might look ass. Unsupported.", + "Monochrome": "Monochrome", + "Random: Konachan": "Random: Konachan", + "Center title": "Center title", + "Neutral": "Neutral", + "Pick wallpaper image on your system": "Pick wallpaper image on your system", + "No": "No", + "AI": "AI", + "Local only": "Local only", + "Policies": "Policies", + "Weeb": "Weeb", + "Closet": "Closet", + "Show next time": "Show next time", + "Usage": "Usage", + "Plain rectangle": "Plain rectangle", + "Useless buttons": "Useless buttons", + "GitHub": "GitHub", + "Style & wallpaper": "Style & wallpaper", + "Configuration": "Configuration", + "Change any time later with /dark, /light, /img in the launcher": "Change any time later with /dark, /light, /img in the launcher", + "Keybinds": "Keybinds", + "Float": "Float", + "Hug": "Hug", + "illogical-impulse Welcome": "illogical-impulse Welcome", + "Info": "Info", + "Volume limit": "Volume limit", + "Prevents abrupt increments and restricts volume limit": "Prevents abrupt increments and restricts volume limit", + "Resources": "Resources", + "12h am/pm": "12h am/pm", + "Base URL": "Base URL", + "Audio": "Audio", + "Networking": "Networking", + "Format": "Format", + "Time": "Time", + "Battery": "Battery", + "Prefixes": "Prefixes", + "Emojis": "Emojis", + "Earbang protection": "Earbang protection", + "Automatically suspends the system when battery is low": "Automatically suspends the system when battery is low", + "Automatic suspend": "Automatic suspend", + "Suspend at": "Suspend at", + "Max allowed increase": "Max allowed increase", + "Web search": "Web search", + "Polling interval (ms)": "Polling interval (ms)", + "Clipboard": "Clipboard", + "Low warning": "Low warning", + "24h": "24h", + "Use Levenshtein distance-based algorithm instead of fuzzy": "Use Levenshtein distance-based algorithm instead of fuzzy", + "System prompt": "System prompt", + "12h AM/PM": "12h AM/PM", + "Could be better if you make a ton of typos,\nbut results can be weird and might not work with acronyms\n(e.g. \"GIMP\" might not give you the paint program)": "Could be better if you make a ton of typos,\nbut results can be weird and might not work with acronyms\n(e.g. \"GIMP\" might not give you the paint program)", + "Critical warning": "Critical warning", + "User agent (for services that require it)": "User agent (for services that require it)", + "Such regions could be images or parts of the screen that have some containment.\nMight not always be accurate.\nThis is done with an image processing algorithm run locally and no AI is used.": "Such regions could be images or parts of the screen that have some containment.\nMight not always be accurate.\nThis is done with an image processing algorithm run locally and no AI is used.", + "Note: turning off can hurt readability": "Note: turning off can hurt readability", + "Workspaces shown": "Workspaces shown", + "Dark/Light toggle": "Dark/Light toggle", + "Dock": "Dock", + "Weather": "Weather", + "Pinned on startup": "Pinned on startup", + "Tip: Hide icons and always show numbers for\nthe classic illogical-impulse experience": "Tip: Hide icons and always show numbers for\nthe classic illogical-impulse experience", + "Always show numbers": "Always show numbers", + "Buttons": "Buttons", + "Keyboard toggle": "Keyboard toggle", + "Scale (%)": "Scale (%)", + "Overview": "Overview", + "Rows": "Rows", + "Borderless": "Borderless", + "Screenshot tool": "Screenshot tool", + "Number show delay when pressing Super (ms)": "Number show delay when pressing Super (ms)", + "Timeout (ms)": "Timeout (ms)", + "Show app icons": "Show app icons", + "Workspaces": "Workspaces", + "Columns": "Columns", + "On-screen display": "On-screen display", + "Screen snip": "Screen snip", + "Mic toggle": "Mic toggle", + "Hover to reveal": "Hover to reveal", + "Bar": "Bar", + "Show background": "Show background", + "Show regions of potential interest": "Show regions of potential interest", + "Color picker": "Color picker", + "Help & Support": "Help & Support", + "Discussions": "Discussions", + "Color generation": "Color generation", + "Dotfiles": "Dotfiles", + "Distro": "Distro", + "Privacy Policy": "Privacy Policy", + "Documentation": "Documentation", + "Shell & utilities theming must also be enabled": "Shell & utilities theming must also be enabled", + "Ignored if terminal theming is not enabled": "Ignored if terminal theming is not enabled", + "illogical-impulse": "illogical-impulse", + "Donate": "Donate", + "Terminal": "Terminal", + "Shell & utilities": "Shell & utilities", + "Qt apps": "Qt apps", + "Force dark mode in terminal": "Force dark mode in terminal", + "Report a Bug": "Report a Bug", + "Issues": "Issues", + "Drag or click a region • LMB: Copy • RMB: Edit": "Drag or click a region • LMB: Copy • RMB: Edit", + "Current model: %1\nSet it with %2model MODEL": "Current model: %1\nSet it with %2model MODEL", + "Message the model... \"%1\" for commands": "Message the model... \"%1\" for commands", + "No API key set for %1": "No API key set for %1", + "Loaded the following system prompt\n\n---\n\n%1": "Loaded the following system prompt\n\n---\n\n%1", + "%1 | Right-click to configure": "%1 | Right-click to configure", + "API key set for %1": "API key set for %1", + "Online via %1 | %2's model": "Online via %1 | %2's model", + "Current API endpoint: %1\nSet it with %2mode PROVIDER": "Current API endpoint: %1\nSet it with %2mode PROVIDER", + "Go to source (%1)": "Go to source (%1)", + "Temperature set to %1": "Temperature set to %1", + "Enter tags, or \"%1\" for commands": "Enter tags, or \"%1\" for commands", + "%1 queries pending": "%1 queries pending", + "API key:\n\n```txt\n%1\n```": "API key:\n\n```txt\n%1\n```", + "%1 Safe Storage": "%1 Safe Storage", + "%1 does not require an API key": "%1 does not require an API key", + "Temperature: %1": "Temperature: %1", + "Model set to %1": "Model set to %1", + "Page %1": "Page %1", + "Local Ollama model | %1": "Local Ollama model | %1", + "The current system prompt is\n\n---\n\n%1": "The current system prompt is\n\n---\n\n%1", + "Unknown function call: %1": "Unknown function call: %1", + "%1 notifications": "%1 notifications", + "Load chat from %1": "Load chat from %1", + "Load prompt from %1": "Load prompt from %1", + "Save chat to %1": "Save chat to %1", + "Weather Service": "Weather Service", + "Cannot find a GPS service. Using the fallback method instead.": "Cannot find a GPS service. Using the fallback method instead.", + "Critically low battery": "Critically low battery", + "Select output device": "Select output device", + "Code saved to file": "Code saved to file", + "Online models disallowed\n\nControlled by `policies.ai` config option": "Online models disallowed\n\nControlled by `policies.ai` config option", + "Scroll to change volume": "Scroll to change volume", + "Elements": "Elements", + "%1 • %2 tasks": "%1 • %2 tasks", + "Download complete": "Download complete", + "Please charge!\nAutomatic suspend triggers at %1": "Please charge!\nAutomatic suspend triggers at %1", + "Cloudflare WARP": "Cloudflare WARP", + "Cloudflare WARP (1.1.1.1)": "Cloudflare WARP (1.1.1.1)", + "Scroll to change brightness": "Scroll to change brightness", + "Connection failed. Please inspect manually with the warp-cli command": "Connection failed. Please inspect manually with the warp-cli command", + "Select input device": "Select input device", + "Registration failed. Please inspect manually with the warp-cli command": "Registration failed. Please inspect manually with the warp-cli command", + "Consider plugging in your device": "Consider plugging in your device", + "Low battery": "Low battery", + "Saved to %1": "Saved to %1", + "Sunset": "Sunset", + "UV Index": "UV Index", + "Humidity": "Humidity", + "Wind": "Wind", + "Sunrise": "Sunrise", + "Pressure": "Pressure", + "Visibility": "Visibility", + "Precipitation": "Precipitation", + "Time to full:": "Time to full:", + "Time to empty:": "Time to empty:", + "Fully charged": "Fully charged", + "Charging:": "Charging:", + "Discharging:": "Discharging:", + "No pending tasks": "No pending tasks", + "... and %1 more": "... and %1 more", + "Used:": "Used:", + "Free:": "Free:", + "Total:": "Total:", + "Load:": "Load:", + "High": "High", + "Medium": "Medium", + "Low": "Low", + "To set an API key, pass it with the %4 command\n\nTo view the key, pass \"get\" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3": "To set an API key, pass it with the %4 command\n\nTo view the key, pass \"get\" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3", + "Online | %1's model | Delivers fast, responsive and well-formatted answers. Disadvantages: not very eager to do stuff; might make up unknown function calls": "Online | %1's model | Delivers fast, responsive and well-formatted answers. Disadvantages: not very eager to do stuff; might make up unknown function calls", + "Incorrect password": "Incorrect password", + "Usage: %1load CHAT_NAME": "Usage: %1load CHAT_NAME", + "Usage: %1tool TOOL_NAME": "Usage: %1tool TOOL_NAME", + "Feels like %1": "Feels like %1", + "Always": "Always", + "Break": "Break", + "Vertical": "Vertical", + "Set the tool to use for the model.": "Set the tool to use for the model.", + "Lap": "Lap", + "Approve": "Approve", + "No API key\nSet it with /key YOUR_API_KEY": "No API key\nSet it with /key YOUR_API_KEY", + "Sidebars": "Sidebars", + "Up %1": "Up %1", + "Your package manager is running": "Your package manager is running", + "Commands, edit configs, search.\nTakes an extra turn to switch to search mode if that's needed": "Commands, edit configs, search.\nTakes an extra turn to switch to search mode if that's needed", + "Tool set to: %1": "Tool set to: %1", + "Disable tools": "Disable tools", + "Tray": "Tray", + "Temperature\nChange with /temp VALUE": "Temperature\nChange with /temp VALUE", + "Conflicts with the shell's notification implementation": "Conflicts with the shell's notification implementation", + "Pause": "Pause", + "Wallpaper parallax": "Wallpaper parallax", + "Kill conflicting programs?": "Kill conflicting programs?", + "🌿 Long break: %1 minutes": "🌿 Long break: %1 minutes", + "Reject": "Reject", + "Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers": "Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers", + "Welcome app": "Welcome app", + "Attach a file. Only works with Gemini.": "Attach a file. Only works with Gemini.", + "Depends on workspace": "Depends on workspace", + "Night Light | Right-click to toggle Auto mode": "Night Light | Right-click to toggle Auto mode", + "Total token count\nInput: %1\nOutput: %2": "Total token count\nInput: %1\nOutput: %2", + "Tint icons": "Tint icons", + "Refreshing (manually triggered)": "Refreshing (manually triggered)", + "Gives the model search capabilities (immediately)": "Gives the model search capabilities (immediately)", + "Performance Profile toggle": "Performance Profile toggle", + "Timer": "Timer", + "**Pricing**: Free tier available with limited rates. See https://docs.github.com/en/billing/concepts/product-billing/github-models\n\n**Instructions**: Generate a GitHub personal access token with Models permission, then set as API key here\n\n**Note**: To use this you will have to set the temperature parameter to 1": "**Pricing**: Free tier available with limited rates. See https://docs.github.com/en/billing/concepts/product-billing/github-models\n\n**Instructions**: Generate a GitHub personal access token with Models permission, then set as API key here\n\n**Note**: To use this you will have to set the temperature parameter to 1", + "Command rejected by user": "Command rejected by user", + "Thought": "Thought", + "Long break": "Long break", + "Hi there! First things first...": "Hi there! First things first...", + "Preferred wallpaper zoom (%)": "Preferred wallpaper zoom (%)", + "Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput.": "Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput.", + "🔴 Focus: %1 minutes": "🔴 Focus: %1 minutes", + "Enjoy! You can reopen the welcome app any time with Super+Shift+Alt+/. To open the settings app, hit Super+I": "Enjoy! You can reopen the welcome app any time with Super+Shift+Alt+/. To open the settings app, hit Super+I", + "There might be a download in progress": "There might be a download in progress", + "Start": "Start", + "System uptime:": "System uptime:", + "Keep right sidebar loaded": "Keep right sidebar loaded", + "Current tool: %1\nSet it with %2tool TOOL": "Current tool: %1\nSet it with %2tool TOOL", + "Corner style": "Corner style", + "Reset": "Reset", + "Stopwatch": "Stopwatch", + "Usage: %1save CHAT_NAME": "Usage: %1save CHAT_NAME", + "API key is set\nChange with /key YOUR_API_KEY": "API key is set\nChange with /key YOUR_API_KEY", + "Resume": "Resume", + "Enter password": "Enter password", + "Overall appearance": "Overall appearance", + "Horizontal": "Horizontal", + "Config file": "Config file", + "Pomodoro": "Pomodoro", + "Tint app icons": "Tint app icons", + "Invalid tool. Supported tools:\n- %1": "Invalid tool. Supported tools:\n- %1", + "Invalid arguments. Must provide `command`.": "Invalid arguments. Must provide `command`.", + "When enabled keeps the content of the right sidebar loaded to reduce the delay when opening,\nat the cost of around 15MB of consistent RAM usage. Delay significance depends on your system's performance.\nUsing a custom kernel like linux-cachyos might help": "When enabled keeps the content of the right sidebar loaded to reduce the delay when opening,\nat the cost of around 15MB of consistent RAM usage. Delay significance depends on your system's performance.\nUsing a custom kernel like linux-cachyos might help", + "Depends on sidebars": "Depends on sidebars", + "To Do:": "To Do:", + "**Instructions**: Log into Mistral account, go to Keys on the sidebar, click Create new key": "**Instructions**: Log into Mistral account, go to Keys on the sidebar, click Create new key", + "Online | Google's model\nFast, can perform searches for up-to-date information": "Online | Google's model\nFast, can perform searches for up-to-date information", + "Place at the bottom/right": "Place at the bottom/right", + "Bar layout": "Bar layout", + "Focus": "Focus", + "Conflicts with the shell's system tray implementation": "Conflicts with the shell's system tray implementation", + "Shell conflicts killer": "Shell conflicts killer", + "☕ Break: %1 minutes": "☕ Break: %1 minutes", + "Open the shell config file.\nIf the button doesn't work or doesn't open in your favorite editor,\nyou can manually open ~/.config/illogical-impulse/config.json": "Open the shell config file.\nIf the button doesn't work or doesn't open in your favorite editor,\nyou can manually open ~/.config/illogical-impulse/config.json", + "Online | Google's model\nGoogle's state-of-the-art multipurpose model that excels at coding and complex reasoning tasks.": "Online | Google's model\nGoogle's state-of-the-art multipurpose model that excels at coding and complex reasoning tasks.", + "EasyEffects | Right-click to configure": "EasyEffects | Right-click to configure", + "Automatically hide": "Automatically hide" +} diff --git a/translations/it_IT.json b/translations/it_IT.json new file mode 100644 index 0000000..ce7cbc4 --- /dev/null +++ b/translations/it_IT.json @@ -0,0 +1,363 @@ +{ + "Launch": "Avvia", + "Columns": "Colonne", + "Save": "Salva", + "Temperature: %1": "Temperatura: %1", + "Night Light | Right-click to toggle Auto mode": "Modalità notte", + "Silent": "Silenzia", + "To Do": "Promemoria", + "Action": "Comandi", + "Search the web": "Cerca sul web", + "Workspace": "Spazio di lavoro", + "Desktop": "Scrivania", + "Settings": "Impostazioni", + "Math result": "Risultato", + "Calendar": "Calendario", + "Run": "Esegui", + "Cancel": "Cancella", + "Search": "Cerca", + "Battery": "Batteria", + "Weather": "Meteo", + "Brightness": "Luminosità", + "Clear": "Cancella", + "No notifications": "Nessuna notifica", + "No media": "Non in riproduzione", + "Add task": "Aggiungi promemoria", + "Run command": "Esegui comando", + "Game mode": "Modalità gioco", + "Reload Hyprland & Quickshell": "Riavvia Hyprland e Quickshell", + "Task description": "Titolo promemoria", + "%1 | Right-click to configure": "%1", + "Done": "Completati", + "Keep system awake": "Mantieni schermo attivo", + "Search, calculate or run": "Cerca, calcola o esegui", + "Copy": "Copia", + "Rows": "Righe", + "Session": "Sessione", + "Notifications": "Notifiche", + "Unfinished": "Da completare", + "Add": "Aggiungi", + "Nothing here!": "Nessun promemoria", + "Mo": "Lu", + "Tu": "Ma", + "We": "Me", + "Th": "Gi", + "Fr": "Ve", + "Sa": "Sa", + "Su": "Do", + "Edit config": "Apri config.", + "Center title": "Titolo centrato", + "Elements": "Elementi", + "Color picker": "Selettore colore", + "Title bar": "Barra del titolo", + "Sleep": "Sospendi", + "Transparency": "Trasparenza", + "Bluetooth": "Bluetooth", + "UV Index": "Indice UV", + "Bar": "Barra", + "Format": "Formato", + "Select output device": "Seleziona dispositivo di output", + "Pressure": "Pressione", + "Volume": "Volume", + "Volume mixer": "Mixer volume", + "Interface": "Interfaccia", + "Workspaces": "Spazi di lavoro", + "Dark": "Scuro", + "%1 notifications": "%1 notifiche", + "Reboot": "Riavvia", + "No": "No", + "Wind": "Vento", + "Humidity": "Umidità", + "Select Language": "Seleziona lingua", + "Wallpaper": "Sfondo", + "Copy code": "Copia codice", + "Allow NSFW": "Mostra NSFW", + "Colors & Wallpaper": "Sfondo e stile", + "Shutdown": "Spegni", + "Decorations & Effects": "Decorazioni e effetti", + "Translation goes here...": "Traduzione", + "Polling interval (ms)": "Intervallo di polling (ms)", + "System prompt": "Prompt di sistema", + "Base URL": "URL base", + "Always show numbers": "Mostra numeri", + "Wallpaper parallax": "Effetto parallasse sfondo", + "Plain rectangle": "Semplice", + "illogical-impulse Welcome": "Benvenuto su illogical-impulse", + "Local only": "Solo locale", + "Dark/Light toggle": "Modalità chiaro/scuro", + "Screen snip": "Cattura schermo", + "Style & wallpaper": "Sfondo e stile", + "Show app icons": "Mostra icone app", + "Useless buttons": "Tasti inutili", + "Scale (%)": "Dimensione (%)", + "Show background": "Mostra sfondo", + "Intelligence": "AI", + "Enable": "Abilita", + "Borderless": "Senza bordi", + "Random: Konachan": "Casuale: Konachan", + "Yes": "Sì", + "Documentation": "Documentazione", + "Unknown Artist": "Artista sconosciuto", + "Help & Support": "Aiuto e supporto", + "Report a Bug": "Segnala un bug", + "Sunset": "Tramonto", + "Weeb": "Anime", + "Shell windows": "Finestre", + "Workspaces shown": "Numero spazi di lavoro", + "Screenshot tool": "Cattura schermo", + "Enter text to translate...": "Inserisci il testo qui", + "Unknown Album": "Album sconosciuto", + "Hug": "Stondato", + "Scroll to change brightness": "Scorri per cambiare luminosità", + "Privacy Policy": "Privacy policy", + "12h AM/PM": "12 ore (AM/PM)", + "Material palette": "Tavolozza dei colori", + "No audio source": "Nessuna sorgente audio", + "Download": "Scarica", + "Prefixes": "Prefissi", + "Show next time": "Mostra al prossimo avvio", + "Lock": "Blocca", + "Scroll to change volume": "Scorri per cambiare volume", + "Unknown": "Sconosciuto", + "Jump to current month": "Torna al mese corrente", + "Cloudflare WARP (1.1.1.1)": "Cloudflare WARP (1.1.1.1)", + "Terminal": "Terminale", + "About": "Informazioni", + "Precipitation": "Precipitazioni", + "Keybinds": "Comandi", + "Select input device": "Seleziona dispositivo di input", + "When not fullscreen": "Tranne a schermo intero", + "Unknown Title": "Titolo sconosciuto", + "Task Manager": "Gestione attività", + "System": "Sistema", + "Choose file": "Scegli file", + "Pinned on startup": "Fissa sullo schermo", + "Policies": "Servizi", + "View Markdown source": "Mostra Markdown", + "Sunrise": "Alba", + "Configuration": "Configurazione", + "Overview": "Panoramica", + "Translator": "Traduttore", + "Finished tasks will go here": "Nessun promemoria", + "Mic toggle": "Microfono", + "Light": "Chiaro", + "Advanced": "Avanzate", + "Web search": "Cerca sul web", + "12h am/pm": "12 ore (am/pm)", + "Services": "Servizi", + "Donate": "Supporta", + "Resources": "Risorse", + "Float": "Fluttuante", + "Fake screen rounding": "Bordi curvi schermo", + "Hibernate": "Iberna", + "Visibility": "Visibilità", + "Delete": "Elimina", + "Style": "Stile", + "Page %1": "Pagina %1", + "Keyboard toggle": "Tastiera virtuale", + "Buttons": "Pulsanti", + "24h": "24 ore", + "Time": "Ora", + "Clipboard": "Appunti", + "or": "o", + "Edit": "Modifica", + "Emojis": "Emoji", + "Shell & utilities": "App e shell", + "Reboot to firmware settings": "Riavvia alle impostazioni firmware", + "No API key set for %1": "Chiave API non impostata per %1", + "Disable NSFW content": "Nascondi contenuti NSFW", + "Closet": "Nascosto", + "Depends on sidebars": "Abilita per barre laterali", + "Invalid model. Supported: \n```": "Modello non valido. Supportati: \n```", + "Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window": "Usa /key per utilizzare i modelli online\nCtrl+O per espandere\nCtrl+P per separare in finestra", + "Not visible to model": "Non visible al modello", + "Local Ollama model | %1": "Modello Ollama locale | %1", + "Open file link": "Apri link al file", + "Waiting for response...": "In attesa di risposta...", + "Cheat sheet": "Prontuario", + "Allow NSFW content": "Mostra contenuti NSFW", + "%1 characters": "%1 caratteri", + "Model set to %1": "Modello impostato su %1", + "Be patient...": "Attendi...", + "User agent (for services that require it)": "User agent (usato dai servizi)", + "Current API endpoint: %1\nSet it with %2mode PROVIDER": "Endpoint API: %1\nUsa %2mode per cambiarlo", + "For desktop wallpapers | Good quality": "Per sfondi del desktop | Buona qualità", + "For storing API keys and other sensitive information": "Gestione credenziali e informazioni sensibili", + "Your package manager is running": "Il package manager è in esecuzione", + "Provider set to": "Provider impostato su", + "Color generation": "Tavolozza dei colori", + "Temperature must be between 0 and 2": "Il valore della temperatura deve essere fra 0 e 2", + "Earbang protection": "Protezione udito", + "Alternatively use /dark, /light, /img in the launcher": "Oppure usa /dark, /light, /img nel launcher", + "Change any time later with /dark, /light, /img in the launcher": "Oppure usa /dark, /light, /img nel launcher", + "Current model: %1\nSet it with %2model MODEL": "Modello attuale: %1\nUsa %2model per cambiarlo", + "Waifus only | Excellent quality, limited quantity": "Solo waifu | Qualità eccellente, quantità limitata", + "Save to Downloads": "Salva in Scaricati", + "Thinking": "Ragionando", + "On-screen display": "Popup di sistema", + "%1 • %2 tasks": "%1 • %2 promemoria", + "Qt apps": "Applicazioni Qt", + "The popular one | Best quantity, but quality can vary wildly": "Il più usato | Quantità elevata, ma la qualità potrebbe variare totalmente", + "Set the system prompt for the model.": "Imposta il prompt di sistema per il modello.", + "Markdown test": "Test Markdown", + "Prevents abrupt increments and restricts volume limit": "Impedice aumenti improvvisi del volume e ne imposta un limite.", + "Preferred wallpaper zoom (%)": "Zoom sfondo (%)", + "Depends on workspace": "Abilita per spazi di lavoro", + "Note: turning off can hurt readability": "Nota: disattivarlo potrebbe ridurre la leggibilità", + "Pick wallpaper image on your system": "Seleziona un'immagine nel tuo sistema", + "Invalid API provider. Supported: \n-": "Provider API non valido. Supportati: \n-", + "The hentai one | Great quantity, a lot of NSFW, quality varies wildly": "Hentai | Quantità elevata, NSFW, la qualità potrebbe variare totalmente", + "Saved to %1": "Salvato in %1", + "Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5.": "Imposta la temperatura (casualità) del modello. Il valore va da 0 a 2 per Gemini, e da 0 a 1 per gli altri modelli. Il valore predefinito è 0.5.", + "Close": "Chiudi", + "Might look ass. Unsupported.": "Potrebbe fare schifo. Non supportato.", + "API key set for %1": "Chiave API impostata per %1", + "Temperature\nChange with /temp VALUE": "Temperatura\nUsa /temp per cambiarla", + "%1 does not require an API key": "%1 non richiede una chiave API", + "Online models disallowed\n\nControlled by `policies.ai` config option": "Modelli online disattivati\n\nDisattiva l'opzione \"Solo locale\"", + "Set the current API provider": "Imposta il provider API", + "Total token count\nInput: %1\nOutput: %2": "Numero token totali\nInput: %1\nOutput: %2", + "EasyEffects | Right-click to configure": "EasyEffects", + "Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers": "Sfondo anime SFW casuale da Konachan\nL'immagine verrà salvata in ~/Immagini/Wallpapers", + "Hover to reveal": "Mostra col passaggio del mouse", + "Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel": "Usa i tasti freccia per navigare, Invio per selezionare\nEsc o clicca qualsiasi punto per uscire", + "Save chat to %1": "Salva chat in %1", + "Choose model": "Seleziona modello", + "No API key\nSet it with /key YOUR_API_KEY": "Nessuna chiave API\nUsa /key per impostarla", + "API key:\n\n```txt\n%1\n```": "Chiave API:\n\n```txt\n%1\n```", + "Use Levenshtein distance-based algorithm instead of fuzzy": "Usa algoritmo di Levenshtein al posto di fuzzy", + "Download complete": "Download completato", + "Tip: Hide icons and always show numbers for\nthe classic illogical-impulse experience": "Suggerimento: Nascondi le icone e mostra i numeri se vuoi\nun'esperienza fedele all'originale", + "Usage": "Istruzioni", + "Set API key": "Imposta chiave API", + "Networking": "Rete", + "Volume limit": "Limite volume", + "Temperature set to %1": "Temperatura impostata a %1", + "Large images | God tier quality, no NSFW.": "Immagini grandi | Qualità perfetta, no NSFW.", + "Shell & utilities theming must also be enabled": "\"App e shell\" deve essere abilitato", + "API key is set\nChange with /key YOUR_API_KEY": "Chiave API impostata\nUsa /key per cambiarla", + "All-rounder | Good quality, decent quantity": "Versatile | Qualità buona, quantità discreta", + "Large language models": "Intelligenza artificiale", + "Could be better if you make a ton of typos,\nbut results can be weird and might not work with acronyms\n(e.g. \"GIMP\" might not give you the paint program)": "Può aiutare in caso di refusi,\nma i risultati potrebbero non essere accurati\n(es. \"GIMP\" potrebbe non restituire l'omonimo programma)", + "Automatic suspend": "Sospensione automatica", + "Max allowed increase": "Aumento massimo consentito", + "Please charge!\nAutomatic suspend triggers at %1": "Ricarica la batteria!\nSospensione automatica in %1", + "Weather Service": "Servizio meteo", + "The current system prompt is\n\n---\n\n%1": "Il prompt di sistema attuale è\n\n---\n\n%1", + "%1 Safe Storage": "Portachiavi di %1", + "Load prompt from %1": "Carica prompt da %1", + "That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number": "Nessun risultato. Suggerimenti:\n- Controlla i tag e le impostazioni NSFW\n- Se non hai un tag in mente, inserici il numero di pagina", + "Load chat": "Carica chat", + "**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key": "**Prezzo**: gratuito. I tuoi dati vengono usati per training.\n\n**Istruzioni**: Accedi al tuo account Google, abilita i permessi richiesti da AI Studio, torna indietro e seleziona \"Get API key\"", + "Online via %1 | %2's model": "Online tramite %1 | Modello di %2", + "Get the next page of results": "Ottieni la pagina successiva dei risultati", + "Unknown function call: %1": "Funzione sconosciuta: %1", + "Cannot find a GPS service. Using the fallback method instead.": "Servizio GPS non disponibile. Verrà utilizzata la città preimpostata.", + "Registration failed. Please inspect manually with the warp-cli command": "Registrazione fallita. Verifica l'errore manualmente col comando warp-cli", + "Code saved to file": "Codice salvato", + "Low warning": "Livello basso", + "Clear the current list of images": "Elimina la lista corrente di immagini", + "Invalid arguments. Must provide `key` and `value`.": "Argomenti non validi. Il comando richiede `key` e `value`.", + "Connection failed. Please inspect manually with the warp-cli command": "Connessione fallita. Verifica l'errore manualmente col comando warp-cli", + "Unknown command:": "Comando sconosciuto:", + "Message the model... \"%1\" for commands": "\"%1\" per mostrare i comandi", + "Load chat from %1": "Carica chat da %1", + "**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key": "**Prezzo**: gratuito. Le policy di trattamento dei dati potrebbero variare in base alle impostazioni del tuo account OpenRouter.\n\n**Istruzioni**: Accedi al tuo account OpenRouter, vai nella sezione \"Keys\" dal menu in alto, clicca \"Create API Key\"", + "Enter tags, or \"%1\" for commands": "\"%1\" per mostrare i comandi", + "Show regions of potential interest": "Mostra regioni d'interesse", + "Critical warning": "Livello critico", + "Go to source (%1)": "Vai alla fonte (%1)", + "Automatically suspends the system when battery is low": "Sospende automaticamente il sistema quando il livello della batteria è basso", + "Clean stuff | Excellent quality, no NSFW": "Roba pulita | Qualità eccellente, no NSFW", + ". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!": ". Note per Zerochan:\n- Devi inserire un colore\n- Imposta il tuo nome utente di zerochan nell'opzione `sidebar.booru.zerochan.username`. Potresti [venire bannato se non lo fai](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!", + "Critically low battery": "Batteria scarica", + "Loaded the following system prompt\n\n---\n\n%1": "Il seguente prompt di sistema è stato caricato\n\n---\n\n%1", + "Suspend at": "Sospendi al", + "Such regions could be images or parts of the screen that have some containment.\nMight not always be accurate.\nThis is done with an image processing algorithm run locally and no AI is used.": "Queste regioni potrebbero essere immagini o parti di schermo contenute.\nPotrebbe non essere preciso.\nViene utilizzato un algoritmo di image processing in locale, non viene usata AI.", + "Clear chat history": "Elimina cronologia chat", + "Low battery": "Batteria quasi scarica", + "Save chat": "Salva chat", + "Switched to search mode. Continue with the user's request.": "Modalità ricerca attiva. Continua con la richiesta dell'utente.", + "Number show delay when pressing Super (ms)": "Mostra numeri premendo Super dopo (ms)", + "Drag or click a region • LMB: Copy • RMB: Edit": "Trascina o clicca una regione • LMB: Copia • RMB: Modifica", + "Consider plugging in your device": "Connetti il tuo dispositivo alla rete elettrica", + "%1 queries pending": "%1 ricerche in sospeso", + "No further instruction provided": "Nessun'altra istruzione fornita", + "There might be a download in progress": "Potrebbe esserci un download in corso", + "Approve": "Approva", + "Invalid arguments. Must provide `command`.": "Argomenti non validi. Il comando richiede `command`.", + "Reject": "Rifiuta", + "Thought": "Pensiero", + "Performance Profile toggle": "Profilo prestazioni", + "Command rejected by user": "Comando rifiutato dall'utente", + "Up %1": "Tempo di attività: %1", + "Overall appearance": "Aspetto", + "Online | Google's model\nGoogle's state-of-the-art multipurpose model that excels at coding and complex reasoning tasks.": "Online | Modello di Google\nModello multiuso che eccelle in scrittura di codice e compiti di ragionamento complessi.", + "Usage: %1tool TOOL_NAME": "Istruzioni: %1tool TOOL_NAME", + "Set the tool to use for the model.": "Imposta lo strumento da usare con questo modello.", + "Online | Google's model\nFast, can perform searches for up-to-date information": "Online | Modello di Google\nVeloce, effettua ricerche con informazioni attuali", + "Invalid tool. Supported tools:\n- %1": "Strumento non valido. Strumenti supportati:\n- %1", + "Usage: %1load CHAT_NAME": "Istruzioni: %1load CHAT_NAME", + "Current tool: %1\nSet it with %2tool TOOL": "Strumento attuale: %1\nUsa %2tool per cambiarlo", + "Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput.": "Online | Modello di Google\nModello Gemini 2.5 Flash ottimizzato per efficienza e throughput elevato.", + "To set an API key, pass it with the %4 command\n\nTo view the key, pass \"get\" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3": "Per impostare una chiave API, utilizzala come argomento del comando %4\n\nPer vedere la chiave corrente, utilizza come argomento \"get\"
\n\n### Per %1:\n\n**Link**: %2\n\n%3", + "Tool set to: %1": "Strumento impostato su: %1", + "Disable tools": "Disattiva strumenti", + "Usage: %1save CHAT_NAME": "Istruzioni: %1save CHAT_NAME", + "Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers": "Online | Modello di Google\nPiù lento del suo predecessore ma restituisce risposte di qualità maggiore", + "Tint icons": "Icone monocromatiche", + "Tray": "Area di notifica", + "Tint app icons": "Icone monocromatiche", + "Sidebars": "Barre laterali", + "Keep right sidebar loaded": "Mantieni barra laterale destra in memoria", + "Automatically hide": "Nascondi automaticamente", + "Pause": "Pausa", + "Stopwatch": "Cronometro", + "🌿 Long break: %1 minutes": "🌿 Pausa lunga: %1 minuti", + "Reset": "Ripristina", + "Resume": "Riprendi", + "Break": "Pausa breve", + "🔴 Focus: %1 minutes": "🔴 Focus: %1 minuti", + "☕ Break: %1 minutes": "☕ Pausa breve: %1 minuti", + "Incorrect password": "Password errata", + "Start": "Avvia", + "Lap": "Parziale", + "Enter password": "Inserisci password", + "Long break": "Pausa lunga", + "Anime boorus": "Boorus anime", + "High": "Alto", + "To Do:": "Promemoria:", + "Charging:": "In carica:", + "... and %1 more": "... e altri %1", + "No pending tasks": "Nessuno", + "System uptime:": "Tempo di attività:", + "Total:": "Totale:", + "Medium": "Medio", + "Time to full:": "Tempo di ricarica:", + "Discharging:": "Consumo:", + "Free:": "Disponibile:", + "Fully charged": "Carica completa", + "Time to empty:": "Tempo rimanente:", + "Load:": "Carico:", + "Used:": "In uso:", + "Low": "Basso", + "Vertical": "Verticale", + "Horizontal": "Orizzontale", + "Hi there! First things first...": "Ciao! Per cominciare...", + "Welcome app": "App di benvenuto", + "Enjoy! You can reopen the welcome app any time with Super+Shift+Alt+/. To open the settings app, hit Super+I": "Puoi riaprire la schermata di benvenuto con Super+Shift+Alt+/. Per aprire le impostazioni, usa Super+I", + "Attach a file. Only works with Gemini.": "Allega un file. Funziona solo su Gemini.", + "Always": "Sempre", + "Place at the bottom/right": "Posiziona in basso/a destra", + "**Pricing**: Free tier available with limited rates. See https://docs.github.com/en/billing/concepts/product-billing/github-models\n\n**Instructions**: Generate a GitHub personal access token with Models permission, then set as API key here\n\n**Note**: To use this you will have to set the temperature parameter to 1": "**Prezzo**: Piano gratuito disponibile con utilizzo limitato. Più info su https://docs.github.com/en/billing/concepts/product-billing/github-models\n\n**Istruzioni**: Genera un personal access token su GitHub con i permessi \"Models\" e impostalo come chiave API\n\n**Nota**: Per utilizzare questo modello imposta la temperatura a 1", + "Refreshing (manually triggered)": "Aggiornamento in corso (richiesto manualmente)", + "Shell conflicts killer": "Avviso conflitti", + "Kill conflicting programs?": "Terminare programmi in conflitto?", + "Conflicts with the shell's notification implementation": "In conflitto con il sistema di notifiche della shell", + "Conflicts with the shell's system tray implementation": "In conflitto con l'area di notifica della shell", + "When enabled keeps the content of the right sidebar loaded to reduce the delay when opening,\nat the cost of around 15MB of consistent RAM usage. Delay significance depends on your system's performance.\nUsing a custom kernel like linux-cachyos might help": "Mantiene il contenuto della barra laterale destra in memoria per ridurne il tempo di apertura,\nrichiede circa 15MB di RAM. Il tempo di apertura dipende dalle performance del tuo sistema.\nUsare un custom kernel come linux-cachyos può migliorare la velocità", + "**Instructions**: Log into Mistral account, go to Keys on the sidebar, click Create new key": "**Istruzioni**: Accedi al tuo account Mistral, vai nella sezione \"Keys\" dal menu laterale, clicca \"Create new key\"", + "Gives the model search capabilities (immediately)": "Abilita modalità ricerca del modello (immediatamente)", + "Commands, edit configs, search.\nTakes an extra turn to switch to search mode if that's needed": "Comanda, modifica la richiesta, cerca.\nRichiede un turno in più per attivare la modalità ricerca se richiesta", + "Online | %1's model | Delivers fast, responsive and well-formatted answers. Disadvantages: not very eager to do stuff; might make up unknown function calls": "Online | Modello di %1 | Ritorna risposte veloci e formattate correttamente. Svantaggi: poca voglia di svolgere compiti; potrebbe fare chiamate di funzioni inesistenti" +} \ No newline at end of file diff --git a/translations/ja_JP.json b/translations/ja_JP.json new file mode 100644 index 0000000..105bf6e --- /dev/null +++ b/translations/ja_JP.json @@ -0,0 +1,432 @@ +{ + "Mo": "月/*keep*/", + "Tu": "火/*keep*/", + "We": "水/*keep*/", + "Th": "木/*keep*/", + "Fr": "金/*keep*/", + "Sa": "土/*keep*/", + "Su": "日/*keep*/", + "%1 characters": "%1 文字", + "**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key": "**料金**: 無料。データ利用ポリシーはOpenRouterアカウント設定によって異なります。\n\n**手順**: OpenRouterアカウントにログインし、右上メニューのKeysに進み、Create API Keyをクリックしてください。", + "**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key": "**料金**: 無料。データは学習に使用されます。\n\n**手順**: Googleアカウントにログインし、AI StudioにGoogle Cloudプロジェクトの作成などを許可し、戻ってAPIキーを取得してください。", + ". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!": "Zerochanの注意:\n- 色を入力する必要があります\n- `sidebar.booru.zerochan.username`設定でユーザー名を設定してください。設定しないと[利用停止になる場合があります](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!", + "No further instruction provided": "追加の指示はありません", + "Action": "操作", + "Add": "追加", + "Add task": "タスクを追加", + "All-rounder | Good quality, decent quantity": "万能型 | 高品質・十分な量", + "Allow NSFW": "NSFWを許可", + "Allow NSFW content": "NSFWコンテンツを許可", + "Anime": "アニメ", + "Anime boorus": "アニメ画像掲示板", + "App": "アプリ", + "Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel": "矢印キーで移動、Enterで選択\nEsc/クリックでキャンセル", + "Bluetooth": "Bluetooth", + "Brightness": "明るさ", + "Cancel": "キャンセル", + "Cheat sheet": "チートシート", + "Choose model": "モデルを選択", + "Clean stuff | Excellent quality, no NSFW": "健全 | 高品質・NSFWなし", + "Clear": "クリア", + "Clear chat history": "チャット履歴を消去", + "Clear the current list of images": "現在の画像リストをクリア", + "Close": "閉じる", + "Copy": "コピー", + "Copy code": "コードをコピー", + "Delete": "削除", + "Desktop": "デスクトップ", + "Disable NSFW content": "NSFWコンテンツを無効化", + "Done": "完了", + "Download": "ダウンロード", + "Edit": "編集", + "Enter text to translate...": "翻訳するテキストを入力...", + "Finished tasks will go here": "完了したタスクはここに表示されます", + "For desktop wallpapers | Good quality": "デスクトップ壁紙向け | 高品質", + "For storing API keys and other sensitive information": "APIキーや機密情報の保存用", + "Game mode": "ゲームモード", + "Get the next page of results": "次のページを取得", + "Hibernate": "休止", + "Input": "入力", + "Intelligence": "知能", + "Interface": "インターフェース", + "Invalid arguments. Must provide `key` and `value`.": "無効な引数です。`key`と`value`を指定してください。", + "Jump to current month": "現在の月へ移動", + "Keep system awake": "システムをスリープさせない", + "Large images | God tier quality, no NSFW.": "大きな画像 | 最高品質・NSFWなし", + "Large language models": "大規模言語モデル", + "Launch": "起動", + "Lock": "ロック", + "Logout": "ログアウト", + "Markdown test": "Markdownテスト", + "Math result": "計算結果", + "No audio source": "音声ソースなし", + "No media": "メディアなし", + "No notifications": "通知なし", + "Not visible to model": "モデルには表示されません", + "Nothing here!": "何もありません!", + "Notifications": "通知", + "OK": "OK", + "Open file link": "ファイルリンクを開く", + "Output": "出力", + "Reboot": "再起動", + "Reboot to firmware settings": "ファームウェア設定で再起動", + "Reload Hyprland & Quickshell": "HyprlandとQuickshellを再読み込み", + "Run": "実行", + "Run command": "コマンドを実行", + "Save": "保存", + "Save to Downloads": "ダウンロードに保存", + "Search": "検索", + "Search the web": "ウェブ検索", + "Search, calculate or run": "検索・計算・実行", + "Select Language": "言語を選択", + "Session": "セッション", + "Set API key": "APIキーを設定", + "Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5.": "モデルの温度(ランダム性)を設定します。Geminiは0~2、他は0~1です。初期値は0.5です。", + "Set the current API provider": "現在のAPIプロバイダーを設定します", + "Shutdown": "シャットダウン", + "Silent": "サイレント", + "Sleep": "スリープ", + "System": "システム", + "Task Manager": "タスクマネージャー", + "Task description": "タスクの説明", + "Temperature must be between 0 and 2": "温度は0~2の間で指定してください", + "The hentai one | Great quantity, a lot of NSFW, quality varies wildly": "成人向け | 量は多いが品質は様々・NSFW多数", + "The popular one | Best quantity, but quality can vary wildly": "人気 | 量は最多だが品質は様々", + "Thinking": "考え中", + "Translation goes here...": "ここに翻訳が表示されます...", + "Translator": "翻訳", + "Unfinished": "未完了", + "Unknown": "不明", + "Unknown Album": "不明なアルバム", + "Unknown Artist": "不明なアーティスト", + "Unknown Title": "不明なタイトル", + "View Markdown source": "Markdownソースを表示", + "Volume": "音量", + "Volume mixer": "ボリュームミキサー", + "Waifus only | Excellent quality, limited quantity": "キャラクター | 高品質・量は少なめ", + "Waiting for response...": "応答待ち...", + "Workspace": "ワークスペース", + "Invalid API provider. Supported: \n-": "無効なAPIプロバイダー。対応: \n-", + "Unknown command:": "不明なコマンド:", + "Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window": "/key でオンラインモデルを開始\nCtrl+O でサイドバーを展開\nCtrl+P でサイドバーをウィンドウ化", + "Provider set to": "プロバイダーを設定しました:", + "Invalid model. Supported: \n```": "無効なモデル。対応: \n```", + "That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number": "うまくいきませんでした。ヒント:\n- タグやNSFW設定を確認\n- タグがなければページ番号を入力", + "Switched to search mode. Continue with the user's request.": "検索モードに切り替えました。リクエストを続行します。", + "Settings": "設定", + "Save chat": "チャットを保存", + "Load chat": "チャットを読み込み", + "or": "または", + "Set the system prompt for the model.": "モデルのシステムプロンプトを設定", + "To Do": "やること", + "Calendar": "カレンダー", + "Advanced": "詳細", + "About": "このアプリについて", + "Services": "サービス", + "Style": "スタイル", + "Edit config": "設定を編集", + "Colors & Wallpaper": "色と壁紙", + "Light": "ライト", + "Dark": "ダーク", + "Material palette": "マテリアルパレット", + "Fidelity": "忠実度", + "Fruit Salad": "フルーツサラダ", + "Alternatively use /dark, /light, /img in the launcher": "ランチャーで /dark, /light, /img も使用できます", + "Fake screen rounding": "画面の角を丸める(疑似)", + "When not fullscreen": "フルスクリーンでない時", + "Choose file": "ファイルを選択", + "Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers": "Konachanから健全なアニメ壁紙をランダムで取得し、~/Pictures/Wallpapers に保存します", + "Be patient...": "少々お待ちください…", + "Decorations & Effects": "装飾と効果", + "Tonal Spot": "トーナルスポット", + "Shell windows": "シェルウィンドウ", + "Auto": "自動", + "Wallpaper": "壁紙", + "Content": "コンテンツ", + "Title bar": "タイトルバー", + "Transparency": "透明度", + "Expressive": "表現豊か", + "Yes": "表示", + "Enable": "有効化", + "Rainbow": "レインボー", + "Might look ass. Unsupported.": "表示が崩れる可能性があります(非推奨)", + "Monochrome": "モノクロ", + "Random: Konachan": "ランダム: Konachan", + "Center title": "タイトルを中央に", + "Neutral": "ニュートラル", + "Pick wallpaper image on your system": "システムから壁紙画像を選択", + "No": "非表示", + "AI": "AI", + "Local only": "ローカルのみ", + "Policies": "ポリシー", + "Weeb": "アニメファン向け", + "Closet": "隠し", + "Show next time": "次回も表示する", + "Usage": "使用状況", + "Plain rectangle": "長方形", + "Useless buttons": "ダミーボタン", + "GitHub": "GitHub", + "Style & wallpaper": "スタイルと壁紙", + "Configuration": "設定", + "Change any time later with /dark, /light, /img in the launcher": "ランチャーで/dark, /light, /imgでいつでも変更可能", + "Keybinds": "キー割り当て", + "Float": "フローティング", + "Hug": "固定", + "illogical-impulse Welcome": "illogical-impulse へようこそ!", + "Info": "情報", + "Volume limit": "ボリューム制限", + "Prevents abrupt increments and restricts volume limit": "急な音量変化を防ぎ、音量の上限を設定します", + "Resources": "リソース", + "12h am/pm": "12時間(AM/PM)", + "Base URL": "ベースURL", + "Audio": "オーディオ", + "Networking": "ネットワーク", + "Format": "フォーマット", + "Time": "時間", + "Battery": "バッテリー", + "Prefixes": "接頭辞", + "Emojis": "絵文字", + "Earbang protection": "聴覚保護(急な大音量防止)", + "Automatically suspends the system when battery is low": "バッテリー残量が少ないときに自動でスリープします", + "Automatic suspend": "自動スリープ", + "Suspend at": "スリープ開始残量(%)", + "Max allowed increase": "音量の最大増加幅", + "Web search": "ウェブ検索", + "Polling interval (ms)": "ポーリング間隔(ms)", + "Clipboard": "クリップボード", + "Low warning": "バッテリー低下の警告", + "24h": "24時間", + "Use Levenshtein distance-based algorithm instead of fuzzy": "あいまい検索の代わりにレーベンシュタイン距離アルゴリズムを使用", + "System prompt": "システムプロンプト", + "12h AM/PM": "12時間(AM/PM)", + "Could be better if you make a ton of typos,\nbut results can be weird and might not work with acronyms\n(e.g. \"GIMP\" might not give you the paint program)":"入力ミスが多い場合に便利ですが、結果が意図しないものになったり、略語(例:GIMP)が正しく検索できないことがあります", + "Critical warning": "重大な警告", + "User agent (for services that require it)": "ユーザーエージェント(必要なサービス用)", + "Such regions could be images or parts of the screen that have some containment.\nMight not always be accurate.\nThis is done with an image processing algorithm run locally and no AI is used.": "これらの領域は、画像や画面の一部など、まとまりのある部分を指します。\n必ずしも正確ではありません。\nAIではなく、ローカルで実行される画像処理アルゴリズムによって検出されます。", + "Note: turning off can hurt readability": "注意: オフにすると可読性が損なわれる場合があります", + "Workspaces shown": "表示中のワークスペース", + "Dark/Light toggle": "ダーク/ライト切替", + "Dock": "ドック", + "Weather": "天気", + "Pinned on startup": "起動時にピン留め", + "Tip: Hide icons and always show numbers for\nthe classic illogical-impulse experience": "ヒント: アイコンを非表示にして番号を常に表示すると、クラシックなillogical-impulseの使用感を体験できます", + "Always show numbers": "数字を常に表示", + "Buttons": "ボタン", + "Keyboard toggle": "キーボード切替", + "Scale (%)": "スケール(%)", + "Overview": "概要", + "Rows": "行数", + "Borderless": "枠なし", + "Screenshot tool": "スクリーンショットツール", + "Number show delay when pressing Super (ms)": "Superキー押下時の数字表示遅延(ms)", + "Timeout (ms)": "タイムアウト(ms)", + "Show app icons": "アプリアイコンを表示", + "Workspaces": "ワークスペース", + "Columns": "列数", + "On-screen display": "画面表示", + "Screen snip": "画面の切り抜き", + "Mic toggle": "マイク切替", + "Hover to reveal": "ホバーで表示", + "Bar": "バー", + "Show background": "背景を表示", + "Show regions of potential interest": "注目領域を表示", + "Color picker": "カラーピッカー", + "Help & Support": "ヘルプとサポート", + "Discussions": "ディスカッション", + "Color generation": "色の生成", + "Dotfiles": "ドットファイル", + "Distro": "ディストリビューション", + "Privacy Policy": "プライバシーポリシー", + "Documentation": "ドキュメント", + "Shell & utilities theming must also be enabled": "「シェルとユーティリティ」のテーマ設定も有効にする必要があります", + "illogical-impulse": "illogical-impulse", + "Donate": "寄付", + "Terminal": "ターミナル", + "Shell & utilities": "シェルとユーティリティ", + "Qt apps": "Qtアプリ", + "Report a Bug": "バグを報告", + "Issues": "課題", + "Drag or click a region • LMB: Copy • RMB: Edit": "領域をドラッグまたはクリック • 左クリック: コピー • 右クリック: 編集", + "Current model: %1\nSet it with %2model MODEL": "現在のモデル: %1\n%2model MODELで設定", + "Message the model... \"%1\" for commands": "モデルにメッセージを送信... コマンドは「%1」", + "No API key set for %1": "%1のAPIキーが設定されていません", + "Loaded the following system prompt\n\n---\n\n%1": "次のシステムプロンプトを読み込みました\n\n---\n\n%1", + "%1 | Right-click to configure": "%1 | 右クリックで設定", + "API key set for %1": "%1のAPIキーを設定しました", + "Online via %1 | %2's model": "%1経由オンライン | %2のモデル", + "Current API endpoint: %1\nSet it with %2mode PROVIDER": "現在のAPIエンドポイント: %1\n%2mode PROVIDERで設定", + "Go to source (%1)": "ソースを開く(%1)", + "Temperature set to %1": "温度を%1に設定", + "Enter tags, or \"%1\" for commands": "タグを入力、またはコマンドは「%1」", + "%1 queries pending": "%1件のクエリが保留中", + "API key:\n\n```txt\n%1\n```": "APIキー:\n\n```txt\n%1\n```", + "%1 Safe Storage": "%1 セーフストレージ", + "%1 does not require an API key": "%1はAPIキー不要です", + "Temperature: %1": "温度: %1", + "Model set to %1": "モデルを%1に設定", + "Page %1": "ページ %1", + "Local Ollama model | %1": "ローカルOllamaモデル | %1", + "The current system prompt is\n\n---\n\n%1": "現在のシステムプロンプトは\n\n---\n\n%1", + "Unknown function call: %1": "不明な関数呼び出し: %1", + "%1 notifications": "%1件の通知", + "Load chat from %1": "%1からチャットを読み込み", + "Load prompt from %1": "%1からプロンプトを読み込み", + "Save chat to %1": "チャットを%1に保存", + "Weather Service": "天気サービス", + "Cannot find a GPS service. Using the fallback method instead.": "GPSサービスが見つかりません。代替手段を使用します。", + "Critically low battery": "バッテリー残量が非常に少ない", + "Select output device": "出力デバイスを選択", + "Code saved to file": "コードをファイルに保存しました", + "Online models disallowed\n\nControlled by `policies.ai` config option": "オンラインモデルは許可されていません\n\n`policies.ai` 設定で管理されています", + "Scroll to change volume": "スクロールで音量調整", + "Elements": "要素", + "%1 • %2 tasks": "%1 • %2件のタスク", + "Download complete": "ダウンロード完了", + "Please charge!\nAutomatic suspend triggers at %1": "充電してください!\n%1で自動的にサスペンドします", + "Cloudflare WARP": "Cloudflare WARP", + "Cloudflare WARP (1.1.1.1)": "Cloudflare WARP (1.1.1.1)", + "Scroll to change brightness": "スクロールで明るさ調整", + "Connection failed. Please inspect manually with the warp-cli command": "接続に失敗しました。warp-cli コマンドで手動確認してください", + "Select input device": "入力デバイスを選択", + "Registration failed. Please inspect manually with the warp-cli command": "登録に失敗しました。warp-cli コマンドで手動確認してください", + "Consider plugging in your device": "電源を接続することを推奨します", + "Low battery": "バッテリー残量低下", + "Saved to %1": "%1に保存しました", + "Sunset": "日没", + "UV Index": "UV指数", + "Humidity": "湿度", + "Wind": "風", + "Sunrise": "日の出", + "Pressure": "気圧", + "Visibility": "視界", + "Precipitation": "降水量", + "Time to full:": "満充電まで:", + "Time to empty:": "空になるまで:", + "Fully charged": "充電完了", + "Charging:": "充電中:", + "Discharging:": "放電中:", + "No pending tasks": "保留中のタスクなし", + "... and %1 more": "...他%1件", + "Used:": "使用済み:", + "Free:": "空き:", + "Total:": "合計:", + "Swap:": "スワップ:", + "Not configured": "未設定", + "Load:": "負荷:", + "High": "高", + "Medium": "中", + "Low": "低", + "Use the system file picker instead": "システム標準のファイル選択ツールを使用", + "Tint icons": "アイコンに色付けする", + "Connect to Wi-Fi": "Wi-Fiに接続", + "Invalid arguments. Must provide `command`.": "無効な引数です。`command`を指定してください。", + "System uptime:": "システム稼働時間:", + "Gives the model search capabilities (immediately)": "モデルにすぐに検索能力を与えます", + "Click to toggle light/dark mode (applied when wallpaper is chosen)": "クリックでライト/ダークモード切替(壁紙選択時に適用されます)", + "**Pricing**: Free tier available with limited rates. See https://docs.github.com/en/billing/concepts/product-billing/github-models\n\n**Instructions**: Generate a GitHub personal access token with Models permission, then set as API key here\n\n**Note**: To use this you will have to set the temperature parameter to 1": "**料金**: 制限付きの無料枠があります。詳細は https://docs.github.com/en/billing/concepts/product-billing/github-models を参照\n\n**手順**: Modelsアクセス権を持つGitHub個人アクセストークンを生成し、ここでAPIキーとして設定してください\n\n**注意**: 使用するには温度パラメータを1に設定する必要があります", + "Depends on workspace": "ワークスペースに依存", + "Hi there! First things first...": "こんにちは!まずは始めましょう...", + "Refreshing (manually triggered)": "更新中(手動で開始)", + "Always": "常に", + "No API key\nSet it with /key YOUR_API_KEY": "APIキーがありません\n/key YOUR_API_KEYで設定してください", + "Usage: %1load CHAT_NAME": "使用法: %1load チャット名", + "Sidebars": "サイドバー", + "Search wallpapers": "壁紙を検索", + "When this is off you'll have to click": "オフの場合、クリックで表示します", + "Depends on sidebars": "サイドバーに依存", + "Incorrect password": "パスワードが間違っています", + "Current tool: %1\nSet it with %2tool TOOL": "現在のツール: %1\n%2tool TOOLで設定", + "Overall appearance": "全体の外観", + "To Do:": "やること:", + "Region height": "領域の高さ", + "Auto (System)": "自動(システム)", + "Place the corners to trigger at the bottom": "トリガーとなる角を下部に配置", + "Shell conflicts killer": "シェルの競合解消", + "Enter password": "パスワードを入力", + "☕ Break: %1 minutes": "☕ 休憩: %1分", + "Reset": "リセット", + "Connect": "接続", + "Tint app icons": "アプリアイコンに淡い色付けをする", + "Bar layout": "バーのレイアウト", + "Conflicts with the shell's notification implementation": "シェルの通知機能と競合することがあります", + "Corner open": "コーナー起動", + "🌿 Long break: %1 minutes": "🌿 長い休憩: %1分", + "Reject": "拒否", + "Command rejected by user": "コマンドはユーザーによって拒否されました", + "Start": "開始", + "Brightness and volume": "明るさ・音量", + "Corner style": "角のスタイル", + "Total token count\nInput: %1\nOutput: %2": "トークン数合計\n入力: %1\n出力: %2", + "No active player": "アクティブなプレーヤーがありません", + "Performance Profile toggle": "パフォーマンスプロファイル切替", + "Timer": "タイマー", + "Conflicts with the shell's system tray implementation": "シェルのシステムトレイ機能と競合することがあります", + "Online | %1's model | Delivers fast, responsive and well-formatted answers. Disadvantages: not very eager to do stuff; might make up unknown function calls": "オンライン | %1のモデル | 高速で反応が良く、整形された回答が得られます。欠点: あまり積極的でない場合があり、存在しない関数を提案することがあります", + "Welcome app": "ようこそアプリ", + "Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput.": "オンライン | Googleモデル\nコスト効率と高スループットに最適化されたGemini 2.5 Flashモデル。", + "Wallpaper parallax": "壁紙パララックス効果", + "Place at the bottom": "下部に配置", + "Invalid tool. Supported tools:\n- %1": "無効なツールです。対応ツール:\n- %1", + "Password": "パスワード", + "Details": "詳細", + "Edit directory": "ディレクトリを編集", + "Language": "言語", + "Visualize region": "領域を可視化", + "Enjoy! You can reopen the welcome app any time with Super+Shift+Alt+/. To open the settings app, hit Super+I": "お楽しみください!ウェルカムアプリはSuper+Shift+Alt+/でいつでも開けます。設定アプリを開くにはSuper+Iを押してください", + "Online | Google's model\nGoogle's state-of-the-art multipurpose model that excels at coding and complex reasoning tasks.": "オンライン | Googleモデル\nコーディングや複雑な推論タスクに優れた、Googleの最先端多目的モデル。", + "When enabled keeps the content of the right sidebar loaded to reduce the delay when opening,\nat the cost of around 15MB of consistent RAM usage. Delay significance depends on your system's performance.\nUsing a custom kernel like linux-cachyos might help": "有効にすると右サイドバーのコンテンツを常に読み込んでおくことで、表示遅延を短縮します。\n代償として約15MBのメモリを継続的に使用します。遅延の度合いはシステム性能に依存します。\nlinux-cachyosのようなカスタムカーネルの使用が効果的な場合があります。", + "Place at bottom": "下部に配置", + "Tool set to: %1": "ツールを設定: %1", + "Set the tool to use for the model.": "モデルが使用するツールを設定します。", + "Make sure your player has MPRIS support\nor try turning off duplicate player filtering": "お使いのプレイヤーがMPRISをサポートしているか確認するか、\n重複プレイヤーのフィルタリングを無効にしてみてください", + "Select the language for the user interface.\n\"Auto\" will use your system's locale.": "インターフェース言語を選択します。\n「自動」を選ぶとシステムのロケールが使用されます。", + "Value scroll": "スクロールで音量・明るさを調整", + "There might be a download in progress": "ダウンロードが進行中のようです", + "Approve": "承認", + "Tray": "トレイ", + "**Instructions**: Log into Mistral account, go to Keys on the sidebar, click Create new key": "**手順**: Mistralアカウントにログインし、サイドバーのKeysに進み、Create new keyをクリックしてください", + "Pomodoro": "ポモドーロ", + "Language setting saved. Please restart Quickshell (Ctrl+Super+R) to apply the new language.": "言語設定を保存しました。新しい言語を適用するにはQuickshellを再起動してください(Ctrl+Super+R)。", + "Feels like %1": "体感温度 %1", + "Commands, edit configs, search.\nTakes an extra turn to switch to search mode if that's needed": "コマンド、設定編集、検索が可能。\n検索モードへの切替が必要な場合は追加の対話が必要です", + "Resume": "再開", + "Preferred wallpaper zoom (%)": "壁紙の拡大率(%)", + "Disable tools": "ツールをオフ", + "Night Light | Right-click to toggle Auto mode": "ナイトライト | 右クリックで自動モード切替", + "Online | Google's model\nFast, can perform searches for up-to-date information": "オンライン | Googleモデル\n高速で、最新情報を検索できます", + "Hover to trigger": "ホバーでトリガー", + "Keep right sidebar loaded": "右サイドバーを常に読み込む", + "Kill conflicting programs?": "競合するプログラムを終了しますか?", + "Pick a wallpaper": "壁紙を選択", + "Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers": "オンライン | Googleモデル\n旧モデルより低速ですが、より高品質な回答が期待できる新モデル", + "Hit \"/\" to search": "「/」で検索", + "Config file": "設定ファイル", + "Attach a file. Only works with Gemini.": "ファイルを添付 (Geminiでのみ利用可能)", + "To set an API key, pass it with the %4 command\n\nTo view the key, pass \"get\" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3": "APIキーを設定するには、%4コマンドを使用してください\n\nキーを確認するには、コマンドに「get」を付けてください
\n\n### %1について:\n\n**リンク**: %2\n\n%3", + "Thought": "思考", + "Long break": "長い休憩", + "Temperature\nChange with /temp VALUE": "温度\n/temp 値で変更", + "Usage: %1tool TOOL_NAME": "使用法: %1tool ツール名", + "Pause": "一時停止", + "Allows you to open sidebars by clicking or hovering screen corners regardless of bar position": "バーの配置に関わらず、画面コーナーのクリックまたはホバーでサイドバーを開けるようにします", + "EasyEffects | Right-click to configure": "EasyEffects | 右クリックで設定", + "Up %1": "%1稼働中", + "Place at the bottom/right": "下部/右側に配置", + "Focus": "集中", + "Stopwatch": "ストップウォッチ", + "Interface Language": "インターフェース言語", + "Memory usage": "メモリ使用量", + "Automatically hide": "自動的に隠す", + "Break": "休憩", + "Your package manager is running": "パッケージマネージャーが実行中です", + "API key is set\nChange with /key YOUR_API_KEY": "APIキーが設定済み\n/key YOUR_API_KEYで変更できます", + "Open the shell config file.\nIf the button doesn't work or doesn't open in your favorite editor,\nyou can manually open ~/.config/illogical-impulse/config.json": "シェルの設定ファイルを開きます。\nボタンが機能しない、または任意のエディタで開かない場合は、\n~/.config/illogical-impulse/config.json を手動で開いてください", + "CPU usage": "CPU使用率", + "Swap usage": "スワップ使用量", + "Usage: %1save CHAT_NAME": "使用法: %1save チャット名", + "🔴 Focus: %1 minutes": "🔴 集中: %1分", + "Lap": "ラップ", + "Horizontal": "水平", + "Region width": "領域の幅", + "Vertical": "垂直" +} diff --git a/translations/ru_RU.json b/translations/ru_RU.json new file mode 100644 index 0000000..288bfaa --- /dev/null +++ b/translations/ru_RU.json @@ -0,0 +1,391 @@ +{ + "Output": "Вывод", + "Markdown test": "Тест Markdown", + "Intelligence": "ИИ", + "Load chat": "Загрузить чат", + "Workspaces shown": "Кол-во рабочих столов", + "Style": "Стиль", + "Reject": "Отклонить", + "Volume": "Громкость", + "Shutdown": "Завершение работы", + "Fidelity": "Верность", + "Switched to search mode. Continue with the user's request.": "Переключено в режим поиска. Продолжай с запросом пользователя.", + "No notifications": "Нет уведомлений", + "EasyEffects | Right-click to configure": "EasyEffects | ПКМ, чтобы настроить", + "Suspend at": "Переход в сон", + "Brightness": "Яркость", + "Volume mixer": "Микшер громкости", + "Discussions": "Обсуждения", + "Earbang protection": "Защита от громких звуков", + "Message the model... \"%1\" for commands": "Сообщение для ИИ... \"%1\" для команд", + "Decorations & Effects": "Декорации и эффекты", + "Load chat from %1": "Загрузить чат из %1", + "OK": "ОК", + "No further instruction provided": "Больше инструкций не предоставлено", + "Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5.": "Установить температуру (предсказуемость) модели. Выберите значение от 0 до 2 для Gemini, от 0 до 1 для других моделей. По умолчанию: 0.5", + "Open file link": "Открыть ссылку на файл", + "API key is set\nChange with /key YOUR_API_KEY": "API ключ установлен\nПоменять с помощью /key ВАШ_КЛЮЧ_API", + "Advanced": "Прочие", + "Title bar": "Заголовок", + "Keybinds": "Шорткаты", + "Alternatively use /dark, /light, /img in the launcher": "Также можно использовать /dark, /light, /img в лаунчере", + "Dark/Light toggle": "Темный/светлый", + "Shell & utilities": "Оболочка", + "Clipboard": "Буфер обмена", + "Show app icons": "Показывать иконки", + "Save": "Сохранить", + "Style & wallpaper": "Стиль и обои", + "Battery": "Батарея", + "Expressive": "Выразительность", + "Reboot": "Перезагрузка", + "AI": "ИИ", + "Sleep": "Спящий режим", + "Allow NSFW": "Разрешить NSFW", + "Please charge!\nAutomatic suspend triggers at %1": "Подключите ПК к источнику питания!\nПереход в спящий режим на %1", + "Select input device": "Выберите микрофон", + "Hover to reveal": "Наведите, чтобы раскрыть", + "Unknown Artist": "Неизвестный исполнитель", + "Connection failed. Please inspect manually with the warp-cli command": "Ошибка подключения. Проверьте вручную командой warp-cli", + "Lock": "Блокировка", + "Monochrome": "Монохром", + "**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key": "**Цена**: бесплатно. Данные используются для обучения.\n\n**Инстуркции**: Войдите в аккаунт Google, разрешите AI Studio создать проект Google Cloud или что оно попросит, вернитесь и нажмите Get API key", + "Online models disallowed\n\nControlled by `policies.ai` config option": "Онлайн модели запрещены\n\nУправляется опцией `policies.ai` в конфиге", + "Emojis": "Эмодзи", + "Wallpaper parallax": "Параллакс обоев", + "Interface": "Интерфейс", + "System prompt": "Системный промпт", + "Edit config": "Ред. конфиг", + "Page %1": "Страница %1", + "Task description": "Описание задачи", + "Fruit Salad": "Фруктовый салат", + "%1 Safe Storage": "Безопасное хранилище %1", + "Distro": "Дистрибутив", + "Add": "Добавить", + "Closet": "Закрытый", + "Task Manager": "Диспетчер задач", + "Configuration": "Настройка", + "There might be a download in progress": "Возможно происходит скачивание", + "Visibility": "Видимость", + "Desktop": "Рабочий стол", + "Run": "Запустить", + "Sunrise": "Рассвет", + "Set API key": "Задать API ключ", + "Shell windows": "Окна оболочки", + "Cloudflare WARP (1.1.1.1)": "Cloudflare WARP (1.1.1.1)", + "Action": "Действие", + "Elements": "Элементы", + "Resources": "Ресурсы", + "**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key": "**Цена**: бесплатно. Политика использования данных зависит от настроек OpenRouter.\n\n**Инструкции**: Войдите в аккаунт OpenRouter, зайдите в Keys в меню сверху справа, нажмите Create API Key", + "Game mode": "Игровой режим", + "Code saved to file": "Код сохранен в файл", + "Issues": "Проблемы", + "Depends on sidebars": "Зависит от панелей", + "Translation goes here...": "Здесь будет перевод...", + "Unknown command:": "Неизвестная команда:", + "Invalid API provider. Supported: \n-": "Неизвестный API провайдер. Поддерживаемые: \n-", + "Clear chat history": "Очистить историю", + "Run command": "Выполнить команду", + "Local only": "Локальные", + "Tonal Spot": "Тональное пятно", + "Content": "Контент", + "Wind": "Ветер", + "Total token count\nInput: %1\nOutput: %2": "Количество токенов\nВвод: %1\nВывод: %2", + "Current model: %1\nSet it with %2model MODEL": "Текущая модель: %1\nСменить с помощью %2model МОДЕЛЬ", + "Dark": "Темный", + "Hug": "Захват", + "Hibernate": "Гибернация", + "Registration failed. Please inspect manually with the warp-cli command": "Ошибка регистрации. Проверьте вручную командой warp-cli", + "Calendar": "Календарь", + "Save chat to %1": "Сохранить чат в %1", + "Finished tasks will go here": "Здесь будут выполненные задачи", + "Set the current API provider": "Задать текущего провайдера API", + "Weather Service": "Сервис погоды", + "Fake screen rounding": "Фейковое округление экрана", + "View Markdown source": "Посмотреть исходной Markdown", + "Change any time later with /dark, /light, /img in the launcher": "Изменяется в любое время с помощью /dark, /light, /img в лаунчере", + "Critical warning": "Критический", + "Waifus only | Excellent quality, limited quantity": "Только вайфу | Превосходное качество, ограниченное количество", + "Unknown function call: %1": "Неизвестный вызов функции: %1", + "Neutral": "Нейтрал", + "Anime": "Аниме", + "Useless buttons": "Бесполезные кнопочки", + "Unfinished": "Незавершенные", + "Privacy Policy": "Политика конфиденциальности", + "Online via %1 | %2's model": "Онлайн через %1 | Модель %2", + "Large images | God tier quality, no NSFW.": "Огромные изображение | Божественное качество, нету NSFW.", + "Base URL": "Базовый URL", + "Not visible to model": "Невидимый для модели", + "Auto": "Авто", + "Might look ass. Unsupported.": "Может выглядеть плохо. Не поддерживается", + "%1 • %2 tasks": "%1 • %2 задач", + "Search the web": "Искать в интернете", + "Scroll to change brightness": "Листайте, чтобы изменить яркость", + "Invalid arguments. Must provide `key` and `value`.": "Неправильные аргументы. Нужно предоставить `key` и `value`.", + "Settings": "Настройки", + "Show regions of potential interest": "Показывать регионы с потенциальным интересом", + "Pressure": "Давление", + "No": "Нет", + "Such regions could be images or parts of the screen that have some containment.\nMight not always be accurate.\nThis is done with an image processing algorithm run locally and no AI is used.": "Такие регионы могут быть изображениями или части экрана, которые имеют некую ограниченность.\nМожет быть неверно.\nЭто происходит с помощью алгоритма обработки изобржений локально и ИИ не используется", + "Select Language": "Выбрать Язык", + "Command rejected by user": "Команда прервана пользователем", + "Approve": "Разрешить", + "Terminal": "Терминал", + "Search": "Поиск", + "or": "или", + "Save chat": "Сохранить чат", + "Load prompt from %1": "Загрузить промпт из %1", + "Critically low battery": "Критически низкий заряд батареи", + "The hentai one | Great quantity, a lot of NSFW, quality varies wildly": "Хентай | Отличный выбор, качество сильно варьируется", + "Input": "Ввод", + "Borderless": "Безрамочный", + "Loaded the following system prompt\n\n---\n\n%1": "Загружен следующий системный промпт\n\n---\n\n%1", + "Thought": "Мысли", + "Your package manager is running": "Ваш пакетный менеджер работает", + "12h AM/PM": "12 ч. AM/PM", + "Scale (%)": "Размер (%)", + "Waiting for response...": "Ожидание ответа...", + "%1 does not require an API key": "%1 не требует API ключ", + "Edit": "Изменить", + "Dock": "Панель задач", + "Low warning": "Низкий", + "Silent": "Выкл. звук", + "Rainbow": "Радуга", + "Anime boorus": "Аниме боору", + "Nothing here!": "Тут ничего нет!", + "Documentation": "Документация", + "Clear": "Очистить", + "Transparency": "Прозрачность", + "Show background": "Показывать фон", + "Info": "Инфо", + "12h am/pm": "12 ч. am/pm", + "Reload Hyprland & Quickshell": "Перезагрузить Hyprland и Quickshell", + "%1 characters": "%1 символов", + ". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!": ". Напоминание для Zerochan:\n- Вы должны ввести цвет\n- Установите свой юзернейм Zerochan с помощью `sidebar.booru.zerochan.username` в конфиге. Вы [можете быть забанены, если не сделаете этого](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!", + "For desktop wallpapers | Good quality": "Для обоев | Отличное качество", + "Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window": "Введите /key, чтобы начать работу с онлайн моделями\nCtrl+O для расширения панели\nCtrl+P для отсоединения панели", + "Close": "Закрыть", + "Search, calculate or run": "Найти, вычислить или выполнить", + "Add task": "Добавить задачу", + "Enable": "Включить", + "Temperature set to %1": "Температура установлена на %1", + "No media": "Нет медиа", + "Screen snip": "Скриншот", + "Go to source (%1)": "Перейти к источнику (%1)", + "Download": "Скачать", + "Sunset": "Закат", + "Weeb": "Аниме", + "The current system prompt is\n\n---\n\n%1": "Текущий системный промпт\n\n---\n\n%1", + "Scroll to change volume": "Листайте, чтобы изменить громкость", + "Unknown Title": "Неизвестное Название", + "Policies": "Ограничения", + "Disable NSFW content": "Отключить NSFW контент", + "Humidity": "Влажность", + "Cheat sheet": "Шпаргалка", + "Translator": "Переводчик", + "Be patient...": "Подождите...", + "Audio": "Аудио", + "Preferred wallpaper zoom (%)": "Предпочитаемый зум обоев (%)", + "Model set to %1": "Установлена модель %1", + "Enter text to translate...": "Введите текст для перевода...", + "Use Levenshtein distance-based algorithm instead of fuzzy": "Использовать алгоритм, основанный на расстоянии Левенштейна,\n вместо нечёткого сопоставления.", + "Depends on workspace": "Зависит от пространства", + "All-rounder | Good quality, decent quantity": "Универсальный | Хорошее качество, приличное количество", + "Timeout (ms)": "Таймаут (мс)", + "Plain rectangle": "Обычный прямоугольник", + "No API key\nSet it with /key YOUR_API_KEY": "Нет API ключа.\nУстановите его с помощью /key ВАШ_КЛЮЧ_API", + "Center title": "Центрировать название", + "Buttons": "Кнопки", + "Copy code": "Скопировать код", + "Precipitation": "Осадки", + "The popular one | Best quantity, but quality can vary wildly": "Популярный | Огромный выбор, но качество сильно варьируется", + "Temperature: %1": "Температура: %1", + "Qt apps": "Qt приложения", + "Delete": "Удалить", + "Saved to %1": "Сохранено в %1", + "Temperature\nChange with /temp VALUE": "Температура\nИзмените с помощью /temp ЗНАЧЕНИЕ", + "App": "Приложение", + "Bluetooth": "Bluetooth", + "Current API endpoint: %1\nSet it with %2mode PROVIDER": "Текущий API: %1\nИзменить с помощью %2mode ПРОВАЙДЕР", + "Cancel": "Отменить", + "Clean stuff | Excellent quality, no NSFW": "Чистый контент | Превосходное качество, нету NSFW", + "Wallpaper": "Обои", + "Provider set to": "Провайдер установлен на", + "Weather": "Погода", + "24h": "24 ч.", + "Show next time": " Показать в следующий раз", + "Unknown": "Неизвестно", + "Launch": "Запустить", + "Overview": "Обзор", + "Help & Support": "Помощь и поддержка", + "Night Light | Right-click to toggle Auto mode": "Ночной свет | ПКМ для переключения Авто режима", + "Web search": "Веб-поиск", + "Cannot find a GPS service. Using the fallback method instead.": "Не удалось найти GPS сервис. Используем запасный вариант.", + "Large language models": "Большие языковые модели", + "Dotfiles": "Дотфайлы", + "When not fullscreen": "Когда не в полноэкранном режиме", + "Screenshot tool": "Инструмент скриншотов", + "Get the next page of results": "Получить резултаты следующей страницы", + "Drag or click a region • LMB: Copy • RMB: Edit": "Проведите или кликните на регион • ЛКМ: Скопировать • ПКМ: Редактировать", + "Material palette": "Material палитра", + "Columns": "Столбцы", + "Bar": "Панель", + "Max allowed increase": "Макс. рост", + "Always show numbers": "Всегда показывать номера", + "%1 notifications": "%1 уведомлений", + "Rows": "Ряды", + "Invalid arguments. Must provide `command`.": "Неправильный аргумент. Нужно предоставить `command`.", + "System": "Система", + "Shell & utilities theming must also be enabled": "Расцветка оболочки также должна быть включена.", + "%1 queries pending": "%1 запросов в очереди", + "Copy": "Скопировать", + "Logout": "Выход", + "Pinned on startup": "Закреплена на запуске", + "%1 | Right-click to configure": "%1 | ПКМ, чтобы настроить", + "Donate": "Задонатить", + "Temperature must be between 0 and 2": "Температура должна быть между 0 и 2", + "Session": "Сессия", + "Mic toggle": "Перекл. микрофон", + "Reboot to firmware settings": "Перезагрузиться в настройки BIOS/UEFI", + "Low battery": "Низкий заряд", + "Usage": "Использование", + "Notifications": "Уведомления", + "Consider plugging in your device": "Задумайтесь о подключении ПК к источнику питания", + "Cloudflare WARP": "Cloudflare WARP", + "Automatically suspends the system when battery is low": "Автоматически переходит в режим сна когда заряд низкий", + "Services": "Сервисы", + "Thinking": "Думаю", + "Color generation": "Генерация цветов", + "Number show delay when pressing Super (ms)": "Задержка показа номеров при нажатии Super (мс)", + "illogical-impulse Welcome": "Добро пожаловать в illogical-impulse", + "User agent (for services that require it)": "User agent (для сервисов, которым он нужен)", + "On-screen display": "Отображение на экране", + "Download complete": "Загрузка завершена", + "Time": "Время", + "Float": "Парящая", + "Pick wallpaper image on your system": "Выберите изображение для обоев на своём ПК", + "Prevents abrupt increments and restricts volume limit": "Предотвращает резкие увеличения громкости и закрепляет лимит громоксти", + "Unknown Album": "Неизвестный Альбом", + "Math result": "Результат вычисления", + "Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers": "Случайные SFW обои с Konachan\nИзображение сохраняются в ~/Изображения/Wallpapers", + "Could be better if you make a ton of typos,\nbut results can be weird and might not work with acronyms\n(e.g. \"GIMP\" might not give you the paint program)": "Может быть лучше, если вы сделаете много ошибок,\nно результаты могут быть странными и могут не работать с сокращениями (т.е. \"GIMP\" не выдаст программу для рисования)", + "Select output device": "Выберите динамики", + "Set the system prompt for the model.": "Задать системный промпт для этой модели.", + "Choose file": "Выберите файл", + "Choose model": "Выберите модель", + "Tip: Hide icons and always show numbers for\nthe classic illogical-impulse experience": "Подсказка: Включите \"Показывать иконик \" и \"Всегда показывать номера\"\nдля классического опыта illogical-impulse", + "Yes": "Да", + "Local Ollama model | %1": "Локальная модель Ollama | %1", + "API key set for %1": "API ключ установлен на %1", + "Format": "Формат", + "Colors & Wallpaper": "Цвета и Обои", + "Note: turning off can hurt readability": "Подсказка: отключение может повредить читабельность", + "illogical-impulse": "illogical-impulse", + "Automatic suspend": "Авто-сон", + "Random: Konachan": "Рандом: Konachan", + "Workspace": "Рабочее пространство", + "About": "О системе", + "Color picker": "Пипетка", + "Report a Bug": "Сообщить об ошибке", + "Volume limit": "Лимит громкости", + "GitHub": "GitHub", + "Prefixes": "Префиксы", + "Done": "Готово", + "Invalid model. Supported: \n```": "Неправильная модель. Доступны: \n```", + "UV Index": "УФ-индекс", + "Clear the current list of images": "Очистить список изображений", + "No audio source": "Нет источников аудио", + "API key:\n\n```txt\n%1\n```": "API ключ:\n\n```txt\n%1\n```", + "For storing API keys and other sensitive information": "Для хранения API ключей и другой чувствительной информации", + "Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel": "Стрелочки для навигации, Enter для выбора\nEsc или клик везде чтобы отменить", + "Networking": "Сеть", + "Keep system awake": "Держать ПК включённым", + "Polling interval (ms)": "Интервал опроса (мс)", + "To Do": "Задачи", + "Workspaces": "Рабочие пространства", + "That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number": "Это не сработало. Подсказки:\n- Проверьте теги и настройки NSFW\n- Если на уме нету тегов, введите номер страницы", + "Jump to current month": "Перейти к текущему месяцу", + "Enter tags, or \"%1\" for commands": "Введите теги, или \"%1\" для команд", + "No API key set for %1": "API ключ не установлен для %1", + "Allow NSFW content": "Разрешить NSFW контент", + "Save to Downloads": "Сохранить в загрузки", + "Light": "Светлый", + "Keyboard toggle": "Экранная клавиатура", + "Timer": "Таймер", + "Stopwatch": "Секундомер", + "Pomodoro": "Pomodoro", + "Start": "Начать", + "Reset": "Сбросить", + "Pause": "Пауза", + "Resume": "Возобновить", + "Lap": "Круг", + "Fully charged": "Полностью заряжена", + "No pending tasks": "Нет незавершенных задач", + "Place at the bottom/right": "Разместить внизу/справа", + "Tray": "Трей", + "Tint icons": "Тень иконок", + "Overall appearance": "Общий внешний вид", + "Tint app icons": "Тень иконок приложений", + "We": "Ср/*keep*/", + "Mo": "Пн/*keep*/", + "Su": "Вс/*keep*/", + "Th": "Чт/*keep*/", + "Tu": "Вт/*keep*/", + "Sa": "Сб/*keep*/", + "Fr": "Пт/*keep*/", + "Usage: %1load CHAT_NAME": "Использование: %1load ИМЯ_ЧАТА", + "Set the tool to use for the model.": "Установите инструмент для этой модели.", + "Invalid tool. Supported tools:\n- %1": "Неправильный инструмент. Доступны:\n- %1", + "Usage: %1tool TOOL_NAME": "Использование: %1tool ИНСТРУМЕНТ", + "Performance Profile toggle": "Переключатель питания", + "Usage: %1save CHAT_NAME": "Использование: %1save ИМЯ_ЧАТА", + "Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput.": "Онлайн | Модель Google\nМодель Gemini 2.5 Flash оптимизирована под меньшие затраты и высокую производительнность", + "Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers": "Онлайн | Модель Google\nНовая модель, которая медленее, чем ее предшественник, но выдает лучшее качество", + "Online | Google's model\nFast, can perform searches for up-to-date information": "Онлайн | Модель Google\nБыстрая, может выполнять поиск актуальной информации", + "Current tool: %1\nSet it with %2tool TOOL": "Текущий инструмент: %1\nИзменяется с помощью %2tool ИНСТРУМЕНТ", + "Hi there! First things first...": "Привет! Начнем с самого главного...", + "Time to empty:": "Время до полной разрядки:", + "Free:": "Free:", + "Up %1": "Up %1", + "To Do:": "Задачи:", + "Total:": "Total:", + "Welcome app": "Приветственное приложение", + "Incorrect password": "Неверный пароль", + "Charging:": "Заряжается:", + "To set an API key, pass it with the %4 command\n\nTo view the key, pass \"get\" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3": "Чтобы установить ключ API, передайте его с помощью команды %4.\n\nЧтобы просмотреть ключ, передайте \"get\" с помощью команды.
\n\n### Для %1:\n\n**Ссылка**: %2\n\n%3", + "🔴 Focus: %1 minutes": "🔴 Focus: %1 minutes", + "System uptime:": "Время работы системы:", + "Automatically hide": "Автоматически скрывать", + "Online | %1's model | Delivers fast, responsive and well-formatted answers. Disadvantages: not very eager to do stuff; might make up unknown function calls": "Онлайн | Модель %1 | Выдаёт быстрые, отзывчивые и хорошо отформатированные ответы. Недостатки: не очень охотно работает; может создавать неизвестные вызовы функций.", + "**Pricing**: Free tier available with limited rates. See https://docs.github.com/en/billing/concepts/product-billing/github-models\n\n**Instructions**: Generate a GitHub personal access token with Models permission, then set as API key here\n\n**Note**: To use this you will have to set the temperature parameter to 1": "**Цена**: Доступен бесплатный тариф с ограниченными возможностями. См. https://docs.github.com/en/billing/concepts/product-billing/github-models\n\n**Инструкция**: Создайте персональный токен доступа GitHub с разрешением Models, затем установите его в качестве ключа API здесь.\n\n**Примечание**: Для использования этого параметра необходимо установить значение температуры равным 1.", + "Shell conflicts killer": "Убийца конфликтных оболочек", + "🌿 Long break: %1 minutes": "🌿 Long break: %1 minutes", + "Always": "Всегда", + "Enter password": "Введите пароль", + "Keep right sidebar loaded": "Держать правую панель загруженной", + "Tool set to: %1": "Инструмент установлен: %1", + "Sidebars": "Боковые панели", + "Kill conflicting programs?": "Убить конфликтующую программу?", + "Used:": "Used:", + "Time to full:": "Время до полной зарядки:", + "Horizontal": "Горизонтальная", + "Medium": "Средний", + "Low": "Низкий", + "Conflicts with the shell's system tray implementation": "Конфликтует с системным треем оболочки.", + "Long break": "Long break", + "Vertical": "Вертикальная", + "Commands, edit configs, search.\nTakes an extra turn to switch to search mode if that's needed": "Команды, редактирование конфигов, поиск.\nПри необходимости переход в режим поиска, если это необходимо", + "Gives the model search capabilities (immediately)": "Сразу предоставляет возможности поиска модели", + "**Instructions**: Log into Mistral account, go to Keys on the sidebar, click Create new key": "**Инструкция**: Войдите в учетную запись Mistral, перейдите в раздел «Ключи» на боковой панели, нажмите «Создать новый ключ».", + "☕ Break: %1 minutes": "☕ Break: %1 minutes", + "Discharging:": "Разряжается:", + "... and %1 more": "... и ещё %1", + "Break": "Перерыв", + "Disable tools": "Отключить инструменты", + "Enjoy! You can reopen the welcome app any time with Super+Shift+Alt+/. To open the settings app, hit Super+I": "Наслаждайтесь! Вы всегда можете снова открыть приветственное приложение сочетанием клавиш Super+Shift+Alt+/. Чтобы открыть настройки, нажмите Super+I", + "Conflicts with the shell's notification implementation": "Конфликтует с уведомлениями оболочки", + "Online | Google's model\nGoogle's state-of-the-art multipurpose model that excels at coding and complex reasoning tasks.": "Онлайн | Модель Google\nСовременная универсальная модель Google, которая превосходно справляется с задачами кодирования и сложными рассуждениями.", + "When enabled keeps the content of the right sidebar loaded to reduce the delay when opening,\nat the cost of around 15MB of consistent RAM usage. Delay significance depends on your system's performance.\nUsing a custom kernel like linux-cachyos might help": "При включении этого параметра содержимое правой боковой панели сохраняется загруженным, что сокращает задержку при открытии,\nзатрачивая около 15 МБ постоянного использования оперативной памяти. Длительность задержки зависит от производительности вашей системы.\nИспользование пользовательского ядра, например, linux-cachyos, может помочь.", + "High": "Высокий", + "Focus": "Фокус", + "Load:": "Load:" +} diff --git a/translations/tools/README.md b/translations/tools/README.md new file mode 100644 index 0000000..94c2136 --- /dev/null +++ b/translations/tools/README.md @@ -0,0 +1,285 @@ +# Translation Management Tool Suite + +This suite is used to manage project translation files, automatically extract translatable texts, compare differences between language files, and provide maintenance functions. + +## Tool Components + +### 1. `translation-manager.py` - Main Translation Manager +- Extract translatable texts +- Compare and update translation files +- Interactive addition/removal of translation keys + +### 2. `translation-cleaner.py` - Translation File Maintenance Tool +- Clean unused translation keys +- Synchronize key structure across different language files + +### 3. `manage-translations.sh` - Convenient Wrapper Script +- Provides a unified command-line interface +- Displays translation status +- Simplifies common operations + +## Quick Start + +### Using the Wrapper Script (Recommended) + +```bash +# Enter the tools directory +cd .config/quickshell/translations/tools + +# Show help +./manage-translations.sh --help + +# Show current translation status +./manage-translations.sh status + +# Extract translatable texts +./manage-translations.sh extract + +# Update all translation files +./manage-translations.sh update + +# Update a specific language +./manage-translations.sh update -l zh_CN + +# Clean unused keys +./manage-translations.sh clean + +# Synchronize keys across all language files +./manage-translations.sh sync +``` + +Or run from the project root: +```bash +# Run from the project root +.config/quickshell/translations/tools/manage-translations.sh status +.config/quickshell/translations/tools/manage-translations.sh update +``` + +## Detailed Usage + +### Translation Manager (`translation-manager.py`) + +Basic usage: +```bash +# Process all languages +./translation-manager.py + +# Specify a particular language +./translation-manager.py --language zh_CN + +# Extract translatable texts only +./translation-manager.py --extract-only + +# Show extracted texts +./translation-manager.py --extract-only --show-temp +``` + +Parameter description: +- `--translations-dir`, `-t`: Translation files directory (default: `.config/quickshell/translations`) +- `--source-dir`, `-s`: Source code directory (default: `.config/quickshell`) +- `--language`, `-l`: Specify the language code to process +- `--extract-only`, `-e`: Only extract translatable texts +- `--show-temp`: Show the content of the temporary extraction file + +### Translation Cleaner (`translation-cleaner.py`) + +```bash +# Clean unused translation keys +./translation-cleaner.py --clean + +# Synchronize translation keys (using en_US as the base) +./translation-cleaner.py --sync + +# Specify a different source language for syncing +./translation-cleaner.py --sync --source-lang zh_CN + +# Clean without creating backups +./translation-cleaner.py --clean --no-backup +``` + +## Workflow + +### Regular Translation Update Workflow + +1. **Check status**: + ```bash + ./manage-translations.sh status + ``` + +2. **Update translations**: + ```bash + ./manage-translations.sh update + ``` + +3. **Clean unused keys** (optional): + ```bash + ./manage-translations.sh clean + ``` + +### Adding a New Language + +1. **Create a new language file**: + ```bash + ./manage-translations.sh update -l new_lang + ``` + +2. **Synchronize key structure**: + ```bash + ./manage-translations.sh sync + ``` + +### Cleanup After Large Refactoring + +1. **Backup translation files**: + ```bash + cp -r .config/quickshell/translations .config/quickshell/translations.backup + ``` + +2. **Clean unused keys**: + ```bash + ./manage-translations.sh clean + ``` + +3. **Synchronize all languages**: + ```bash + ./manage-translations.sh sync + ``` + +## Supported Translatable Text Formats + +The tool recognizes the following formats for translatable texts: + +```qml +// Basic format +Translation.tr("Hello, world!") +Translation.tr('Hello, world!') +Translation.tr(`Hello, world!`) + +// With line breaks +Translation.tr("Line 1\nLine 2") + +// With escape characters +Translation.tr("Say \"Hello\"") + +// With parameter placeholders +Translation.tr("Hello, %1!").arg(name) +``` + +## Example Output + +### Status Display +``` +$ ./manage-translations.sh status +Analyzing translation status... +=== Current Project Status === +166 translatable texts extracted + +=== Translation File Status === + en_US: 470 keys + zh_CN: 470 keys +``` + +### Update Translations +``` +$ ./manage-translations.sh update -l zh_CN +Updating translation files... +================================================== +Processing language: zh_CN +================================================== +Analysis result: + Missing keys: 5 + Extra keys: 20 + +Found 5 missing translation keys: +1. "New feature text" +2. "Another new text" +... + +Add these 5 missing keys? (y/n): y +5 keys added + +Found 20 extra translation keys: +1. "Removed old text" -> "已删除的旧文本" +... + +Delete these 20 extra keys? (y/n): y +20 keys deleted + +Translation file saved +``` + +### Clean Unused Keys +``` +$ ./manage-translations.sh clean +Cleaning unused translation keys... +Processing language: zh_CN +Found 50 unused keys: + 1. "old_unused_text" + 2. "deprecated_message" + ... + +Delete these 50 unused keys? (y/n): y +50 keys deleted +Original key count: 470, after cleaning: 420 +``` + +## Advanced Features + +### Custom Directory Structure + +```bash +# Use custom directories +./translation-manager.py \ + --translations-dir /path/to/translations \ + --source-dir /path/to/source +``` + +### Ignore Mark Feature + +For dynamic resources or special texts that should not be automatically cleaned, you can add `/*keep*/` at the end of the translation value. The tool will automatically ignore these keys and will not delete them during cleaning or syncing. + +Example: +```json +{ + "dynamic_key": "Some dynamic value /*keep*/" +} +``` + +## Notes + +1. **Backup is important**: The tool automatically creates backups before cleaning, but it is recommended to manually back up important files + +2. **Text extraction limitations**: + - ~~Only supports static strings, not dynamically constructed strings~~ + - Dynamic resources (such as variable concatenation or runtime-generated text) cannot be automatically extracted. You need to manually add them to the translation file and use the `/*keep*/` mark for ignore management. + - Must use the `Translation.tr()` format + +3. **File encoding**: All files must use UTF-8 encoding + +4. **Key naming conventions**: It is recommended to use English for key names and avoid special characters + +## Troubleshooting + +### Common Issues + +**Q: Text does not appear after adding Translation.tr?** +A: You need to import the translation feature in your QML file using `import "root:/"`, otherwise the translation text will not be displayed correctly. + +**Q: The number of extracted texts does not match expectations?** +A: Check whether all translatable texts use the `Translation.tr()` format and ensure there are no dynamically constructed strings. + +**Q: Some translations are missing after syncing?** +A: Check whether the source language file contains all necessary keys, and consider using a different source language for syncing. + +**Q: The cleaning operation deleted needed keys?** +A: Restore from the automatically created backup file and check whether `Translation.tr()` is used correctly in the source code. + +### Restore Backup + +```bash +# Restore a single file +cp .config/quickshell/translations/zh_CN.json.backup .config/quickshell/translations/zh_CN.json + +# Restore all files +cp .config/quickshell/translations.backup/* .config/quickshell/translations/ +``` diff --git a/translations/tools/guide/translation-tools-guide-zh_CN.md b/translations/tools/guide/translation-tools-guide-zh_CN.md new file mode 100644 index 0000000..821533b --- /dev/null +++ b/translations/tools/guide/translation-tools-guide-zh_CN.md @@ -0,0 +1,286 @@ +# 翻译管理工具套件 + +这套工具用于管理项目的翻译文件,自动提取可翻译文本,比较不同语言文件之间的差异,并提供维护功能。 + +## 工具组成 + +### 1. `translation-manager.py` - 主要翻译管理器 +- 提取可翻译文本 +- 比较和更新翻译文件 +- 交互式添加/删除翻译键 + +### 2. `translation-cleaner.py` - 翻译文件维护工具 +- 清理不再使用的翻译键 +- 同步不同语言文件的键结构 + +### 3. `manage-translations.sh` - 便捷包装脚本 +- 提供统一的命令行界面 +- 显示翻译状态 +- 简化常用操作 + +## 快速开始 + +### 使用便捷脚本(推荐) + +```bash +# 进入工具目录 +cd .config/quickshell/translations/tools + +# 查看帮助 +./manage-translations.sh --help + +# 显示当前翻译状态 +./manage-translations.sh status + +# 提取可翻译文本 +./manage-translations.sh extract + +# 更新所有翻译文件 +./manage-translations.sh update + +# 更新特定语言 +./manage-translations.sh update -l zh_CN + +# 清理不再使用的键 +./manage-translations.sh clean + +# 同步所有语言文件的键 +./manage-translations.sh sync +``` + +或者从项目根目录运行: +```bash +# 从项目根目录运行 +.config/quickshell/translations/tools/manage-translations.sh status +.config/quickshell/translations/tools/manage-translations.sh update +``` + +## 详细使用说明 + +### 翻译管理器 (`translation-manager.py`) + +基本用法: +```bash +# 处理所有语言 +./translation-manager.py + +# 指定特定语言 +./translation-manager.py --language zh_CN + +# 仅提取可翻译文本 +./translation-manager.py --extract-only + +# 显示提取的文本 +./translation-manager.py --extract-only --show-temp +``` + +参数说明: +- `--translations-dir`, `-t`: 翻译文件目录(默认:`.config/quickshell/translations`) +- `--source-dir`, `-s`: 源代码目录(默认:`.config/quickshell`) +- `--language`, `-l`: 指定要处理的语言代码 +- `--extract-only`, `-e`: 仅提取可翻译文本 +- `--show-temp`: 显示临时提取文件的内容 + +### 翻译清理器 (`translation-cleaner.py`) + +```bash +# 清理不再使用的翻译键 +./translation-cleaner.py --clean + +# 同步翻译键(以 en_US 为基准) +./translation-cleaner.py --sync + +# 指定不同的源语言进行同步 +./translation-cleaner.py --sync --source-lang zh_CN + +# 清理时不创建备份 +./translation-cleaner.py --clean --no-backup +``` + +## 工作流程 + +### 日常翻译更新流程 + +1. **检查状态**: + ```bash + ./manage-translations.sh status + ``` + +2. **更新翻译**: + ```bash + ./manage-translations.sh update + ``` + +3. **清理无用键**(可选): + ```bash + ./manage-translations.sh clean + ``` + +### 新增语言流程 + +1. **创建新语言文件**: + ```bash + ./manage-translations.sh update -l new_lang + ``` + +2. **同步键结构**: + ```bash + ./manage-translations.sh sync + ``` + +### 大规模重构后的清理流程 + +1. **备份翻译文件**: + ```bash + cp -r .config/quickshell/translations .config/quickshell/translations.backup + ``` + +2. **清理无用键**: + ```bash + ./manage-translations.sh clean + ``` + +3. **同步所有语言**: + ```bash + ./manage-translations.sh sync + ``` + +## 支持的翻译文本格式 + +工具可以识别以下格式的可翻译文本: + +```qml +// 基本格式 +Translation.tr("Hello, world!") +Translation.tr('Hello, world!') +Translation.tr(`Hello, world!`) + +// 带换行符 +Translation.tr("Line 1\nLine 2") + +// 带转义字符 +Translation.tr("Say \"Hello\"") + +// 带参数占位符 +Translation.tr("Hello, %1!").arg(name) +``` + +## 示例输出 + +### 状态显示 +``` +$ ./manage-translations.sh status +正在分析翻译状态... +=== 当前项目状态 === +提取到 166 个可翻译文本 + +=== 翻译文件状态 === + en_US: 470 个键 + zh_CN: 470 个键 +``` + +### 更新翻译 +``` +$ ./manage-translations.sh update -l zh_CN +更新翻译文件... +================================================== +处理语言: zh_CN +================================================== +分析结果: + 缺少的键: 5 + 多余的键: 20 + +发现 5 个缺少的翻译键: +1. "New feature text" +2. "Another new text" +... + +是否添加这 5 个缺少的键? (y/n): y +已添加 5 个键 + +发现 20 个多余的翻译键: +1. "Removed old text" -> "已删除的旧文本" +... + +是否删除这 20 个多余的键? (y/n): y +已删除 20 个键 + +已保存翻译文件 +``` + +### 清理无用键 +``` +$ ./manage-translations.sh clean +清理不再使用的翻译键... +处理语言: zh_CN +发现 50 个不再使用的键: + 1. "old_unused_text" + 2. "deprecated_message" + ... + +是否删除这 50 个不再使用的键? (y/n): y +已删除 50 个键 +原始键数: 470, 清理后: 420 +``` + +## 高级功能 + +### 自定义目录结构 + +```bash +# 使用自定义目录 +./translation-manager.py \ + --translations-dir /path/to/translations \ + --source-dir /path/to/source +``` + + +## 注意事项 + +1. **备份重要**:在执行清理操作前,工具会自动创建备份,但建议手动备份重要文件 + +2. **文本提取限制**: + - ~~只支持静态字符串,不支持动态构建的字符串~~ + - 动态资源(如变量拼接、运行时生成的文本)无法自动提取,需要在翻译文件中手动添加,并使用 `/*keep*/` 标记进行忽略管理。 + - 必须使用 `Translation.tr()` 格式 +### 忽略标记功能 + +对于动态资源或特殊文本,如果不希望被自动清理,可在翻译值末尾添加 `/*keep*/`,工具会自动忽略这些键,不会在清理和同步时删除。 + +示例: +```json +{ + "dynamic_key": "Some dynamic value /*keep*/" +} +``` + +3. **文件编码**:所有文件必须使用 UTF-8 编码 + +4. **键名规范**:建议使用英文作为键名,避免使用特殊字符 + +## 故障排除 + +### 常见问题 + + +**Q: 添加了 Translation.tr 后文字不显示?** +A: 需要在 QML 文件中使用 `import "root:/"` 导入翻译功能,否则无法正常显示翻译文本。 + +**Q: 提取的文本数量与预期不符?** +A: 检查是否所有可翻译文本都使用了 `Translation.tr()` 格式,确保没有动态构建的字符串。 + +**Q: 同步后某些翻译丢失?** +A: 检查源语言文件是否包含所有必要的键,考虑使用不同的源语言进行同步。 + +**Q: 清理操作删除了需要的键?** +A: 从自动创建的备份文件中恢复,检查源代码中是否正确使用了 `Translation.tr()`。 + +### 恢复备份 + +```bash +# 恢复单个文件 +cp .config/quickshell/translations/zh_CN.json.backup .config/quickshell/translations/zh_CN.json + +# 恢复所有文件 +cp .config/quickshell/translations.backup/* .config/quickshell/translations/ +``` diff --git a/translations/tools/guide/translation-tools-guide.md b/translations/tools/guide/translation-tools-guide.md new file mode 100644 index 0000000..94c2136 --- /dev/null +++ b/translations/tools/guide/translation-tools-guide.md @@ -0,0 +1,285 @@ +# Translation Management Tool Suite + +This suite is used to manage project translation files, automatically extract translatable texts, compare differences between language files, and provide maintenance functions. + +## Tool Components + +### 1. `translation-manager.py` - Main Translation Manager +- Extract translatable texts +- Compare and update translation files +- Interactive addition/removal of translation keys + +### 2. `translation-cleaner.py` - Translation File Maintenance Tool +- Clean unused translation keys +- Synchronize key structure across different language files + +### 3. `manage-translations.sh` - Convenient Wrapper Script +- Provides a unified command-line interface +- Displays translation status +- Simplifies common operations + +## Quick Start + +### Using the Wrapper Script (Recommended) + +```bash +# Enter the tools directory +cd .config/quickshell/translations/tools + +# Show help +./manage-translations.sh --help + +# Show current translation status +./manage-translations.sh status + +# Extract translatable texts +./manage-translations.sh extract + +# Update all translation files +./manage-translations.sh update + +# Update a specific language +./manage-translations.sh update -l zh_CN + +# Clean unused keys +./manage-translations.sh clean + +# Synchronize keys across all language files +./manage-translations.sh sync +``` + +Or run from the project root: +```bash +# Run from the project root +.config/quickshell/translations/tools/manage-translations.sh status +.config/quickshell/translations/tools/manage-translations.sh update +``` + +## Detailed Usage + +### Translation Manager (`translation-manager.py`) + +Basic usage: +```bash +# Process all languages +./translation-manager.py + +# Specify a particular language +./translation-manager.py --language zh_CN + +# Extract translatable texts only +./translation-manager.py --extract-only + +# Show extracted texts +./translation-manager.py --extract-only --show-temp +``` + +Parameter description: +- `--translations-dir`, `-t`: Translation files directory (default: `.config/quickshell/translations`) +- `--source-dir`, `-s`: Source code directory (default: `.config/quickshell`) +- `--language`, `-l`: Specify the language code to process +- `--extract-only`, `-e`: Only extract translatable texts +- `--show-temp`: Show the content of the temporary extraction file + +### Translation Cleaner (`translation-cleaner.py`) + +```bash +# Clean unused translation keys +./translation-cleaner.py --clean + +# Synchronize translation keys (using en_US as the base) +./translation-cleaner.py --sync + +# Specify a different source language for syncing +./translation-cleaner.py --sync --source-lang zh_CN + +# Clean without creating backups +./translation-cleaner.py --clean --no-backup +``` + +## Workflow + +### Regular Translation Update Workflow + +1. **Check status**: + ```bash + ./manage-translations.sh status + ``` + +2. **Update translations**: + ```bash + ./manage-translations.sh update + ``` + +3. **Clean unused keys** (optional): + ```bash + ./manage-translations.sh clean + ``` + +### Adding a New Language + +1. **Create a new language file**: + ```bash + ./manage-translations.sh update -l new_lang + ``` + +2. **Synchronize key structure**: + ```bash + ./manage-translations.sh sync + ``` + +### Cleanup After Large Refactoring + +1. **Backup translation files**: + ```bash + cp -r .config/quickshell/translations .config/quickshell/translations.backup + ``` + +2. **Clean unused keys**: + ```bash + ./manage-translations.sh clean + ``` + +3. **Synchronize all languages**: + ```bash + ./manage-translations.sh sync + ``` + +## Supported Translatable Text Formats + +The tool recognizes the following formats for translatable texts: + +```qml +// Basic format +Translation.tr("Hello, world!") +Translation.tr('Hello, world!') +Translation.tr(`Hello, world!`) + +// With line breaks +Translation.tr("Line 1\nLine 2") + +// With escape characters +Translation.tr("Say \"Hello\"") + +// With parameter placeholders +Translation.tr("Hello, %1!").arg(name) +``` + +## Example Output + +### Status Display +``` +$ ./manage-translations.sh status +Analyzing translation status... +=== Current Project Status === +166 translatable texts extracted + +=== Translation File Status === + en_US: 470 keys + zh_CN: 470 keys +``` + +### Update Translations +``` +$ ./manage-translations.sh update -l zh_CN +Updating translation files... +================================================== +Processing language: zh_CN +================================================== +Analysis result: + Missing keys: 5 + Extra keys: 20 + +Found 5 missing translation keys: +1. "New feature text" +2. "Another new text" +... + +Add these 5 missing keys? (y/n): y +5 keys added + +Found 20 extra translation keys: +1. "Removed old text" -> "已删除的旧文本" +... + +Delete these 20 extra keys? (y/n): y +20 keys deleted + +Translation file saved +``` + +### Clean Unused Keys +``` +$ ./manage-translations.sh clean +Cleaning unused translation keys... +Processing language: zh_CN +Found 50 unused keys: + 1. "old_unused_text" + 2. "deprecated_message" + ... + +Delete these 50 unused keys? (y/n): y +50 keys deleted +Original key count: 470, after cleaning: 420 +``` + +## Advanced Features + +### Custom Directory Structure + +```bash +# Use custom directories +./translation-manager.py \ + --translations-dir /path/to/translations \ + --source-dir /path/to/source +``` + +### Ignore Mark Feature + +For dynamic resources or special texts that should not be automatically cleaned, you can add `/*keep*/` at the end of the translation value. The tool will automatically ignore these keys and will not delete them during cleaning or syncing. + +Example: +```json +{ + "dynamic_key": "Some dynamic value /*keep*/" +} +``` + +## Notes + +1. **Backup is important**: The tool automatically creates backups before cleaning, but it is recommended to manually back up important files + +2. **Text extraction limitations**: + - ~~Only supports static strings, not dynamically constructed strings~~ + - Dynamic resources (such as variable concatenation or runtime-generated text) cannot be automatically extracted. You need to manually add them to the translation file and use the `/*keep*/` mark for ignore management. + - Must use the `Translation.tr()` format + +3. **File encoding**: All files must use UTF-8 encoding + +4. **Key naming conventions**: It is recommended to use English for key names and avoid special characters + +## Troubleshooting + +### Common Issues + +**Q: Text does not appear after adding Translation.tr?** +A: You need to import the translation feature in your QML file using `import "root:/"`, otherwise the translation text will not be displayed correctly. + +**Q: The number of extracted texts does not match expectations?** +A: Check whether all translatable texts use the `Translation.tr()` format and ensure there are no dynamically constructed strings. + +**Q: Some translations are missing after syncing?** +A: Check whether the source language file contains all necessary keys, and consider using a different source language for syncing. + +**Q: The cleaning operation deleted needed keys?** +A: Restore from the automatically created backup file and check whether `Translation.tr()` is used correctly in the source code. + +### Restore Backup + +```bash +# Restore a single file +cp .config/quickshell/translations/zh_CN.json.backup .config/quickshell/translations/zh_CN.json + +# Restore all files +cp .config/quickshell/translations.backup/* .config/quickshell/translations/ +``` diff --git a/translations/tools/manage-translations.sh b/translations/tools/manage-translations.sh new file mode 100755 index 0000000..c20896a --- /dev/null +++ b/translations/tools/manage-translations.sh @@ -0,0 +1,149 @@ +#!/bin/bash +# Translation management script - convenient wrapper + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TRANSLATIONS_DIR="$(dirname "$SCRIPT_DIR")" +SOURCE_DIR="$(dirname "$(dirname "$TRANSLATIONS_DIR")")" + +show_help() { + echo "Translation Management Tool - Convenient Wrapper" + echo "" + echo "Usage: $0 [options] " + echo "" + echo "Commands:" + echo " extract Extract translatable texts to temporary file" + echo " update Update translation files (add missing/remove extra keys)" + echo " clean Clean unused translation keys" + echo " sync Sync keys across all language files" + echo " status Show translation status" + echo "" + echo "Options:" + echo " -l, --lang LANG Specify language (e.g.: zh_CN)" + echo " -t, --trans-dir DIR Translation files directory (default: $TRANSLATIONS_DIR)" + echo " -s, --source-dir DIR Source code directory (default: $SOURCE_DIR)" + echo " -h, --help Show this help message" + echo "" + echo "Examples:" + echo " $0 extract # Extract translatable texts" + echo " $0 update -l zh_CN # Update Chinese translations" + echo " $0 update # Update all translations" + echo " $0 clean # Clean unused keys" + echo " $0 sync # Sync keys across all languages" + echo " $0 status # Show translation status" +} + +show_status() { + echo "Analyzing translation status..." + + # Extract current text count + echo "=== Current Project Status ===" + python3 "$SCRIPT_DIR/translation-manager.py" \ + --translations-dir "$TRANSLATIONS_DIR" \ + --source-dir "$SOURCE_DIR" \ + --extract-only | grep "Extracted" + + echo "" + echo "=== Translation File Status ===" + + if [ -d "$TRANSLATIONS_DIR" ]; then + for file in "$TRANSLATIONS_DIR"/*.json; do + if [ -f "$file" ]; then + lang=$(basename "$file" .json) + count=$(jq 'length' "$file" 2>/dev/null || echo "error") + echo " $lang: $count keys" + fi + done + else + echo " Translation directory does not exist: $TRANSLATIONS_DIR" + fi +} + +# Parse command line arguments +LANG_CODE="" +COMMAND="" + +while [[ $# -gt 0 ]]; do + case $1 in + -l|--lang) + LANG_CODE="$2" + shift 2 + ;; + -t|--trans-dir) + TRANSLATIONS_DIR="$2" + shift 2 + ;; + -s|--source-dir) + SOURCE_DIR="$2" + shift 2 + ;; + -h|--help) + show_help + exit 0 + ;; + extract|update|clean|sync|status) + if [ -n "$COMMAND" ]; then + echo "Error: Only one command can be specified" + exit 1 + fi + COMMAND="$1" + shift + ;; + *) + echo "Unknown option: $1" + show_help + exit 1 + ;; + esac +done + +if [ -z "$COMMAND" ]; then + echo "Error: A command must be specified" + show_help + exit 1 +fi + +# Check dependencies +if ! command -v python3 >/dev/null 2>&1; then + echo "Error: python3 is required" + exit 1 +fi + +if [ "$COMMAND" = "status" ] && ! command -v jq >/dev/null 2>&1; then + echo "Warning: jq is not installed, status display may be incomplete" +fi + +# Build base arguments +BASE_ARGS="--translations-dir $TRANSLATIONS_DIR --source-dir $SOURCE_DIR" + +case $COMMAND in + extract) + echo "Extracting translatable texts..." + python3 "$SCRIPT_DIR/translation-manager.py" $BASE_ARGS --extract-only --show-temp + ;; + update) + echo "Updating translation files..." + if [ -n "$LANG_CODE" ]; then + python3 "$SCRIPT_DIR/translation-manager.py" $BASE_ARGS --language "$LANG_CODE" + else + python3 "$SCRIPT_DIR/translation-manager.py" $BASE_ARGS + fi + ;; + clean) + echo "Cleaning unused translation keys..." + python3 "$SCRIPT_DIR/translation-cleaner.py" $BASE_ARGS --clean + ;; + sync) + echo "Syncing translation keys..." + python3 "$SCRIPT_DIR/translation-cleaner.py" $BASE_ARGS --sync + ;; + status) + show_status + ;; + *) + echo "Unknown command: $COMMAND" + show_help + exit 1 + ;; +esac diff --git a/translations/tools/translation-cleaner.py b/translations/tools/translation-cleaner.py new file mode 100755 index 0000000..b26e3fb --- /dev/null +++ b/translations/tools/translation-cleaner.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Translation File Maintenance Helper +Used to clean and organize translation files, removing unused keys +""" + +import os +import sys +import json +import argparse +import importlib.util +from pathlib import Path +from typing import Dict, Set, List + +# Import from the same directory using importlib +current_dir = os.path.dirname(os.path.abspath(__file__)) +manager_path = os.path.join(current_dir, 'translation-manager.py') +spec = importlib.util.spec_from_file_location("translation_manager", manager_path) +translation_manager = importlib.util.module_from_spec(spec) +spec.loader.exec_module(translation_manager) +TranslationManager = translation_manager.TranslationManager + +def clean_translation_files(translations_dir: str, source_dir: str, backup: bool = True): + """Clean translation files by removing unused keys""" + print("Starting translation file cleanup...") + + # Create manager + manager = TranslationManager(translations_dir, source_dir) + + # Extract currently used texts + print("Extracting currently used translatable texts...") + current_texts = manager.extract_translatable_texts() + print(f"Extracted {len(current_texts)} currently used texts") + + # Get all language files + languages = manager.get_available_languages() + if not languages: + print("No translation files found") + return + + print(f"Found language files: {', '.join(languages)}") + + total_removed = 0 + + for lang in languages: + print(f"\nProcessing language: {lang}") + + # Load translation file + translations = manager.load_translation_file(lang) + original_count = len(translations) + + # Find unused keys, skip those whose value ends with /*keep*/ + unused_keys = set() + for k in translations.keys(): + v = translations[k] + if k not in current_texts: + if isinstance(v, str) and v.strip().endswith('/*keep*/'): + continue + unused_keys.add(k) + + if unused_keys: + print(f"Found {len(unused_keys)} unused keys:") + for i, key in enumerate(sorted(unused_keys)[:10], 1): # Only show first 10 + print(f" {i}. \"{key[:50]}{'...' if len(key) > 50 else ''}\"") + if len(unused_keys) > 10: + print(f" ... and {len(unused_keys) - 10} more keys") + + response = input(f"Delete these {len(unused_keys)} unused keys? (y/n): ") + if response.lower().strip() in ['y', 'yes']: + if backup: + # Create backup only when user confirms deletion + backup_file = Path(translations_dir) / f"{lang}.json.bak" + with open(backup_file, 'w', encoding='utf-8') as f: + json.dump(translations, f, ensure_ascii=False, indent=2) + print(f"Created backup: {backup_file}") + # Delete unused keys + for key in unused_keys: + del translations[key] + + # Save cleaned file + manager.save_translation_file(lang, translations) + removed_count = len(unused_keys) + total_removed += removed_count + print(f"Deleted {removed_count} keys") + else: + print("Skipped deletion") + else: + print("No unused keys found") + + new_count = len(translations) + print(f"Original key count: {original_count}, after cleanup: {new_count}") + + print(f"\nCleanup completed! Total deleted {total_removed} unused keys.") + +def sync_translations(translations_dir: str, source_lang: str = "en_US", target_langs: List[str] = None): + """Sync translation keys to ensure all language files have the same keys""" + print(f"Starting translation key sync using {source_lang} as reference...") + + translations_path = Path(translations_dir) + + # Load source language file + source_file = translations_path / f"{source_lang}.json" + if not source_file.exists(): + print(f"Error: Source language file does not exist: {source_file}") + return + + with open(source_file, 'r', encoding='utf-8') as f: + source_translations = json.load(f) + + source_keys = set(source_translations.keys()) + print(f"Source language {source_lang} has {len(source_keys)} keys") + + # Get target language list + if target_langs is None: + target_langs = [] + for file_path in translations_path.glob("*.json"): + lang_code = file_path.stem + if lang_code != source_lang: + target_langs.append(lang_code) + + if not target_langs: + print("No target language files found") + return + + print(f"Target languages: {', '.join(target_langs)}") + + for target_lang in target_langs: + print(f"\nSyncing language: {target_lang}") + + target_file = translations_path / f"{target_lang}.json" + if target_file.exists(): + with open(target_file, 'r', encoding='utf-8') as f: + target_translations = json.load(f) + else: + target_translations = {} + + target_keys = set(target_translations.keys()) + + # Find missing and extra keys + missing_keys = source_keys - target_keys + extra_keys = target_keys - source_keys + + print(f" Missing keys: {len(missing_keys)}") + print(f" Extra keys: {len(extra_keys)}") + + # Add missing keys + if missing_keys: + for key in missing_keys: + # Use source language value as placeholder by default + target_translations[key] = source_translations[key] + print(f" Added {len(missing_keys)} missing keys") + + # Ask whether to delete extra keys + if extra_keys: + response = input(f" Delete {len(extra_keys)} extra keys? (y/n): ") + if response.lower().strip() in ['y', 'yes']: + for key in extra_keys: + del target_translations[key] + print(f" Deleted {len(extra_keys)} extra keys") + + # Save file (ensure UTF-8, fix for special chars) + with open(target_file, 'w', encoding='utf-8', newline='') as f: + json.dump(target_translations, f, ensure_ascii=False, indent=2) + print(f" Saved: {target_file}") + +def main(): + parser = argparse.ArgumentParser(description="Translation File Maintenance Helper") + parser.add_argument("--translations-dir", "-t", + default=".config/quickshell/translations", + help="Translation files directory") + parser.add_argument("--source-dir", "-s", + default=".config/quickshell", + help="Source code directory") + parser.add_argument("--clean", "-c", action="store_true", + help="Clean unused translation keys") + parser.add_argument("--sync", action="store_true", + help="Sync translation keys") + parser.add_argument("--source-lang", default="en_US", + help="Source language for syncing (default: en_US)") + parser.add_argument("--no-backup", action="store_true", + help="Do not create backup files when cleaning") + + args = parser.parse_args() + + # Convert to absolute paths + translations_dir = os.path.abspath(args.translations_dir) + source_dir = os.path.abspath(args.source_dir) + + if args.clean: + clean_translation_files(translations_dir, source_dir, backup=not args.no_backup) + elif args.sync: + sync_translations(translations_dir, args.source_lang) + else: + print("Please specify an operation:") + print(" --clean: Clean unused translation keys") + print(" --sync: Sync translation keys") + +if __name__ == "__main__": + main() diff --git a/translations/tools/translation-manager.py b/translations/tools/translation-manager.py new file mode 100755 index 0000000..a310544 --- /dev/null +++ b/translations/tools/translation-manager.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Translation File Management Script +Used to update and extract translatable texts, manage JSON translation file key comparison +""" + +import os +import json +import re +import sys +import argparse +from pathlib import Path +from typing import Dict, Set, List, Tuple +import tempfile +import subprocess + +class TranslationManager: + def __init__(self, translations_dir: str, source_dir: str): + self.translations_dir = Path(translations_dir) + self.source_dir = Path(source_dir) + self.temp_extracted_file = None + + # Ensure translation directory exists + self.translations_dir.mkdir(parents=True, exist_ok=True) + + def extract_translatable_texts(self) -> Set[str]: + """Extract translatable texts from source code""" + translatable_texts = set() + + # Search patterns: Translation.tr("text") or Translation.tr('text') + # Improved regex that handles nested quotes correctly + patterns = [ + r'Translation\.tr\s*\(\s*(["\'])(((?!\1)[^\\]|\\.)*)(\1)\s*\)', # Double or single quotes with escape support + r'Translation\.tr\s*\(\s*`([^`]*(?:\\.[^`]*)*?)`\s*\)', # Backticks (template strings) + ] + + # Search all .qml and .js files + file_extensions = ['*.qml', '*.js'] + + for ext in file_extensions: + for file_path in self.source_dir.rglob(ext): + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + for pattern in patterns: + matches = re.findall(pattern, content, re.MULTILINE | re.DOTALL) + for match in matches: + # Handle different match group structures + if isinstance(match, tuple): + # For improved regex, text is in the second group + if len(match) >= 3: + text = match[1] # Second group is the text content + else: + text = match[0] if match else "" + else: + text = match + + try: + if '\\u' in text or '\\x' in text: + clean_text = bytes(text, "utf-8").decode("unicode_escape") + else: + clean_text = ( + text.replace('\\n', '\n') + .replace('\\t', '\t') + .replace('\\r', '\r') + .replace('\\"', '"') + .replace('\\\'', "'") + .replace('\\f', '\f') + .replace('\\b', '\b') + .replace('\\\\', '\\') + ) + except Exception: + clean_text = text + + # Clean text (remove extra whitespace) + clean_text = clean_text.strip() + if clean_text: + translatable_texts.add(clean_text) + + except (UnicodeDecodeError, IOError) as e: + print(f"Warning: Cannot read file {file_path}: {e}") + + return translatable_texts + + def create_temp_translation_file(self, texts: Set[str]) -> str: + """Create temporary JSON file containing extracted texts""" + temp_data = {} + for text in sorted(texts): + temp_data[text] = text # Key and value are the same, indicating untranslated + + # Create temporary file + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') as f: + json.dump(temp_data, f, ensure_ascii=False, indent=2) + self.temp_extracted_file = f.name + + return self.temp_extracted_file + + def load_translation_file(self, lang_code: str) -> Dict[str, str]: + """Load translation file for specified language""" + file_path = self.translations_dir / f"{lang_code}.json" + if file_path.exists(): + try: + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, IOError) as e: + print(f"Warning: Cannot load translation file {file_path}: {e}") + return {} + return {} + + def save_translation_file(self, lang_code: str, translations: Dict[str, str]): + """Save translation file""" + file_path = self.translations_dir / f"{lang_code}.json" + try: + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(translations, f, ensure_ascii=False, indent=2) + print(f"Translation file saved: {file_path}") + except IOError as e: + print(f"Error: Cannot save translation file {file_path}: {e}") + + def get_available_languages(self) -> List[str]: + """Get list of available languages""" + languages = [] + for file_path in self.translations_dir.glob("*.json"): + lang_code = file_path.stem + languages.append(lang_code) + return sorted(languages) + + def compare_translations(self, extracted_texts: Set[str], target_lang: str) -> Tuple[Set[str], Set[str]]: + """Compare extracted texts with existing translation file""" + existing_translations = self.load_translation_file(target_lang) + existing_keys = set(existing_translations.keys()) + + missing_keys = extracted_texts - existing_keys # Missing keys + extra_keys = existing_keys - extracted_texts # Extra keys + + return missing_keys, extra_keys + + def interactive_update(self, lang_code: str, missing_keys: Set[str], extra_keys: Set[str]): + """Interactively update translation file, create backup only if updating""" + translations = self.load_translation_file(lang_code) + modified = False + backup_created = False + + # Handle missing keys + if missing_keys: + print(f"\nFound {len(missing_keys)} missing translation keys:") + for i, key in enumerate(sorted(missing_keys), 1): + print(f"{i}. \"{key}\"") + + if self.ask_yes_no(f"\nAdd these {len(missing_keys)} missing keys?"): + if not backup_created: + backup_file = self.translations_dir / f"{lang_code}.json.bak" + with open(backup_file, 'w', encoding='utf-8') as f: + json.dump(translations, f, ensure_ascii=False, indent=2) + print(f"Created backup: {backup_file}") + backup_created = True + for key in missing_keys: + translations[key] = key # Default value is the key itself + modified = True + print(f"Added {len(missing_keys)} keys") + + # Handle extra keys + if extra_keys: + # Only show extra keys that are not marked with /*keep*/ + filtered_extra_keys = [key for key in extra_keys if not (isinstance(translations.get(key, ""), str) and translations.get(key, "").strip().endswith('/*keep*/'))] + if filtered_extra_keys: + print(f"\nFound {len(filtered_extra_keys)} extra translation keys:") + for i, key in enumerate(sorted(filtered_extra_keys), 1): + print(f"{i}. \"{key}\" -> \"{translations.get(key, '')}\"") + if self.ask_yes_no(f"\nDelete these {len(filtered_extra_keys)} extra keys?"): + if not backup_created: + backup_file = self.translations_dir / f"{lang_code}.json.bak" + with open(backup_file, 'w', encoding='utf-8') as f: + json.dump(translations, f, ensure_ascii=False, indent=2) + print(f"Created backup: {backup_file}") + backup_created = True + deleted_count = 0 + for key in filtered_extra_keys: + if key in translations: + del translations[key] + modified = True + deleted_count += 1 + print(f"Deleted {deleted_count} keys") + + # Save changes + if modified: + self.save_translation_file(lang_code, translations) + else: + print("No changes made") + + def ask_yes_no(self, question: str) -> bool: + """Ask user for confirmation""" + while True: + response = input(f"{question} (y/n): ").lower().strip() + if response in ['y', 'yes']: + return True + elif response in ['n', 'no']: + return False + else: + print("Please enter y/yes or n/no") + + def cleanup(self): + """Clean up temporary files""" + if self.temp_extracted_file and os.path.exists(self.temp_extracted_file): + os.unlink(self.temp_extracted_file) + +def main(): + parser = argparse.ArgumentParser(description="Translation file management tool") + parser.add_argument("--translations-dir", "-t", + default=".config/quickshell/translations", + help="Translation files directory (default: .config/quickshell/translations)") + parser.add_argument("--source-dir", "-s", + default=".config/quickshell", + help="Source code directory (default: .config/quickshell)") + parser.add_argument("--language", "-l", + help="Specify language code to process (e.g., zh_CN)") + parser.add_argument("--extract-only", "-e", action="store_true", + help="Only extract translatable texts to temporary file") + parser.add_argument("--show-temp", action="store_true", + help="Show temporary extracted file content") + + args = parser.parse_args() + + # Convert to absolute paths + translations_dir = os.path.abspath(args.translations_dir) + source_dir = os.path.abspath(args.source_dir) + + print(f"Translation directory: {translations_dir}") + print(f"Source code directory: {source_dir}") + + # Check if directories exist + if not os.path.exists(source_dir): + print(f"Error: Source code directory does not exist: {source_dir}") + sys.exit(1) + + # Create manager + manager = TranslationManager(translations_dir, source_dir) + + try: + # Extract translatable texts + print("\nExtracting translatable texts...") + extracted_texts = manager.extract_translatable_texts() + print(f"Extracted {len(extracted_texts)} translatable texts") + + # Create temporary file + temp_file = manager.create_temp_translation_file(extracted_texts) + print(f"Created temporary file: {temp_file}") + + if args.show_temp: + print("\nTemporary file contents:") + with open(temp_file, 'r', encoding='utf-8') as f: + print(f.read()) + + if args.extract_only: + print("Extract-only mode, program finished") + return + + # Get available languages + available_languages = manager.get_available_languages() + + if args.language: + target_languages = [args.language] + else: + print(f"\nAvailable languages: {', '.join(available_languages) if available_languages else 'None'}") + + if not available_languages: + print("No existing translation files found") + lang_input = input("Enter language code to create (e.g.: zh_CN): ").strip() + if lang_input: + target_languages = [lang_input] + else: + print("No language specified, program finished") + return + else: + print("Choose language to process:") + for i, lang in enumerate(available_languages, 1): + print(f"{i}. {lang}") + print("a. Process all languages") + + choice = input("Please choose (enter number, language code, or 'a'): ").strip() + + if choice.lower() == 'a': + target_languages = available_languages + elif choice.isdigit() and 1 <= int(choice) <= len(available_languages): + target_languages = [available_languages[int(choice) - 1]] + elif choice in available_languages: + target_languages = [choice] + else: + print("Invalid choice, program finished") + return + + # Process each language + for lang in target_languages: + print(f"\n{'='*50}") + print(f"Processing language: {lang}") + print('='*50) + + missing_keys, extra_keys = manager.compare_translations(extracted_texts, lang) + + if not missing_keys and not extra_keys: + print(f"Translation file for language {lang} is already up to date") + continue + + print(f"Analysis results:") + print(f" Missing keys: {len(missing_keys)}") + # Load translation file for current lang to get values + current_translations = manager.load_translation_file(lang) + filtered_extra_keys = [key for key in extra_keys if not (isinstance(current_translations.get(key, ""), str) and current_translations.get(key, "").strip().endswith('/*keep*/'))] + ignored_extra_keys = [key for key in extra_keys if (isinstance(current_translations.get(key, ""), str) and current_translations.get(key, "").strip().endswith('/*keep*/'))] + print(f" Extra keys: {len(filtered_extra_keys)}") + if ignored_extra_keys: + print(f" Ignored keys: {len(ignored_extra_keys)} (marked with /*keep*/)") + + if missing_keys or extra_keys: + manager.interactive_update(lang, missing_keys, extra_keys) + + finally: + # Clean up temporary files + manager.cleanup() + +if __name__ == "__main__": + main() diff --git a/translations/uk_UA.json b/translations/uk_UA.json new file mode 100644 index 0000000..830a257 --- /dev/null +++ b/translations/uk_UA.json @@ -0,0 +1,342 @@ +{ + "Mo": "Пн/*keep*/", + "Tu": "Вт/*keep*/", + "We": "Ср/*keep*/", + "Th": "Чт/*keep*/", + "Fr": "Пт/*keep*/", + "Sa": "Сб/*keep*/", + "Su": "Нд/*keep*/", + "%1 characters": "%1 символів", + "**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key": "**Вартість**: безкоштовно. Політика використання даних залежить від параметрів облікового запису OpenRouter.\n\n**Інструкції**: Увійдіть в обліковий запис OpenRouter, перейдіть до розділу «Keys» у верхньому правому меню, натисніть «Create API Key»", + "**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key": "**Вартість**: безкоштовно. Дані використовуються для тренування.\n\n**Інструкції**: Увійдіть в обліковий запис Google, дозвольте AI Studio створити проект Google Cloud або те, що вона попросить, поверніться назад і натисніть Get API key", + ". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!": ". Примітки для Zerochan:\n- Ви повинні ввести колір\n- Встановіть ваше ім'я користувача zerochan у параметрі конфігурації `sidebar.booru.zerochan.username`. Якщо ви цього не зробите, вас [може бути заблоковано](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!", + "No further instruction provided": "Подальших інструкцій не надано", + "Action": "Дія", + "Add": "Додати", + "Add task": "Додати завдання", + "All-rounder | Good quality, decent quantity": "Універсальний | Хороша якість, пристойна кількість", + "Allow NSFW": "Дозволити NSFW", + "Allow NSFW content": "Дозволити NSFW вміст", + "Anime": "Аніме", + "Anime boorus": "Аніме боору", + "App": "Програма", + "Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel": "Стрілки для навігації, Enter для вибору\nНатисніть Esc або будь де щоб скасувати", + "Bluetooth": "Bluetooth", + "Brightness": "Яскравість", + "Cancel": "Скасувати", + "Cheat sheet": "Шпаргалка", + "Choose model": "Виберіть модель", + "Clean stuff | Excellent quality, no NSFW": "Чистий вміст | Відмінна якість, без NSFW", + "Clear": "Очистити", + "Clear chat history": "Очистити історію чату", + "Clear the current list of images": "Очистити поточний список картинок", + "Close": "Закрити", + "Copy": "Копіювати", + "Copy code": "Копіювати код", + "Delete": "Видалити", + "Desktop": "Стільниця", + "Disable NSFW content": "Вимкнути NSFW вміст", + "Done": "Готово", + "Download": "Завантажити", + "Edit": "Редагувати", + "Enter text to translate...": "Введіть текст щоб перекласти...", + "Finished tasks will go here": "Завершені завдання зберігаються тут", + "For desktop wallpapers | Good quality": "На шпалери | Хороша якість", + "For storing API keys and other sensitive information": "Для зберігання API ключів та іншої конфіденційної інформація", + "Game mode": "Ігровий режим", + "Get the next page of results": "Наступна сторінка результатів", + "Hibernate": "Сплячий режим", + "Input": "Ввід", + "Intelligence": "Інтелект", + "Interface": "Інтерфейс", + "Invalid arguments. Must provide `key` and `value`.": "Неправильні аргументи. Повинні бути вказані `key` та `value`.", + "Jump to current month": "Перейти до поточного місяця", + "Keep system awake": "Тримати систему в режимі очікування", + "Large images | God tier quality, no NSFW.": "Великі зображення | Божественна якість, без NSFW.", + "Large language models": "Великі мовні моделі", + "Launch": "Пуск", + "Lock": "Блокувати", + "Logout": "Вийти", + "Markdown test": "Тест Markdown", + "Math result": "Результат обчислень", + "No audio source": "Немає джерела аудіо", + "No media": "Немає медіа", + "No notifications": "Немає сповіщень", + "Not visible to model": "Не видно для моделі", + "Nothing here!": "Тут нічого!", + "Notifications": "Сповіщення", + "OK": "Гаразд", + "Open file link": "Відкрити лінк до файлу", + "Output": "Вивід", + "Reboot": "Перезапуск", + "Reboot to firmware settings": "Перезапуск в параметри UEFI/BIOS", + "Reload Hyprland & Quickshell": "Перезавантажити Hyprland та Quickshell", + "Run": "Виконати", + "Run command": "Виконати команду", + "Save": "Зберегти", + "Save to Downloads": "Зберегти в Завантаження", + "Search": "Пошук", + "Search the web": "Шукати в інтернеті", + "Search, calculate or run": "Шукати, обчислювати або запускати", + "Select Language": "Вибрати мову", + "Session": "Сесія", + "Set API key": "Вказати ключ API", + "Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5.": "Задати температуру (випадковість) моделі. Значення в діапазоні від 0 до 2 для Gemini, від 0 до 1 для інших моделей. Значення за замовчуванням 0.5.", + "Set the current API provider": "Встановити поточного провайдера API", + "Shutdown": "Вимкнути", + "Silent": "Тиша", + "Sleep": "Сон", + "System": "Система", + "Task Manager": "Менеджер завдань", + "Task description": "Опис завдання", + "Temperature must be between 0 and 2": "Температура має бути між 0 та 2", + "The hentai one | Great quantity, a lot of NSFW, quality varies wildly": "Хентай | Велика кількість, багато NSFW, якість сильно варіюється", + "The popular one | Best quantity, but quality can vary wildly": "Популярний | Найкраща кількість, але якість може сильно варіюватись", + "Thinking": "Замислився", + "Translation goes here...": "Переклад буде тут...", + "Translator": "Перекладач", + "Unfinished": "Незавершений", + "Unknown": "Невідмий", + "Unknown Album": "Невідомий Альбои", + "Unknown Artist": "Невідомий Виконавець", + "Unknown Title": "Невідома назва", + "View Markdown source": "Дивитися джерело Markdown", + "Volume": "Гучність", + "Volume mixer": "Мікшер гучності", + "Waifus only | Excellent quality, limited quantity": "Лише вайфу | Відмінна якість, обмежена кількість", + "Waiting for response...": "Чекаємо відповідь...", + "Workspace": "Простір", + "Invalid API provider. Supported: \n-": "Неправильний провайдер API. Підтримується: \n-", + "Unknown command:": "Невідома команда:", + "Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window": "Введіть /key, щоб почати роботу з онлайн моделями\nCtrl+O, щоб розгорнути бічну панель\nCtrl+P, щоб прибрати бічну панель у вікно", + "Provider set to": "Провайдер виставлений на", + "Invalid model. Supported: \n```": "Неправельна модель. Підтримується: \n```", + "That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number": "Це не спрацювало. Поради:\n- Перевірте свої теги та параметри NSFW\n- Якщо ви не знаєте тега, введіть номер сторінки", + "Switched to search mode. Continue with the user's request.": "Перейшли в режим пошуку. Продовження пошуку за запитом користувача.", + "Settings": "Параметри", + "Save chat": "Зберегти чат", + "Load chat": "Завантажити чат", + "or": "або", + "Set the system prompt for the model.": "Встановіть системний запит для моделі.", + "To Do": "Зробити", + "Calendar": "Календар", + "Advanced": "Розширені", + "About": "Про", + "Services": "Сервіси", + "Style": "Стиль", + "Edit config": "Редагувати конфігурацію", + "Colors & Wallpaper": "Кольори та Шпалери", + "Light": "Світла", + "Dark": "Темна", + "Material palette": "Палітра кольорів", + "Fidelity": "Вірність", + "Fruit Salad": "Фруктовий салат", + "Alternatively use /dark, /light, /img in the launcher": "Або використовуйте /dark, /light, /img у лаунчері", + "Fake screen rounding": "Фальшиві заокруглення екрану", + "When not fullscreen": "Коли не на весь екран", + "Choose file": "Вибрати файл", + "Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers": "Випадкові SFW аніме шпалери від Konachan\nЗображення збережено до ~/Pictures/Wallpapers", + "Be patient...": "Потерпіть...", + "Decorations & Effects": "Декорації та ефекти", + "Tonal Spot": "Тональна пляма", + "Shell windows": "Вікна оболонки", + "Auto": "Авто", + "Wallpaper": "Шпалери", + "Content": "Вміст", + "Title bar": "Заголовок", + "Transparency": "Прозорість", + "Expressive": "Виразний", + "Yes": "Так", + "Enable": "Увімкнути", + "Rainbow": "Веселка", + "Might look ass. Unsupported.": "Виглядатиме дурновато. Не підтримується.", + "Monochrome": "Монохромний", + "Random: Konachan": "Випадково: Konachan", + "Center title": "Назва по центру", + "Neutral": "Нейтральний", + "Pick wallpaper image on your system": "Виберіть зображення шпалер на вашому комп'ютері", + "No": "Ні", + "AI": "ШІ", + "Local only": "Лише локально", + "Policies": "Політика", + "Weeb": "Віабу", + "Closet": "Шафа", + "Bar style": "Стиль заголовку", + "Show next time": "Показати пізніше", + "Usage": "Використання", + "Plain rectangle": "Звичайний квадрат", + "Useless buttons": "Безкорисні кнопки", + "GitHub": "GitHub", + "Style & wallpaper": "Стиль та шпалери", + "Configuration": "Конфігурація", + "Change any time later with /dark, /light, /img in the launcher": "Змініть будь-коли пізніше за допомогою /dark, /light, /img у лаунчері", + "Keybinds": "Комбінації клавіш", + "Float": "Плаваюче", + "Hug": "Обійми", + "Yooooo hi there": "Йоооо, привіт", + "illogical-impulse Welcome": "illogical-impulse Вітаємо", + "Info": "Інфо", + "Volume limit": "Обмеження гучності", + "Prevents abrupt increments and restricts volume limit": "Запобігає різкому збільшенню та обмежує ліміт гучності", + "Resources": "Ресурси", + "12h am/pm": "12г AM/PM", + "Base URL": "Базовий лінк", + "Audio": "Аудіо", + "Networking": "Мережування", + "Format": "Формат", + "Time": "Час", + "Battery": "Батарея", + "Prefixes": "Префікси", + "Emojis": "Емодзі", + "Earbang protection": "Захист навушників", + "Automatically suspends the system when battery is low": "Автоматично призупиняє роботу системи, коли батарея розряджається", + "Automatic suspend": "Автоматична зупинка", + "Suspend at": "Зупиняти на", + "Max allowed increase": "Максимально допустимий приріст", + "Web search": "Пошук в інтернеті", + "Polling interval (ms)": "Інтервал між опитуваннями (мс)", + "Clipboard": "Буфер обміну", + "Low warning": "Незначні попередження", + "24h": "24г", + "Use Levenshtein distance-based algorithm instead of fuzzy": "Використовуйте алгоритм на основі відстані Левенштейна замість нечіткого", + "System prompt": "Системний запит", + "12h AM/PM": "12г AM/PM", + "Could be better if you make a ton of typos,\nbut results can be weird and might not work with acronyms\n(e.g. \"GIMP\" might not give you the paint program)": "Було б краще, якби ви зробили купу помилок,\nале результати можуть бути дивними і не працювати з абревіатурами\n(наприклад, «GIMP» може не вивести програму для малювання).", + "Critical warning": "Критичні попередження", + "User agent (for services that require it)": "User agent (для сервісів де це потрібно)", + "Such regions could be images or parts of the screen that have some containment.\nMight not always be accurate.\nThis is done with an image processing algorithm run locally and no AI is used.": "Такими областями можуть бути зображення або частини екрана, які мають певні обмеження.\nМоже бути не завжди точним.\nЦе робиться за допомогою алгоритму обробки зображень, запущеного локально, і не використовується ШІ.", + "Note: turning off can hurt readability": "Примітка: вимкнення може погіршити читабельність", + "Workspaces shown": "Показані простори", + "Dark/Light toggle": "Перемикач Світлої/Темної", + "Dock": "Лоток", + "Weather": "Погода", + "Pinned on startup": "Прикріплено до запуску", + "Tip: Hide icons and always show numbers for\nthe classic illogical-impulse experience": "Порада: приховуйте іконки і завжди показуйте цифри для класичного досвіду illogical-impulse", + "Appearance": "Вигляд", + "Always show numbers": "Завжди показувати номери", + "Buttons": "Кнопки", + "Keyboard toggle": "Перемикання клавіатури", + "Scale (%)": "Розмір (%)", + "Overview": "Огляд", + "Rows": "Рядки", + "Borderless": "Без меж", + "Screenshot tool": "Інструмент створення скріншотів", + "Number show delay when pressing Super (ms)": "Затримка відображення номера при натисканні клавіші Super (мс)", + "Timeout (ms)": "Тайм-аут (ms)", + "Show app icons": "Показувати іконки програм", + "Workspaces": "Простори", + "Columns": "Стовбці", + "On-screen display": "Екранний дисплей", + "Screen snip": "Скріншот", + "Mic toggle": "Перемикач мікрофону", + "Hover to reveal": "Наведіть курсор, щоб відкрити", + "Bar": "Панель", + "Show background": "Показувати задній фон", + "Show regions of potential interest": "Показати регіони потенційного інтересу", + "Color picker": "Вибір кольору", + "Help & Support": "Допомога та підтримка", + "Discussions": "Дискусії", + "Color generation": "Генератор кольорів", + "Dotfiles": "Дотфайли", + "Distro": "Дистрибютив", + "Privacy Policy": "Політика Конфіденційності", + "Documentation": "Документація", + "Shell & utilities theming must also be enabled": "Тему оболонки та утиліт також слід увімкнути", + "illogical-impulse": "illogical-impulse", + "Donate": "Донат", + "Terminal": "Термінал", + "Shell & utilities": "Оболонка та утиліти", + "Qt apps": "Програми Qt", + "Report a Bug": "Повідомити про помилку", + "Issues": "Проблеми", + "Drag or click a region • LMB: Copy • RMB: Edit": "Перетягніть або клацніть регіон - LMB: Копіювати - RMB: Редагувати", + "Current model: %1\nSet it with %2model MODEL": "Поточна модель: %1\nЗадати її за допомогою %2model МОДЕЛЬ", + "Message the model... \"%1\" for commands": "Написати моделі... «%1» для команд", + "No API key set for %1": "Немає вказаного API ключач для %1", + "Loaded the following system prompt\n\n---\n\n%1": "Завантажився наступний системний запит\n\n---\n\n%1", + "%1 | Right-click to configure": "%1 | ПКМ для налаштування", + "API key set for %1": "ключ API вказано для %1", + "Online via %1 | %2's model": "Онлайн через %1 | %2's модель", + "Current API endpoint: %1\nSet it with %2mode PROVIDER": "Поточна кінцева точка API: %1\nВстановіть її за допомогою %2mode ПРОВАЙТЕР", + "Go to source (%1)": "До джерела (%1)", + "Temperature set to %1": "Температура виставлена на %1", + "Enter tags, or \"%1\" for commands": "Напишіть тег або \"%1\" для команд", + "%1 queries pending": "%1 запитів на розгляді", + "API key:\n\n```txt\n%1\n```": "ключ API:\n\n```txt\n%1\n```", + "Uptime: %1": "Активно: %1", + "%1 Safe Storage": "%1 Надійне зберігання", + "%1 does not require an API key": "%1 не потребує API ключа", + "Temperature: %1": "температура: %1", + "Model set to %1": "Модель встановлена на %1", + "Page %1": "Сторінка %1", + "Local Ollama model | %1": "Локальна модель Оллама | %1", + "The current system prompt is\n\n---\n\n%1": "Поточний запит системи\n\n---\n\n%1", + "Unknown function call: %1": "Невідомий виклик функції: %1", + "%1 notifications": "%1 сповіщень", + "Load chat from %1": "Завантажено чат з %1", + "Load prompt from %1": "Завантажено запит з %1", + "Save chat to %1": "Зберегти чат до %1", + "Weather Service": "Сервіс погоди", + "Cannot find a GPS service. Using the fallback method instead.": "Не вдається знайти службу GPS. Замість цього використовується резервний метод.", + "Critically low battery": "Критичний рівень заряду батареї", + "Select output device": "Виберіть вихідний пристрій", + "Code saved to file": "Код збережений у файл", + "Online models disallowed\n\nControlled by `policies.ai` config option": "Онлайн-моделі заборонено\n\nКерується параметром конфігурації `policies.ai`", + "Scroll to change volume": "Прокрутіть, щоб змінити гучність", + "Elements": "Елементи", + "%1 • %2 tasks": "%1 • %2 завдань", + "Download complete": "Завантаження завершено", + "Please charge!\nAutomatic suspend triggers at %1": "Будь ласка, зарядіть! \nАвтоматичне призупинення спрацьовування при %1", + "Cloudflare WARP": "Cloudflare WARP", + "Cloudflare WARP (1.1.1.1)": "Cloudflare WARP (1.1.1.1)", + "Scroll to change brightness": "Прокрутіть щоб змінити яскравість", + "Connection failed. Please inspect manually with the warp-cli command": "Помилка зєднання. Перевірте самостійно за допомогою команди warp-cli", + "Select input device": "Виберіть вхідний пристрій", + "Registration failed. Please inspect manually with the warp-cli command": "Помилка реєстрації. Перевірте самостійно за допомогою команди warp-cli", + "Consider plugging in your device": "Подумайте про те, щоб підключити свій пристрій", + "Low battery": "Низький заряд батареї", + "Saved to %1": "Збережено до %1", + "Sunset": "Захід сонця", + "UV Index": "Індекс UV", + "Humidity": "Вологість", + "Wind": "Вітер", + "Sunrise": "Світанок", + "Pressure": "Тиск", + "Visibility": "Видимість", + "Precipitation": "Опади", + "No API key\nSet it with /key YOUR_API_KEY": "Немає API ключа\nВстановіть його можна командою /key YOUR_API_KEY", + "Your package manager is running": "Ваш пакетний менеджер запущено", + "Night Light | Right-click to toggle Auto mode": "Нічне світло | ПКМ щоб увімкнути автоматичний режим", + "Gives the model search capabilities (immediately)": "Надає можливість пошуку моделі (негайно)", + "Depends on workspace": "Залежно від простору", + "Invalid arguments. Must provide `command`.": "Неправельні параметри. Потрібно вказати `command`.", + "Temperature\nChange with /temp VALUE": "Температура\nЗмінити можна командою /temp ЗНАЧЕННЯ", + "Online | Google's model\nGoogle's state-of-the-art multipurpose model that excels at coding and complex reasoning tasks.": "Онлайн | Модель Google\nСучасна багатоцільова модель Google, яка чудово справляється з кодуванням і складними завданнями на міркування.", + "EasyEffects | Right-click to configure": "EasyEffects | ПКМ щоб налаштувати", + "Thought": "Думка", + "Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput.": "Онлайн | Модель Google \nМодель Gemini 2.5 Flash оптимізована для економічної ефективності та високої пропускної здатності.", + "API key is set\nChange with /key YOUR_API_KEY": "API ключ встановлено\nЗмінити можна командою /key YOUR_API_KEY", + "Current tool: %1\nSet it with %2tool TOOL": "Поточний інструмент: %1\nВстановіть його командою %2tool TOOL", + "**Instructions**: Log into Mistral account, go to Keys on the sidebar, click Create new key": "**Інструкції**: Увійдіть в обліковий запис Mistral, перейдіть до розділу «Keys» на бічній панелі, натисніть «Create new key»", + "Usage: %1tool TOOL_NAME": "Використання: %1tool TOOL_NAME", + "Online | Google's model\nFast, can perform searches for up-to-date information": "Онлайн | модель Google \nШвидкий, може здійснювати пошук актуальної інформації", + "Approve": "Схвалити", + "Preferred wallpaper zoom (%)": "Бажаний маштаб шпалер (%)", + "Performance Profile toggle": "Перемикач профілю продуктивності", + "Total token count\nInput: %1\nOutput: %2": "Загальна кількість токенів\nВхідні: %1\nВихідні: %2", + "Wallpaper parallax": "Обємні шпалери", + "Invalid tool. Supported tools:\n- %1": "Неправельний інструмент. Підтримуються:\n- %1", + "Usage: %1load CHAT_NAME": "Виокристання: %1load CHAT_NAME", + "Reject": "Відхилити", + "Usage: %1save CHAT_NAME": "Використання: %1save CHAT_NAME", + "Set the tool to use for the model.": "Вкажіть інструмент для роботи з моделью", + "Online | %1's model | Delivers fast, responsive and well-formatted answers. Disadvantages: not very eager to do stuff; might make up unknown function calls": "Онлайн | %1's модель | Надає швидкі, чуйні та добре відформатовані відповіді. Недоліки: не дуже охоче виконує завдання; може вигадувати виклики невідомих функцій", + "Depends on sidebars": "Залежно від бокових панелей", + "Command rejected by user": "Команда відхилена користувачем", + "There might be a download in progress": "Можливо, триває завантаження", + "Disable tools": "Вимкнути інструмент", + "Tool set to: %1": "Інструмет вказано: %1", + "Commands, edit configs, search.\nTakes an extra turn to switch to search mode if that's needed": "Команди, редагування конфігурацій, пошук.\nВиконує додаткову дію для переходу в режим пошуку, якщо це потрібно", + "To set an API key, pass it with the %4 command\n\nTo view the key, pass \"get\" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3": "Щоб задати ключ API, передайте його командою %4\n\nЩоб переглянути ключ, передайте \"get\" командою
\n\n### Для %1:\n\n**Лінк**: %2\n\n%3", + "Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers": "Онлайн | Модель Google\nНовіша модель, яка повільніша за свою попередницю, але має надавати якісніші відповіді" +} diff --git a/translations/vi_VN.json b/translations/vi_VN.json new file mode 100644 index 0000000..ce77f2b --- /dev/null +++ b/translations/vi_VN.json @@ -0,0 +1,373 @@ +{ + "Unknown function call: %1": "Hàm không xác định: %1", + "Show next time": "Hiển thị lần sau", + "Fidelity": "Khá giống gốc", + "Open file link": "Mở liên kết tệp", + "Interrupts possibility of overview being toggled on release.": "Ngăn mở overview khi nhả nút.", + "No audio source": "Không có nguồn âm thanh", + "Might look ass. Unsupported.": "Có thể rất tệ. Không được hỗ trợ.", + "Jump to current month": "Nhảy đến tháng hiện tại", + "Delete": "Xóa", + "**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key": "**Giá**: miễn phí. Dữ liệu được sử dụng cho mục đích huấn luyện. **Hướng dẫn**: Đăng nhập vào tài khoản Google, cho phép AI Studio tạo dự án Google Cloud gì đó, quay lại rồi ấn Get API key", + "Rainbow": "Cầu vồng", + "%1 does not require an API key": "%1 không cần API key", + "Choose model": "Chọn model", + "Prevents abrupt increments and restricts volume limit": "Chặn thay đổi đột ngột và giới hạn âm lượng", + "%1 characters": "%1 ký tự", + "Change any time later with /dark, /light, /img in the launcher": "Thay đổi bất cứ lúc nào sau này với /dark, /light, /img trong launcher", + "Tonal Spot": "Tonal Spot", + "Neutral": "Trung tính", + "To Do": "Cần làm", + "Auto": "Tự động", + "Polling interval (ms)": "Thời gian lặp lại (ms)", + "Center title": "Căn giữa tiêu đề", + "Lock": "Khóa màn hình", + "Screen snip": "Chụp màn hình (chọn vùng)", + "User agent (for services that require it)": "User agent (nếu cần)", + "Report a Bug": "Báo lỗi", + "Shutdown": "Tắt máy", + "Keyboard toggle": "Mở/đóng bàn phím ảo", + "The hentai one | Great quantity, a lot of NSFW, quality varies wildly": "Cái nhiều hentai nhất | Số lượng rất tốt, rất nhiều NSFW, chất lượng có thể khác nhau nhiều", + "Download": "Tải xuống", + "Note: turning off can hurt readability": "Ghi chú: nếu tắt có thể khó đọc", + "Local Ollama model | %1": "Model Ollama trên máy | %1", + "Silent": "Im lặng", + "Columns": "Số cột", + "Set with /mode PROVIDER": "Set with /mode PROVIDER", + "Issues": "Các vấn đề", + "Policies": "Chính sách", + "Load chat from %1": "Tải trò chuyện từ %1", + "Unknown Album": "Album không xác định", + "Yes": "Có", + "Battery": "Pin", + "Material palette": "Kiểu material", + "Chain of Thought": "Dòng suy nghĩ", + "This is necessary because GlobalShortcut.onReleased in quickshell triggers whether or not you press something else while holding the key.": "Cần cái này vì GlobalShortcut.onReleased cho một phím của Quickshell được kích hoạt kể cả khi ban ân phím khác trước khi thả phím đó.", + "Low warning": "Cảnh báo thấp", + ". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!": ". Với Zerochan:\n- Hãy nhập tên một màu (bằng tiếng Anh)\n- Đặt username Zerochan trong tùy chọn `sidebar.booru.zerochan.username`. Bạn [có thể bị ban nếu không tuân thủ](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!", + "Brightness": "Độ sáng", + "Yooooo hi there": "Yooooo chào bạn", + "Colors & Wallpaper": "Màu sắc & Hình nền", + "No media": "Không có media", + "Critical warning": "Cảnh báo rất thấp", + "Mic toggle": "Bật/tắt mic", + "12h AM/PM": "12h AM/PM", + "Large language models": "Mô hình ngôn ngữ lớn", + "Markdown test": "Test markdown", + "Temperature: %1": "Nhiệt độ: %1", + "Edit": "Sửa", + "Waifus only | Excellent quality, limited quantity": "Chỉ waifus | Chất lượng xuất sắc, số lượng hạn chế", + "Cheat sheet": "Bảng tra cứu", + "Current model: %1\nSet it with %2model MODEL": "Model đang chọn: %1\nChọn với lệnh %2model MODEL", + "Provider set to": "Đã đặt nhà cung cấp thành", + "Clear": "Xóa hết", + "GitHub": "GitHub", + "App": "Ứng dụng", + "Title bar": "Thanh tiêu đề", + "Web search": "Tìm kiếm web", + "Invalid model. Supported: \n```": "Model không hợp lệ. Các lựa chọn: \n```", + "Calendar": "Lịch", + "Done": "Đã xong", + "Monochrome": "Đen trắng", + "Show regions of potential interest": "Hiển thị vùng thông minh", + "Dark/Light toggle": "Chuyển chế độ sáng/tối", + "Unknown command:": "Lệnh không xác định:", + "Allow NSFW content": "Cho phép nội dung NSFW", + "Closes cheatsheet on press": "Đóng bảng tra cứu khi ấn", + "Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5.": "Chỉnh giá trị nhiệt độ (sự ngẫu nhiên) của model. Giá trị 0-2 với Gemini, 0-1 với các model khác. Mặc định là 0.5.", + "Invalid API provider. Supported: \n-": "Nhà cung cấp API không hợp lệ. Các lựa chọn: \n-", + "Shell windows": "Cửa sổ của shell", + "Loaded the following system prompt\n\n---\n\n%1": "Đã tải chỉ dẫn hệ thống sau đây\n\n---\n\n%1", + "Clipboard": "Clipboard", + "For storing API keys and other sensitive information": "Để lưu trữ API key và các thông tin nhạy cảm khác", + "Wallpaper": "Hình nền", + "Decorations & Effects": "Trang trí & Hiệu ứng", + "AI": "AI", + "Large images | God tier quality, no NSFW.": "Ảnh kích thước lớn | Chất lượng cực tốt, không có NSFW.", + "When not fullscreen": "Khi không toàn màn hình", + "Resources": "Tài nguyên", + "Light": "Sáng", + "Weeb": "Wibu", + "Disable NSFW content": "Tắt nội dung NSFW", + "OK": "OK", + "Screenshot tool": "Công cụ chụp màn hình", + "Enable": "Bật", + "Select Language": "Chọn ngôn ngữ", + "System": "Hệ thống", + "Emojis": "Emoji", + "The current system prompt is\n\n---\n\n%1": "Chỉ dẫn hệ thống hiện tại như sau\n\n---\n\n%1", + "Translator": "Dịch", + "Sleep": "Ngủ", + "Action": "Hành động", + "Audio": "Âm thanh", + "Show background": "Hiện nền", + "All-rounder | Good quality, decent quantity": "Tốt đều | Chất lượng tốt, số lượng ổn", + "Documentation": "Tài liệu", + "Terminal": "Terminal", + "Distro": "Distro", + "Clear chat history": "Xóa lịch sử trò chuyện", + "Float": "Nổi", + "No further instruction provided": "No further instruction provided", + "Choose file": "Chọn tệp", + "Set the system prompt for the model.": "Đặt chỉ dẫn hệ thống cho model.", + "Unknown Title": "Bài hát không rõ tên", + "Math result": "Kết quả phép tính", + "Logout": "Đăng xuất", + "Privacy Policy": "Chính sách quyền riêng tư", + "Style": "Phong cách", + "Borderless": "Không viền", + "Set API key": "Đặt API key", + "Clean stuff | Excellent quality, no NSFW": "Sạch sẽ | Chất lượng xuất sắc, không có NSFW", + "Experimental | Online | Google's model\nCan do a little more but doesn't search quickly": "Thử nghiệm | Trực tuyến | Model của Google\nCó thể làm nhiều hơn một chút nhưng không tìm kiếm nhanh chóng", + "Toggles cheatsheet on press": "Mở/đóng bảng tra cứu khi ấn", + "Thinking": "Đang nghĩ", + "Earbang protection": "Bảo vệ tai", + "Advanced": "Nâng cao", + "Could be better if you make a ton of typos,\nbut results can be weird and might not work with acronyms\n(e.g. \"GIMP\" might not give you the paint program)": "Có thể tốt hơn nếu bạn gõ lệch phím nhiều,\nnhưng kết quả có thể hơi lạ và không hoạt động tốt với từ viết tắt\n(ví dụ tìm \"GIMP\" có thể không ra cái chương trình vẽ)", + "Shell & utilities theming must also be enabled": "Cần Shell & công cụ cũng bật", + "Desktop": "Màn hình chính", + "Anime": "Anime", + "Qt apps": "Các ứng dụng Qt", + "Style & wallpaper": "Phong cách & hình nền", + "Finished tasks will go here": "Việc đã xong sẽ hiện ở đây", + "Weather": "Thời tiết", + "Settings": "Cài đặt", + "Shell & utilities": "Shell & tiện ích", + "Unfinished": "Chưa xong", + "Random: Konachan": "Ngẫu nhiên: Konachan", + "Pick wallpaper image on your system": "Chọn hình nền trên máy", + "Volume": "Âm lượng", + "Add": "Thêm", + "Hibernate": "Ngủ đông", + "Run": "Chạy", + "Keep system awake": "Giữ hệ thống bật", + "To make sure this works consistently, use binditn = MODKEYS, catchall in an automatically triggered submap that includes everything.": "Để đảm bảo luôn hoạt động, dùng binditn = MODKEYS, catchall trong một submap luôn được kích hoạt bao trùm mọi thứ.", + "Plain rectangle": "Hình chữ nhật", + "%1 queries pending": "%1 lệnh gọi đang chờ", + "Temperature set to %1": "Nhiệt độ đã được đặt thành %1", + "Notifications": "Thông báo", + "System prompt": "Chỉ dẫn hệ thống", + "Hover to reveal": "Đặt chuột vào để hiện", + "No": "Không", + "Bar": "Bar", + "Search the web": "Tìm kiếm web", + "Page %1": "Trang %1", + "Reboot": "Khởi động lại", + "Such regions could be images or parts of the screen that have some containment.\nMight not always be accurate.\nThis is done with an image processing algorithm run locally and no AI is used.": "Các vùng có thể là hình ảnh hoặc phần của màn hình cớ vẻ được bao chứa.\nKhông luôn chính xác.\nSử dụng một thuật toán xử lý ảnh chạy trên máy, không dùng AI.", + "Show app icons": "Hiện biểu tượng ứng dụng", + "Closet": "Nghiện mà ngại", + "Set the current API provider": "Đặt nguồn cung cấp API", + "Cancel": "Hủy", + "Networking": "Mạng", + "Overview": "Overview", + "Search, calculate or run": "Tìm, tính hoặc chạy", + "Useless buttons": "Mấy nút vô dụng", + "Transparency": "Sự trong suốt", + "Temperature must be between 0 and 2": "Nhiệt độ phải trong khoảng từ 0 đến 2", + "Automatically suspends the system when battery is low": "Tự động ngủ khi pin thấp", + "Current API endpoint: %1\nSet it with %2mode PROVIDER": "Endpoint API hiện tại: %1\nĐặt với lệnh %2mode PROVIDER", + "Services": "Các dịch vụ", + "Reload Hyprland & Quickshell": "Tải lại Hyprland & Quickshell", + "Automatic suspend": "Tự động ngủ", + "illogical-impulse Welcome": "illogical-impulse - Xin chào", + "Interface": "Giao diện", + "Load chat": "Tải cuộc trò chuyện", + "Number show delay when pressing Super (ms)": "Thời gian chờ hiện số khi nhấn Super (ms)", + "Clear the current list of images": "Xóa danh sách hình ảnh hiện tại", + "Fake screen rounding": "Giả bo tròn màn hình", + "Tip: Hide icons and always show numbers for\nthe classic illogical-impulse experience": "Mẹo: Ẩn biểu tượng và luôn hiển thị số nếu\nmuốn giống trải nghiệm illogical-impulse gốc", + "Launch": "Chạy", + "%1 notifications": "%1 thông báo", + "%1 | Right-click to configure": "%1 | Ấn chuột phải để chỉnh", + "Unknown Artist": "Nghệ sĩ không xác định", + "Appearance": "Giao diện", + "Task Manager": "Quản lí ứng dụng đang chạy", + "To set an API key, pass it with the command\n\nTo view the key, pass \"get\" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3": "Để đặt API key, viết nó sau lệnh\n\nĐể xem lại, viết \"get\" sau lệnh
\n\n### Với %1:\n\n**Link**: %2\n\n%3", + "Opens cheatsheet on press": "Mở bảng tra cứu khi ấn", + "Invalid arguments. Must provide `key` and `value`.": "Biến không hợp lệ. cần cả `key` và `value`.", + "About": "Giới thiệu", + "illogical-impulse": "illogical-impulse", + "Help & Support": "Trợ giúp", + "Enter tags, or \"%1\" for commands": "Nhập tag hoặc \"%1\" để xem các lệnh", + "Format": "Định dạng", + "Content": "Giống gốc", + "Edit config": "Sửa config", + "Bluetooth": "Bluetooth", + "Be patient...": "Bình tĩnh...", + "Discussions": "Thảo luận", + "Anime boorus": "Các booru anime", + "That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number": "Quả này không được. Mẹo:\n- Kiểm tra tag và cài đặt NSFW\n- Nếu không nghĩ ra tag nào có thể nhập số trang", + "Task description": "Mô tả công việc", + "Max allowed increase": "Thay đổi tối đa", + "Rows": "Số hàng", + "Switched to search mode. Continue with the user's request.": "Đã chuyển sang chế độ tìm kiếm. Tiếp tục với yêu cầu của người dùng.", + "Use Levenshtein distance-based algorithm instead of fuzzy": "Sử dụng thuật toán dùng khoảng cách Levenshtein thay vì fuzzy", + "Copy": "Sao chép", + "12h am/pm": "12h am/pm", + "Unknown": "Không xác định", + "Waiting for response...": "Đang chờ phản hồi...", + "Workspace": "Workspace", + "Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers": "Hình nền Anime SFW ngẫu nhiên từ Konachan\nẢnh được lưu vào ~/Pictures/Wallpapers", + "Online via %1 | %2's model": "Trực tuyến qua %1 | Model của %2", + "Always show numbers": "Luôn hiện số", + "or": "hoặc", + "Drag or click a region • LMB: Copy • RMB: Edit": "Kéo thả hoặc chọn vùng • Chuột trái: Sao chép • Chuột phải: Chỉnh sửa", + "Local only": "Chỉ trên máy", + "Donate": "Ủng hộ", + "Online | Google's model\nGives up-to-date information with search.": "Trực tuyến | Model của Google\nCó thể tìm kiếm để cung cấp thông tin cập nhật.", + "Run command": "Chạy lệnh", + "Dotfiles": "Dotfiles", + "Volume limit": "Giới hạn âm lượng", + "On-screen display": "Âm lượng/độ sáng", + "Reboot to firmware settings": "Khởi động lại vào cài đặt firmware", + "Workspaces shown": "Số workspace hiển thị", + "Save": "Lưu", + "The popular one | Best quantity, but quality can vary wildly": "Phổ biến | Số lượng tốt nhất, nhưng chất lượng không biết đâu vào đâu", + "Save chat": "Lưu cuộc trò chuyện", + "Intelligence": "Trí tuệ", + "Translation goes here...": "Bản dịch sẽ hiện ở đây...", + "Toggle clipboard query on overview widget": "Mở/đóng tìm kiếm clipboard trên overview", + "Search": "Tìm kiếm", + "Timeout (ms)": "Thời gian chờ (ms)", + "24h": "24h", + "Color picker": "Chọn màu", + "Save to Downloads": "Lưu vào Downloads", + "No notifications": "Không có thông báo", + "Game mode": "Chế độ game", + "Alternatively use /dark, /light, /img in the launcher": "Có thể dùng /dark, /light, /img trong launcher", + "Info": "Thông tin", + "Dock": "Dock", + "Pinned on startup": "Ghim khi khởi động", + "Suspend at": "Tạm dừng ở", + "Fruit Salad": "Salad hoa quả", + "API key:\n\n```txt\n%1\n```": "API key:\n\n```txt\n%1\n```", + "API key set for %1": "API key đã đặt cho %1", + "Not visible to model": "Không hiển thị cho model", + "Expressive": "Biểu cảm", + "Enter text to translate...": "Nhập văn bản để dịch...", + "Usage": "Cách dùng", + "Message the model... \"%1\" for commands": "Hỏi model... \"%1\" để xem lệnh", + "Keybinds": "Phím tắt", + "Model set to %1": "Đã đặt model thành %1", + "Scale (%)": "Tỉ lệ (%)", + "Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window": "Gõ /key để bắt đầu dùng các model trực tuyến\nCtrl+O để mở rộng sidebar\nCtrl+P để nhấc sidebar thành cửa sổ", + "Output": "Đầu ra", + "Uptime: %1": "Máy bật được %1", + "For desktop wallpapers | Good quality": "Cho hình nền máy tính | Chất lượng tốt", + "Nothing here!": "Không có gì ở đây!", + "Close": "Đóng", + "Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel": "Phím mũi tên để chọn, Enter để xác nhận\nEsc hoặc ấn bất kỳ đâu để thoát", + "Copy code": "Sao chép code", + "Load prompt from %1": "Tải chỉ dẫn từ %1", + "Time": "Thời gian", + "**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key": "**Giá**: miễn phí. Chính sách sử dụng dữ liệu tùy thuộc vào cài đặt tài khoản OpenRouter của bạn.\n\n**Hướng dẫn**: Đăng nhập vào tài khoản OpenRouter, mở Keys ở menu góc trên bên phải, ấn Create API Key", + "Bar style": "Phong cách bar", + "Configuration": "Cài đặt", + "Prefixes": "Kí tự đầu", + "No API key set for %1": "Không có API key cho %1", + "Add task": "Thêm công việc", + "Volume mixer": "Trộn âm lượng", + "Go to source (%1)": "Đi đến nguồn (%1)", + "The current API used. Endpoint:": "API đang sử dụng. Endpoint:", + "View Markdown source": "Xem nguồn Markdown", + "Input": "Đầu vào", + "Allow NSFW": "Cho phép NSFW", + "Session": "Session", + "Detach left sidebar into a window/Attach it back": "Nhấc sidebar trái thành cửa sổ/Đặt nó lại", + "Night Light": "Lọc ánh sáng xanh", + "Workspaces": "Các workspace", + "Dark": "Tối", + "Base URL": "Base URL", + "Hug": "Ôm", + "Buttons": "Các nút", + "Get the next page of results": "Lấy trang kết quả tiếp theo", + "%1 Safe Storage": "Lưu trữ an toàn %1", + "Color generation": "Chỉnh màu", + "Select output device": "Chọn đầu ra", + "Select input device": "Chọn đầu vào", + "%1 • %2 tasks": "%1 • %2 việc cần làm", + "Online models disallowed\n\nControlled by `policies.ai` config option": "Model trực tuyến không được cho phép\n\nCài đặt bởi lựa chọn `policies.ai`", + "Download complete": "Đã tải xong", + "Code saved to file": "Code đã lưu vào file", + "Critically low battery": "Pin rất thấp", + "Scroll to change brightness": "Cuộn để thay đổi độ sáng", + "Cloudflare WARP": "Cloudflare WARP", + "Toggles bar on press": "Mở/đóng bar khi ấn", + "Saved to %1": "Đã lưu vào %1", + "Elements": "Nguyên tố", + "Save chat to %1": "Lưu chat vào %1", + "Connection failed. Please inspect manually with the warp-cli command": "Kết nối không thành công. Hãy xem lại với lệnh warp-cli", + "Weather Service": "Thời tiết", + "Registration failed. Please inspect manually with the warp-cli command": "Đăng ký không thành công. Hãy xem lại với lệnh warp-cli", + "Consider plugging in your device": "Hãy cắm nguồn thiết bị của bạn", + "Cloudflare WARP (1.1.1.1)": "Cloudflare WARP (1.1.1.1)", + "Cannot find a GPS service. Using the fallback method instead.": "Không tìm thấy dịch vụ GPS. Đang sử dụng phương pháp dự phòng.", + "Opens bar on press": "Mở bar khi ấn", + "Low battery": "Pin yếu", + "Scroll to change volume": "Cuộn để thay đổi âm lượng", + "Please charge!\nAutomatic suspend triggers at %1": "Hãy sạc pin!\nHệ thống sẽ tự động ngủ khi pin xuống %1", + "Closes bar on press": "Đóng bar khi ấn", + "Mo": "T2/*keep*/", + "Tu": "T3/*keep*/", + "We": "T4/*keep*/", + "Th": "T5/*keep*/", + "Fr": "T6/*keep*/", + "Sa": "T7/*keep*/", + "Su": "CN/*keep*/", + "Approve": "Chấp nhận", + "Set the tool to use for the model.": "Chọn công cụ để sử dụng với model.", + "No API key\nSet it with /key YOUR_API_KEY": "Không có API key\nĐặt bằng /key API_KEY", + "API Key": "API Key", + "EasyEffects | Right-click to configure": "EasyEffects | Ấn chuột phải để chỉnh cài đặt", + "API key is set": "API key đã đặt", + "Invalid tool. Supported tools:\n- %1": "Công cụ không hợp lệ. Các lựa chọn:\n- %1", + "Thought": "Suy nghĩ", + "Current tool: %1\nSet it with %2tool TOOL": "Công cụ: %1\nĐặt bằng %2tool CÔNG_CỤ", + "Edit shell config file": "Chỉnh file config của shell", + "A download might be in progress": "Có thể có tệp đang tải", + "API key is set\nChange with /key YOUR_API_KEY": "API key đã đặt\nThay đổi bằng /key API_KEY", + "Temperature\nChange with /temp VALUE": "Nhiệt độ (độ ngẫu nhiên)\nThay đổi bằng /temp GIÁ_TRỊ", + "Your package manager is running": "Package manager đang chạy", + "Usage: %1load CHAT_NAME": "Hướng dẫn: %1load TÊN_ĐOẠN_CHAT", + "Cannot switch to search mode from %1": "Không thể chuyển sang chế độ tìm kiếm từ %1", + "UV Index": "Chỉ số UV", + "Online | Google's model\nFast, can perform searches for up-to-date information": "Trực tuyến | Model của Google\nNhanh, có thể tìm kiếm Google để lấy thông tin cập nhật", + "Token count": "Số lượng token", + "Experimental | Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput.": "Thử nghiệm | Trực tuyến | Model của Google\nModel Gemini 2.5 Flash tối ưu hóa cho hiệu quả chi phí và băng thông.", + "Wallpaper parallax": "Hiệu ứng parallax (hình nền)", + "Usage: %1tool TOOL_NAME": "Hướng dẫn: %1tool TÊN_CÔNG_CỤ", + "Humidity": "Độ ẩm", + "Invalid tool. Supported tools: %1": "Công cụ không hợp lệ. Các lựa chọn: %1", + "Sunset": "Hoàng hôn", + "Total token count\nInput: %1\nOutput: %2": "Số lượng token\nInput: %1\nOutput: %2", + "Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput.": "Trực tuyến | Model của Google\nModel Gemini 2.5 Flash tối ưu hóa cho hiệu quả chi phí và băng thông.", + "Visibility": "Tầm nhìn", + "Pressure": "Áp suất", + "Depends on workspace": "Phụ thuộc vào workspace", + "Reject": "Từ chối", + "Precipitation": "Lượng mưa", + "Wind": "Gió", + "Usage: %1save CHAT_NAME": "Hướng dẫn: %1save TÊN_ĐOẠN_CHAT", + "Enable EasyEffects": "Bật EasyEffects", + "Night Light | Click to toggle, right-click to toggle automatic mode": "Lọc ánh sáng xanh | ấn để bật/tắt, ấn chuột phải để bật/tắt chế độ tự động", + "Night Light | Right-click to toggle Auto mode": "Lọc ánh sáng xanh | Ấn chuột phải để bật/tắt chế độ tự động", + "No command provided": "Không có lệnh nào được cung cấp", + "No API key": "Không có API key", + "Performance Profile toggle": "Nút Performance Profile", + "Sunrise": "Bình minh", + "Online | Google's model\nNewer one that's slower": "Trực tuyến | Model của Google\nMới hơn nhưng chậm hơn", + "Command rejected by user": "Lệnh bị từ chối bởi người dùng", + "Experimental | Online | Google's model\nCan do a little more but takes an extra turn to perform search": "Thử nghiệm | Trực tuyến | Model của Google\nCó thể làm nhiều hơn một chút nhưng mất thêm một lượt để thực hiện tìm kiếm", + "Depends on sidebars": "Phụ thuộc vào sidebar", + "Temperature": "Nhiệt độ", + "There might be a download in progress": "Có thể có tệp đang tải", + "EasyEffects": "EasyEffects", + "Token count | Input: %1 | Output: %2": "Số lượng token | Input: %1 | Output: %2", + "Tool set to %1": "Công cụ được đặt thành %1", + "Invalid arguments. Must provide `command`.": "Tham số không hợp lệ. Phải cung cấp `command`.", + "A download is in progress": "Có một tệp đang tải", + "illogical-impulse Settings": "Cài đặt illogical-impulse", + "Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers": "Trực tuyến | Model của Google\nMới hơn nhưng chậm hơn so với phiên bản trước nhưng nên cung cấp câu trả lời chất lượng cao hơn", + "Preferred wallpaper zoom (%)": "Tỷ lệ thu phóng hình nền (%)", + "Function Response": "Phản hồi function" +} \ No newline at end of file diff --git a/translations/zh_CN.json b/translations/zh_CN.json new file mode 100644 index 0000000..fe69690 --- /dev/null +++ b/translations/zh_CN.json @@ -0,0 +1,404 @@ +{ + "Mo": "一/*keep*/", + "Tu": "二/*keep*/", + "We": "三/*keep*/", + "Th": "四/*keep*/", + "Fr": "五/*keep*/", + "Sa": "六/*keep*/", + "Su": "日/*keep*/", + "%1 characters": "%1 个字符", + "**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key": "**价格**:免费。数据用于训练。\n\n**说明**:登录 Google 账户,允许 AI Studio 创建 Google Cloud 项目或其他要求,然后返回并点击获取 API 密钥", + "**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key": "**价格**:免费。数据使用政策取决于您的 OpenRouter 账户设置。\n\n**说明**:登录 OpenRouter 账户,在右上角菜单中选择 Keys,点击创建 API 密钥", + ". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!": ". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!", + "No further instruction provided": "未提供进一步说明", + "API key set for %1": "已为 %1 设置 API 密钥", + "API key:\n\n```txt\n%1\n```": "API 密钥:\n\n```txt\n%1\n```", + "Action": "操作", + "Add": "添加", + "Add task": "添加任务", + "All-rounder | Good quality, decent quantity": "全能型 | 质量好,数量适中", + "Allow NSFW": "允许 NSFW", + "Allow NSFW content": "允许 NSFW 内容", + "Anime": "动漫", + "Anime boorus": "动漫图库", + "App": "应用", + "Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel": "方向键导航,回车选择\nEsc 或点击任意地方取消", + "Bluetooth": "蓝牙", + "Brightness": "亮度", + "Cancel": "取消", + "Cheat sheet": "快捷键表", + "Choose model": "选择模型", + "Clean stuff | Excellent quality, no NSFW": "清洁内容 | 优秀质量,无 NSFW", + "Clear": "清除", + "Clear chat history": "清除聊天记录", + "Clear the current list of images": "清除当前图片列表", + "Close": "关闭", + "Copy": "复制", + "Copy code": "复制代码", + "Current API endpoint: %1\nSet it with %2mode PROVIDER": "当前 API 端点:%1\n使用 %2mode PROVIDER 设置", + "Delete": "删除", + "Desktop": "桌面", + "Disable NSFW content": "禁用 NSFW 内容", + "Done": "完成", + "Download": "下载", + "Edit": "编辑", + "Enter text to translate...": "输入要翻译的文本...", + "Finished tasks will go here": "已完成的任务将显示在这里", + "For desktop wallpapers | Good quality": "桌面壁纸专用 | 质量好", + "For storing API keys and other sensitive information": "用于存储 API 密钥和其他敏感信息", + "Game mode": "游戏模式", + "Get the next page of results": "获取下一页结果", + "Go to source (%1)": "转到源 (%1)", + "Hibernate": "休眠", + "Input": "输入", + "Intelligence": "智能体", + "Interface": "界面", + "Invalid arguments. Must provide `key` and `value`.": "参数无效。必须提供 `key` 和 `value`。", + "Jump to current month": "跳转到当前月份", + "Keep system awake": "保持系统唤醒", + "Large images | God tier quality, no NSFW.": "大尺寸图片 | 顶级质量,无 NSFW", + "Large language models": "大语言模型", + "Launch": "启动", + "Local Ollama model | %1": "本地 Ollama 模型 | %1", + "Lock": "锁定", + "Logout": "注销", + "Markdown test": "Markdown 测试", + "Math result": "数学结果", + "No API key set for %1": "未为 %1 设置 API 密钥", + "No audio source": "无音频源", + "No media": "无媒体", + "No notifications": "无通知", + "Not visible to model": "对模型不可见", + "Nothing here!": "这里什么都没有!", + "Notifications": "通知", + "OK": "确定", + "Open file link": "打开文件链接", + "Output": "输出", + "Page %1": "第 %1 页", + "Reboot": "重启", + "Reboot to firmware settings": "重启到固件设置", + "Reload Hyprland & Quickshell": "重新加载 Hyprland 和 Quickshell", + "Run": "运行", + "Run command": "运行命令", + "Save": "保存", + "Save to Downloads": "保存到下载文件夹", + "Search": "搜索", + "Search the web": "在网络上搜索", + "Search, calculate or run": "搜索、计算或运行", + "Select Language": "选择语言", + "Session": "会话", + "Set API key": "设置 API 密钥", + "Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5.": "设置模型的温度(随机性)。Gemini 模型范围为 0 到 2,其他模型为 0 到 1。默认值为 0.5。", + "Set the current API provider": "设置当前 API 提供商", + "Shutdown": "关机", + "Silent": "静音", + "Sleep": "睡眠", + "System": "系统", + "Task Manager": "任务管理器", + "Task description": "任务描述", + "Temperature must be between 0 and 2": "温度必须在 0 到 2 之间", + "Temperature set to %1": "温度设置为 %1", + "Temperature: %1": "温度:%1", + "The hentai one | Great quantity, a lot of NSFW, quality varies wildly": "成人向 | 数量巨大,大量 NSFW,质量参差不齐", + "The popular one | Best quantity, but quality can vary wildly": "最受欢迎 | 数量最多,但质量参差不齐", + "Thinking": "思考中", + "Translation goes here...": "翻译结果会显示在这里...", + "Translator": "翻译器", + "Unfinished": "未完成", + "Unknown": "未知", + "Unknown Album": "未知专辑", + "Unknown Artist": "未知艺术家", + "Unknown Title": "未知标题", + "Unknown function call: %1": "未知函数调用:%1", + "View Markdown source": "查看 Markdown 源码", + "Volume": "音量", + "Volume mixer": "音量混合器", + "Waifus only | Excellent quality, limited quantity": "仅限角色 | 优秀质量,数量有限", + "Waiting for response...": "等待响应...", + "Workspace": "工作区", + "%1 Safe Storage": "%1 安全存储", + "%1 does not require an API key": "%1 不需要 API 密钥", + "%1 queries pending": "%1 个查询等待中", + "%1 | Right-click to configure": "%1 | 右键点击进行配置", + "Invalid API provider. Supported: \n-": "无效的 API 提供商。支持的:\n-", + "Unknown command:": "未知命令:", + "Type /key to get started with online models\nCtrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window": "输入 /key 开始使用在线模型\nCtrl+O 展开侧边栏\nCtrl+P 将侧边栏分离为窗口", + "Provider set to": "提供商设置为", + "Invalid model. Supported: \n```": "无效模型。支持的:\n```", + "Switched to search mode. Continue with the user's request.": "已切换到搜索模式。继续处理用户请求。", + "Enter tags, or \"%1\" for commands": "输入标签,或 \"%1\" 查看命令", + "Online via %1 | %2's model": "通过 %1 在线 | %2 的模型", + "That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number": "没有找到结果。提示:\n- 检查您的标签和 NSFW 设置\n- 如果没有想到标签,请输入页码", + "Settings": "设置", + "Save chat": "保存对话", + "Load chat": "加载对话", + "or": "或", + "Set the system prompt for the model.": "为模型设置系统提示。", + "To Do": "待办", + "Calendar": "日历", + "Advanced": "高级", + "About": "关于", + "Services": "服务", + "Style": "样式", + "Colors & Wallpaper": "颜色和壁纸", + "Light": "浅色", + "Dark": "深色", + "Material palette": "颜色主题", + "Fidelity": "保真度", + "Fruit Salad": "水果沙拉", + "Alternatively use /dark, /light, /img in the launcher": "或者在启动器中使用 /dark、/light、/img", + "Fake screen rounding": "伪造屏幕圆角", + "When not fullscreen": "非全屏时", + "Choose file": "选择文件", + "Random SFW Anime wallpaper from Konachan\nImage is saved to ~/Pictures/Wallpapers": "随机 Konachan SFW 动漫壁纸\n图片保存到 ~/图片/Wallpapers", + "Be patient...": "请耐心等待...", + "Decorations & Effects": "装饰与特效", + "Tonal Spot": "色调点", + "Shell windows": "Shell 窗口", + "Auto": "自动", + "Wallpaper": "壁纸", + "Content": "内容", + "Title bar": "标题栏", + "Transparency": "透明度", + "Expressive": "表现力", + "Yes": "是", + "Enable": "启用", + "Rainbow": "彩虹", + "Might look ass. Unsupported.": "可能效果很差。不支持。", + "Monochrome": "单色", + "Random: Konachan": "随机:Konachan", + "Center title": "标题居中", + "Neutral": "中性", + "Pick wallpaper image on your system": "在系统中选择壁纸图片", + "No": "否", + "AI": "AI", + "Local only": "仅本地", + "Policies": "策略", + "Weeb": "二次元", + "Closet": "隐藏", + "Show next time": "下次显示", + "Usage": "用法", + "Plain rectangle": "纯矩形", + "Useless buttons": "无用按钮", + "GitHub": "GitHub", + "Style & wallpaper": "样式与壁纸", + "Configuration": "配置", + "Change any time later with /dark, /light, /img in the launcher": "之后可在启动器用 /dark、/light、/img 更改", + "Keybinds": "快捷键", + "Float": "浮动", + "Hug": "贴合", + "illogical-impulse Welcome": "illogical-impulse 欢迎页", + "Info": "信息", + "Volume limit": "音量限制", + "Prevents abrupt increments and restricts volume limit": "防止骤增并限制音量", + "Resources": "资源", + "12h am/pm": "12小时 上午/下午", + "Base URL": "基础 URL", + "Audio": "声音", + "Networking": "网络", + "Format": "格式", + "Time": "时间", + "Battery": "电池", + "Prefixes": "前缀", + "Emojis": "表情符号", + "Earbang protection": "防爆音保护", + "Automatically suspends the system when battery is low": "电池电量低时自动挂起系统", + "Automatic suspend": "自动挂起", + "Suspend at": "挂起阈值", + "Max allowed increase": "最大允许增幅", + "Web search": "网页搜索", + "Polling interval (ms)": "轮询间隔(毫秒)", + "Clipboard": "剪贴板", + "Low warning": "低电量警告", + "24h": "24小时制", + "Use Levenshtein distance-based algorithm instead of fuzzy": "使用 Levenshtein 距离算法替代模糊匹配", + "System prompt": "系统提示词", + "12h AM/PM": "12小时 AM/PM", + "Could be better if you make a ton of typos,\nbut results can be weird and might not work with acronyms\n(e.g. \"GIMP\" might not give you the paint program)": "如果你经常打错字可能更好用,但结果可能很奇怪,并且可能无法匹配缩写(如 \"GIMP\" 可能搜不到绘图程序)", + "Critical warning": "临界警告", + "User agent (for services that require it)": "用户代理(部分服务需要)", + "Such regions could be images or parts of the screen that have some containment.\nMight not always be accurate.\nThis is done with an image processing algorithm run locally and no AI is used.": "这些区域可能是图片或屏幕中具有一定包容性的部分。\n可能并不总是准确。\n这是通过本地运行的图像处理算法实现的,没有使用 AI。", + "Note: turning off can hurt readability": "注意:关闭后可能影响可读性", + "Workspaces shown": "显示的工作区数", + "Dark/Light toggle": "深浅色切换", + "Dock": "停靠栏", + "Weather": "天气", + "Pinned on startup": "启动时固定", + "Tip: Hide icons and always show numbers for\nthe classic illogical-impulse experience": "提示:隐藏图标并始终显示数字以获得经典体验", + "Always show numbers": "总是显示数字", + "Buttons": "按钮", + "Keyboard toggle": "键盘切换", + "Scale (%)": "缩放比例(%)", + "Overview": "概览", + "Rows": "行数", + "Borderless": "无边框", + "Screenshot tool": "截图工具", + "Number show delay when pressing Super (ms)": "按下 Super 时数字显示延迟(ms)", + "Timeout (ms)": "超时时间(ms)", + "Show app icons": "显示应用图标", + "Workspaces": "工作区", + "Columns": "列数", + "On-screen display": "屏幕显示", + "Screen snip": "屏幕截图", + "Mic toggle": "麦克风切换", + "Hover to reveal": "悬停显示", + "Bar": "条栏", + "Show background": "显示背景", + "Show regions of potential interest": "显示可能感兴趣的区域", + "Color picker": "取色器", + "Help & Support": "帮助与支持", + "Discussions": "讨论区", + "Color generation": "配色生成", + "Dotfiles": "配置文件", + "Distro": "发行版", + "Privacy Policy": "隐私政策", + "Documentation": "文档", + "Shell & utilities theming must also be enabled": "必须同时启用 Shell 与工具主题", + "Ignored if terminal theming is not enabled": "终端主题未启用时不生效", + "illogical-impulse": "illogical-impulse", + "Donate": "捐助", + "Terminal": "终端", + "Shell & utilities": "Shell 与工具", + "Qt apps": "Qt 应用", + "Force dark mode in terminal": "终端强制使用深色模式", + "Report a Bug": "报告问题", + "Issues": "问题追踪", + "Drag or click a region • LMB: Copy • RMB: Edit": "拖动或点击一个区域 • 鼠标左键:复制 • 鼠标右键:编辑", + "Current model: %1\nSet it with %2model MODEL": "当前模型:%1\n使用 %2model MODEL 设置", + "Message the model... \"%1\" for commands": "与模型对话... \"%1\" 查看命令", + "The current system prompt is\n\n---\n\n%1": "当前系统提示词为\n\n---\n\n%1", + "Model set to %1": "模型已设置为 %1", + "Loaded the following system prompt\n\n---\n\n%1": "已加载以下系统提示词\n\n---\n\n%1", + "%1 notifications": "%1 条通知", + "Save chat to %1": "保存聊天记录到 %1", + "Load chat from %1": "从 %1 加载聊天记录", + "Load prompt from %1": "从 %1 加载提示词", + "Select output device": "选择输出设备", + "%1 • %2 tasks": "%1 • %2 个任务", + "Online models disallowed\n\nControlled by `policies.ai` config option": "禁止在线模型\n\n由 `policies.ai` 配置项控制", + "Select input device": "选择输入设备", + "Low battery": "电量低", + "Registration failed. Please inspect manually with the warp-cli command": "注册失败。请使用 warp-cli 命令手动检查", + "Code saved to file": "代码已保存到文件", + "Consider plugging in your device": "请考虑连接您的设备", + "Weather Service": "天气服务", + "Please charge!\nAutomatic suspend triggers at %1": "请充电!\n自动挂起将在 %1 时触发", + "Cloudflare WARP (1.1.1.1)": "Cloudflare WARP (1.1.1.1)", + "Cloudflare WARP": "Cloudflare WARP", + "Download complete": "下载完成", + "Critically low battery": "电量极低", + "Scroll to change brightness": "滚动以调节亮度", + "Saved to %1": "已保存到 %1", + "Cannot find a GPS service. Using the fallback method instead.": "无法找到 GPS 服务。正在使用备用方法。", + "Elements": "元素", + "Scroll to change volume": "滚动以调节音量", + "Connection failed. Please inspect manually with the warp-cli command": "连接失败。请使用 warp-cli 命令手动检查", + "UV Index": "紫外线指数", + "Pressure": "气压", + "Visibility": "能见度", + "Sunrise": "日出", + "Sunset": "日落", + "Humidity": "湿度", + "Wind": "风", + "Precipitation": "降水量", + "Time to full:": "距离充满:", + "Time to empty:": "距离耗尽:", + "Fully charged": "已充满电", + "Charging:": "充电功率:", + "Discharging:": "放电功率:", + "No pending tasks": "没有待办任务", + "... and %1 more": "... 还有 %1 个", + "Used:": "已用:", + "Free:": "可用:", + "Total:": "总计:", + "Load:": "负载:", + "High": "高", + "Medium": "中", + "Low": "低", + "Tint icons": "图标着色", + "Performance Profile toggle": "性能配置文件切换", + "**Instructions**: Log into Mistral account, go to Keys on the sidebar, click Create new key": "**说明**:登录 Mistral 账户,在侧边栏中选择 Keys,点击创建新密钥", + "Invalid arguments. Must provide `command`.": "参数无效。必须提供 `command`。", + "Thought": "思考", + "Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput.": "在线 | Google 模型\n针对成本效益和高吞吐量优化的 Gemini 2.5 Flash 模型。", + "Online | Google's model\nFast, can perform searches for up-to-date information": "在线 | Google 模型\n速度快,可搜索最新信息", + "Your package manager is running": "您的包管理器正在运行", + "Gives the model search capabilities (immediately)": "为模型提供搜索功能(即时)", + "Set the tool to use for the model.": "设置模型使用的工具。", + "Night Light | Right-click to toggle Auto mode": "夜间模式 | 右键切换自动模式", + "Online | %1's model | Delivers fast, responsive and well-formatted answers. Disadvantages: not very eager to do stuff; might make up unknown function calls": "在线 | %1 的模型 | 提供快速、响应迅速且格式良好的答案。缺点:不太积极主动;可能编造未知的函数调用", + "Depends on workspace": "取决于工作区", + "Usage: %1tool TOOL_NAME": "用法:%1tool 工具名称", + "Tray": "托盘", + "Usage: %1save CHAT_NAME": "用法:%1save 聊天名称", + "Approve": "批准", + "Depends on sidebars": "取决于侧边栏", + "Commands, edit configs, search.\nTakes an extra turn to switch to search mode if that's needed": "命令、编辑配置、搜索。\n如果需要,会额外执行一次切换到搜索模式", + "Overall appearance": "整体外观", + "Up %1": "运行 %1", + "Tool set to: %1": "工具设置为:%1", + "Wallpaper parallax": "壁纸视差", + "Online | Google's model\nGoogle's state-of-the-art multipurpose model that excels at coding and complex reasoning tasks.": "在线 | Google 模型\nGoogle 最先进的多用途模型,在编程和复杂推理任务方面表现卓越。", + "Tint app icons": "应用图标着色", + "Preferred wallpaper zoom (%)": "首选壁纸缩放比例 (%)", + "To set an API key, pass it with the %4 command\n\nTo view the key, pass \"get\" with the command
\n\n### For %1:\n\n**Link**: %2\n\n%3": "要设置 API 密钥,请使用 %4 命令传递\n\n要查看密钥,请在命令中传递 \"get\"
\n\n### 对于 %1:\n\n**链接**:%2\n\n%3", + "No API key\nSet it with /key YOUR_API_KEY": "无 API 密钥\n使用 /key YOUR_API_KEY 设置", + "Total token count\nInput: %1\nOutput: %2": "总令牌数\n输入:%1\n输出:%2", + "Disable tools": "禁用工具", + "API key is set\nChange with /key YOUR_API_KEY": "API 密钥已设置\n使用 /key YOUR_API_KEY 更改", + "Usage: %1load CHAT_NAME": "用法:%1load 聊天名称", + "Sidebars": "侧边栏", + "Temperature\nChange with /temp VALUE": "温度\n使用 /temp VALUE 更改", + "Current tool: %1\nSet it with %2tool TOOL": "当前工具:%1\n使用 %2tool TOOL 设置", + "There might be a download in progress": "可能有下载正在进行", + "Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers": "在线 | Google 模型\n比前代模型更慢但应该提供更高质量答案的新模型", + "EasyEffects | Right-click to configure": "EasyEffects | 右键配置", + "Command rejected by user": "用户拒绝了命令", + "Invalid tool. Supported tools:\n- %1": "无效工具。支持的工具:\n- %1", + "Keep right sidebar loaded": "保持右侧边栏加载", + "Reject": "拒绝", + "Enter password": "输入密码", + "Automatically hide": "自动隐藏", + "**Pricing**: Free tier available with limited rates. See https://docs.github.com/en/billing/concepts/product-billing/github-models\n\n**Instructions**: Generate a GitHub personal access token with Models permission, then set as API key here\n\n**Note**: To use this you will have to set the temperature parameter to 1": "**定价**:提供免费层,速率有限。详情见 https://docs.github.com/en/billing/concepts/product-billing/github-models\n\n**说明**:生成一个具有 Models 权限的 GitHub 个人访问令牌,并在此处将其设置为 API 密钥\n\n**注意**:使用此提供商时需要将温度参数设置为 1", + "Conflicts with the shell's system tray implementation": "与 Shell 的系统托盘实现冲突", + "Horizontal": "水平", + "Enjoy! You can reopen the welcome app any time with Super+Shift+Alt+/. To open the settings app, hit Super+I": "祝您愉快!随时可以按 Super+Shift+Alt+/ 重新打开欢迎应用。要打开设置应用,请按 Super+I", + "Reset": "重置", + "☕ Break: %1 minutes": "☕ 休息:%1 分钟", + "Pomodoro": "番茄钟", + "Long break": "长休息", + "Resume": "继续", + "Start": "开始", + "Hi there! First things first...": "嗨!先做些重要的事情...", + "Refreshing (manually triggered)": "刷新中(手动触发)", + "Kill conflicting programs?": "结束冲突程序?", + "Place at the bottom/right": "放置在底部/右侧", + "When enabled keeps the content of the right sidebar loaded to reduce the delay when opening,\nat the cost of around 15MB of consistent RAM usage. Delay significance depends on your system's performance.\nUsing a custom kernel like linux-cachyos might help": "启用后会保持右侧边栏内容常驻,以减少打开时的延迟,代价是大约 15MB 的持续内存使用。延迟的影响程度取决于系统性能。使用像 linux-cachyos 这样的自定义内核可能会有所帮助", + "Config file": "配置文件", + "Stopwatch": "秒表", + "Break": "休息", + "Shell conflicts killer": "终止冲突程序", + "Vertical": "垂直", + "🔴 Focus: %1 minutes": "🔴 专注:%1 分钟", + "System uptime:": "系统运行时间:", + "Focus": "专注", + "Open the shell config file.\nIf the button doesn't work or doesn't open in your favorite editor,\nyou can manually open ~/.config/illogical-impulse/config.json": "打开 Shell 配置文件。\n如果按钮无法工作或没有在您喜欢的编辑器中打开,\n可以手动打开 ~/.config/illogical-impulse/config.json", + "Attach a file. Only works with Gemini.": "附加文件。仅适用于 Gemini。", + "🌿 Long break: %1 minutes": "🌿 长休息:%1 分钟", + "Always": "始终", + "To Do:": "待办:", + "Incorrect password": "密码错误", + "Timer": "计时器", + "Conflicts with the shell's notification implementation": "与 Shell 的通知实现冲突", + "Pause": "暂停", + "Feels like %1": "体感温度:%1", + "Lap": "计时", + "Welcome app": "欢迎应用", + "Corner style": "角落样式", + "Bar layout": "条栏布局", + "Language": "语言", + "Select the language for the user interface.\n\"Auto\" will use your system's locale.": "选择用户界面的语言。\n\"自动\" 将使用系统语言环境。", + "Language setting saved. Please restart Quickshell (Ctrl+Super+R) to apply the new language.": "语言设置已保存。请重启 Quickshell (Ctrl+Super+R) 以应用新语言。", + "Auto (System)": "自动(系统)", + "Interface Language": "界面语言" +}