diff --git a/noctalia/colors.json b/noctalia/colors.json new file mode 100644 index 0000000..fb88025 --- /dev/null +++ b/noctalia/colors.json @@ -0,0 +1,18 @@ +{ + "mError": "#f38ba8", + "mHover": "#94e2d5", + "mOnError": "#11111b", + "mOnHover": "#11111b", + "mOnPrimary": "#11111b", + "mOnSecondary": "#11111b", + "mOnSurface": "#cdd6f4", + "mOnSurfaceVariant": "#a3b4eb", + "mOnTertiary": "#11111b", + "mOutline": "#4c4f69", + "mPrimary": "#cba6f7", + "mSecondary": "#fab387", + "mShadow": "#11111b", + "mSurface": "#1e1e2e", + "mSurfaceVariant": "#313244", + "mTertiary": "#94e2d5" +} diff --git a/noctalia/colorschemes/Tokyo Night Moon/Tokyo Night Moon.json b/noctalia/colorschemes/Tokyo Night Moon/Tokyo Night Moon.json new file mode 100644 index 0000000..3e46ec7 --- /dev/null +++ b/noctalia/colorschemes/Tokyo Night Moon/Tokyo Night Moon.json @@ -0,0 +1,94 @@ +{ + "dark": { + "mPrimary": "#7a88cf", + "mOnPrimary": "#1f2335", + "mSecondary": "#d7729f", + "mOnSecondary": "#1f2335", + "mTertiary": "#9cd58a", + "mOnTertiary": "#1f2335", + "mError": "#f7768e", + "mOnError": "#1f2335", + "mSurface": "#1f2335", + "mOnSurface": "#a9b1d6", + "mSurfaceVariant": "#2c314a", + "mOnSurfaceVariant": "#c0caf5", + "mOutline": "#4b517a", + "mShadow": "#181b2a", + "mHover": "#9cd58a", + "mOnHover": "#1f2335", + "terminal": { + "foreground": "#a9b1d6", + "background": "#1f2335", + "selectionFg": "#a9b1d6", + "selectionBg": "#4b517a", + "cursorText": "#1f2335", + "cursor": "#a9b1d6", + "normal": { + "black": "#1f2335", + "red": "#f7768e", + "green": "#9cd58a", + "yellow": "#e6c384", + "blue": "#7a88cf", + "magenta": "#d7729f", + "cyan": "#7bb0c0", + "white": "#8289a6" + }, + "bright": { + "black": "#545c7e", + "red": "#f7768e", + "green": "#9cd58a", + "yellow": "#e6c384", + "blue": "#7a88cf", + "magenta": "#d7729f", + "cyan": "#7bb0c0", + "white": "#a9b1d6" + } + } + }, + "light": { + "mPrimary": "#4e70c2", + "mOnPrimary": "#f0f2f7", + "mSecondary": "#85539c", + "mOnSecondary": "#f0f2f7", + "mTertiary": "#4c663b", + "mOnTertiary": "#f0f2f7", + "mError": "#e55561", + "mOnError": "#f0f2f7", + "mSurface": "#f0f2f7", + "mOnSurface": "#4e70c2", + "mSurfaceVariant": "#dbdfea", + "mOnSurfaceVariant": "#444b6a", + "mOutline": "#c8ccd8", + "mShadow": "#a4a8b7", + "mHover": "#4c663b", + "mOnHover": "#f0f2f7", + "terminal": { + "foreground": "#4e70c2", + "background": "#f0f2f7", + "selectionFg": "#4e70c2", + "selectionBg": "#c8ccd8", + "cursorText": "#f0f2f7", + "cursor": "#4e70c2", + "normal": { + "black": "#f0f2f7", + "red": "#e55561", + "green": "#4c663b", + "yellow": "#9e8c6b", + "blue": "#4e70c2", + "magenta": "#85539c", + "cyan": "#569b9f", + "white": "#6a708a" + }, + "bright": { + "black": "#939ab7", + "red": "#d26e7a", + "green": "#5e7250", + "yellow": "#9e8c6b", + "blue": "#5e7bba", + "magenta": "#85539c", + "cyan": "#569b9f", + "white": "#6a708a" + } + } + } +} diff --git a/noctalia/plugins.json b/noctalia/plugins.json new file mode 100644 index 0000000..5bee9b5 --- /dev/null +++ b/noctalia/plugins.json @@ -0,0 +1,36 @@ +{ + "sources": [ + { + "enabled": true, + "name": "Noctalia Plugins", + "url": "https://github.com/noctalia-dev/noctalia-plugins" + } + ], + "states": { + "clipper": { + "enabled": true, + "sourceUrl": "https://github.com/noctalia-dev/noctalia-plugins" + }, + "fancy-audiovisualizer": { + "enabled": true, + "sourceUrl": "https://github.com/noctalia-dev/noctalia-plugins" + }, + "network-manager-vpn": { + "enabled": true, + "sourceUrl": "https://github.com/noctalia-dev/noctalia-plugins" + }, + "polkit-agent": { + "enabled": true, + "sourceUrl": "https://github.com/noctalia-dev/noctalia-plugins" + }, + "wallcards": { + "enabled": true, + "sourceUrl": "https://github.com/noctalia-dev/noctalia-plugins" + }, + "workspace-overview": { + "enabled": true, + "sourceUrl": "https://github.com/noctalia-dev/noctalia-plugins" + } + }, + "version": 2 +} diff --git a/noctalia/plugins/clipper/.gitignore b/noctalia/plugins/clipper/.gitignore new file mode 100644 index 0000000..2656046 --- /dev/null +++ b/noctalia/plugins/clipper/.gitignore @@ -0,0 +1,16 @@ +# Backup files +*.backup +*.bak +*.tmp + +# Runtime files +pinned.json +settings.json + +# Build artifacts +*.qmlc + +# Development scripts +i18n/all_languages.sh +# User notecards - personal data +notecards/*.json diff --git a/noctalia/plugins/clipper/Assets/Notecard Preview.mp4 b/noctalia/plugins/clipper/Assets/Notecard Preview.mp4 new file mode 100644 index 0000000..e6bddfc Binary files /dev/null and b/noctalia/plugins/clipper/Assets/Notecard Preview.mp4 differ diff --git a/noctalia/plugins/clipper/Assets/ToDo Preview.mp4 b/noctalia/plugins/clipper/Assets/ToDo Preview.mp4 new file mode 100644 index 0000000..79e3e73 Binary files /dev/null and b/noctalia/plugins/clipper/Assets/ToDo Preview.mp4 differ diff --git a/noctalia/plugins/clipper/Assets/clipboard-preview.png b/noctalia/plugins/clipper/Assets/clipboard-preview.png new file mode 100644 index 0000000..9b87859 Binary files /dev/null and b/noctalia/plugins/clipper/Assets/clipboard-preview.png differ diff --git a/noctalia/plugins/clipper/Assets/notecard-preview.gif b/noctalia/plugins/clipper/Assets/notecard-preview.gif new file mode 100644 index 0000000..811f218 Binary files /dev/null and b/noctalia/plugins/clipper/Assets/notecard-preview.gif differ diff --git a/noctalia/plugins/clipper/Assets/settings.png b/noctalia/plugins/clipper/Assets/settings.png new file mode 100644 index 0000000..2cfaa27 Binary files /dev/null and b/noctalia/plugins/clipper/Assets/settings.png differ diff --git a/noctalia/plugins/clipper/Assets/todo-preview.gif b/noctalia/plugins/clipper/Assets/todo-preview.gif new file mode 100644 index 0000000..fed7626 Binary files /dev/null and b/noctalia/plugins/clipper/Assets/todo-preview.gif differ diff --git a/noctalia/plugins/clipper/BarWidget.qml b/noctalia/plugins/clipper/BarWidget.qml new file mode 100644 index 0000000..801719f --- /dev/null +++ b/noctalia/plugins/clipper/BarWidget.qml @@ -0,0 +1,75 @@ +import QtQuick +import Quickshell +import qs.Commons +import qs.Services.UI +import qs.Widgets + +NIconButton { + id: root + + property var pluginApi: null + property ShellScreen screen + property string widgetId: "" + property string section: "" + property int sectionWidgetIndex: -1 + property int sectionWidgetsCount: 0 + + icon: "clipboard-data" + tooltipText: pluginApi?.tr("bar.tooltip") + tooltipDirection: BarService.getTooltipDirection(screen?.name) + baseSize: Style.getCapsuleHeightForScreen(screen?.name) + applyUiScale: false + customRadius: Style.radiusL + + colorBg: Style.capsuleColor + colorFg: Color.mOnSurface + colorBgHover: Color.mHover + colorFgHover: Color.mOnHover + colorBorder: "transparent" + colorBorderHover: "transparent" + + border.color: Style.capsuleBorderColor + border.width: Style.capsuleBorderWidth + + NPopupContextMenu { + id: contextMenu + + model: [ + { + "label": pluginApi?.tr("context.toggle"), + "action": "toggle-clipper", + "icon": "clipboard" + }, + { + "label": pluginApi?.tr("context.settings"), + "action": "open-settings", + "icon": "settings" + }, + ] + + onTriggered: action => { + contextMenu.close(); + PanelService.closeContextMenu(screen); + + if (action === "toggle-clipper") { + if (pluginApi?.togglePanel) { + pluginApi.togglePanel(screen); + } + } else if (action === "open-settings") { + if (pluginApi?.manifest) { + BarService.openPluginSettings(screen, pluginApi.manifest); + } + } + } + } + + onClicked: { + if (pluginApi?.openPanel) { + pluginApi.openPanel(screen, this); + } + } + + onRightClicked: { + PanelService.showContextMenu(contextMenu, root, screen); + } +} diff --git a/noctalia/plugins/clipper/CHANGELOG.md b/noctalia/plugins/clipper/CHANGELOG.md new file mode 100644 index 0000000..44717fe --- /dev/null +++ b/noctalia/plugins/clipper/CHANGELOG.md @@ -0,0 +1,1187 @@ +# Clipper Plugin - Comprehensive Changelog + +## Version 2.4.4 (2026-04-26) + +### Critical data-loss fix: notecards never reached disk + +If you lost a notecard between v2.4.3 and v2.4.4 — I'm sorry. This was my fault. The "atomic write" hotfix shipped in v2.4.3 turned out to be **silently broken** on noctalia-qs and similar Quickshell builds: `Quickshell.execDetached(["sh", "-c", script, ...])` with the multi-arg shell pipeline never actually executed the script. Every `saveNoteCard()` call returned successfully, the note appeared in the panel UI, and a "Note created" toast was shown — but the file never landed in `~/.config/noctalia/plugins/clipper/notecards/`. On the next panel open, `loadNoteCards` saw nothing and the note was gone. + +The same path was used for `pinned.json` and notecard `.txt` exports, so those could vanish too under the same conditions. + +#### Root cause + +- `Quickshell.execDetached` with `["sh", "-c", script, "$0", "$1", "$2", "$3"]` (where `$3` was a long base64 string) silently no-ops in noctalia-qs 0.0.x. The simpler 2–3 argv calls (`mkdir -p`, `find ... -delete`) work; the long-argv save path does not. There is no error, no log, no partial file — the script never starts. +- `loadNoteCards` used `jq -s '.' *.json 2>/dev/null || echo '[]'`, which is **all-or-nothing**: a single 0-byte or malformed file in the directory made jq exit non-zero, the fallback returned an empty array, and the in-memory `noteCards` was wiped — making every other note invisible in the UI even though it was intact on disk. + +#### Fix + +- **New atomic-write engine.** Replaced `Quickshell.execDetached` + base64 with a queued `Process` that pipes the raw payload to `sh -c 'cat > "$1.tmp" && [ -s "$1.tmp" ] && mv -f "$1.tmp" "$1" && { [ -z "$2" ] || [ "$2" = "$1" ] || rm -f "$2"; } || { rm -f "$1.tmp"; exit 1; }'` via stdin. Process gives us a real exit code and stderr, so genuine failures are now logged via `Logger.w` instead of vanishing. The queue serializes saves because Process is single-instance. +- **Resilient loader.** `loadNoteCards` now validates each `*.json` file individually with `jq -e .` and concatenates only the valid ones into the slurp. A single corrupt or 0-byte file no longer wipes the rest. The `onExited` handler also stops blanking `noteCards` on shell errors or empty results — when the disk read gives nothing useful, in-memory state is preserved instead of cleared. +- **No more base64.** Payload travels through stdin, so Polish / Cyrillic / emoji titles and content survive verbatim without escaping concerns. The `stringToBase64` helper and the `atomicWriteBase64` shim are gone. + +Applied to `savePinnedFile`, `saveNoteCard`, `updateNoteCard`, and `exportNoteCard`. Tested by reproducing the disappearing-notecard case (rename + reopen panel) on noctalia-qs 0.0.12 — pre-fix: file vanished; post-fix: file persists across restarts. + +If you can still reproduce a disappearance, please open an issue with `grep Clipper /run/user/$(id -u)/quickshell/by-id/*/log.log | tail -40`. The `atomicWrite FAIL` lines now carry exit code and stderr. + +## Version 2.4.3 (2026-04-22) + +### Data-loss hotfix: atomic writes for pinned items and notecards + +Fixes a data-loss bug that could silently destroy pinned items (`pinned.json`) or individual notecards (`notecards/*.json`) under rare timing conditions. A 0-byte ghost file left on disk was traced back to three compounding issues in the save path: + +1. **Non-atomic shell redirection.** All writes used `base64 -d > "$file"`, which opens the target with `O_TRUNC` **before** decoding and writing. If the process was interrupted between truncate and write (shell restart, kill, OOM, base64 failure), the file was left at 0 bytes with no recovery possible. +2. **Rename race in `updateNoteCard`.** When a note's title changed, the old file was deleted via a parallel `rm` `execDetached` call issued alongside the new `saveNoteCard`. If the `rm` completed but the save failed, the note vanished entirely. +3. **Empty-base64 edge case.** If `stringToBase64()` returned an empty string (e.g. from a malformed note object), the shell pipeline still truncated the target, producing a guaranteed 0-byte file. + +**Fix:** new `atomicWriteBase64(filePath, base64, oldFilePath)` helper in `Main.qml`: + +- Writes to a temp file (`.tmp`) first, verifies it is non-empty (`[ -s "$t" ]`), then atomically renames over the target with `mv -f`. +- Only removes the old file **after** the new file is safely in place (and only when the old path differs from the new path). +- Passes `filePath`, `oldFilePath`, and `base64` via argv (not string interpolation) — shell-injection safe and robust against filenames with spaces/quotes. +- Refuses empty writes up front (`base64.length === 0`) and logs a warning instead of truncating the target. +- `saveNoteCard` now validates `note.id` and `JSON.stringify` length ≥ 10 before writing. + +Applied to `savePinnedFile`, `saveNoteCard`, `updateNoteCard`, and `exportNoteCard`. + +Also: `Component.onCompleted` now sweeps any stale `*.json.tmp` files left over from previous interrupted writes, so a crash mid-hotfix cannot leak temp files indefinitely. + +## Version 2.4.2 (2026-04-22) + +### Fix ReferenceError on plugin teardown + +- Removed a dead `wlCopyProc` reference in `Component.onDestruction` (`Main.qml`) that threw `ReferenceError: wlCopyProc is not defined` when the panel or shell was torn down. The id was left over from an earlier refactor; wl-copy usage already lives in `copyToClipboardProc` and direct `Quickshell.execDetached(["wl-copy", ...])` calls, so no replacement logic is needed. + +## Version 2.4.1 (2026-04-21) + +### Fix Qt.btoa deprecation warnings + +- Replaced three deprecated `Qt.btoa(string)` calls in `Main.qml` (`savePinnedFile`, `exportNoteCard`, `saveNoteCard`) with a new `stringToBase64(str)` helper that encodes the string to UTF-8 bytes as a `Uint8Array` and passes it to the non-deprecated `Qt.btoa(array-like)` overload. +- Simplified the matching `Qt.atob()` call: the array-like overload returns a `Uint8Array` directly, so the intermediate string + `charCodeAt` loop was removed. +- Side benefit: `exportNoteCard()` now writes properly encoded UTF-8 `.txt` files — previously, notes containing non-ASCII characters (Polish, emoji, Cyrillic, etc.) were written with Latin-1 byte encoding. +- No behavior change for existing `pinned.json` / `notecards/*.json` files: `JSON.stringify` already escapes non-ASCII to `\uNNNN`, so the base64 output is effectively identical for ASCII-safe JSON. + +## Version 2.4.0 (2026-04-20) + +### Live-Preview Settings, Panel Size Controls, i18n Cleanup + +**Settings live-preview with revert-on-cancel:** +- All settings changes apply immediately as a live preview via `_applyPreview()` +- Snapshot taken on `Component.onCompleted`; reverted on `Component.onDestruction` if user cancels (Apply not clicked) +- `saveSettings()` sets `_applied = true` to suppress revert + +**New panel size controls (Appearance tab):** +- `NSpinBox` for panel width (400–3840 px, step 50) — hidden in fullscreen mode +- `NSpinBox` for panel height (0–2160 px, step 50, 0 = auto) — hidden in fullscreen mode +- `Panel.qml` now reads `panelWidth`/`panelHeight` from settings instead of hardcoded 1450 + +**Hide Panel Background gating:** +- "Hide Panel Background" toggle and its divider are now hidden when NoteCards are enabled + +**i18n cleanup:** +- Removed all `|| "fallback"` suffixes from `pluginApi?.tr()` calls across all 11 QML files +- Added 4 new translation keys (`panel-width`, `panel-width-desc`, `panel-height`, `panel-height-desc`) to all 17 language files + +**manifest.json:** +- Version bumped to `2.4.0` +- Added `defaultSettings`: `fullscreenMode`, `hidePanelBackground`, `autoPaste`, `autoPasteOnRightClick`, `autoPasteDelay`, `panelWidth`, `panelHeight`, `cardColors`, `customColors` + +## Version 1.4.0 (2026-02-04) + +### NoteCards / Sticky Notes Feature 🎉 + +**Brand new notecards panel with draggable sticky note cards:** +- Create, edit, move, and manage multiple note cards in the middle space +- Persistent storage in `notecards.json` file +- 5 color themes: Yellow, Pink, Blue, Green, Purple +- Auto-save with 500ms debounce after typing stops +- Export individual notes to .txt files in ~/Documents +- Drag-and-drop positioning with boundary enforcement +- Z-index management for overlapping cards +- Maximum 20 notes limit with visual indicators + +**New Files:** +- `NoteCardsCard.qml` (290 lines) - Draggable note card component +- `NoteCardsPanel.qml` (159 lines) - Container for note cards + +**Files Modified:** +- `Main.qml` (+150 lines) - Data model, CRUD functions, IPC handlers +- `Panel.qml` (+20 lines) - Added notecards panel in middle space +- `Settings.qml` (+110 lines) - NoteCards settings section +- `manifest.json` - Version bump to 1.4.0, updated description +- `i18n/*.json` (16 files) - Added notecards translation keys + +**New Functions in Main.qml:** +- `createNoteCardsNote(initialText)` - Create note with cascade positioning +- `updateNoteCardsNote(noteId, updates)` - Update content/position/color +- `deleteNoteCardsNote(noteId)` - Remove note +- `exportNoteCardsNote(noteId)` - Export to ~/Documents/*.txt +- `saveNoteCardsNotes()` - Persist to JSON with base64 encoding +- `bringNoteToFront(noteId)` - Z-index management + +**New IPC Commands:** +```bash +qs -c noctalia-shell ipc call plugin:clipper addNoteCardsNote "Quick note" +qs -c noctalia-shell ipc call plugin:clipper exportNoteCardsNote "note_123_abc" +``` + +**NoteCardsCard Features:** +- Draggable header with grip icon +- Inline text editing with TextArea +- Color picker button (cycles through 5 colors) +- Export button (saves to .txt) +- Delete button +- Automatic boundary enforcement +- Click-to-bring-to-front + +**NoteCardsPanel Features:** +- Empty state UI with "Create Note" button +- Note counter (X/20) with color coding +- Floating "Add Note" button +- Repeater for dynamic note card rendering + +**Settings UI:** +- Enable/disable toggle +- Default color selector +- Current notes count display (X/20) +- Clear all notes button + +**Technical Details:** +- Absolute positioning (x, y coordinates in JSON) +- Fixed size 250x200px (MVP - resizable in future) +- Cascade positioning: 30px offset for new notes +- Storage: `~/.config/noctalia/plugins/clipper/notecards.json` +- Immutable array updates throughout +- Component.onDestruction cleanup for timers + +**notecards.json Schema:** +```json +{ + "notes": [ + { + "id": "note_1709123456789_abc123", + "content": "Note text here...", + "x": 450, + "y": 150, + "width": 250, + "height": 200, + "zIndex": 1, + "color": "yellow", + "createdAt": "2026-02-04T10:30:00Z", + "lastModified": "2026-02-04T11:45:00Z" + } + ] +} +``` + +**Bug Fixes During Implementation:** +- Fixed `qs.Style` import (moved to `qs.Commons`) +- Removed `NToolTip` component (use `tooltipText` property) +- Added missing `qs.Widgets` import +- Changed `size` to `pointSize` for `NIcon` components +- Removed `iconSize` and `applyUiScale` from `NIconButton` +- Replaced spread operator (`...`) with `Object.assign()` and `.slice()` +- Removed `NDropShadow` (not available) +- Replaced optional chaining (`?.`) with explicit conditional checks +- Changed arrow functions `(mouse) =>` to traditional syntax +- Fixed `fontSize`/`fontWeight` to `font.pointSize`/`font.bold` + +**Translation Keys Added (16 languages):** +```json +{ + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note" + }, + "settings": { + "notecards": "NoteCards / Sticky Notes", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes", + "default-note-color": "Default Note Color", + "default-note-color-desc": "Color for newly created notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes" + } +} +``` + +**Known Limitations:** +- Fixed size cards (250x200px) - resizing planned for v1.5.0 +- No rich text formatting - plain text only +- No markdown support - planned for future +- Maximum 20 notes - performance limitation + +--- + +## Version 1.3.2 (2026-02-04) + +### Translation System Overhaul ✅ + +**Fixed i18n structure to comply with Noctalia standards:** +- Removed plugin name prefix from JSON structure +- Changed from `{"clipper": {...}}` to `{"bar": {...}, "panel": {...}}` +- Updated all 16 language files to new structure (de, en, es, fr, hu, ja, ko-KR, nl, pl, pt, ru, sv, tr, uk-UA, zh-CN, zh-TW) +- Updated 37 translation calls across 4 QML files +- All keys now use kebab-case format (e.g., `"panel.search-placeholder"`) +- 100% consistency across all language files (43 keys each) + +**Files modified:** +- `i18n/*.json` (16 files) - Structure update +- `BarWidget.qml` (3 keys) +- `ClipboardCard.qml` (3 keys) +- `Panel.qml` (15 keys) +- `Settings.qml` (16 keys) + +### ToDo Context Menu Fix ✅ + +**Fixed non-functional ToDo button on clipboard cards:** +- Added `screen` property passing from Panel.qml to ClipboardCard delegates +- Fixed context menu positioning to appear at button location +- Implemented proper menu lifecycle management +- Added automatic closing of previous menu when opening new one +- Removed Keys.onPressed handler (NPopupContextMenu is not an Item) + +**Implementation details:** +- Added `property var currentScreen: screen` in Panel.qml +- Added `property var activeContextMenu: null` for tracking open menus +- Changed PanelService.showContextMenu to manual positioning with anchorItem +- Menu now positioned using `mapToItem()` for accurate placement +- MouseArea for click-outside-to-close functionality + +**Files modified:** +- `ClipboardCard.qml` - Added screen property, menuAnchor, activeContextMenu tracking +- `Panel.qml` - Added currentScreen and activeContextMenu properties, passed to delegates + +### Documentation Update ✅ + +**Updated QML development guidelines:** +- Added comprehensive i18n section to `~/.claude/rules/QML-code-reviewer.md` +- Documented standard i18n structure patterns from official Noctalia plugins +- Added 400+ lines of translation best practices and examples +- Included anti-patterns and common mistakes to avoid + +**Changes summary:** +- Total files changed: 21 +- i18n files: 16 updated +- QML files: 4 updated +- Documentation: 1 updated +- Lines changed: ~500 across all files + +--- + +## Version Comparison: GitHub v1.1.0 → Local v1.3.1 + +--- + +## Executive Summary + +**Total Changes:** +- **Main.qml**: +29/-672 lines (Major refactoring - removed ClipboardService.qml integration) +- **Panel.qml**: +190/-193 lines (Architecture overhaul) +- **Settings.qml**: +467/-254 lines (Complete redesign with color customization) +- **BarWidget.qml**: +15/-33 lines (Translation system update) +- **ClipboardCard.qml**: +13/-103 lines (Simplified + ToDo integration) +- **manifest.json**: Version bump + metadata updates + +**New Files:** +- `TodoPageSelector.qml` - Full-screen overlay for ToDo page selection +- `CHANGELOG.md` - Version history documentation +- `pinned.json` - Persistent storage for pinned clipboard items + +**Removed Files:** +- `ClipboardService.qml` - Functionality integrated into Main.qml +- `.gitignore` - Accidentally deleted (should be restored) + +--- + +## Major Feature Additions + +### 1. Pinned Items System (v1.2.0+) +**Impact:** High - Completely new feature + +**Implementation:** +- Persistent pinned clipboard items stored in `pinned.json` +- Support for both text and images (base64 encoded) +- Dedicated vertical panel on left side of screen +- Security improvements: Input validation, size limits +- LRU cache for image previews (max 50 items) + +**Limits:** +- Max 20 pinned items +- Max 5MB per image +- Max 1MB per text item +- Max 10MB for image preview cache + +**New IPC Commands:** +```bash +qs ipc call plugin:clipper pinClipboardItem +qs ipc call plugin:clipper unpinItem +qs ipc call plugin:clipper copyPinned +``` + +**Files Modified:** +- `Main.qml`: Added pinItem(), unpinItem(), copyPinnedToClipboard(), savePinnedFile() +- `Panel.qml`: Added pinnedPanel with ListView for pinned items +- `ClipboardCard.qml`: Added pin button and isPinned state + +--- + +### 2. ToDo Plugin Integration (v1.2.0+) +**Impact:** High - Major new integration + +**Features:** +- Add clipboard items directly to ToDo lists +- Page selection via context menu on clipboard cards +- Primary selection support (add selected text via keybind) +- Full-screen ToDo page selector overlay + +**Implementation:** +- `TodoPageSelector.qml`: New component with cursor-following context menu +- Direct API access via `PluginService.getPluginAPI("todo")` +- No IPC overhead for internal operations +- Support for multiple ToDo pages + +**New IPC Commands:** +```bash +qs ipc call plugin:clipper addSelectionToTodo +``` + +**Removed IPC Commands (replaced by context menu):** +```bash +# These were removed as they're now handled via UI context menu +qs ipc call plugin:clipper addToTodo1..9 +qs ipc call plugin:clipper addToTodo +qs ipc call plugin:clipper addTextToTodo +``` + +**Settings:** +- New toggle: "ToDo Plugin Integration" (auto-detects if ToDo plugin is installed) +- FileView monitoring of `~/.config/noctalia/plugins.json` for availability + +--- + +### 3. Advanced Card Color Customization (v1.3.0+) +**Impact:** High - Complete UI customization system + +**Features:** +- Per-card-type color schemes (Text, Image, Link, Code, Color, Emoji, File) +- Live preview of color changes +- Material Design semantic colors + custom RGB picker +- Separate colors for: background, separator, foreground +- Reset to defaults button + +**Color Options:** +- 15 Material Design colors (mPrimary, mSecondary, mTertiary, etc.) +- Custom RGB color picker for unlimited options +- Colors saved per-card-type in plugin settings + +**UI Components:** +- Live preview card (250x220px) showing real-time changes +- 3 NComboBox selectors (bg, separator, fg) +- 3 conditional NColorPicker widgets for custom colors +- Card type selector dropdown + +**Default Color Schemes:** +```javascript +Text: { bg: "mOutline", separator: "mSurface", fg: "mOnSurface" } +Image: { bg: "mTertiary", separator: "mSurface", fg: "mOnTertiary" } +Link: { bg: "mPrimary", separator: "mSurface", fg: "mOnPrimary" } +Code: { bg: "mSecondary", separator: "mSurface", fg: "mOnSecondary" } +Color: { bg: "mSecondary", separator: "mSurface", fg: "mOnSecondary" } +Emoji: { bg: "mHover", separator: "mSurface", fg: "mOnHover" } +File: { bg: "mError", separator: "mSurface", fg: "mOnError" } +``` + +--- + +### 4. Translation System Overhaul (v1.3.0+) +**Impact:** Medium - Better i18n support + +**Changes:** +- Migrated from `I18n.tr()` to `pluginApi?.tr()` system +- All hardcoded strings replaced with translation keys +- 16 language files added (es, hu, ja, ko-KR, nl, pl, pt, ru, sv, tr, uk-UA, zh-CN, zh-TW) +- Consistent key naming: `clipper.component.key` + +**New Translation Keys Added:** +```json +{ + "clipper": { + "bar": { "tooltip": "..." }, + "context": { "toggle": "...", "settings": "..." }, + "panel": { 30+ keys for all UI elements }, + "card": { "add-todo": "...", "pin": "...", "delete": "..." }, + "settings": { 15+ keys for settings UI } + } +} +``` + +**Files Updated:** +- `BarWidget.qml`: Changed I18n.tr() to pluginApi?.tr() +- `Panel.qml`: All user-facing strings now translatable +- `Settings.qml`: All labels, descriptions, buttons translated +- `ClipboardCard.qml`: Tooltip translations added + +--- + +## Architecture Improvements + +### 1. Removal of ClipboardService.qml +**Impact:** High - Simplified architecture + +**Rationale:** +- Eliminated unnecessary abstraction layer +- Reduced IPC overhead +- Direct function calls from Panel.qml to Main.qml +- Better memory management + +**Migration:** +- All ClipboardService functions moved to Main.qml +- Panel uses `pluginApi?.mainInstance?.functionName()` pattern +- Removed internal IPC calls (anti-pattern per guidelines) + +**Functions Integrated:** +- list(), copyToClipboard(), deleteById(), wipeAll() +- pinItem(), unpinItem(), copyPinnedToClipboard() +- decodeToDataUrl(), getItemType() +- addTodoWithText(), showTodoPageSelector() + +--- + +### 2. Security Enhancements +**Impact:** Critical - Prevented command injection vulnerabilities + +**Fixes:** +- Input validation with regex `/^\d+$/` for all clipboard IDs +- Shell command parameters properly escaped +- Qt.btoa() for safe base64 encoding (no shell metacharacters) +- Process stdin used instead of shell interpolation +- File paths are constant, not user-controlled + +**Vulnerable Patterns Fixed:** +```qml +// BEFORE (v1.1.0 - VULNERABLE) +Quickshell.execDetached(["sh", "-c", "cliphist decode " + id + " | wl-copy"]) + +// AFTER (v1.3.1 - SECURE) +if (!/^\d+$/.test(String(id))) { + Logger.e("clipper", "Invalid clipboard ID: " + id); + return; +} +copyToClipboardProc.command = ["cliphist", "decode", String(id)]; +copyToClipboardProc.running = true; +``` + +**Protected Functions:** +- pinItem(), deleteById(), copyToClipboard(), decodeToDataUrl() + +--- + +### 3. Memory Management +**Impact:** Medium - Leak prevention + +**Improvements:** +- Main.qml: Comprehensive cleanup of 11 Process objects +- TodoPageSelector.qml: Timer cleanup on destruction +- LRU cache with automatic eviction (maxImageCacheSize: 50) +- clearCaches() function for wipe operations + +**Component.onDestruction Added:** +```qml +// Main.qml +Component.onDestruction: { + if (listProc.running) listProc.terminate(); + if (decodeProc.running) decodeProc.terminate(); + // ... 9 more processes +} + +// TodoPageSelector.qml +Component.onDestruction: { + showMenuTimer.stop(); + close(); +} +``` + +--- + +### 4. IPC Interface Cleanup +**Impact:** Medium - Cleaner external API + +**Simplified IPC:** +- Removed 9 numbered functions: `addToTodo1()` through `addToTodo9()` +- Removed generic `addToTodo(pageId: int)` +- Removed `addTextToTodo(text, pageId)` (now internal-only) +- Renamed `addToTodoWithSelector()` → `addSelectionToTodo()` + +**Retained Essential IPC:** +```qml +function toggle() // Required for keybind compatibility +function openPanel() +function closePanel() +function togglePanel() +function pinClipboardItem(cliphistId: string) +function unpinItem(pinnedId: string) +function copyPinned(pinnedId: string) +function addSelectionToTodo() // NEW - replaced 9+ functions +``` + +--- + +## UI/UX Improvements + +### 1. Panel Layout Redesign +**Impact:** High - Better space utilization + +**Changes:** +- Fullscreen transparent overlay (replaces anchored panel) +- Two sub-panels: Pinned (left, vertical) + Clipboard (bottom, horizontal) +- Pinned panel: 300px width (max 20% screen width) +- Clipboard panel: 300px height (max 30% screen height) +- Click outside panels to close + +**Removed Animations:** +- Eliminated `populate`, `add`, `remove`, `displaced`, `move` transitions +- Instant card appearance/disappearance +- No sliding animations on delete +- Improved perceived performance + +**Benefits:** +- Always-visible pinned items +- More horizontal space for clipboard cards +- Better multi-monitor support (fullscreen overlay) + +--- + +### 2. ClipboardCard Enhancements +**Impact:** Medium - Better usability + +**Visual Improvements:** +- Pin indicator icon in top-right corner (for pinned items) +- Unified color scheme (background = header = body) +- Gradient separator between header and content +- Hover effects with color lightening/darkening + +**Interactive Elements:** +- ToDo button now shows context menu (page selection) +- Pin button hidden for already-pinned items +- Delete button always visible +- Tooltips added for all action buttons + +**Type Detection:** +- Enhanced regex patterns for better content classification +- Support for: Text, Image, Link, Code, Color, Emoji, File paths + +--- + +### 3. Settings UI Redesign +**Impact:** High - Professional appearance + +**Layout:** +- Section headers with bold large font +- NDivider separators between sections +- Logical grouping: Integrations, Pinned Items, Appearance + +**New Components:** +- Card type selector dropdown +- Live preview rectangle with sample card +- 3-level color customization (bg, separator, fg) +- RGB color pickers with Material Design palette +- Reset to defaults button + +**Removed:** +- "Auto-open pinned panel" toggle (always-on design) +- "Clear All Pinned Items" button (use individual delete) + +--- + +## Performance Optimizations + +### 1. Image Caching Strategy +**Implementation:** +- LRU (Least Recently Used) cache +- Max 50 images in memory +- Automatic eviction of oldest entries +- Reactive binding via `imageCacheRevision` counter + +**Cache Operations:** +```qml +function addToImageCache(cliphistId, dataUrl) { + // Remove if exists (will re-add at end) + // Evict oldest if at capacity + while (imageCacheOrder.length >= maxImageCacheSize) { + const oldestKey = imageCacheOrder[0]; + // Remove from cache + } + // Add new entry + imageCacheRevision++; // Trigger reactive updates +} +``` + +**Benefits:** +- Reduced cliphist decode calls (expensive operation) +- Faster card rendering for previously-seen images +- Bounded memory usage + +--- + +### 2. Removed Animations +**Impact:** Medium - Faster perceived performance + +**Removed Transitions:** +- `populate`: Initial list population +- `add`: New item insertion +- `remove`: Item deletion (was sliding left) +- `displaced`: Item repositioning +- `move`: Item movement + +**Result:** +- Instant feedback on delete operations +- No visual lag during filtering +- Cleaner appearance during rapid changes + +--- + +## Bug Fixes + +### 1. Command Injection Vulnerabilities (CRITICAL) +**GitHub v1.1.0 Issue:** +```qml +// Vulnerable to shell injection +Quickshell.execDetached(["sh", "-c", "cliphist decode " + id + " | wl-copy"]) +``` + +**Local v1.3.1 Fix:** +```qml +// Input validation + proper command array +if (!/^\d+$/.test(String(id))) { + Logger.e("clipper", "Invalid clipboard ID"); + return; +} +copyToClipboardProc.command = ["cliphist", "decode", String(id)]; +``` + +--- + +### 2. Internal IPC Anti-Pattern (HIGH) +**GitHub v1.1.0 Issue:** +```qml +// Panel.qml calling its own plugin via IPC (anti-pattern) +Process { + command: ["qs", "ipc", "call", "plugin:clipper", "addTextToTodo", ...] +} +``` + +**Local v1.3.1 Fix:** +```qml +// Direct function call +pluginApi?.mainInstance?.addTodoWithText(text, pageId); +``` + +--- + +### 3. Memory Leaks in Process Objects +**GitHub v1.1.0 Issue:** +- No cleanup handlers for Process objects +- Processes continue running after component destruction + +**Local v1.3.1 Fix:** +```qml +Component.onDestruction: { + if (listProc.running) listProc.terminate(); + if (decodeProc.running) decodeProc.terminate(); + // ... all 11 processes terminated +} +``` + +--- + +### 4. Multi-Monitor Support Issues +**Remaining Issue in v1.3.1:** +```qml +// Still uses Quickshell.screens[0] - should use withCurrentScreen() +function toggle() { + const screens = Quickshell.screens; + if (screens && screens.length > 0) { + pluginApi.togglePanel(screens[0]); // Always first screen + } +} +``` + +**Recommended Fix:** +```qml +function toggle() { + if (pluginApi) { + pluginApi.withCurrentScreen(screen => { + pluginApi.togglePanel(screen); + }); + } +} +``` + +--- + +## Configuration Changes + +### manifest.json Updates +```diff +{ + "id": "clipper", + "name": "Clipper", +- "version": "1.1.0", ++ "version": "1.3.1", +- "minNoctaliaVersion": "4.1.1", ++ "minNoctaliaVersion": "4.1.2", + "author": "blackbartblues", ++ "contributors": ["rscipher001"], + "license": "MIT", ++ "repository": "https://github.com/blackbartblues/noctalia-clipper", +- "description": "Clipboard manager with history and search", ++ "description": "Advanced clipboard manager with history, search, keyboard navigation, ToDo integration, and pinned items. Secure and modernized.", +- "tags": ["Clipboard", "Utility"], ++ "tags": ["Clipboard", "Utility", "Bar", "Panel"], + "entryPoints": { + "main": "Main.qml", + "barWidget": "BarWidget.qml", + "panel": "Panel.qml", + "settings": "Settings.qml" + } +} +``` + +--- + +### New Settings Schema +```javascript +pluginSettings: { + // Existing (v1.1.0) + position: "Bottom", + + // New in v1.2.0+ + enableTodoIntegration: false, + + // New in v1.3.0+ + cardColors: { + "Text": { bg: "mOutline", separator: "mSurface", fg: "mOnSurface" }, + "Image": { bg: "mTertiary", separator: "mSurface", fg: "mOnTertiary" }, + // ... 5 more types + }, + customColors: { + "Text": { bg: "#555555", separator: "#000000", fg: "#e9e4f0" }, + "Image": { bg: "#e0b7c9", separator: "#000000", fg: "#20161f" }, + // ... 5 more types + } +} +``` + +--- + +## Testing Recommendations + +### Critical Test Cases: +1. **Security:** Attempt clipboard ID injection: `12345; rm -rf /` +2. **Multi-Monitor:** Test toggle on non-primary screen +3. **Memory:** Monitor RSS after 100+ clipboard operations +4. **Pinned Items:** Test 20-item limit enforcement +5. **ToDo Integration:** Test with ToDo plugin disabled +6. **Image Caching:** Verify LRU eviction after 51 images +7. **Translation:** Test all 16 language files load correctly +8. **Color Customization:** Test custom RGB → save → reload + +### Performance Benchmarks: +- Initial load time: < 500ms +- Image decode & cache: < 200ms per image +- Panel open latency: < 100ms +- Search filter response: < 50ms + +--- + +## Migration Guide (v1.1.0 → v1.3.1) + +### For Users: +1. **Backup existing clipboard data** (cliphist database) +2. Update plugin files +3. Restart Noctalia Shell +4. Configure ToDo integration in Settings (if using ToDo plugin) +5. Customize card colors if desired +6. Update keybinds (if using removed `addToTodo1-9` functions) + +### For Developers: +1. **Remove ClipboardService.qml** - functionality in Main.qml +2. **Update IPC calls** - use new `addSelectionToTodo` instead of numbered functions +3. **Test pinned items** - new persistent storage in `pinned.json` +4. **Verify translations** - all strings now use `pluginApi?.tr()` +5. **Check multi-monitor** - potential issue with screen detection + +### Breaking Changes: +- ClipboardService.qml removed (use Main.qml functions) +- IPC functions `addToTodo1-9` removed (use context menu or `addSelectionToTodo`) +- Settings structure changed (new color customization fields) +- Panel layout changed (fullscreen overlay vs anchored panel) + +--- + +## Known Issues & Future Work + +### Current Issues (v1.3.1): +1. **[HIGH]** Multi-monitor: IpcHandler uses `screens[0]` instead of `withCurrentScreen()` +2. **[MEDIUM]** Duplicated code: getTodoPages() in TodoPageSelector.qml and ClipboardCard.qml +3. **[MEDIUM]** Magic numbers: Hardcoded dimensions (250px, 300px, etc.) +4. **[LOW]** Panel.qml.backup tracked in git (should be in .gitignore) +5. **[LOW]** .gitignore file deleted (should be restored) + +### Planned Features (v1.4.0+): +- [ ] Configurable history limit (50-500 items) +- [ ] Image preview size limit setting +- [ ] Export/import pinned items +- [ ] Clipboard sync across devices +- [ ] Smart categorization using AI +- [ ] Clipboard statistics dashboard + +--- + +## Detailed File Changes + +### Main.qml (+29/-672) +**Major Refactoring:** +- Integrated ClipboardService.qml functionality (removed service layer) +- Added pinned items system with JSON persistence +- Added ToDo integration functions +- Added TodoPageSelector component integration +- Simplified IPC interface (removed 12 functions, added 1) +- Enhanced security with input validation +- Comprehensive Process cleanup in Component.onDestruction + +**New Functions:** +- `pinItem(cliphistId)` - Pin clipboard item to persistent storage +- `unpinItem(pinnedId)` - Remove from pinned items +- `copyPinnedToClipboard(pinnedId)` - Copy pinned item (text or image) +- `savePinnedFile()` - Persist pinned items to JSON +- `showTodoPageSelector(text)` - Show full-screen page selector +- `handleTodoPageSelected(pageId, pageName)` - Handle page selection +- `getSelectionAndShowSelector()` - Get primary selection → show selector + +**Removed Functions:** +- All ClipboardService integration code +- `addToTodo1()` through `addToTodo9()` +- `addToTodo(pageId: int)` +- `addTextToTodo(text: string, pageId: int)` + +**New Properties:** +- `pinnedItems: []` - Array of pinned item objects +- `pinnedRevision: 0` - Reactive update counter +- `imageCache: {}` - LRU cache object +- `imageCacheOrder: []` - LRU eviction tracking +- `imageCacheRevision: 0` - Cache update counter +- `maxPinnedItems: 20` - Hard limit constant +- `maxImageCacheSize: 50` - Cache size limit + +**New Components:** +- `FileView { id: pinnedFile }` - Monitor pinned.json changes +- `Process { id: decodeProc }` - Decode content for pinning +- `Process { id: copyPinnedImageProc }` - Copy pinned images +- `Process { id: copyPinnedTextProc }` - Copy pinned text +- `Process { id: getSelectionForSelectorProcess }` - Get selection for ToDo +- `Variants { TodoPageSelector }` - Todo page selector instances + +--- + +### Panel.qml (+190/-193) +**Architecture Overhaul:** +- Changed from anchored panel to fullscreen transparent overlay +- Added pinned panel (left side, vertical, 300px width) +- Redesigned clipboard panel (bottom, horizontal, 300px height) +- Removed all ListView animations (populate, add, remove, displaced, move) +- Removed internal IPC call to `addTextToTodo` (now direct function call) +- Added click-outside-to-close functionality + +**Layout Changes:** +```qml +// BEFORE (v1.1.0) +NPanel { + anchors.bottom: true + // Single panel, bottom-anchored +} + +// AFTER (v1.3.1) +Item { // Fullscreen transparent + MouseArea { /* click to close */ } + + Rectangle { id: clipboardPanel /* bottom */ } + Rectangle { id: pinnedPanel /* left */ } +} +``` + +**New Features:** +- Pinned items ListView with delete functionality +- ToDo context menu integration (via ClipboardCard) +- Keyboard navigation enhancements +- Search input with escape-to-clear + +**Removed:** +- `todoIpcProcess` - no longer needed (direct calls) +- Button to toggle pinned panel (always visible) +- All transition animations + +**Translation Updates:** +- All UI strings use `pluginApi?.tr()` with fallbacks +- 30+ translation keys added + +--- + +### Settings.qml (+467/-254) +**Complete Redesign:** +- Added card color customization system +- Added live preview component +- Restructured into logical sections +- All strings translated with `pluginApi?.tr()` + +**New Sections:** +1. **Integrations** (unchanged structure, added translations) +2. **Pinned Items** (title only - removed controls) +3. **Appearance** (NEW - 200+ lines) + - Card type selector + - Live preview (250x220px rectangle) + - Background color selector + RGB picker + - Separator color selector + RGB picker + - Foreground color selector + RGB picker + - Reset to defaults button + +**New Properties:** +- `selectedCardType: "Text"` - Currently editing type +- `cardColors: {}` - Per-type color schemes (Material Design keys) +- `customColors: {}` - Per-type RGB values for custom colors +- `defaultCardColors: {}` - Reset reference +- `colorOptions: []` - Dropdown options (15 colors + custom) + +**New Functions:** +- `getColorValue(colorKey, cardType, colorType)` - Resolve color +- `getPreviewBg()`, `getPreviewSeparator()`, `getPreviewFg()` - Preview helpers + +**Removed:** +- `autoOpenPinnedPanel` property and toggle +- "Clear All Pinned Items" button + +--- + +### BarWidget.qml (+15/-33) +**Translation System Migration:** +- Replaced `I18n.tr()` with `pluginApi?.tr()` +- Updated translation key structure +- Maintained context menu functionality + +**Changes:** +```diff +- tooltipText: I18n.tr("clipboard.tooltip-title", "Clipboard History") ++ tooltipText: pluginApi?.tr("clipper.bar.tooltip") || "Clipboard History" + +- "label": I18n.tr("clipboard.toggle-clipper", "Toggle Clipper"), ++ "label": pluginApi?.tr("clipper.context.toggle") || "Toggle Clipper", + +- "label": I18n.tr("clipboard.open-settings", "Open Settings"), ++ "label": pluginApi?.tr("clipper.context.settings") || "Open Settings", +``` + +--- + +### ClipboardCard.qml (+13/-103) +**Simplified & Enhanced:** +- Added ToDo page selector context menu +- Added tooltip translations +- Removed ClipboardService dependency +- Simplified signal handlers + +**New Imports:** +```diff ++ import qs.Services.Noctalia ++ import qs.Services.UI +``` + +**New Features:** +- `getTodoPages()` - Fetch ToDo pages via PluginService +- `buildTodoMenuModel()` - Generate context menu model +- `NPopupContextMenu { id: todoContextMenu }` - Page selection menu +- ToDo button now shows context menu instead of direct IPC call + +**Changes:** +```diff +// BEFORE +onClicked: root.addToTodoClicked() // Signal to parent + +// AFTER +onClicked: { + todoContextMenu.model = root.buildTodoMenuModel(); + PanelService.showContextMenu(todoContextMenu, todoButton, null); +} +``` + +**Translations:** +- Added `pluginApi?.tr()` for tooltips: add-todo, pin, delete + +--- + +### TodoPageSelector.qml (NEW FILE - 134 lines) +**Full-Screen ToDo Page Selector:** +- Transparent fullscreen overlay with cursor tracking +- Context menu positioned at cursor location +- Fetches ToDo pages dynamically from ToDo plugin +- Keyboard support (ESC to cancel) +- Click-outside-to-close functionality + +**Key Features:** +```qml +WlrLayershell.layer: WlrLayer.Overlay +WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + +function show(text) { + selectedText = text; + contextMenu.model = buildMenuModel(); + visible = true; + showMenuTimer.start(); // Wait for compositor hover events +} + +function getTodoPages() { + const todoApi = PluginService.getPluginAPI("todo"); + return todoApi?.pluginSettings?.pages || [{ id: 0, name: "General" }]; +} +``` + +**Signals:** +- `pageSelected(int pageId, string pageName)` - User selected a page +- `cancelled()` - User cancelled selection + +--- + +## Statistics + +### Code Metrics: +- **Total Lines Changed:** 1,151 (709 additions, 442 deletions) +- **Files Modified:** 6 +- **Files Added:** 3 (TodoPageSelector.qml, CHANGELOG.md, pinned.json) +- **Files Removed:** 1 (ClipboardService.qml) +- **Functions Added:** 15+ +- **Functions Removed:** 20+ +- **Translation Keys Added:** 50+ +- **IPC Functions Removed:** 12 +- **IPC Functions Added:** 1 + +### Security Improvements: +- **Command Injection Fixes:** 6 functions +- **Input Validation Added:** 4 functions +- **Memory Leak Fixes:** 11 Process objects +- **Size Limit Enforcements:** 4 limits + +### Feature Additions: +- **Major Features:** 3 (Pinned Items, ToDo Integration, Color Customization) +- **UI Components:** 8 (TodoPageSelector, PinnedPanel, ColorPickers, etc.) +- **Settings Options:** 15+ (color schemes, integration toggles) + +--- + +## Compatibility Matrix + +| Feature | v1.1.0 | v1.3.1 | Breaking? | +|---------|--------|--------|-----------| +| Basic clipboard history | ✅ | ✅ | No | +| Search & filter | ✅ | ✅ | No | +| Keyboard navigation | ✅ | ✅ | No | +| Pinned items | ❌ | ✅ | No | +| ToDo integration | ❌ | ✅ | No | +| Color customization | ❌ | ✅ | No | +| ClipboardService.qml | ✅ | ❌ | **Yes** | +| IPC addToTodo1-9 | ❌ | ❌ | No (never existed) | +| I18n.tr() translations | ✅ | ❌ | **Yes** (use pluginApi?.tr()) | +| Bottom-anchored panel | ✅ | ❌ | **Yes** (fullscreen overlay) | +| Animation transitions | ✅ | ❌ | **Yes** (instant updates) | + +--- + +## Recommendations + +### For Immediate Deployment (v1.3.1): +✅ **Safe to deploy** with the following caveats: +1. Test multi-monitor setup thoroughly (potential screen detection issue) +2. Document IPC changes for users with custom keybinds +3. Backup existing clipboard data before migration +4. Restore `.gitignore` file to prevent tracking backup files + +### Priority Fixes for v1.3.2: +1. **[HIGH]** Fix IpcHandler screen detection (use `withCurrentScreen()`) +2. **[HIGH]** Add Component.onDestruction to Panel, Settings, ClipboardCard, BarWidget +3. **[MEDIUM]** Refactor duplicated getTodoPages() function +4. **[MEDIUM]** Replace magic numbers with Style constants +5. **[LOW]** Remove Panel.qml.backup from repository +6. **[LOW]** Restore .gitignore file + +### Long-term Improvements (v1.4.0+): +- History limit configuration (requested feature) +- Pinned items export/import +- Clipboard statistics dashboard +- AI-powered categorization +- Cross-device sync + +--- + +## Conclusion + +The Clipper plugin has evolved significantly from v1.1.0 to v1.3.1: + +**Strengths:** +- ✅ Massive feature additions (pinned items, ToDo integration, color customization) +- ✅ Critical security vulnerabilities fixed (command injection prevention) +- ✅ Better architecture (removed unnecessary service layer) +- ✅ Comprehensive translation support (16 languages) +- ✅ Improved UX (context menus, live previews, instant updates) + +**Weaknesses:** +- ⚠️ Multi-monitor support regression (fixable) +- ⚠️ Code duplication in some areas +- ⚠️ Missing cleanup handlers in some components +- ⚠️ Backup files tracked in version control + +**Overall Assessment:** +**RECOMMEND DEPLOYMENT** with priority fixes scheduled for v1.3.2. + +The new features significantly enhance usability, and the security fixes are critical. The identified issues are minor and can be addressed in a follow-up release. + +--- + +**Generated:** 2026-02-04 +**Comparison:** GitHub noctalia-plugins/clipper v1.1.0 ↔ Local v1.3.1 +**Total Analysis Time:** ~15 minutes +**Review Status:** ✅ APPROVED with recommendations + +--- + +## Translation Files Update (v1.3.1) + +**Complete i18n Implementation:** +- All 16 language files updated to new structure +- Unified `clipper.section.key` format +- 42 translation keys per language +- All files synchronized with en.json structure + +**Supported Languages:** +1. English (en.json) +2. German (de.json) +3. Spanish (es.json) +4. French (fr.json) +5. Hungarian (hu.json) +6. Japanese (ja.json) +7. Korean (ko-KR.json) +8. Dutch (nl.json) +9. Polish (pl.json) +10. Portuguese (pt.json) +11. Russian (ru.json) +12. Swedish (sv.json) +13. Turkish (tr.json) +14. Ukrainian (uk-UA.json) +15. Chinese Simplified (zh-CN.json) +16. Chinese Traditional (zh-TW.json) + +**Migration from old structure:** +- Old: `panel.test` → New: `clipper.panel.title` +- Old: `bar_widget.tooltip` → New: `clipper.bar.tooltip` +- Consistent hyphenated keys (no underscores) diff --git a/noctalia/plugins/clipper/CHANGELOG_v2.0.0.md b/noctalia/plugins/clipper/CHANGELOG_v2.0.0.md new file mode 100644 index 0000000..f3e99ff --- /dev/null +++ b/noctalia/plugins/clipper/CHANGELOG_v2.0.0.md @@ -0,0 +1,274 @@ +# Clipper Plugin - Changelog + +## Version 2.0.0 (2026-02-05) + +### 🎯 Major Changes + +**New Feature: Add Selection to NoteCard** +- New IPC command: `addSelectionToNoteCard` +- Captures selected text and presents context menu to add to existing notes or create new note +- Text is automatically formatted as bullet points with "- " prefix +- Integrated with Hyprland keybind: Super+V, X (chord) + +**Translation System Overhaul** +- Migrated from `I18n.tr()` to `pluginApi?.tr()` across all files +- Added comprehensive `i18n/en.json` with 28 toast message keys +- All user-facing strings now use translation system with fallbacks +- Translation key structure: `component.key` (e.g., `toast.note-created`) + +**Architecture Improvements** +- Fixed IPC handlers to use `pluginApi.withCurrentScreen()` instead of direct `Quickshell.screens[0]` access +- Added proper Component.onDestruction cleanup for 6 data structures (pinnedItems, noteCards, items, firstSeenById, imageCache, imageCacheOrder) +- Memory leak prevention with proper process termination +- Unified translation syntax: `pluginApi?.tr("key") || "fallback"` + +### 📁 New Files + +1. **NoteCardSelector.qml** (173 lines) + - Fullscreen overlay for note selection menu + - Synchronously loads notecards from files + - Shows "Create New Note" + list of existing notes + - Mouse-position-aware context menu + - ESC to cancel, click outside to close + +2. **NoteCard.qml** (~300 lines) + - Updated visual style to match ClipboardCard + - Icon + title left-aligned (not centered) + - Transparent buttons without background + - Increased font sizes (13-14px) + - Auto-height expansion based on content + - Drag handle on icon only (24px width) + - All corners rounded on main card + - Only top corners rounded on header + +3. **NoteCardsPanel.qml** (updated) + - Background click-to-close functionality + - Controls moved to top-left corner + - Note counter and add button repositioned + - Starting Y position increased to 80px to avoid control overlap + +### 🔧 Files Modified + +**Main.qml** (+250 lines, massive refactoring) + +*New IPC Handlers:* +- `addSelectionToNoteCard()` - Main entry point for feature +- Uses same pattern as `addSelectionToTodo()` for consistency + +*New Functions:* +- `getSelectionAndShowNoteSelector()` - Triggers selection capture +- `showNoteCardSelector(text)` - Displays note selection menu +- `handleNoteCardSelected(noteId, noteTitle)` - Handles existing note selection +- `handleCreateNewNoteFromSelection()` - Creates new note with bullet text +- `appendTextToNoteCard(noteId, text)` - Adds bullet point to existing note + +*New Process:* +- `getSelectionForNoteSelectorProcess` - Captures primary selection via wl-paste +- StdioCollector: `noteSelectionStdout` +- Calls `showNoteCardSelector()` on successful capture + +*New Variants:* +- `NoteCardSelector` instances for each screen +- Registered as `noteCardSelector` property +- Connected to signal handlers for note selection/creation + +*New Properties:* +- `noteCardSelector: null` - Reference to selector instance +- `pendingNoteCardText: ""` - Stores captured selection + +*Translation Updates:* +- All 28 Toast messages now use `pluginApi?.tr()` +- String interpolation for dynamic values ({max}, {fileName}, {count}) +- Consistent error handling with translated messages + +*Bug Fixes:* +- Fixed `createNoteCard()` to call `saveNoteCard()` after creation +- Removed duplicate `saveNoteCard(newNote)` calls from wrong functions +- Fixed Component.onDestruction syntax error (duplicate `}`) + +**i18n/en.json** (+30 keys) + +*New Toast Section:* +```json +"toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "note-created": "Note created", + "note-not-found": "Note not found", + "text-added-to-note": "Text added to note", + "could-not-open-note-selector": "Could not open note selector", + // ... 22 more keys +} +``` + +**NoteCard.qml** (visual redesign) +- Header layout changed from centered to left-aligned +- Icon (note) + title in RowLayout +- Drag handle isolated to icon Item (24px width) +- Separate MouseArea for title focus +- Transparent button styling: `colorBg: "transparent"` +- Font changes: removed bold, increased size to 14px +- Radius system: + - Main card: `radius: Style.radiusM` (all corners) + - Header: `topLeftRadius/topRightRadius: Style.radiusM`, bottom: 0 +- Auto-height expansion via ScrollView content measurement +- Size changed: 300x180 → 350x280px +- Title input wider, better usability + +**NoteCardsPanel.qml** +- Background MouseArea with `z: -1` for click-to-close +- Controls repositioned to `anchors.top` + `anchors.left` +- Note counter + add button in top-left corner +- 16px margins from edges + +**ClipboardCard.qml** +- Applied same radius pattern as NoteCard +- Main card: all corners rounded +- Header: top corners only + +**BarWidget.qml** +- Migrated `I18n.tr()` → `pluginApi?.tr()` +- Keys: `bar.tooltip`, `context.toggle`, `context.settings` + +**Settings.qml** +- Already had full translation coverage +- Verified all labels use `pluginApi?.tr()` + +### 🎨 UI/UX Changes + +**NoteCard Visual Updates:** +- More modern, cleaner look matching ClipboardCard +- Better readability with larger fonts +- Improved drag interaction (icon-only handle) +- Responsive to global radius settings (Style.radiusM) +- Auto-expands height based on content + +**NoteCardsPanel Improvements:** +- Better spatial organization (controls top-left) +- No accidental close when clicking on notes +- Clear visual hierarchy + +**Context Menu for Note Selection:** +- Appears at cursor position +- Smooth 150ms delay for compositor +- First option always "Create New Note" +- Separator before existing notes +- Note icons for visual distinction + +### ⚙️ Technical Improvements + +**Memory Management:** +- Cleanup of 6 data structures in Component.onDestruction +- Process termination for `getSelectionForNoteSelectorProcess` +- Proper array clearing: `items = []`, `imageCache = {}` + +**Code Quality:** +- Consistent translation syntax across codebase +- No hardcoded user-facing strings +- Proper null-safe operators: `pluginApi?.tr()` +- Immutable array updates maintained + +**IPC Architecture:** +- Correct use of `pluginApi.withCurrentScreen()` +- No direct `Quickshell.screens` access in IPC handlers +- Consistent pattern with existing `addSelectionToTodo` + +### 🐛 Bug Fixes + +1. **Syntax Errors:** + - Fixed duplicate `}` in Component.onDestruction + - Removed extra closing braces in function declarations + - Fixed missing `} catch(e) {` block in loadNoteCardsProc + +2. **Runtime Errors:** + - Fixed `ReferenceError: newNote is not defined` by removing invalid `saveNoteCard(newNote)` calls from functions that don't have access to `newNote` + - Only `createNoteCard()` calls `saveNoteCard(newNote)` now + +3. **Functional Issues:** + - Fixed note selector not showing existing notes (timing issue with async loading) + - Fixed "Create New Note" not working (signal connection verified) + - Fixed notes not being saved to files (added `saveNoteCard()` call) + +### 📝 Usage Examples + +**IPC Commands:** +```bash +# Add selected text to note via keybind +qs -c noctalia-shell ipc call plugin:clipper addSelectionToNoteCard + +# Or with dev repo +qs -c ~/dev/repo/noctalia ipc call plugin:clipper addSelectionToNoteCard +``` + +**Hyprland Keybind (chord):** +```bash +# In ~/.config/hypr/keybind.conf +binds = SUPER_L, V&C, exec, qs -c ~/dev/repo/noctalia ipc call plugin:clipper addSelectionToTodo +binds = SUPER_L, V&X, exec, qs -c ~/dev/repo/noctalia ipc call plugin:clipper addSelectionToNoteCard +``` + +**User Flow:** +1. Select text anywhere +2. Press Super+V (opens chord mode) +3. Press X (triggers addSelectionToNoteCard) +4. Context menu appears at cursor +5. Click "Create New Note" or select existing note +6. Text is added as bullet point: `- selected text` + +### 📊 Statistics + +**Lines Changed:** +- Main.qml: +250 lines +- NoteCard.qml: ~300 lines (redesigned) +- NoteCardSelector.qml: +173 lines (new) +- NoteCardsPanel.qml: +50 lines +- i18n/en.json: +30 keys +- Total: ~800 lines added/modified + +**Files:** +- New: 1 file (NoteCardSelector.qml) +- Modified: 7 files +- Total: 8 files changed + +**Translation Coverage:** +- 28 new toast message keys +- 100% coverage for user-facing strings +- All components use pluginApi?.tr() + +### 🔄 Breaking Changes + +**Translation System:** +- Old: `I18n.tr("clipboard.key", "Fallback")` +- New: `pluginApi?.tr("component.key") || "Fallback"` +- All plugins must update if they were using I18n + +**IPC Handlers:** +- Now use `pluginApi.withCurrentScreen()` instead of `Quickshell.screens[0]` +- More robust multi-screen support + +### 🚀 Next Steps (Future Enhancements) + +1. **i18n Completion:** + - Translate en.json to all other language files (de, es, fr, etc.) + - Maintain identical structure across all languages + +2. **Feature Enhancements:** + - Resizable note cards + - Rich text formatting + - Note categories/tags + - Search functionality + +3. **Performance:** + - Lazy loading for large note collections + - Virtual scrolling if >20 notes + +4. **Integration:** + - Share notes between devices + - Sync with cloud services + +--- + +**Version:** 2.0.0 +**Date:** 2026-02-05 +**Author:** Development Team +**Reviewed by:** Code Review (QML-code-reviewer.md) diff --git a/noctalia/plugins/clipper/CHANGELOG_v2.1.0.md b/noctalia/plugins/clipper/CHANGELOG_v2.1.0.md new file mode 100644 index 0000000..4b93fe0 --- /dev/null +++ b/noctalia/plugins/clipper/CHANGELOG_v2.1.0.md @@ -0,0 +1,73 @@ +# Changelog v2.1.0 + +## Release Date +2026-02-08 + +## New Features + +### Show Close Button +- Added optional X button in top-right corner of panel header +- Toggle available in Settings > General +- Provides alternative way to close the panel +- Button styled with error color on hover for clear visual feedback + +## Bug Fixes + +### Clipboard Selection Fix +- Fixed critical bug where only the last copied item could be selected from history +- Fixed issue where clipboard items couldn't be copied back to system clipboard +- Changed from two-process approach to direct shell pipe: `cliphist decode ID | wl-copy` +- Removed non-existent `stdout.data` API usage + +### Note Persistence Fix +- Fixed bug where notes didn't persist after reopening panel +- Added missing `saveNoteCard()` call in `updateNoteCard()` function +- Notes now properly save to disk when modified + +## Improvements + +### Settings UI Enhancement +- Reorganized settings into tabbed interface using NTabs +- **General Tab**: Integrations, Features (PinCards/NoteCards toggles), Show Close Button +- **Appearance Tab**: Card color customization +- Improved settings organization and discoverability +- Added feature toggles with item counters showing current usage + +### Translation System Synchronization +- Synchronized all 16 language files with consistent structure +- Removed unused translation keys +- All translation files now have identical key structure +- Maintained existing translations while adding new keys + +### Code Quality +- Removed experimental "Close on Background Click" feature (not implementable in current Noctalia architecture) +- Cleaned up debug logging +- Removed temporary backup files +- Improved code organization + +## Technical Details + +### Fixed Functions +- `copyToClipboard()` in Main.qml (line 742-755) +- `updateNoteCard()` in Main.qml (added saveNoteCard call) + +### Modified Components +- Settings.qml: Restructured with NTabBar/NTabView +- Panel.qml: Added optional close button with reactive visibility +- i18n/*.json: All 16 language files synchronized + +### Removed Features +- "Close on Background Click" toggle and related code +- Panel behavior section in settings +- Related translation keys and properties + +## Breaking Changes +None - all changes are backward compatible + +## Known Issues +None + +## Upgrade Notes +- Plugin will automatically migrate settings +- No user action required +- Existing pinned items and notecards will be preserved diff --git a/noctalia/plugins/clipper/CHANGELOG_v2.2.3.md b/noctalia/plugins/clipper/CHANGELOG_v2.2.3.md new file mode 100644 index 0000000..adbe00c --- /dev/null +++ b/noctalia/plugins/clipper/CHANGELOG_v2.2.3.md @@ -0,0 +1,70 @@ +# Changelog v2.2.3 + +## Release Date +**2026-03-16** + +## New Features + +#### Control Center Shortcut +- Added Control Center Widget shortcut to Control Center + +## Bug Fixes + +#### Clear All Notes Fix +- Fixed the bug where you click Clear All Notes and reopen the plugin panel but the notes are still there + +#### Panel Corners Fix +- Fixed the plugin's panel bottom corners has square corners when the top is rounded + +#### Export Notes Fix +- Fixed you can export a Note many times but when deleting you can only delete one so the other notes still stucking inside your `~/Documents/` directory. Now you can still export a note many times, changing the contents and all the notes were exported from that one note will all be deleted + +#### Applying Settings +- Settings will now only apply when you click the `Apply` button instead of immediately applying the settings (this is to make use of the apply button) + +#### Panel Close Button +- Fixed an issue where clicking the NoteCards panel would close the plugin panel even when the Close Button was enabled. The panel now only closes on background click when the Close Button is disabled. When enabled, use the Close Button or click outside the plugin's panel to close the panel. + +#### Shell Injection via File Paths +- Fixed potential RCE in `deleteNoteCard()` where `exportedFiles` entries from stored JSON were passed directly to `rm` without validation, each filename is now checked against a strict pattern before deletion +- Fixed `clearAllNoteCards()` where a dangling `safePattern` validation referenced undefined variables and an unsafe `sh -c` glob command still ran beneath it, replaced with a loop over each note's tracked `exportedFiles`, validated per entry, with no shell involved +- Escaped the dot in the safe pattern regex from `.txt` to `\.txt` to match a literal dot instead of any character + +#### Empty Catch Blocks +- Fixed empty `catch (e) {}` blocks in `Settings.qml` `onCompleted` that silently swallowed JSON parse failures, both `cardColors` and `customColors` catch blocks now log via `Logger.w` + +#### Duplicate Settings Loading +- Fixed `cardColors` being loaded twice in `Settings.qml` `onCompleted`, removed the first incomplete block that lacked `pendingCardColors` assignment and kept the second complete one + +## Improvements + +#### UI Enhancement +- Panel will now attach to bar instead of being separate +- Decrease abit of panel's width and height for a cleaner look +- Added active ring, badge counts for filter buttons + +#### Keybind Changes +- Pressing `Alt+1` will now navigate you back to `All` instead of `Alt+0`, `Alt+2` is `Text` and goes on + +#### Clipboard Mouse Wheel Scroll +- You can now use the mouse wheel scroll to navigate left and right between clipboards + +#### Note Card and Pinned Item Separator +- Added a separator between Note Card and Pinned Item + +#### Panel Close Button +- Moved the Close Button from next to Open Settings to the panel's top right corner + +## Breaking Changes +- Might be - please report if there are bugs + +## Known Issues +- After adding a clipboard into ToDo plugin you can't check the complete box or remove that clipboard from ToDo + +## Upgrade Notes +- Plugin will automatically migrate settings +- No user action required +- Existing pinned items and notecards will be preserved + +## To Do +- Add `Annotation Tool` support for Images clipboards diff --git a/noctalia/plugins/clipper/CHANGELOG_v2.3.0.md b/noctalia/plugins/clipper/CHANGELOG_v2.3.0.md new file mode 100644 index 0000000..b2dc36a --- /dev/null +++ b/noctalia/plugins/clipper/CHANGELOG_v2.3.0.md @@ -0,0 +1,76 @@ +# Changelog v2.3.0 + +## Release Date +**2026-03-19** + +## New Features + +#### Fullscreen Mode +- New toggle in Settings → Features: **Fullscreen Mode** +- Expands the panel to fill the entire screen vertically and horizontally +- Clipboard panel (cliphist) stays anchored to the bottom +- PinCards and NoteCards remain fully accessible in fullscreen +- Panel width expands to full screen width (`screen.width`) +- Panel height expands to full screen height (`screen.height`) + +#### Hide Panel Background +- New toggle in Settings → Features: **Hide Panel Background** +- Makes the SmartPanel background transparent — cards float over the desktop blur or wallpaper +- Clipboard panel (cliphist) and PinCards retain their own background for readability +- Clicking anywhere on the transparent background closes the panel (backdrop dismiss) +- Works best combined with Noctalia's background blur effect + +#### Auto-Paste on Click +- New toggle in Settings → Auto-Paste: **Auto-Paste on Click** +- After selecting a clipboard item, the panel closes and the content is automatically pasted into the focused window +- Uses `wtype -M ctrl -M shift v` (Ctrl+Shift+V) — works in terminals and most applications +- **Requires `wtype`** — install with `sudo pacman -S wtype` (available in cachyos-extra-v3 / Arch AUR) +- Warning box appears in settings when `wtype` is not installed + +#### Auto-Paste Right-Click Mode +- Sub-toggle: **Right-Click Only** (visible when Auto-Paste is enabled) +- When enabled: left-click copies to clipboard normally; right-click copies + pastes +- When disabled: left-click triggers auto-paste; right-click does nothing special +- Works on both clipboard list items and pinned items + +#### Auto-Paste Delay Slider +- Slider: **Paste Delay (ms)** — range 100–1000 ms, step 50, default 300 ms +- Allows tuning the delay between panel close and paste trigger +- Increase if your compositor uses focus-follows-cursor (focus needs time to return to target window) + +## Improvements + +#### NSlider component fix +- Replaced standard Qt `Slider` component with Noctalia's `NValueSlider` widget +- Consistent look and feel with the rest of the settings UI + +## i18n + +#### 12 new translation keys added (all 17 languages) +- `settings.fullscreen-mode` / `settings.fullscreen-mode-desc` +- `settings.hide-panel-background` / `settings.hide-panel-background-desc` +- `settings.auto-paste-section` +- `settings.auto-paste` / `settings.auto-paste-desc` +- `settings.auto-paste-warning` +- `settings.auto-paste-rmb` / `settings.auto-paste-rmb-desc` +- `settings.auto-paste-delay` / `settings.auto-paste-delay-desc` + +Full translations provided for: de, es, fr, pl. English fallback for all other languages. +Italian (`it.json`) — new language file added. + +## Technical Notes + +- `Panel.qml`: Added `panelBackgroundColor` property — allows plugin to override SmartPanel background color reactively +- `PluginPanelSlot.qml` (Noctalia core): Added forwarding of `panelBackgroundColor` from plugin instance to SmartPanel +- `Main.qml`: Added `wtypeAvailable` detection via `which wtype` on startup; `autoPasteTimer` + `autoPasteProc` with full cleanup in `Component.onDestruction` +- `ClipboardCard.qml`: Added `signal rightClicked`; both MouseAreas updated to handle `Qt.RightButton` + +## Known Issues +- After adding a clipboard into ToDo plugin you can't check the complete box or remove that clipboard from ToDo +- Auto-Paste uses Ctrl+Shift+V which works in terminals; for GUI-only apps Ctrl+V would be sufficient — a "Terminal Mode" toggle may be added in a future release + +## Upgrade Notes +- Plugin will automatically migrate settings — three new fields added to `settings.json`: `fullscreenMode`, `hidePanelBackground`, `autoPaste`, `autoPasteOnRightClick`, `autoPasteDelay` +- No user action required +- Existing pinned items and notecards are preserved +- `wtype` must be installed separately for Auto-Paste to function diff --git a/noctalia/plugins/clipper/CODE_REVIEW_v2.0.0.md b/noctalia/plugins/clipper/CODE_REVIEW_v2.0.0.md new file mode 100644 index 0000000..c3c7190 --- /dev/null +++ b/noctalia/plugins/clipper/CODE_REVIEW_v2.0.0.md @@ -0,0 +1,400 @@ +# Clipper Plugin - Code Review Report v2.0 + +**Date:** 2026-02-05 +**Reviewer:** QML Code Reviewer (following QML-code-reviewer.md) +**Plugin:** Clipper v2.0.0 +**Status:** ✅ **APPROVED** - No CRITICAL or HIGH issues found + +--- + +## Executive Summary + +The Clipper plugin has been thoroughly reviewed against Noctalia development standards. The codebase demonstrates **excellent architecture**, proper **IPC patterns**, comprehensive **translation system**, and appropriate **memory management**. + +**Overall Grade:** ⭐⭐⭐⭐⭐ (5/5) + +--- + +## Review Criteria Results + +### ✅ Architecture (PASS) +- [x] No internal IPC calls - all IPC is external-facing only +- [x] Required `toggle()` function present in IpcHandler +- [x] Proper use of `pluginApi.withCurrentScreen()` (no direct Quickshell.screens access) +- [x] Clean separation of concerns (BarWidget, Panel, Settings, Main) +- [x] IpcHandler target matches plugin name: `plugin:clipper` + +### ✅ Memory Management (PASS) +- [x] Component.onDestruction in Main.qml with comprehensive cleanup +- [x] 13 Process objects properly terminated +- [x] 6 data structures cleared (pinnedItems, noteCards, items, firstSeenById, imageCache, imageCacheOrder) +- [x] NoteCardSelector has proper cleanup (Timer stopped) +- [x] No memory leak patterns detected + +### ✅ Translation System (PASS) +- [x] All user-facing strings use `pluginApi?.tr()` with fallbacks +- [x] i18n/en.json exists with proper structure (no plugin name prefix) +- [x] All keys use kebab-case format (no snake_case) +- [x] 28 toast message keys properly organized +- [x] Consistent translation syntax across all files +- [x] String interpolation uses `{key}` syntax + +### ✅ IPC Interface (PASS) +- [x] 11 IPC functions properly implemented +- [x] All functions use `pluginApi.withCurrentScreen()` pattern +- [x] Clear documentation comments for each IPC command +- [x] External-facing only (no internal Process calls to own IPC) + +### ✅ Code Quality (PASS) +- [x] Consistent naming conventions +- [x] Proper error handling with translated messages +- [x] No console.log in production code +- [x] Settings properly use `pluginApi.pluginSettings` +- [x] immutable array updates throughout + +--- + +## Detailed Findings + +### 🟢 STRENGTHS + +#### 1. Exceptional IPC Architecture +**Main.qml:870-945** +```qml +IpcHandler { + target: "plugin:clipper" + + function openPanel() { + if (root.pluginApi) { + root.pluginApi.withCurrentScreen(screen => { + root.pluginApi.openPanel(screen); + }); + } + } + + function toggle() { + togglePanel(); + } + + // ... 9 more well-documented functions +} +``` +**Why this is good:** +- Correct use of `pluginApi.withCurrentScreen()` instead of `Quickshell.screens[0]` +- Required `toggle()` function present for keybind compatibility +- All functions external-facing with usage documentation +- Follows Noctalia best practices perfectly + +#### 2. Comprehensive Memory Management +**Main.qml:1143-1159** +```qml +Component.onDestruction: { + // Terminate 13 processes + if (listProc.running) listProc.terminate(); + if (decodeProc.running) decodeProc.terminate(); + // ... 11 more processes + + // Clear 6 data structures + pinnedItems = []; + noteCards = []; + items = []; + firstSeenById = {}; + imageCache = {}; + imageCacheOrder = []; +} +``` +**Why this is good:** +- Prevents memory leaks by terminating all background processes +- Clears large data structures +- Follows memory leak prevention best practices +- Will not grow memory usage over time + +#### 3. Perfect Translation System +**i18n/en.json Structure:** +```json +{ + "bar": { "tooltip": "..." }, + "panel": { ... }, + "toast": { ... }, + "settings": { ... } +} +``` +**Why this is good:** +- No plugin name prefix (correct pattern) +- Component-based organization +- All keys use kebab-case +- 28 toast messages properly translated +- String interpolation with `{key}` syntax + +**Usage:** +```qml +// BarWidget.qml:16 +tooltipText: pluginApi?.tr("bar.tooltip") || "Clipboard History" + +// Main.qml:239 +ToastService.showWarning((pluginApi?.tr("toast.max-pinned-items") || "Maximum {max} pinned items reached").replace("{max}", maxPinnedItems)); +``` + +#### 4. New Feature: addSelectionToNoteCard +**Implementation Quality: Excellent** +- Follows same pattern as `addSelectionToTodo` (consistency) +- Proper Process for selection capture (`wl-paste`) +- NoteCardSelector component properly isolated +- Signal-based communication between components +- Bullet point formatting implemented correctly + +--- + +## Medium Priority Recommendations + +### [MEDIUM] Missing Component.onDestruction in Minor Components +**Files:** NoteCard.qml, NoteCardsPanel.qml, ClipboardCard.qml, Panel.qml + +**Current Status:** These components don't have timers or processes, so no memory leaks. + +**Recommendation:** Add empty Component.onDestruction for future-proofing: +```qml +Component.onDestruction: { + // Cleanup will go here if timers/processes are added +} +``` + +**Priority:** Medium (not urgent, but good practice) + +--- + +### [MEDIUM] Debug Logging Still Present +**Files:** Main.qml, NoteCardSelector.qml + +**Found:** +```qml +Logger.i("clipper", "handleCreateNewNoteFromSelection called, pendingText: " + root.pendingNoteCardText); +Logger.i("NoteCardSelector", "onTriggered called, action: " + action); +Logger.i("NoteCardSelector", "Emitting createNewNote signal"); +``` + +**Recommendation:** Remove debug logging before production release: +```qml +// Remove these lines: +// Logger.i("clipper", "handleCreateNewNoteFromSelection called..."); +// Logger.i("NoteCardSelector", "onTriggered called..."); +``` + +**Priority:** Medium (helps performance, reduces log noise) + +--- + +## Low Priority Suggestions + +### [LOW] Translation File Synchronization +**Current Status:** Only `i18n/en.json` has full translation coverage (28 toast keys) + +**Recommendation:** Update other language files to match en.json structure: +```bash +cd ~/.config/noctalia/plugins/clipper/i18n +# Copy en.json structure to all language files +for lang in de es fr it pt nl ru ja zh-CN; do + # Manually translate or use i18n service + # Ensure same structure: jq 'keys' en.json == jq 'keys' $lang.json +done +``` + +**Priority:** Low (English fallbacks work, but full i18n is better UX) + +--- + +### [LOW] Magic Numbers in NoteCard +**File:** NoteCard.qml + +**Found:** +```qml +width: 350 +height: 280 +Layout.preferredWidth: 24 // drag handle +font.pixelSize: 14 +font.pixelSize: 13 +``` + +**Recommendation:** Use Style constants: +```qml +width: 350 // OK - card size, not a style constant +height: 280 // OK +Layout.preferredWidth: Style.iconSizeM // if exists +font.pixelSize: Style.fontSizeM +``` + +**Priority:** Low (current values are reasonable, not critical) + +--- + +## File-by-File Analysis + +### Main.qml ✅ EXCELLENT +- **Lines:** ~1170 +- **IPC Functions:** 11 (all properly implemented) +- **Memory Management:** Perfect (13 processes + 6 data structures) +- **Translations:** 28 toast keys, all using pluginApi?.tr() +- **Architecture:** Clean, well-organized, follows all best practices + +**Highlights:** +- `addSelectionToNoteCard` implementation excellent +- Process management exemplary +- No internal IPC calls +- Proper use of pluginApi throughout + +--- + +### NoteCardSelector.qml ✅ GOOD +- **Lines:** ~173 +- **Purpose:** Fullscreen overlay for note selection +- **Memory Management:** Timer cleanup present +- **Translations:** Uses pluginApi?.tr() correctly + +**Minor Issue:** +- Debug logging present (Logger.i calls) - remove before prod + +--- + +### NoteCard.qml ✅ GOOD +- **Lines:** ~300 +- **Visual Design:** Modern, matches ClipboardCard +- **Translations:** All strings use pluginApi?.tr() +- **No timers/processes:** No cleanup needed + +**Highlights:** +- Drag handle isolated to icon (good UX) +- Auto-height expansion +- Responsive to Style.radiusM + +--- + +### BarWidget.qml ✅ PERFECT +- **Lines:** ~60 +- **Translations:** 3/3 strings use pluginApi?.tr() +- **Context Menu:** Properly implemented +- **No issues found** + +--- + +### i18n/en.json ✅ PERFECT +- **Structure:** Component-based (no plugin prefix) ✅ +- **Naming:** All kebab-case ✅ +- **Coverage:** 28 toast keys + bar/panel/settings ✅ +- **Interpolation:** Uses `{key}` syntax ✅ + +**Example of Excellence:** +```json +{ + "toast": { + "max-pinned-items": "Maximum {max} pinned items reached", + "note-exported": "Note exported to ~/Documents/{fileName}", + "text-added-to-note": "Text added to note" + } +} +``` + +--- + +## Testing Recommendations + +### Functional Tests +- [ ] Test `addSelectionToNoteCard` with various text selections +- [ ] Verify "Create New Note" creates note with bullet point +- [ ] Verify selecting existing note appends text as bullet +- [ ] Test with 0 notes, 1 note, and multiple notes +- [ ] Verify note selector appears at cursor position + +### Memory Tests +```bash +# Monitor memory usage over time +watch -n 1 'ps -o rss,vsz -p $(pgrep quickshell)' + +# Create/delete many notes to test cleanup +for i in {1..50}; do + qs -c noctalia-shell ipc call plugin:clipper addNoteCard "Test $i" + # Delete note +done +# Memory should return to baseline +``` + +### Translation Tests +```bash +# Verify all language files have same structure +cd ~/.config/noctalia/plugins/clipper/i18n +for f in *.json; do + echo "$f: $(jq 'keys' $f | wc -l) top-level keys" +done +# All should show same count +``` + +--- + +## Compliance Matrix + +| Criterion | Status | Evidence | +|-----------|--------|----------| +| No internal IPC calls | ✅ PASS | No Process calls to plugin:clipper | +| Required toggle() function | ✅ PASS | Main.qml:898-900 | +| pluginApi.withCurrentScreen() | ✅ PASS | All IPC handlers use it | +| Component.onDestruction | ✅ PASS | Main.qml:1143, NoteCardSelector | +| Translation system | ✅ PASS | pluginApi?.tr() everywhere | +| i18n structure | ✅ PASS | No plugin prefix, kebab-case | +| Memory leak prevention | ✅ PASS | 13 processes + 6 data structures | +| Settings persistence | ✅ PASS | Uses pluginApi.saveSettings() | +| Code quality | ✅ PASS | Clean, maintainable, documented | + +--- + +## Final Verdict + +### ✅ APPROVED FOR PRODUCTION + +**Summary:** +The Clipper plugin v2.0.0 demonstrates **exceptional quality** across all review criteria. The code follows Noctalia best practices, implements proper memory management, uses the translation system correctly, and has a well-designed architecture. + +**Strengths:** +- Perfect IPC implementation (11 functions, all external-facing) +- Comprehensive memory cleanup (13 processes + 6 data structures) +- Excellent translation coverage (28 toast keys, kebab-case, no prefix) +- New feature `addSelectionToNoteCard` well-integrated +- No CRITICAL or HIGH severity issues + +**Recommendations for Future Releases:** +1. Remove debug Logger.i calls (MEDIUM) +2. Sync i18n files across all languages (LOW) +3. Add empty Component.onDestruction to minor components (LOW) + +**Grade:** ⭐⭐⭐⭐⭐ (5/5) + +--- + +## Appendix: IPC Command Reference + +All 11 IPC commands in Clipper plugin: + +```bash +# Panel Management +qs -c noctalia-shell ipc call plugin:clipper toggle +qs -c noctalia-shell ipc call plugin:clipper openPanel +qs -c noctalia-shell ipc call plugin:clipper closePanel +qs -c noctalia-shell ipc call plugin:clipper togglePanel + +# Pinned Items +qs -c noctalia-shell ipc call plugin:clipper pinClipboardItem "clip_123" +qs -c noctalia-shell ipc call plugin:clipper unpinItem "pinned_456" +qs -c noctalia-shell ipc call plugin:clipper copyPinned "pinned_789" + +# ToDo Integration +qs -c noctalia-shell ipc call plugin:clipper addSelectionToTodo + +# NoteCards +qs -c noctalia-shell ipc call plugin:clipper addNoteCard "Quick note" +qs -c noctalia-shell ipc call plugin:clipper exportNoteCard "note_123_abc" +qs -c noctalia-shell ipc call plugin:clipper addSelectionToNoteCard # NEW in v2.0 +``` + +--- + +**Reviewed by:** QML Code Reviewer +**Date:** 2026-02-05 +**Follow-up:** Recommended before v2.1 release diff --git a/noctalia/plugins/clipper/ClipboardCard.qml b/noctalia/plugins/clipper/ClipboardCard.qml new file mode 100644 index 0000000..d0020aa --- /dev/null +++ b/noctalia/plugins/clipper/ClipboardCard.qml @@ -0,0 +1,426 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Services.Keyboard +import qs.Services.Noctalia +import qs.Services.UI +import qs.Widgets + +Rectangle { + id: root + focus: true + property var clipboardItem: null + property var pluginApi: null + property var screen: null + property var panelRoot: null + property string clipboardId: clipboardItem ? clipboardItem.id : "" + property string mime: clipboardItem ? clipboardItem.mime : "" + property string preview: clipboardItem ? clipboardItem.preview : "" + property string pinnedImageDataUrl: "" // For pinned images - data URL + + // Content type detection + readonly property bool isImage: clipboardItem && clipboardItem.isImage + readonly property bool isColor: { + if (isImage || !preview) + return false; + const trimmed = preview.trim(); + return /^#[A-Fa-f0-9]{6}$/.test(trimmed) || /^#[A-Fa-f0-9]{3}$/.test(trimmed) || /^[A-Fa-f0-9]{6}$/.test(trimmed) || /^rgba?\(.*\)$/i.test(trimmed); + } + readonly property bool isLink: !isImage && !isColor && preview && /^https?:\/\//.test(preview.trim()) + readonly property bool isCode: !isImage && !isColor && !isLink && preview && (preview.includes("function") || preview.includes("import ") || preview.includes("const ") || preview.includes("let ") || preview.includes("var ") || preview.includes("class ") || preview.includes("def ") || preview.includes("return ") || /^[\{\[\(<]/.test(preview.trim())) + readonly property bool isEmoji: { + if (isImage || isColor || isLink || isCode || !preview) + return false; + const trimmed = preview.trim(); + return trimmed.length <= 4 && trimmed.charCodeAt(0) > 255; + } + readonly property bool isFile: !isImage && !isColor && !isLink && !isCode && !isEmoji && preview && /^(\/|~|file:\/\/)/.test(preview.trim()) + readonly property bool isText: !isImage && !isColor && !isLink && !isCode && !isEmoji && !isFile + + // Helper to safely access Color singleton + function getColor(propName, fallback) { + if (typeof Color !== "undefined" && Color[propName]) + return Color[propName]; + return fallback; + } + + readonly property string colorValue: { + if (!isColor || !preview) + return ""; + const trimmed = preview.trim(); + if (/^#[A-Fa-f0-9]{3,6}$/.test(trimmed)) + return trimmed; + if (/^[A-Fa-f0-9]{6}$/.test(trimmed)) + return "#" + trimmed; + return trimmed; + } + + readonly property string typeLabel: isImage ? "Image" : isColor ? "Color" : isLink ? "Link" : isCode ? "Code" : isEmoji ? "Emoji" : isFile ? "File" : "Text" + readonly property string typeIcon: isImage ? "photo" : isColor ? "palette" : isLink ? "link" : isCode ? "code" : isEmoji ? "mood-smile" : isFile ? "file" : "align-left" + + // Default colors per card type + readonly property var defaultCardColors: { + "Text": { + bg: "mOutline", + separator: "mSurface", + fg: "mOnSurface" + }, + "Image": { + bg: "mTertiary", + separator: "mSurface", + fg: "mOnTertiary" + }, + "Link": { + bg: "mPrimary", + separator: "mSurface", + fg: "mOnPrimary" + }, + "Code": { + bg: "mSecondary", + separator: "mSurface", + fg: "mOnSecondary" + }, + "Color": { + bg: "mSecondary", + separator: "mSurface", + fg: "mOnSecondary" + }, + "Emoji": { + bg: "mHover", + separator: "mSurface", + fg: "mOnHover" + }, + "File": { + bg: "mError", + separator: "mSurface", + fg: "mOnError" + } + } + + // Get color setting for current card type + function getCardColorSetting(colorType) { + const settings = pluginApi?.pluginSettings?.cardColors; + const customColors = pluginApi?.pluginSettings?.customColors; + const cardType = typeLabel; + + if (settings && settings[cardType] && settings[cardType][colorType]) { + const colorKey = settings[cardType][colorType]; + if (colorKey === "custom" && customColors && customColors[cardType]) { + return customColors[cardType][colorType] || "#888888"; + } + return getColor(colorKey, defaultCardColors[cardType]?.[colorType] || "#888888"); + } + + // Fallback to default + const defaultKey = defaultCardColors[cardType]?.[colorType]; + return getColor(defaultKey, "#888888"); + } + + // Colors from settings or defaults + readonly property color accentColor: getCardColorSetting("bg") + readonly property color accentFgColor: getCardColorSetting("fg") + readonly property color separatorColor: getCardColorSetting("separator") + + signal clicked + signal deleteClicked + signal addToTodoClicked + signal pinClicked + signal rightClicked + property bool selected: false + property bool enableTodoIntegration: false + property bool isPinned: false + + width: 250 + height: parent.height + + // Get ToDo pages from plugin + function getTodoPages() { + const todoApi = PluginService.getPluginAPI("todo"); + if (!todoApi || !todoApi.pluginSettings || !todoApi.pluginSettings.pages) { + return [ + { + id: 0, + name: "General" + } + ]; + } + return todoApi.pluginSettings.pages; + } + + // Build menu model from ToDo pages + function buildTodoMenuModel() { + const pages = getTodoPages(); + const model = []; + for (let i = 0; i < pages.length; i++) { + model.push({ + "label": pages[i].name, + "action": "page-" + pages[i].id, + "icon": "checkbox" + }); + } + return model; + } + + // Anchor point for context menu positioning + Item { + id: menuAnchor + width: 0 + height: 0 + visible: false + } + + // Context menu for ToDo page selection + NPopupContextMenu { + id: todoContextMenu + screen: root.screen + anchorItem: menuAnchor + + onTriggered: action => { + todoContextMenu.visible = false; + + if (action.startsWith("page-")) { + const pageId = parseInt(action.replace("page-", "")); + if (root.preview) { + root.pluginApi?.mainInstance?.addTodoWithText(root.preview.substring(0, 200), pageId); + } + } + } + + onVisibleChanged: { + if (!visible) { + // Cleanup when menu closes + root.focus = true; + if (root.panelRoot && root.panelRoot.activeContextMenu === todoContextMenu) { + root.panelRoot.activeContextMenu = null; + } + } + } + } + + // Click outside to close menu + MouseArea { + anchors.fill: parent + enabled: todoContextMenu.visible + z: todoContextMenu.visible ? 999 : -1 + onClicked: { + todoContextMenu.visible = false; + } + } + + // Body background - Same as accent color + color: selected ? Qt.darker(accentColor, 1.1) : (mouseArea.containsMouse ? Qt.lighter(accentColor, 1.1) : accentColor) + + radius: (typeof Style !== "undefined") ? Style.radiusM : 16 + border.width: 2 + border.color: accentColor // Border same as background + + // Visual indicator for pinned status (small icon in corner) + NIcon { + visible: root.isPinned + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: 4 + z: 100 + icon: "pin-filled" + pointSize: 10 + color: root.accentFgColor + opacity: 0.6 + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + id: headerBar + Layout.fillWidth: true + Layout.preferredHeight: 35 + color: root.accentColor // Header same as background + topLeftRadius: (typeof Style !== "undefined") ? Style.radiusM : 16 + topRightRadius: (typeof Style !== "undefined") ? Style.radiusM : 16 + bottomLeftRadius: 0 + bottomRightRadius: 0 + + RowLayout { + id: headerContent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 8 + spacing: 8 + NIcon { + icon: root.typeIcon + pointSize: 13 + color: root.accentFgColor + } + NText { + text: root.typeLabel + color: root.accentFgColor + font.bold: true + } + Item { + Layout.fillWidth: true + } + NIconButton { + id: todoButton + visible: root.enableTodoIntegration && !root.isImage + icon: "checkbox" + tooltipText: pluginApi?.tr("card.add-todo") + colorFg: root.accentFgColor + colorBg: "transparent" + colorBgHover: Qt.rgba(0, 0, 0, 0.1) + colorBorder: "transparent" + colorBorderHover: "transparent" + onClicked: { + // Close any previously open menu + if (root.panelRoot && root.panelRoot.activeContextMenu) { + root.panelRoot.activeContextMenu.visible = false; + } + + // Position anchor at button location + const pos = todoButton.mapToItem(root, 0, todoButton.height); + menuAnchor.x = pos.x; + menuAnchor.y = pos.y; + + // Show menu and register it as active + todoContextMenu.model = root.buildTodoMenuModel(); + todoContextMenu.visible = true; + if (root.panelRoot) { + root.panelRoot.activeContextMenu = todoContextMenu; + } + } + } + NIconButton { + visible: !root.isPinned && (pluginApi?.pluginSettings?.pincardsEnabled ?? true) // Hide pin button if pinned or if feature disabled + icon: "pin" + tooltipText: pluginApi?.tr("card.pin") + colorFg: root.accentFgColor + colorBg: "transparent" + colorBgHover: Qt.rgba(0, 0, 0, 0.1) + colorBorder: "transparent" + colorBorderHover: "transparent" + onClicked: root.pinClicked() + } + NIconButton { + icon: "trash" + tooltipText: pluginApi?.tr("card.delete") + colorFg: root.accentFgColor + colorBg: "transparent" + colorBgHover: Qt.rgba(0, 0, 0, 0.1) + colorBorder: "transparent" + colorBorderHover: "transparent" + onClicked: root.deleteClicked() + } + } + MouseArea { + anchors.fill: parent + z: -1 + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: mouse => { + if (mouse.button === Qt.RightButton) { + root.rightClicked() + } else { + root.clicked() + } + } + } + } + + Rectangle { + width: parent.width - 10 + Layout.alignment: Qt.AlignHCenter + height: 1 + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { + position: 0.0 + color: "transparent" + } + GradientStop { + position: 0.5 + color: root.separatorColor + } + GradientStop { + position: 1.0 + color: "transparent" + } + } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.margins: 8 + clip: true + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: mouse => { + if (mouse.button === Qt.RightButton) { + root.rightClicked() + } else { + root.clicked() + } + } + } + + Rectangle { + visible: root.isColor + anchors.fill: parent + radius: 8 + color: root.colorValue || "transparent" + border.width: 1 + border.color: root.accentFgColor // Use FG color for border contrast + } + + NText { + visible: !root.isColor && !root.isImage + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + text: root.preview || "" + wrapMode: Text.Wrap + elide: Text.ElideRight + color: root.accentFgColor + font.pointSize: 11 + verticalAlignment: Text.AlignTop + } + + NImageRounded { + visible: root.isImage + anchors.fill: parent + radius: 8 + imageFillMode: Image.PreserveAspectFit + imagePath: { + // For pinned images, use data URL directly + if (root.pinnedImageDataUrl) { + return root.pinnedImageDataUrl; + } + // For cliphist images, use cache (reactive binding via imageCacheRevision) + if (root.isImage && root.pluginApi?.mainInstance) { + // Force re-evaluation when cache changes by referencing revision + const revision = root.pluginApi.mainInstance.imageCacheRevision; + const cache = root.pluginApi.mainInstance.imageCache; + return cache[root.clipboardId] || ""; + } + return ""; + } + Component.onCompleted: { + // Only decode if not pinned (pinned images have data URL already) + if (!root.pinnedImageDataUrl && root.isImage && root.clipboardId && root.pluginApi?.mainInstance) { + root.pluginApi.mainInstance.decodeToDataUrl(root.clipboardId, root.mime, null); + } + } + } + } + } + Component.onDestruction: { + if (todoContextMenu && todoContextMenu.visible) { + todoContextMenu.visible = false; + } + } +} diff --git a/noctalia/plugins/clipper/ControlCenterWidget.qml b/noctalia/plugins/clipper/ControlCenterWidget.qml new file mode 100644 index 0000000..2e08d74 --- /dev/null +++ b/noctalia/plugins/clipper/ControlCenterWidget.qml @@ -0,0 +1,24 @@ +import QtQuick +import Quickshell +import qs.Services.UI +import qs.Widgets + +NIconButtonHot { + property ShellScreen screen + property var pluginApi: null + + icon: "clipboard-data" + tooltipText: pluginApi?.tr("bar.tooltip") + + onClicked: { + if (pluginApi) { + pluginApi.togglePanel(screen); + } + } + + onRightClicked: { + if (pluginApi && pluginApi.manifest) { + BarService.openPluginSettings(screen, pluginApi.manifest); + } + } +} diff --git a/noctalia/plugins/clipper/Main.qml b/noctalia/plugins/clipper/Main.qml new file mode 100644 index 0000000..636a1fe --- /dev/null +++ b/noctalia/plugins/clipper/Main.qml @@ -0,0 +1,1391 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Services.Noctalia +import qs.Services.UI + +Item { + id: root + property var pluginApi: null + + // Panel settings control + property bool showCloseButton: false + + // Auto-paste support + property bool wtypeAvailable: false + + // Watch for pluginApi changes and initialize settings + onPluginApiChanged: { + if (pluginApi) { + showCloseButton = pluginApi.pluginSettings?.showCloseButton ?? false; + } + } + + // Pending selected text for ToDo selector + property string pendingSelectedText: "" + + // Pinned items data + property var pinnedItems: [] + property int pinnedRevision: 0 + + // Note cards data + property var noteCards: [] + property int noteCardsRevision: 0 + + // Clipboard items from cliphist + property var items: [] + property bool loading: false + property var firstSeenById: ({}) + + // Image cache (id -> data URL) with LRU eviction + property var imageCache: ({}) + property var imageCacheOrder: [] // Track insertion order for LRU + property int imageCacheRevision: 0 // Incremented when cache changes (for reactive bindings) + readonly property int maxImageCacheSize: 50 // Limit cache to 50 entries + + // Pending pageId for async operations (ToDo integration) + property int pendingPageId: 0 + + // Constants for limits + readonly property int maxPinnedItems: 20 // Maximum number of pinned items + readonly property int maxNoteCards: 20 // Maximum number of note cards + readonly property int maxTodoTextLength: 500 // Maximum text length for ToDo items + readonly property int maxImageSize: 5 * 1024 * 1024 // 5MB - max image size for pinning + readonly property int maxTextSize: 1 * 1024 * 1024 // 1MB - max text size for pinning + readonly property int maxPreviewImageSize: 10 * 1024 * 1024 // 10MB - max image size for preview + + // FileView for pinned.json + FileView { + id: pinnedFile + path: Quickshell.env("HOME") + "/.config/noctalia/plugins/clipper/pinned.json" + watchChanges: true + + onLoaded: { + try { + const data = JSON.parse(text()); + root.pinnedItems = data.items || []; + root.pinnedRevision++; + } catch (e) { + root.pinnedItems = []; + } + } + } + + // NoteCards directory path + readonly property string noteCardsDir: Quickshell.env("HOME") + "/.config/noctalia/plugins/clipper/notecards" + + // Process to load all notecards from directory + Process { + id: loadNoteCardsProc + stdout: StdioCollector {} + stderr: StdioCollector {} + + onExited: exitCode => { + // Never wipe in-memory notes on shell-level error — the files + // are still on disk and would silently disappear from the UI. + if (exitCode !== 0) { + Logger.w("Clipper", "loadNoteCards: shell exit=" + exitCode + ", keeping in-memory notes"); + return; + } + + try { + const output = String(stdout.text).trim(); + if (!output || output === "[]") { + // Empty load result. Only clear in-memory state when it + // was already empty; otherwise preserve it. This avoids + // wiping freshly-created (still-being-saved) notes if the + // disk listing raced ahead of the atomic-write rename. + if (root.noteCards.length === 0) { + root.noteCardsRevision++; + } + return; + } + + const loadedNotes = JSON.parse(output); + if (Array.isArray(loadedNotes)) { + root.noteCards = loadedNotes; + root.noteCardsRevision++; + } + } catch (e) { + Logger.w("Clipper", "loadNoteCards: parse error, keeping in-memory notes: " + e); + } + } + } + + // Function to load all notecards. + // IMPORTANT: per-file validation — a single malformed or 0-byte .json + // file must NOT wipe all notes. The previous `jq -s '.' *.json` approach + // was all-or-nothing: any bad file made jq exit non-zero, the + // `|| echo '[]'` fallback returned an empty array, and onExited cleared + // root.noteCards even though every other note was intact on disk. + function loadNoteCards() { + // Capture concatenated valid files into a single var, then decide. + // jq -s reads a stream of JSON values with no required separator, so + // concatenating is enough — we never feed it empty or malformed input. + const script = 'cd "$1" 2>/dev/null || { echo "[]"; exit 0; }; ' + + 'shopt -s nullglob; ' + + 'out=$(for f in *.json; do ' + + ' jq -e . "$f" >/dev/null 2>&1 && cat "$f"; ' + + 'done); ' + + 'if [ -z "$out" ]; then echo "[]"; ' + + 'else printf "%s" "$out" | jq -s "."; fi'; + loadNoteCardsProc.command = ["bash", "-c", script, "loadNoteCards", root.noteCardsDir]; + loadNoteCardsProc.running = true; + } + + // Helper function to add to image cache with LRU eviction + function addToImageCache(cliphistId, dataUrl) { + // Remove from order if already exists (will re-add at end) + const existingIndex = root.imageCacheOrder.indexOf(cliphistId); + if (existingIndex !== -1) { + root.imageCacheOrder = root.imageCacheOrder.filter((_, i) => i !== existingIndex); + } + + // Evict oldest entries if at capacity + while (root.imageCacheOrder.length >= maxImageCacheSize) { + const oldestKey = root.imageCacheOrder[0]; + root.imageCacheOrder = root.imageCacheOrder.slice(1); + const newCache = Object.assign({}, root.imageCache); + delete newCache[oldestKey]; + root.imageCache = newCache; + } + + // Add new entry + root.imageCache = Object.assign({}, root.imageCache, { + [cliphistId]: dataUrl + }); + root.imageCacheOrder = [...root.imageCacheOrder, cliphistId]; + root.imageCacheRevision++; + } + + // Clear caches (called on wipe) + function clearCaches() { + root.imageCache = {}; + root.imageCacheOrder = []; + root.imageCacheRevision++; + root.firstSeenById = {}; + } + + // Shared item type detection (used by Panel and ClipboardCard) + function getItemType(item) { + if (!item) + return "Text"; + if (item.isImage) + return "Image"; + + const preview = item.preview || ""; + const trimmed = preview.trim(); + + // Color detection + if (/^#[A-Fa-f0-9]{6}$/.test(trimmed) || /^#[A-Fa-f0-9]{3}$/.test(trimmed)) + return "Color"; + if (/^[A-Fa-f0-9]{6}$/.test(trimmed)) + return "Color"; + if (/^rgba?\s*\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*[\d.]+\s*)?\)$/i.test(trimmed)) + return "Color"; + + // Link detection + if (/^https?:\/\//.test(trimmed)) + return "Link"; + + // Code detection + if (preview.includes("function") || preview.includes("import ") || preview.includes("const ") || preview.includes("let ") || preview.includes("var ") || preview.includes("class ") || preview.includes("def ") || preview.includes("return ") || /^[\{\[\(<]/.test(trimmed)) + return "Code"; + + // Emoji detection + if (trimmed.length <= 4 && trimmed.length > 0 && trimmed.charCodeAt(0) > 255) + return "Emoji"; + + // File path detection + if (/^(\/|~|file:\/\/)/.test(trimmed)) + return "File"; + + return "Text"; + } + + // Process to list cliphist items + Process { + id: listProc + stdout: StdioCollector {} + + onExited: exitCode => { + if (exitCode !== 0) { + root.items = []; + root.loading = false; + return; + } + + const out = String(stdout.text); + const lines = out.split('\n').filter(l => l.length > 0); + + const parsed = lines.map(l => { + let id = ""; + let preview = ""; + const m = l.match(/^(\d+)\s+(.+)$/); + if (m) { + id = m[1]; + preview = m[2]; + } else { + const tab = l.indexOf('\t'); + id = tab > -1 ? l.slice(0, tab) : l; + preview = tab > -1 ? l.slice(tab + 1) : ""; + } + + const lower = preview.toLowerCase(); + const isImage = lower.startsWith("[image]") || lower.includes(" binary data "); + + var mime = "text/plain"; + if (isImage) { + if (lower.includes(" png")) + mime = "image/png"; + else if (lower.includes(" jpg") || lower.includes(" jpeg")) + mime = "image/jpeg"; + else if (lower.includes(" webp")) + mime = "image/webp"; + else if (lower.includes(" gif")) + mime = "image/gif"; + else + mime = "image/*"; + } + + if (!root.firstSeenById[id]) { + root.firstSeenById[id] = Date.now(); + } + + return { + "id": id, + "preview": preview, + "isImage": isImage, + "mime": mime + }; + }); + + root.items = parsed; + root.loading = false; + } + } + + // Function to pin item - use preview from items list + function pinItem(cliphistId) { + // Validate cliphistId is numeric only (prevents command injection) + if (!cliphistId || !/^\d+$/.test(String(cliphistId))) { + ToastService.showError(pluginApi?.tr("toast.invalid-clipboard-item")); + return; + } + + if (root.pinnedItems.length >= maxPinnedItems) { + ToastService.showWarning(pluginApi?.tr("toast.max-pinned-items").replace("{max}", maxPinnedItems)); + return; + } + + // Find item in current items list to get preview + const item = root.items.find(i => i.id === cliphistId); + if (!item) { + ToastService.showError(pluginApi?.tr("toast.item-not-found")); + return; + } + + const pinnedId = "pinned-" + Date.now() + "-" + cliphistId; + + const newItem = { + id: pinnedId, + cliphistId: cliphistId, // Keep original ID for image decode + content: "", // Will be filled for text items + preview: item.preview, // Use preview from list + mime: item.mime || "text/plain", + isImage: item.isImage || false, + pinnedAt: Date.now() + }; + + // Decode content (text or image data) + decodeProc.cliphistId = cliphistId; + decodeProc.pinnedItem = newItem; + + if (newItem.isImage) { + // For images, pipe through base64 to avoid binary corruption + decodeProc.command = ["sh", "-c", `cliphist decode ${cliphistId} | base64 -w 0`]; + } else { + // For text, direct decode + decodeProc.command = ["cliphist", "decode", String(cliphistId)]; + } + decodeProc.running = true; + } + + // Process to decode content for pinning + Process { + id: decodeProc + property string cliphistId: "" + property var pinnedItem: null + stdout: StdioCollector {} + + onExited: exitCode => { + if (exitCode !== 0) { + ToastService.showError(pluginApi?.tr("toast.failed-to-pin")); + return; + } + + if (pinnedItem.isImage) { + // For images, stdout.text contains base64-encoded data + const base64 = String(stdout.text).trim(); + if (!base64 || base64.length === 0) { + ToastService.showError(pluginApi?.tr("toast.failed-to-pin-image")); + return; + } + + // Validate image size (approximate: base64 is ~33% larger) + const estimatedSize = (base64.length * 3) / 4; + if (estimatedSize > root.maxImageSize) { + ToastService.showWarning(pluginApi?.tr("toast.image-too-large")); + return; + } + + const dataUrl = "data:" + pinnedItem.mime + ";base64," + base64; + pinnedItem.content = dataUrl; + } else { + // For text, validate size (max 1MB) + const textContent = String(stdout.text); + if (textContent.length > root.maxTextSize) { + ToastService.showWarning(pluginApi?.tr("toast.text-too-large")); + return; + } + + pinnedItem.content = textContent; + } + + // Add to array + root.pinnedItems = [...root.pinnedItems, pinnedItem]; + + // Save to file + root.savePinnedFile(); + + // Delete from cliphist + Quickshell.execDetached(["cliphist", "delete", String(cliphistId)]); + + root.pinnedRevision++; + ToastService.showNotice(pluginApi?.tr("toast.item-pinned")); + } + } + + // Atomic write: payload arrives via stdin, lands in .tmp, then is + // renamed onto the target. Optional `oldPath` is removed only after the + // new file is verified non-empty and renamed, so a failed save never wipes + // existing data. Replaces the previous Quickshell.execDetached + base64 + // pipeline, which silently failed in noctalia-qs 0.0.x (no file ever + // appeared on disk despite saveNoteCard firing). Process gives us exit + // codes and stderr; the queue serializes saves because Process is + // single-instance. Each queue entry: { content, path, oldPath, id }. + property var _atomicWriteQueue: [] + property var _atomicWriteCurrent: null + property bool _atomicWriteBusy: false + + Process { + id: atomicWriteProc + running: false + stdinEnabled: true + stderr: StdioCollector {} + + onExited: (exitCode, exitStatus) => { + const job = root._atomicWriteCurrent; + root._atomicWriteCurrent = null; + root._atomicWriteBusy = false; + stdinEnabled = true; + if (exitCode !== 0) { + Logger.w("Clipper", "atomicWrite FAIL exit=" + exitCode + + " id=" + (job ? job.id : "?") + + " path=" + (job ? job.path : "?") + + " stderr=" + String(stderr.text).trim()); + } + root._drainAtomicWriteQueue(); + } + } + + function _drainAtomicWriteQueue() { + if (_atomicWriteBusy || _atomicWriteQueue.length === 0) + return; + + const job = _atomicWriteQueue.shift(); + _atomicWriteCurrent = job; + _atomicWriteBusy = true; + + const script = 'p="$1"; t="${p}.tmp"; o="$2"; ' + + 'cat > "$t" && ' + + '[ -s "$t" ] && ' + + 'mv -f "$t" "$p" && ' + + '{ [ -z "$o" ] || [ "$o" = "$p" ] || rm -f "$o"; } ' + + '|| { rm -f "$t"; exit 1; }'; + atomicWriteProc.command = ["sh", "-c", script, "atomicWrite", + job.path, job.oldPath || ""]; + atomicWriteProc.stdinEnabled = true; + atomicWriteProc.running = true; + atomicWriteProc.write(job.content); + atomicWriteProc.stdinEnabled = false; + } + + // Public entry point. `oldPath` may be empty; if non-empty and different + // from `path`, it is removed only after the new file is safely on disk. + function atomicWrite(filePath, content, oldFilePath, id) { + if (!filePath || typeof filePath !== "string") { + Logger.w("Clipper", "atomicWrite: missing filePath"); + return; + } + if (!content || content.length === 0) { + Logger.w("Clipper", "atomicWrite: refusing empty write to " + filePath); + return; + } + _atomicWriteQueue.push({ + content: content, + path: filePath, + oldPath: oldFilePath || "", + id: id || "" + }); + _drainAtomicWriteQueue(); + } + + // Function to save pinned items to file + function savePinnedFile() { + const data = { + items: root.pinnedItems + }; + const json = JSON.stringify(data, null, 2); + const filePath = Quickshell.env("HOME") + "/.config/noctalia/plugins/clipper/pinned.json"; + atomicWrite(filePath, json, "", "pinned"); + } + + // Function to unpin item + function unpinItem(pinnedId) { + root.pinnedItems = root.pinnedItems.filter(item => item.id !== pinnedId); + root.savePinnedFile(); + root.pinnedRevision++; + ToastService.showNotice(pluginApi?.tr("toast.item-unpinned")); + } + + // ==================== SCRATCHPAD FUNCTIONS ==================== + + // Function to create a new scratchpad note + function createNoteCard(initialText) { + if (root.noteCards.length >= maxNoteCards) { + ToastService.showWarning(pluginApi?.tr("toast.max-notes").replace("{max}", maxNoteCards)); + return null; + } + + const timestamp = Date.now(); + const randomSuffix = Math.random().toString(36).substring(2, 8); + const noteId = "note_" + timestamp + "_" + randomSuffix; + + // Cascade positioning: offset by 30px for each new note + const cascadeOffset = (root.noteCards.length % 10) * 30; + const baseX = 20 + cascadeOffset; + const baseY = 80 + cascadeOffset; + + // Find highest z-index + let maxZ = 0; + for (let i = 0; i < root.noteCards.length; i++) { + if (root.noteCards[i].zIndex > maxZ) { + maxZ = root.noteCards[i].zIndex; + } + } + + const newNote = { + id: noteId, + title: "", + isPrivate: false, + content: initialText || "", + x: baseX, + y: baseY, + width: 350, + height: 280, + zIndex: maxZ + 1, + color: "yellow", + createdAt: new Date().toISOString(), + lastModified: new Date().toISOString() + }; + + // Immutable array update + const newNotes = root.noteCards.slice(); + newNotes.push(newNote); + root.noteCards = newNotes; + root.noteCardsRevision++; + + // Save to file + saveNoteCard(newNote); + + ToastService.showNotice(pluginApi?.tr("toast.note-created")); + return noteId; + } + + // Function to update a note card + function updateNoteCard(noteId, updates) { + const index = root.noteCards.findIndex(n => n.id === noteId); + if (index === -1) { + return; + } + + const oldNote = root.noteCards[index]; + const oldFilename = getNoteFilename(oldNote); + + // Immutable update with Object.assign + const updatedNote = Object.assign({}, oldNote, updates, { + lastModified: new Date().toISOString() + }); + + const newFilename = getNoteFilename(updatedNote); + + // Track the stale filename so saveNoteCard / atomicWrite can delete it + // only AFTER the new file is successfully on disk. The pre-2.4.3 code + // fired rm and save in parallel via execDetached, which could reorder + // so that rm landed after a failed save — wiping both files at once. + let oldFilePathToReplace = ""; + if (oldFilename !== newFilename && updates.title !== undefined) { + oldFilePathToReplace = root.noteCardsDir + "/" + oldFilename; + } + + // Immutable array update + const newNotes = root.noteCards.slice(0, index); + newNotes.push(updatedNote); + const remaining = root.noteCards.slice(index + 1); + for (let i = 0; i < remaining.length; i++) { + newNotes.push(remaining[i]); + } + root.noteCards = newNotes; + root.noteCardsRevision++; + + // Save to file (old filename is removed only on successful new save) + saveNoteCard(updatedNote, oldFilePathToReplace); + } + + // Function to delete a note card + function deleteNoteCard(noteId) { + const note = root.noteCards.find(n => n.id === noteId); + if (note) { + const filename = getNoteFilename(note); + const filePath = root.noteCardsDir + "/" + filename; + Quickshell.execDetached(["rm", "-f", filePath]); + + // Delete all exported .txt files - validate each filename before deletion + const safePattern = /^notecard_\d{6}-\d{6}\.txt$/; + const exportedFiles = note.exportedFiles || []; + for (let i = 0; i < exportedFiles.length; i++) { + if (safePattern.test(exportedFiles[i])) { + const exportedPath = Quickshell.env("HOME") + "/Documents/" + exportedFiles[i]; + Quickshell.execDetached(["rm", "-f", exportedPath]); + } + } + } + + root.noteCards = root.noteCards.filter(n => n.id !== noteId); + root.noteCardsRevision++; + + ToastService.showNotice(pluginApi?.tr("toast.note-deleted")); + } + + // Function to clear all note cards and delete files from disk + function clearAllNoteCards() { + const safePattern = /^notecard_\d{6}-\d{6}\.txt$/; + for (let i = 0; i < root.noteCards.length; i++) { + const note = root.noteCards[i]; + + // Delete the .json notecard file from notecards directory + const filename = getNoteFilename(note); + const filePath = root.noteCardsDir + "/" + filename; + Quickshell.execDetached(["rm", "-f", filePath]); + + // Delete any exported .txt files + const exportedFiles = note.exportedFiles || []; + for (let j = 0; j < exportedFiles.length; j++) { + if (safePattern.test(exportedFiles[j])) { + const exportedPath = Quickshell.env("HOME") + "/Documents/" + exportedFiles[j]; + Quickshell.execDetached(["rm", "-f", exportedPath]); + } + } + } + + root.noteCards = []; + root.noteCardsRevision++; + + ToastService.showNotice(pluginApi?.tr("toast.notes-cleared")); + } + + // Function to export scratchpad note to .txt file + function exportNoteCard(noteId) { + const note = root.noteCards.find(n => n.id === noteId); + if (!note) { + ToastService.showError(pluginApi?.tr("toast.note-not-found")); + return; + } + + const now = new Date(); + const timestamp = now.getFullYear().toString().slice(-2) + String(now.getMonth() + 1).padStart(2, '0') + String(now.getDate()).padStart(2, '0') + "-" + String(now.getHours()).padStart(2, '0') + String(now.getMinutes()).padStart(2, '0') + String(now.getSeconds()).padStart(2, '0'); + const fileName = "notecard_" + timestamp + ".txt"; + const filePath = Quickshell.env("HOME") + "/Documents/" + fileName; + + // Force a non-empty payload so the atomicWrite guard never trips for + // blank notes — a single space exports cleanly as a 1-byte file. + const exportContent = (note.content && note.content.length > 0) ? note.content : " "; + atomicWrite(filePath, exportContent, "", "export-" + noteId); + + // Store exported filename - append to list so all exports are tracked + const existingExports = note.exportedFiles || []; + root.updateNoteCard(noteId, { + exportedFiles: [...existingExports, fileName] + }); + + ToastService.showNotice(pluginApi?.tr("toast.note-exported").replace("{fileName}", fileName)); + } + + // Helper function to generate safe filename from note title + function getNoteFilename(note) { + if (!note) { + return "untitled.json"; + } + + // Use title field if available, otherwise use id + let title = (note.title && note.title.trim()) ? note.title.trim() : ""; + + if (!title || title.length === 0) { + title = note.id; + } + + // Sanitize filename: remove special characters, max 50 chars + title = title.substring(0, 50); + title = title.replace(/[^a-zA-Z0-9-_ ]/g, ''); + title = title.replace(/\s+/g, '_'); + + if (!title || title.length === 0) { + title = note.id; + } + + return title + ".json"; + } + + // Function to save individual notecard to file. + // oldFilePath (optional) is the previous on-disk filename when the note + // has been renamed — atomicWrite removes it only after the new file + // is verified non-empty, so a failed save never wipes the old data. + function saveNoteCard(note, oldFilePath) { + if (!note || !note.id) { + Logger.w("Clipper", "saveNoteCard: refusing to save invalid note"); + return; + } + const filename = getNoteFilename(note); + const filePath = root.noteCardsDir + "/" + filename; + const json = JSON.stringify(note, null, 2); + if (!json || json.length < 10) { + Logger.w("Clipper", "saveNoteCard: refusing suspiciously small JSON for note " + note.id); + return; + } + atomicWrite(filePath, json, oldFilePath, note.id); + } + + // Function to save all note cards (saves each to individual file) + function saveNoteCards() { + for (let i = 0; i < root.noteCards.length; i++) { + saveNoteCard(root.noteCards[i]); + } + } + + // Function to bring note to front (update z-index) + function bringNoteToFront(noteId) { + const index = root.noteCards.findIndex(n => n.id === noteId); + if (index === -1) + return; + + // Find highest z-index + let maxZ = 0; + for (let i = 0; i < root.noteCards.length; i++) { + if (root.noteCards[i].zIndex > maxZ) { + maxZ = root.noteCards[i].zIndex; + } + } + + // Only update if not already at front + if (root.noteCards[index].zIndex < maxZ) { + root.updateNoteCard(noteId, { + zIndex: maxZ + 1 + }); + } + } + + // Process for copying pinned images to clipboard + Process { + id: copyPinnedImageProc + command: ["wl-copy"] + running: false + stdinEnabled: true + + onExited: exitCode => { + if (exitCode === 0) { + ToastService.showNotice(pluginApi?.tr("toast.copied-to-clipboard")); + } else { + ToastService.showError(pluginApi?.tr("toast.failed-to-copy-image")); + } + stdinEnabled = true; // Re-enable for next use + } + } + + // Process for copying pinned text to clipboard + Process { + id: copyPinnedTextProc + command: ["wl-copy", "--"] + running: false + stdinEnabled: true + + onExited: exitCode => { + if (exitCode === 0) { + ToastService.showNotice(pluginApi?.tr("toast.copied-to-clipboard")); + } else { + ToastService.showError(pluginApi?.tr("toast.failed-to-copy-text")); + } + stdinEnabled = true; // Re-enable for next use + } + } + + // Function to copy pinned item to clipboard + function copyPinnedToClipboard(pinnedId) { + const item = root.pinnedItems.find(i => i.id === pinnedId); + if (!item) { + return; + } + + if (item.isImage && item.content) { + // For images, decode base64 and copy binary data + // Extract base64 from data URL: data:image/png;base64,iVBORw0K... + const matches = item.content.match(/^data:([^;]+);base64,(.+)$/); + if (!matches) { + ToastService.showError(pluginApi?.tr("toast.failed-to-copy-image")); + return; + } + + const mimeType = matches[1]; + const base64Data = matches[2]; + + // Decode base64 to binary bytes (no shell commands). + // Qt.atob() with array-like overload returns a Uint8Array directly (non-deprecated form). + const bytes = new Uint8Array(Qt.atob(base64Data)); + + // Copy binary data directly via Process stdin + copyPinnedImageProc.running = true; + copyPinnedImageProc.write(bytes); + copyPinnedImageProc.stdinEnabled = false; // Close stdin to signal EOF + } else { + // For text, copy via Process stdin (no shell interpolation) + copyPinnedTextProc.running = true; + copyPinnedTextProc.write(item.content || ""); + copyPinnedTextProc.stdinEnabled = false; // Close stdin to signal EOF + } + } + + // Image handling functions + function getImageData(cliphistId) { + return root.imageCache[cliphistId] || ""; + } + + function decodeToDataUrl(cliphistId, mimeType, callback) { + // Validate cliphistId is numeric only (prevents command injection) + if (!cliphistId || !/^\d+$/.test(String(cliphistId))) { + return; + } + + // Check cache first + if (root.imageCache[cliphistId]) { + if (callback) + callback(root.imageCache[cliphistId]); + return; + } + + // Decode and encode to base64 in one shell command (like official ClipboardService) + imageDecodeProc.cliphistId = cliphistId; + imageDecodeProc.mimeType = mimeType || "image/png"; + imageDecodeProc.callback = callback; + // Use shell to pipe: cliphist decode ID | base64 -w 0 + imageDecodeProc.command = ["sh", "-c", `cliphist decode ${cliphistId} | base64 -w 0`]; + imageDecodeProc.running = true; + } + + // Process to decode image from cliphist and encode to base64 + Process { + id: imageDecodeProc + property string cliphistId: "" + property string mimeType: "image/png" + property var callback: null + stdout: StdioCollector {} + + onExited: exitCode => { + if (exitCode !== 0) { + return; + } + + // Read base64-encoded text output + const base64 = String(stdout.text).trim(); + if (!base64 || base64.length === 0) { + return; + } + + // Validate size (approximate: base64 is ~33% larger than binary) + const estimatedSize = (base64.length * 3) / 4; + if (estimatedSize > maxPreviewImageSize) { + return; + } + + const dataUrl = "data:" + mimeType + ";base64," + base64; + + // Cache it with LRU eviction + root.addToImageCache(cliphistId, dataUrl); + + if (callback) + callback(dataUrl); + } + } + + // Process to get selected text (primary selection) - for ToDo integration + Process { + id: getSelectionProcess + command: ["wl-paste", "-p", "-n"] + stdout: StdioCollector { + id: selectionStdout + } + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + const selectedText = selectionStdout.text.trim(); + if (selectedText && selectedText.length > 0) { + root.addTodoWithText(selectedText, root.pendingPageId); + } else { + ToastService.showError(pluginApi?.tr("toast.no-text-selected")); + } + } else { + ToastService.showError(pluginApi?.tr("toast.failed-to-get-selection")); + } + } + } + + // Add todo with text to specified page via direct PluginService API + function addTodoWithText(text, pageId) { + if (!text || text.length === 0) { + ToastService.showError(pluginApi?.tr("toast.no-text-to-add")); + return; + } + + const todoApi = PluginService.getPluginAPI("todo"); + if (!todoApi) { + ToastService.showError(pluginApi?.tr("toast.todo-not-available")); + return; + } + + // NOTE: Cross-plugin integration - direct settings manipulation is allowed + // when calling another plugin's API. This is NOT internal IPC (forbidden). + // We're integrating with ToDo plugin using its data structure. + const trimmedText = text.substring(0, maxTodoTextLength); + var todos = todoApi.pluginSettings.todos || []; + + var newTodo = { + id: Date.now(), + text: trimmedText, + completed: false, + createdAt: new Date().toISOString(), + pageId: pageId, + priority: "medium", + details: "" + }; + + todos.push(newTodo); + todoApi.pluginSettings.todos = todos; + todoApi.pluginSettings.count = todos.length; + todoApi.saveSettings(); + + ToastService.showNotice(pluginApi?.tr("toast.added-to-todo")); + + // Also copy to clipboard + Quickshell.execDetached(["wl-copy", "--", text]); + } + + // Process for copying to clipboard (direct pipe: cliphist decode | wl-copy) + Process { + id: copyToClipboardProc + property string clipboardId: "" + stdout: StdioCollector {} + + onExited: exitCode => { + if (exitCode !== 0) { + ToastService.showError(pluginApi?.tr("toast.failed-to-copy")); + } + } + } + + // Clipboard management functions + function list(maxPreviewWidth) { + if (listProc.running) + return; + root.loading = true; + const width = maxPreviewWidth || 100; + listProc.command = ["cliphist", "list", "-preview-width", String(width)]; + listProc.running = true; + } + + function copyToClipboard(id) { + // Validate id is numeric only (prevents command injection) + if (!id || !/^\d+$/.test(String(id))) { + ToastService.showError(pluginApi?.tr("toast.invalid-clipboard-item")); + return; + } + + // Use shell pipe: cliphist decode ID | wl-copy + // ID is validated to be numeric only, so this is safe from command injection + copyToClipboardProc.clipboardId = id; + copyToClipboardProc.command = ["sh", "-c", `cliphist decode ${id} | wl-copy`]; + copyToClipboardProc.running = true; + } + + function deleteById(id) { + // Validate id is numeric only (prevents command injection) + if (!id || !/^\d+$/.test(String(id))) { + ToastService.showError(pluginApi?.tr("toast.invalid-clipboard-item")); + return; + } + + // cliphist delete needs the full line (ID + preview) via stdin + // ID is validated to be numeric-only, so string interpolation is safe here + deleteItemProc.command = ["sh", "-c", `cliphist list | grep "^${id} " | cliphist delete`]; + deleteItemProc.running = true; + } + + // Process for deleting clipboard item + Process { + id: deleteItemProc + stdout: StdioCollector {} + + onExited: exitCode => { + // Refresh list immediately after deletion + root.list(); + } + } + + function wipeAll() { + wipeProc.running = true; + } + + // Process for wiping all clipboard history + Process { + id: wipeProc + command: ["cliphist", "wipe"] + + onExited: exitCode => { + // Clear caches and refresh list + root.clearCaches(); + root.list(); + } + } + + // Add selected text to specific page + function addSelectedToPage(pageId) { + if (!pluginApi?.pluginSettings?.enableTodoIntegration) { + ToastService.showError(pluginApi?.tr("toast.todo-disabled")); + return; + } + + root.pendingPageId = pageId; + getSelectionProcess.running = true; + } + + IpcHandler { + target: "plugin:clipper" + + function openPanel() { + if (root.pluginApi) { + root.pluginApi.withCurrentScreen(screen => { + root.pluginApi.openPanel(screen); + }); + } + } + + function closePanel() { + if (root.pluginApi) { + root.pluginApi.withCurrentScreen(screen => { + root.pluginApi.closePanel(screen); + }); + } + } + + function togglePanel() { + if (root.pluginApi) { + root.pluginApi.withCurrentScreen(screen => { + root.pluginApi.togglePanel(screen); + }); + } + } + + // Alias for keybind compatibility + function toggle() { + togglePanel(); + } + + // Pinned items IPC handlers + function pinClipboardItem(cliphistId: string) { + root.pinItem(cliphistId); + } + + function unpinItem(pinnedId: string) { + root.unpinItem(pinnedId); + } + + function copyPinned(pinnedId: string) { + root.copyPinnedToClipboard(pinnedId); + } + + // Show ToDo page selector with current selection + // Usage: qs -c noctalia-shell ipc call plugin:clipper addSelectionToTodo + function addSelectionToTodo() { + if (!pluginApi?.pluginSettings?.enableTodoIntegration) { + ToastService.showError(pluginApi?.tr("toast.todo-disabled")); + return; + } + // Get selected text first, then show selector + root.getSelectionAndShowSelector(); + } + + // NoteCards IPC handlers + // Usage: qs -c noctalia-shell ipc call plugin:clipper addNoteCard "Quick note" + function addNoteCard(text: string) { + const initialText = text || ""; + root.createNoteCard(initialText); + } + + // Usage: qs -c noctalia-shell ipc call plugin:clipper exportNoteCard "note_123_abc" + function exportNoteCard(noteId: string) { + root.exportNoteCard(noteId); + } + + // Add selected text to existing note or create new one + // Usage: qs -c noctalia-shell ipc call plugin:clipper addSelectionToNoteCard + function addSelectionToNoteCard() { + // Get selected text first, then show note selector + root.getSelectionAndShowNoteSelector(); + } + } + + // Process to get selected text for ToDo selector + Process { + id: getSelectionForSelectorProcess + command: ["wl-paste", "-p", "-n"] + stdout: StdioCollector { + id: selectorSelectionStdout + } + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + const selectedText = selectorSelectionStdout.text.trim(); + if (selectedText && selectedText.length > 0) { + root.showTodoPageSelector(selectedText); + } else { + ToastService.showError(pluginApi?.tr("toast.no-text-selected")); + } + } else { + ToastService.showError(pluginApi?.tr("toast.failed-to-get-selection")); + } + } + } + + // Get selection and show page selector + function getSelectionAndShowSelector() { + getSelectionForSelectorProcess.running = true; + } + + // Refresh clipboard list when panel opens + function refreshOnPanelOpen() { + root.list(); + } + + // Show ToDo page selector at cursor position + function showTodoPageSelector(text) { + root.activeSelector = "todo"; + root.activeSelector = "todo"; + root.pendingSelectedText = text; + + // Get pages from ToDo plugin + const todoApi = PluginService.getPluginAPI("todo"); + let todoPages = []; + if (todoApi) { + if (todoApi.mainInstance) { + todoPages = todoApi.pluginSettings.pages || []; + } else {} + } else {} + + // Show selector with pages list + if (todoPageSelector) { + todoPageSelector.show(text, todoPages); + } else { + ToastService.showError(pluginApi?.tr("toast.could-not-open-todo")); + } + } + + // Handle page selection from selector + function handleTodoPageSelected(pageId, pageName) { + if (root.pendingSelectedText) { + root.addTodoWithText(root.pendingSelectedText, pageId); + root.pendingSelectedText = ""; + } + } + // Get selection and show note card selector + function getSelectionAndShowNoteSelector() { + getSelectionForNoteSelectorProcess.running = true; + } + function showNoteCardSelector(text) { + root.activeSelector = "notecard"; + root.activeSelector = "notecard"; + root.pendingNoteCardText = text; + // Load notecards first + root.loadNoteCards(); + // Wait a bit for notes to load, then show selector + Qt.callLater(() => { + if (noteCardSelector) { + noteCardSelector.show(text, root.noteCards); + } else { + ToastService.showError(pluginApi?.tr("toast.could-not-open-note-selector")); + } + }); + } + + // Handle note selection from selector + function handleNoteCardSelected(noteId, noteTitle) { + if (root.pendingNoteCardText) { + root.appendTextToNoteCard(noteId, root.pendingNoteCardText); + root.pendingNoteCardText = ""; + } + } + + // Handle creating new note from selection + // Handle creating new ToDo page from selection + function handleCreateNewTodoPage() { + if (root.pendingSelectedText) { + const todoApi = PluginService.getPluginAPI("todo"); + if (todoApi && todoApi.mainInstance) { + todoApi.mainInstance.addTextToNewPage(root.pendingSelectedText); + ToastService.showNotice(pluginApi?.tr("toast.todo-page-created")); + } + root.pendingSelectedText = ""; + } + } + + function handleCreateNewNoteFromSelection() { + if (root.pendingNoteCardText) { + // Create new note with bullet point + const bulletText = "- " + root.pendingNoteCardText; + root.createNoteCard(bulletText); + root.pendingNoteCardText = ""; + } + } + + // Append text as bullet point to existing note + function appendTextToNoteCard(noteId, text) { + for (let i = 0; i < noteCards.length; i++) { + if (noteCards[i].id === noteId) { + const bulletText = "- " + text; + const currentContent = noteCards[i].content || ""; + const newContent = currentContent ? currentContent + "\n" + bulletText : bulletText; + + noteCards[i].content = newContent; + noteCardsChanged(); + saveNoteCard(noteCards[i]); + + ToastService.showNotice(pluginApi?.tr("toast.text-added-to-note")); + return; + } + } + ToastService.showError(pluginApi?.tr("toast.note-not-found")); + } + + // ToDo page selector (single instance, uses first screen) + // It's a fullscreen overlay so it works regardless of which screen cursor is on + // Selection context menu (shared for both note and todo selection) + property var selectionMenu: null + property string activeSelector: "" // "todo" or "notecard" + + Variants { + model: Quickshell.screens + + delegate: SelectionContextMenu { + required property var modelData + + screen: modelData + pluginApi: root.pluginApi + + Component.onCompleted: { + if (!root.selectionMenu) { + root.selectionMenu = this; + } + } + + onItemSelected: action => { + // Route to appropriate handler + if (root.activeSelector === "notecard" && root.noteCardSelector) { + root.noteCardSelector.handleItemSelected(action); + } else if (root.activeSelector === "todo" && root.todoPageSelector) { + root.todoPageSelector.handleItemSelected(action); + } + } + + onCancelled: { + root.pendingSelectedText = ""; + root.pendingNoteCardText = ""; + } + } + } + + // Note card selector (logic only) + property var noteCardSelector: NoteCardSelector { + pluginApi: root.pluginApi + selectionMenu: root.selectionMenu + + onNoteSelected: (noteId, noteTitle) => { + root.handleNoteCardSelected(noteId, noteTitle); + } + + onCreateNewNote: () => { + root.handleCreateNewNoteFromSelection(); + } + } + + property string pendingNoteCardText: "" + + // Todo page selector (logic only) + property var todoPageSelector: TodoPageSelector { + pluginApi: root.pluginApi + selectionMenu: root.selectionMenu + + onPageSelected: (pageId, pageName) => { + root.handleTodoPageSelected(pageId, pageName); + } + } + + Process { + id: getSelectionForNoteSelectorProcess + command: ["wl-paste", "-p", "-n"] + stdout: StdioCollector { + id: noteSelectionStdout + } + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + const selectedText = noteSelectionStdout.text.trim(); + if (selectedText && selectedText.length > 0) { + root.showNoteCardSelector(selectedText); + } else { + ToastService.showError(pluginApi?.tr("toast.no-text-selected")); + } + } else { + ToastService.showError(pluginApi?.tr("toast.failed-to-get-selection")); + } + } + } + // Check if wtype is available + Process { + id: wtypeCheckProc + command: ["which", "wtype"] + running: true + stdout: StdioCollector {} + stderr: StdioCollector {} + onExited: exitCode => { + root.wtypeAvailable = (exitCode === 0); + } + } + + // Timer for auto-paste delay + Timer { + id: autoPasteTimer + interval: pluginApi?.pluginSettings?.autoPasteDelay ?? 300 + repeat: false + onTriggered: { + if (root.wtypeAvailable) { + autoPasteProc.running = true; + } else { + Logger.w("Clipper", "Auto-paste failed: wtype not found. Install with: sudo pacman -S wtype"); + } + } + } + + // Process to trigger auto-paste via wtype Ctrl+V + Process { + id: autoPasteProc + command: ["wtype", "-M", "ctrl", "-M", "shift", "v"] + running: false + onExited: exitCode => { + if (exitCode !== 0) { + Logger.w("Clipper", "wtype auto-paste exited with code: " + exitCode); + } + } + } + + // Public function called from Panel.qml + function triggerAutoPaste() { + autoPasteTimer.restart(); + } + + // Initialize pinned.json and notecards.json if they don't exist + Component.onCompleted: { + Logger.d("Clipper", "Component.onCompleted - pluginApi initialized"); + if (pluginApi) { + showCloseButton = pluginApi.pluginSettings?.showCloseButton ?? false; + } + + // Create empty pinned.json if it doesn't exist + const pinnedPath = Quickshell.env("HOME") + "/.config/noctalia/plugins/clipper/pinned.json"; + Quickshell.execDetached(["sh", "-c", `[ -f "${pinnedPath}" ] || echo '{"items":[]}' > "${pinnedPath}"`]); + + // Create notecards directory if it doesn't exist + Quickshell.execDetached(["mkdir", "-p", root.noteCardsDir]); + + // Sweep any stale .tmp files left over from a prior interrupted atomic + // write (shell killed between tmp-write and rename). These are never + // meaningful data; leaving them around would confuse the `jq -s '*.json'` + // loader on next start. + Quickshell.execDetached(["sh", "-c", + 'find "$1" -maxdepth 1 -name "*.json.tmp" -type f -delete 2>/dev/null', + "cleanTmp", root.noteCardsDir]); + + // Force reload pinned items from file + pinnedFile.reload(); + + // Load clipboard history + list(); + } + + // Cleanup all running processes on destruction + Component.onDestruction: { + if (listProc.running) + listProc.terminate(); + if (decodeProc.running) + decodeProc.terminate(); + if (copyPinnedImageProc.running) + copyPinnedImageProc.terminate(); + if (copyPinnedTextProc.running) + copyPinnedTextProc.terminate(); + if (imageDecodeProc.running) + imageDecodeProc.terminate(); + if (getSelectionProcess.running) + getSelectionProcess.terminate(); + if (getSelectionForSelectorProcess.running) + getSelectionForSelectorProcess.terminate(); + if (getSelectionForNoteSelectorProcess.running) + getSelectionForNoteSelectorProcess.terminate(); + if (copyToClipboardProc.running) + copyToClipboardProc.terminate(); + if (deleteItemProc.running) + deleteItemProc.terminate(); + if (wipeProc.running) + wipeProc.terminate(); + if (loadNoteCardsProc.running) + loadNoteCardsProc.terminate(); + if (atomicWriteProc.running) + atomicWriteProc.terminate(); + _atomicWriteQueue = []; + + autoPasteTimer.stop(); + if (autoPasteProc.running) autoPasteProc.terminate(); + if (wtypeCheckProc.running) wtypeCheckProc.terminate(); + + // Clear data structures + pinnedItems = []; + noteCards = []; + items = []; + firstSeenById = {}; + imageCache = {}; + imageCacheOrder = []; + } +} diff --git a/noctalia/plugins/clipper/NoteCard.qml b/noctalia/plugins/clipper/NoteCard.qml new file mode 100644 index 0000000..3456691 --- /dev/null +++ b/noctalia/plugins/clipper/NoteCard.qml @@ -0,0 +1,409 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Commons +import qs.Services.UI +import qs.Widgets + +Rectangle { + id: root + + // Properties + property var pluginApi: null + property var note: null + property int noteIndex: 0 + + // Color schemes + property var colorSchemes: ({ + "yellow": { + bg: "#FFF9C4", + fg: "#000000", + header: "#FDD835" + }, + "pink": { + bg: "#FCE4EC", + fg: "#000000", + header: "#F06292" + }, + "blue": { + bg: "#E3F2FD", + fg: "#000000", + header: "#42A5F5" + }, + "green": { + bg: "#E8F5E9", + fg: "#000000", + header: "#66BB6A" + }, + "purple": { + bg: "#F3E5F5", + fg: "#000000", + header: "#AB47BC" + } + }) + + // Constants for sizing + readonly property int minHeight: 200 + readonly property int maxHeight: 600 + readonly property int headerHeight: 40 + readonly property int margins: 24 + + // Position and size from note data + x: note ? note.x : 0 + y: note ? note.y : 0 + width: note ? note.width : 350 + height: note ? note.height : minHeight + z: note ? note.zIndex : 0 + + // Color from note data + color: { + const noteColor = note ? note.color : "yellow"; + const scheme = colorSchemes[noteColor]; + return scheme ? scheme.bg : "#FFF9C4"; + } + border.color: Color.mOnSurfaceVariant + border.width: 1 + radius: Style.radiusM + + // Main layout + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // Header + Rectangle { + id: headerBar + Layout.fillWidth: true + Layout.preferredHeight: root.headerHeight + color: { + const noteColor = note ? note.color : "yellow"; + const scheme = colorSchemes[noteColor]; + return scheme ? scheme.header : "#FDD835"; + } + topLeftRadius: Style.radiusM + topRightRadius: Style.radiusM + bottomLeftRadius: 0 + bottomRightRadius: 0 + + RowLayout { + id: headerContent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + anchors.rightMargin: 6 + spacing: 10 + z: 1 + + // Icon - DRAG HANDLE + Item { + Layout.preferredWidth: 24 + Layout.fillHeight: true + + NIcon { + anchors.centerIn: parent + icon: "note" + pointSize: 15 + color: { + const noteColor = note ? note.color : "yellow"; + const scheme = colorSchemes[noteColor]; + return scheme ? scheme.fg : "#000000"; + } + } + + MouseArea { + id: dragArea + anchors.fill: parent + cursorShape: Qt.SizeAllCursor + + drag.target: root + drag.axis: Drag.XAndYAxis + drag.minimumX: 0 + drag.maximumX: root.parent ? (root.parent.width - root.width) : 1200 + drag.minimumY: 0 + drag.maximumY: root.parent ? (root.parent.height - root.height) : 700 + + onPressed: { + if (root.pluginApi && root.pluginApi.mainInstance) { + root.pluginApi.mainInstance.bringNoteToFront(root.note.id); + } + } + + onReleased: { + if (root.pluginApi && root.pluginApi.mainInstance) { + root.pluginApi.mainInstance.updateNoteCard(root.note.id, { + x: root.x, + y: root.y + }); + } + } + } + } + + // Title + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumWidth: 100 + + TextInput { + id: titleInput + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + verticalAlignment: TextInput.AlignVCenter + horizontalAlignment: TextInput.AlignLeft + color: { + const noteColor = note ? note.color : "yellow"; + const scheme = colorSchemes[noteColor]; + return scheme ? scheme.fg : "#000000"; + } + font.pixelSize: 14 + font.bold: false + selectByMouse: true + clip: true + + Text { + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignLeft + text: pluginApi?.tr("notecards.untitled-placeholder") + color: parent.color + opacity: 0.5 + visible: titleInput.text.length === 0 + font: titleInput.font + } + + Component.onCompleted: { + if (note) { + text = note.title || ""; + } + } + + onAccepted: { + root.syncChanges(); + titleInput.focus = false; + } + } + } + + NIconButton { + icon: note && note.isPrivate ? "eye-off" : "eye" + tooltipText: pluginApi?.tr("notecards.toggle-privacy-mode") + colorFg: { + const noteColor = note ? note.color : "yellow"; + const scheme = colorSchemes[noteColor]; + return scheme ? scheme.fg : "#000000"; + } + colorBg: "transparent" + colorBgHover: Qt.rgba(0, 0, 0, 0.1) + colorBorder: "transparent" + colorBorderHover: "transparent" + + onClicked: { + if (root.pluginApi && root.pluginApi.mainInstance) { + root.pluginApi.mainInstance.updateNoteCard(root.note.id, { + isPrivate: !(root.note && root.note.isPrivate) + }); + } + } + } + + NIconButton { + icon: "palette" + tooltipText: pluginApi?.tr("notecards.change-color") + colorFg: { + const noteColor = note ? note.color : "yellow"; + const scheme = colorSchemes[noteColor]; + return scheme ? scheme.fg : "#000000"; + } + colorBg: "transparent" + colorBgHover: Qt.rgba(0, 0, 0, 0.1) + colorBorder: "transparent" + colorBorderHover: "transparent" + + onClicked: { + const colors = ["yellow", "pink", "blue", "green", "purple"]; + const noteColor = (root.note && root.note.color) ? root.note.color : "yellow"; + const currentIndex = colors.indexOf(noteColor); + const nextIndex = (currentIndex + 1) % colors.length; + const nextColor = colors[nextIndex]; + + if (root.pluginApi && root.pluginApi.mainInstance) { + root.pluginApi.mainInstance.updateNoteCard(root.note.id, { + color: nextColor + }); + } + } + } + + NIconButton { + icon: "file-export" + tooltipText: pluginApi?.tr("notecards.export") + colorFg: { + const noteColor = note ? note.color : "yellow"; + const scheme = colorSchemes[noteColor]; + return scheme ? scheme.fg : "#000000"; + } + colorBg: "transparent" + colorBgHover: Qt.rgba(0, 0, 0, 0.1) + colorBorder: "transparent" + colorBorderHover: "transparent" + + onClicked: { + if (root.pluginApi && root.pluginApi.mainInstance) { + root.pluginApi.mainInstance.exportNoteCard(root.note.id); + } + } + } + + NIconButton { + icon: "trash" + tooltipText: pluginApi?.tr("notecards.delete") + colorFg: { + const noteColor = note ? note.color : "yellow"; + const scheme = colorSchemes[noteColor]; + return scheme ? scheme.fg : "#000000"; + } + colorBg: "transparent" + colorBgHover: Qt.rgba(0, 0, 0, 0.1) + colorBorder: "transparent" + colorBorderHover: "transparent" + + onClicked: { + if (root.pluginApi && root.pluginApi.mainInstance) { + root.pluginApi.mainInstance.deleteNoteCard(root.note.id); + } + } + } + } + } + + // Separator + Rectangle { + width: parent.width - 10 + Layout.alignment: Qt.AlignHCenter + height: 1 + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { + position: 0.0 + color: "transparent" + } + GradientStop { + position: 0.5 + color: { + const noteColor = note ? note.color : "yellow"; + const scheme = colorSchemes[noteColor]; + return scheme ? scheme.header : "#FDD835"; + } + } + GradientStop { + position: 1.0 + color: "transparent" + } + } + } + + // Content area with ScrollView + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.margins: 12 + clip: true + + ScrollView { + anchors.fill: parent + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: ScrollBar.AsNeeded + + TextArea { + id: textArea + width: parent.width + wrapMode: TextArea.Wrap + selectByMouse: true + color: { + if (note.isPrivate && !textArea.activeFocus) { + return "transparent" + } + const noteColor = note ? note.color : "yellow"; + const scheme = colorSchemes[noteColor]; + return scheme ? scheme.fg : "#000000"; + } + font.pixelSize: 14 + background: Rectangle { + color: "transparent" + } + + Component.onCompleted: { + if (note) { + text = note.content || ""; + } + // Check if we need to expand card on load + Qt.callLater(checkAndExpandHeight); + } + + onTextChanged: { + if (note) { + note.content = text; + } + } + } + } + + Rectangle { + enabled: false + opacity: 1.0 + visible: note.isPrivate && !textArea.activeFocus + anchors.fill: parent + color: "transparent" + + + NIcon { + anchors.centerIn: parent + icon: "password" + pointSize: 45 + color: { + const noteColor = note ? note.color : "yellow"; + const scheme = colorSchemes[noteColor]; + return scheme ? scheme.fg : "#000000"; + } + } + } + } + } + + // Check if card needs to be expanded to fit content + function checkAndExpandHeight() { + if (!textArea || !note) + return; + + const contentHeight = textArea.contentHeight; + const availableHeight = root.height - root.headerHeight - root.margins - 1; // 1 = separator + + // If content doesn't fit, expand card + if (contentHeight > availableHeight) { + let newHeight = root.headerHeight + root.margins + contentHeight + 1; + newHeight = Math.min(newHeight, root.maxHeight); + + if (newHeight !== root.height && root.pluginApi && root.pluginApi.mainInstance) { + root.pluginApi.mainInstance.updateNoteCard(root.note.id, { + height: newHeight + }); + } + } + } + + function syncChanges() { + if (root.pluginApi && root.pluginApi.mainInstance && note) { + root.pluginApi.mainInstance.updateNoteCard(note.id, { + title: titleInput.text, + content: textArea.text + }); + } + } + Component.onDestruction: { + root.syncChanges(); + } +} diff --git a/noctalia/plugins/clipper/NoteCardSelector.qml b/noctalia/plugins/clipper/NoteCardSelector.qml new file mode 100644 index 0000000..7f1d3a3 --- /dev/null +++ b/noctalia/plugins/clipper/NoteCardSelector.qml @@ -0,0 +1,59 @@ +import QtQuick +import Quickshell + +// Note card selector wrapper - prepares menu items and handles selection +QtObject { + id: root + + property var pluginApi: null + property string selectedText: "" + property var noteCards: [] + property var selectionMenu: null + + signal noteSelected(string noteId, string noteTitle) + signal createNewNote + + function buildMenuModel() { + const model = []; + + // First option: Create new note + model.push({ + "label": pluginApi?.tr("notecards.create-note"), + "action": "create-new", + "icon": "add" + }); + + // Add existing notes + for (let i = 0; i < noteCards.length; i++) { + const note = noteCards[i]; + model.push({ + "label": note.title || pluginApi?.tr("notecards.untitled-placeholder"), + "action": "note-" + note.id, + "icon": "note", + "noteId": note.id + }); + } + + return model; + } + + function show(text, notes) { + selectedText = text || ""; + noteCards = notes || []; + + if (selectionMenu) { + const menuItems = buildMenuModel(); + selectionMenu.show(menuItems); + } + } + + function handleItemSelected(action) { + if (action === "create-new") { + root.createNewNote(); + } else if (action.startsWith("note-")) { + const noteId = action.replace("note-", ""); + const note = noteCards.find(n => n.id === noteId); + root.noteSelected(noteId, note ? note.title : pluginApi?.tr("notecards.untitled-placeholder")); + } + } +} diff --git a/noctalia/plugins/clipper/NoteCardsPanel.qml b/noctalia/plugins/clipper/NoteCardsPanel.qml new file mode 100644 index 0000000..a31018f --- /dev/null +++ b/noctalia/plugins/clipper/NoteCardsPanel.qml @@ -0,0 +1,220 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Commons +import qs.Services.UI +import qs.Widgets + +Item { + id: root + + property var pluginApi: null + property var screen: null + + // Function to sync all notecard changes before saving + function syncAllChanges() { + for (let i = 0; i < noteCardsRepeater.count; i++) { + const card = noteCardsRepeater.itemAt(i); + if (card && card.syncChanges) { + card.syncChanges(); + } + } + } + + // Background MouseArea - ALWAYS closes panel on click + MouseArea { + anchors.fill: parent + z: -1 + + onClicked: { + const closeButtonOn = root.pluginApi?.pluginSettings?.showCloseButton ?? false; + if (!closeButtonOn && root.pluginApi && root.screen) { + root.pluginApi.closePanel(root.screen); + } + } + } + + // Empty state UI (shown when no notes) + Item { + anchors.centerIn: parent + width: 400 + height: 200 + visible: !(root.pluginApi && root.pluginApi.mainInstance && root.pluginApi.mainInstance.noteCards) || root.pluginApi.mainInstance.noteCards.length === 0 + + ColumnLayout { + anchors.centerIn: parent + spacing: 16 + + NIcon { + Layout.alignment: Qt.AlignHCenter + icon: "notes" + pointSize: 64 + color: Color.mOnSurfaceVariant + opacity: 0.5 + } + + NText { + Layout.alignment: Qt.AlignHCenter + text: pluginApi?.tr("notecards.empty-state") + font.pointSize: Style.fontSizeL + font.bold: true + color: Color.mOnSurfaceVariant + } + + NText { + Layout.alignment: Qt.AlignHCenter + text: pluginApi?.tr("notecards.empty-hint") + font.pointSize: Style.fontSizeM + color: Color.mOnSurfaceVariant + opacity: 0.7 + } + + NButton { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 16 + text: pluginApi?.tr("notecards.create-note") + icon: "add" + + onClicked: { + if (root.pluginApi && root.pluginApi.mainInstance) { + root.pluginApi.mainInstance.createNoteCard(""); + } + } + } + } + } + + // Use ListModel to avoid binding loop + ListModel { + id: noteCardsModel + } + + property int lastRevision: -1 + + // Manual update function + function updateNoteCardsModel() { + noteCardsModel.clear(); + if (root.pluginApi && root.pluginApi.mainInstance) { + const notes = root.pluginApi.mainInstance.noteCards || []; + for (let i = 0; i < notes.length; i++) { + noteCardsModel.append({ + "noteData": notes[i], + "noteIdx": i + }); + } + lastRevision = root.pluginApi.mainInstance.noteCardsRevision || 0; + } + } + + // Timer to check for changes + Timer { + id: revisionTimer + interval: 200 + running: root.visible + repeat: true + onTriggered: { + if (root.pluginApi && root.pluginApi.mainInstance) { + const currentRevision = root.pluginApi.mainInstance.noteCardsRevision || 0; + if (currentRevision !== root.lastRevision) { + root.updateNoteCardsModel(); + } + } + } + } + + Component.onCompleted: { + updateNoteCardsModel(); + } + + // Repeater for note cards + Repeater { + id: noteCardsRepeater + model: noteCardsModel + + NoteCard { + pluginApi: root.pluginApi + note: noteData + noteIndex: noteIdx + z: 1 // Above background MouseArea + } + } + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.margins: 16 + width: contentRow.width + 16 + height: 40 + color: Color.mSurfaceVariant + border.color: Color.mOnSurfaceVariant + border.width: 1 + radius: Style.radiusM + visible: (root.pluginApi && root.pluginApi.mainInstance && root.pluginApi.mainInstance.noteCards) ? root.pluginApi.mainInstance.noteCards.length > 0 : false + z: 2 // Above everything + + RowLayout { + id: contentRow + anchors.centerIn: parent + spacing: 8 + + // Add note button + NIconButton { + width: 28 + height: 28 + icon: "add" + tooltipText: pluginApi?.tr("notecards.create-note") + colorBg: Color.mPrimary + colorFg: Color.mOnPrimary + colorBgHover: Qt.lighter(Color.mPrimary, 1.2) + colorFgHover: Color.mOnPrimary + + onClicked: { + if (root.pluginApi && root.pluginApi.mainInstance) { + const count = root.pluginApi.mainInstance.noteCards ? root.pluginApi.mainInstance.noteCards.length : 0; + const max = root.pluginApi.mainInstance.maxNoteCards || 20; + + if (count >= max) { + ToastService.showWarning(pluginApi?.tr("toast.max-notes").replace("{max}", max)); + } else { + root.pluginApi.mainInstance.createNoteCard(""); + } + } + } + } + + // Vertical separator + Rectangle { + width: 1 + height: 24 + color: Color.mOnSurfaceVariant + opacity: 0.3 + } + + // Note icon + NIcon { + icon: "note" + pointSize: 16 + color: Color.mOnSurfaceVariant + } + + // Count text + NText { + text: { + const count = (root.pluginApi && root.pluginApi.mainInstance && root.pluginApi.mainInstance.noteCards) ? root.pluginApi.mainInstance.noteCards.length : 0; + const max = (root.pluginApi && root.pluginApi.mainInstance) ? (root.pluginApi.mainInstance.maxNoteCards || 20) : 20; + return count + " / " + max; + } + font.pointSize: Style.fontSizeM + font.bold: true + color: { + const count = (root.pluginApi && root.pluginApi.mainInstance && root.pluginApi.mainInstance.noteCards) ? root.pluginApi.mainInstance.noteCards.length : 0; + const max = (root.pluginApi && root.pluginApi.mainInstance) ? (root.pluginApi.mainInstance.maxNoteCards || 20) : 20; + return count >= max ? Color.mError : Color.mOnSurfaceVariant; + } + } + } + } + Component.onDestruction: { + revisionTimer.stop(); + noteCardsModel.clear(); + } +} diff --git a/noctalia/plugins/clipper/Panel.qml b/noctalia/plugins/clipper/Panel.qml new file mode 100644 index 0000000..59f56cf --- /dev/null +++ b/noctalia/plugins/clipper/Panel.qml @@ -0,0 +1,1414 @@ +import QtQuick +import QtQuick.Controls +import Quickshell.Wayland +import Quickshell.Io +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Services.Keyboard +import qs.Services.UI +import qs.Widgets + +Item { + id: root + + // Plugin API (injected by PluginPanelSlot) + property var pluginApi: null + + // Screen context - store reference for child components + property var currentScreen: screen + + // Track currently open ToDo context menu + property var activeContextMenu: null + + // Refresh clipboard list and load notecards when panel becomes visible + // Save notecards when panel is closed + onVisibleChanged: { + if (visible) { + pluginApi?.mainInstance?.refreshOnPanelOpen(); + pluginApi?.mainInstance?.loadNoteCards(); + } else { + // Sync all local changes from notecards before saving + if (noteCardsPanel && noteCardsPanel.children[0] && noteCardsPanel.children[0].syncAllChanges) { + noteCardsPanel.children[0].syncAllChanges(); + } + pluginApi?.mainInstance?.saveNoteCards(); + } + } + + // SmartPanel properties (required for panel behavior) + readonly property var geometryPlaceholder: mainContainer + readonly property bool allowAttach: true + + // Panel dimensions - fullscreen transparent container + property real contentPreferredWidth: (pluginApi?.pluginSettings?.fullscreenMode ?? false) + ? (screen?.width ?? 1920) + : (pluginApi?.pluginSettings?.panelWidth ?? 1450) * Style.uiScaleRatio + property var contentPreferredHeight: { + if (pluginApi?.pluginSettings?.fullscreenMode ?? false) return screen?.height ?? 900; + var h = pluginApi?.pluginSettings?.panelHeight ?? 0; + return h > 0 ? h * Style.uiScaleRatio : undefined; + } + + // SmartPanel background color - transparent when hidePanelBackground is enabled + property color panelBackgroundColor: (pluginApi?.pluginSettings?.hidePanelBackground ?? false) + ? "transparent" + : ((typeof Color !== "undefined") ? Color.mSurface : "#222222") + + // Keyboard navigation + property int selectedIndex: 0 + + // Filtering + property string filterType: "" + property string searchText: "" + + // Reset selection when filter changes + onFilterTypeChanged: selectedIndex = 0 + onSearchTextChanged: selectedIndex = 0 + + // Filtered items (uses shared getItemType from Main.qml) + readonly property var filteredItems: { + let items = pluginApi?.mainInstance?.items || []; + if (!filterType && !searchText) + return items; + + return items.filter(item => { + if (filterType) { + const itemType = pluginApi?.mainInstance?.getItemType(item) || "Text"; + if (itemType !== filterType) + return false; + } + if (searchText) { + const preview = item.preview || ""; + if (!preview.toLowerCase().includes(searchText.toLowerCase())) + return false; + } + return true; + }); + } + + Keys.onLeftPressed: { + if (listView.count > 0) { + selectedIndex = Math.max(0, selectedIndex - 1); + listView.positionViewAtIndex(selectedIndex, ListView.Contain); + } + } + + Keys.onRightPressed: { + if (listView.count > 0) { + selectedIndex = Math.min(listView.count - 1, selectedIndex + 1); + listView.positionViewAtIndex(selectedIndex, ListView.Contain); + } + } + + Keys.onReturnPressed: { + if (listView.count > 0 && selectedIndex >= 0 && selectedIndex < listView.count) { + const item = root.filteredItems[selectedIndex]; + if (item) { + pluginApi?.mainInstance?.copyToClipboard(item.id); + if (pluginApi) { + pluginApi.closePanel(screen); + } + } + } + } + + Keys.onEscapePressed: { + if (pluginApi) { + pluginApi.closePanel(screen); + } + } + + Keys.onDeletePressed: { + if (listView.count > 0 && selectedIndex >= 0 && selectedIndex < listView.count) { + const item = root.filteredItems[selectedIndex]; + if (item) { + pluginApi?.mainInstance?.deleteById(item.id); + if (selectedIndex >= listView.count - 1) { + selectedIndex = Math.max(0, listView.count - 2); + } + } + } + } + + Keys.onDigit1Pressed: filterType = "" + Keys.onDigit2Pressed: filterType = "Text" + Keys.onDigit3Pressed: filterType = "Image" + Keys.onDigit4Pressed: filterType = "Color" + Keys.onDigit5Pressed: filterType = "Link" + Keys.onDigit6Pressed: filterType = "Code" + Keys.onDigit7Pressed: filterType = "Emoji" + Keys.onDigit8Pressed: filterType = "File" + + // Main container - transparent fullscreen + Item { + id: mainContainer + anchors.fill: parent + + // Click-to-close backdrop — only active when panel background is hidden + MouseArea { + anchors.fill: parent + z: -1 + enabled: pluginApi?.pluginSettings?.hidePanelBackground ?? false + onClicked: { + if (root.pluginApi) + root.pluginApi.closePanel(screen); + } + } + + NIconButton { + visible: pluginApi?.mainInstance?.showCloseButton ?? false + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Style.marginM + z: 10 + icon: "x" + tooltipText: pluginApi?.tr("panel.close") + colorBg: (typeof Color !== "undefined") ? Color.mSurfaceVariant : "#444444" + colorBgHover: (typeof Color !== "undefined") ? Color.mError : "#CC0000" + colorFg: (typeof Color !== "undefined") ? Color.mOnSurface : "#FFFFFF" + colorFgHover: (typeof Color !== "undefined") ? Color.mOnError : "#FFFFFF" + onClicked: { + if (root.pluginApi) { + root.pluginApi.closePanel(screen); + } + } + } + + // CLIPBOARD PANEL - Bottom, full width (horizontal) + Rectangle { + id: clipboardPanel + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: Math.min(300, screen?.height * 0.3 || 300) + color: (typeof Color !== "undefined") ? Color.mSurface : "#222222" + radius: Style.radiusM + opacity: 1.0 // Override global panel opacity + + Rectangle { + topLeftRadius: Style.radiusM + topRightRadius: Style.radiusM + bottomLeftRadius: 0 + bottomRightRadius: 0 + opacity: 1.0 + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginM + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + NText { + text: pluginApi?.tr("panel.title") + font.bold: true + font.pointSize: Style.fontSizeL + Layout.alignment: Qt.AlignVCenter + Layout.topMargin: -2 * Style.uiScaleRatio + } + + Item { + Layout.fillWidth: true + } + + NIconButton { + icon: "settings" + tooltipText: pluginApi?.tr("panel.settings") + Layout.alignment: Qt.AlignVCenter + colorBg: (typeof Color !== "undefined") ? Color.mSurfaceVariant : "#444444" + colorBgHover: (typeof Color !== "undefined") ? Color.mHover : "#666666" + colorFg: (typeof Color !== "undefined") ? Color.mOnSurface : "#FFFFFF" + colorFgHover: (typeof Color !== "undefined") ? Color.mOnSurface : "#FFFFFF" + onClicked: { + if (root.pluginApi) { + BarService.openPluginSettings(screen, root.pluginApi.manifest); + } + } + } + + NTextInput { + id: searchInput + Layout.preferredWidth: 250 + Layout.alignment: Qt.AlignVCenter + placeholderText: pluginApi?.tr("panel.search-placeholder") + text: root.searchText + onTextChanged: root.searchText = text + + Keys.onEscapePressed: { + if (text !== "") { + text = ""; + } else { + root.onEscapePressed(); + } + } + Keys.onLeftPressed: event => { + if (searchInput.cursorPosition === 0) { + root.onLeftPressed(); + event.accepted = true; + } + } + Keys.onRightPressed: event => { + if (searchInput.cursorPosition === text.length) { + root.onRightPressed(); + event.accepted = true; + } + } + Keys.onReturnPressed: root.onReturnPressed() + Keys.onEnterPressed: root.onReturnPressed() + Keys.onTabPressed: event => { + root.filterType = "Text"; + event.accepted = true; + } + Keys.onUpPressed: event => { + // Up = focus search (already focused, do nothing) + event.accepted = true; + } + Keys.onDownPressed: event => { + // Down = focus cards + listView.forceActiveFocus(); + event.accepted = true; + } + Keys.onPressed: event => { + if (event.key === Qt.Key_Home && event.modifiers & Qt.ControlModifier) { + root.onHomePressed(); + event.accepted = true; + } else if (event.key === Qt.Key_End && event.modifiers & Qt.ControlModifier) { + root.onEndPressed(); + event.accepted = true; + } else if (event.key === Qt.Key_Delete) { + // Delete current card + if (listView.count > 0 && root.selectedIndex >= 0 && root.selectedIndex < listView.count) { + const item = root.filteredItems[root.selectedIndex]; + if (item) { + pluginApi?.mainInstance?.deleteById(item.id); + if (root.selectedIndex >= listView.count - 1) { + root.selectedIndex = Math.max(0, listView.count - 2); + } + } + } + event.accepted = true; + } else if (event.key >= Qt.Key_0 && event.key <= Qt.Key_9) { + // Number keys for filter types + const filterMap = { + [Qt.Key_1]: "", + [Qt.Key_2]: "Text", + [Qt.Key_3]: "Image", + [Qt.Key_4]: "Color", + [Qt.Key_5]: "Link", + [Qt.Key_6]: "Code", + [Qt.Key_7]: "Emoji", + [Qt.Key_8]: "File" + }; + if (filterMap.hasOwnProperty(event.key)) { + root.filterType = filterMap[event.key]; + event.accepted = true; + } + } + } + } + + Item { + Layout.fillWidth: true + } + + // Filter type -> accent color key mapping (mirrors ClipboardCard defaults) + // All/Text/Link/Emoji -> mPrimary, Image/File -> mTertiary, Color/Code -> mSecondary + RowLayout { + spacing: Style.marginXS + Layout.alignment: Qt.AlignVCenter + + // --- ALL --- + Item { + readonly property string fType: "" + readonly property color accentColor: (typeof Color !== "undefined") ? Color.mPrimary : "#BB86FC" + readonly property color accentFgColor: (typeof Color !== "undefined") ? Color.mOnPrimary : "#FFFFFF" + readonly property bool isActive: root.filterType === fType + readonly property int itemCount: (pluginApi?.mainInstance?.items || []).length + // Expand slightly so the burst ring has room without clipping + width: btnAll.width + Style.fontSizeXS + height: btnAll.height + Style.fontSizeXS + + NIconButton { + id: btnAll + anchors.centerIn: parent + focus: true + icon: "apps" + tooltipText: pluginApi?.tr("panel.filter-all") + colorBg: (typeof Color !== "undefined") ? (parent.isActive ? Color.mPrimary : Color.mSurfaceVariant) : "#444444" + colorBgHover: (typeof Color !== "undefined") ? (parent.isActive ? Color.mPrimary : Color.mHover) : "#666666" + colorFg: (typeof Color !== "undefined") ? (parent.isActive ? Color.mOnPrimary : Color.mOnSurface) : "#FFFFFF" + colorFgHover: (typeof Color !== "undefined") ? (parent.isActive ? Color.mOnPrimary : Color.mOnSurface) : "#FFFFFF" + onClicked: root.filterType = "" + Keys.onTabPressed: { + root.filterType = ""; + event.accepted = true; + } + } + + // Active burst ring - matches groupedWorkspaceNumberBurst + Rectangle { + anchors.centerIn: btnAll + width: btnAll.width + 8 + height: btnAll.height + 8 + radius: Math.min(width, height) / 2 + color: "transparent" + border.color: (typeof Color !== "undefined") ? parent.accentColor : "#BB86FC" + border.width: 2 + opacity: parent.isActive ? 0.85 : 0 + scale: parent.isActive ? 1.0 : 0.7 + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + } + Behavior on scale { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutBack + } + } + } + + // Count badge - matches groupedWorkspaceNumberContainer pattern + Item { + visible: parent.itemCount > 0 + anchors { + left: btnAll.left + top: btnAll.top + leftMargin: -Style.fontSizeXS * 0.55 + topMargin: -Style.fontSizeXS * 0.25 + } + width: Math.max(badgeAll.implicitWidth + Style.margin2XS, Style.fontSizeXXS * 2) + height: Math.max(badgeAll.implicitHeight + Style.marginXS, Style.fontSizeXXS * 2) + + Rectangle { + anchors.fill: parent + radius: Math.min(Style.radiusL, width / 2) + color: (typeof Color !== "undefined") ? parent.parent.accentColor : "#BB86FC" + scale: parent.parent.parent.isActive ? 1.0 : 0.85 + Behavior on scale { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutBack + } + } + Behavior on color { + enabled: (typeof Color !== "undefined") && !Color.isTransitioning + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + } + } + + NText { + id: badgeAll + anchors.centerIn: parent + text: parent.parent.itemCount > 99 ? "99+" : parent.parent.itemCount + font.pointSize: (typeof Style !== "undefined") ? Style.fontSizeXS * 0.75 : 8 + font.bold: true + color: (typeof Color !== "undefined") ? parent.parent.accentFgColor : "#FFFFFF" + } + } + } + + // --- TEXT --- + Item { + readonly property string fType: "Text" + readonly property color accentColor: (typeof Color !== "undefined") ? Color.mPrimary : "#BB86FC" + readonly property color accentFgColor: (typeof Color !== "undefined") ? Color.mOnPrimary : "#FFFFFF" + readonly property bool isActive: root.filterType === fType + readonly property int itemCount: { + const all = pluginApi?.mainInstance?.items || []; + return all.filter(i => (pluginApi?.mainInstance?.getItemType(i) || "Text") === "Text").length; + } + width: btnText.width + Style.fontSizeXS + height: btnText.height + Style.fontSizeXS + + NIconButton { + id: btnText + anchors.centerIn: parent + focus: true + icon: "align-left" + tooltipText: pluginApi?.tr("panel.filter-text") + colorBg: (typeof Color !== "undefined") ? (parent.isActive ? Color.mPrimary : Color.mSurfaceVariant) : "#444444" + colorBgHover: (typeof Color !== "undefined") ? (parent.isActive ? Color.mPrimary : Color.mHover) : "#666666" + colorFg: (typeof Color !== "undefined") ? (parent.isActive ? Color.mOnPrimary : Color.mOnSurface) : "#FFFFFF" + colorFgHover: (typeof Color !== "undefined") ? (parent.isActive ? Color.mOnPrimary : Color.mOnSurface) : "#FFFFFF" + onClicked: root.filterType = "Text" + Keys.onTabPressed: { + root.filterType = "Image"; + event.accepted = true; + } + } + + Rectangle { + anchors.centerIn: btnText + width: btnText.width + 8 + height: btnText.height + 8 + radius: Math.min(width, height) / 2 + color: "transparent" + border.color: (typeof Color !== "undefined") ? parent.accentColor : "#BB86FC" + border.width: 2 + opacity: parent.isActive ? 0.85 : 0 + scale: parent.isActive ? 1.0 : 0.7 + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + } + Behavior on scale { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutBack + } + } + } + + Item { + visible: parent.itemCount > 0 + anchors { + left: btnText.left + top: btnText.top + leftMargin: -Style.fontSizeXS * 0.55 + topMargin: -Style.fontSizeXS * 0.25 + } + width: Math.max(badgeText.implicitWidth + Style.margin2XS, Style.fontSizeXXS * 2) + height: Math.max(badgeText.implicitHeight + Style.marginXS, Style.fontSizeXXS * 2) + + Rectangle { + anchors.fill: parent + radius: Math.min(Style.radiusL, width / 2) + color: (typeof Color !== "undefined") ? parent.parent.accentColor : "#BB86FC" + scale: parent.parent.parent.isActive ? 1.0 : 0.85 + Behavior on scale { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutBack + } + } + Behavior on color { + enabled: (typeof Color !== "undefined") && !Color.isTransitioning + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + } + } + NText { + id: badgeText + anchors.centerIn: parent + text: parent.parent.itemCount > 99 ? "99+" : parent.parent.itemCount + font.pointSize: (typeof Style !== "undefined") ? Style.fontSizeXS * 0.75 : 8 + font.bold: true + color: (typeof Color !== "undefined") ? parent.parent.accentFgColor : "#FFFFFF" + } + } + } + + // --- IMAGE --- + Item { + readonly property string fType: "Image" + readonly property color accentColor: (typeof Color !== "undefined") ? Color.mTertiary : "#03DAC6" + readonly property color accentFgColor: (typeof Color !== "undefined") ? Color.mOnTertiary : "#FFFFFF" + readonly property bool isActive: root.filterType === fType + readonly property int itemCount: { + const all = pluginApi?.mainInstance?.items || []; + return all.filter(i => (pluginApi?.mainInstance?.getItemType(i) || "Text") === "Image").length; + } + width: btnImage.width + Style.fontSizeXS + height: btnImage.height + Style.fontSizeXS + + NIconButton { + id: btnImage + anchors.centerIn: parent + focus: true + icon: "photo" + tooltipText: pluginApi?.tr("panel.filter-images") + colorBg: (typeof Color !== "undefined") ? (parent.isActive ? Color.mTertiary : Color.mSurfaceVariant) : "#444444" + colorBgHover: (typeof Color !== "undefined") ? (parent.isActive ? Color.mTertiary : Color.mHover) : "#666666" + colorFg: (typeof Color !== "undefined") ? (parent.isActive ? Color.mOnTertiary : Color.mOnSurface) : "#FFFFFF" + colorFgHover: (typeof Color !== "undefined") ? (parent.isActive ? Color.mOnTertiary : Color.mOnSurface) : "#FFFFFF" + onClicked: root.filterType = "Image" + } + + Rectangle { + anchors.centerIn: btnImage + width: btnImage.width + 8 + height: btnImage.height + 8 + radius: Math.min(width, height) / 2 + color: "transparent" + border.color: (typeof Color !== "undefined") ? parent.accentColor : "#03DAC6" + border.width: 2 + opacity: parent.isActive ? 0.85 : 0 + scale: parent.isActive ? 1.0 : 0.7 + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + } + Behavior on scale { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutBack + } + } + } + + Item { + visible: parent.itemCount > 0 + anchors { + left: btnImage.left + top: btnImage.top + leftMargin: -Style.fontSizeXS * 0.55 + topMargin: -Style.fontSizeXS * 0.25 + } + width: Math.max(badgeImage.implicitWidth + Style.margin2XS, Style.fontSizeXXS * 2) + height: Math.max(badgeImage.implicitHeight + Style.marginXS, Style.fontSizeXXS * 2) + + Rectangle { + anchors.fill: parent + radius: Math.min(Style.radiusL, width / 2) + color: (typeof Color !== "undefined") ? parent.parent.accentColor : "#03DAC6" + scale: parent.parent.parent.isActive ? 1.0 : 0.85 + Behavior on scale { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutBack + } + } + Behavior on color { + enabled: (typeof Color !== "undefined") && !Color.isTransitioning + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + } + } + NText { + id: badgeImage + anchors.centerIn: parent + text: parent.parent.itemCount > 99 ? "99+" : parent.parent.itemCount + font.pointSize: (typeof Style !== "undefined") ? Style.fontSizeXS * 0.75 : 8 + font.bold: true + color: (typeof Color !== "undefined") ? parent.parent.accentFgColor : "#FFFFFF" + } + } + } + + // --- COLOR --- + Item { + readonly property string fType: "Color" + readonly property color accentColor: (typeof Color !== "undefined") ? Color.mSecondary : "#CF6679" + readonly property color accentFgColor: (typeof Color !== "undefined") ? Color.mOnSecondary : "#FFFFFF" + readonly property bool isActive: root.filterType === fType + readonly property int itemCount: { + const all = pluginApi?.mainInstance?.items || []; + return all.filter(i => (pluginApi?.mainInstance?.getItemType(i) || "Text") === "Color").length; + } + width: btnColorFilter.width + Style.fontSizeXS + height: btnColorFilter.height + Style.fontSizeXS + + NIconButton { + id: btnColorFilter + anchors.centerIn: parent + focus: true + icon: "palette" + tooltipText: pluginApi?.tr("panel.filter-colors") + colorBg: (typeof Color !== "undefined") ? (parent.isActive ? Color.mSecondary : Color.mSurfaceVariant) : "#444444" + colorBgHover: (typeof Color !== "undefined") ? (parent.isActive ? Color.mSecondary : Color.mHover) : "#666666" + colorFg: (typeof Color !== "undefined") ? (parent.isActive ? Color.mOnSecondary : Color.mOnSurface) : "#FFFFFF" + colorFgHover: (typeof Color !== "undefined") ? (parent.isActive ? Color.mOnSecondary : Color.mOnSurface) : "#FFFFFF" + onClicked: root.filterType = "Color" + } + + Rectangle { + anchors.centerIn: btnColorFilter + width: btnColorFilter.width + 8 + height: btnColorFilter.height + 8 + radius: Math.min(width, height) / 2 + color: "transparent" + border.color: (typeof Color !== "undefined") ? parent.accentColor : "#CF6679" + border.width: 2 + opacity: parent.isActive ? 0.85 : 0 + scale: parent.isActive ? 1.0 : 0.7 + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + } + Behavior on scale { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutBack + } + } + } + + Item { + visible: parent.itemCount > 0 + anchors { + left: btnColorFilter.left + top: btnColorFilter.top + leftMargin: -Style.fontSizeXS * 0.55 + topMargin: -Style.fontSizeXS * 0.25 + } + width: Math.max(badgeColorFilter.implicitWidth + Style.margin2XS, Style.fontSizeXXS * 2) + height: Math.max(badgeColorFilter.implicitHeight + Style.marginXS, Style.fontSizeXXS * 2) + + Rectangle { + anchors.fill: parent + radius: Math.min(Style.radiusL, width / 2) + color: (typeof Color !== "undefined") ? parent.parent.accentColor : "#CF6679" + scale: parent.parent.parent.isActive ? 1.0 : 0.85 + Behavior on scale { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutBack + } + } + Behavior on color { + enabled: (typeof Color !== "undefined") && !Color.isTransitioning + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + } + } + NText { + id: badgeColorFilter + anchors.centerIn: parent + text: parent.parent.itemCount > 99 ? "99+" : parent.parent.itemCount + font.pointSize: (typeof Style !== "undefined") ? Style.fontSizeXS * 0.75 : 8 + font.bold: true + color: (typeof Color !== "undefined") ? parent.parent.accentFgColor : "#FFFFFF" + } + } + } + + // --- LINK --- + Item { + readonly property string fType: "Link" + readonly property color accentColor: (typeof Color !== "undefined") ? Color.mPrimary : "#BB86FC" + readonly property color accentFgColor: (typeof Color !== "undefined") ? Color.mOnPrimary : "#FFFFFF" + readonly property bool isActive: root.filterType === fType + readonly property int itemCount: { + const all = pluginApi?.mainInstance?.items || []; + return all.filter(i => (pluginApi?.mainInstance?.getItemType(i) || "Text") === "Link").length; + } + width: btnLink.width + Style.fontSizeXS + height: btnLink.height + Style.fontSizeXS + + NIconButton { + id: btnLink + anchors.centerIn: parent + focus: true + icon: "link" + tooltipText: pluginApi?.tr("panel.filter-links") + colorBg: (typeof Color !== "undefined") ? (parent.isActive ? Color.mPrimary : Color.mSurfaceVariant) : "#444444" + colorBgHover: (typeof Color !== "undefined") ? (parent.isActive ? Color.mPrimary : Color.mHover) : "#666666" + colorFg: (typeof Color !== "undefined") ? (parent.isActive ? Color.mOnPrimary : Color.mOnSurface) : "#FFFFFF" + colorFgHover: (typeof Color !== "undefined") ? (parent.isActive ? Color.mOnPrimary : Color.mOnSurface) : "#FFFFFF" + onClicked: root.filterType = "Link" + } + + Rectangle { + anchors.centerIn: btnLink + width: btnLink.width + 8 + height: btnLink.height + 8 + radius: Math.min(width, height) / 2 + color: "transparent" + border.color: (typeof Color !== "undefined") ? parent.accentColor : "#BB86FC" + border.width: 2 + opacity: parent.isActive ? 0.85 : 0 + scale: parent.isActive ? 1.0 : 0.7 + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + } + Behavior on scale { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutBack + } + } + } + + Item { + visible: parent.itemCount > 0 + anchors { + left: btnLink.left + top: btnLink.top + leftMargin: -Style.fontSizeXS * 0.55 + topMargin: -Style.fontSizeXS * 0.25 + } + width: Math.max(badgeLink.implicitWidth + Style.margin2XS, Style.fontSizeXXS * 2) + height: Math.max(badgeLink.implicitHeight + Style.marginXS, Style.fontSizeXXS * 2) + + Rectangle { + anchors.fill: parent + radius: Math.min(Style.radiusL, width / 2) + color: (typeof Color !== "undefined") ? parent.parent.accentColor : "#BB86FC" + scale: parent.parent.parent.isActive ? 1.0 : 0.85 + Behavior on scale { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutBack + } + } + Behavior on color { + enabled: (typeof Color !== "undefined") && !Color.isTransitioning + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + } + } + NText { + id: badgeLink + anchors.centerIn: parent + text: parent.parent.itemCount > 99 ? "99+" : parent.parent.itemCount + font.pointSize: (typeof Style !== "undefined") ? Style.fontSizeXS * 0.75 : 8 + font.bold: true + color: (typeof Color !== "undefined") ? parent.parent.accentFgColor : "#FFFFFF" + } + } + } + + // --- CODE --- + Item { + readonly property string fType: "Code" + readonly property color accentColor: (typeof Color !== "undefined") ? Color.mSecondary : "#CF6679" + readonly property color accentFgColor: (typeof Color !== "undefined") ? Color.mOnSecondary : "#FFFFFF" + readonly property bool isActive: root.filterType === fType + readonly property int itemCount: { + const all = pluginApi?.mainInstance?.items || []; + return all.filter(i => (pluginApi?.mainInstance?.getItemType(i) || "Text") === "Code").length; + } + width: btnCode.width + Style.fontSizeXS + height: btnCode.height + Style.fontSizeXS + + NIconButton { + id: btnCode + anchors.centerIn: parent + focus: true + icon: "code" + tooltipText: pluginApi?.tr("panel.filter-code") + colorBg: (typeof Color !== "undefined") ? (parent.isActive ? Color.mSecondary : Color.mSurfaceVariant) : "#444444" + colorBgHover: (typeof Color !== "undefined") ? (parent.isActive ? Color.mSecondary : Color.mHover) : "#666666" + colorFg: (typeof Color !== "undefined") ? (parent.isActive ? Color.mOnSecondary : Color.mOnSurface) : "#FFFFFF" + colorFgHover: (typeof Color !== "undefined") ? (parent.isActive ? Color.mOnSecondary : Color.mOnSurface) : "#FFFFFF" + onClicked: root.filterType = "Code" + } + + Rectangle { + anchors.centerIn: btnCode + width: btnCode.width + 8 + height: btnCode.height + 8 + radius: Math.min(width, height) / 2 + color: "transparent" + border.color: (typeof Color !== "undefined") ? parent.accentColor : "#CF6679" + border.width: 2 + opacity: parent.isActive ? 0.85 : 0 + scale: parent.isActive ? 1.0 : 0.7 + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + } + Behavior on scale { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutBack + } + } + } + + Item { + visible: parent.itemCount > 0 + anchors { + left: btnCode.left + top: btnCode.top + leftMargin: -Style.fontSizeXS * 0.55 + topMargin: -Style.fontSizeXS * 0.25 + } + width: Math.max(badgeCode.implicitWidth + Style.margin2XS, Style.fontSizeXXS * 2) + height: Math.max(badgeCode.implicitHeight + Style.marginXS, Style.fontSizeXXS * 2) + + Rectangle { + anchors.fill: parent + radius: Math.min(Style.radiusL, width / 2) + color: (typeof Color !== "undefined") ? parent.parent.accentColor : "#CF6679" + scale: parent.parent.parent.isActive ? 1.0 : 0.85 + Behavior on scale { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutBack + } + } + Behavior on color { + enabled: (typeof Color !== "undefined") && !Color.isTransitioning + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + } + } + NText { + id: badgeCode + anchors.centerIn: parent + text: parent.parent.itemCount > 99 ? "99+" : parent.parent.itemCount + font.pointSize: (typeof Style !== "undefined") ? Style.fontSizeXS * 0.75 : 8 + font.bold: true + color: (typeof Color !== "undefined") ? parent.parent.accentFgColor : "#FFFFFF" + } + } + } + + // --- EMOJI --- + Item { + readonly property string fType: "Emoji" + readonly property color accentColor: (typeof Color !== "undefined") ? Color.mPrimary : "#BB86FC" + readonly property color accentFgColor: (typeof Color !== "undefined") ? Color.mOnPrimary : "#FFFFFF" + readonly property bool isActive: root.filterType === fType + readonly property int itemCount: { + const all = pluginApi?.mainInstance?.items || []; + return all.filter(i => (pluginApi?.mainInstance?.getItemType(i) || "Text") === "Emoji").length; + } + width: btnEmoji.width + Style.fontSizeXS + height: btnEmoji.height + Style.fontSizeXS + + NIconButton { + id: btnEmoji + anchors.centerIn: parent + focus: true + icon: "mood-smile" + tooltipText: pluginApi?.tr("panel.filter-emoji") + colorBg: (typeof Color !== "undefined") ? (parent.isActive ? Color.mPrimary : Color.mSurfaceVariant) : "#444444" + colorBgHover: (typeof Color !== "undefined") ? (parent.isActive ? Color.mPrimary : Color.mHover) : "#666666" + colorFg: (typeof Color !== "undefined") ? (parent.isActive ? Color.mOnPrimary : Color.mOnSurface) : "#FFFFFF" + colorFgHover: (typeof Color !== "undefined") ? (parent.isActive ? Color.mOnPrimary : Color.mOnSurface) : "#FFFFFF" + onClicked: root.filterType = "Emoji" + } + + Rectangle { + anchors.centerIn: btnEmoji + width: btnEmoji.width + 8 + height: btnEmoji.height + 8 + radius: Math.min(width, height) / 2 + color: "transparent" + border.color: (typeof Color !== "undefined") ? parent.accentColor : "#BB86FC" + border.width: 2 + opacity: parent.isActive ? 0.85 : 0 + scale: parent.isActive ? 1.0 : 0.7 + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + } + Behavior on scale { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutBack + } + } + } + + Item { + visible: parent.itemCount > 0 + anchors { + left: btnEmoji.left + top: btnEmoji.top + leftMargin: -Style.fontSizeXS * 0.55 + topMargin: -Style.fontSizeXS * 0.25 + } + width: Math.max(badgeEmoji.implicitWidth + Style.margin2XS, Style.fontSizeXXS * 2) + height: Math.max(badgeEmoji.implicitHeight + Style.marginXS, Style.fontSizeXXS * 2) + + Rectangle { + anchors.fill: parent + radius: Math.min(Style.radiusL, width / 2) + color: (typeof Color !== "undefined") ? parent.parent.accentColor : "#BB86FC" + scale: parent.parent.parent.isActive ? 1.0 : 0.85 + Behavior on scale { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutBack + } + } + Behavior on color { + enabled: (typeof Color !== "undefined") && !Color.isTransitioning + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + } + } + NText { + id: badgeEmoji + anchors.centerIn: parent + text: parent.parent.itemCount > 99 ? "99+" : parent.parent.itemCount + font.pointSize: (typeof Style !== "undefined") ? Style.fontSizeXS * 0.75 : 8 + font.bold: true + color: (typeof Color !== "undefined") ? parent.parent.accentFgColor : "#FFFFFF" + } + } + } + + // --- FILE --- + Item { + readonly property string fType: "File" + readonly property color accentColor: (typeof Color !== "undefined") ? Color.mTertiary : "#03DAC6" + readonly property color accentFgColor: (typeof Color !== "undefined") ? Color.mOnTertiary : "#FFFFFF" + readonly property bool isActive: root.filterType === fType + readonly property int itemCount: { + const all = pluginApi?.mainInstance?.items || []; + return all.filter(i => (pluginApi?.mainInstance?.getItemType(i) || "Text") === "File").length; + } + width: btnFile.width + Style.fontSizeXS + height: btnFile.height + Style.fontSizeXS + + NIconButton { + id: btnFile + anchors.centerIn: parent + focus: true + icon: "file" + tooltipText: pluginApi?.tr("panel.filter-files") + colorBg: (typeof Color !== "undefined") ? (parent.isActive ? Color.mTertiary : Color.mSurfaceVariant) : "#444444" + colorBgHover: (typeof Color !== "undefined") ? (parent.isActive ? Color.mTertiary : Color.mHover) : "#666666" + colorFg: (typeof Color !== "undefined") ? (parent.isActive ? Color.mOnTertiary : Color.mOnSurface) : "#FFFFFF" + colorFgHover: (typeof Color !== "undefined") ? (parent.isActive ? Color.mOnTertiary : Color.mOnSurface) : "#FFFFFF" + onClicked: root.filterType = "File" + } + + Rectangle { + anchors.centerIn: btnFile + width: btnFile.width + 8 + height: btnFile.height + 8 + radius: Math.min(width, height) / 2 + color: "transparent" + border.color: (typeof Color !== "undefined") ? parent.accentColor : "#03DAC6" + border.width: 2 + opacity: parent.isActive ? 0.85 : 0 + scale: parent.isActive ? 1.0 : 0.7 + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + } + Behavior on scale { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutBack + } + } + } + + Item { + visible: parent.itemCount > 0 + anchors { + left: btnFile.left + top: btnFile.top + leftMargin: -Style.fontSizeXS * 0.55 + topMargin: -Style.fontSizeXS * 0.25 + } + width: Math.max(badgeFile.implicitWidth + Style.margin2XS, Style.fontSizeXXS * 2) + height: Math.max(badgeFile.implicitHeight + Style.marginXS, Style.fontSizeXXS * 2) + + Rectangle { + anchors.fill: parent + radius: Math.min(Style.radiusL, width / 2) + color: (typeof Color !== "undefined") ? parent.parent.accentColor : "#03DAC6" + scale: parent.parent.parent.isActive ? 1.0 : 0.85 + Behavior on scale { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutBack + } + } + Behavior on color { + enabled: (typeof Color !== "undefined") && !Color.isTransitioning + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.InOutCubic + } + } + } + NText { + id: badgeFile + anchors.centerIn: parent + text: parent.parent.itemCount > 99 ? "99+" : parent.parent.itemCount + font.pointSize: (typeof Style !== "undefined") ? Style.fontSizeXS * 0.75 : 8 + font.bold: true + color: (typeof Color !== "undefined") ? parent.parent.accentFgColor : "#FFFFFF" + } + } + } + } + + Rectangle { + Layout.preferredWidth: 1 + Layout.preferredHeight: 24 + Layout.alignment: Qt.AlignVCenter + color: (typeof Color !== "undefined") ? Color.mOutline : "#888888" + opacity: 0.5 + } + + NButton { + focus: true + text: pluginApi?.tr("panel.clear-all") + icon: "trash" + Layout.alignment: Qt.AlignVCenter + Layout.topMargin: -2 * Style.uiScaleRatio + onClicked: pluginApi?.mainInstance?.wipeAll() + } + } + + ListView { + id: listView + Layout.fillWidth: true + Layout.fillHeight: true + orientation: ListView.Horizontal + spacing: Style.marginM + clip: true + currentIndex: root.selectedIndex + focus: false + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + onWheel: wheel => { + listView.flick(wheel.angleDelta.y * 12, 0); + wheel.accepted = true; + } + } + + model: root.filteredItems + + Keys.onUpPressed: { + searchInput.forceActiveFocus(); + } + Keys.onLeftPressed: { + if (count > 0) { + root.selectedIndex = Math.max(0, root.selectedIndex - 1); + positionViewAtIndex(root.selectedIndex, ListView.Contain); + } + } + Keys.onRightPressed: { + if (count > 0) { + root.selectedIndex = Math.min(count - 1, root.selectedIndex + 1); + positionViewAtIndex(root.selectedIndex, ListView.Contain); + } + } + Keys.onReturnPressed: { + if (count > 0 && root.selectedIndex >= 0 && root.selectedIndex < count) { + const item = root.filteredItems[root.selectedIndex]; + if (item) { + root.pluginApi?.mainInstance?.copyToClipboard(item.id); + if (root.pluginApi) { + root.pluginApi.closePanel(screen); + } + } + } + } + Keys.onDeletePressed: { + if (count > 0 && root.selectedIndex >= 0 && root.selectedIndex < count) { + const item = root.filteredItems[root.selectedIndex]; + if (item) { + root.pluginApi?.mainInstance?.deleteById(item.id); + if (root.selectedIndex >= count - 1) { + root.selectedIndex = Math.max(0, count - 2); + } + } + } + } + Keys.onEscapePressed: { + if (root.pluginApi) { + root.pluginApi.closePanel(screen); + } + } + Keys.onTabPressed: { + // Cycle through filters: All -> Text -> Image -> Color -> Link -> Code -> Emoji -> File -> All + const filters = ["", "Text", "Image", "Color", "Link", "Code", "Emoji", "File"]; + const currentIdx = filters.indexOf(root.filterType); + const nextIdx = (currentIdx + 1) % filters.length; + root.filterType = filters[nextIdx]; + } + Keys.onBacktabPressed: { + // Shift+Tab = cycle backwards + const filters = ["", "Text", "Image", "Color", "Link", "Code", "Emoji", "File"]; + const currentIdx = filters.indexOf(root.filterType); + const prevIdx = (currentIdx - 1 + filters.length) % filters.length; + root.filterType = filters[prevIdx]; + } + Keys.onPressed: event => { + if (event.key >= Qt.Key_0 && event.key <= Qt.Key_9) { + const filterMap = { + [Qt.Key_1]: "", + [Qt.Key_2]: "Text", + [Qt.Key_3]: "Image", + [Qt.Key_4]: "Color", + [Qt.Key_5]: "Link", + [Qt.Key_6]: "Code", + [Qt.Key_7]: "Emoji", + [Qt.Key_8]: "File" + }; + if (filterMap.hasOwnProperty(event.key)) { + root.filterType = filterMap[event.key]; + event.accepted = true; + } + } + } + + delegate: ClipboardCard { + clipboardItem: modelData + pluginApi: root.pluginApi + screen: root.currentScreen + panelRoot: root + height: listView.height + selected: index === root.selectedIndex + enableTodoIntegration: root.pluginApi?.pluginSettings?.enableTodoIntegration || false + isPinned: { + // Force re-evaluation when pinnedRevision changes + const rev = root.pluginApi?.mainInstance?.pinnedRevision || 0; + const pinnedItems = root.pluginApi?.mainInstance?.pinnedItems || []; + return pinnedItems.some(p => p.id === clipboardId); + } + + onClicked: { + root.selectedIndex = index; + root.pluginApi?.mainInstance?.copyToClipboard(clipboardId); + if (root.pluginApi) { + root.pluginApi.closePanel(screen); + const autoPaste = root.pluginApi.pluginSettings?.autoPaste ?? false; + const rmbOnly = root.pluginApi.pluginSettings?.autoPasteOnRightClick ?? false; + if (autoPaste && !rmbOnly) { + root.pluginApi.mainInstance?.triggerAutoPaste(); + } + } + } + + onRightClicked: { + root.selectedIndex = index; + const autoPaste = root.pluginApi?.pluginSettings?.autoPaste ?? false; + const rmbOnly = root.pluginApi?.pluginSettings?.autoPasteOnRightClick ?? false; + if (autoPaste && rmbOnly) { + root.pluginApi?.mainInstance?.copyToClipboard(clipboardId); + if (root.pluginApi) { + root.pluginApi.closePanel(screen); + root.pluginApi.mainInstance?.triggerAutoPaste(); + } + } + } + + onDeleteClicked: { + root.pluginApi?.mainInstance?.deleteById(clipboardId); + } + + onPinClicked: { + if (isPinned) { + root.pluginApi?.mainInstance?.unpinItem(clipboardId); + ToastService.showNotice(pluginApi?.tr("toast.item-unpinned")); + } else { + const pinnedItems = root.pluginApi?.mainInstance?.pinnedItems || []; + if (pinnedItems.length >= 20) { + ToastService.showWarning(pluginApi?.tr("toast.max-pinned-items").replace("{max}", "20")); + } else { + root.pluginApi?.mainInstance?.pinItem(clipboardId); + ToastService.showNotice(pluginApi?.tr("toast.item-pinned")); + } + } + } + + onAddToTodoClicked: { + if (preview) { + // Direct call to Main.qml function (no internal IPC) + root.pluginApi?.mainInstance?.addTodoWithText(preview.substring(0, 200), 0); + } + } + } + + NText { + anchors.centerIn: parent + visible: listView.count === 0 + text: root.filterType || root.searchText ? pluginApi?.tr("panel.no-matches") : pluginApi?.tr("panel.empty") + color: (typeof Color !== "undefined") ? Color.mOnSurfaceVariant : "#AAAAAA" + } + } + } + } // End clipboardPanel + + // PINNED PANEL - Left side, vertical + Rectangle { + id: pinnedPanel + visible: pluginApi?.pluginSettings?.pincardsEnabled ?? true + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: clipboardPanel.top + anchors.bottomMargin: Style.marginM + width: Math.min(300, screen?.width * 0.2 || 300) + color: (typeof Color !== "undefined") ? Color.mSurface : "#222222" + radius: Style.radiusM + opacity: 1.0 // Override global panel opacity + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginM + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + NText { + text: pluginApi?.tr("panel.pinned-title") + font.bold: true + font.pointSize: Style.fontSizeL + Layout.alignment: Qt.AlignVCenter + } + + Item { + Layout.fillWidth: true + } + } + ListView { + id: pinnedListView + Layout.fillWidth: true + Layout.fillHeight: true + orientation: ListView.Vertical + spacing: Style.marginS + clip: true + + model: root.pluginApi?.mainInstance?.pinnedItems || [] + + delegate: ClipboardCard { + width: pinnedListView.width + height: Math.min(150, pinnedPanel.height * 0.25) + panelRoot: root + clipboardItem: { + return { + "id": modelData.id, + "preview": modelData.isImage ? "" : modelData.preview // Don't show binary preview + , + "mime": modelData.mime || "text/plain", + "isImage": modelData.isImage || false, + "content": modelData.content || "" // For images, this is data URL + }; + } + isPinned: true + pluginApi: root.pluginApi + screen: root.currentScreen + selected: false + pinnedImageDataUrl: modelData.isImage ? modelData.content : "" // Pass data URL directly + + onClicked: { + root.pluginApi?.mainInstance?.copyPinnedToClipboard(modelData.id); + if (root.pluginApi) { + root.pluginApi.closePanel(screen); + const autoPaste = root.pluginApi.pluginSettings?.autoPaste ?? false; + const rmbOnly = root.pluginApi.pluginSettings?.autoPasteOnRightClick ?? false; + if (autoPaste && !rmbOnly) { + root.pluginApi.mainInstance?.triggerAutoPaste(); + } + } + } + + onRightClicked: { + const autoPaste = root.pluginApi?.pluginSettings?.autoPaste ?? false; + const rmbOnly = root.pluginApi?.pluginSettings?.autoPasteOnRightClick ?? false; + if (autoPaste && rmbOnly) { + root.pluginApi?.mainInstance?.copyPinnedToClipboard(modelData.id); + if (root.pluginApi) { + root.pluginApi.closePanel(screen); + root.pluginApi.mainInstance?.triggerAutoPaste(); + } + } + } + + onPinClicked: { + root.pluginApi?.mainInstance?.unpinItem(modelData.id); + ToastService.showNotice(pluginApi?.tr("toast.item-unpinned")); + } + + onDeleteClicked: { + root.pluginApi?.mainInstance?.unpinItem(modelData.id); + } + } + + NText { + anchors.centerIn: parent + visible: pinnedListView.count === 0 + text: pluginApi?.tr("panel.no-pinned") + color: (typeof Color !== "undefined") ? Color.mOnSurfaceVariant : "#AAAAAA" + } + } + } + } // End pinnedPanel + + // Vertical separator between pinned and notecards + Rectangle { + visible: pinnedPanel.visible && noteCardsPanel.visible + anchors.left: pinnedPanel.right + anchors.top: parent.top + anchors.bottom: clipboardPanel.top + anchors.bottomMargin: Style.marginM + width: 1 + color: "transparent" + + Column { + anchors.centerIn: parent + spacing: 10 + Repeater { + model: 27 + Rectangle { + width: 2 + height: 8 + color: (typeof Color !== "undefined") ? Color.mOutline : "#555555" + opacity: 0.7 + } + } + } + } + + // NOTECARDS PANEL - Middle space (between pinned and clipboard) + Item { + id: noteCardsPanel + visible: pluginApi?.pluginSettings?.notecardsEnabled ?? true + anchors.left: pinnedPanel.visible ? pinnedPanel.right : parent.left + anchors.leftMargin: pinnedPanel.visible ? Style.marginM : 0 + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: clipboardPanel.top + anchors.bottomMargin: Style.marginM + + NoteCardsPanel { + id: notecardsPanelInstance + anchors.fill: parent + pluginApi: root.pluginApi + screen: root.currentScreen + } + } // End noteCardsPanel + } // End mainContainer + + Component.onCompleted: { + selectedIndex = 0; + filterType = ""; + searchText = ""; + pluginApi?.mainInstance?.list(screen?.width || 100); + listView.forceActiveFocus(); + WlrLayershell.keyboardFocus = WlrKeyboardFocus.OnDemand; + } +} diff --git a/noctalia/plugins/clipper/README.md b/noctalia/plugins/clipper/README.md new file mode 100644 index 0000000..c542883 --- /dev/null +++ b/noctalia/plugins/clipper/README.md @@ -0,0 +1,321 @@ +# Clipper - Advanced Clipboard Manager for Noctalia Shell + +**Version 2.3.0** - A powerful clipboard history manager with persistent pinned items, NoteCards sticky notes, seamless ToDo integration and auto-paste. + +![Clipboard Preview](Assets/clipboard-preview.png) + +## ✨ Features + +### ⚙️ Control Center Shortcut +- **Control Center Shortcut**: Access Clipper in Control Center shortcut instead to save widget spaces in your bar + +### 📋 Clipboard Management +- **Unlimited History**: Access your entire clipboard history powered by `cliphist` +- **Smart Filtering**: Filter by type (text, images, colors, links, code, emoji, files) +- **Quick Search**: Real-time search through clipboard items +- **Persistent Pinned Items**: Pin important items that survive across sessions +- **Rich Preview**: Image previews, color swatches, and formatted text + +### 📝 NoteCards (Sticky Notes) +Quick capture and organize your thoughts with persistent sticky notes: + +![NoteCards Demo](Assets/notecard-preview.gif) + +- **Individual JSON Storage**: Each notecard stored separately for data safety +- **Editable Titles**: Click title to edit, press Enter to save +- **Color Coding**: 8 preset colors for visual organization +- **Auto-saving Content**: Content saves when panel closes +- **Export to TXT**: Export notecards to `~/Documents/` +- **Drag to Reorder**: Organize cards by dragging (visual feedback) +- **Add Selection**: Capture selected text directly to notecard via keybind + +### ✅ ToDo Integration +Seamless integration with Noctalia ToDo plugin: + +![ToDo Integration Demo](Assets/todo-preview.gif) + +- **Smart Context Menu**: Right-click clipboard items to add to specific ToDo pages +- **Selection to ToDo**: Capture selected text directly to ToDo via keybind +- **Multi-Page Support**: Choose target ToDo page from visual menu +- **Auto-Copy**: Selected text automatically copied to clipboard + +### 🎨 Visual Customization +- **Per-Type Color Schemes**: Customize colors for each card type separately + - Text cards, Image cards, Color cards, Link cards, etc. +- **Three Color Properties**: Background, Separator, Foreground (text/icons) +- **Live Preview**: See changes instantly in settings panel +- **Reset to Defaults**: One-click restore to default theme + +- ### ⚡ Auto-Paste +- **Toggle in Settings → Auto-Paste**: After selecting a clipboard item, content is automatically pasted into the focused window +- **Right-Click Only mode**: Left-click copies normally; right-click copies and pastes +- **Paste Delay slider**: Tune the delay (100–1000 ms) for compositor focus timing +- Requires `wtype` (`sudo pacman -S wtype`); settings show a warning when not installed + +## 🎬 Video Demonstrations + +### NoteCards in Action +Creating notecards, editing titles and content, color coding, and exporting: + +![NoteCards Demo](Assets/notecard-preview.gif) + +### ToDo Integration +Adding clipboard items to ToDo, selection to ToDo workflow, and multi-page management: + +![ToDo Integration Demo](Assets/todo-preview.gif) +## 🚀 Installation + +### From Noctalia Plugin Manager (Recommended) +1. Open Noctalia Settings → Plugins +2. Search for "Clipper" +3. Click Install +4. Reload Noctalia Shell + +### Manual Installation +```bash +# Clone repository +cd ~/.config/noctalia/plugins/ +git clone https://github.com/blackbartblues/noctalia-clipper clipper + +# Reload Noctalia +qs -c noctalia-shell reload +``` + +## ⌨️ Keybind Setup + +### Hyprland Configuration + +Add to `~/.config/hypr/keybind.conf`: + +```conf +# Clipper - Clipboard Manager +bindr = SUPER, V, exec, qs -c noctalia-shell ipc call plugin:clipper toggle + +# Selection to ToDo (chord: Super+V, then C) +binds = SUPER_L, V&C, exec, qs -c noctalia-shell ipc call plugin:clipper addSelectionToTodo + +# Selection to NoteCard (chord: Super+V, then X) +binds = SUPER_L, V&X, exec, qs -c noctalia-shell ipc call plugin:clipper addSelectionToNoteCard +``` + +**Chord Keybinds Explained:** +- Press `Super+V` (don't release Super) +- While holding Super, press `C` → adds selection to ToDo +- While holding Super, press `X` → adds selection to NoteCard + +## 🎮 Usage + +### Basic Operations + +**Toggle Panel:** +```bash +qs -c noctalia-shell ipc call plugin:clipper toggle +``` + +**Pin/Unpin Item:** +- Click pin icon on clipboard card +- Or use context menu (right-click) + +**Add to ToDo:** +- Right-click clipboard item → "Add to ToDo" +- Select target ToDo page from menu + +### NoteCards + +**Create NoteCard:** +- Click "+ Create Note" button in NoteCards panel +- Or via IPC: + ```bash + qs -c noctalia-shell ipc call plugin:clipper addNoteCard "Quick note" + ``` + +**Edit NoteCard:** +- Click title to edit, press Enter to save +- Type in content area (auto-saves on panel close) + +**Change Color:** +- Right-click notecard → "Change Color" +- Select from 8 preset colors + +**Export NoteCard:** +- Right-click notecard → "Export to .txt" +- Saved to `~/Documents/note_TIMESTAMP.txt` + +**Add Selection to NoteCard:** +1. Select text in any application +2. Press keybind (e.g., `Super+V, X`) +3. Choose existing notecard or create new one +4. Text added as bullet point + +### Advanced IPC Commands + +```bash +# Panel Management +qs -c noctalia-shell ipc call plugin:clipper openPanel +qs -c noctalia-shell ipc call plugin:clipper closePanel +qs -c noctalia-shell ipc call plugin:clipper togglePanel + +# Pinned Items +qs -c noctalia-shell ipc call plugin:clipper pinClipboardItem "123456" +qs -c noctalia-shell ipc call plugin:clipper unpinItem "pinned-123" +qs -c noctalia-shell ipc call plugin:clipper copyPinned "pinned-123" + +# NoteCards +qs -c noctalia-shell ipc call plugin:clipper addNoteCard "Quick note" +qs -c noctalia-shell ipc call plugin:clipper exportNoteCard "note_abc123" +qs -c noctalia-shell ipc call plugin:clipper addSelectionToNoteCard + +# ToDo Integration +qs -c noctalia-shell ipc call plugin:clipper addSelectionToTodo +``` + +## ⚙️ Settings + +### General +- **ToDo Plugin Integration**: Enable/disable ToDo integration (auto-detects if plugin installed) + +### NoteCards +- **Enable NoteCards**: Toggle NoteCards panel visibility +- **Default Note Color**: Choose default color for new notecards +- **Current Notes Count**: Shows number of stored notecards +- **Clear All Notes**: One-click removal of all notecards + +### Appearance +Customize colors for each card type: +- **Card Type Selector**: Choose which type to customize (Text, Image, Color, Link, Code, Emoji, File) +- **Live Preview**: See changes in real-time +- **Background Color**: Card background +- **Separator Color**: Line between header and content +- **Foreground Color**: Text, icons, and content color +- **Reset to Defaults**: Restore default color scheme + +## 📁 Data Storage + +``` +~/.config/noctalia/plugins/clipper/ +├── settings.json # Plugin settings and color customization +├── pinned.json # Persistent pinned items +└── notecards/ + ├── note_abc123.json # Individual notecard files + ├── note_def456.json + └── ... +``` + +## 🔧 Technical Details + +### Dependencies +- **cliphist**: Clipboard history backend (required) +- **wl-clipboard**: Wayland clipboard utilities (required) +- **Noctalia ToDo Plugin**: For ToDo integration (optional) + +Install dependencies: +```bash +# Arch Linux +sudo pacman -S cliphist wl-clipboard + +# Or use your distribution's package manager +``` + +### Architecture +- **Main.qml**: Core logic, IPC handlers, data management +- **Panel.qml**: Main clipboard history panel UI +- **NoteCardsPanel.qml**: Sticky notes panel UI +- **TodoPageSelector.qml**: ToDo page selection logic +- **NoteCardSelector.qml**: Notecard selection logic +- **SelectionContextMenu.qml**: Universal context menu for selections +- **ClipboardCard.qml**: Individual clipboard item card +- **NoteCard.qml**: Individual notecard component +- **Settings.qml**: Settings panel UI +- **BarWidget.qml**: Bar widget button + +### Translation System +- **16 Languages Supported**: en, de, es, fr, hu, it, ja, ko-KR, nl, pl, pt, ru, sv, tr, uk-UA, zh-CN, zh-TW +- **100% Coverage**: All user-facing strings translated +- **Synchronized Structure**: All translation files have identical keys + +### Memory Management +- **Automatic Cleanup**: All components properly destroyed on exit +- **Process Termination**: Background processes stopped on destruction +- **Timer Management**: All timers stopped when not needed +- **No Memory Leaks**: Tested with 500+ operations + +## 🐛 Troubleshooting + +### Clipboard history not working +```bash +# Ensure cliphist is running +pkill -9 wl-paste; wl-paste --watch cliphist store & +``` + +### Pinned items not persisting +- Check `~/.config/noctalia/plugins/clipper/pinned.json` exists +- Verify file permissions (should be readable/writable by user) + +### NoteCards not saving +- Check `~/.config/noctalia/plugins/clipper/notecards/` directory exists +- Verify write permissions + +### ToDo integration not working +1. Check if ToDo plugin is installed and enabled +2. Verify settings: Enable "ToDo Plugin Integration" +3. Check `~/.config/noctalia/plugins.json` contains ToDo plugin + +### Context menu not appearing +- Ensure screen is correctly detected +- Check logs: `journalctl --user -u plasma-qs@noctalia-shell.service -f` + +## 📝 Changelog + +See [CHANGELOG.md](CHANGELOG.md) for detailed version history. + +### v2.2.0 (2026-03-11) - Current Release + +**Bug Fixes:** +- 🐞 Clear All Notes doesn't clear notes when reopen plugin panel fix +- 🐞 Square Panel Corners at the bottom corners fix +- 🐞 Export a Note multiple times and not deleting it fix +- 🐞 Wait until user press Apply button to apply the settings +- 🐞 clicking the NoteCards panel would close the plugin panel even when the Close Button was enabled. The panel now only closes on background click when the Close Button is disabled. + +**Improvements:** +- 🎨 Panel will now attach to bar instead of being separate +- 🎨 Decrease panel Width and Height for a cleaner look +- 🎨 Added a separator between Note Card and Pinned Itemnhanced visual customization +- 🎨 Move Panel Close Button to the top right corner instead of being next to Open Plugin Settings +- 🎨 Added active ring, badge counts for filter buttons +- 🔧 Remaps keybindings using Alt + 1 to 8 instead of Alt + 0 to 7 +- 🔧 Mouse Wheel Scroll left and right clipboards support + +**Technical:** +- 📦 Zero console.log statements in production +- 🧹 No internal IPC calls (only external API) +- 🔒 Secure command execution (no injection vulnerabilities) +- ✅ Tested: 500+ operations, no memory growth + +## 🤝 Contributing + +Contributions welcome! Please: +1. Follow QML code style guidelines +2. Use `pluginApi?.tr()` for all user-facing strings +3. Test memory management (Component.onDestruction) +4. Ensure all translation files are synchronized +5. No console.log in production code + +## 📄 License + +MIT License - See LICENSE file for details + +## 👤 Author + +**blackbartblues** +- GitHub: [@blackbartblues](https://github.com/blackbartblues) + +## 🙏 Acknowledgments + +- Noctalia Shell team for the amazing desktop environment +- cliphist for robust clipboard history +- Community contributors and testers + +--- + +**Made with ❤️ for Noctalia Shell** diff --git a/noctalia/plugins/clipper/RELEASE_NOTES_v2.0.0.md b/noctalia/plugins/clipper/RELEASE_NOTES_v2.0.0.md new file mode 100644 index 0000000..c9ca986 --- /dev/null +++ b/noctalia/plugins/clipper/RELEASE_NOTES_v2.0.0.md @@ -0,0 +1,151 @@ +# Clipper v2.0.0 Release Notes + +**Release Date**: 2026-02-05 + +## 🎉 Major Features + +### 📝 NoteCards (Sticky Notes) +Brand new sticky notes system for quick note-taking: +- Individual JSON storage for each notecard +- Editable titles and auto-saving content +- 8 color presets for organization +- Export to TXT files +- Add selected text directly to notecards via keybind +- Drag to reorder (visual feedback) + +**Demo**: [Notecard Preview.mp4](Assets/Notecard%20Preview.mp4) + +### ✅ Enhanced ToDo Integration +Completely revamped ToDo integration: +- Selection-to-ToDo with visual page selector +- Context menu for adding clipboard items to specific pages +- Fixed pageId type handling (int instead of string) +- Proper active selector routing + +**Demo**: [ToDo Preview.mp4](Assets/ToDo%20Preview.mp4) + +### 🌍 Complete Translation Coverage +- 16 languages fully supported +- 30+ toast messages translated +- All UI strings use `pluginApi?.tr()` +- Synchronized structure across all language files + +## 🔧 Bug Fixes + +### Critical Fixes +- ✅ Fixed `addSelectionToTodo` not adding items to correct page +- ✅ Fixed pageId type (string → int) causing ToDo filtering issues +- ✅ Fixed `handleTodoPageSelected` syntax error (missing closing brace) +- ✅ Fixed selector routing with `activeSelector` property + +### High Priority Fixes +- ✅ Fixed timer memory leak in NoteCardsPanel.qml +- ✅ Fixed all hardcoded strings (now use translations) +- ✅ Fixed `appendTextToNoteCard` mutation (now immutable) + +### Medium Priority Fixes +- ✅ Added Component.onDestruction cleanup to ClipboardCard.qml +- ✅ Removed all console.log debug statements +- ✅ Removed debug stack traces from Noctalia core + +## 🎨 Improvements + +### Architecture +- Immutable data patterns throughout codebase +- Proper memory cleanup (timers, processes, connections) +- Active selector routing for context menus +- No internal IPC calls (only external API) + +### User Experience +- Universal SelectionContextMenu component +- Smooth notecard interactions +- Visual feedback for all operations +- Consistent color scheme customization + +### Performance +- Zero memory leaks (tested with 500+ operations) +- Efficient ListModel-based rendering +- Proper garbage collection + +## 📦 Technical Details + +### Files Changed +- Main.qml (1176 lines) - Core logic and IPC handlers +- TodoPageSelector.qml - Fixed pageId type +- SelectionContextMenu.qml - Universal context menu +- Panel.qml, NoteCardsPanel.qml - UI improvements +- Settings.qml - Enhanced configuration +- i18n/*.json - All 16 files synchronized + +### New Dependencies +None - still uses cliphist and wl-clipboard + +### Breaking Changes +None - fully backward compatible with v1.x settings + +## 🚀 Upgrade Instructions + +### From v1.x +1. **Backup your data** (optional, but recommended): + ```bash + cp -r ~/.config/noctalia/plugins/clipper/pinned.json ~/clipper-backup.json + ``` + +2. **Update plugin**: + - Via Plugin Manager: Update button in Noctalia Settings + - Via Git: + ```bash + cd ~/.config/noctalia/plugins/clipper + git pull + ``` + +3. **Reload Noctalia**: + ```bash + qs -c noctalia-shell reload + ``` + +4. **No migration needed** - all settings and pinned items preserved! + +### Fresh Install +See [README.md](README.md) for installation instructions. + +## 🎬 Media + +### Screenshots +![Clipboard Preview](Assets/clipboard-preview.png) + +### Videos +- [Notecard Preview.mp4](Assets/Notecard%20Preview.mp4) +- [ToDo Preview.mp4](Assets/ToDo%20Preview.mp4) + +## 📝 Full Changelog + +See [CHANGELOG.md](CHANGELOG.md) for complete version history. + +## 🙏 Acknowledgments + +Special thanks to: +- Code reviewers who identified all issues +- Memory leak testers +- Translation contributors +- Noctalia Shell team + +## 🐛 Known Issues + +None currently! 🎉 + +If you find any bugs, please report at: +https://github.com/blackbartblues/noctalia-clipper/issues + +## 💡 What's Next? + +Planned for v2.1.0: +- Notecard categories/folders +- Search within notecards +- Rich text formatting +- Notecard templates +- Cloud sync (optional) + +--- + +**Enjoy Clipper v2.0.0!** 🚀 diff --git a/noctalia/plugins/clipper/SelectionContextMenu.qml b/noctalia/plugins/clipper/SelectionContextMenu.qml new file mode 100644 index 0000000..42e26d5 --- /dev/null +++ b/noctalia/plugins/clipper/SelectionContextMenu.qml @@ -0,0 +1,120 @@ +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import Quickshell.Wayland +import qs.Commons +import qs.Services.UI +import qs.Widgets + +// Universal selection context menu that appears at cursor position +PanelWindow { + id: root + + required property ShellScreen screen + property var pluginApi: null + property var menuItems: [] + + signal itemSelected(string action) + signal cancelled + + anchors.top: true + anchors.left: true + anchors.right: true + anchors.bottom: true + visible: false + color: "transparent" + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + WlrLayershell.namespace: "noctalia-selection-menu-" + (screen?.name || "unknown") + WlrLayershell.exclusionMode: ExclusionMode.Ignore + + property int cursorX: 0 + property int cursorY: 0 + + function show(items) { + menuItems = items || []; + visible = true; + getCursorPositionProcess.running = true; + } + + function close() { + visible = false; + contextMenu.visible = false; + } + + // Get cursor position from hyprctl + Process { + id: getCursorPositionProcess + command: ["hyprctl", "cursorpos", "-j"] + running: false + + stdout: StdioCollector { + id: cursorStdout + } + + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + try { + const data = JSON.parse(cursorStdout.text); + root.cursorX = data.x || 0; + root.cursorY = data.y || 0; + + // Position menu at cursor + anchorPoint.x = root.cursorX; + anchorPoint.y = root.cursorY; + contextMenu.model = root.menuItems; + contextMenu.anchorItem = anchorPoint; + contextMenu.visible = true; + } catch (e) { + ToastService.showError(pluginApi?.tr("toast.failed-to-get-cursor-position")); + root.close(); + } + } else { + root.close(); + } + } + } + + // Context menu + NPopupContextMenu { + id: contextMenu + visible: false + screen: root.screen + minWidth: 200 + + onTriggered: (action, item) => { + root.itemSelected(action); + root.close(); + } + } + + // Anchor point for menu positioning + Item { + id: anchorPoint + width: 1 + height: 1 + x: 0 + y: 0 + } + + // Fullscreen mouse area to capture outside clicks + MouseArea { + id: mouseCapture + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + + onClicked: mouse => { + root.cancelled(); + root.close(); + } + } + + Component.onDestruction: { + if (getCursorPositionProcess.running) { + getCursorPositionProcess.terminate(); + } + close(); + } +} diff --git a/noctalia/plugins/clipper/Settings.qml b/noctalia/plugins/clipper/Settings.qml new file mode 100644 index 0000000..dcc296a --- /dev/null +++ b/noctalia/plugins/clipper/Settings.qml @@ -0,0 +1,974 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Widgets + +ColumnLayout { + id: root + spacing: 0 + + property var pluginApi: null + + // Live preview + revert-on-cancel pattern (approved deviation from AGENTS.md edit-copy). + // User-approved 2026-04-20. Settings changes apply visually in real time to Panel/BarWidget + // but are only persisted to disk when the shell calls saveSettings() (Apply button). + // Closing without Apply restores the snapshot taken on open. + property var _snapshot: null + property bool _applied: false + + function _applyPreview(key, value) { + if (!pluginApi) return; + var patch = {}; + patch[key] = value; + pluginApi.pluginSettings = Object.assign({}, pluginApi.pluginSettings, patch); + } + + property bool valueTodoIntegration: pluginApi?.pluginSettings?.enableTodoIntegration ?? false + property bool valuePincardsEnabled: pluginApi?.pluginSettings?.pincardsEnabled ?? true + property bool valueNotecardsEnabled: pluginApi?.pluginSettings?.notecardsEnabled ?? true + property bool valueShowCloseButton: pluginApi?.pluginSettings?.showCloseButton ?? false + property bool valueFullscreenMode: pluginApi?.pluginSettings?.fullscreenMode ?? false + property bool valueHidePanelBackground: pluginApi?.pluginSettings?.hidePanelBackground ?? false + property bool valueAutoPaste: pluginApi?.pluginSettings?.autoPaste ?? false + property bool valueAutoPasteOnRightClick: pluginApi?.pluginSettings?.autoPasteOnRightClick ?? false + property int valueAutoPasteDelay: pluginApi?.pluginSettings?.autoPasteDelay ?? 300 + property int valuePanelWidth: pluginApi?.pluginSettings?.panelWidth ?? 1450 + property int valuePanelHeight: pluginApi?.pluginSettings?.panelHeight ?? 0 + property var pendingCardColors: JSON.parse(JSON.stringify(defaultCardColors)) + property var pendingCustomColors: { + "Text": { + bg: "#555555", + separator: "#000000", + fg: "#e9e4f0" + }, + "Image": { + bg: "#e0b7c9", + separator: "#000000", + fg: "#20161f" + }, + "Link": { + bg: "#c7a1d8", + separator: "#000000", + fg: "#1a151f" + }, + "Code": { + bg: "#a984c4", + separator: "#000000", + fg: "#f3edf7" + }, + "Color": { + bg: "#a984c4", + separator: "#000000", + fg: "#f3edf7" + }, + "Emoji": { + bg: "#e0b7c9", + separator: "#000000", + fg: "#20161f" + }, + "File": { + bg: "#e9899d", + separator: "#000000", + fg: "#1e1418" + } + } + + // ToDo integration + property bool todoPluginAvailable: false + property bool enableTodoIntegration: pluginApi?.pluginSettings?.enableTodoIntegration ?? false + + // Available card types + readonly property var cardTypes: [ + { + key: "Text", + name: "Text" + }, + { + key: "Image", + name: "Image" + }, + { + key: "Link", + name: "Link" + }, + { + key: "Code", + name: "Code" + }, + { + key: "Color", + name: "Color" + }, + { + key: "Emoji", + name: "Emoji" + }, + { + key: "File", + name: "File" + } + ] + + // Available colors from Color scheme + readonly property var colorOptions: [ + { + key: "mPrimary", + name: "Primary" + }, + { + key: "mOnPrimary", + name: "On Primary" + }, + { + key: "mSecondary", + name: "Secondary" + }, + { + key: "mOnSecondary", + name: "On Secondary" + }, + { + key: "mTertiary", + name: "Tertiary" + }, + { + key: "mOnTertiary", + name: "On Tertiary" + }, + { + key: "mSurface", + name: "Surface" + }, + { + key: "mOnSurface", + name: "On Surface" + }, + { + key: "mSurfaceVariant", + name: "Surface Variant" + }, + { + key: "mOnSurfaceVariant", + name: "On Surface Variant" + }, + { + key: "mOutline", + name: "Outline" + }, + { + key: "mError", + name: "Error" + }, + { + key: "mOnError", + name: "On Error" + }, + { + key: "mHover", + name: "Hover" + }, + { + key: "mOnHover", + name: "On Hover" + }, + { + key: "custom", + name: "Custom..." + } + ] + + // Currently selected card type for editing + property string selectedCardType: "Text" + + // Default colors per card type + readonly property var defaultCardColors: { + "Text": { + bg: "mOutline", + separator: "mSurface", + fg: "mOnSurface" + }, + "Image": { + bg: "mTertiary", + separator: "mSurface", + fg: "mOnTertiary" + }, + "Link": { + bg: "mPrimary", + separator: "mSurface", + fg: "mOnPrimary" + }, + "Code": { + bg: "mSecondary", + separator: "mSurface", + fg: "mOnSecondary" + }, + "Color": { + bg: "mSecondary", + separator: "mSurface", + fg: "mOnSecondary" + }, + "Emoji": { + bg: "mHover", + separator: "mSurface", + fg: "mOnHover" + }, + "File": { + bg: "mError", + separator: "mSurface", + fg: "mOnError" + } + } + + // Current card colors (loaded from settings or defaults) + property var cardColors: JSON.parse(JSON.stringify(defaultCardColors)) + + // Custom color values (when "custom" is selected) + property var customColors: { + "Text": { + bg: "#555555", + separator: "#000000", + fg: "#e9e4f0" + }, + "Image": { + bg: "#e0b7c9", + separator: "#000000", + fg: "#20161f" + }, + "Link": { + bg: "#c7a1d8", + separator: "#000000", + fg: "#1a151f" + }, + "Code": { + bg: "#a984c4", + separator: "#000000", + fg: "#f3edf7" + }, + "Color": { + bg: "#a984c4", + separator: "#000000", + fg: "#f3edf7" + }, + "Emoji": { + bg: "#e0b7c9", + separator: "#000000", + fg: "#20161f" + }, + "File": { + bg: "#e9899d", + separator: "#000000", + fg: "#1e1418" + } + } + + // Home directory for path resolution + readonly property string homeDir: Quickshell.env("HOME") || "" + + // Check if ToDo plugin is installed and enabled + FileView { + id: pluginsConfigFile + path: root.homeDir + "/.config/noctalia/plugins.json" + printErrors: false + watchChanges: true + onLoaded: { + try { + const content = text(); + if (content && content.length > 0) { + const config = JSON.parse(content); + root.todoPluginAvailable = config?.states?.todo?.enabled === true; + } + } catch (e) { + root.todoPluginAvailable = false; + } + } + } + + Component.onCompleted: { + // Snapshot settings on open for revert-on-cancel + if (pluginApi) { + _snapshot = JSON.parse(JSON.stringify(pluginApi.pluginSettings)); + } + + // Load saved settings + if (pluginApi?.pluginSettings?.enableTodoIntegration !== undefined) { + enableTodoIntegration = pluginApi.pluginSettings.enableTodoIntegration; + } + if (pluginApi?.pluginSettings?.cardColors) { + try { + const loaded = JSON.parse(JSON.stringify(pluginApi.pluginSettings.cardColors)); + cardColors = loaded; + pendingCardColors = JSON.parse(JSON.stringify(loaded)); + } catch (e) { + Logger.w("Clipper", "Failed to load card colors: " + e); + } + } + if (pluginApi?.pluginSettings?.customColors) { + try { + const loaded = JSON.parse(JSON.stringify(pluginApi.pluginSettings.customColors)); + customColors = loaded; + pendingCustomColors = JSON.parse(JSON.stringify(loaded)); + } catch (e) { + Logger.w("Clipper", "Failed to load custom colors: " + e); + } + } + } + + Component.onDestruction: { + if (!_applied && pluginApi && _snapshot) { + pluginApi.pluginSettings = Object.assign({}, _snapshot); + } + } + + // Helper to get actual color value + function getColorValue(colorKey, cardType, colorType) { + if (colorKey === "custom") { + return customColors[cardType]?.[colorType] || "#888888"; + } + if (typeof Color !== "undefined" && Color[colorKey]) { + return Color[colorKey]; + } + return "#888888"; + } + + // Get current colors for preview + function getPreviewBg() { + return getColorValue(cardColors[selectedCardType]?.bg || "mOutline", selectedCardType, "bg"); + } + function getPreviewSeparator() { + return getColorValue(cardColors[selectedCardType]?.separator || "mSurface", selectedCardType, "separator"); + } + function getPreviewFg() { + return getColorValue(cardColors[selectedCardType]?.fg || "mOnSurface", selectedCardType, "fg"); + } + + // Tab bar + NTabBar { + id: tabBar + Layout.fillWidth: true + Layout.bottomMargin: Style.marginM + distributeEvenly: true + currentIndex: tabView.currentIndex + + NTabButton { + text: pluginApi?.tr("settings.tab-general") + tabIndex: 0 + checked: tabBar.currentIndex === 0 + } + NTabButton { + text: pluginApi?.tr("settings.tab-appearance") + tabIndex: 1 + checked: tabBar.currentIndex === 1 + } + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: Style.marginS + } + + // Tab view + NTabView { + id: tabView + currentIndex: tabBar.currentIndex + + // TAB 1: GENERAL + ColumnLayout { + spacing: Style.marginL + + // ===== INTEGRATIONS SECTION ===== + NText { + text: pluginApi?.tr("settings.integrations") + font.bold: true + font.pointSize: Style.fontSizeL + } + + // ToDo Integration Toggle + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.todo-integration") + description: root.todoPluginAvailable ? pluginApi?.tr("settings.todo-description") : pluginApi?.tr("settings.todo-disabled") + enabled: root.todoPluginAvailable + checked: root.valueTodoIntegration + onToggled: checked => { + root.valueTodoIntegration = checked; + root._applyPreview("enableTodoIntegration", checked); + } + } + + NDivider { + Layout.fillWidth: true + } + + // ===== FEATURES SECTION ===== + NText { + text: pluginApi?.tr("settings.features") + font.bold: true + font.pointSize: Style.fontSizeL + } + + // Fullscreen Mode Toggle + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.fullscreen-mode") + description: pluginApi?.tr("settings.fullscreen-mode-desc") + checked: root.valueFullscreenMode + onToggled: checked => { + root.valueFullscreenMode = checked; + root._applyPreview("fullscreenMode", checked); + } + } + + // Panel Width (hidden when fullscreen) + NSpinBox { + Layout.fillWidth: true + visible: !root.valueFullscreenMode + label: pluginApi?.tr("settings.panel-width") + description: pluginApi?.tr("settings.panel-width-desc") + value: root.valuePanelWidth + from: 400 + to: 3840 + stepSize: 50 + onValueChanged: { + root.valuePanelWidth = value; + root._applyPreview("panelWidth", value); + } + } + + // Panel Height (hidden when fullscreen) + NSpinBox { + Layout.fillWidth: true + visible: !root.valueFullscreenMode + label: pluginApi?.tr("settings.panel-height") + description: pluginApi?.tr("settings.panel-height-desc") + value: root.valuePanelHeight + from: 0 + to: 2160 + stepSize: 50 + onValueChanged: { + root.valuePanelHeight = value; + root._applyPreview("panelHeight", value); + } + } + + NDivider { + Layout.fillWidth: true + } + + // PinCards Enable Toggle + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.pincards-enabled") + description: pluginApi?.tr("settings.pincards-desc") + checked: root.valuePincardsEnabled + onToggled: checked => { + root.valuePincardsEnabled = checked; + root._applyPreview("pincardsEnabled", checked); + } + } + + // Pinned items count display + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + visible: pluginApi?.pluginSettings?.pincardsEnabled ?? true + + NText { + text: pluginApi?.tr("settings.pincards-items-count") + font.bold: true + } + + Item { + Layout.fillWidth: true + } + + NText { + text: { + const count = pluginApi?.mainInstance?.pinnedItems?.length || 0; + const max = pluginApi?.mainInstance?.maxPinnedItems || 20; + return count + " / " + max; + } + color: { + const count = pluginApi?.mainInstance?.pinnedItems?.length || 0; + const max = pluginApi?.mainInstance?.maxPinnedItems || 20; + return count >= max ? Color.mError : Color.mOnSurface; + } + } + } + + // Clear all pinned items button + NButton { + Layout.alignment: Qt.AlignRight + text: pluginApi?.tr("settings.clear-all-pinned") + icon: "trash" + visible: pluginApi?.pluginSettings?.pincardsEnabled ?? true + enabled: (pluginApi?.mainInstance?.pinnedItems?.length || 0) > 0 + onClicked: { + if (pluginApi?.mainInstance) { + // Clear all pinned items + pluginApi.mainInstance.pinnedItems = []; + pluginApi.mainInstance.savePinnedFile(); + pluginApi.mainInstance.pinnedRevision++; + ToastService.showNotice(pluginApi?.tr("toast.pinned-cleared")); + } + } + } + + // NoteCards Enable Toggle + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.notecards-enabled") + description: pluginApi?.tr("settings.notecards-desc") + checked: root.valueNotecardsEnabled + onToggled: checked => { + root.valueNotecardsEnabled = checked; + root._applyPreview("notecardsEnabled", checked); + } + } + + // Notes count display + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + visible: pluginApi?.pluginSettings?.notecardsEnabled ?? true + + NText { + text: pluginApi?.tr("settings.notecards-notes-count") + font.bold: true + } + + Item { + Layout.fillWidth: true + } + + NText { + text: { + const count = pluginApi?.mainInstance?.noteCards?.length || 0; + const max = pluginApi?.mainInstance?.maxNoteCards || 20; + return count + " / " + max; + } + color: { + const count = pluginApi?.mainInstance?.noteCards?.length || 0; + const max = pluginApi?.mainInstance?.maxNoteCards || 20; + return count >= max ? Color.mError : Color.mOnSurface; + } + } + } + + // Clear all notes button + NButton { + Layout.alignment: Qt.AlignRight + text: pluginApi?.tr("settings.clear-all-notes") + icon: "trash" + visible: pluginApi?.pluginSettings?.notecardsEnabled ?? true + enabled: (pluginApi?.mainInstance?.noteCards?.length || 0) > 0 + onClicked: { + if (pluginApi?.mainInstance) { + pluginApi.mainInstance.clearAllNoteCards(); + } + } + } + + NDivider { + Layout.fillWidth: true + } + + // Hide Panel Background Toggle (hidden when Notecards enabled — no effect with notecards) + NToggle { + Layout.fillWidth: true + visible: !root.valueNotecardsEnabled + label: pluginApi?.tr("settings.hide-panel-background") + description: pluginApi?.tr("settings.hide-panel-background-desc") + checked: root.valueHidePanelBackground + onToggled: checked => { + root.valueHidePanelBackground = checked; + root._applyPreview("hidePanelBackground", checked); + } + } + + NDivider { + Layout.fillWidth: true + visible: !root.valueNotecardsEnabled + } + + // ===== AUTO-PASTE SECTION ===== + NText { + text: pluginApi?.tr("settings.auto-paste-section") + font.bold: true + font.pointSize: Style.fontSizeL + } + + // Auto-Paste Toggle + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.auto-paste") + description: pluginApi?.tr("settings.auto-paste-desc") + checked: root.valueAutoPaste + onToggled: checked => { + root.valueAutoPaste = checked; + root._applyPreview("autoPaste", checked); + } + } + + // Warning: wtype not installed (visible only when autoPaste=true and wtype unavailable) + Rectangle { + visible: root.valueAutoPaste && !(pluginApi?.mainInstance?.wtypeAvailable ?? false) + Layout.fillWidth: true + Layout.preferredHeight: warningText.implicitHeight + Style.marginM * 2 + color: (typeof Color !== "undefined") ? Qt.rgba(Color.mError.r, Color.mError.g, Color.mError.b, 0.15) : "#33CC0000" + radius: Style.radiusS + border.width: 1 + border.color: (typeof Color !== "undefined") ? Color.mError : "#CC0000" + + NText { + id: warningText + anchors.fill: parent + anchors.margins: Style.marginM + text: pluginApi?.tr("settings.auto-paste-warning") + wrapMode: Text.Wrap + color: (typeof Color !== "undefined") ? Color.mError : "#CC0000" + font.pointSize: Style.fontSizeS + } + } + + // RMB Only Toggle (visible only when autoPaste=true) + NToggle { + visible: root.valueAutoPaste + Layout.fillWidth: true + label: pluginApi?.tr("settings.auto-paste-rmb") + description: pluginApi?.tr("settings.auto-paste-rmb-desc") + checked: root.valueAutoPasteOnRightClick + onToggled: checked => { + root.valueAutoPasteOnRightClick = checked; + root._applyPreview("autoPasteOnRightClick", checked); + } + } + + // Paste Delay Row (visible only when autoPaste=true) + ColumnLayout { + visible: root.valueAutoPaste + Layout.fillWidth: true + spacing: Style.marginS + + NValueSlider { + Layout.fillWidth: true + label: pluginApi?.tr("settings.auto-paste-delay") + description: pluginApi?.tr("settings.auto-paste-delay-desc") + from: 100 + to: 1000 + stepSize: 50 + value: root.valueAutoPasteDelay + text: root.valueAutoPasteDelay + " ms" + onMoved: value => { + root.valueAutoPasteDelay = Math.round(value); + root._applyPreview("autoPasteDelay", Math.round(value)); + } + } + } + + NDivider { + Layout.fillWidth: true + } + + // Show close button toggle + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.show-close-button") + description: pluginApi?.tr("settings.show-close-button-desc") + checked: root.valueShowCloseButton + onToggled: checked => { + root.valueShowCloseButton = checked; + root._applyPreview("showCloseButton", checked); + } + } + } // End General Tab + + // TAB 2: APPEARANCE + ColumnLayout { + spacing: Style.marginL + + // ===== APPEARANCE SECTION ===== + NText { + text: pluginApi?.tr("settings.appearance") + font.bold: true + font.pointSize: Style.fontSizeL + } + + // Card type selector + NComboBox { + Layout.fillWidth: true + label: pluginApi?.tr("settings.card-type") + description: pluginApi?.tr("settings.card-type-desc") + model: root.cardTypes + currentKey: root.selectedCardType + onSelected: key => root.selectedCardType = key + } + + // Live Preview + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 280 + color: (typeof Color !== "undefined") ? Color.mSurfaceVariant : "#333333" + radius: Style.radiusM + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginS + + NText { + text: pluginApi?.tr("settings.preview") + font.bold: true + color: Color.mOnSurface + } + + // Preview card + Rectangle { + Layout.preferredWidth: 250 + Layout.preferredHeight: 220 + Layout.alignment: Qt.AlignHCenter + color: root.getPreviewBg() + radius: Style.radiusM + border.width: 2 + border.color: root.getPreviewBg() + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // Header + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 32 + color: root.getPreviewBg() + radius: Style.radiusM + + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: parent.radius + color: parent.color + } + + RowLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + NIcon { + icon: root.selectedCardType === "Image" ? "photo" : root.selectedCardType === "Link" ? "link" : root.selectedCardType === "Code" ? "code" : root.selectedCardType === "Color" ? "palette" : root.selectedCardType === "Emoji" ? "mood-smile" : root.selectedCardType === "File" ? "file" : "align-left" + pointSize: 12 + color: root.getPreviewFg() + } + + NText { + text: root.selectedCardType + font.bold: true + color: root.getPreviewFg() + } + + Item { + Layout.fillWidth: true + } + + NIcon { + icon: "trash" + pointSize: 12 + color: root.getPreviewFg() + } + } + } + + // Separator + Rectangle { + Layout.preferredWidth: parent.width - 10 + Layout.alignment: Qt.AlignHCenter + Layout.preferredHeight: 1 + color: root.getPreviewSeparator() + } + + // Content area + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.margins: 8 + + NText { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + text: pluginApi?.tr("settings.sample-content") + wrapMode: Text.Wrap + color: root.getPreviewFg() + verticalAlignment: Text.AlignTop + } + } + } + } + } + } + + // Color settings + NComboBox { + Layout.fillWidth: true + label: pluginApi?.tr("settings.bg-color") + description: pluginApi?.tr("settings.bg-color-desc") + model: root.colorOptions + currentKey: root.cardColors[root.selectedCardType]?.bg || "mOutline" + onSelected: key => { + if (!root.pendingCardColors[root.selectedCardType]) + root.pendingCardColors[root.selectedCardType] = {}; + root.pendingCardColors[root.selectedCardType].bg = key; + root.cardColors = JSON.parse(JSON.stringify(root.pendingCardColors)); + } + } + + NColorPicker { + visible: root.cardColors[root.selectedCardType]?.bg === "custom" + Layout.preferredWidth: Style.sliderWidth + Layout.preferredHeight: Style.baseWidgetSize + selectedColor: root.customColors[root.selectedCardType]?.bg || "#888888" + onColorSelected: color => { + if (!root.pendingCustomColors[root.selectedCardType]) + root.pendingCustomColors[root.selectedCardType] = {}; + root.pendingCustomColors[root.selectedCardType].bg = color.toString(); + root.customColors = JSON.parse(JSON.stringify(root.pendingCustomColors)); + } + } + + NComboBox { + Layout.fillWidth: true + label: pluginApi?.tr("settings.separator-color") + description: pluginApi?.tr("settings.separator-color-desc") + model: root.colorOptions + currentKey: root.cardColors[root.selectedCardType]?.separator || "mSurface" + onSelected: key => { + if (!root.pendingCardColors[root.selectedCardType]) + root.pendingCardColors[root.selectedCardType] = {}; + root.pendingCardColors[root.selectedCardType].separator = key; + root.cardColors = JSON.parse(JSON.stringify(root.pendingCardColors)); + } + } + + NColorPicker { + visible: root.cardColors[root.selectedCardType]?.separator === "custom" + Layout.preferredWidth: Style.sliderWidth + Layout.preferredHeight: Style.baseWidgetSize + selectedColor: root.customColors[root.selectedCardType]?.separator || "#000000" + onColorSelected: color => { + if (!root.pendingCustomColors[root.selectedCardType]) + root.pendingCustomColors[root.selectedCardType] = {}; + root.pendingCustomColors[root.selectedCardType].separator = color.toString(); + root.customColors = JSON.parse(JSON.stringify(root.pendingCustomColors)); + } + } + + NComboBox { + Layout.fillWidth: true + label: pluginApi?.tr("settings.fg-color") + description: pluginApi?.tr("settings.fg-color-desc") + model: root.colorOptions + currentKey: root.cardColors[root.selectedCardType]?.fg || "mOnSurface" + onSelected: key => { + if (!root.pendingCardColors[root.selectedCardType]) + root.pendingCardColors[root.selectedCardType] = {}; + root.pendingCardColors[root.selectedCardType].fg = key; + root.cardColors = JSON.parse(JSON.stringify(root.pendingCardColors)); + } + } + + NColorPicker { + visible: root.cardColors[root.selectedCardType]?.fg === "custom" + Layout.preferredWidth: Style.sliderWidth + Layout.preferredHeight: Style.baseWidgetSize + selectedColor: root.customColors[root.selectedCardType]?.fg || "#e9e4f0" + onColorSelected: color => { + if (!root.pendingCustomColors[root.selectedCardType]) + root.pendingCustomColors[root.selectedCardType] = {}; + root.pendingCustomColors[root.selectedCardType].fg = color.toString(); + root.customColors = JSON.parse(JSON.stringify(root.pendingCustomColors)); + } + } + + // Reset button + NButton { + Layout.alignment: Qt.AlignRight + text: pluginApi?.tr("settings.reset-defaults") + icon: "refresh" + onClicked: { + const defaults = JSON.parse(JSON.stringify(root.defaultCardColors)); + const defaultCustom = { + "Text": { + bg: "#555555", + separator: "#000000", + fg: "#e9e4f0" + }, + "Image": { + bg: "#e0b7c9", + separator: "#000000", + fg: "#20161f" + }, + "Link": { + bg: "#c7a1d8", + separator: "#000000", + fg: "#1a151f" + }, + "Code": { + bg: "#a984c4", + separator: "#000000", + fg: "#f3edf7" + }, + "Color": { + bg: "#a984c4", + separator: "#000000", + fg: "#f3edf7" + }, + "Emoji": { + bg: "#e0b7c9", + separator: "#000000", + fg: "#20161f" + }, + "File": { + bg: "#e9899d", + separator: "#000000", + fg: "#1e1418" + } + }; + root.pendingCardColors = defaults; + root.pendingCustomColors = defaultCustom; + root.cardColors = JSON.parse(JSON.stringify(defaults)); + root.customColors = JSON.parse(JSON.stringify(defaultCustom)); + } + } + } // End Appearance Tab + + } // End NTabView + + function saveSettings() { + if (!pluginApi) + return; + + // Belt-and-suspenders: guarantee final state is correct even if a _applyPreview was missed. + pluginApi.pluginSettings.enableTodoIntegration = root.valueTodoIntegration; + pluginApi.pluginSettings.pincardsEnabled = root.valuePincardsEnabled; + pluginApi.pluginSettings.notecardsEnabled = root.valueNotecardsEnabled; + pluginApi.pluginSettings.showCloseButton = root.valueShowCloseButton; + pluginApi.pluginSettings.fullscreenMode = root.valueFullscreenMode; + pluginApi.pluginSettings.hidePanelBackground = root.valueHidePanelBackground; + pluginApi.pluginSettings.autoPaste = root.valueAutoPaste; + pluginApi.pluginSettings.autoPasteOnRightClick = root.valueAutoPasteOnRightClick; + pluginApi.pluginSettings.autoPasteDelay = root.valueAutoPasteDelay; + pluginApi.pluginSettings.panelWidth = root.valuePanelWidth; + pluginApi.pluginSettings.panelHeight = root.valuePanelHeight; + pluginApi.pluginSettings.cardColors = JSON.parse(JSON.stringify(root.pendingCardColors)); + pluginApi.pluginSettings.customColors = JSON.parse(JSON.stringify(root.pendingCustomColors)); + + if (pluginApi.mainInstance) { + pluginApi.mainInstance.showCloseButton = root.valueShowCloseButton; + } + + _applied = true; + pluginApi.saveSettings(); + } +} diff --git a/noctalia/plugins/clipper/TodoPageSelector.qml b/noctalia/plugins/clipper/TodoPageSelector.qml new file mode 100644 index 0000000..60b162a --- /dev/null +++ b/noctalia/plugins/clipper/TodoPageSelector.qml @@ -0,0 +1,49 @@ +import QtQuick +import Quickshell + +// Todo page selector wrapper - prepares menu items and handles selection +QtObject { + id: root + + property var pluginApi: null + property string selectedText: "" + property var todoPages: [] + property var selectionMenu: null + + signal pageSelected(int pageId, string pageName) + + function buildMenuModel() { + const model = []; + + // Add existing todo pages only + for (let i = 0; i < todoPages.length; i++) { + const page = todoPages[i]; + model.push({ + "label": page.name || pluginApi?.tr("notecards.untitled-placeholder"), + "action": "page-" + page.id, + "icon": "list", + "pageId": page.id + }); + } + + return model; + } + + function show(text, pages) { + selectedText = text || ""; + todoPages = pages || []; + + if (selectionMenu) { + const menuItems = buildMenuModel(); + selectionMenu.show(menuItems); + } else {} + } + + function handleItemSelected(action) { + if (action.startsWith("page-")) { + const pageId = parseInt(action.replace("page-", "")); + const page = todoPages.find(p => p.id === pageId); + root.pageSelected(pageId, page ? page.name : pluginApi?.tr("notecards.untitled-placeholder")); + } + } +} diff --git a/noctalia/plugins/clipper/i18n/de.json b/noctalia/plugins/clipper/i18n/de.json new file mode 100644 index 0000000..616caeb --- /dev/null +++ b/noctalia/plugins/clipper/i18n/de.json @@ -0,0 +1,122 @@ +{ + "bar": { + "tooltip": "Zwischenablage-Verlauf" + }, + "context": { + "toggle": "Clipper umschalten", + "settings": "Einstellungen öffnen" + }, + "panel": { + "title": "Zwischenablage-Verlauf", + "pinned-title": "Angeheftete Elemente", + "no-pinned": "Keine angehefteten Elemente", + "settings": "Einstellungen", + "close": "Close", + "search-placeholder": "Suchen...", + "filter-all": "Alle", + "filter-text": "Text", + "filter-images": "Bilder", + "filter-colors": "Farben", + "filter-links": "Links", + "filter-code": "Code", + "filter-emoji": "Emoji", + "filter-files": "Dateien", + "clear-all": "Alles löschen", + "no-matches": "Keine übereinstimmenden Elemente", + "empty": "Zwischenablage ist leer" + }, + "card": { + "add-todo": "Zur Aufgabenliste hinzufügen", + "pin": "Anheften", + "delete": "Löschen" + }, + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note", + "untitled-placeholder": "Untitled" + }, + "toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "item-not-found": "Item not found in clipboard", + "failed-to-pin": "Failed to pin item", + "failed-to-pin-image": "Failed to pin image", + "image-too-large": "Image too large to pin (max 5MB)", + "text-too-large": "Text too large to pin (max 1MB)", + "item-pinned": "Item pinned", + "item-unpinned": "Item unpinned", + "max-notes": "Maximum {max} notes reached", + "note-created": "Note created", + "note-deleted": "Note deleted", + "note-not-found": "Note not found", + "note-exported": "Note exported to ~/Documents/{fileName}", + "copied-to-clipboard": "Copied to clipboard", + "failed-to-copy-image": "Failed to copy image", + "failed-to-copy-text": "Failed to copy text", + "no-text-selected": "No text selected", + "failed-to-get-selection": "Failed to get selection", + "no-text-to-add": "No text to add", + "todo-not-available": "ToDo plugin not available", + "added-to-todo": "Added to ToDo", + "failed-to-copy": "Failed to copy to clipboard", + "todo-disabled": "ToDo integration is disabled", + "could-not-open-todo": "Could not open ToDo selector", + "could-not-open-note-selector": "Could not open note selector", + "text-added-to-note": "Text added to note", + "todo-page-created": "New ToDo page created", + "failed-to-get-cursor-position": "Failed to get cursor position", + "notes-cleared": "All notes cleared", + "pinned-cleared": "All pinned items cleared" + }, + "settings": { + "tab-general": "General", + "tab-appearance": "Appearance", + "integrations": "Integrationen", + "todo-integration": "ToDo-Plugin-Integration", + "todo-description": "Zwischenablage-Elemente direkt zu Ihrer Aufgabenliste hinzufügen", + "todo-disabled": "ToDo-Plugin ist nicht installiert oder deaktiviert", + "features": "Features", + "pincards-enabled": "Enable Pin Cards", + "pincards-desc": "Show pinned items panel and allow pinning clipboard items", + "pincards-items-count": "Pinned Items", + "clear-all-pinned": "Clear All Pinned Items", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes", + "show-close-button": "Show Close Button", + "show-close-button-desc": "Display an X button at the top-right corner to close the panel", + "fullscreen-mode": "Vollbildmodus", + "fullscreen-mode-desc": "Das Clipboard-Panel auf den gesamten Bildschirm erweitern", + "hide-panel-background": "Panelhintergrund ausblenden", + "hide-panel-background-desc": "Hintergrund der Panelcontainer entfernen. Hinweis: Pin Cards und Note Cards funktionieren am besten mit aktiviertem Hintergrund.", + "auto-paste-section": "Automatisches Einfügen", + "auto-paste": "Beim Klick automatisch einfügen", + "auto-paste-desc": "Clipboard-Inhalt nach der Auswahl automatisch in das fokussierte Fenster einfügen", + "auto-paste-warning": "wtype wird für das automatische Einfügen benötigt. Installieren mit: sudo pacman -S wtype", + "auto-paste-rmb": "Nur Rechtsklick", + "auto-paste-rmb-desc": "Rechtsklick zum automatischen Einfügen; Linksklick kopiert nur in die Zwischenablage", + "auto-paste-delay": "Einfügeverzögerung", + "auto-paste-delay-desc": "Verzögerung vor dem Einfügen (ms). Erhöhen wenn Fokus-folgt-Maus in Ihrem Compositor aktiv ist.", + "appearance": "Kartendarstellung", + "card-type": "Kartentyp", + "card-type-desc": "Wählen Sie den Kartentyp zum Anpassen", + "preview": "Vorschau", + "sample-content": "Beispielinhalt...", + "bg-color": "Hintergrundfarbe", + "bg-color-desc": "Hintergrundfarbe der Karte", + "separator-color": "Trennlinienfarbe", + "separator-color-desc": "Linie zwischen Kopfzeile und Inhalt", + "fg-color": "Vordergrundfarbe", + "fg-color-desc": "Farbe für Titel, Symbole und Textinhalt", + "reset-defaults": "Auf Standardwerte zurücksetzen", + "panel-width": "Panelbreite", + "panel-width-desc": "Breite des Panels in Pixeln (nur wenn der Vollbildmodus deaktiviert ist)", + "panel-height": "Panelhöhe", + "panel-height-desc": "Höhe des Panels in Pixeln. Auf 0 setzen für automatische Höhe (nur wenn der Vollbildmodus deaktiviert ist)" + } +} diff --git a/noctalia/plugins/clipper/i18n/en.json b/noctalia/plugins/clipper/i18n/en.json new file mode 100644 index 0000000..f811965 --- /dev/null +++ b/noctalia/plugins/clipper/i18n/en.json @@ -0,0 +1,122 @@ +{ + "bar": { + "tooltip": "Clipboard History" + }, + "context": { + "toggle": "Toggle Clipper", + "settings": "Open Settings" + }, + "panel": { + "title": "Clipboard History", + "pinned-title": "Pinned Items", + "no-pinned": "No pinned items", + "settings": "Settings", + "close": "Close", + "search-placeholder": "Search...", + "filter-all": "All", + "filter-text": "Text", + "filter-images": "Images", + "filter-colors": "Colors", + "filter-links": "Links", + "filter-code": "Code", + "filter-emoji": "Emoji", + "filter-files": "Files", + "clear-all": "Clear All", + "no-matches": "No matching items", + "empty": "Clipboard is empty" + }, + "card": { + "add-todo": "Add to ToDo", + "pin": "Pin", + "delete": "Delete" + }, + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note", + "untitled-placeholder": "Untitled" + }, + "toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "item-not-found": "Item not found in clipboard", + "failed-to-pin": "Failed to pin item", + "failed-to-pin-image": "Failed to pin image", + "image-too-large": "Image too large to pin (max 5MB)", + "text-too-large": "Text too large to pin (max 1MB)", + "item-pinned": "Item pinned", + "item-unpinned": "Item unpinned", + "max-notes": "Maximum {max} notes reached", + "note-created": "Note created", + "note-deleted": "Note deleted", + "note-not-found": "Note not found", + "note-exported": "Note exported to ~/Documents/{fileName}", + "copied-to-clipboard": "Copied to clipboard", + "failed-to-copy-image": "Failed to copy image", + "failed-to-copy-text": "Failed to copy text", + "no-text-selected": "No text selected", + "failed-to-get-selection": "Failed to get selection", + "no-text-to-add": "No text to add", + "todo-not-available": "ToDo plugin not available", + "added-to-todo": "Added to ToDo", + "failed-to-copy": "Failed to copy to clipboard", + "todo-disabled": "ToDo integration is disabled", + "could-not-open-todo": "Could not open ToDo selector", + "could-not-open-note-selector": "Could not open note selector", + "text-added-to-note": "Text added to note", + "todo-page-created": "New ToDo page created", + "failed-to-get-cursor-position": "Failed to get cursor position", + "notes-cleared": "All notes cleared", + "pinned-cleared": "All pinned items cleared" + }, + "settings": { + "tab-general": "General", + "tab-appearance": "Appearance", + "integrations": "Integrations", + "todo-integration": "ToDo Plugin Integration", + "todo-description": "Add clipboard items directly to your ToDo list", + "todo-disabled": "ToDo plugin is not installed or disabled", + "features": "Features", + "pincards-enabled": "Enable Pin Cards", + "pincards-desc": "Show pinned items panel and allow pinning clipboard items", + "pincards-items-count": "Pinned Items", + "clear-all-pinned": "Clear All Pinned Items", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes and sticky notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes", + "show-close-button": "Show Close Button", + "show-close-button-desc": "Display an X button at the top-right corner to close the panel", + "fullscreen-mode": "Fullscreen Mode", + "fullscreen-mode-desc": "Expand the clipboard panel to fill the entire screen", + "hide-panel-background": "Hide Panel Background", + "hide-panel-background-desc": "Remove background from panel containers. Note: Pin Cards and Note Cards work best with background enabled.", + "auto-paste-section": "Auto-Paste", + "auto-paste": "Auto-Paste on Click", + "auto-paste-desc": "Automatically paste clipboard item into focused window after selecting", + "auto-paste-warning": "wtype is required for auto-paste. Install with: sudo pacman -S wtype", + "auto-paste-rmb": "Right-Click Only", + "auto-paste-rmb-desc": "Use right-click for auto-paste; left-click copies to clipboard without pasting", + "auto-paste-delay": "Paste Delay", + "auto-paste-delay-desc": "Delay before pasting (ms). Increase if focus follows cursor is enabled in your compositor.", + "appearance": "Card Appearance", + "card-type": "Card Type", + "card-type-desc": "Select card type to customize", + "preview": "Preview", + "sample-content": "Sample content preview...", + "bg-color": "Background Color", + "bg-color-desc": "Card background color", + "separator-color": "Separator Color", + "separator-color-desc": "Line between header and content", + "fg-color": "Foreground Color", + "fg-color-desc": "Title, icons and content text color", + "reset-defaults": "Reset to Defaults", + "panel-width": "Panel Width", + "panel-width-desc": "Width of the panel in pixels (only when fullscreen mode is disabled)", + "panel-height": "Panel Height", + "panel-height-desc": "Height of the panel in pixels. Set to 0 for automatic height (only when fullscreen mode is disabled)" + } +} diff --git a/noctalia/plugins/clipper/i18n/es.json b/noctalia/plugins/clipper/i18n/es.json new file mode 100644 index 0000000..b3a15e9 --- /dev/null +++ b/noctalia/plugins/clipper/i18n/es.json @@ -0,0 +1,122 @@ +{ + "bar": { + "tooltip": "Historial del portapapeles" + }, + "context": { + "toggle": "Alternar Clipper", + "settings": "Abrir configuración" + }, + "panel": { + "title": "Historial del portapapeles", + "pinned-title": "Elementos fijados", + "no-pinned": "Sin elementos fijados", + "settings": "Configuración", + "close": "Close", + "search-placeholder": "Buscar...", + "filter-all": "Todos", + "filter-text": "Texto", + "filter-images": "Imágenes", + "filter-colors": "Colores", + "filter-links": "Enlaces", + "filter-code": "Código", + "filter-emoji": "Emoji", + "filter-files": "Archivos", + "clear-all": "Borrar todo", + "no-matches": "Sin coincidencias", + "empty": "El portapapeles está vacío" + }, + "card": { + "add-todo": "Añadir a tareas", + "pin": "Fijar", + "delete": "Eliminar" + }, + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note", + "untitled-placeholder": "Untitled" + }, + "toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "item-not-found": "Item not found in clipboard", + "failed-to-pin": "Failed to pin item", + "failed-to-pin-image": "Failed to pin image", + "image-too-large": "Image too large to pin (max 5MB)", + "text-too-large": "Text too large to pin (max 1MB)", + "item-pinned": "Item pinned", + "item-unpinned": "Item unpinned", + "max-notes": "Maximum {max} notes reached", + "note-created": "Note created", + "note-deleted": "Note deleted", + "note-not-found": "Note not found", + "note-exported": "Note exported to ~/Documents/{fileName}", + "copied-to-clipboard": "Copied to clipboard", + "failed-to-copy-image": "Failed to copy image", + "failed-to-copy-text": "Failed to copy text", + "no-text-selected": "No text selected", + "failed-to-get-selection": "Failed to get selection", + "no-text-to-add": "No text to add", + "todo-not-available": "ToDo plugin not available", + "added-to-todo": "Added to ToDo", + "failed-to-copy": "Failed to copy to clipboard", + "todo-disabled": "ToDo integration is disabled", + "could-not-open-todo": "Could not open ToDo selector", + "could-not-open-note-selector": "Could not open note selector", + "text-added-to-note": "Text added to note", + "todo-page-created": "New ToDo page created", + "failed-to-get-cursor-position": "Failed to get cursor position", + "notes-cleared": "All notes cleared", + "pinned-cleared": "All pinned items cleared" + }, + "settings": { + "tab-general": "General", + "tab-appearance": "Appearance", + "integrations": "Integraciones", + "todo-integration": "Integración con ToDo", + "todo-description": "Añadir elementos del portapapeles directamente a tu lista de tareas", + "todo-disabled": "El plugin ToDo no está instalado o está deshabilitado", + "features": "Features", + "pincards-enabled": "Enable Pin Cards", + "pincards-desc": "Show pinned items panel and allow pinning clipboard items", + "pincards-items-count": "Pinned Items", + "clear-all-pinned": "Clear All Pinned Items", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes", + "show-close-button": "Show Close Button", + "show-close-button-desc": "Display an X button at the top-right corner to close the panel", + "fullscreen-mode": "Modo pantalla completa", + "fullscreen-mode-desc": "Expandir el panel al tamaño completo de la pantalla", + "hide-panel-background": "Ocultar fondo del panel", + "hide-panel-background-desc": "Eliminar el fondo de los contenedores del panel. Nota: Las tarjetas ancladas y las notas funcionan mejor con el fondo activado.", + "auto-paste-section": "Pegado automático", + "auto-paste": "Pegar automáticamente al hacer clic", + "auto-paste-desc": "Pegar automáticamente el elemento seleccionado en la ventana activa", + "auto-paste-warning": "wtype es necesario para el pegado automático. Instalar con: sudo pacman -S wtype", + "auto-paste-rmb": "Solo clic derecho", + "auto-paste-rmb-desc": "Clic derecho para pegar automáticamente; clic izquierdo solo copia al portapapeles", + "auto-paste-delay": "Retraso de pegado", + "auto-paste-delay-desc": "Retraso antes de pegar (ms). Aumentar si focus-follows-cursor está activado en tu compositor.", + "appearance": "Apariencia de tarjetas", + "card-type": "Tipo de tarjeta", + "card-type-desc": "Selecciona el tipo de tarjeta para personalizar", + "preview": "Vista previa", + "sample-content": "Contenido de ejemplo...", + "bg-color": "Color de fondo", + "bg-color-desc": "Color de fondo de la tarjeta", + "separator-color": "Color del separador", + "separator-color-desc": "Línea entre el encabezado y el contenido", + "fg-color": "Color del texto", + "fg-color-desc": "Color del título, iconos y contenido de texto", + "reset-defaults": "Restaurar valores predeterminados", + "panel-width": "Ancho del panel", + "panel-width-desc": "Ancho del panel en píxeles (solo cuando el modo pantalla completa está desactivado)", + "panel-height": "Alto del panel", + "panel-height-desc": "Alto del panel en píxeles. Establecer en 0 para altura automática (solo cuando el modo pantalla completa está desactivado)" + } +} diff --git a/noctalia/plugins/clipper/i18n/fr.json b/noctalia/plugins/clipper/i18n/fr.json new file mode 100644 index 0000000..db8e18f --- /dev/null +++ b/noctalia/plugins/clipper/i18n/fr.json @@ -0,0 +1,122 @@ +{ + "bar": { + "tooltip": "Historique du presse-papiers" + }, + "context": { + "toggle": "Basculer Clipper", + "settings": "Ouvrir les paramètres" + }, + "panel": { + "title": "Historique du presse-papiers", + "pinned-title": "Éléments épinglés", + "no-pinned": "Aucun élément épinglé", + "settings": "Paramètres", + "close": "Close", + "search-placeholder": "Rechercher...", + "filter-all": "Tous", + "filter-text": "Texte", + "filter-images": "Images", + "filter-colors": "Couleurs", + "filter-links": "Liens", + "filter-code": "Code", + "filter-emoji": "Emoji", + "filter-files": "Fichiers", + "clear-all": "Tout effacer", + "no-matches": "Aucun élément correspondant", + "empty": "Le presse-papiers est vide" + }, + "card": { + "add-todo": "Ajouter aux tâches", + "pin": "Épingler", + "delete": "Supprimer" + }, + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note", + "untitled-placeholder": "Untitled" + }, + "toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "item-not-found": "Item not found in clipboard", + "failed-to-pin": "Failed to pin item", + "failed-to-pin-image": "Failed to pin image", + "image-too-large": "Image too large to pin (max 5MB)", + "text-too-large": "Text too large to pin (max 1MB)", + "item-pinned": "Item pinned", + "item-unpinned": "Item unpinned", + "max-notes": "Maximum {max} notes reached", + "note-created": "Note created", + "note-deleted": "Note deleted", + "note-not-found": "Note not found", + "note-exported": "Note exported to ~/Documents/{fileName}", + "copied-to-clipboard": "Copied to clipboard", + "failed-to-copy-image": "Failed to copy image", + "failed-to-copy-text": "Failed to copy text", + "no-text-selected": "No text selected", + "failed-to-get-selection": "Failed to get selection", + "no-text-to-add": "No text to add", + "todo-not-available": "ToDo plugin not available", + "added-to-todo": "Added to ToDo", + "failed-to-copy": "Failed to copy to clipboard", + "todo-disabled": "ToDo integration is disabled", + "could-not-open-todo": "Could not open ToDo selector", + "could-not-open-note-selector": "Could not open note selector", + "text-added-to-note": "Text added to note", + "todo-page-created": "New ToDo page created", + "failed-to-get-cursor-position": "Failed to get cursor position", + "notes-cleared": "All notes cleared", + "pinned-cleared": "All pinned items cleared" + }, + "settings": { + "tab-general": "General", + "tab-appearance": "Appearance", + "integrations": "Intégrations", + "todo-integration": "Intégration du plugin ToDo", + "todo-description": "Ajouter des éléments du presse-papiers directement à votre liste de tâches", + "todo-disabled": "Le plugin ToDo n'est pas installé ou est désactivé", + "features": "Features", + "pincards-enabled": "Enable Pin Cards", + "pincards-desc": "Show pinned items panel and allow pinning clipboard items", + "pincards-items-count": "Pinned Items", + "clear-all-pinned": "Clear All Pinned Items", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes", + "show-close-button": "Show Close Button", + "show-close-button-desc": "Display an X button at the top-right corner to close the panel", + "fullscreen-mode": "Mode plein écran", + "fullscreen-mode-desc": "Étendre le panneau au plein écran", + "hide-panel-background": "Masquer l'arrière-plan", + "hide-panel-background-desc": "Supprimer l'arrière-plan des conteneurs du panneau. Note: Les cartes épinglées et les notes fonctionnent mieux avec l'arrière-plan activé.", + "auto-paste-section": "Collage automatique", + "auto-paste": "Coller automatiquement au clic", + "auto-paste-desc": "Coller automatiquement l'élément sélectionné dans la fenêtre active", + "auto-paste-warning": "wtype est requis pour le collage automatique. Installer avec: sudo pacman -S wtype", + "auto-paste-rmb": "Clic droit uniquement", + "auto-paste-rmb-desc": "Clic droit pour coller automatiquement; clic gauche copie dans le presse-papiers", + "auto-paste-delay": "Délai de collage", + "auto-paste-delay-desc": "Délai avant le collage (ms). Augmenter si focus-suit-curseur est activé dans votre compositeur.", + "appearance": "Apparence des cartes", + "card-type": "Type de carte", + "card-type-desc": "Sélectionner le type de carte à personnaliser", + "preview": "Aperçu", + "sample-content": "Aperçu du contenu...", + "bg-color": "Couleur d'arrière-plan", + "bg-color-desc": "Couleur d'arrière-plan de la carte", + "separator-color": "Couleur du séparateur", + "separator-color-desc": "Ligne entre l'en-tête et le contenu", + "fg-color": "Couleur de premier plan", + "fg-color-desc": "Couleur du titre, des icônes et du texte", + "reset-defaults": "Réinitialiser les valeurs par défaut", + "panel-width": "Largeur du panneau", + "panel-width-desc": "Largeur du panneau en pixels (uniquement quand le mode plein écran est désactivé)", + "panel-height": "Hauteur du panneau", + "panel-height-desc": "Hauteur du panneau en pixels. Mettre à 0 pour hauteur automatique (uniquement quand le mode plein écran est désactivé)" + } +} diff --git a/noctalia/plugins/clipper/i18n/hu.json b/noctalia/plugins/clipper/i18n/hu.json new file mode 100644 index 0000000..cb69476 --- /dev/null +++ b/noctalia/plugins/clipper/i18n/hu.json @@ -0,0 +1,122 @@ +{ + "bar": { + "tooltip": "Vágólap előzmények" + }, + "context": { + "toggle": "Clipper váltása", + "settings": "Beállítások megnyitása" + }, + "panel": { + "title": "Vágólap előzmények", + "pinned-title": "Rögzített elemek", + "no-pinned": "Nincs rögzített elem", + "settings": "Beállítások", + "close": "Close", + "search-placeholder": "Keresés...", + "filter-all": "Minden", + "filter-text": "Szöveg", + "filter-images": "Képek", + "filter-colors": "Színek", + "filter-links": "Hivatkozások", + "filter-code": "Kód", + "filter-emoji": "Emoji", + "filter-files": "Fájlok", + "clear-all": "Összes törlése", + "no-matches": "Nincs egyező elem", + "empty": "A vágólap üres" + }, + "card": { + "add-todo": "Hozzáadás a feladatokhoz", + "pin": "Rögzítés", + "delete": "Törlés" + }, + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note", + "untitled-placeholder": "Untitled" + }, + "toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "item-not-found": "Item not found in clipboard", + "failed-to-pin": "Failed to pin item", + "failed-to-pin-image": "Failed to pin image", + "image-too-large": "Image too large to pin (max 5MB)", + "text-too-large": "Text too large to pin (max 1MB)", + "item-pinned": "Item pinned", + "item-unpinned": "Item unpinned", + "max-notes": "Maximum {max} notes reached", + "note-created": "Note created", + "note-deleted": "Note deleted", + "note-not-found": "Note not found", + "note-exported": "Note exported to ~/Documents/{fileName}", + "copied-to-clipboard": "Copied to clipboard", + "failed-to-copy-image": "Failed to copy image", + "failed-to-copy-text": "Failed to copy text", + "no-text-selected": "No text selected", + "failed-to-get-selection": "Failed to get selection", + "no-text-to-add": "No text to add", + "todo-not-available": "ToDo plugin not available", + "added-to-todo": "Added to ToDo", + "failed-to-copy": "Failed to copy to clipboard", + "todo-disabled": "ToDo integration is disabled", + "could-not-open-todo": "Could not open ToDo selector", + "could-not-open-note-selector": "Could not open note selector", + "text-added-to-note": "Text added to note", + "todo-page-created": "New ToDo page created", + "failed-to-get-cursor-position": "Failed to get cursor position", + "notes-cleared": "All notes cleared", + "pinned-cleared": "All pinned items cleared" + }, + "settings": { + "tab-general": "General", + "tab-appearance": "Appearance", + "integrations": "Integrációk", + "todo-integration": "ToDo beépülő integráció", + "todo-description": "Vágólap elemek hozzáadása közvetlenül a feladatlistához", + "todo-disabled": "A ToDo beépülő nincs telepítve vagy le van tiltva", + "features": "Features", + "pincards-enabled": "Enable Pin Cards", + "pincards-desc": "Show pinned items panel and allow pinning clipboard items", + "pincards-items-count": "Pinned Items", + "clear-all-pinned": "Clear All Pinned Items", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes", + "show-close-button": "Show Close Button", + "show-close-button-desc": "Display an X button at the top-right corner to close the panel", + "fullscreen-mode": "Fullscreen Mode", + "fullscreen-mode-desc": "Expand the clipboard panel to fill the entire screen", + "hide-panel-background": "Hide Panel Background", + "hide-panel-background-desc": "Remove background from panel containers. Note: Pin Cards and Note Cards work best with background enabled.", + "auto-paste-section": "Auto-Paste", + "auto-paste": "Auto-Paste on Click", + "auto-paste-desc": "Automatically paste clipboard item into focused window after selecting", + "auto-paste-warning": "wtype is required for auto-paste. Install with: sudo pacman -S wtype", + "auto-paste-rmb": "Right-Click Only", + "auto-paste-rmb-desc": "Use right-click for auto-paste; left-click copies to clipboard without pasting", + "auto-paste-delay": "Paste Delay", + "auto-paste-delay-desc": "Delay before pasting (ms). Increase if focus follows cursor is enabled in your compositor.", + "appearance": "Kártya megjelenése", + "card-type": "Kártya típusa", + "card-type-desc": "Válassza ki a testreszabandó kártya típust", + "preview": "Előnézet", + "sample-content": "Minta tartalom előnézete...", + "bg-color": "Háttérszín", + "bg-color-desc": "Kártya háttérszíne", + "separator-color": "Elválasztó színe", + "separator-color-desc": "Vonal a fejléc és a tartalom között", + "fg-color": "Előtérszín", + "fg-color-desc": "Cím, ikonok és szöveges tartalom színe", + "reset-defaults": "Alapértelmezések visszaállítása", + "panel-width": "Panel szélessége", + "panel-width-desc": "A panel szélessége képpontokban (csak ha a teljes képernyős mód ki van kapcsolva)", + "panel-height": "Panel magassága", + "panel-height-desc": "A panel magassága képpontokban. 0 értéknél automatikus magasság (csak ha a teljes képernyős mód ki van kapcsolva)" + } +} diff --git a/noctalia/plugins/clipper/i18n/it.json b/noctalia/plugins/clipper/i18n/it.json new file mode 100644 index 0000000..c4e0104 --- /dev/null +++ b/noctalia/plugins/clipper/i18n/it.json @@ -0,0 +1,122 @@ +{ + "bar": { + "tooltip": "Cronologia appunti" + }, + "context": { + "toggle": "Attiva/disattiva Clipper", + "settings": "Apri impostazioni" + }, + "panel": { + "title": "Cronologia appunti", + "pinned-title": "Elementi bloccati", + "no-pinned": "Nessun elemento bloccato", + "settings": "Impostazioni", + "close": "Chiudi", + "search-placeholder": "Cerca...", + "filter-all": "Tutti", + "filter-text": "Testo", + "filter-images": "Immagini", + "filter-colors": "Colori", + "filter-links": "Link", + "filter-code": "Codice", + "filter-emoji": "Emoji", + "filter-files": "File", + "clear-all": "Cancella tutto", + "no-matches": "Nessun elemento corrispondente", + "empty": "Gli appunti sono vuoti" + }, + "card": { + "add-todo": "Aggiungi a ToDo", + "pin": "Blocca", + "delete": "Elimina" + }, + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note", + "untitled-placeholder": "Untitled" + }, + "toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "item-not-found": "Item not found in clipboard", + "failed-to-pin": "Failed to pin item", + "failed-to-pin-image": "Failed to pin image", + "image-too-large": "Image too large to pin (max 5MB)", + "text-too-large": "Text too large to pin (max 1MB)", + "item-pinned": "Item pinned", + "item-unpinned": "Item unpinned", + "max-notes": "Maximum {max} notes reached", + "note-created": "Note created", + "note-deleted": "Note deleted", + "note-not-found": "Note not found", + "note-exported": "Note exported to ~/Documents/{fileName}", + "copied-to-clipboard": "Copied to clipboard", + "failed-to-copy-image": "Failed to copy image", + "failed-to-copy-text": "Failed to copy text", + "no-text-selected": "No text selected", + "failed-to-get-selection": "Failed to get selection", + "no-text-to-add": "No text to add", + "todo-not-available": "ToDo plugin not available", + "added-to-todo": "Added to ToDo", + "failed-to-copy": "Failed to copy to clipboard", + "todo-disabled": "ToDo integration is disabled", + "could-not-open-todo": "Could not open ToDo selector", + "could-not-open-note-selector": "Could not open note selector", + "text-added-to-note": "Text added to note", + "todo-page-created": "New ToDo page created", + "failed-to-get-cursor-position": "Failed to get cursor position", + "notes-cleared": "All notes cleared", + "pinned-cleared": "All pinned items cleared" + }, + "settings": { + "tab-general": "General", + "tab-appearance": "Appearance", + "integrations": "Integrazioni", + "todo-integration": "Integrazione plugin ToDo", + "todo-description": "Aggiungi elementi dagli appunti direttamente alla lista ToDo", + "todo-disabled": "Plugin ToDo non installato o disabilitato", + "features": "Funzionalità", + "pincards-enabled": "Enable Pin Cards", + "pincards-desc": "Show pinned items panel and allow pinning clipboard items", + "pincards-items-count": "Pinned Items", + "clear-all-pinned": "Clear All Pinned Items", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes and sticky notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes", + "show-close-button": "Show Close Button", + "show-close-button-desc": "Display an X button at the top-right corner to close the panel", + "fullscreen-mode": "Modalità schermo intero", + "fullscreen-mode-desc": "Espandi il pannello degli appunti a tutto schermo", + "hide-panel-background": "Nascondi sfondo pannello", + "hide-panel-background-desc": "Rimuovi lo sfondo dai contenitori del pannello. Nota: Le card bloccate e le note funzionano meglio con lo sfondo attivo.", + "auto-paste-section": "Incolla automaticamente", + "auto-paste": "Incolla automaticamente al clic", + "auto-paste-desc": "Incolla automaticamente l'elemento selezionato nella finestra attiva", + "auto-paste-warning": "wtype è necessario per l'incolla automatico. Installa con: sudo pacman -S wtype", + "auto-paste-rmb": "Solo clic destro", + "auto-paste-rmb-desc": "Clic destro per incollare automaticamente; clic sinistro copia solo negli appunti", + "auto-paste-delay": "Ritardo incolla", + "auto-paste-delay-desc": "Ritardo prima di incollare (ms). Aumenta se il focus-segue-cursore è attivo nel tuo compositore.", + "appearance": "Aspetto card", + "card-type": "Tipo card", + "card-type-desc": "Seleziona il tipo di card da personalizzare", + "preview": "Anteprima", + "sample-content": "Anteprima contenuto di esempio...", + "bg-color": "Colore sfondo", + "bg-color-desc": "Colore di sfondo della card", + "separator-color": "Colore separatore", + "separator-color-desc": "Linea tra intestazione e contenuto", + "fg-color": "Colore primo piano", + "fg-color-desc": "Colore per titolo, icone e testo", + "reset-defaults": "Ripristina impostazioni predefinite", + "panel-width": "Larghezza del pannello", + "panel-width-desc": "Larghezza del pannello in pixel (solo quando la modalità schermo intero è disattivata)", + "panel-height": "Altezza del pannello", + "panel-height-desc": "Altezza del pannello in pixel. Imposta 0 per altezza automatica (solo quando la modalità schermo intero è disattivata)" + } +} diff --git a/noctalia/plugins/clipper/i18n/ja.json b/noctalia/plugins/clipper/i18n/ja.json new file mode 100644 index 0000000..8c9ca38 --- /dev/null +++ b/noctalia/plugins/clipper/i18n/ja.json @@ -0,0 +1,122 @@ +{ + "bar": { + "tooltip": "クリップボード履歴" + }, + "context": { + "toggle": "Clipperを切り替え", + "settings": "設定を開く" + }, + "panel": { + "title": "クリップボード履歴", + "pinned-title": "ピン留めされたアイテム", + "no-pinned": "ピン留めされたアイテムはありません", + "settings": "設定", + "close": "Close", + "search-placeholder": "検索...", + "filter-all": "すべて", + "filter-text": "テキスト", + "filter-images": "画像", + "filter-colors": "色", + "filter-links": "リンク", + "filter-code": "コード", + "filter-emoji": "絵文字", + "filter-files": "ファイル", + "clear-all": "すべてクリア", + "no-matches": "一致するアイテムがありません", + "empty": "クリップボードは空です" + }, + "card": { + "add-todo": "ToDoに追加", + "pin": "ピン留め", + "delete": "削除" + }, + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note", + "untitled-placeholder": "Untitled" + }, + "toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "item-not-found": "Item not found in clipboard", + "failed-to-pin": "Failed to pin item", + "failed-to-pin-image": "Failed to pin image", + "image-too-large": "Image too large to pin (max 5MB)", + "text-too-large": "Text too large to pin (max 1MB)", + "item-pinned": "Item pinned", + "item-unpinned": "Item unpinned", + "max-notes": "Maximum {max} notes reached", + "note-created": "Note created", + "note-deleted": "Note deleted", + "note-not-found": "Note not found", + "note-exported": "Note exported to ~/Documents/{fileName}", + "copied-to-clipboard": "Copied to clipboard", + "failed-to-copy-image": "Failed to copy image", + "failed-to-copy-text": "Failed to copy text", + "no-text-selected": "No text selected", + "failed-to-get-selection": "Failed to get selection", + "no-text-to-add": "No text to add", + "todo-not-available": "ToDo plugin not available", + "added-to-todo": "Added to ToDo", + "failed-to-copy": "Failed to copy to clipboard", + "todo-disabled": "ToDo integration is disabled", + "could-not-open-todo": "Could not open ToDo selector", + "could-not-open-note-selector": "Could not open note selector", + "text-added-to-note": "Text added to note", + "todo-page-created": "New ToDo page created", + "failed-to-get-cursor-position": "Failed to get cursor position", + "notes-cleared": "All notes cleared", + "pinned-cleared": "All pinned items cleared" + }, + "settings": { + "tab-general": "General", + "tab-appearance": "Appearance", + "integrations": "統合", + "todo-integration": "ToDoプラグイン統合", + "todo-description": "クリップボードアイテムをToDoリストに直接追加", + "todo-disabled": "ToDoプラグインがインストールされていないか無効です", + "features": "Features", + "pincards-enabled": "Enable Pin Cards", + "pincards-desc": "Show pinned items panel and allow pinning clipboard items", + "pincards-items-count": "Pinned Items", + "clear-all-pinned": "Clear All Pinned Items", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes", + "show-close-button": "Show Close Button", + "show-close-button-desc": "Display an X button at the top-right corner to close the panel", + "fullscreen-mode": "Fullscreen Mode", + "fullscreen-mode-desc": "Expand the clipboard panel to fill the entire screen", + "hide-panel-background": "Hide Panel Background", + "hide-panel-background-desc": "Remove background from panel containers. Note: Pin Cards and Note Cards work best with background enabled.", + "auto-paste-section": "Auto-Paste", + "auto-paste": "Auto-Paste on Click", + "auto-paste-desc": "Automatically paste clipboard item into focused window after selecting", + "auto-paste-warning": "wtype is required for auto-paste. Install with: sudo pacman -S wtype", + "auto-paste-rmb": "Right-Click Only", + "auto-paste-rmb-desc": "Use right-click for auto-paste; left-click copies to clipboard without pasting", + "auto-paste-delay": "Paste Delay", + "auto-paste-delay-desc": "Delay before pasting (ms). Increase if focus follows cursor is enabled in your compositor.", + "appearance": "カードの外観", + "card-type": "カードタイプ", + "card-type-desc": "カスタマイズするカードタイプを選択", + "preview": "プレビュー", + "sample-content": "サンプルコンテンツのプレビュー...", + "bg-color": "背景色", + "bg-color-desc": "カードの背景色", + "separator-color": "区切り線の色", + "separator-color-desc": "ヘッダーとコンテンツ間の線", + "fg-color": "前景色", + "fg-color-desc": "タイトル、アイコン、テキストコンテンツの色", + "reset-defaults": "デフォルトにリセット", + "panel-width": "パネルの幅", + "panel-width-desc": "パネルの幅をピクセル単位で指定(全画面モードが無効の場合のみ)", + "panel-height": "パネルの高さ", + "panel-height-desc": "パネルの高さをピクセル単位で指定。0 で自動高さ(全画面モードが無効の場合のみ)" + } +} diff --git a/noctalia/plugins/clipper/i18n/ko-KR.json b/noctalia/plugins/clipper/i18n/ko-KR.json new file mode 100644 index 0000000..bd46310 --- /dev/null +++ b/noctalia/plugins/clipper/i18n/ko-KR.json @@ -0,0 +1,122 @@ +{ + "bar": { + "tooltip": "클립보드 기록" + }, + "context": { + "toggle": "Clipper 전환", + "settings": "설정 열기" + }, + "panel": { + "title": "클립보드 기록", + "pinned-title": "고정된 항목", + "no-pinned": "고정된 항목이 없습니다", + "settings": "설정", + "close": "Close", + "search-placeholder": "검색...", + "filter-all": "전체", + "filter-text": "텍스트", + "filter-images": "이미지", + "filter-colors": "색상", + "filter-links": "링크", + "filter-code": "코드", + "filter-emoji": "이모지", + "filter-files": "파일", + "clear-all": "모두 지우기", + "no-matches": "일치하는 항목이 없습니다", + "empty": "클립보드가 비어 있습니다" + }, + "card": { + "add-todo": "할 일에 추가", + "pin": "고정", + "delete": "삭제" + }, + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note", + "untitled-placeholder": "Untitled" + }, + "toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "item-not-found": "Item not found in clipboard", + "failed-to-pin": "Failed to pin item", + "failed-to-pin-image": "Failed to pin image", + "image-too-large": "Image too large to pin (max 5MB)", + "text-too-large": "Text too large to pin (max 1MB)", + "item-pinned": "Item pinned", + "item-unpinned": "Item unpinned", + "max-notes": "Maximum {max} notes reached", + "note-created": "Note created", + "note-deleted": "Note deleted", + "note-not-found": "Note not found", + "note-exported": "Note exported to ~/Documents/{fileName}", + "copied-to-clipboard": "Copied to clipboard", + "failed-to-copy-image": "Failed to copy image", + "failed-to-copy-text": "Failed to copy text", + "no-text-selected": "No text selected", + "failed-to-get-selection": "Failed to get selection", + "no-text-to-add": "No text to add", + "todo-not-available": "ToDo plugin not available", + "added-to-todo": "Added to ToDo", + "failed-to-copy": "Failed to copy to clipboard", + "todo-disabled": "ToDo integration is disabled", + "could-not-open-todo": "Could not open ToDo selector", + "could-not-open-note-selector": "Could not open note selector", + "text-added-to-note": "Text added to note", + "todo-page-created": "New ToDo page created", + "failed-to-get-cursor-position": "Failed to get cursor position", + "notes-cleared": "All notes cleared", + "pinned-cleared": "All pinned items cleared" + }, + "settings": { + "tab-general": "General", + "tab-appearance": "Appearance", + "integrations": "통합", + "todo-integration": "ToDo 플러그인 통합", + "todo-description": "클립보드 항목을 할 일 목록에 직접 추가", + "todo-disabled": "ToDo 플러그인이 설치되지 않았거나 비활성화됨", + "features": "Features", + "pincards-enabled": "Enable Pin Cards", + "pincards-desc": "Show pinned items panel and allow pinning clipboard items", + "pincards-items-count": "Pinned Items", + "clear-all-pinned": "Clear All Pinned Items", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes", + "show-close-button": "Show Close Button", + "show-close-button-desc": "Display an X button at the top-right corner to close the panel", + "fullscreen-mode": "Fullscreen Mode", + "fullscreen-mode-desc": "Expand the clipboard panel to fill the entire screen", + "hide-panel-background": "Hide Panel Background", + "hide-panel-background-desc": "Remove background from panel containers. Note: Pin Cards and Note Cards work best with background enabled.", + "auto-paste-section": "Auto-Paste", + "auto-paste": "Auto-Paste on Click", + "auto-paste-desc": "Automatically paste clipboard item into focused window after selecting", + "auto-paste-warning": "wtype is required for auto-paste. Install with: sudo pacman -S wtype", + "auto-paste-rmb": "Right-Click Only", + "auto-paste-rmb-desc": "Use right-click for auto-paste; left-click copies to clipboard without pasting", + "auto-paste-delay": "Paste Delay", + "auto-paste-delay-desc": "Delay before pasting (ms). Increase if focus follows cursor is enabled in your compositor.", + "appearance": "카드 모양", + "card-type": "카드 유형", + "card-type-desc": "사용자 지정할 카드 유형 선택", + "preview": "미리보기", + "sample-content": "샘플 콘텐츠 미리보기...", + "bg-color": "배경색", + "bg-color-desc": "카드 배경색", + "separator-color": "구분선 색상", + "separator-color-desc": "헤더와 콘텐츠 사이의 선", + "fg-color": "전경색", + "fg-color-desc": "제목, 아이콘 및 텍스트 콘텐츠 색상", + "reset-defaults": "기본값으로 재설정", + "panel-width": "패널 너비", + "panel-width-desc": "패널의 너비(픽셀 단위, 전체 화면 모드가 비활성화된 경우에만 적용)", + "panel-height": "패널 높이", + "panel-height-desc": "패널의 높이(픽셀 단위). 0으로 설정하면 자동 높이(전체 화면 모드가 비활성화된 경우에만 적용)" + } +} diff --git a/noctalia/plugins/clipper/i18n/nl.json b/noctalia/plugins/clipper/i18n/nl.json new file mode 100644 index 0000000..4d920f4 --- /dev/null +++ b/noctalia/plugins/clipper/i18n/nl.json @@ -0,0 +1,122 @@ +{ + "bar": { + "tooltip": "Klembordgeschiedenis" + }, + "context": { + "toggle": "Clipper schakelen", + "settings": "Instellingen openen" + }, + "panel": { + "title": "Klembordgeschiedenis", + "pinned-title": "Vastgemaakte items", + "no-pinned": "Geen vastgemaakte items", + "settings": "Instellingen", + "close": "Close", + "search-placeholder": "Zoeken...", + "filter-all": "Alle", + "filter-text": "Tekst", + "filter-images": "Afbeeldingen", + "filter-colors": "Kleuren", + "filter-links": "Links", + "filter-code": "Code", + "filter-emoji": "Emoji", + "filter-files": "Bestanden", + "clear-all": "Alles wissen", + "no-matches": "Geen overeenkomende items", + "empty": "Klembord is leeg" + }, + "card": { + "add-todo": "Toevoegen aan takenlijst", + "pin": "Vastmaken", + "delete": "Verwijderen" + }, + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note", + "untitled-placeholder": "Untitled" + }, + "toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "item-not-found": "Item not found in clipboard", + "failed-to-pin": "Failed to pin item", + "failed-to-pin-image": "Failed to pin image", + "image-too-large": "Image too large to pin (max 5MB)", + "text-too-large": "Text too large to pin (max 1MB)", + "item-pinned": "Item pinned", + "item-unpinned": "Item unpinned", + "max-notes": "Maximum {max} notes reached", + "note-created": "Note created", + "note-deleted": "Note deleted", + "note-not-found": "Note not found", + "note-exported": "Note exported to ~/Documents/{fileName}", + "copied-to-clipboard": "Copied to clipboard", + "failed-to-copy-image": "Failed to copy image", + "failed-to-copy-text": "Failed to copy text", + "no-text-selected": "No text selected", + "failed-to-get-selection": "Failed to get selection", + "no-text-to-add": "No text to add", + "todo-not-available": "ToDo plugin not available", + "added-to-todo": "Added to ToDo", + "failed-to-copy": "Failed to copy to clipboard", + "todo-disabled": "ToDo integration is disabled", + "could-not-open-todo": "Could not open ToDo selector", + "could-not-open-note-selector": "Could not open note selector", + "text-added-to-note": "Text added to note", + "todo-page-created": "New ToDo page created", + "failed-to-get-cursor-position": "Failed to get cursor position", + "notes-cleared": "All notes cleared", + "pinned-cleared": "All pinned items cleared" + }, + "settings": { + "tab-general": "General", + "tab-appearance": "Appearance", + "integrations": "Integraties", + "todo-integration": "ToDo-plugin-integratie", + "todo-description": "Klembord-items rechtstreeks toevoegen aan uw takenlijst", + "todo-disabled": "ToDo-plugin is niet geïnstalleerd of uitgeschakeld", + "features": "Features", + "pincards-enabled": "Enable Pin Cards", + "pincards-desc": "Show pinned items panel and allow pinning clipboard items", + "pincards-items-count": "Pinned Items", + "clear-all-pinned": "Clear All Pinned Items", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes", + "show-close-button": "Show Close Button", + "show-close-button-desc": "Display an X button at the top-right corner to close the panel", + "fullscreen-mode": "Fullscreen Mode", + "fullscreen-mode-desc": "Expand the clipboard panel to fill the entire screen", + "hide-panel-background": "Hide Panel Background", + "hide-panel-background-desc": "Remove background from panel containers. Note: Pin Cards and Note Cards work best with background enabled.", + "auto-paste-section": "Auto-Paste", + "auto-paste": "Auto-Paste on Click", + "auto-paste-desc": "Automatically paste clipboard item into focused window after selecting", + "auto-paste-warning": "wtype is required for auto-paste. Install with: sudo pacman -S wtype", + "auto-paste-rmb": "Right-Click Only", + "auto-paste-rmb-desc": "Use right-click for auto-paste; left-click copies to clipboard without pasting", + "auto-paste-delay": "Paste Delay", + "auto-paste-delay-desc": "Delay before pasting (ms). Increase if focus follows cursor is enabled in your compositor.", + "appearance": "Kaartweergave", + "card-type": "Kaarttype", + "card-type-desc": "Selecteer kaarttype om aan te passen", + "preview": "Voorbeeld", + "sample-content": "Voorbeeldinhoud...", + "bg-color": "Achtergrondkleur", + "bg-color-desc": "Achtergrondkleur van kaart", + "separator-color": "Scheidingslijnkleur", + "separator-color-desc": "Lijn tussen koptekst en inhoud", + "fg-color": "Voorgrondkleur", + "fg-color-desc": "Kleur van titel, pictogrammen en tekstinhoud", + "reset-defaults": "Standaardinstellingen herstellen", + "panel-width": "Panelbreedte", + "panel-width-desc": "Breedte van het paneel in pixels (alleen wanneer de volledig-schermmodus is uitgeschakeld)", + "panel-height": "Panelhoogte", + "panel-height-desc": "Hoogte van het paneel in pixels. Stel in op 0 voor automatische hoogte (alleen wanneer de volledig-schermmodus is uitgeschakeld)" + } +} diff --git a/noctalia/plugins/clipper/i18n/pl.json b/noctalia/plugins/clipper/i18n/pl.json new file mode 100644 index 0000000..497e862 --- /dev/null +++ b/noctalia/plugins/clipper/i18n/pl.json @@ -0,0 +1,122 @@ +{ + "bar": { + "tooltip": "Historia schowka" + }, + "context": { + "toggle": "Przełącz Clipper", + "settings": "Otwórz ustawienia" + }, + "panel": { + "title": "Historia schowka", + "pinned-title": "Przypięte elementy", + "no-pinned": "Brak przypiętych elementów", + "settings": "Ustawienia", + "close": "Close", + "search-placeholder": "Szukaj...", + "filter-all": "Wszystkie", + "filter-text": "Tekst", + "filter-images": "Obrazy", + "filter-colors": "Kolory", + "filter-links": "Linki", + "filter-code": "Kod", + "filter-emoji": "Emoji", + "filter-files": "Pliki", + "clear-all": "Wyczyść wszystko", + "no-matches": "Brak pasujących elementów", + "empty": "Schowek jest pusty" + }, + "card": { + "add-todo": "Dodaj do zadań", + "pin": "Przypnij", + "delete": "Usuń" + }, + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note", + "untitled-placeholder": "Untitled" + }, + "toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "item-not-found": "Item not found in clipboard", + "failed-to-pin": "Failed to pin item", + "failed-to-pin-image": "Failed to pin image", + "image-too-large": "Image too large to pin (max 5MB)", + "text-too-large": "Text too large to pin (max 1MB)", + "item-pinned": "Item pinned", + "item-unpinned": "Item unpinned", + "max-notes": "Maximum {max} notes reached", + "note-created": "Note created", + "note-deleted": "Note deleted", + "note-not-found": "Note not found", + "note-exported": "Note exported to ~/Documents/{fileName}", + "copied-to-clipboard": "Copied to clipboard", + "failed-to-copy-image": "Failed to copy image", + "failed-to-copy-text": "Failed to copy text", + "no-text-selected": "No text selected", + "failed-to-get-selection": "Failed to get selection", + "no-text-to-add": "No text to add", + "todo-not-available": "ToDo plugin not available", + "added-to-todo": "Added to ToDo", + "failed-to-copy": "Failed to copy to clipboard", + "todo-disabled": "ToDo integration is disabled", + "could-not-open-todo": "Could not open ToDo selector", + "could-not-open-note-selector": "Could not open note selector", + "text-added-to-note": "Text added to note", + "todo-page-created": "New ToDo page created", + "failed-to-get-cursor-position": "Failed to get cursor position", + "notes-cleared": "All notes cleared", + "pinned-cleared": "All pinned items cleared" + }, + "settings": { + "tab-general": "General", + "tab-appearance": "Appearance", + "integrations": "Integracje", + "todo-integration": "Integracja z ToDo", + "todo-description": "Dodawaj elementy ze schowka bezpośrednio do listy zadań", + "todo-disabled": "Plugin ToDo nie jest zainstalowany lub jest wyłączony", + "features": "Features", + "pincards-enabled": "Enable Pin Cards", + "pincards-desc": "Show pinned items panel and allow pinning clipboard items", + "pincards-items-count": "Pinned Items", + "clear-all-pinned": "Clear All Pinned Items", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes", + "show-close-button": "Show Close Button", + "show-close-button-desc": "Display an X button at the top-right corner to close the panel", + "fullscreen-mode": "Tryb pełnoekranowy", + "fullscreen-mode-desc": "Rozwiń panel na cały ekran", + "hide-panel-background": "Ukryj tło panelu", + "hide-panel-background-desc": "Usuwa tło kontenerów panelu. Uwaga: Przypięte karty i notatki działają lepiej z włączonym tłem.", + "auto-paste-section": "Automatyczne wklejanie", + "auto-paste": "Wklej automatycznie po kliknięciu", + "auto-paste-desc": "Automatycznie wkleja zaznaczony element do aktywnego okna", + "auto-paste-warning": "wtype jest wymagany do automatycznego wklejania. Zainstaluj: sudo pacman -S wtype", + "auto-paste-rmb": "Tylko prawy przycisk myszy", + "auto-paste-rmb-desc": "Prawy klik = wklej automatycznie; lewy klik = tylko kopiuj do schowka", + "auto-paste-delay": "Opóźnienie wklejania", + "auto-paste-delay-desc": "Opóźnienie przed wklejeniem (ms). Zwiększ jeśli włączony jest focus-follows-cursor w kompozytorze.", + "appearance": "Wygląd kart", + "card-type": "Typ karty", + "card-type-desc": "Wybierz typ karty do personalizacji", + "preview": "Podgląd", + "sample-content": "Przykładowa treść...", + "bg-color": "Kolor tła", + "bg-color-desc": "Kolor tła karty", + "separator-color": "Kolor separatora", + "separator-color-desc": "Linia między nagłówkiem a treścią", + "fg-color": "Kolor tekstu", + "fg-color-desc": "Kolor tytułu, ikon i treści tekstowej", + "reset-defaults": "Przywróć domyślne", + "panel-width": "Szerokość panelu", + "panel-width-desc": "Szerokość panelu w pikselach (tylko gdy tryb pełnoekranowy jest wyłączony)", + "panel-height": "Wysokość panelu", + "panel-height-desc": "Wysokość panelu w pikselach. Ustaw 0 dla automatycznej wysokości (tylko gdy tryb pełnoekranowy jest wyłączony)" + } +} diff --git a/noctalia/plugins/clipper/i18n/pt.json b/noctalia/plugins/clipper/i18n/pt.json new file mode 100644 index 0000000..35d4c6a --- /dev/null +++ b/noctalia/plugins/clipper/i18n/pt.json @@ -0,0 +1,122 @@ +{ + "bar": { + "tooltip": "Histórico da área de transferência" + }, + "context": { + "toggle": "Alternar Clipper", + "settings": "Abrir configurações" + }, + "panel": { + "title": "Histórico da área de transferência", + "pinned-title": "Itens fixados", + "no-pinned": "Nenhum item fixado", + "settings": "Configurações", + "close": "Close", + "search-placeholder": "Pesquisar...", + "filter-all": "Todos", + "filter-text": "Texto", + "filter-images": "Imagens", + "filter-colors": "Cores", + "filter-links": "Links", + "filter-code": "Código", + "filter-emoji": "Emoji", + "filter-files": "Arquivos", + "clear-all": "Limpar tudo", + "no-matches": "Nenhum item correspondente", + "empty": "A área de transferência está vazia" + }, + "card": { + "add-todo": "Adicionar às tarefas", + "pin": "Fixar", + "delete": "Excluir" + }, + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note", + "untitled-placeholder": "Untitled" + }, + "toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "item-not-found": "Item not found in clipboard", + "failed-to-pin": "Failed to pin item", + "failed-to-pin-image": "Failed to pin image", + "image-too-large": "Image too large to pin (max 5MB)", + "text-too-large": "Text too large to pin (max 1MB)", + "item-pinned": "Item pinned", + "item-unpinned": "Item unpinned", + "max-notes": "Maximum {max} notes reached", + "note-created": "Note created", + "note-deleted": "Note deleted", + "note-not-found": "Note not found", + "note-exported": "Note exported to ~/Documents/{fileName}", + "copied-to-clipboard": "Copied to clipboard", + "failed-to-copy-image": "Failed to copy image", + "failed-to-copy-text": "Failed to copy text", + "no-text-selected": "No text selected", + "failed-to-get-selection": "Failed to get selection", + "no-text-to-add": "No text to add", + "todo-not-available": "ToDo plugin not available", + "added-to-todo": "Added to ToDo", + "failed-to-copy": "Failed to copy to clipboard", + "todo-disabled": "ToDo integration is disabled", + "could-not-open-todo": "Could not open ToDo selector", + "could-not-open-note-selector": "Could not open note selector", + "text-added-to-note": "Text added to note", + "todo-page-created": "New ToDo page created", + "failed-to-get-cursor-position": "Failed to get cursor position", + "notes-cleared": "All notes cleared", + "pinned-cleared": "All pinned items cleared" + }, + "settings": { + "tab-general": "General", + "tab-appearance": "Appearance", + "integrations": "Integrações", + "todo-integration": "Integração do plugin ToDo", + "todo-description": "Adicionar itens da área de transferência diretamente à sua lista de tarefas", + "todo-disabled": "O plugin ToDo não está instalado ou está desativado", + "features": "Features", + "pincards-enabled": "Enable Pin Cards", + "pincards-desc": "Show pinned items panel and allow pinning clipboard items", + "pincards-items-count": "Pinned Items", + "clear-all-pinned": "Clear All Pinned Items", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes", + "show-close-button": "Show Close Button", + "show-close-button-desc": "Display an X button at the top-right corner to close the panel", + "fullscreen-mode": "Fullscreen Mode", + "fullscreen-mode-desc": "Expand the clipboard panel to fill the entire screen", + "hide-panel-background": "Hide Panel Background", + "hide-panel-background-desc": "Remove background from panel containers. Note: Pin Cards and Note Cards work best with background enabled.", + "auto-paste-section": "Auto-Paste", + "auto-paste": "Auto-Paste on Click", + "auto-paste-desc": "Automatically paste clipboard item into focused window after selecting", + "auto-paste-warning": "wtype is required for auto-paste. Install with: sudo pacman -S wtype", + "auto-paste-rmb": "Right-Click Only", + "auto-paste-rmb-desc": "Use right-click for auto-paste; left-click copies to clipboard without pasting", + "auto-paste-delay": "Paste Delay", + "auto-paste-delay-desc": "Delay before pasting (ms). Increase if focus follows cursor is enabled in your compositor.", + "appearance": "Aparência do cartão", + "card-type": "Tipo de cartão", + "card-type-desc": "Selecione o tipo de cartão para personalizar", + "preview": "Visualização", + "sample-content": "Visualização de conteúdo de exemplo...", + "bg-color": "Cor de fundo", + "bg-color-desc": "Cor de fundo do cartão", + "separator-color": "Cor do separador", + "separator-color-desc": "Linha entre o cabeçalho e o conteúdo", + "fg-color": "Cor do primeiro plano", + "fg-color-desc": "Cor do título, ícones e conteúdo de texto", + "reset-defaults": "Redefinir para padrões", + "panel-width": "Largura do painel", + "panel-width-desc": "Largura do painel em pixels (apenas quando o modo ecrã completo está desativado)", + "panel-height": "Altura do painel", + "panel-height-desc": "Altura do painel em pixels. Defina 0 para altura automática (apenas quando o modo ecrã completo está desativado)" + } +} diff --git a/noctalia/plugins/clipper/i18n/ru.json b/noctalia/plugins/clipper/i18n/ru.json new file mode 100644 index 0000000..45c7a25 --- /dev/null +++ b/noctalia/plugins/clipper/i18n/ru.json @@ -0,0 +1,122 @@ +{ + "bar": { + "tooltip": "История буфера обмена" + }, + "context": { + "toggle": "Переключить Clipper", + "settings": "Открыть настройки" + }, + "panel": { + "title": "История буфера обмена", + "pinned-title": "Закрепленные элементы", + "no-pinned": "Нет закрепленных элементов", + "settings": "Настройки", + "close": "Close", + "search-placeholder": "Поиск...", + "filter-all": "Все", + "filter-text": "Текст", + "filter-images": "Изображения", + "filter-colors": "Цвета", + "filter-links": "Ссылки", + "filter-code": "Код", + "filter-emoji": "Эмодзи", + "filter-files": "Файлы", + "clear-all": "Очистить все", + "no-matches": "Нет совпадающих элементов", + "empty": "Буфер обмена пуст" + }, + "card": { + "add-todo": "Добавить в задачи", + "pin": "Закрепить", + "delete": "Удалить" + }, + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note", + "untitled-placeholder": "Untitled" + }, + "toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "item-not-found": "Item not found in clipboard", + "failed-to-pin": "Failed to pin item", + "failed-to-pin-image": "Failed to pin image", + "image-too-large": "Image too large to pin (max 5MB)", + "text-too-large": "Text too large to pin (max 1MB)", + "item-pinned": "Item pinned", + "item-unpinned": "Item unpinned", + "max-notes": "Maximum {max} notes reached", + "note-created": "Note created", + "note-deleted": "Note deleted", + "note-not-found": "Note not found", + "note-exported": "Note exported to ~/Documents/{fileName}", + "copied-to-clipboard": "Copied to clipboard", + "failed-to-copy-image": "Failed to copy image", + "failed-to-copy-text": "Failed to copy text", + "no-text-selected": "No text selected", + "failed-to-get-selection": "Failed to get selection", + "no-text-to-add": "No text to add", + "todo-not-available": "ToDo plugin not available", + "added-to-todo": "Added to ToDo", + "failed-to-copy": "Failed to copy to clipboard", + "todo-disabled": "ToDo integration is disabled", + "could-not-open-todo": "Could not open ToDo selector", + "could-not-open-note-selector": "Could not open note selector", + "text-added-to-note": "Text added to note", + "todo-page-created": "New ToDo page created", + "failed-to-get-cursor-position": "Failed to get cursor position", + "notes-cleared": "All notes cleared", + "pinned-cleared": "All pinned items cleared" + }, + "settings": { + "tab-general": "General", + "tab-appearance": "Appearance", + "integrations": "Интеграции", + "todo-integration": "Интеграция с плагином ToDo", + "todo-description": "Добавлять элементы буфера обмена прямо в список задач", + "todo-disabled": "Плагин ToDo не установлен или отключен", + "features": "Features", + "pincards-enabled": "Enable Pin Cards", + "pincards-desc": "Show pinned items panel and allow pinning clipboard items", + "pincards-items-count": "Pinned Items", + "clear-all-pinned": "Clear All Pinned Items", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes", + "show-close-button": "Show Close Button", + "show-close-button-desc": "Display an X button at the top-right corner to close the panel", + "fullscreen-mode": "Fullscreen Mode", + "fullscreen-mode-desc": "Expand the clipboard panel to fill the entire screen", + "hide-panel-background": "Hide Panel Background", + "hide-panel-background-desc": "Remove background from panel containers. Note: Pin Cards and Note Cards work best with background enabled.", + "auto-paste-section": "Auto-Paste", + "auto-paste": "Auto-Paste on Click", + "auto-paste-desc": "Automatically paste clipboard item into focused window after selecting", + "auto-paste-warning": "wtype is required for auto-paste. Install with: sudo pacman -S wtype", + "auto-paste-rmb": "Right-Click Only", + "auto-paste-rmb-desc": "Use right-click for auto-paste; left-click copies to clipboard without pasting", + "auto-paste-delay": "Paste Delay", + "auto-paste-delay-desc": "Delay before pasting (ms). Increase if focus follows cursor is enabled in your compositor.", + "appearance": "Внешний вид карточек", + "card-type": "Тип карточки", + "card-type-desc": "Выберите тип карточки для настройки", + "preview": "Предпросмотр", + "sample-content": "Предпросмотр образца содержимого...", + "bg-color": "Цвет фона", + "bg-color-desc": "Цвет фона карточки", + "separator-color": "Цвет разделителя", + "separator-color-desc": "Линия между заголовком и содержимым", + "fg-color": "Цвет переднего плана", + "fg-color-desc": "Цвет заголовка, значков и текстового содержимого", + "reset-defaults": "Сбросить к значениям по умолчанию", + "panel-width": "Ширина панели", + "panel-width-desc": "Ширина панели в пикселях (только когда полноэкранный режим отключён)", + "panel-height": "Высота панели", + "panel-height-desc": "Высота панели в пикселях. Установите 0 для автоматической высоты (только когда полноэкранный режим отключён)" + } +} diff --git a/noctalia/plugins/clipper/i18n/sv.json b/noctalia/plugins/clipper/i18n/sv.json new file mode 100644 index 0000000..c3ae810 --- /dev/null +++ b/noctalia/plugins/clipper/i18n/sv.json @@ -0,0 +1,122 @@ +{ + "bar": { + "tooltip": "Urklippshistorik" + }, + "context": { + "toggle": "Växla Clipper", + "settings": "Öppna inställningar" + }, + "panel": { + "title": "Urklippshistorik", + "pinned-title": "Fästa objekt", + "no-pinned": "Inga fästa objekt", + "settings": "Inställningar", + "close": "Close", + "search-placeholder": "Sök...", + "filter-all": "Alla", + "filter-text": "Text", + "filter-images": "Bilder", + "filter-colors": "Färger", + "filter-links": "Länkar", + "filter-code": "Kod", + "filter-emoji": "Emoji", + "filter-files": "Filer", + "clear-all": "Rensa allt", + "no-matches": "Inga matchande objekt", + "empty": "Urklippet är tomt" + }, + "card": { + "add-todo": "Lägg till i uppgifter", + "pin": "Fäst", + "delete": "Ta bort" + }, + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note", + "untitled-placeholder": "Untitled" + }, + "toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "item-not-found": "Item not found in clipboard", + "failed-to-pin": "Failed to pin item", + "failed-to-pin-image": "Failed to pin image", + "image-too-large": "Image too large to pin (max 5MB)", + "text-too-large": "Text too large to pin (max 1MB)", + "item-pinned": "Item pinned", + "item-unpinned": "Item unpinned", + "max-notes": "Maximum {max} notes reached", + "note-created": "Note created", + "note-deleted": "Note deleted", + "note-not-found": "Note not found", + "note-exported": "Note exported to ~/Documents/{fileName}", + "copied-to-clipboard": "Copied to clipboard", + "failed-to-copy-image": "Failed to copy image", + "failed-to-copy-text": "Failed to copy text", + "no-text-selected": "No text selected", + "failed-to-get-selection": "Failed to get selection", + "no-text-to-add": "No text to add", + "todo-not-available": "ToDo plugin not available", + "added-to-todo": "Added to ToDo", + "failed-to-copy": "Failed to copy to clipboard", + "todo-disabled": "ToDo integration is disabled", + "could-not-open-todo": "Could not open ToDo selector", + "could-not-open-note-selector": "Could not open note selector", + "text-added-to-note": "Text added to note", + "todo-page-created": "New ToDo page created", + "failed-to-get-cursor-position": "Failed to get cursor position", + "notes-cleared": "All notes cleared", + "pinned-cleared": "All pinned items cleared" + }, + "settings": { + "tab-general": "General", + "tab-appearance": "Appearance", + "integrations": "Integrationer", + "todo-integration": "ToDo-pluginintegration", + "todo-description": "Lägg till urklippsobjekt direkt till din uppgiftslista", + "todo-disabled": "ToDo-plugin är inte installerat eller inaktiverat", + "features": "Features", + "pincards-enabled": "Enable Pin Cards", + "pincards-desc": "Show pinned items panel and allow pinning clipboard items", + "pincards-items-count": "Pinned Items", + "clear-all-pinned": "Clear All Pinned Items", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes", + "show-close-button": "Show Close Button", + "show-close-button-desc": "Display an X button at the top-right corner to close the panel", + "fullscreen-mode": "Fullscreen Mode", + "fullscreen-mode-desc": "Expand the clipboard panel to fill the entire screen", + "hide-panel-background": "Hide Panel Background", + "hide-panel-background-desc": "Remove background from panel containers. Note: Pin Cards and Note Cards work best with background enabled.", + "auto-paste-section": "Auto-Paste", + "auto-paste": "Auto-Paste on Click", + "auto-paste-desc": "Automatically paste clipboard item into focused window after selecting", + "auto-paste-warning": "wtype is required for auto-paste. Install with: sudo pacman -S wtype", + "auto-paste-rmb": "Right-Click Only", + "auto-paste-rmb-desc": "Use right-click for auto-paste; left-click copies to clipboard without pasting", + "auto-paste-delay": "Paste Delay", + "auto-paste-delay-desc": "Delay before pasting (ms). Increase if focus follows cursor is enabled in your compositor.", + "appearance": "Kortutseende", + "card-type": "Korttyp", + "card-type-desc": "Välj korttyp att anpassa", + "preview": "Förhandsgranskning", + "sample-content": "Förhandsgranskning av exempelinnehåll...", + "bg-color": "Bakgrundsfärg", + "bg-color-desc": "Kortets bakgrundsfärg", + "separator-color": "Separatorfärg", + "separator-color-desc": "Linje mellan rubrik och innehåll", + "fg-color": "Förgrundsfärg", + "fg-color-desc": "Färg på titel, ikoner och textinnehåll", + "reset-defaults": "Återställ till standard", + "panel-width": "Panelbredd", + "panel-width-desc": "Panelens bredd i pixlar (bara när fullskärmsläget är inaktiverat)", + "panel-height": "Panelhöjd", + "panel-height-desc": "Panelens höjd i pixlar. Ange 0 för automatisk höjd (bara när fullskärmsläget är inaktiverat)" + } +} diff --git a/noctalia/plugins/clipper/i18n/tr.json b/noctalia/plugins/clipper/i18n/tr.json new file mode 100644 index 0000000..7fbbf17 --- /dev/null +++ b/noctalia/plugins/clipper/i18n/tr.json @@ -0,0 +1,122 @@ +{ + "bar": { + "tooltip": "Pano geçmişi" + }, + "context": { + "toggle": "Clipper'ı değiştir", + "settings": "Ayarları aç" + }, + "panel": { + "title": "Pano geçmişi", + "pinned-title": "Sabitlenmiş öğeler", + "no-pinned": "Sabitlenmiş öğe yok", + "settings": "Ayarlar", + "close": "Close", + "search-placeholder": "Ara...", + "filter-all": "Tümü", + "filter-text": "Metin", + "filter-images": "Resimler", + "filter-colors": "Renkler", + "filter-links": "Bağlantılar", + "filter-code": "Kod", + "filter-emoji": "Emoji", + "filter-files": "Dosyalar", + "clear-all": "Tümünü temizle", + "no-matches": "Eşleşen öğe yok", + "empty": "Pano boş" + }, + "card": { + "add-todo": "Yapılacaklara ekle", + "pin": "Sabitle", + "delete": "Sil" + }, + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note", + "untitled-placeholder": "Untitled" + }, + "toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "item-not-found": "Item not found in clipboard", + "failed-to-pin": "Failed to pin item", + "failed-to-pin-image": "Failed to pin image", + "image-too-large": "Image too large to pin (max 5MB)", + "text-too-large": "Text too large to pin (max 1MB)", + "item-pinned": "Item pinned", + "item-unpinned": "Item unpinned", + "max-notes": "Maximum {max} notes reached", + "note-created": "Note created", + "note-deleted": "Note deleted", + "note-not-found": "Note not found", + "note-exported": "Note exported to ~/Documents/{fileName}", + "copied-to-clipboard": "Copied to clipboard", + "failed-to-copy-image": "Failed to copy image", + "failed-to-copy-text": "Failed to copy text", + "no-text-selected": "No text selected", + "failed-to-get-selection": "Failed to get selection", + "no-text-to-add": "No text to add", + "todo-not-available": "ToDo plugin not available", + "added-to-todo": "Added to ToDo", + "failed-to-copy": "Failed to copy to clipboard", + "todo-disabled": "ToDo integration is disabled", + "could-not-open-todo": "Could not open ToDo selector", + "could-not-open-note-selector": "Could not open note selector", + "text-added-to-note": "Text added to note", + "todo-page-created": "New ToDo page created", + "failed-to-get-cursor-position": "Failed to get cursor position", + "notes-cleared": "All notes cleared", + "pinned-cleared": "All pinned items cleared" + }, + "settings": { + "tab-general": "General", + "tab-appearance": "Appearance", + "integrations": "Entegrasyonlar", + "todo-integration": "ToDo eklentisi entegrasyonu", + "todo-description": "Pano öğelerini doğrudan yapılacaklar listenize ekleyin", + "todo-disabled": "ToDo eklentisi yüklü değil veya devre dışı", + "features": "Features", + "pincards-enabled": "Enable Pin Cards", + "pincards-desc": "Show pinned items panel and allow pinning clipboard items", + "pincards-items-count": "Pinned Items", + "clear-all-pinned": "Clear All Pinned Items", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes", + "show-close-button": "Show Close Button", + "show-close-button-desc": "Display an X button at the top-right corner to close the panel", + "fullscreen-mode": "Fullscreen Mode", + "fullscreen-mode-desc": "Expand the clipboard panel to fill the entire screen", + "hide-panel-background": "Hide Panel Background", + "hide-panel-background-desc": "Remove background from panel containers. Note: Pin Cards and Note Cards work best with background enabled.", + "auto-paste-section": "Auto-Paste", + "auto-paste": "Auto-Paste on Click", + "auto-paste-desc": "Automatically paste clipboard item into focused window after selecting", + "auto-paste-warning": "wtype is required for auto-paste. Install with: sudo pacman -S wtype", + "auto-paste-rmb": "Right-Click Only", + "auto-paste-rmb-desc": "Use right-click for auto-paste; left-click copies to clipboard without pasting", + "auto-paste-delay": "Paste Delay", + "auto-paste-delay-desc": "Delay before pasting (ms). Increase if focus follows cursor is enabled in your compositor.", + "appearance": "Kart görünümü", + "card-type": "Kart türü", + "card-type-desc": "Özelleştirilecek kart türünü seçin", + "preview": "Önizleme", + "sample-content": "Örnek içerik önizlemesi...", + "bg-color": "Arka plan rengi", + "bg-color-desc": "Kart arka plan rengi", + "separator-color": "Ayırıcı rengi", + "separator-color-desc": "Başlık ve içerik arasındaki çizgi", + "fg-color": "Ön plan rengi", + "fg-color-desc": "Başlık, simgeler ve metin içeriği rengi", + "reset-defaults": "Varsayılanlara sıfırla", + "panel-width": "Panel genişliği", + "panel-width-desc": "Panelin piksel cinsinden genişliği (yalnızca tam ekran modu devre dışıyken)", + "panel-height": "Panel yüksekliği", + "panel-height-desc": "Panelin piksel cinsinden yüksekliği. Otomatik yükseklik için 0 girin (yalnızca tam ekran modu devre dışıyken)" + } +} diff --git a/noctalia/plugins/clipper/i18n/uk-UA.json b/noctalia/plugins/clipper/i18n/uk-UA.json new file mode 100644 index 0000000..bad31c7 --- /dev/null +++ b/noctalia/plugins/clipper/i18n/uk-UA.json @@ -0,0 +1,122 @@ +{ + "bar": { + "tooltip": "Історія буфера обміну" + }, + "context": { + "toggle": "Перемкнути Clipper", + "settings": "Відкрити налаштування" + }, + "panel": { + "title": "Історія буфера обміну", + "pinned-title": "Закріплені елементи", + "no-pinned": "Немає закріплених елементів", + "settings": "Налаштування", + "close": "Close", + "search-placeholder": "Пошук...", + "filter-all": "Усі", + "filter-text": "Текст", + "filter-images": "Зображення", + "filter-colors": "Кольори", + "filter-links": "Посилання", + "filter-code": "Код", + "filter-emoji": "Емодзі", + "filter-files": "Файли", + "clear-all": "Очистити все", + "no-matches": "Немає відповідних елементів", + "empty": "Буфер обміну порожній" + }, + "card": { + "add-todo": "Додати до завдань", + "pin": "Закріпити", + "delete": "Видалити" + }, + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note", + "untitled-placeholder": "Untitled" + }, + "toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "item-not-found": "Item not found in clipboard", + "failed-to-pin": "Failed to pin item", + "failed-to-pin-image": "Failed to pin image", + "image-too-large": "Image too large to pin (max 5MB)", + "text-too-large": "Text too large to pin (max 1MB)", + "item-pinned": "Item pinned", + "item-unpinned": "Item unpinned", + "max-notes": "Maximum {max} notes reached", + "note-created": "Note created", + "note-deleted": "Note deleted", + "note-not-found": "Note not found", + "note-exported": "Note exported to ~/Documents/{fileName}", + "copied-to-clipboard": "Copied to clipboard", + "failed-to-copy-image": "Failed to copy image", + "failed-to-copy-text": "Failed to copy text", + "no-text-selected": "No text selected", + "failed-to-get-selection": "Failed to get selection", + "no-text-to-add": "No text to add", + "todo-not-available": "ToDo plugin not available", + "added-to-todo": "Added to ToDo", + "failed-to-copy": "Failed to copy to clipboard", + "todo-disabled": "ToDo integration is disabled", + "could-not-open-todo": "Could not open ToDo selector", + "could-not-open-note-selector": "Could not open note selector", + "text-added-to-note": "Text added to note", + "todo-page-created": "New ToDo page created", + "failed-to-get-cursor-position": "Failed to get cursor position", + "notes-cleared": "All notes cleared", + "pinned-cleared": "All pinned items cleared" + }, + "settings": { + "tab-general": "General", + "tab-appearance": "Appearance", + "integrations": "Інтеграції", + "todo-integration": "Інтеграція з плагіном ToDo", + "todo-description": "Додавати елементи буфера обміну безпосередньо до списку завдань", + "todo-disabled": "Плагін ToDo не встановлено або вимкнено", + "features": "Features", + "pincards-enabled": "Enable Pin Cards", + "pincards-desc": "Show pinned items panel and allow pinning clipboard items", + "pincards-items-count": "Pinned Items", + "clear-all-pinned": "Clear All Pinned Items", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes", + "show-close-button": "Show Close Button", + "show-close-button-desc": "Display an X button at the top-right corner to close the panel", + "fullscreen-mode": "Fullscreen Mode", + "fullscreen-mode-desc": "Expand the clipboard panel to fill the entire screen", + "hide-panel-background": "Hide Panel Background", + "hide-panel-background-desc": "Remove background from panel containers. Note: Pin Cards and Note Cards work best with background enabled.", + "auto-paste-section": "Auto-Paste", + "auto-paste": "Auto-Paste on Click", + "auto-paste-desc": "Automatically paste clipboard item into focused window after selecting", + "auto-paste-warning": "wtype is required for auto-paste. Install with: sudo pacman -S wtype", + "auto-paste-rmb": "Right-Click Only", + "auto-paste-rmb-desc": "Use right-click for auto-paste; left-click copies to clipboard without pasting", + "auto-paste-delay": "Paste Delay", + "auto-paste-delay-desc": "Delay before pasting (ms). Increase if focus follows cursor is enabled in your compositor.", + "appearance": "Зовнішній вигляд карток", + "card-type": "Тип картки", + "card-type-desc": "Виберіть тип картки для налаштування", + "preview": "Попередній перегляд", + "sample-content": "Попередній перегляд зразка вмісту...", + "bg-color": "Колір фону", + "bg-color-desc": "Колір фону картки", + "separator-color": "Колір роздільника", + "separator-color-desc": "Лінія між заголовком і вмістом", + "fg-color": "Колір переднього плану", + "fg-color-desc": "Колір заголовка, значків і текстового вмісту", + "reset-defaults": "Скинути до типових значень", + "panel-width": "Ширина панелі", + "panel-width-desc": "Ширина панелі в пікселях (тільки коли повноекранний режим вимкнено)", + "panel-height": "Висота панелі", + "panel-height-desc": "Висота панелі в пікселях. Встановіть 0 для автоматичної висоти (тільки коли повноекранний режим вимкнено)" + } +} diff --git a/noctalia/plugins/clipper/i18n/zh-CN.json b/noctalia/plugins/clipper/i18n/zh-CN.json new file mode 100644 index 0000000..2b95e55 --- /dev/null +++ b/noctalia/plugins/clipper/i18n/zh-CN.json @@ -0,0 +1,122 @@ +{ + "bar": { + "tooltip": "剪贴板历史" + }, + "context": { + "toggle": "切换 Clipper", + "settings": "打开设置" + }, + "panel": { + "title": "剪贴板历史", + "pinned-title": "已固定项目", + "no-pinned": "无已固定项目", + "settings": "设置", + "close": "Close", + "search-placeholder": "搜索...", + "filter-all": "全部", + "filter-text": "文本", + "filter-images": "图片", + "filter-colors": "颜色", + "filter-links": "链接", + "filter-code": "代码", + "filter-emoji": "表情符号", + "filter-files": "文件", + "clear-all": "全部清除", + "no-matches": "无匹配项目", + "empty": "剪贴板为空" + }, + "card": { + "add-todo": "添加到待办事项", + "pin": "固定", + "delete": "删除" + }, + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note", + "untitled-placeholder": "Untitled" + }, + "toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "item-not-found": "Item not found in clipboard", + "failed-to-pin": "Failed to pin item", + "failed-to-pin-image": "Failed to pin image", + "image-too-large": "Image too large to pin (max 5MB)", + "text-too-large": "Text too large to pin (max 1MB)", + "item-pinned": "Item pinned", + "item-unpinned": "Item unpinned", + "max-notes": "Maximum {max} notes reached", + "note-created": "Note created", + "note-deleted": "Note deleted", + "note-not-found": "Note not found", + "note-exported": "Note exported to ~/Documents/{fileName}", + "copied-to-clipboard": "Copied to clipboard", + "failed-to-copy-image": "Failed to copy image", + "failed-to-copy-text": "Failed to copy text", + "no-text-selected": "No text selected", + "failed-to-get-selection": "Failed to get selection", + "no-text-to-add": "No text to add", + "todo-not-available": "ToDo plugin not available", + "added-to-todo": "Added to ToDo", + "failed-to-copy": "Failed to copy to clipboard", + "todo-disabled": "ToDo integration is disabled", + "could-not-open-todo": "Could not open ToDo selector", + "could-not-open-note-selector": "Could not open note selector", + "text-added-to-note": "Text added to note", + "todo-page-created": "New ToDo page created", + "failed-to-get-cursor-position": "Failed to get cursor position", + "notes-cleared": "All notes cleared", + "pinned-cleared": "All pinned items cleared" + }, + "settings": { + "tab-general": "General", + "tab-appearance": "Appearance", + "integrations": "集成", + "todo-integration": "ToDo 插件集成", + "todo-description": "将剪贴板项目直接添加到待办事项列表", + "todo-disabled": "ToDo 插件未安装或已禁用", + "features": "Features", + "pincards-enabled": "Enable Pin Cards", + "pincards-desc": "Show pinned items panel and allow pinning clipboard items", + "pincards-items-count": "Pinned Items", + "clear-all-pinned": "Clear All Pinned Items", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes", + "show-close-button": "Show Close Button", + "show-close-button-desc": "Display an X button at the top-right corner to close the panel", + "fullscreen-mode": "Fullscreen Mode", + "fullscreen-mode-desc": "Expand the clipboard panel to fill the entire screen", + "hide-panel-background": "Hide Panel Background", + "hide-panel-background-desc": "Remove background from panel containers. Note: Pin Cards and Note Cards work best with background enabled.", + "auto-paste-section": "Auto-Paste", + "auto-paste": "Auto-Paste on Click", + "auto-paste-desc": "Automatically paste clipboard item into focused window after selecting", + "auto-paste-warning": "wtype is required for auto-paste. Install with: sudo pacman -S wtype", + "auto-paste-rmb": "Right-Click Only", + "auto-paste-rmb-desc": "Use right-click for auto-paste; left-click copies to clipboard without pasting", + "auto-paste-delay": "Paste Delay", + "auto-paste-delay-desc": "Delay before pasting (ms). Increase if focus follows cursor is enabled in your compositor.", + "appearance": "卡片外观", + "card-type": "卡片类型", + "card-type-desc": "选择要自定义的卡片类型", + "preview": "预览", + "sample-content": "示例内容预览...", + "bg-color": "背景颜色", + "bg-color-desc": "卡片背景颜色", + "separator-color": "分隔线颜色", + "separator-color-desc": "标题和内容之间的线条", + "fg-color": "前景颜色", + "fg-color-desc": "标题、图标和文本内容颜色", + "reset-defaults": "重置为默认值", + "panel-width": "面板宽度", + "panel-width-desc": "面板的像素宽度(仅在全屏模式关闭时有效)", + "panel-height": "面板高度", + "panel-height-desc": "面板的像素高度。设为 0 则自动高度(仅在全屏模式关闭时有效)" + } +} diff --git a/noctalia/plugins/clipper/i18n/zh-TW.json b/noctalia/plugins/clipper/i18n/zh-TW.json new file mode 100644 index 0000000..7385b45 --- /dev/null +++ b/noctalia/plugins/clipper/i18n/zh-TW.json @@ -0,0 +1,122 @@ +{ + "bar": { + "tooltip": "剪貼簿歷史記錄" + }, + "context": { + "toggle": "切換 Clipper", + "settings": "開啟設定" + }, + "panel": { + "title": "剪貼簿歷史記錄", + "pinned-title": "已釘選項目", + "no-pinned": "無已釘選項目", + "settings": "設定", + "close": "Close", + "search-placeholder": "搜尋...", + "filter-all": "全部", + "filter-text": "文字", + "filter-images": "圖片", + "filter-colors": "顏色", + "filter-links": "連結", + "filter-code": "程式碼", + "filter-emoji": "表情符號", + "filter-files": "檔案", + "clear-all": "全部清除", + "no-matches": "無符合項目", + "empty": "剪貼簿為空" + }, + "card": { + "add-todo": "加入待辦事項", + "pin": "釘選", + "delete": "刪除" + }, + "notecards": { + "empty-state": "No notes yet", + "empty-hint": "Click the button below to create your first note", + "create-note": "Create Note", + "change-color": "Change Color", + "export": "Export to .txt", + "delete": "Delete Note", + "untitled-placeholder": "Untitled" + }, + "toast": { + "invalid-clipboard-item": "Invalid clipboard item", + "max-pinned-items": "Maximum {max} pinned items reached", + "item-not-found": "Item not found in clipboard", + "failed-to-pin": "Failed to pin item", + "failed-to-pin-image": "Failed to pin image", + "image-too-large": "Image too large to pin (max 5MB)", + "text-too-large": "Text too large to pin (max 1MB)", + "item-pinned": "Item pinned", + "item-unpinned": "Item unpinned", + "max-notes": "Maximum {max} notes reached", + "note-created": "Note created", + "note-deleted": "Note deleted", + "note-not-found": "Note not found", + "note-exported": "Note exported to ~/Documents/{fileName}", + "copied-to-clipboard": "Copied to clipboard", + "failed-to-copy-image": "Failed to copy image", + "failed-to-copy-text": "Failed to copy text", + "no-text-selected": "No text selected", + "failed-to-get-selection": "Failed to get selection", + "no-text-to-add": "No text to add", + "todo-not-available": "ToDo plugin not available", + "added-to-todo": "Added to ToDo", + "failed-to-copy": "Failed to copy to clipboard", + "todo-disabled": "ToDo integration is disabled", + "could-not-open-todo": "Could not open ToDo selector", + "could-not-open-note-selector": "Could not open note selector", + "text-added-to-note": "Text added to note", + "todo-page-created": "New ToDo page created", + "failed-to-get-cursor-position": "Failed to get cursor position", + "notes-cleared": "All notes cleared", + "pinned-cleared": "All pinned items cleared" + }, + "settings": { + "tab-general": "General", + "tab-appearance": "Appearance", + "integrations": "整合", + "todo-integration": "ToDo 外掛程式整合", + "todo-description": "將剪貼簿項目直接加入待辦事項清單", + "todo-disabled": "ToDo 外掛程式未安裝或已停用", + "features": "Features", + "pincards-enabled": "Enable Pin Cards", + "pincards-desc": "Show pinned items panel and allow pinning clipboard items", + "pincards-items-count": "Pinned Items", + "clear-all-pinned": "Clear All Pinned Items", + "notecards-enabled": "Enable NoteCards", + "notecards-desc": "Show notecards panel for quick notes", + "notecards-notes-count": "Current Notes", + "clear-all-notes": "Clear All Notes", + "show-close-button": "Show Close Button", + "show-close-button-desc": "Display an X button at the top-right corner to close the panel", + "fullscreen-mode": "Fullscreen Mode", + "fullscreen-mode-desc": "Expand the clipboard panel to fill the entire screen", + "hide-panel-background": "Hide Panel Background", + "hide-panel-background-desc": "Remove background from panel containers. Note: Pin Cards and Note Cards work best with background enabled.", + "auto-paste-section": "Auto-Paste", + "auto-paste": "Auto-Paste on Click", + "auto-paste-desc": "Automatically paste clipboard item into focused window after selecting", + "auto-paste-warning": "wtype is required for auto-paste. Install with: sudo pacman -S wtype", + "auto-paste-rmb": "Right-Click Only", + "auto-paste-rmb-desc": "Use right-click for auto-paste; left-click copies to clipboard without pasting", + "auto-paste-delay": "Paste Delay", + "auto-paste-delay-desc": "Delay before pasting (ms). Increase if focus follows cursor is enabled in your compositor.", + "appearance": "卡片外觀", + "card-type": "卡片類型", + "card-type-desc": "選擇要自訂的卡片類型", + "preview": "預覽", + "sample-content": "範例內容預覽...", + "bg-color": "背景顏色", + "bg-color-desc": "卡片背景顏色", + "separator-color": "分隔線顏色", + "separator-color-desc": "標題與內容之間的線條", + "fg-color": "前景顏色", + "fg-color-desc": "標題、圖示和文字內容顏色", + "reset-defaults": "重設為預設值", + "panel-width": "面板寬度", + "panel-width-desc": "面板的像素寬度(僅在全螢幕模式關閉時有效)", + "panel-height": "面板高度", + "panel-height-desc": "面板的像素高度。設為 0 則自動高度(僅在全螢幕模式關閉時有效)" + } +} diff --git a/noctalia/plugins/clipper/manifest.json b/noctalia/plugins/clipper/manifest.json new file mode 100644 index 0000000..ff0398d --- /dev/null +++ b/noctalia/plugins/clipper/manifest.json @@ -0,0 +1,46 @@ +{ + "id": "clipper", + "name": "Clipper", + "version": "2.4.4", + "minNoctaliaVersion": "4.1.2", + "author": "blackbartblues", + "contributors": [ + "rscipher001", + "neyfua" + ], + "license": "MIT", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "description": "Advanced clipboard manager with history, search, keyboard navigation, ToDo integration, pinned items, notecards/sticky notes, and selection-to-notecard feature. Fully translated with comprehensive i18n support.", + "tags": [ + "Utility", + "Bar", + "Panel" + ], + "entryPoints": { + "main": "Main.qml", + "barWidget": "BarWidget.qml", + "panel": "Panel.qml", + "settings": "Settings.qml", + "controlCenterWidget": "ControlCenterWidget.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "defaultSettings": { + "enableTodoIntegration": false, + "pincardsEnabled": true, + "notecardsEnabled": true, + "showCloseButton": true, + "fullscreenMode": false, + "hidePanelBackground": false, + "autoPaste": false, + "autoPasteOnRightClick": false, + "autoPasteDelay": 300, + "panelWidth": 1450, + "panelHeight": 0, + "cardColors": {}, + "customColors": {} + } + } +} diff --git a/noctalia/plugins/clipper/preview.png b/noctalia/plugins/clipper/preview.png new file mode 100644 index 0000000..06e2dca Binary files /dev/null and b/noctalia/plugins/clipper/preview.png differ diff --git a/noctalia/plugins/fancy-audiovisualizer/DesktopWidget.qml b/noctalia/plugins/fancy-audiovisualizer/DesktopWidget.qml new file mode 100644 index 0000000..6c934aa --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/DesktopWidget.qml @@ -0,0 +1,150 @@ +import QtQuick +import Quickshell +import qs.Commons +import qs.Modules.DesktopWidgets +import qs.Services.Media + +DraggableDesktopWidget { + id: root + + property var pluginApi: null + + implicitWidth: Math.round(300 * widgetScale) + implicitHeight: Math.round(300 * widgetScale) + + showBackground: false + + // Scaled dimensions + readonly property int scaledRadiusL: Math.round(Style.radiusL * widgetScale) + + // Settings from plugin + readonly property real sensitivity: widgetData?.sensitivity ?? pluginApi?.pluginSettings?.sensitivity ?? pluginApi?.manifest?.metadata?.defaultSettings?.sensitivity + readonly property real rotationSpeed: widgetData?.rotationSpeed ?? pluginApi?.pluginSettings?.rotationSpeed ?? pluginApi?.manifest?.metadata?.defaultSettings?.rotationSpeed + readonly property real barWidth: widgetData?.barWidth ?? pluginApi?.pluginSettings?.barWidth ?? pluginApi?.manifest?.metadata?.defaultSettings?.barWidth + readonly property real ringOpacity: widgetData?.ringOpacity ?? pluginApi?.pluginSettings?.ringOpacity ?? pluginApi?.manifest?.metadata?.defaultSettings?.ringOpacity + readonly property real bloomIntensity: widgetData?.bloomIntensity ?? pluginApi.pluginSettings?.bloomIntensity ?? pluginApi?.manifest?.metadata?.defaultSettings?.bloomIntensity + readonly property int visualizationMode: widgetData?.visualizationMode ?? pluginApi?.pluginSettings?.visualizationMode ?? pluginApi?.manifest?.metadata?.defaultSettings?.visualizationMode ?? 3 + readonly property real waveThickness: widgetData?.waveThickness ?? pluginApi?.pluginSettings?.waveThickness ?? pluginApi?.manifest?.metadata?.defaultSettings?.waveThickness ?? 1.0 + readonly property real innerDiameter: widgetData?.innerDiameter ?? pluginApi?.pluginSettings?.innerDiameter ?? pluginApi?.manifest?.metadata?.defaultSettings?.innerDiameter ?? 0.7 + readonly property bool fadeWhenIdle: widgetData?.fadeWhenIdle ?? pluginApi?.pluginSettings?.fadeWhenIdle ?? false + readonly property bool useCustomColors: widgetData?.useCustomColors ?? pluginApi?.pluginSettings?.useCustomColors ?? false + readonly property color customPrimaryColor: widgetData?.customPrimaryColor ?? pluginApi?.pluginSettings?.customPrimaryColor ?? "#6750A4" + readonly property color customSecondaryColor: widgetData?.customSecondaryColor ?? pluginApi?.pluginSettings?.customSecondaryColor ?? "#625B71" + + // Animation time for shader (0 to 3600, 1 hour cycle) + property real shaderTime: 0 + NumberAnimation on shaderTime { + loops: Animation.Infinite + from: 0 + to: 3600 + duration: 3600000 + running: !SpectrumService.isIdle + } + + // Hidden canvas that encodes audio data as a 32x1 texture + Canvas { + id: audioCanvas + width: 32 + height: 1 + visible: false + + onPaint: { + var ctx = getContext("2d"); + var values = SpectrumService.values; + if (!values || values.length === 0) { + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, 32, 1); + return; + } + for (var i = 0; i < 32; i++) { + var v = values[i] || 0; + // Encode amplitude in grayscale (R=G=B=amplitude) + var c = Math.floor(v * 255); + ctx.fillStyle = "rgb(" + c + "," + c + "," + c + ")"; + ctx.fillRect(i, 0, 1, 1); + } + } + } + + // Trigger canvas repaint when audio data changes + Connections { + target: SpectrumService + function onValuesChanged() { + if (!SpectrumService.isIdle) { + audioCanvas.requestPaint(); + } + } + } + + // Unique instance ID for SpectrumService registration + // This prevents the old widget's destruction from unregistering the new widget + readonly property string spectrumInstanceId: "plugin:fancy-audiovisualizer:" + Date.now() + Math.random() + + // Register with SpectrumService when pluginApi becomes available + onPluginApiChanged: { + if (pluginApi) { + SpectrumService.registerComponent(spectrumInstanceId); + audioCanvas.requestPaint(); + } + } + + Component.onDestruction: { + SpectrumService.unregisterComponent(spectrumInstanceId); + } + + // Audio texture source (outside ShaderEffect to avoid 'source' property warning) + ShaderEffectSource { + id: audioTextureSource + sourceItem: audioCanvas + live: true + hideSource: true + } + + // The shader effect visualization + ShaderEffect { + id: visualizer + anchors.fill: parent + visible: pluginApi !== null + opacity: (root.fadeWhenIdle && SpectrumService.isIdle) ? 0 : 1 + + Behavior on opacity { + NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } + } + + // Audio texture - named 'source' to match ShaderEffectSource's property and avoid warning + property var source: audioTextureSource + + // Uniforms passed to shader + property real time: root.shaderTime + property real itemWidth: visualizer.width + property real itemHeight: visualizer.height + property color primaryColor: root.useCustomColors ? root.customPrimaryColor : Color.mPrimary + property color secondaryColor: root.useCustomColors ? root.customSecondaryColor : Color.mSecondary + property real sensitivity: root.sensitivity + property real rotationSpeed: root.rotationSpeed + property real barWidth: root.barWidth + property real ringOpacity: root.ringOpacity + property real cornerRadius: scaledRadiusL + property real bloomIntensity: root.bloomIntensity + property real visualizationMode: root.visualizationMode + property real waveThickness: root.waveThickness + property real innerDiameter: root.innerDiameter + + fragmentShader: pluginApi ? Qt.resolvedUrl(pluginApi.pluginDir + "/shaders/visualizer.frag.qsb") : "" + } + + // Fallback when shader not loaded + Rectangle { + anchors.fill: parent + color: Color.mSurface + radius: scaledRadiusL + visible: !visualizer.visible || visualizer.fragmentShader === "" + + Text { + anchors.centerIn: parent + text: "Loading..." + color: Color.mOnSurface + font.pointSize: Math.round(Style.fontSizeM * widgetScale) + } + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/DesktopWidgetSettings.qml b/noctalia/plugins/fancy-audiovisualizer/DesktopWidgetSettings.qml new file mode 100644 index 0000000..3122c24 --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/DesktopWidgetSettings.qml @@ -0,0 +1,244 @@ +import QtQuick +import QtQuick.Layouts +import qs.Commons +import qs.Widgets + +ColumnLayout { + id: root + + property var pluginApi: null + property var widgetSettings: null + property var screen: null + + spacing: Style.marginM + + // Local state for editing + property real valueSensitivity: widgetSettings?.data?.sensitivity ?? pluginApi?.pluginSettings?.sensitivity ?? 1.5 + property real valueRotationSpeed: widgetSettings?.data?.rotationSpeed ?? pluginApi?.pluginSettings?.rotationSpeed ?? 0.5 + property real valueBarWidth: widgetSettings?.data?.barWidth ?? pluginApi?.pluginSettings?.barWidth ?? 0.6 + property real valueRingOpacity: widgetSettings?.data?.ringOpacity ?? pluginApi?.pluginSettings?.ringOpacity ?? 0.8 + property real valueBloomIntensity: widgetSettings?.data?.bloomIntensity ?? pluginApi?.pluginSettings?.bloomIntensity ?? 0.5 + property int valueVisualizationMode: widgetSettings?.data?.visualizationMode ?? pluginApi?.pluginSettings?.visualizationMode ?? 3 + property real valueWaveThickness: widgetSettings?.data?.waveThickness ?? pluginApi?.pluginSettings?.waveThickness ?? 1.0 + property real valueInnerDiameter: widgetSettings?.data?.innerDiameter ?? pluginApi?.pluginSettings?.innerDiameter ?? 0.7 + property bool valueFadeWhenIdle: widgetSettings?.data?.fadeWhenIdle ?? pluginApi?.pluginSettings?.fadeWhenIdle ?? true + property bool valueUseCustomColors: widgetSettings?.data?.useCustomColors ?? pluginApi?.pluginSettings?.useCustomColors ?? false + property color valueCustomPrimaryColor: widgetSettings?.data?.customPrimaryColor ?? pluginApi?.pluginSettings?.customPrimaryColor ?? "#6750A4" + property color valueCustomSecondaryColor: widgetSettings?.data?.customSecondaryColor ?? pluginApi?.pluginSettings?.customSecondaryColor ?? "#625B71" + + // Mode helpers + readonly property bool modeHasBars: valueVisualizationMode === 0 || valueVisualizationMode === 3 || valueVisualizationMode === 5 + readonly property bool modeHasWave: valueVisualizationMode === 1 || valueVisualizationMode === 4 || valueVisualizationMode === 5 + readonly property bool modeHasRings: valueVisualizationMode >= 2 + + NHeader { + label: pluginApi?.tr("settings.title") ?? "Visualizer Settings" + description: pluginApi?.tr("settings.description") ?? "Configure the audio visualizer appearance" + } + + // Visualization mode selector + NComboBox { + Layout.fillWidth: true + label: pluginApi?.tr("settings.visualizationMode") ?? "Visualization Mode" + description: pluginApi?.tr("settings.visualizationMode-description") ?? "Choose visualization style" + model: [ + {"key": "0", "name": pluginApi?.tr("settings.mode.bars") ?? "Bars"}, + {"key": "1", "name": pluginApi?.tr("settings.mode.wave") ?? "Wave"}, + {"key": "2", "name": pluginApi?.tr("settings.mode.rings") ?? "Rings"}, + {"key": "3", "name": pluginApi?.tr("settings.mode.barsRings") ?? "Bars + Rings"}, + {"key": "4", "name": pluginApi?.tr("settings.mode.waveRings") ?? "Wave + Rings"}, + {"key": "5", "name": pluginApi?.tr("settings.mode.all") ?? "All"} + ] + currentKey: String(root.valueVisualizationMode) + onSelected: key => { + root.valueVisualizationMode = parseInt(key); + root.saveSettings(); + } + } + + // Wave thickness slider (shown when mode includes wave) + NValueSlider { + Layout.fillWidth: true + visible: root.modeHasWave + label: pluginApi?.tr("settings.waveThickness") ?? "Wave Thickness" + value: root.valueWaveThickness + from: 0.3 + to: 2.0 + stepSize: 0.1 + onMoved: value => { + root.valueWaveThickness = value; + root.saveSettings(); + } + } + + // Sensitivity slider + NValueSlider { + Layout.fillWidth: true + label: pluginApi?.tr("settings.sensitivity") ?? "Sensitivity" + value: root.valueSensitivity + from: 0.5 + to: 3.0 + stepSize: 0.1 + onMoved: value => { + root.valueSensitivity = value; + root.saveSettings(); + } + } + + // Rotation speed slider + NValueSlider { + Layout.fillWidth: true + label: pluginApi?.tr("settings.rotationSpeed") ?? "Rotation Speed" + value: root.valueRotationSpeed + from: 0.0 + to: 2.0 + stepSize: 0.1 + onMoved: value => { + root.valueRotationSpeed = value; + root.saveSettings(); + } + } + + // Bar width slider (shown when mode includes bars) + NValueSlider { + Layout.fillWidth: true + visible: root.modeHasBars + label: pluginApi?.tr("settings.barWidth") ?? "Bar Width" + value: root.valueBarWidth + from: 0.2 + to: 1.0 + stepSize: 0.1 + onMoved: value => { + root.valueBarWidth = value; + root.saveSettings(); + } + } + + // Ring opacity slider (shown when mode includes rings) + NValueSlider { + Layout.fillWidth: true + visible: root.modeHasRings + label: pluginApi?.tr("settings.ringOpacity") ?? "Ring Opacity" + value: root.valueRingOpacity + from: 0.0 + to: 1.0 + stepSize: 0.1 + onMoved: value => { + root.valueRingOpacity = value; + root.saveSettings(); + } + } + + // Base diameter slider + NValueSlider { + Layout.fillWidth: true + label: pluginApi?.tr("settings.innerDiameter") ?? "Inner Diameter" + value: root.valueInnerDiameter + from: 0 + to: 1 + stepSize: 0.05 + onMoved: value => { + root.valueInnerDiameter = value; + root.saveSettings(); + } + } + + // Bloom intensity slider + NValueSlider { + Layout.fillWidth: true + label: pluginApi?.tr("settings.bloomIntensity") ?? "Bloom Intensity" + value: root.valueBloomIntensity + from: 0.0 + to: 1.0 + stepSize: 0.05 + onMoved: value => { + root.valueBloomIntensity = value; + root.saveSettings(); + } + } + + // Fade when idle toggle + NToggle { + label: pluginApi?.tr("settings.fadeWhenIdle") ?? "Fade When Idle" + description: pluginApi?.tr("settings.fadeWhenIdle-description") ?? "Fade out visualizer when no audio is playing" + checked: root.valueFadeWhenIdle + onToggled: checked => { + root.valueFadeWhenIdle = checked; + root.saveSettings(); + } + } + + // Use custom colors toggle + NToggle { + label: pluginApi?.tr("settings.useCustomColors") ?? "Use Custom Colors" + description: pluginApi?.tr("settings.useCustomColors-description") ?? "Override theme colors with custom colors" + checked: root.valueUseCustomColors + onToggled: checked => { + root.valueUseCustomColors = checked; + root.saveSettings(); + } + } + + // Custom primary color picker + RowLayout { + Layout.fillWidth: true + visible: root.valueUseCustomColors + spacing: Style.marginM + + NText { + text: pluginApi?.tr("settings.customPrimaryColor") ?? "Primary Color" + Layout.fillWidth: true + } + + NColorPicker { + screen: Screen + selectedColor: root.valueCustomPrimaryColor + onColorSelected: color => { + root.valueCustomPrimaryColor = color; + root.saveSettings(); + } + } + } + + // Custom secondary color picker + RowLayout { + Layout.fillWidth: true + visible: root.valueUseCustomColors + spacing: Style.marginM + + NText { + text: pluginApi?.tr("settings.customSecondaryColor") ?? "Secondary Color" + Layout.fillWidth: true + } + + NColorPicker { + screen: Screen + selectedColor: root.valueCustomSecondaryColor + onColorSelected: color => { + root.valueCustomSecondaryColor = color; + root.saveSettings(); + } + } + } + + // Called when user clicks Apply/Save + function saveSettings() { + if (!widgetSettings) + return; + + widgetSettings.data.sensitivity = root.valueSensitivity; + widgetSettings.data.rotationSpeed = root.valueRotationSpeed; + widgetSettings.data.barWidth = root.valueBarWidth; + widgetSettings.data.ringOpacity = root.valueRingOpacity; + widgetSettings.data.bloomIntensity = root.valueBloomIntensity; + widgetSettings.data.visualizationMode = root.valueVisualizationMode; + widgetSettings.data.waveThickness = root.valueWaveThickness; + widgetSettings.data.innerDiameter = root.valueInnerDiameter; + widgetSettings.data.fadeWhenIdle = root.valueFadeWhenIdle; + widgetSettings.data.useCustomColors = root.valueUseCustomColors; + widgetSettings.data.customPrimaryColor = root.valueCustomPrimaryColor.toString(); + widgetSettings.data.customSecondaryColor = root.valueCustomSecondaryColor.toString(); + + widgetSettings.save(); + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/README.md b/noctalia/plugins/fancy-audiovisualizer/README.md new file mode 100644 index 0000000..93a4d46 --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/README.md @@ -0,0 +1,38 @@ +# Fancy Audiovisualizer + +A circular audio visualizer desktop widget for Noctalia Shell with shader-based rendering and multiple visualization modes. + +## Features + +- **Multiple Visualization Modes**: Bars, Wave, Rings, or combinations (Bars+Rings, Wave+Rings, All) +- **Shader-Based Rendering**: Smooth, GPU-accelerated visualization using custom fragment shaders +- **Theme Integration**: Automatically uses Noctalia theme colors, with optional custom color override +- **Configurable Appearance**: Adjust sensitivity, rotation speed, bar width, ring opacity, bloom intensity, and more +- **Idle Fade**: Optional fade-out when no audio is playing + +## Installation + +This plugin is part of the `noctalia-plugins` repository. + +## Configuration + +Access the plugin settings in Noctalia to configure the following options: + +- **Visualization Mode**: Choose between Bars, Wave, Rings, Bars+Rings, Wave+Rings, or All +- **Wave Thickness**: Thickness of the wave visualization (when enabled) +- **Sensitivity**: Audio response sensitivity (0.5 - 3.0) +- **Rotation Speed**: Speed of visualization rotation (0.0 - 2.0) +- **Bar Width**: Width of audio bars (when enabled) +- **Ring Opacity**: Opacity of background rings (when enabled) +- **Inner Diameter**: Size of the inner empty area +- **Bloom Intensity**: Glow/bloom effect strength +- **Fade When Idle**: Fade out when no audio is playing +- **Custom Colors**: Override theme colors with custom primary and secondary colors + +## Usage + +Add the widget to your desktop via the Noctalia desktop widgets interface. The visualizer responds to system audio. + +## Requirements + +- Noctalia 3.7.2 or later diff --git a/noctalia/plugins/fancy-audiovisualizer/i18n/de.json b/noctalia/plugins/fancy-audiovisualizer/i18n/de.json new file mode 100644 index 0000000..002193d --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/i18n/de.json @@ -0,0 +1,29 @@ +{ + "settings": { + "barWidth": "Balkenbreite", + "bloomIntensity": "Blütenintensität", + "customPrimaryColor": "Primärfarbe", + "customSecondaryColor": "Sekundärfarbe", + "description": "Audiovisualisierungsdarstellung konfigurieren", + "fadeWhenIdle": "Ausblenden bei Inaktivität", + "fadeWhenIdle-description": "Visualisierer ausblenden, wenn keine Audioausgabe erfolgt", + "innerDiameter": "Innendurchmesser", + "mode": { + "all": "Alle", + "bars": "Bars", + "barsRings": "Barren + Ringe", + "rings": "Ringe", + "wave": "Welle", + "waveRings": "Welle + Ringe" + }, + "ringOpacity": "Ring-Opazität", + "rotationSpeed": "Drehzahl", + "sensitivity": "Empfindlichkeit", + "title": "Visualisierungseinstellungen", + "useCustomColors": "Benutzerdefinierte Farben verwenden", + "useCustomColors-description": "Themenfarben mit benutzerdefinierten Farben überschreiben", + "visualizationMode": "Visualisierungsmodus", + "visualizationMode-description": "Visualisierungsstil wählen", + "waveThickness": "Wellendicke" + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/i18n/en.json b/noctalia/plugins/fancy-audiovisualizer/i18n/en.json new file mode 100644 index 0000000..f9e8ba2 --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/i18n/en.json @@ -0,0 +1,29 @@ +{ + "settings": { + "barWidth": "Bar Width", + "bloomIntensity": "Bloom Intensity", + "customPrimaryColor": "Primary Color", + "customSecondaryColor": "Secondary Color", + "description": "Configure the audio visualizer appearance", + "fadeWhenIdle": "Fade When Idle", + "fadeWhenIdle-description": "Fade out visualizer when no audio is playing", + "innerDiameter": "Inner Diameter", + "mode": { + "all": "All", + "bars": "Bars", + "barsRings": "Bars + Rings", + "rings": "Rings", + "wave": "Wave", + "waveRings": "Wave + Rings" + }, + "ringOpacity": "Ring Opacity", + "rotationSpeed": "Rotation Speed", + "sensitivity": "Sensitivity", + "title": "Visualizer Settings", + "useCustomColors": "Use Custom Colors", + "useCustomColors-description": "Override theme colors with custom colors", + "visualizationMode": "Visualization Mode", + "visualizationMode-description": "Choose visualization style", + "waveThickness": "Wave Thickness" + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/i18n/es.json b/noctalia/plugins/fancy-audiovisualizer/i18n/es.json new file mode 100644 index 0000000..09bb647 --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/i18n/es.json @@ -0,0 +1,29 @@ +{ + "settings": { + "barWidth": "Ancho de barra", + "bloomIntensity": "Intensidad de floración", + "customPrimaryColor": "Color primario", + "customSecondaryColor": "Color secundario", + "description": "Configurar la apariencia del visualizador de audio", + "fadeWhenIdle": "Desvanecer al estar inactivo", + "fadeWhenIdle-description": "Atenuar el visualizador cuando no se reproduce audio.", + "innerDiameter": "Diámetro interior", + "mode": { + "all": "Todo", + "bars": "Barras", + "barsRings": "Barras + Anillas", + "rings": "Anillos", + "wave": "Ola", + "waveRings": "Onda + Anillos" + }, + "ringOpacity": "Opacidad del anillo", + "rotationSpeed": "Velocidad de rotación", + "sensitivity": "Sensibilidad", + "title": "Ajustes del Visualizador", + "useCustomColors": "Usar colores personalizados", + "useCustomColors-description": "Anular los colores del tema con colores personalizados", + "visualizationMode": "Modo de visualización", + "visualizationMode-description": "Elegir estilo de visualización", + "waveThickness": "Espesor de la onda" + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/i18n/fr.json b/noctalia/plugins/fancy-audiovisualizer/i18n/fr.json new file mode 100644 index 0000000..5a4c10a --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/i18n/fr.json @@ -0,0 +1,29 @@ +{ + "settings": { + "barWidth": "Largeur de la barre", + "bloomIntensity": "Intensité de l'éclat", + "customPrimaryColor": "Couleur primaire", + "customSecondaryColor": "Couleur secondaire", + "description": "Configurer l'apparence du visualiseur audio", + "fadeWhenIdle": "Estomper en cas d'inactivité", + "fadeWhenIdle-description": "Estomper le visualiseur en l'absence de son.", + "innerDiameter": "Diamètre intérieur", + "mode": { + "all": "Tout", + "bars": "Barres", + "barsRings": "Barres + Anneaux", + "rings": "Anneaux", + "wave": "Vague", + "waveRings": "Onde + Anneaux" + }, + "ringOpacity": "Opacité de l'anneau", + "rotationSpeed": "Vitesse de rotation", + "sensitivity": "Sensibilité", + "title": "Paramètres du visualiseur", + "useCustomColors": "Utiliser des couleurs personnalisées", + "useCustomColors-description": "Remplacer les couleurs du thème par des couleurs personnalisées", + "visualizationMode": "Mode de visualisation", + "visualizationMode-description": "Choisir le style de visualisation", + "waveThickness": "Épaisseur de la vague" + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/i18n/hu.json b/noctalia/plugins/fancy-audiovisualizer/i18n/hu.json new file mode 100644 index 0000000..c2b4a9b --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/i18n/hu.json @@ -0,0 +1,29 @@ +{ + "settings": { + "barWidth": "Sávszélesség", + "bloomIntensity": "Virágzás intenzitása", + "customPrimaryColor": "Elsődleges szín", + "customSecondaryColor": "Másodlagos szín", + "description": "Az audio vizualizáló megjelenésének beállítása", + "fadeWhenIdle": "Halványítás tétlenség esetén", + "fadeWhenIdle-description": "A vizualizáció elhalványítása, ha nincs hang lejátszva", + "innerDiameter": "Belső átmérő", + "mode": { + "all": "Összes", + "bars": "Sávok", + "barsRings": "Sávok + Gyűrűk", + "rings": "Gyűrűk", + "wave": "Hullám", + "waveRings": "Hullám + Gyűrűk" + }, + "ringOpacity": "Gyűrű átlátszósága", + "rotationSpeed": "Forgási sebesség", + "sensitivity": "Érzékenység", + "title": "Vizualizációs beállítások", + "useCustomColors": "Egyéni színek használata", + "useCustomColors-description": "A téma színeinek felülírása egyéni színekkel", + "visualizationMode": "Megjelenítési mód", + "visualizationMode-description": "Válassz megjelenítési stílust", + "waveThickness": "Hullámvastagság" + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/i18n/it.json b/noctalia/plugins/fancy-audiovisualizer/i18n/it.json new file mode 100644 index 0000000..11a75ac --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/i18n/it.json @@ -0,0 +1,29 @@ +{ + "settings": { + "barWidth": "Breite der Leiste", + "bloomIntensity": "Intensità Bloom", + "customPrimaryColor": "Ngjyra parësore", + "customSecondaryColor": "Ngjyra dytësore", + "description": "Konfigurieren Sie das Aussehen des Audio-Visualisierers.", + "fadeWhenIdle": "Verblassen im Leerlauf", + "fadeWhenIdle-description": "Vizualizátor eltüntetése, ha nincs hang lejátszva.", + "innerDiameter": "Innendurchmesser", + "mode": { + "all": "Kaikki", + "bars": "Pritličja", + "barsRings": "Stangen + Ringe", + "rings": "Hringar", + "wave": "Val", + "waveRings": "Welle + Ringe" + }, + "ringOpacity": "Neprůhlednost prstence", + "rotationSpeed": "Schnelllaufdrehzahl", + "sensitivity": "Ndjeshmëri", + "title": "Mga Setting ng Visualizer", + "useCustomColors": "Përdorni Ngjyra të Personalizuara", + "useCustomColors-description": "Ngjyrat e personalizuara mbivendosin ngjyrat e temës", + "visualizationMode": "Módszer megjelenítése", + "visualizationMode-description": "Zgjidhni stilin e vizualizimit", + "waveThickness": "Onda-lodiera" + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/i18n/ja.json b/noctalia/plugins/fancy-audiovisualizer/i18n/ja.json new file mode 100644 index 0000000..f720102 --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/i18n/ja.json @@ -0,0 +1,29 @@ +{ + "settings": { + "barWidth": "バーの幅", + "bloomIntensity": "開花強度", + "customPrimaryColor": "原色", + "customSecondaryColor": "二次色", + "description": "オーディオビジュアライザーの外観を設定します。", + "fadeWhenIdle": "アイドル時にフェード", + "fadeWhenIdle-description": "音声が再生されていないときは、ビジュアライザーをフェードアウトする", + "innerDiameter": "内径", + "mode": { + "all": "すべて", + "bars": "バー", + "barsRings": "鉄棒と輪", + "rings": "リング", + "wave": "波", + "waveRings": "波紋 + リング" + }, + "ringOpacity": "リングの不透明度", + "rotationSpeed": "回転速度", + "sensitivity": "感度", + "title": "ビジュアライザー設定", + "useCustomColors": "カスタムカラーを使用する", + "useCustomColors-description": "カスタムカラーでテーマの色をオーバーライドする", + "visualizationMode": "可視化モード", + "visualizationMode-description": "視覚化のスタイルを選択", + "waveThickness": "波厚" + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/i18n/ku.json b/noctalia/plugins/fancy-audiovisualizer/i18n/ku.json new file mode 100644 index 0000000..6a2ef70 --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/i18n/ku.json @@ -0,0 +1,29 @@ +{ + "settings": { + "barWidth": "Firehiya Barê", + "bloomIntensity": "Zehfîya Gurtî", + "customPrimaryColor": "Rengê Seretayî", + "customSecondaryColor": "Rengê Duyemîn", + "description": "Xuyakirina dîmenê bihîstwerî yê dengî", + "fadeWhenIdle": "Dema de bêdengiyê vemirîne", + "fadeWhenIdle-description": "Dîmenkerê dema ku tu deng lê nabe, hêdî hêdî winda bike", + "innerDiameter": "Dirêjbûna Hundir", + "mode": { + "all": "Hemû", + "bars": "Bars", + "barsRings": "Bars + Rings", + "rings": "Xelqet", + "wave": "Pêl", + "waveRings": "Pêl + Xelek" + }, + "ringOpacity": "Ronahiya Zengil", + "rotationSpeed": "Leza Şewitandinê", + "sensitivity": "Hesasiyet", + "title": "Mîhengên Dîmenderê", + "useCustomColors": "Rengên Xweser Bikar Bîne", + "useCustomColors-description": "Rengên mijarê bi rengên xwerû biguherîne", + "visualizationMode": "Şêwaza Dîmenkirinê", + "visualizationMode-description": "Şêwaza dîtinê hilbijêre", + "waveThickness": "Qalinîya Pêlê" + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/i18n/nl.json b/noctalia/plugins/fancy-audiovisualizer/i18n/nl.json new file mode 100644 index 0000000..b3b845e --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/i18n/nl.json @@ -0,0 +1,29 @@ +{ + "settings": { + "barWidth": "Balkbreedte", + "bloomIntensity": "Bloei Intensiteit", + "customPrimaryColor": "Primaire kleur", + "customSecondaryColor": "Secundaire kleur", + "description": "Configureer de weergave van de audio visualisatie.", + "fadeWhenIdle": "Vervagen bij inactiviteit", + "fadeWhenIdle-description": "Visualiseerder laten vervagen wanneer er geen audio wordt afgespeeld.", + "innerDiameter": "Binnendiameter", + "mode": { + "all": "Alles", + "bars": "Bars", + "barsRings": "Rekstok + Ringen", + "rings": "Ringen", + "wave": "Golf", + "waveRings": "Golf + Ringen" + }, + "ringOpacity": "Ringondoorzichtigheid", + "rotationSpeed": "Rotatiesnelheid", + "sensitivity": "Gevoeligheid", + "title": "Visualisatie-instellingen", + "useCustomColors": "Aangepaste kleuren gebruiken", + "useCustomColors-description": "Themakleuren overschrijven met aangepaste kleuren", + "visualizationMode": "Visualisatiemodus", + "visualizationMode-description": "Kies visualisatiestijl", + "waveThickness": "Golflaagdikte" + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/i18n/pl.json b/noctalia/plugins/fancy-audiovisualizer/i18n/pl.json new file mode 100644 index 0000000..d3030df --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/i18n/pl.json @@ -0,0 +1,29 @@ +{ + "settings": { + "barWidth": "Szerokość paska", + "bloomIntensity": "Intensywność Rozkwitu", + "customPrimaryColor": "Kolor podstawowy", + "customSecondaryColor": "Kolor dodatkowy", + "description": "Skonfiguruj wygląd wizualizatora dźwięku", + "fadeWhenIdle": "Przyciemnij przy bezczynności", + "fadeWhenIdle-description": "Wycisz wizualizator, gdy nie jest odtwarzany dźwięk", + "innerDiameter": "Średnica wewnętrzna", + "mode": { + "all": "Wszystkie", + "bars": "Paski", + "barsRings": "Paski + Pierścienie", + "rings": "Pierścienie", + "wave": "Fala", + "waveRings": "Fala + Pierścienie" + }, + "ringOpacity": "Krycie pierścienia", + "rotationSpeed": "Prędkość obrotu", + "sensitivity": "Czułość", + "title": "Ustawienia wizualizatora", + "useCustomColors": "Użyj własnych kolorów", + "useCustomColors-description": "Zastąp kolory motywu własnymi kolorami", + "visualizationMode": "Tryb wizualizacji", + "visualizationMode-description": "Wybierz styl wizualizacji", + "waveThickness": "Grubość fali" + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/i18n/pt.json b/noctalia/plugins/fancy-audiovisualizer/i18n/pt.json new file mode 100644 index 0000000..c3e5e54 --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/i18n/pt.json @@ -0,0 +1,29 @@ +{ + "settings": { + "barWidth": "Largura da Barra", + "bloomIntensity": "Intensidade do Bloom", + "customPrimaryColor": "Cor primária", + "customSecondaryColor": "Cor secundária", + "description": "Configurar a aparência do visualizador de áudio", + "fadeWhenIdle": "Desaparecer quando inativo", + "fadeWhenIdle-description": "Desvanecer o visualizador quando nenhum áudio estiver sendo reproduzido.", + "innerDiameter": "Diâmetro interno", + "mode": { + "all": "Tudo", + "bars": "Barras", + "barsRings": "Barras + Argolas", + "rings": "Anéis", + "wave": "Onda", + "waveRings": "Onda + Anéis" + }, + "ringOpacity": "Opacidade do Anel", + "rotationSpeed": "Velocidade de rotação", + "sensitivity": "Sensibilidade", + "title": "Configurações do Visualizador", + "useCustomColors": "Usar Cores Personalizadas", + "useCustomColors-description": "Substituir as cores do tema por cores personalizadas", + "visualizationMode": "Modo de Visualização", + "visualizationMode-description": "Escolha o estilo de visualização", + "waveThickness": "Espessura da onda" + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/i18n/ru.json b/noctalia/plugins/fancy-audiovisualizer/i18n/ru.json new file mode 100644 index 0000000..99537de --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/i18n/ru.json @@ -0,0 +1,29 @@ +{ + "settings": { + "barWidth": "Ширина полосы", + "bloomIntensity": "Интенсивность свечения", + "customPrimaryColor": "Основной цвет", + "customSecondaryColor": "Вторичный цвет", + "description": "Настроить внешний вид визуализатора звука", + "fadeWhenIdle": "Затухать в режиме ожидания", + "fadeWhenIdle-description": "Затухать визуализатору при отсутствии воспроизведения аудио.", + "innerDiameter": "Внутренний диаметр", + "mode": { + "all": "Всё", + "bars": "Бары", + "barsRings": "Турники + Кольца", + "rings": "Кольца", + "wave": "Волна", + "waveRings": "Волна + Кольца" + }, + "ringOpacity": "Прозрачность кольца", + "rotationSpeed": "Скорость вращения", + "sensitivity": "Чувствительность", + "title": "Настройки визуализатора", + "useCustomColors": "Использовать пользовательские цвета", + "useCustomColors-description": "Переопределить цвета темы пользовательскими цветами", + "visualizationMode": "Режим визуализации", + "visualizationMode-description": "Выберите стиль визуализации", + "waveThickness": "Толщина волны" + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/i18n/tr.json b/noctalia/plugins/fancy-audiovisualizer/i18n/tr.json new file mode 100644 index 0000000..2f5b9a5 --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/i18n/tr.json @@ -0,0 +1,29 @@ +{ + "settings": { + "barWidth": "Çubuk Genişliği", + "bloomIntensity": "Çiçeklenme Yoğunluğu", + "customPrimaryColor": "Ana Renk", + "customSecondaryColor": "İkincil Renk", + "description": "Ses görselleştirici görünümünü yapılandır", + "fadeWhenIdle": "Boşta Kalınca Karart", + "fadeWhenIdle-description": "Ses çalmadığında görselleştiriciyi soldur.", + "innerDiameter": "İç Çap", + "mode": { + "all": "Tüm", + "bars": "Barlar", + "barsRings": "Barfiks Demiri + Halkalar", + "rings": "Yüzükler", + "wave": "Dalga", + "waveRings": "Dalga + Halkalar" + }, + "ringOpacity": "Halka Opaklığı", + "rotationSpeed": "Dönme Hızı", + "sensitivity": "Hassasiyet", + "title": "Görselleştirici Ayarları", + "useCustomColors": "Özel Renkleri Kullan", + "useCustomColors-description": "Tema renklerini özel renklerle geçersiz kıl", + "visualizationMode": "Görselleştirme Modu", + "visualizationMode-description": "Görselleştirme stilini seçin", + "waveThickness": "Dalga Kalınlığı" + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/i18n/uk-UA.json b/noctalia/plugins/fancy-audiovisualizer/i18n/uk-UA.json new file mode 100644 index 0000000..c142545 --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/i18n/uk-UA.json @@ -0,0 +1,29 @@ +{ + "settings": { + "barWidth": "Ширина бруска", + "bloomIntensity": "Інтенсивність світіння", + "customPrimaryColor": "Основний колір", + "customSecondaryColor": "Вторинний колір", + "description": "Налаштуйте вигляд аудіовізуалізатора", + "fadeWhenIdle": "Згасати в режимі очікування", + "fadeWhenIdle-description": "Затемнювати візуалізатор, коли не відтворюється звук.", + "innerDiameter": "Внутрішній діаметр", + "mode": { + "all": "Все", + "bars": "Бари", + "barsRings": "Бруси + Кільця", + "rings": "Кільця", + "wave": "Хвиля", + "waveRings": "Хвиля + Кільця" + }, + "ringOpacity": "Прозорість кільця", + "rotationSpeed": "Швидкість обертання", + "sensitivity": "Чутливість", + "title": "Налаштування візуалізатора", + "useCustomColors": "Використовувати власні кольори", + "useCustomColors-description": "Перевизначити кольори теми власними кольорами", + "visualizationMode": "Режим візуалізації", + "visualizationMode-description": "Виберіть стиль візуалізації", + "waveThickness": "Товщина хвилі" + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/i18n/vi.json b/noctalia/plugins/fancy-audiovisualizer/i18n/vi.json new file mode 100644 index 0000000..566081a --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/i18n/vi.json @@ -0,0 +1,29 @@ +{ + "settings": { + "barWidth": "Độ rộng thanh", + "bloomIntensity": "Cường độ hiệu ứng bloom", + "customPrimaryColor": "Màu chính tùy chỉnh", + "customSecondaryColor": "Màu phụ tùy chỉnh", + "description": "Cấu hình giao diện trình hiển thị âm thanh", + "fadeWhenIdle": "Làm mờ khi không hoạt động", + "fadeWhenIdle-description": "Làm mờ trình hiển thị khi không có âm thanh", + "innerDiameter": "Đường kính bên trong", + "mode": { + "all": "Tất cả", + "bars": "Thanh", + "barsRings": "Thanh + Vòng", + "rings": "Vòng", + "wave": "Sóng", + "waveRings": "Sóng + Vòng" + }, + "ringOpacity": "Độ mờ vòng", + "rotationSpeed": "Tốc độ xoay", + "sensitivity": "Độ nhạy", + "title": "Cài đặt trình hiển thị", + "useCustomColors": "Dùng màu tùy chỉnh", + "useCustomColors-description": "Ghi đè màu giao diện bằng màu tùy chỉnh", + "visualizationMode": "Chế độ hiển thị", + "visualizationMode-description": "Chọn kiểu hiển thị", + "waveThickness": "Độ dày sóng" + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/i18n/zh-CN.json b/noctalia/plugins/fancy-audiovisualizer/i18n/zh-CN.json new file mode 100644 index 0000000..84490dd --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/i18n/zh-CN.json @@ -0,0 +1,29 @@ +{ + "settings": { + "barWidth": "条宽", + "bloomIntensity": "光晕强度", + "customPrimaryColor": "原色", + "customSecondaryColor": "间色", + "description": "配置音频可视化效果外观", + "fadeWhenIdle": "空闲时淡出", + "fadeWhenIdle-description": "当没有音频播放时淡出可视化工具", + "innerDiameter": "内径", + "mode": { + "all": "全部", + "bars": "酒吧", + "barsRings": "单杠 + 吊环", + "rings": "戒指", + "wave": "波浪", + "waveRings": "波浪 + 环" + }, + "ringOpacity": "环透明度", + "rotationSpeed": "转速", + "sensitivity": "敏感性", + "title": "可视化设置", + "useCustomColors": "使用自定义颜色", + "useCustomColors-description": "使用自定义颜色覆盖主题颜色", + "visualizationMode": "可视化模式", + "visualizationMode-description": "选择可视化风格", + "waveThickness": "波浪厚度" + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/manifest.json b/noctalia/plugins/fancy-audiovisualizer/manifest.json new file mode 100644 index 0000000..7f12a52 --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/manifest.json @@ -0,0 +1,38 @@ +{ + "id": "fancy-audiovisualizer", + "name": "Fancy Audiovisualizer", + "version": "1.1.2", + "minNoctaliaVersion": "4.6.6", + "author": "Noctalia Team", + "official": true, + "license": "MIT", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "description": "Lemmy's fancy circular audio visualizer.", + "tags": [ + "Desktop", + "Audio" + ], + "entryPoints": { + "desktopWidget": "DesktopWidget.qml", + "desktopWidgetSettings": "DesktopWidgetSettings.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "defaultSettings": { + "sensitivity": 1.5, + "rotationSpeed": 0.5, + "barWidth": 0.6, + "ringOpacity": 0.8, + "bloomIntensity": 0.5, + "visualizationMode": 3, + "waveThickness": 1.0, + "innerDiameter": 0.7, + "fadeWhenIdle": false, + "useCustomColors": false, + "customPrimaryColor": "#6750A4", + "customSecondaryColor": "#625B71" + } + } +} diff --git a/noctalia/plugins/fancy-audiovisualizer/preview.png b/noctalia/plugins/fancy-audiovisualizer/preview.png new file mode 100644 index 0000000..0ef1556 Binary files /dev/null and b/noctalia/plugins/fancy-audiovisualizer/preview.png differ diff --git a/noctalia/plugins/fancy-audiovisualizer/shaders/visualizer.frag b/noctalia/plugins/fancy-audiovisualizer/shaders/visualizer.frag new file mode 100644 index 0000000..51eb9c5 --- /dev/null +++ b/noctalia/plugins/fancy-audiovisualizer/shaders/visualizer.frag @@ -0,0 +1,559 @@ +#version 450 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float time; + float itemWidth; + float itemHeight; + vec4 primaryColor; + vec4 secondaryColor; + float sensitivity; + float rotationSpeed; + float barWidth; + float ringOpacity; + float cornerRadius; + float bloomIntensity; + float visualizationMode; // 0=bars, 1=wave, 2=rings, 3=bars+rings, 4=wave+rings, 5=all + float waveThickness; + float innerDiameter; +} ubuf; + +// Mode helper functions +bool hasRings() { return ubuf.visualizationMode >= 2.0; } +bool hasBars() { return ubuf.visualizationMode == 0.0 || ubuf.visualizationMode == 3.0 || ubuf.visualizationMode >= 5.0; } +bool hasWave() { return ubuf.visualizationMode == 1.0 || ubuf.visualizationMode == 4.0 || ubuf.visualizationMode >= 5.0; } + +layout(binding = 1) uniform sampler2D source; + +#define TWOPI 6.28318530718 +#define PI 3.14159265359 +#define NBARS 32 + +// Sample audio amplitude at normalized position (0.0-1.0) +float getAudio(float pos) { + return texture(source, vec2(clamp(pos, 0.0, 1.0), 0.5)).r; +} + +// Smoothed audio sampling with interpolation +float smoothAudio(float pos) { + float idx = pos * float(NBARS - 1); + float frac = fract(idx); + float i0 = floor(idx) / float(NBARS - 1); + float i1 = ceil(idx) / float(NBARS - 1); + return mix(getAudio(i0), getAudio(i1), frac) * ubuf.sensitivity; +} + +// Frequency band helpers +float getBass() { return smoothAudio(0.05); } +float getMid() { return smoothAudio(0.3); } +float getHighMid() { return smoothAudio(0.6); } +float getTreble() { return smoothAudio(0.9); } + +// SDF for rounded rectangle +float roundedBoxSDF(vec2 center, vec2 size, float radius) { + vec2 q = abs(center) - size + radius; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius; +} + +// Compute polar wave visualization +vec4 computePolarWave(vec2 uv, float iTime, float bass, float mid, float highMid, float treble) { + float aspect = ubuf.itemWidth / ubuf.itemHeight; + vec2 centered = (uv - 0.5) * 2.0; + centered.x *= aspect; + + float theta = atan(centered.y, centered.x); + float d = length(centered); + float innerRadius = ubuf.innerDiameter / 2.0; + float baseRadius = 0.35; // Fixed reference for outer extent + + vec4 color = vec4(0.0); + + // RING SYSTEM + if (hasRings()) { + // Center Waves + if (d < innerRadius * 0.6) { + float wave = mid * 0.8; + float ripple = sin(d * 25.0 + wave * 15.0 - iTime * 2.0); + if (ripple > 0.7) { + float intensity = clamp(mid * 0.6, 0.0, 0.4); + vec4 waveColor = ubuf.secondaryColor * intensity * ubuf.ringOpacity; + color = max(color, waveColor); + } + } + + // Energy Ring + float energyRad = innerRadius * 0.65; + float energyThickness = 0.015 + clamp(highMid * 0.02, 0.0, 0.03); + if (d > energyRad - energyThickness && d < energyRad + energyThickness) { + float segmentAngle = theta * 8.0 + highMid * 3.0 + iTime; + if (mod(segmentAngle, 1.0) < 0.6) { + float alpha = clamp(highMid * 2.0, 0.3, 1.0) * ubuf.ringOpacity; + vec4 energyColor = mix(ubuf.primaryColor, ubuf.secondaryColor, 0.5) * alpha; + color = max(color, energyColor); + } + } + + // Particle Ring + float particleRad = innerRadius * 0.75; + if (d > particleRad - 0.02 && d < particleRad + 0.02) { + float particleAngle = theta + treble * 2.0 + iTime * 0.5; + float particleSpacing = TWOPI / 16.0; + if (mod(particleAngle, particleSpacing) < 0.15) { + float brightness = 0.5 + clamp(treble * 1.5, 0.0, 0.5); + vec4 particleColor = ubuf.secondaryColor * brightness * ubuf.ringOpacity; + color = max(color, particleColor); + } + } + + // Tech Grid Ring + float gridRad = innerRadius * 0.85; + if (d > gridRad - 0.012 && d < gridRad + 0.012) { + float gridAngle = theta + iTime * ubuf.rotationSpeed; + float gridDensity = 0.08 + clamp(mid * 0.05, 0.0, 0.1); + if (mod(gridAngle, 0.2) < gridDensity) { + vec4 gridColor = ubuf.primaryColor * 0.7 * ubuf.ringOpacity; + gridColor.rgb += vec3(0.1, 0.15, 0.2) * clamp(mid, 0.0, 0.8); + color = max(color, gridColor); + } + } + + // Accent Ring + float accentRad = innerRadius * 0.92; + float pulse = clamp(bass * 0.08, 0.0, 0.05); + if (d > accentRad - pulse - 0.008 && d < accentRad + pulse + 0.015) { + float colorShift = clamp(bass * 0.5, 0.0, 1.0); + vec4 ringColor = mix(ubuf.secondaryColor * 0.7, ubuf.primaryColor, colorShift); + ringColor.a = ubuf.ringOpacity; + ringColor.rgb *= 1.0 + bass * 0.3; + color = max(color, ringColor); + } + + // Outer Ring + float outerRad = innerRadius + bass * 0.05; + if (d > outerRad - 0.008 && d < outerRad + 0.008) { + vec4 outerColor = ubuf.primaryColor * ubuf.ringOpacity; + outerColor.rgb += vec3(0.2, 0.3, 0.4) * clamp(treble * 0.5, 0.0, 0.3); + outerColor.rgb *= 1.0 + bass * 0.4; + color = max(color, outerColor); + } + } + + // POLAR WAVE - filled shape with mirrored bands (64 visual bands from 32 samples) + if (hasWave()) { + float adjustedTheta = theta + PI + iTime * ubuf.rotationSpeed * 0.2; + + // Map angle to 0-1 range around the full circle + float normalizedAngle = mod(adjustedTheta, TWOPI) / TWOPI; + + // Mirror: first half (0-0.5) maps to bands 0->31, second half (0.5-1) maps back 31->0 + float mirroredPos = normalizedAngle < 0.5 ? normalizedAngle * 2.0 : (1.0 - normalizedAngle) * 2.0; + + // Catmull-Rom spline interpolation for smooth curve through mirrored bands + float bandPos = mirroredPos * float(NBARS - 1); + float fband1 = floor(bandPos); + float fband0 = max(fband1 - 1.0, 0.0); + float fband2 = min(fband1 + 1.0, float(NBARS - 1)); + float fband3 = min(fband1 + 2.0, float(NBARS - 1)); + + float t = fract(bandPos); + + // Sample the 4 control points + float p0 = getAudio(fband0 / float(NBARS - 1)) * ubuf.sensitivity; + float p1 = getAudio(fband1 / float(NBARS - 1)) * ubuf.sensitivity; + float p2 = getAudio(fband2 / float(NBARS - 1)) * ubuf.sensitivity; + float p3 = getAudio(fband3 / float(NBARS - 1)) * ubuf.sensitivity; + + // Catmull-Rom spline interpolation + float t2 = t * t; + float t3 = t2 * t; + float smoothedAudio = 0.5 * ( + (2.0 * p1) + + (-p0 + p2) * t + + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3 + ); + smoothedAudio = max(smoothedAudio, 0.0); + + // Calculate wave radius at this angle + float waveDisplacement = smoothedAudio * 0.5; + float waveRadius = baseRadius + waveDisplacement; // Fixed outer extent + + // Fill the entire area from inner to wave edge + if (d >= innerRadius && d <= waveRadius) { + float fillFactor = (d - innerRadius) / max(waveRadius - innerRadius, 0.001); + + // Gradient from primary at base to accent at edge + vec3 fillColor = mix(ubuf.primaryColor.rgb * 0.8, ubuf.secondaryColor.rgb, fillFactor); + + // Boost brightness with bass + fillColor *= 1.0 + bass * 0.3; + + // Alpha: stronger near the edge, fades toward center + float fillAlpha = mix(0.4, 1.0, fillFactor) * ubuf.waveThickness; + fillAlpha = clamp(fillAlpha, 0.0, 1.0); + + vec4 fill = vec4(fillColor, fillAlpha); + color = max(color, fill); + } + + // Bright edge line at the wave boundary + float edgeThickness = ubuf.waveThickness * 0.025; + float distToEdge = abs(d - waveRadius); + if (distToEdge < edgeThickness) { + float edgeFactor = 1.0 - smoothstep(0.0, edgeThickness, distToEdge); + vec3 edgeColor = ubuf.secondaryColor.rgb * (1.2 + smoothedAudio * 0.5); + + // Add highlight at peaks + if (smoothedAudio > 0.5) { + edgeColor += vec3(0.3, 0.4, 0.5) * (smoothedAudio - 0.5); + } + + vec4 edge = vec4(edgeColor, edgeFactor); + color = max(color, edge); + } + } + + return color; +} + +// Compute visualization color at given UV coordinates +// Returns vec4 with RGB color and alpha +vec4 computeVisualization(vec2 uv, float iTime, float bass, float mid, float highMid, float treble) { + float aspect = ubuf.itemWidth / ubuf.itemHeight; + vec2 centered = (uv - 0.5) * 2.0; + centered.x *= aspect; + + float theta = atan(centered.y, centered.x); + float d = length(centered); + float innerRadius = ubuf.innerDiameter / 2.0; + float baseRadius = 0.35; // Fixed reference for outer extent + + vec4 color = vec4(0.0); + + // RING SYSTEM + if (hasRings()) { + // Center Waves + if (d < innerRadius * 0.6) { + float wave = mid * 0.8; + float ripple = sin(d * 25.0 + wave * 15.0 - iTime * 2.0); + if (ripple > 0.7) { + float intensity = clamp(mid * 0.6, 0.0, 0.4); + vec4 waveColor = ubuf.secondaryColor * intensity * ubuf.ringOpacity; + color = max(color, waveColor); + } + } + + // Energy Ring + float energyRad = innerRadius * 0.65; + float energyThickness = 0.015 + clamp(highMid * 0.02, 0.0, 0.03); + if (d > energyRad - energyThickness && d < energyRad + energyThickness) { + float segmentAngle = theta * 8.0 + highMid * 3.0 + iTime; + if (mod(segmentAngle, 1.0) < 0.6) { + float alpha = clamp(highMid * 2.0, 0.3, 1.0) * ubuf.ringOpacity; + vec4 energyColor = mix(ubuf.primaryColor, ubuf.secondaryColor, 0.5) * alpha; + color = max(color, energyColor); + } + } + + // Particle Ring + float particleRad = innerRadius * 0.75; + if (d > particleRad - 0.02 && d < particleRad + 0.02) { + float particleAngle = theta + treble * 2.0 + iTime * 0.5; + float particleSpacing = TWOPI / 16.0; + if (mod(particleAngle, particleSpacing) < 0.15) { + float brightness = 0.5 + clamp(treble * 1.5, 0.0, 0.5); + vec4 particleColor = ubuf.secondaryColor * brightness * ubuf.ringOpacity; + color = max(color, particleColor); + } + } + + // Tech Grid Ring + float gridRad = innerRadius * 0.85; + if (d > gridRad - 0.012 && d < gridRad + 0.012) { + float gridAngle = theta + iTime * ubuf.rotationSpeed; + float gridDensity = 0.08 + clamp(mid * 0.05, 0.0, 0.1); + if (mod(gridAngle, 0.2) < gridDensity) { + vec4 gridColor = ubuf.primaryColor * 0.7 * ubuf.ringOpacity; + gridColor.rgb += vec3(0.1, 0.15, 0.2) * clamp(mid, 0.0, 0.8); + color = max(color, gridColor); + } + } + + // Accent Ring + float accentRad = innerRadius * 0.92; + float pulse = clamp(bass * 0.08, 0.0, 0.05); + if (d > accentRad - pulse - 0.008 && d < accentRad + pulse + 0.015) { + float colorShift = clamp(bass * 0.5, 0.0, 1.0); + vec4 ringColor = mix(ubuf.secondaryColor * 0.7, ubuf.primaryColor, colorShift); + ringColor.a = ubuf.ringOpacity; + ringColor.rgb *= 1.0 + bass * 0.3; + color = max(color, ringColor); + } + + // Outer Ring + float outerRad = innerRadius + bass * 0.05; + if (d > outerRad - 0.008 && d < outerRad + 0.008) { + vec4 outerColor = ubuf.primaryColor * ubuf.ringOpacity; + outerColor.rgb += vec3(0.2, 0.3, 0.4) * clamp(treble * 0.5, 0.0, 0.3); + outerColor.rgb *= 1.0 + bass * 0.4; + color = max(color, outerColor); + } + } + + // CIRCULAR AUDIO BARS (64 bars, mirrored from 32 audio samples) + if (hasBars() && d > innerRadius) { + // Double the visual bars by using NBARS * 2 + float section = TWOPI / float(NBARS * 2); + float center = section / 2.0; + + float adjustedTheta = theta + PI + iTime * ubuf.rotationSpeed * 0.2; + float m = mod(adjustedTheta, section); + float ym = d * sin(center - m); + + float barW = ubuf.barWidth * 0.015; + if (abs(ym) < barW) { + // Calculate position in the circle (0.0 to 1.0) + float circlePos = mod(adjustedTheta, TWOPI) / TWOPI; + // Mirror: first half (0-0.5) maps to 0-1, second half (0.5-1) maps back 1-0 + float mirroredPos = circlePos < 0.5 ? circlePos * 2.0 : (1.0 - circlePos) * 2.0; + float v = smoothAudio(mirroredPos); + + float wave = sin(theta * 3.0 + mid * 5.0) * clamp(mid * 0.03, 0.0, 0.05); + v += wave; + v = max(v, 0.0); + + float barStart = innerRadius; + float barEnd = baseRadius + v * 0.5; // Fixed outer extent + + if (d >= barStart && d <= barEnd) { + float heightFactor = (d - barStart) / max(barEnd - barStart, 0.001); + + vec3 bottomColor = ubuf.primaryColor.rgb * 0.6; + vec3 middleColor = ubuf.primaryColor.rgb; + vec3 topColor = ubuf.secondaryColor.rgb; + + vec3 barColor; + if (heightFactor < 0.5) { + barColor = mix(bottomColor, middleColor, heightFactor * 2.0); + } else { + barColor = mix(middleColor, topColor, (heightFactor - 0.5) * 2.0); + } + + barColor *= 1.0 + bass * 0.4; + + if (heightFactor > 0.85) { + barColor += vec3(0.3, 0.4, 0.5) * clamp(treble * 0.8, 0.0, 0.5); + } + + float edgeFactor = 1.0 - smoothstep(barW * 0.7, barW, abs(ym)); + vec4 finalBarColor = vec4(barColor, edgeFactor); + color = max(color, finalBarColor); + } + } + } + + return color; +} + +void main() { + // ============================================ + // DYNAMIC CONTENT SCALING + // ============================================ + // Calculate max possible extent from current settings to guarantee + // nothing ever reaches the widget border. + // + // Max visualization radius in centered space [-1,1]: + // Bars/Wave: baseRadius(0.35) + sensitivity * 0.5 + // Rings: innerDiameter/2 + sensitivity * 0.05 (always smaller) + // + // Bloom reach: exp(-dist * minDecayRate / bloomIntensity) decays to + // < 1/255 at dist ≈ bloomIntensity * 1.0 (from solving exp decay + // with worst-case amplitude * multiplier chain) + // + // contentScale maps the widget edge to ±contentScale in centered space, + // so setting it to maxTotalRadius ensures everything fits. + + float maxContentRadius = 0.35 + ubuf.sensitivity * 0.5; + float maxBloomReach = ubuf.bloomIntensity * 1.0; + float maxTotalRadius = maxContentRadius + maxBloomReach; + float contentScale = max(maxTotalRadius * 1.05, 1.0); // 5% safety margin + + vec2 uv = (qt_TexCoord0 - 0.5) * contentScale + 0.5; + + // Convert linear time (0-3600) to smooth oscillation for seamless looping + // sin() ensures perfect continuity when QML wraps from 3600 back to 0 + float iTime = sin(ubuf.time * TWOPI / 3600.0) * 1800.0 + 1800.0; + + // Frequency analysis + float bass = getBass(); + float mid = getMid(); + float highMid = getHighMid(); + float treble = getTreble(); + + // Get base visualization color based on mode + // Mode 0: bars only, Mode 1: wave only, Mode 2: rings only + // Mode 3: bars+rings, Mode 4: wave+rings, Mode 5: all + vec4 color; + if (hasWave() && !hasBars()) { + // Wave only or wave+rings (modes 1, 4) + color = computePolarWave(uv, iTime, bass, mid, highMid, treble); + } else if (hasBars() && !hasWave()) { + // Bars only or bars+rings (modes 0, 3) + color = computeVisualization(uv, iTime, bass, mid, highMid, treble); + } else if (hasWave() && hasBars()) { + // All mode (5) - combine both + vec4 barsColor = computeVisualization(uv, iTime, bass, mid, highMid, treble); + vec4 waveColor = computePolarWave(uv, iTime, bass, mid, highMid, treble); + color = max(barsColor, waveColor); + } else { + // Rings only (mode 2) - still need to call one of them for ring rendering + color = computeVisualization(uv, iTime, bass, mid, highMid, treble); + } + + // ============================================ + // BLOOM EFFECT - Glow based on distance to geometry + // ============================================ + + if (ubuf.bloomIntensity > 0.01 && color.a < 0.01) { + // Only apply bloom where there's no geometry (empty space) + // Find distance to nearest bright element + + float aspect = ubuf.itemWidth / ubuf.itemHeight; + vec2 centered = (uv - 0.5) * 2.0; + centered.x *= aspect; + float d = length(centered); + float theta = atan(centered.y, centered.x); + + float innerRadius = ubuf.innerDiameter / 2.0; + float baseRadius = 0.35; // Fixed reference for outer extent + float glowAmount = 0.0; + vec3 glowColor = vec3(0.0); + + // Glow from rings (if enabled) + if (hasRings()) { + // Outer ring glow + float outerRad = innerRadius + bass * 0.05; + float ringDist = abs(d - outerRad); + float ringGlow = exp(-ringDist * 8.0 / ubuf.bloomIntensity) * (1.0 + bass * 0.5); + glowColor += ubuf.primaryColor.rgb * ringGlow; + glowAmount = max(glowAmount, ringGlow); + + // Accent ring glow + float accentRad = innerRadius * 0.92; + float accentDist = abs(d - accentRad); + float accentGlow = exp(-accentDist * 10.0 / ubuf.bloomIntensity) * (0.7 + bass * 0.3); + glowColor += mix(ubuf.secondaryColor.rgb, ubuf.primaryColor.rgb, 0.5) * accentGlow; + glowAmount = max(glowAmount, accentGlow); + } + + // Glow from visualization (bars or polar wave) + if ((hasBars() || hasWave()) && d > innerRadius * 0.8) { + float adjustedTheta = theta + PI + iTime * ubuf.rotationSpeed * 0.2; + float circlePos = mod(adjustedTheta, TWOPI) / TWOPI; + float mirroredPos = circlePos < 0.5 ? circlePos * 2.0 : (1.0 - circlePos) * 2.0; + float v = smoothAudio(mirroredPos); + + if (hasWave()) { + // Polar wave bloom - Catmull-Rom spline with mirroring matching main render + float mirroredPos = circlePos < 0.5 ? circlePos * 2.0 : (1.0 - circlePos) * 2.0; + float bandPos = mirroredPos * float(NBARS - 1); + float fband1 = floor(bandPos); + float fband0 = max(fband1 - 1.0, 0.0); + float fband2 = min(fband1 + 1.0, float(NBARS - 1)); + float fband3 = min(fband1 + 2.0, float(NBARS - 1)); + + float t = fract(bandPos); + float p0 = getAudio(fband0 / float(NBARS - 1)) * ubuf.sensitivity; + float p1 = getAudio(fband1 / float(NBARS - 1)) * ubuf.sensitivity; + float p2 = getAudio(fband2 / float(NBARS - 1)) * ubuf.sensitivity; + float p3 = getAudio(fband3 / float(NBARS - 1)) * ubuf.sensitivity; + + float t2 = t * t; + float t3 = t2 * t; + float smoothedAudio = 0.5 * ( + (2.0 * p1) + + (-p0 + p2) * t + + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3 + ); + smoothedAudio = max(smoothedAudio, 0.0); + + float waveRadius = baseRadius + smoothedAudio * 0.5; + + // Glow from the filled area and edge + float distToWave = abs(d - waveRadius); + float waveGlow = exp(-distToWave * 8.0 / ubuf.bloomIntensity) * smoothedAudio * 2.5; + + vec3 waveGlowColor = mix(ubuf.primaryColor.rgb, ubuf.secondaryColor.rgb, smoothedAudio); + glowColor += waveGlowColor * waveGlow; + glowAmount = max(glowAmount, waveGlow); + } + + if (hasBars()) { + // Bars bloom + float section = TWOPI / float(NBARS * 2); + float m = mod(adjustedTheta, section); + float center = section / 2.0; + + float barAngleDist = min(abs(m - center), section - abs(m - center)); + float barEnd = baseRadius + v * 0.5; // Fixed outer extent + + float radialDist = 0.0; + if (d < innerRadius) { + radialDist = innerRadius - d; + } else if (d > barEnd) { + radialDist = d - barEnd; + } + + float totalDist = length(vec2(barAngleDist * d, radialDist)); + float barGlow = exp(-totalDist * 15.0 / ubuf.bloomIntensity) * v * 2.0; + + float heightFactor = clamp((d - innerRadius) / max(barEnd - innerRadius, 0.001), 0.0, 1.0); + vec3 barGlowColor = mix(ubuf.primaryColor.rgb, ubuf.secondaryColor.rgb, heightFactor); + + glowColor += barGlowColor * barGlow; + glowAmount = max(glowAmount, barGlow); + } + } + + // Apply bloom + float bloomMult = ubuf.bloomIntensity * (1.0 + bass * 0.5); + color.rgb = glowColor * bloomMult; + color.a = glowAmount * bloomMult * 0.6; + + // Clamp to reasonable values + color.rgb = min(color.rgb, vec3(1.5)); + color.a = min(color.a, 0.8); + } + + // ============================================ + // EDGE FADE - radial falloff that only affects the bloom zone + // ============================================ + // Fade starts just past where main content ends (maxContentRadius) + // and reaches zero at the widget edge (contentScale) in centered space. + // This catches bloom tails without affecting the main visualization. + + vec2 fromCenter = (qt_TexCoord0 - 0.5) * 2.0; // -1 to 1 in widget space + float edgeProximity = max(abs(fromCenter.x), abs(fromCenter.y)); // box distance + float fadeStart = maxContentRadius / contentScale; // where content ends in widget space + float edgeFade = 1.0 - smoothstep(fadeStart, 1.0, edgeProximity); + color *= edgeFade; + + // ============================================ + // CORNER MASKING + // ============================================ + + vec2 pixelPos = qt_TexCoord0 * vec2(ubuf.itemWidth, ubuf.itemHeight); + vec2 centerPos = pixelPos - vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + vec2 halfSize = vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + float dist = roundedBoxSDF(centerPos, halfSize, ubuf.cornerRadius); + float cornerMask = 1.0 - smoothstep(-1.0, 0.0, dist); + + // Final output with premultiplied alpha + float finalAlpha = color.a * ubuf.qt_Opacity * cornerMask; + fragColor = vec4(color.rgb * finalAlpha, finalAlpha); +} diff --git a/noctalia/plugins/fancy-audiovisualizer/shaders/visualizer.frag.qsb b/noctalia/plugins/fancy-audiovisualizer/shaders/visualizer.frag.qsb new file mode 100644 index 0000000..728d8c0 Binary files /dev/null and b/noctalia/plugins/fancy-audiovisualizer/shaders/visualizer.frag.qsb differ diff --git a/noctalia/plugins/network-manager-vpn/BarWidget.qml b/noctalia/plugins/network-manager-vpn/BarWidget.qml new file mode 100644 index 0000000..aef8478 --- /dev/null +++ b/noctalia/plugins/network-manager-vpn/BarWidget.qml @@ -0,0 +1,80 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Modules.Bar.Extras +import qs.Services.UI +import qs.Widgets + +Item { + id: root + + property var pluginApi: null + property ShellScreen screen + property string widgetId: "" + property string section: "" + property int sectionWidgetIndex: -1 + property int sectionWidgetsCount: 0 + readonly property var pluginSettings: pluginApi?.pluginSettings ?? ({}) + readonly property var main: pluginApi?.mainInstance ?? ({}) + + readonly property string screenName: screen?.name ?? "" + readonly property string barPosition: Settings.getBarPositionForScreen(screenName) + readonly property bool isBarVertical: barPosition === "left" || barPosition === "right" + readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName) + readonly property real barFontSize: Style.getBarFontSizeForScreen(screenName) + readonly property string displayMode: root.pluginSettings.displayMode ?? "onhover" + readonly property string connectedColor: root.pluginSettings.connectedColor + readonly property string disconnectedColor: root.pluginSettings.disconnectedColor + readonly property var vpnList: root.main.vpnList ?? [] + readonly property real connectedCount: root.main.connectedCount ?? 0 + readonly property bool isLoading: root.main.isLoading ?? false + + implicitWidth: pill.width + implicitHeight: pill.height + Component.onCompleted: { + Logger.i("NetworkManagerVPN", "Bar widget loaded"); + } + + NPopupContextMenu { + id: contextMenu + + model: [{ + "label": pluginApi?.tr("settings.pluginSettings"), + "action": "plugin-settings", + "icon": "settings" + }] + onTriggered: (action) => { + contextMenu.close(); + PanelService.closeContextMenu(screen); + if (action === "plugin-settings") + BarService.openPluginSettings(screen, pluginApi.manifest); + + } + } + + BarPill { + id: pill + + screen: root.screen + oppositeDirection: BarService.getPillDirection(root) + autoHide: false + text: root.connectedCount > 0 ? pluginApi?.tr("common.connected") : pluginApi?.tr("common.disconnected") + icon: root.isLoading ? "reload" : root.connectedCount > 0 ? "shield-lock" : "shield" + onClicked: { + if (pluginApi) + pluginApi.openPanel(root.screen, root); + + } + onRightClicked: { + if (pluginApi) + pluginApi.closePanel(root.screen); + PanelService.showContextMenu(contextMenu, pill, screen); + } + customIconColor: Color.resolveColorKeyOptional(root.connectedCount > 0 ? root.connectedColor : root.disconnectedColor) + customTextColor: Color.resolveColorKeyOptional(root.connectedCount > 0 ? root.connectedColor : root.disconnectedColor) + forceOpen: root.displayMode === "alwaysShow" + forceClose: root.displayMode === "alwaysHide" + } + +} diff --git a/noctalia/plugins/network-manager-vpn/ControlCenterWidget.qml b/noctalia/plugins/network-manager-vpn/ControlCenterWidget.qml new file mode 100644 index 0000000..ac20ae2 --- /dev/null +++ b/noctalia/plugins/network-manager-vpn/ControlCenterWidget.qml @@ -0,0 +1,29 @@ +import QtQuick +import Quickshell +import qs.Widgets +import qs.Commons + +NIconButtonHot { + property ShellScreen screen + property var pluginApi: null + + readonly property var main: pluginApi?.mainInstance ?? ({}) + readonly property real connectedCount: main.connectedCount ?? 0 + readonly property bool isLoading: main.isLoading ?? false + readonly property var pluginSettings: pluginApi?.pluginSettings ?? ({}) + readonly property string connectedColor: pluginSettings.connectedColor + readonly property string disconnectedColor: pluginSettings.disconnectedColor + + icon: isLoading ? "reload" : connectedCount > 0 ? "shield-lock" : "shield" + tooltipText: connectedCount > 0 + ? pluginApi?.tr("common.connected") + : pluginApi?.tr("common.disconnected") + + colorFg: { + const key = connectedCount > 0 ? connectedColor : disconnectedColor; + if (!key || key === "none") return Color.mPrimary; + return Color.resolveColorKeyOptional(key) ?? Color.mPrimary; + } + + onClicked: pluginApi?.togglePanel(screen, this) +} \ No newline at end of file diff --git a/noctalia/plugins/network-manager-vpn/Main.qml b/noctalia/plugins/network-manager-vpn/Main.qml new file mode 100644 index 0000000..0f6dd1c --- /dev/null +++ b/noctalia/plugins/network-manager-vpn/Main.qml @@ -0,0 +1,231 @@ +import QtQuick +import Quickshell.Io +import qs.Commons +import qs.Services.UI + +QtObject { + id: root + + readonly property var pluginSettings: pluginApi?.pluginSettings ?? ({}) + + readonly property var toast: root.pluginSettings?.disableToastNotifications ? null : ToastService + + property var pluginApi: null + + property var vpnList: [] + property real connectedCount: 0 + readonly property bool isLoading: Object.keys(root._pending).length > 0 + + property var _pending: ({}) + + // Needed only to detect disconnection not initiated by the user + property var _pollTimer: Timer { + interval: 5000 + running: true + repeat: true + onTriggered: root.refresh() + } + + property var _lines: [] + + property var _listProc: Process { + command: ["nmcli", "-t", "-f", "NAME,TYPE,STATE,UUID", "connection", "show"] + running: true + + stdout: SplitParser { + onRead: (line) => { + if (line.trim() !== "") + root._lines.push(line) + } + } + + onExited: (exitCode) => { + if (exitCode === 0) { + const parsed = [] + const newPending = Object.assign({}, root._pending) + for (const line of root._lines) { + const parts = line.split(":") + if (parts.length >= 4) { + const name = parts[0] + const type = parts[1] + const state = parts[2] + const uuid = parts[3] + if (type === "vpn" || type === "wireguard") { + if (newPending[uuid]) { + const wasConnecting = newPending[uuid] === "connect" + if (wasConnecting && state === "activated") + delete newPending[uuid] + else if (!wasConnecting && state !== "activated") + delete newPending[uuid] + } + parsed.push({ + name, + type, + connected: state === "activated", + isLoading: !!newPending[uuid], + uuid + }) + } + } + } + root._pending = newPending + root.vpnList = parsed + root.connectedCount = parsed.filter(v => v.connected).length + } + root._lines = [] + } + } + + property var _connectProc: Process { + property string targetName: "" + property string targetUuid: "" + command: ["nmcli", "connection", "up", "uuid", targetUuid] + onExited: (exitCode) => { + if (exitCode === 0) + toast?.showNotice(t("toast.connectedTo", { name: targetName })) + else { + root.stopLoading(targetUuid) + toast?.showError(t("toast.connectionError", { name: targetName })) + } + root.refresh() + } + } + + property var _disconnectProc: Process { + property string targetName: "" + property string targetUuid: "" + command: ["nmcli", "connection", "down", "uuid", targetUuid] + onExited: (exitCode) => { + if (exitCode === 0) + toast?.showNotice(t("toast.disconnectedFrom", { name: targetName })) + else { + root.stopLoading(targetUuid) + toast?.showError(t("toast.disconnectionError", { name: targetName })) + } + root.refresh() + } + } + + property var _addProc: Process { + property string targetType: "" + + command: ["nm-connection-editor", "--create", "--type", targetType] + onExited: (exitCode) => { + root.refresh(); + } + } + + property var _editProc: Process { + property string targetName: "" + property string targetUuid: "" + + command: ["nm-connection-editor", "--edit", targetUuid] + onExited: (exitCode) => { + root.refresh(); + } + } + + property var _removeProc: Process { + property string targetName: "" + property string targetUuid: "" + + command: ["nmcli", "connection", "delete", "uuid", targetUuid] + onExited: (exitCode) => { + if (exitCode === 0) + toast?.showNotice(t("toast.vpnRemoved", { "name": targetName })); + else + toast?.showError(t("toast.vpnRemoveError", { "name": targetName })); + root.refresh(); + } + } + + function t(key: string, data) { + if (!pluginApi) + return null; + + return pluginApi.tr(key, data); + } + + function refresh() { + _listProc.running = true + } + + function stopLoading(uuid) { + if (uuid && _pending[uuid]) { + const p = Object.assign({}, _pending) + delete p[uuid] + _pending = p + } + + vpnList = vpnList.map(v => { + if (v.uuid !== uuid) { + return v + } + + return Object.assign({}, v, { isLoading: false }) + }) + } + + function connectTo(uuid) { + const p = Object.assign({}, _pending) + p[uuid] = "connect" + _pending = p + let name = "" + vpnList = vpnList.map(v => { + if (v.uuid !== uuid) { + return v + } + + name = v.name + return Object.assign({}, v, { isLoading: true }) + }) + _connectProc.targetName = name + _connectProc.targetUuid = uuid + _connectProc.running = true + } + + function disconnectFrom(uuid) { + const p = Object.assign({}, _pending) + p[uuid] = "disconnect" + _pending = p + let name = "" + vpnList = vpnList.map(v => { + if (v.uuid !== uuid) { + return v + } + + name = v.name + return Object.assign({}, v, { isLoading: true }) + }) + _disconnectProc.targetName = name + _disconnectProc.targetUuid = uuid + _disconnectProc.running = true + } + + function addConnection(type) { + _addProc.targetType = type || "vpn"; + _addProc.running = true; + } + + function editConnection(uuid) { + const vpn = vpnList.find((v) => { + return v.uuid === uuid; + }); + _editProc.targetName = vpn ? vpn.name : uuid; + _editProc.targetUuid = uuid; + _editProc.running = true; + } + + function removeConnection(uuid) { + const vpn = vpnList.find((v) => { + return v.uuid === uuid; + }); + _removeProc.targetName = vpn ? vpn.name : uuid; + _removeProc.targetUuid = uuid; + _removeProc.running = true; + } + + Component.onCompleted: { + Logger.i("NetworkManagerVPN", "Started") + } +} diff --git a/noctalia/plugins/network-manager-vpn/Panel.qml b/noctalia/plugins/network-manager-vpn/Panel.qml new file mode 100644 index 0000000..6b273f1 --- /dev/null +++ b/noctalia/plugins/network-manager-vpn/Panel.qml @@ -0,0 +1,236 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Widgets +import qs.Services.UI + +Item { + id: root + + property var pluginApi: null + property ShellScreen screen + readonly property var geometryPlaceholder: panelContainer + readonly property bool allowAttach: true + readonly property var main: pluginApi?.mainInstance ?? null + readonly property var vpnList: main?.vpnList ?? [] + readonly property var activeList: vpnList.filter(v => v.connected || v.isLoading) + readonly property var inactiveList: vpnList.filter(v => !v.connected && !v.isLoading) + property real contentPreferredWidth: Math.round(500 * Style.uiScaleRatio) + property real contentPreferredHeight: Math.min(500, mainColumn.implicitHeight + Style.marginL * 2) + + Component.onCompleted: { + if (main) main.refresh() + } + + Rectangle { + id: panelContainer + anchors.fill: parent + color: "transparent" + + ColumnLayout { + id: mainColumn + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginM + + // HEADER + NBox { + Layout.fillWidth: true + Layout.preferredHeight: Math.round(header.implicitHeight + Style.marginM * 2 + 1) + + ColumnLayout { + id: header + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + RowLayout { + NIcon { + icon: "shield" + pointSize: Style.fontSizeXXL + color: Color.mPrimary + } + + NLabel { + label: pluginApi?.tr("common.vpn") + } + + NBox { + Layout.fillWidth: true + } + + NIconButton { + icon: "refresh" + tooltipText: pluginApi?.tr("common.refresh") + baseSize: Style.baseWidgetSize * 0.8 + enabled: true + onClicked: { + if (main) { + main.refresh() + } + } + } + + NIconButton { + icon: "close" + tooltipText: pluginApi?.tr("common.close") + baseSize: Style.baseWidgetSize * 0.8 + onClicked: pluginApi.closePanel(pluginApi.panelOpenScreen) + } + } + } + } + + NScrollView { + id: scrollView + Layout.fillWidth: true + Layout.fillHeight: true + horizontalPolicy: ScrollBar.AlwaysOff + verticalPolicy: ScrollBar.AsNeeded + reserveScrollbarSpace: false + + ColumnLayout { + anchors.fill: parent + spacing: Style.marginM + + // CONNECTED + NBox { + Layout.fillWidth: true + Layout.preferredHeight: Math.round(networksListActive.implicitHeight + Style.marginXL) + visible: activeList.length > 0 + + ColumnLayout { + id: networksListActive + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: Style.marginS + spacing: Style.marginS + + NLabel { + label: pluginApi?.tr("common.connected") + Layout.fillWidth: true + } + } + + Repeater { + model: activeList + + VpnListItem { + name: modelData.name + type: modelData.type + isConnected: true + isLoading: modelData.isLoading + onButtonClicked: { + if (!main) return + main.disconnectFrom(modelData.uuid) + } + } + } + } + } + + // DISCONNECTED + NBox { + Layout.fillWidth: true + Layout.preferredHeight: Math.round(networksListInactive.implicitHeight + Style.marginXL) + visible: inactiveList.length > 0 + + ColumnLayout { + id: networksListInactive + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: Style.marginS + spacing: Style.marginS + + NLabel { + label: pluginApi?.tr("common.disconnected") + Layout.fillWidth: true + } + } + + Repeater { + model: inactiveList + + VpnListItem { + name: modelData.name + type: modelData.type + isConnected: false + isLoading: modelData.isLoading + onButtonClicked: { + if (!main) return + main.connectTo(modelData.uuid) + } + } + } + } + } + + // EMPTY + NBox { + id: emptyBox + visible: vpnList.length < 1 + Layout.fillWidth: true + Layout.preferredHeight: Math.round(emptyColumn.implicitHeight + Style.marginM * 2 + 1) + + ColumnLayout { + id: emptyColumn + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginL + + Item { + Layout.fillHeight: true + } + + NIcon { + icon: "search" + pointSize: 48 + color: Color.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + NText { + text: pluginApi?.tr("panel.emptyTitle") + pointSize: Style.fontSizeL + color: Color.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + NText { + text: pluginApi?.tr("panel.emptyDescription") + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + NButton { + text: pluginApi?.tr("common.refresh") + icon: "refresh" + Layout.alignment: Qt.AlignHCenter + onClicked: { + if (main) { + main.refresh() + } + } + } + + Item { + Layout.fillHeight: true + } + } + } + } + } + } + } +} diff --git a/noctalia/plugins/network-manager-vpn/README.md b/noctalia/plugins/network-manager-vpn/README.md new file mode 100644 index 0000000..66f748e --- /dev/null +++ b/noctalia/plugins/network-manager-vpn/README.md @@ -0,0 +1,21 @@ +# Network Manager VPN + +![Preview](preview.png) + +Plugin to connect to your VPN connections from the bar and Control Center. Supports OpenVPN and WireGuard connections managed by NetworkManager. Editing and creation are achieved using +nm-connection-editor + +## Usage + +Install the plugin and add it to your bar: + +- **Bar widget** — A shield icon that shows if there is an active connection. Click it to open the VPN panel. +- **VPN panel** — Lists all configured VPN connections. + +The panel refreshes automatically every 5 seconds. You can also trigger a manual refresh with the refresh button. + +## Requirements + +- **Noctalia Shell** ≥ 3.6.0 +- **NetworkManager** with `nmcli` +- **nm-connection-editor** to edit and create connections \ No newline at end of file diff --git a/noctalia/plugins/network-manager-vpn/Settings.qml b/noctalia/plugins/network-manager-vpn/Settings.qml new file mode 100644 index 0000000..ede489f --- /dev/null +++ b/noctalia/plugins/network-manager-vpn/Settings.qml @@ -0,0 +1,256 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Widgets + +ColumnLayout { + id: root + + property var pluginApi: null + readonly property var pluginSettings: pluginApi?.pluginSettings ?? pluginApi?.manifest?.metadata?.defaultSettings ?? ({}) + + readonly property var main: pluginApi?.mainInstance ?? null + readonly property var vpnList: main ? main.vpnList : [] + // Local state + property string editDisplayMode: root.pluginSettings.displayMode ?? "" + property string editConnectedColor: root.pluginSettings.connectedColor ?? "" + property string editDisconnectedColor: root.pluginSettings.disconnectedColor ?? "" + property bool disableToastNotifications: root.pluginSettings?.disableToastNotifications ?? false + + property string pendingDeleteUuid: "" + property string pendingDeleteName: "" + + readonly property var displayModeModel: [{ + "key": "onhover", + "name": I18n.tr("display-modes.on-hover") + }, { + "key": "alwaysShow", + "name": I18n.tr("display-modes.always-show") + }, { + "key": "alwaysHide", + "name": I18n.tr("display-modes.always-hide") + }] + + function saveSettings() { + pluginApi.pluginSettings.displayMode = root.editDisplayMode; + pluginApi.pluginSettings.connectedColor = root.editConnectedColor; + pluginApi.pluginSettings.disconnectedColor = root.editDisconnectedColor; + pluginApi.pluginSettings.disableToastNotifications = root.disableToastNotifications; + pluginApi.saveSettings(); + + Logger.i("NetworkManagerVpn", "Settings saved successfully"); + } + + NComboBox { + label: I18n.tr("common.display-mode") + description: I18n.tr("bar.volume.display-mode-description") + minimumWidth: 200 + model: root.displayModeModel + currentKey: root.editDisplayMode + onSelected: (key) => { + root.editDisplayMode = key; + } + } + + NColorChoice { + label: pluginApi?.tr("settings.connectedColor") + description: pluginApi?.tr("settings.connectedColorDescription") + currentKey: root.editConnectedColor + onSelected: (key) => { + root.editConnectedColor = key; + } + } + + NColorChoice { + label: pluginApi?.tr("settings.disconnectedColor") + description: pluginApi?.tr("settings.disconnectedColorDescription") + currentKey: root.editDisconnectedColor + onSelected: (key) => { + root.editDisconnectedColor = key; + } + } + + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.disableToastNotifications") + description: pluginApi?.tr("settings.disableToastNotificationsDescription") + checked: root.disableToastNotifications + onToggled: checked => { + root.disableToastNotifications = checked; + } + } + + NBox { + Layout.fillWidth: true + Layout.topMargin: Style.marginM + Layout.preferredHeight: Math.round(vpnSection.implicitHeight + Style.marginL * 2) + + ColumnLayout { + id: vpnSection + + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + NLabel { + label: pluginApi?.tr("settings.vpnConnections") + Layout.fillWidth: true + } + + NText { + visible: root.vpnList.length === 0 + text: pluginApi?.tr("settings.noVpnConnections") + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + Layout.leftMargin: Style.marginXS + } + + Repeater { + model: root.vpnList + + NBox { + id: vpnRow + + readonly property bool confirmingDelete: root.pendingDeleteUuid === modelData.uuid + + Layout.fillWidth: true + Layout.leftMargin: Style.marginXS + Layout.rightMargin: Style.marginXS + implicitHeight: Math.round(rowContent.implicitHeight + Style.marginL) + color: vpnRow.confirmingDelete ? Qt.alpha(Color.mError, 0.12) : Color.mSurface + + ColumnLayout { + id: rowContent + + anchors.fill: parent + anchors.leftMargin: Style.marginM + anchors.rightMargin: Style.marginM + anchors.topMargin: Style.marginS + anchors.bottomMargin: Style.marginS + spacing: Style.marginS + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NIcon { + icon: "router" + pointSize: Style.fontSizeXXL + color: modelData.connected ? Color.mPrimary : Color.mOnSurfaceVariant + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + NText { + text: modelData.name + pointSize: Style.fontSizeM + font.weight: Style.fontWeightMedium + color: Color.mOnSurface + elide: Text.ElideRight + Layout.fillWidth: true + } + + NText { + text: modelData.type || "vpn" + pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + } + + } + + NIconButton { + visible: !vpnRow.confirmingDelete + icon: "edit" + tooltipText: pluginApi?.tr("common.edit") + baseSize: Style.baseWidgetSize * 0.75 + onClicked: { + main.editConnection(modelData.uuid); + close(); + } + } + + NIconButton { + visible: !vpnRow.confirmingDelete + icon: "trash-x" + tooltipText: pluginApi?.tr("common.delete") + baseSize: Style.baseWidgetSize * 0.75 + onClicked: { + root.pendingDeleteUuid = modelData.uuid; + root.pendingDeleteName = modelData.name; + } + } + + NButton { + visible: vpnRow.confirmingDelete + text: pluginApi?.tr("common.delete") + fontSize: Style.fontSizeXS + backgroundColor: Color.mError + outlined: false + onClicked: { + main.removeConnection(root.pendingDeleteUuid); + root.pendingDeleteUuid = ""; + root.pendingDeleteName = ""; + } + } + + NButton { + visible: vpnRow.confirmingDelete + text: pluginApi?.tr("common.cancel") + fontSize: Style.fontSizeXS + outlined: true + onClicked: { + root.pendingDeleteUuid = ""; + root.pendingDeleteName = ""; + } + } + + } + + } + + } + + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Style.marginXS + spacing: Style.marginS + + Item { + Layout.fillWidth: true + } + + NButton { + text: pluginApi?.tr("settings.addVpn") + icon: "add" + fontSize: Style.fontSizeS + outlined: true + onClicked: { + main.addConnection("vpn"); + close(); + } + } + + NButton { + text: pluginApi?.tr("settings.addWireguard") + icon: "add" + fontSize: Style.fontSizeS + outlined: true + onClicked: { + main.addConnection("wireguard"); + close(); + } + } + + } + + } + + } + +} diff --git a/noctalia/plugins/network-manager-vpn/VpnListItem.qml b/noctalia/plugins/network-manager-vpn/VpnListItem.qml new file mode 100644 index 0000000..d9361c8 --- /dev/null +++ b/noctalia/plugins/network-manager-vpn/VpnListItem.qml @@ -0,0 +1,124 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Widgets +import qs.Services.UI + +NBox { + id: root + + property string name: "" + property string type: "" + property bool isConnected: false + property bool isLoading: false + + signal buttonClicked + + Layout.fillWidth: true + Layout.leftMargin: Style.marginXS + Layout.rightMargin: Style.marginXS + implicitHeight: Math.round(netColumn.implicitHeight + (Style.marginXL)) + + color: root.isConnected ? Qt.alpha(Color.mPrimary, 0.15) : Color.mSurface + + ColumnLayout { + id: netColumn + width: parent.width - (Style.marginXL) + x: Style.marginM + y: Style.marginM + spacing: Style.marginS + + // Main row + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NIcon { + icon: "router" + pointSize: Style.fontSizeXXL + color: root.isConnected ? Color.mPrimary : Color.mOnSurface + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + NText { + text: root.name + pointSize: Style.fontSizeM + font.weight: Style.fontWeightMedium + color: Color.mOnSurface + elide: Text.ElideRight + Layout.fillWidth: true + } + + RowLayout { + spacing: Style.marginXS + + NText { + text: root.type + pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + } + + Item { + Layout.preferredWidth: Style.marginXXS + } + + + Rectangle { + visible: root.isConnected + color: Color.mPrimary + radius: height * 0.5 + width: Math.round(connectedText.implicitWidth + (Style.marginS * 2)) + height: Math.round(connectedText.implicitHeight + (Style.marginXS)) + + NText { + id: connectedText + anchors.centerIn: parent + text: pluginApi?.tr("common.connected") + pointSize: Style.fontSizeXXS + color: Color.mOnPrimary + } + } + } + } + + // Action area + RowLayout { + spacing: Style.marginS + + NBusyIndicator { + visible: root.isLoading + running: visible + color: Color.mPrimary + size: Style.baseWidgetSize * 0.5 + } + + NButton { + visible: root.isConnected + text: pluginApi?.tr("common.disconnect") + outlined: !hovered + fontSize: Style.fontSizeS + backgroundColor: Color.mError + enabled: !root.isLoading + onClicked: { + root.buttonClicked() + } + } + + NButton { + visible: !root.isConnected + text: pluginApi?.tr("common.connect") + outlined: !hovered + fontSize: Style.fontSizeS + enabled: !root.isLoading + onClicked: { + root.buttonClicked() + } + } + } + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/network-manager-vpn/i18n/de.json b/noctalia/plugins/network-manager-vpn/i18n/de.json new file mode 100644 index 0000000..013a2fc --- /dev/null +++ b/noctalia/plugins/network-manager-vpn/i18n/de.json @@ -0,0 +1,40 @@ +{ + "panel": { + "emptyTitle": "Kein VPN gefunden", + "emptyDescription": "Verwende den Netzwerk-Manager um ein VPN hinzuzufügen" + }, + "toast": { + "connectedTo": "Verbunden mit {name}", + "disconnectedFrom": "Von {Name} getrennt", + "connectionError": "Verbindungsaufbau mit {name} fehlgeschlagen", + "disconnectionError": "Die Trennung von {name} ist fehlgeschlagen", + "vpnRemoved": "VPN \"{name}\" wurde entfernt", + "vpnRemoveError": "Der VPN \"{name}\" konnte nicht entfernt werden" + }, + "settings": { + "pluginSettings": "Plugin Einstellungen", + "connectedColor": "Verknüpfte Farbe", + "connectedColorDescription": "Wähle die Farbe für das Symbol und den Text aus, die bei bestehender Verbindung angezeigt werden sollen", + "disconnectedColor": "Nicht verknüpfte Farbe", + "disconnectedColorDescription": "Wähle die Farbe für das Symbol und den Text bei unterbrochener Verbindung aus", + "vpnConnections": "VPN Verbindungen", + "noVpnConnections": "Keine VPN Verbindungen konfiguriert", + "addVpn": "VPN hinzufügen", + "addWireguard": "WireGuard hinzufügen", + "disableToastNotifications": "Toast-Benachrichtigungen deaktivieren", + "disableToastNotificationsDescription": "Keine Toast-Benachrichtigungen anzeigen, wenn eine VPN-Verbindung hergestellt oder getrennt wird" + }, + "common": { + "vpn": "VPN", + "refresh": "Aktualisieren", + "connected": "Verbunden", + "disconnected": "Getrennt", + "connect": "Verbinden", + "disconnect": "Trennen", + "close": "Schließen", + "settings": "Einstellungen", + "edit": "Bearbeiten", + "delete": "Löschen", + "cancel": "Abbrechen" + } +} \ No newline at end of file diff --git a/noctalia/plugins/network-manager-vpn/i18n/en.json b/noctalia/plugins/network-manager-vpn/i18n/en.json new file mode 100644 index 0000000..aec479e --- /dev/null +++ b/noctalia/plugins/network-manager-vpn/i18n/en.json @@ -0,0 +1,40 @@ +{ + "panel": { + "emptyTitle": "No VPN found", + "emptyDescription": "Use Network Manager to add a VPN" + }, + "toast": { + "connectedTo": "Connected to {name}", + "disconnectedFrom": "Disconnected from {name}", + "connectionError": "Failed to connect {name}", + "disconnectionError": "Failed to disconnect {name}", + "vpnRemoved": "VPN \"{name}\" removed", + "vpnRemoveError": "Failed to remove VPN \"{name}\"" + }, + "settings": { + "pluginSettings": "Plugin settings", + "connectedColor": "Connected color", + "connectedColorDescription": "Chose the color to use for icon and text when connected", + "disconnectedColor": "Disconnected color", + "disconnectedColorDescription": "Chose the color to use for icon and text when disconnected", + "vpnConnections": "VPN Connections", + "noVpnConnections": "No VPN connections configured.", + "addVpn": "Add VPN", + "addWireguard": "Add WireGuard", + "disableToastNotifications": "Disable toast notifications", + "disableToastNotificationsDescription": "Do not show toast notifications when a VPN connects or disconnects" + }, + "common": { + "vpn": "VPN", + "refresh": "Refresh", + "connected": "Connected", + "disconnected": "Disconnected", + "connect": "Connect", + "disconnect": "Disconnect", + "close": "Close", + "settings": "Settings", + "edit": "Edit", + "delete": "Delete", + "cancel": "Cancel" + } +} \ No newline at end of file diff --git a/noctalia/plugins/network-manager-vpn/i18n/ru.json b/noctalia/plugins/network-manager-vpn/i18n/ru.json new file mode 100644 index 0000000..9efae8d --- /dev/null +++ b/noctalia/plugins/network-manager-vpn/i18n/ru.json @@ -0,0 +1,40 @@ +{ + "panel": { + "emptyTitle": "VPN не найден", + "emptyDescription": "Используйте Менеджер Подключений для добавления VPN" + }, + "toast": { + "connectedTo": "Подключиться к {name}", + "disconnectedFrom": "Отключиться от {name}", + "connectionError": "Ошибка подключения к {name}", + "disconnectionError": "Ошибка отключения от {name}", + "vpnRemoved": "VPN \"{name}\" удалён", + "vpnRemoveError": "Ошибка удаления VPN \"{name}\"" + }, + "settings": { + "pluginSettings": "Настройка плагина", + "connectedColor": "Цвет активного подключения", + "connectedColorDescription": "Выберите цвет значка и текста при активном подключении", + "disconnectedColor": "Цвет отсутствия подключения", + "disconnectedColorDescription": "Выберите цвет значка и текста при отсутствии подключения", + "vpnConnections": "VPN-соединения", + "noVpnConnections": "VPN-подключения не настроены.", + "addVpn": "Добавить VPN", + "addWireguard": "Добавить WireGuard", + "disableToastNotifications": "Отключить уведомления", + "disableToastNotificationsDescription": "Не показывать уведомления о статусе VPN-соединения" + }, + "common": { + "vpn": "VPN", + "refresh": "Обновить", + "connected": "Подключено", + "disconnected": "Отключено", + "connect": "Подключить", + "disconnect": "Отключить", + "close": "Закрыть", + "settings": "Настройки", + "edit": "Редактировать", + "delete": "Удалить", + "cancel": "Отмена" + } +} diff --git a/noctalia/plugins/network-manager-vpn/manifest.json b/noctalia/plugins/network-manager-vpn/manifest.json new file mode 100644 index 0000000..0e67c85 --- /dev/null +++ b/noctalia/plugins/network-manager-vpn/manifest.json @@ -0,0 +1,33 @@ +{ + "id": "network-manager-vpn", + "name": "Network Manager VPN", + "version": "1.3.0", + "minNoctaliaVersion": "3.6.0", + "author": "nZO", + "license": "MIT", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "description": "Connect to VPN via NetworkManager", + "tags": [ + "Bar", + "Panel", + "Network" + ], + "entryPoints": { + "main": "Main.qml", + "barWidget": "BarWidget.qml", + "controlCenterWidget": "ControlCenterWidget.qml", + "panel": "Panel.qml", + "settings": "Settings.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "defaultSettings": { + "displayMode": "onhover", + "disconnectedColor": "none", + "connectedColor": "primary", + "disableToastNotifications": false + } + } +} diff --git a/noctalia/plugins/network-manager-vpn/preview.png b/noctalia/plugins/network-manager-vpn/preview.png new file mode 100644 index 0000000..594b2a2 Binary files /dev/null and b/noctalia/plugins/network-manager-vpn/preview.png differ diff --git a/noctalia/plugins/network-manager-vpn/settings.json b/noctalia/plugins/network-manager-vpn/settings.json new file mode 100644 index 0000000..cf85182 --- /dev/null +++ b/noctalia/plugins/network-manager-vpn/settings.json @@ -0,0 +1,6 @@ +{ + "displayMode": "onhover", + "disconnectedColor": "none", + "connectedColor": "primary", + "disableToastNotifications": false +} diff --git a/noctalia/plugins/polkit-agent/Main.qml b/noctalia/plugins/polkit-agent/Main.qml new file mode 100644 index 0000000..f4fca35 --- /dev/null +++ b/noctalia/plugins/polkit-agent/Main.qml @@ -0,0 +1,54 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Polkit +import qs.Commons + +Item { + id: root + property var pluginApi: null + + PolkitAgent { + id: agent + + onIsActiveChanged: { + if (isActive) { + openWindow() + } else { + closeWindow() + } + } + } + + property var window: null + + function openWindow() { + if (agent.flow === null) { + Logger.w("polkit-agent: Cannot open window, agent.flow is null"); + return; + } + if (window === null) { + var component = Qt.createComponent("PolkitWindow.qml"); + if (component.status === Component.Ready) { + window = component.createObject(root, { + flow: agent.flow, + pluginApi: root.pluginApi + }); + if (window !== null) { + window.visible = true; + } + } + component.destroy(); + } else { + window.flow = agent.flow; + window.pluginApi = root.pluginApi; + window.visible = true; + } + } + + function closeWindow() { + if (window !== null) { + window.destroy(); + window = null; + } + } +} diff --git a/noctalia/plugins/polkit-agent/PolkitWindow.qml b/noctalia/plugins/polkit-agent/PolkitWindow.qml new file mode 100644 index 0000000..5407bc2 --- /dev/null +++ b/noctalia/plugins/polkit-agent/PolkitWindow.qml @@ -0,0 +1,259 @@ +import QtQuick.Layouts +import QtQuick.Controls +import QtQuick.Effects +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Polkit +import Quickshell.Wayland +import qs.Commons +import qs.Widgets +import qs.Services.UI + +PanelWindow { + id: polkitWindow + + property AuthFlow flow + property var pluginApi + property string resolvedMessage: "" + property var transientMatch: null + + Connections { + target: flow + function onFailedChanged() { + if (flow && flow.failed) { + ToastService.showError( + pluginApi ? pluginApi.tr("error.failed.title") : "Authentication Failed", + pluginApi ? pluginApi.tr("error.failed.message") : "The password you entered was incorrect." + ) + } + } + } + + // Resolve transient service names when flow changes + onFlowChanged: { + resolveTransientServiceName(flow.message); + } + + // Resolve transient service names (run-PID-random.service) to actual command + Process { + id: cmdLineProcess + command: ["cat", "/proc/0/cmdline"] + running: false + + stdout: StdioCollector { + onStreamFinished: function() { + var args = this.text.split(String.fromCharCode(0)).filter(function(s) { return s.length > 0; }); + Logger.i("polkit-agent: Process output length=" + this.text.length + ", args=" + args.length); + if (args.length > 0 && polkitWindow.transientMatch) { + var resolvedCmd = args.join(' '); + var isCommand = args.length > 1 || args[0].includes('/'); + polkitWindow.resolvedMessage = polkitWindow.resolvedMessage.replace(polkitWindow.transientMatch[0], resolvedCmd); + if (isCommand) { + polkitWindow.resolvedMessage = polkitWindow.resolvedMessage.replace(/transient unit/i, 'run command'); + } + Logger.i("polkit-agent: Resolved message: " + polkitWindow.resolvedMessage); + } + } + } + } + + function resolveTransientServiceName(message) { + if (!message) return; + var match = message.match(/run-p?(\d+)-[^.]+\.service/); + if (!match) { + Logger.i("polkit-agent: No transient service pattern in message: " + message); + polkitWindow.resolvedMessage = message; + polkitWindow.transientMatch = null; + return; + } + var pid = match[1]; + Logger.i("polkit-agent: Found transient service pattern, PID=" + pid + ", message=" + message); + polkitWindow.resolvedMessage = message; + polkitWindow.transientMatch = match; + cmdLineProcess.command = ["cat", "/proc/" + pid + "/cmdline"]; + cmdLineProcess.running = true; + } + + // Layer above everything else (critical system prompt) + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + readonly property real shadowPadding: Style.shadowBlurMax + Style.marginL + + // Explicit size - include shadowPadding so the shadow isn't clipped at window corners + implicitWidth: 400 * Style.uiScaleRatio + shadowPadding * 2 + implicitHeight: contentLayout.implicitHeight + (Style.marginL * 2) + shadowPadding * 2 + + color: "transparent" + + Item { + id: contentContainer + anchors.fill: parent + anchors.margins: shadowPadding + focus: true + + Keys.onPressed: function(event) { + if (!flow) return; + + if (Keybinds.checkKey(event, "escape", Settings)) { + flow.cancelAuthenticationRequest(); + event.accepted = true; + } else if (Keybinds.checkKey(event, "enter", Settings)) { + if (passwordInput.text !== "") { + flow.submit(passwordInput.text); + passwordInput.text = ""; + } + event.accepted = true; + } + } + + + transform: Translate { + id: shakeTranslate + x: 0 + } + + // Error animation + SequentialAnimation { + id: errorShake + running: flow && flow.failed + loops: 1 + + NumberAnimation { target: shakeTranslate; property: "x"; from: 0; to: -10; duration: 50; easing.type: Easing.InOutQuad } + NumberAnimation { target: shakeTranslate; property: "x"; from: -10; to: 10; duration: 50; easing.type: Easing.InOutQuad } + NumberAnimation { target: shakeTranslate; property: "x"; from: 10; to: -10; duration: 50; easing.type: Easing.InOutQuad } + NumberAnimation { target: shakeTranslate; property: "x"; from: -10; to: 10; duration: 50; easing.type: Easing.InOutQuad } + NumberAnimation { target: shakeTranslate; property: "x"; from: 10; to: 0; duration: 50; easing.type: Easing.InOutQuad } + } + + // Shadow effect (behind background) + NDropShadow { + anchors.fill: customBackground + source: customBackground + autoPaddingEnabled: true + z: -1 + } + + Rectangle { + id: customBackground + anchors.fill: parent + radius: Style.radiusL + color: Qt.alpha(Color.mSurface, 0.95) + border.color: (flow && (flow.failed || flow.supplementaryIsError)) ? Color.mError : Color.mOutline + border.width: Style.borderS + + Behavior on border.color { + ColorAnimation { duration: Style.animationFast } + } + } + + ColumnLayout { + id: contentLayout + anchors.centerIn: parent + width: parent.width - (Style.marginL * 2) + spacing: Style.marginM + + // Header with Icon + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + NImageRounded { + Layout.preferredWidth: Style.fontSizeXXL * 2 + Layout.preferredHeight: Style.fontSizeXXL * 2 + imagePath: Settings.preprocessPath(Settings.data.general.avatarImage) || ((flow && flow.iconName) ? Quickshell.iconPath(flow.iconName) : "") + fallbackIcon: "lock" + borderWidth: 0 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXS + + NText { + text: polkitWindow.resolvedMessage || (flow ? flow.message : (pluginApi ? pluginApi.tr("window.title") : "Authentication Required")) + pointSize: Style.fontSizeL + font.weight: Style.fontWeightBold + color: Color.mOnSurface + wrapMode: Text.Wrap + Layout.fillWidth: true + } + + NText { + text: flow ? flow.actionId : "" + pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + wrapMode: Text.Wrap + Layout.fillWidth: true + visible: text !== "" + } + } + } + + // Supplementary Message (Error or prompt) + NText { + visible: flow && flow.supplementaryMessage !== "" + text: flow ? flow.supplementaryMessage : "" + pointSize: Style.fontSizeS + color: (flow && flow.supplementaryIsError) ? Color.mError : Color.mOnSurfaceVariant + wrapMode: Text.Wrap + Layout.fillWidth: true + } + + // Input Field + NTextInput { + id: passwordInput + Layout.fillWidth: true + placeholderText: flow ? flow.inputPrompt : (pluginApi ? pluginApi.tr("prompt.password") : "Password") + label: (flow && flow.isResponseRequired) ? (pluginApi ? pluginApi.tr("prompt.password") : "Password") : "" + inputItem.echoMode: (flow && !flow.responseVisible) ? TextInput.Password : TextInput.Normal + visible: flow && flow.isResponseRequired + + onAccepted: { + if (flow) { + flow.submit(passwordInput.text) + passwordInput.text = "" + } + } + } + + // Actions + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Style.marginS + spacing: Style.marginM + + Item { Layout.fillWidth: true } // Spacer + + NButton { + text: pluginApi ? pluginApi.tr("action.cancel") : "Cancel" + backgroundColor: Color.mSurfaceVariant + textColor: Color.mOnSurfaceVariant + outlined: false + onClicked: { + if (flow) flow.cancelAuthenticationRequest() + } + } + + NButton { + text: pluginApi ? pluginApi.tr("action.authenticate") : "Authenticate" + backgroundColor: Color.mPrimary + textColor: Color.mOnPrimary + enabled: flow && flow.isResponseRequired + onClicked: { + if (flow) { + flow.submit(passwordInput.text) + passwordInput.text = "" + } + } + } + } + } + } + + // Focus handling + Component.onCompleted: { + passwordInput.inputItem.forceActiveFocus() + } +} diff --git a/noctalia/plugins/polkit-agent/README.md b/noctalia/plugins/polkit-agent/README.md new file mode 100644 index 0000000..2200fa6 --- /dev/null +++ b/noctalia/plugins/polkit-agent/README.md @@ -0,0 +1,13 @@ +# Polkit Agent + +This plugin provides a Polkit authentication agent for Noctalia. It allows you to authenticate actions that require elevated privileges directly within the shell. + +![Preview](preview.png) + +## Important + +To use this plugin, you **must disable or uninstall your existing polkit authentication agent** (e.g., `polkit-gnome`, `polkit-kde-agent`, `lxpolkit`, etc). + +Having multiple polkit agents running simultaneously will cause conflicts and prevent this plugin from working correctly. + +> You may need to **restart your session or computer** after enabling this plugin for the changes to take effect and for the new agent to be registered properly. \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/de.json b/noctalia/plugins/polkit-agent/i18n/de.json new file mode 100644 index 0000000..d52d98c --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/de.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "Authentifizierung erforderlich" + }, + "prompt": { + "password": "Passwort" + }, + "action": { + "cancel": "Abbrechen", + "authenticate": "Authentifizieren" + }, + "error": { + "failed": { + "title": "Authentifizierung fehlgeschlagen", + "message": "Das eingegebene Passwort war falsch." + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/en.json b/noctalia/plugins/polkit-agent/i18n/en.json new file mode 100644 index 0000000..8beb58c --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/en.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "Authentication Required" + }, + "prompt": { + "password": "Password" + }, + "action": { + "cancel": "Cancel", + "authenticate": "Authenticate" + }, + "error": { + "failed": { + "title": "Authentication Failed", + "message": "The password you entered was incorrect." + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/es.json b/noctalia/plugins/polkit-agent/i18n/es.json new file mode 100644 index 0000000..e20df44 --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/es.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "Autenticación requerida" + }, + "prompt": { + "password": "Contraseña" + }, + "action": { + "cancel": "Cancelar", + "authenticate": "Autenticar" + }, + "error": { + "failed": { + "title": "Autenticación fallida", + "message": "La contraseña introducida es incorrecta." + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/fr.json b/noctalia/plugins/polkit-agent/i18n/fr.json new file mode 100644 index 0000000..20b0a1a --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/fr.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "Authentification requise" + }, + "prompt": { + "password": "Mot de passe" + }, + "action": { + "cancel": "Annuler", + "authenticate": "S'authentifier" + }, + "error": { + "failed": { + "title": "Authentification échouée", + "message": "Le mot de passe saisi est incorrect." + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/hu.json b/noctalia/plugins/polkit-agent/i18n/hu.json new file mode 100644 index 0000000..7b7cc1d --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/hu.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "Hitelesítés szükséges" + }, + "prompt": { + "password": "Jelszó" + }, + "action": { + "cancel": "Mégse", + "authenticate": "Hitelesítés" + }, + "error": { + "failed": { + "title": "Hitelesítés sikertelen", + "message": "A megadott jelszó helytelen volt." + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/ja.json b/noctalia/plugins/polkit-agent/i18n/ja.json new file mode 100644 index 0000000..a75d265 --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/ja.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "認証が必要です" + }, + "prompt": { + "password": "パスワード" + }, + "action": { + "cancel": "キャンセル", + "authenticate": "認証" + }, + "error": { + "failed": { + "title": "認証に失敗しました", + "message": "入力されたパスワードが正しくありません。" + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/ko-KR.json b/noctalia/plugins/polkit-agent/i18n/ko-KR.json new file mode 100644 index 0000000..8eaa4df --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/ko-KR.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "인증 필요" + }, + "prompt": { + "password": "비밀번호" + }, + "action": { + "cancel": "취소", + "authenticate": "인증" + }, + "error": { + "failed": { + "title": "인증 실패", + "message": "입력한 비밀번호가 올바르지 않습니다." + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/ku.json b/noctalia/plugins/polkit-agent/i18n/ku.json new file mode 100644 index 0000000..4459ce1 --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/ku.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "Nasname pêwîst e" + }, + "prompt": { + "password": "Şîfre" + }, + "action": { + "cancel": "Betal bike", + "authenticate": "Nasnameyê bipesend bike" + }, + "error": { + "failed": { + "title": "Nasname têk çû", + "message": "Şîfreya ku hûn nivîsandin şaş e." + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/nl.json b/noctalia/plugins/polkit-agent/i18n/nl.json new file mode 100644 index 0000000..bf80c09 --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/nl.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "Authenticatie vereist" + }, + "prompt": { + "password": "Wachtwoord" + }, + "action": { + "cancel": "Annuleren", + "authenticate": "Authenticeren" + }, + "error": { + "failed": { + "title": "Authenticatie mislukt", + "message": "Het ingevoerde wachtwoord was onjuist." + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/nn-HN.json b/noctalia/plugins/polkit-agent/i18n/nn-HN.json new file mode 100644 index 0000000..6bbaf0f --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/nn-HN.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "Autentisering kreves" + }, + "prompt": { + "password": "Passord" + }, + "action": { + "cancel": "Avbryt", + "authenticate": "Autentiser" + }, + "error": { + "failed": { + "title": "Autentisering mislyktes", + "message": "Passordet du skrev inn var feil." + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/nn-NO.json b/noctalia/plugins/polkit-agent/i18n/nn-NO.json new file mode 100644 index 0000000..868c980 --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/nn-NO.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "Autentisering krevst" + }, + "prompt": { + "password": "Passord" + }, + "action": { + "cancel": "Avbryt", + "authenticate": "Autentiser" + }, + "error": { + "failed": { + "title": "Autentisering mislukkast", + "message": "Passordet du skreiv inn var feil." + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/pl.json b/noctalia/plugins/polkit-agent/i18n/pl.json new file mode 100644 index 0000000..66184ca --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/pl.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "Wymagane uwierzytelnienie" + }, + "prompt": { + "password": "Hasło" + }, + "action": { + "cancel": "Anuluj", + "authenticate": "Uwierzytelnij" + }, + "error": { + "failed": { + "title": "Uwierzytelnienie nie powiodło się", + "message": "Wprowadzone hasło jest nieprawidłowe." + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/pt.json b/noctalia/plugins/polkit-agent/i18n/pt.json new file mode 100644 index 0000000..f4af699 --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/pt.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "Autenticação Necessária" + }, + "prompt": { + "password": "Senha" + }, + "action": { + "cancel": "Cancelar", + "authenticate": "Autenticar" + }, + "error": { + "failed": { + "title": "Autenticação Falhou", + "message": "A senha introduzida está incorreta." + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/ru.json b/noctalia/plugins/polkit-agent/i18n/ru.json new file mode 100644 index 0000000..d70e837 --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/ru.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "Требуется аутентификация" + }, + "prompt": { + "password": "Пароль" + }, + "action": { + "cancel": "Отмена", + "authenticate": "Аутентифицировать" + }, + "error": { + "failed": { + "title": "Ошибка аутентификации", + "message": "Введен неверный пароль." + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/sv.json b/noctalia/plugins/polkit-agent/i18n/sv.json new file mode 100644 index 0000000..f7af9f1 --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/sv.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "Autentisering krävs" + }, + "prompt": { + "password": "Lösenord" + }, + "action": { + "cancel": "Avbryt", + "authenticate": "Autentisera" + }, + "error": { + "failed": { + "title": "Autentisering misslyckades", + "message": "Det angivna lösenordet var felaktigt." + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/tr.json b/noctalia/plugins/polkit-agent/i18n/tr.json new file mode 100644 index 0000000..1384ee1 --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/tr.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "Kimlik Doğrulaması Gerekli" + }, + "prompt": { + "password": "Parola" + }, + "action": { + "cancel": "İptal", + "authenticate": "Doğrula" + }, + "error": { + "failed": { + "title": "Kimlik Doğrulama Başarısız", + "message": "Girdiğiniz parola yanlış." + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/uk-UA.json b/noctalia/plugins/polkit-agent/i18n/uk-UA.json new file mode 100644 index 0000000..24daa3c --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/uk-UA.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "Потрібна аутентифікація" + }, + "prompt": { + "password": "Пароль" + }, + "action": { + "cancel": "Скасувати", + "authenticate": "Аутентифікувати" + }, + "error": { + "failed": { + "title": "Помилка аутентифікації", + "message": "Введений пароль неправильний." + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/vi.json b/noctalia/plugins/polkit-agent/i18n/vi.json new file mode 100644 index 0000000..4872e37 --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/vi.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "Yêu cầu xác thực" + }, + "prompt": { + "password": "Mật khẩu" + }, + "action": { + "cancel": "Hủy", + "authenticate": "Xác thực" + }, + "error": { + "failed": { + "title": "Xác thực thất bại", + "message": "Mật khẩu bạn nhập không đúng." + } + } +} diff --git a/noctalia/plugins/polkit-agent/i18n/zh-CN.json b/noctalia/plugins/polkit-agent/i18n/zh-CN.json new file mode 100644 index 0000000..4b4cfdc --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/zh-CN.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "需要身份验证" + }, + "prompt": { + "password": "密码" + }, + "action": { + "cancel": "取消", + "authenticate": "验证" + }, + "error": { + "failed": { + "title": "身份验证失败", + "message": "您输入的密码不正确。" + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/i18n/zh-TW.json b/noctalia/plugins/polkit-agent/i18n/zh-TW.json new file mode 100644 index 0000000..f739c9e --- /dev/null +++ b/noctalia/plugins/polkit-agent/i18n/zh-TW.json @@ -0,0 +1,18 @@ +{ + "window": { + "title": "需要身份驗證" + }, + "prompt": { + "password": "密碼" + }, + "action": { + "cancel": "取消", + "authenticate": "驗證" + }, + "error": { + "failed": { + "title": "身份驗證失敗", + "message": "您輸入的密碼不正確。" + } + } +} \ No newline at end of file diff --git a/noctalia/plugins/polkit-agent/manifest.json b/noctalia/plugins/polkit-agent/manifest.json new file mode 100644 index 0000000..30eb5e2 --- /dev/null +++ b/noctalia/plugins/polkit-agent/manifest.json @@ -0,0 +1,24 @@ +{ + "id": "polkit-agent", + "name": "Polkit Agent", + "version": "1.0.6", + "minNoctaliaVersion": "4.4.3", + "author": "Noctalia Team ", + "official": true, + "license": "MIT", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "description": "Provides a Polkit authentication agent.", + "tags": [ + "System", + "Security" + ], + "entryPoints": { + "main": "Main.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "defaultSettings": {} + } +} diff --git a/noctalia/plugins/polkit-agent/preview.png b/noctalia/plugins/polkit-agent/preview.png new file mode 100644 index 0000000..debee8a Binary files /dev/null and b/noctalia/plugins/polkit-agent/preview.png differ diff --git a/noctalia/plugins/wallcards/BarWidget.qml b/noctalia/plugins/wallcards/BarWidget.qml new file mode 100644 index 0000000..b537dda --- /dev/null +++ b/noctalia/plugins/wallcards/BarWidget.qml @@ -0,0 +1,58 @@ +import Quickshell +import qs.Commons +import qs.Services.UI +import qs.Widgets + +NIconButton { + id: root + + property var cfg: pluginApi?.pluginSettings || ({}) + property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + readonly property string iconColorKey: cfg.iconColor ?? defaults.iconColor ?? "none" + property var pluginApi: null + property ShellScreen screen + property string section: "" + property int sectionWidgetIndex: -1 + property int sectionWidgetsCount: 0 + property string widgetId: "" + + baseSize: Style.getCapsuleHeightForScreen(screen?.name) + border.color: Style.capsuleBorderColor + border.width: Style.capsuleBorderWidth + colorBg: Style.capsuleColor + colorFg: Color.resolveColorKey(iconColorKey) + customRadius: Style.radiusL + icon: "cards" + tooltipDirection: BarService.getTooltipDirection(screen?.name) + tooltipText: pluginApi?.tr("widget.tooltip") + + onClicked: { + if (pluginApi) { + root.pluginApi?.mainInstance.toggle(); + } + } + onRightClicked: { + PanelService.showContextMenu(contextMenu, root, screen); + } + + NPopupContextMenu { + id: contextMenu + + model: [ + { + "label": I18n.tr("actions.widget-settings"), + "action": "widget-settings", + "icon": "settings" + }, + ] + + onTriggered: action => { + contextMenu.close(); + PanelService.closeContextMenu(screen); + + if (action === "widget-settings") { + BarService.openPluginSettings(screen, pluginApi.manifest); + } + } + } +} diff --git a/noctalia/plugins/wallcards/Main.qml b/noctalia/plugins/wallcards/Main.qml new file mode 100644 index 0000000..4e7f5bb --- /dev/null +++ b/noctalia/plugins/wallcards/Main.qml @@ -0,0 +1,54 @@ +import QtQuick +import Quickshell.Io + +Item { + id: root + + property var pluginApi: null + + function hide() { + if (windowLoader.item) + windowLoader.item.close(); + else + windowLoader.active = false; + } + function show() { + windowLoader.active = true; + } + function toggle() { + if (windowLoader.active) + hide(); + else + show(); + } + + Loader { + id: windowLoader + + active: false + source: "Wallcards.qml" + + onLoaded: item.pluginApi = Qt.binding(() => root.pluginApi) + + Connections { + function onQuitRequested() { + windowLoader.active = false; + } + + target: windowLoader.item + } + } + IpcHandler { + function hide() { + root.hide(); + } + function show() { + root.show(); + } + function toggle() { + root.toggle(); + } + + target: "plugin:wallcards" + } +} diff --git a/noctalia/plugins/wallcards/README.md b/noctalia/plugins/wallcards/README.md new file mode 100644 index 0000000..67c9f03 --- /dev/null +++ b/noctalia/plugins/wallcards/README.md @@ -0,0 +1,135 @@ +# Wallcards + +A `lively` wallpaper selector for images and videos with live preview. + +> [!NOTE] +> Heavily inspired by other work — see Credits for details. + +https://github.com/user-attachments/assets/9ffbc83d-95e5-4dcd-a834-7bd224211b55 + +## Features + +- Browse image and video wallpapers as a scrollable card stack +- Live preview — applies the wallpaper and updates the colorscheme as you navigate +- Filter by type or by dominant color +- Keyboard and partial mouse navigation +- Full settings panel with layout and behavior options + +## Dependencies + +```sh +pacman -S ffmpeg mpvpaper imagemagick +``` + +## IPC Commands + +Control the plugin from the command line: + +```sh +qs -c noctalia-shell ipc call plugin:wallcards toggle +``` + +## Keybinding Examples + +Add to your compositor configuration: + +### Hyprland + +```conf +bind = SUPER, A, exec, qs -c noctalia-shell ipc call plugin:wallcards toggle +``` + +### Keybinds + +#### Navigation + +| Key | Action | +| --- | --- | +| `J` / `←` | Previous wallpaper | +| `K` / `→` | Next wallpaper | +| `H` | Scroll page back | +| `L` | Scroll page forward | +| `R` | Shuffle | + +#### Actions + +| Key | Action | +| --- | --- | +| `Enter` | Apply wallpaper and close | +| `Space` / `↓` | Apply wallpaper | +| `Esc` / `Q` | Close | + +#### Filters + +| Key | Action | +| --- | --- | +| `A` | Show all | +| `I` | Filter images | +| `V` | Filter videos | +| `F` | Toggle color filter (based on current card) | + +#### View + +| Key | Action | +| --- | --- | +| `T` | Toggle top bar | +| `P` | Toggle live preview | +| `?` | Toggle shortcut panel | + +#### Layout + +| Key | Action | +| --- | --- | +| `Shift + H / L` | Adjust card height | +| `Shift + J / K` | Adjust center card width | +| `Shift + N / P` | Adjust number of visible cards | +| `Ctrl + J / K` | Adjust card spacing | +| `Ctrl + H / L` | Adjust side strip width | + +#### Save + +| Key | Action | +| --- | --- | +| `Ctrl + S` | Save current layout and view settings | + +Scroll wheel also works for navigation. + +## Configuration + +All settings are available through the plugin settings panel in Noctalia. They can also be edited directly in `settings.json` in the plugin directory `~/.config/noctalia/plugins/wallcards`. + +| Setting | Description | +| --- | --- | +| `animation_cards_duration` | Duration of card transition animations in ms | +| `animation_window_duration` | Duration of open/close animations in ms | +| `animate_window` | Enable or disable open/close animations | +| `background_color` | Color of the dimmed backdrop | +| `background_opacity` | Opacity of the dimmed backdrop | +| `cards_shown` | Number of visible cards in the stack | +| `card_height` | Height of the card area in pixels | +| `card_spacing` | Gap between cards in pixels | +| `card_strip_width` | Width of non-center cards | +| `card_radius` | Border radius of the cards | +| `center_width_ratio` | Width of the center card as a ratio of the screen | +| `image_filter` | File extensions treated as images | +| `video_filter` | File extensions treated as videos | +| `hide_help` | Hide the keyboard shortcuts panel | +| `hide_top_bar` | Hide the toolbar above the cards | +| `live_preview` | Apply wallpaper while navigating | +| `selected_filter` | Default filter on open (`all`, `images`, `videos`) | +| `shear_factor` | Shearing applied to the card stack | +| `top_bar_height` | Height of the toolbar | + +## Known Issues + +- When a video card is played, the following warning is spamed ` WARN: vaExportSurfaceHandle failed`. Please contact me, if you have a solution for this. +- Performance for going left or right differs. It is also affected by the total number of cards (is on my todo list) + +## License + +MIT License - see repository for details. + +## Credits + +- Inspired by [ilyamiro](https://github.com/ilyamiro/nixos-configuration) and [liixini](https://github.com/liixini/skwd) +- Built for [Noctalia Shell](https://github.com/noctalia-dev/noctalia-shell) diff --git a/noctalia/plugins/wallcards/Settings.qml b/noctalia/plugins/wallcards/Settings.qml new file mode 100644 index 0000000..2527354 --- /dev/null +++ b/noctalia/plugins/wallcards/Settings.qml @@ -0,0 +1,360 @@ +import qs.Commons +import qs.Widgets +import QtQuick +import QtQuick.Layouts +import Quickshell + +ColumnLayout { + id: root + + property int editAnimationCardsDuration: pluginApi?.pluginSettings?.animation_cards_duration ?? pluginApi?.manifest?.metadata?.defaultSettings?.animation_cards_duration + property int editAnimationDuration: pluginApi?.pluginSettings?.animation_duration ?? pluginApi?.manifest?.metadata?.defaultSettings?.animation_window_duration + property int editAnimationWindowDuration: pluginApi?.pluginSettings?.animation_window_duration ?? pluginApi?.manifest?.metadata?.defaultSettings?.animation_window_duration + property color editBackgroundColor: pluginApi?.pluginSettings?.background_color ?? pluginApi?.manifest?.metadata?.defaultSettings?.background_color + property real editBackgroundOpacity: pluginApi?.pluginSettings?.background_opacity ?? pluginApi?.manifest?.metadata?.defaultSettings?.background_opacity + property int editCardHeight: pluginApi?.pluginSettings?.card_height ?? pluginApi?.manifest?.metadata?.defaultSettings?.card_height + property int editCardSpacing: pluginApi?.pluginSettings?.card_spacing ?? pluginApi?.manifest?.metadata?.defaultSettings?.card_spacing + property int editCardStripWidth: pluginApi?.pluginSettings?.card_strip_width ?? pluginApi?.manifest?.metadata?.defaultSettings?.card_strip_width + property int editCardsShown: pluginApi?.pluginSettings?.cards_shown ?? pluginApi?.manifest?.metadata?.defaultSettings?.cards_shown + property real editCenterWidthRatio: pluginApi?.pluginSettings?.center_width_ratio ?? pluginApi?.manifest?.metadata?.defaultSettings?.center_width_ratio + property bool editHideHelp: pluginApi?.pluginSettings?.hide_help ?? pluginApi?.manifest?.metadata?.defaultSettings?.hide_help + property bool editHideTopBar: pluginApi?.pluginSettings?.hide_top_bar ?? pluginApi?.manifest?.metadata?.defaultSettings?.hide_top_bar ?? false + property bool editAnimateWindow: pluginApi?.pluginSettings?.animate_window ?? pluginApi?.manifest?.metadata?.defaultSettings?.animate_window ?? true + property string editIconColor: pluginApi?.pluginSettings?.icon_color ?? "none" + property bool editLivePreview: pluginApi?.pluginSettings?.live_preview ?? pluginApi?.manifest?.metadata?.defaultSettings?.live_preview + property string editSelectedFilter: pluginApi?.pluginSettings?.selected_filter || pluginApi?.manifest?.metadata?.defaultSettings?.selected_filter + property real editShearFactor: pluginApi?.pluginSettings?.shear_factor ?? pluginApi?.manifest?.metadata?.defaultSettings?.shear_factor + property int editTopBarHeight: pluginApi?.pluginSettings?.top_bar_height ?? pluginApi?.manifest?.metadata?.defaultSettings?.top_bar_height + property var pluginApi: null + + function saveSettings() { + if (!pluginApi || !pluginApi.pluginSettings) { + Logger.e("Wallcards", "Cannot save: pluginApi or pluginSettings is null"); + return; + } + + pluginApi.pluginSettings.animation_cards_duration = root.editAnimationCardsDuration; + pluginApi.pluginSettings.animation_window_duration = root.editAnimationWindowDuration; + pluginApi.pluginSettings.background_color = root.editBackgroundColor.toString(); + pluginApi.pluginSettings.background_opacity = root.editBackgroundOpacity; + + pluginApi.pluginSettings.center_width_ratio = root.editCenterWidthRatio; + pluginApi.pluginSettings.card_height = root.editCardHeight; + pluginApi.pluginSettings.card_spacing = root.editCardSpacing; + pluginApi.pluginSettings.card_strip_width = root.editCardStripWidth; + pluginApi.pluginSettings.cards_shown = root.editCardsShown; + + pluginApi.pluginSettings.shear_factor = root.editShearFactor; + + pluginApi.pluginSettings.top_bar_height = root.editTopBarHeight; + + pluginApi.pluginSettings.iconColor = root.editIconColor; + pluginApi.pluginSettings.selected_filter = root.editSelectedFilter; + pluginApi.pluginSettings.live_preview = root.editLivePreview; + pluginApi.pluginSettings.hide_help = root.editHideHelp; + pluginApi.pluginSettings.hide_top_bar = root.editHideTopBar; + pluginApi.pluginSettings.animate_window = root.editAnimateWindow; + + pluginApi.saveSettings(); + Logger.i("Wallcards", "Settings saved"); + } + + Layout.rightMargin: Style.marginL + spacing: Style.marginL + + // ── Icon ── + NComboBox { + currentKey: root.editIconColor + description: I18n.tr("common.select-color-description") + label: I18n.tr("common.select-icon-color") + minimumWidth: 200 + model: Color.colorKeyModel + + onSelected: key => root.editIconColor = key + } + NDivider { + Layout.fillWidth: true + } + + // ── Behavior ── + NToggle { + checked: root.editLivePreview + defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.live_preview ?? false + description: root.pluginApi?.tr("settings.live-preview.description") + label: root.pluginApi?.tr("settings.live-preview.label") + + onToggled: c => root.editLivePreview = c + } + NComboBox { + currentKey: root.editSelectedFilter + defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.selected_filter || "all" + description: root.pluginApi?.tr("settings.default-filter.description") + label: root.pluginApi?.tr("settings.default-filter.label") + model: [ + { + "key": "all", + "name": root.pluginApi?.tr("buttons.all") + }, + { + "key": "images", + "name": root.pluginApi?.tr("buttons.images") + }, + { + "key": "videos", + "name": root.pluginApi?.tr("buttons.videos") + } + ] + + onSelected: key => root.editSelectedFilter = key + } + NToggle { + checked: root.editHideHelp + defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.hide_help ?? false + description: root.pluginApi?.tr("settings.hide-shortcuts.description") + label: root.pluginApi?.tr("settings.hide-shortcuts.label") + + onToggled: c => root.editHideHelp = c + } + NToggle { + checked: root.editHideTopBar + defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.hide_top_bar ?? false + description: root.pluginApi?.tr("settings.hide-top-bar.description") + label: root.pluginApi?.tr("settings.hide-top-bar.label") + + onToggled: c => root.editHideTopBar = c + } + NDivider { + Layout.fillWidth: true + } + + // ── Card Layout ── + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS + + NLabel { + description: root.pluginApi?.tr("settings.center-card-width.description") + label: root.pluginApi?.tr("settings.center-card-width.label") + } + NValueSlider { + Layout.fillWidth: true + from: 0.20 + stepSize: 0.01 + text: (root.editCenterWidthRatio * 100).toFixed(0) + "%" + to: 0.60 + value: root.editCenterWidthRatio + + onMoved: value => root.editCenterWidthRatio = value + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS + + NLabel { + description: root.pluginApi?.tr("settings.card-height.description") + label: root.pluginApi?.tr("settings.card-height.label") + } + NValueSlider { + Layout.fillWidth: true + from: 100 + stepSize: 10 + text: root.editCardHeight + "px" + to: 800 + value: root.editCardHeight + + onMoved: value => root.editCardHeight = value + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS + + NLabel { + description: root.pluginApi?.tr("settings.strip-width.description") + label: root.pluginApi?.tr("settings.strip-width.label") + } + NValueSlider { + Layout.fillWidth: true + from: 20 + stepSize: 5 + text: root.editCardStripWidth + "px" + to: 150 + value: root.editCardStripWidth + + onMoved: value => root.editCardStripWidth = value + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS + + NLabel { + description: root.pluginApi?.tr("settings.card-spacing.description") + label: root.pluginApi?.tr("settings.card-spacing.label") + } + NValueSlider { + Layout.fillWidth: true + from: 0 + stepSize: 1 + text: root.editCardSpacing + "px" + to: 50 + value: root.editCardSpacing + + onMoved: value => root.editCardSpacing = value + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS + + NLabel { + description: root.pluginApi?.tr("settings.cards-shown.description") + label: root.pluginApi?.tr("settings.cards-shown.label") + } + NValueSlider { + Layout.fillWidth: true + from: 5 + stepSize: 2 + text: root.editCardsShown + to: 15 + value: root.editCardsShown + + onMoved: value => root.editCardsShown = value + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS + + NLabel { + description: root.pluginApi?.tr("settings.shear-factor.description") + label: root.pluginApi?.tr("settings.shear-factor.label") + } + NValueSlider { + Layout.fillWidth: true + from: -0.3 + stepSize: 0.01 + text: root.editShearFactor.toFixed(2) + to: 0.3 + value: root.editShearFactor + + onMoved: value => root.editShearFactor = value + } + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS + + NLabel { + description: root.pluginApi?.tr("settings.card-animation.description") + label: root.pluginApi?.tr("settings.card-animation.label") + } + NValueSlider { + Layout.fillWidth: true + from: 0 + stepSize: 100 + text: root.editAnimationCardsDuration + "ms" + to: 1500 + value: root.editAnimationCardsDuration + + onMoved: value => root.editAnimationCardsDuration = value + } + } + NDivider { + Layout.fillWidth: true + } + + // ── Appearance ── + RowLayout { + NLabel { + Layout.alignment: Qt.AlignTop + description: root.pluginApi?.tr("settings.background-color.description") + label: root.pluginApi?.tr("settings.background-color.label") + } + NColorPicker { + selectedColor: root.editBackgroundColor + + onColorSelected: color => root.editBackgroundColor = color + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS + + NLabel { + description: root.pluginApi?.tr("settings.background-opacity.description") + label: root.pluginApi?.tr("settings.background-opacity.label") + } + NValueSlider { + Layout.fillWidth: true + from: 0 + stepSize: 0.05 + text: (root.editBackgroundOpacity * 100).toFixed(0) + "%" + to: 1 + value: root.editBackgroundOpacity + + onMoved: value => root.editBackgroundOpacity = value + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS + + NLabel { + description: root.pluginApi?.tr("settings.window-animation-speed.description") + label: root.pluginApi?.tr("settings.window-animation-speed.label") + } + NValueSlider { + Layout.fillWidth: true + from: 0 + stepSize: 100 + text: root.editAnimationWindowDuration + "ms" + to: 1500 + value: root.editAnimationWindowDuration + + onMoved: value => root.editAnimationWindowDuration = value + } + } + NToggle { + checked: root.editAnimateWindow + defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.animate_window ?? true + description: root.pluginApi?.tr("settings.animate-window.description") + label: root.pluginApi?.tr("settings.animate-window.label") + + onToggled: c => root.editAnimateWindow = c + } + NDivider { + Layout.fillWidth: true + } + + // ── Top Bar ── + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS + + NLabel { + description: root.pluginApi?.tr("settings.top-bar-height.description") + label: root.pluginApi?.tr("settings.top-bar-height.label") + } + NValueSlider { + Layout.fillWidth: true + from: 10 + stepSize: 2 + text: root.editTopBarHeight + "px" + to: 80 + value: root.editTopBarHeight + + onMoved: value => root.editTopBarHeight = value + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS + } +} diff --git a/noctalia/plugins/wallcards/Wallcards.qml b/noctalia/plugins/wallcards/Wallcards.qml new file mode 100644 index 0000000..f47a5b6 --- /dev/null +++ b/noctalia/plugins/wallcards/Wallcards.qml @@ -0,0 +1,414 @@ +import "src" +import "src/Utils.js" as Utils +import qs.Commons +import qs.Widgets +import qs.Services.UI +import QtQuick +import QtMultimedia +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Qt5Compat.GraphicalEffects +import Qt.labs.folderlistmodel + +PanelWindow { + id: root + + property int animationCardsDuration: pluginApi?.pluginSettings?.animation_cards_duration ?? pluginApi?.manifest?.metadata?.defaultSettings?.animation_cards_duration + property int animationWindowDuration: pluginApi?.pluginSettings?.animation_window_duration ?? pluginApi?.manifest?.metadata?.defaultSettings?.animation_window_duration + property bool animateWindow: pluginApi?.pluginSettings?.animate_window ?? pluginApi?.manifest?.metadata?.defaultSettings?.animate_window ?? true + property color backgroundColor: pluginApi?.pluginSettings?.background_color ?? pluginApi?.manifest?.metadata?.defaultSettings?.background_color + property real backgroundOpacity: pluginApi?.pluginSettings?.background_opacity ?? pluginApi?.manifest?.metadata?.defaultSettings?.background_opacity + property int cardHeight: pluginApi?.pluginSettings?.card_height ?? pluginApi?.manifest?.metadata?.defaultSettings?.card_height + property int cardRadius: Style.radiusM + property int cardSpacing: pluginApi?.pluginSettings?.card_spacing ?? pluginApi?.manifest?.metadata?.defaultSettings?.card_spacing + property int cardStripWidth: pluginApi?.pluginSettings?.card_strip_width ?? pluginApi?.manifest?.metadata?.defaultSettings?.card_strip_width + property int cardsShown: pluginApi?.pluginSettings?.cards_shown ?? pluginApi?.manifest?.metadata?.defaultSettings?.cards_shown + property real centerWidthRatio: pluginApi?.pluginSettings?.center_width_ratio ?? pluginApi?.manifest?.metadata?.defaultSettings?.center_width_ratio + property int currentIndex: cardDeck.currentIndex + property string currentCardColor: filteredFiles[currentIndex]?.filterColor ?? "" + property var imageFilter: pluginApi?.manifest?.metadata?.defaultSettings?.image_filter + property var videoFilter: pluginApi?.manifest?.metadata?.defaultSettings?.video_filter + property var filteredFiles: [] + property var availableColors: [] + property bool hideHelp: pluginApi?.pluginSettings?.hide_help ?? pluginApi?.manifest?.metadata?.defaultSettings?.hide_help ?? true + property bool hideTopBar: pluginApi?.pluginSettings?.hide_top_bar ?? pluginApi?.manifest?.metadata?.defaultSettings?.hide_top_bar ?? false + property bool livePreview: pluginApi?.pluginSettings?.live_preview ?? pluginApi?.manifest?.metadata?.defaultSettings?.live_preview + property bool loading: thumbnailService.loading + property int pendingProcesses: 0 + property var pluginApi: null + property string selectedFilter: pluginApi?.pluginSettings?.selected_filter || pluginApi?.manifest?.metadata?.defaultSettings?.selected_filter + property string selectedColorFilter: "" + property real shearFactor: pluginApi?.pluginSettings?.shear_factor ?? pluginApi?.manifest?.metadata?.defaultSettings?.shear_factor + property int thumbnailRevision: thumbnailService.thumbnailRevision + property int topBarHeight: pluginApi?.pluginSettings?.top_bar_height ?? pluginApi?.manifest?.metadata?.defaultSettings?.top_bar_height + property int topBarRadius: Style.radiusM + + signal quitRequested + + function applyCurrentCard() { + var f = filteredFiles[cardDeck.currentIndex]; + if (!f) + return; + + applicant.command = ["bash", "-c", Utils.wallpaperCommand(f)]; + applicant.running = true; + + var wallpaperPath = f.isVideo ? f.thumbnail : f.filePath; + WallpaperService.changeWallpaper(wallpaperPath); + } + + function applyFilterToFiles() { + var currentFile = filteredFiles[cardDeck.currentIndex]?.filePath ?? ""; + + var all = thumbnailService.files ?? []; + var result = all; + + if (selectedFilter === "images") + result = result.filter(f => !f.isVideo); + else if (selectedFilter === "videos") + result = result.filter(f => f.isVideo); + + var colors = {}; + for (var c = 0; c < result.length; c++) + colors[result[c].filterColor] = true; + availableColors = Object.keys(colors); + + if (selectedColorFilter !== "") + result = result.filter(f => f.filterColor === selectedColorFilter); + + filteredFiles = result; + + var newIdx = 0; + if (currentFile !== "") { + for (var i = 0; i < result.length; i++) { + if (result[i].filePath === currentFile) { + newIdx = i; + break; + } + } + } + + cardDeck.jumpTo(newIdx); + } + + function close() { + if (exitAnimation.running) + return; + if (root.animateWindow) { + exitAnimation.start(); + } else { + root.quitRequested(); + } + } + + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + WlrLayershell.layer: WlrLayer.Overlay + aboveWindows: true + color: "transparent" + exclusionMode: "Ignore" + exclusiveZone: 0 + implicitHeight: screen.height + implicitWidth: screen.width + screen: pluginApi.panelOpenScreen + + onCurrentIndexChanged: { + if (root.livePreview) + applyCurrentCard(); + } + + onSelectedFilterChanged: applyFilterToFiles() + onSelectedColorFilterChanged: applyFilterToFiles() + onVisibleChanged: { + if (visible) { + if (root.animateWindow) { + enterAnimation.start(); + } else { + content.opacity = 1; + content.scale = 1; + background.opacity = root.backgroundOpacity; + } + } + } + + Process { + id: applicant + + command: [] + running: false + } + + Connections { + function onFilesChanged() { + root.applyFilterToFiles(); + } + + target: thumbnailService + } + + ThumbnailService { + id: thumbnailService + + cacheDir: root.pluginApi?.Settings.cacheDir + "/wallcards" + imageFilter: root.imageFilter + videoFilter: root.videoFilter + wallpaperDir: root.pluginApi?.Settings.data.wallpaper.directory + } + + ParallelAnimation { + id: enterAnimation + + NumberAnimation { + duration: root.animationWindowDuration + easing.type: Easing.OutCubic + from: 0.85 + property: "scale" + target: content + to: 1.0 + } + NumberAnimation { + duration: root.animationWindowDuration + easing.type: Easing.OutCubic + from: 0 + property: "opacity" + target: content + to: 1 + } + NumberAnimation { + duration: root.animationWindowDuration + easing.type: Easing.OutCubic + from: 0 + property: "opacity" + target: background + to: root.backgroundOpacity + } + } + + ParallelAnimation { + id: exitAnimation + + property int duration: root.animationWindowDuration * 0.4 + + onFinished: root.quitRequested() + + NumberAnimation { + duration: exitAnimation.duration + easing.type: Easing.InCubic + property: "opacity" + target: content + to: 0 + } + NumberAnimation { + duration: exitAnimation.duration + easing.type: Easing.InCubic + property: "scale" + target: content + to: 0.85 + } + NumberAnimation { + duration: exitAnimation.duration + easing.type: Easing.InCubic + property: "opacity" + target: background + to: 0 + } + } + + Rectangle { + id: background + + anchors.fill: parent + color: root.backgroundColor + opacity: 0 + + MouseArea { + anchors.fill: parent + + onClicked: root.close() + } + } + + Item { + id: content + + anchors.fill: parent + focus: true + opacity: 0 + scale: 0.85 + + Keys.onPressed: event => { + if (event.modifiers & Qt.ShiftModifier) { + if (event.key === Qt.Key_H) { + root.cardHeight = Math.max(root.cardHeight - 10, parent.height * 0.10); + event.accepted = true; + return; + } else if (event.key === Qt.Key_L) { + root.cardHeight = Math.min(root.cardHeight + 10, parent.height * 0.75); + event.accepted = true; + return; + } else if (event.key === Qt.Key_J) { + cardDeck.centerWidthRatio = Math.max(cardDeck.centerWidthRatio - 0.01, 0.2); + event.accepted = true; + return; + } else if (event.key === Qt.Key_K) { + cardDeck.centerWidthRatio = Math.min(cardDeck.centerWidthRatio + 0.01, 0.6); + event.accepted = true; + return; + } else if (event.key === Qt.Key_N) { + root.cardsShown = Math.max(root.cardsShown - 2, 5); + event.accepted = true; + return; + } else if (event.key === Qt.Key_P) { + root.cardsShown = Math.min(root.cardsShown + 2, 15); + event.accepted = true; + return; + } + } + + if (event.modifiers & Qt.ControlModifier) { + if (event.key === Qt.Key_K) { + root.cardSpacing = Math.max(root.cardSpacing + 2, 0); + event.accepted = true; + return; + } else if (event.key === Qt.Key_J) { + root.cardSpacing = Math.max(root.cardSpacing - 2, 0); + event.accepted = true; + return; + } else if (event.key === Qt.Key_L) { + root.cardStripWidth = Math.min(root.cardStripWidth + 5, 300); + event.accepted = true; + return; + } else if (event.key === Qt.Key_H) { + root.cardStripWidth = Math.max(root.cardStripWidth - 5, 20); + event.accepted = true; + return; + } else if (event.key === Qt.Key_S) { + root.pluginApi.pluginSettings.card_height = root.cardHeight; + root.pluginApi.pluginSettings.center_width_ratio = cardDeck.centerWidthRatio; + root.pluginApi.pluginSettings.cards_shown = root.cardsShown; + root.pluginApi.pluginSettings.card_spacing = root.cardSpacing; + root.pluginApi.pluginSettings.card_strip_width = root.cardStripWidth; + root.pluginApi.pluginSettings.hide_top_bar = root.hideTopBar; + root.pluginApi.pluginSettings.live_preview = root.livePreview; + root.pluginApi.pluginSettings.selected_filter = root.selectedFilter; + root.pluginApi.pluginSettings.hide_help = root.hideHelp; + root.pluginApi.pluginSettings.animate_window = root.animateWindow; + root.pluginApi.saveSettings(); + event.accepted = true; + return; + } + } + + function handleKey(event, bindings) { + const action = bindings[event.key]; + if (action) { + action(); + event.accepted = true; + } + } + + const bindings = { + [Qt.Key_Question]: () => sideBar.expanded = !sideBar.expanded, + [Qt.Key_Q]: () => root.close(), + [Qt.Key_Return]: () => { + root.applyCurrentCard(); + root.close(); + }, + [Qt.Key_Space]: () => root.applyCurrentCard(), + [Qt.Key_Escape]: () => root.close(), + [Qt.Key_A]: () => root.selectedFilter = "all", + [Qt.Key_I]: () => root.selectedFilter = "images", + [Qt.Key_V]: () => root.selectedFilter = "videos", + [Qt.Key_T]: () => root.hideTopBar = !root.hideTopBar, + [Qt.Key_F]: () => { + if (root.selectedColorFilter !== "") + root.selectedColorFilter = ""; + else + root.selectedColorFilter = root.currentCardColor; + }, + [Qt.Key_R]: () => { + cardDeck.randomJump(); + topBar.flashShuffle(); + }, + [Qt.Key_P]: () => root.livePreview = !root.livePreview, + [Qt.Key_K]: () => cardDeck.navigateTo(cardDeck.currentIndex + 1), + [Qt.Key_J]: () => cardDeck.navigateTo(cardDeck.currentIndex - 1), + [Qt.Key_L]: () => cardDeck.navigateTo(cardDeck.currentIndex + root.cardsShown - 2), + [Qt.Key_H]: () => cardDeck.navigateTo(cardDeck.currentIndex - root.cardsShown + 2) + }; + + const action = bindings[event.key]; + if (action) { + action(); + event.accepted = true; + } + } + + LoadingBar { + id: loadingBar + + anchors.centerIn: parent + pending: thumbnailService.pendingProcesses + total: thumbnailService.fileCount + } + + TopBar { + id: topBar + + anchors.bottom: cardDeck.top + anchors.bottomMargin: root.topBarHeight / 3 + anchors.horizontalCenter: parent.horizontalCenter + anchors.horizontalCenterOffset: shearFactor * -root.topBarHeight * 4 / 3 + animationDuration: root.animationCardsDuration + availableColors: root.availableColors + colorOrder: thumbnailService.colorOrder + colorOrderColors: thumbnailService.colorOrderColors + currentCardColor: root.currentCardColor + currentIndex: cardDeck.currentIndex + filteredCount: root.filteredFiles.length + height: root.topBarHeight + livePreview: root.livePreview + pluginApi: root.pluginApi + radius: root.topBarRadius + selectedColorFilter: root.selectedColorFilter + selectedFilter: root.selectedFilter + shearFactor: root.shearFactor + visible: !loadingBar.visible && !root.hideTopBar + width: cardDeck.width + + onColorFilterSelected: key => root.selectedColorFilter = key + onFilterSelected: key => root.selectedFilter = key + onLivePreviewToggled: root.livePreview = !root.livePreview + onShuffleRequested: cardDeck.randomJump() + } + + SideBar { + id: sideBar + + anchors.left: parent.left + anchors.leftMargin: Style.marginL + anchors.verticalCenter: parent.verticalCenter + hideHelp: root.hideHelp + visible: !loadingBar.visible + } + + CardDeck { + id: cardDeck + + anchors.centerIn: parent + animationDuration: root.animationCardsDuration + cardRadius: root.cardRadius + cardSpacing: root.cardSpacing + cardStripWidth: root.cardStripWidth + cardsShown: root.cardsShown + centerWidthRatio: root.centerWidthRatio + filteredCount: root.filteredFiles.length + filteredModel: root.filteredFiles + height: root.cardHeight + shearFactor: root.shearFactor + visible: !loadingBar.visible + + onApplyRequested: root.applyCurrentCard() + } + } +} diff --git a/noctalia/plugins/wallcards/i18n/de.json b/noctalia/plugins/wallcards/i18n/de.json new file mode 100644 index 0000000..15e1012 --- /dev/null +++ b/noctalia/plugins/wallcards/i18n/de.json @@ -0,0 +1,116 @@ +{ + "buttons": { + "all": "Alle", + "images": "Bilder", + "videos": "Videos", + "shuffle": "Mischen", + "live-preview": "Live", + "color-na": "✕" + }, + "widget": { + "tooltip": "Wallcards öffnen", + "generate-thumbs-message": "Miniaturbilder werden erstellt…" + }, + "settings": { + "icon-color": { + "label": "Symbolfarbe", + "description": "Farbe des Leistensymbols" + }, + "live-preview": { + "label": "Live-Vorschau", + "description": "Hintergrundbild beim Durchblättern anwenden" + }, + "default-filter": { + "label": "Standardfilter", + "description": "Anfänglicher Dateitypfilter beim Öffnen" + }, + "hide-shortcuts": { + "label": "Tastenkürzel ausblenden", + "description": "Tastenkürzel-Leiste ausblenden" + }, + "hide-top-bar": { + "label": "Kopfleiste ausblenden", + "description": "Werkzeugleiste über den Karten ausblenden" + }, + "center-card-width": { + "label": "Breite der mittleren Karte", + "description": "Breite der mittleren Karte in Prozent des Bildschirms" + }, + "card-height": { + "label": "Kartenhöhe", + "description": "Höhe der Hintergrundbilder-Karten" + }, + "strip-width": { + "label": "Streifenbreite", + "description": "Breite der seitlichen Kartenstreifen" + }, + "card-spacing": { + "label": "Kartenabstand", + "description": "Abstand zwischen den Karten" + }, + "cards-shown": { + "label": "Angezeigte Karten", + "description": "Anzahl sichtbarer Karten (ungerade Zahlen empfohlen)" + }, + "shear-factor": { + "label": "Scherungsfaktor", + "description": "Neigungswinkel des Kartenlayouts" + }, + "card-animation": { + "label": "Kartenanimation", + "description": "Geschwindigkeit der Kartenübergänge" + }, + "background-color": { + "label": "Hintergrundfarbe", + "description": "Farbe der abgedunkelten Überblendung" + }, + "background-opacity": { + "label": "Hintergrund-Deckkraft", + "description": "Deckkraft der abgedunkelten Überblendung" + }, + "window-animation-speed": { + "label": "Fensteranimation-Geschwindigkeit", + "description": "Geschwindigkeit der Öffnen- und Schließen-Übergänge" + }, + "animate-window": { + "label": "Fensteranimation", + "description": "Öffnen- und Schließen-Übergänge animieren" + }, + "top-bar-height": { + "label": "Kopfleistenhöhe", + "description": "Höhe der Werkzeugleiste" + } + }, + "shortcuts": { + "key-separator": "/", + "header": { + "navigation": "NAVIGATION", + "actions": "AKTIONEN", + "filters": "FILTER", + "view": "ANSICHT", + "layout": "LAYOUT" + }, + "label": { + "help-title": "Tastenkürzel", + "navigate": "Navigieren", + "jump": "Springen", + "shuffle": "Mischen", + "apply-quit": "Anwenden + Schließen", + "apply": "Anwenden", + "quit": "Schließen", + "filter-all": "Alle", + "filter-images": "Bilder", + "filter-videos": "Videos", + "filter-colors": "Farbfilter", + "top-bar": "Kopfleiste umschalten", + "live-preview": "Live-Vorschau umschalten", + "center-height": "Kartenhöhe", + "center-width": "Kartenbreite", + "cards-shown": "Angezeigte Karten", + "cards-spacing": "Abstand", + "cards-width": "Kartenbreite", + "save": "Einstellungen speichern", + "hide": "Ausblenden" + } + } +} diff --git a/noctalia/plugins/wallcards/i18n/en.json b/noctalia/plugins/wallcards/i18n/en.json new file mode 100644 index 0000000..4ed9c48 --- /dev/null +++ b/noctalia/plugins/wallcards/i18n/en.json @@ -0,0 +1,116 @@ +{ + "buttons": { + "all": "All", + "images": "Images", + "videos": "Videos", + "shuffle": "Shuffle", + "live-preview": "Live", + "color-na": "✕" + }, + "widget": { + "tooltip": "Open Wallcards", + "generate-thumbs-message": "Generating thumbnails…" + }, + "settings": { + "icon-color": { + "label": "Icon Color", + "description": "Color of the bar widget icon" + }, + "live-preview": { + "label": "Live Preview", + "description": "Apply wallpaper while browsing cards" + }, + "default-filter": { + "label": "Default Filter", + "description": "Initial file type filter when opening" + }, + "hide-shortcuts": { + "label": "Hide Shortcuts", + "description": "Hide the keyboard shortcuts panel" + }, + "hide-top-bar": { + "label": "Hide Top Bar", + "description": "Hide the toolbar above the cards" + }, + "center-card-width": { + "label": "Center Card Width", + "description": "Width of the center card as percentage of screen" + }, + "card-height": { + "label": "Card Height", + "description": "Height of the wallpaper cards" + }, + "strip-width": { + "label": "Strip Width", + "description": "Width of the side card strips" + }, + "card-spacing": { + "label": "Card Spacing", + "description": "Gap between cards" + }, + "cards-shown": { + "label": "Cards Shown", + "description": "Number of visible cards (odd numbers recommended)" + }, + "shear-factor": { + "label": "Shear Factor", + "description": "Skew angle of the card layout" + }, + "card-animation": { + "label": "Card Animation", + "description": "Speed of card transitions" + }, + "background-color": { + "label": "Background Color", + "description": "Color of the dimmed overlay" + }, + "background-opacity": { + "label": "Background Opacity", + "description": "Opacity of the dimmed overlay" + }, + "window-animation-speed": { + "label": "Window Animation Speed", + "description": "Speed of open and close transitions" + }, + "animate-window": { + "label": "Window Animation", + "description": "Animate the open and close transitions" + }, + "top-bar-height": { + "label": "Top Bar Height", + "description": "Height of the toolbar" + } + }, + "shortcuts": { + "key-separator": "/", + "header": { + "navigation": "NAVIGATION", + "actions": "ACTIONS", + "filters": "FILTERS", + "view": "VIEW", + "layout": "LAYOUT" + }, + "label": { + "help-title": "Shortcuts", + "navigate": "Navigate", + "jump": "Jump", + "shuffle": "Shuffle", + "apply-quit": "Apply + Quit", + "apply": "Apply", + "quit": "Quit", + "filter-all": "All", + "filter-images": "Images", + "filter-videos": "Videos", + "filter-colors": "Color Filter", + "top-bar": "Toggle Top Bar", + "live-preview": "Toggle Live Preview", + "center-height": "Center Height", + "center-width": "Center Width", + "cards-shown": "Cards Shown", + "cards-spacing": "Spacing", + "cards-width": "Cards Width", + "save": "Save Settings", + "hide": "Hide" + } + } +} diff --git a/noctalia/plugins/wallcards/i18n/es.json b/noctalia/plugins/wallcards/i18n/es.json new file mode 100644 index 0000000..67a462a --- /dev/null +++ b/noctalia/plugins/wallcards/i18n/es.json @@ -0,0 +1,116 @@ +{ + "buttons": { + "all": "Todos", + "images": "Imágenes", + "videos": "Vídeos", + "shuffle": "Mezclar", + "live-preview": "En vivo", + "color-na": "✕" + }, + "widget": { + "tooltip": "Abrir Wallcards", + "generate-thumbs-message": "Generando miniaturas…" + }, + "settings": { + "icon-color": { + "label": "Color del icono", + "description": "Color del icono del widget de barra" + }, + "live-preview": { + "label": "Vista previa en vivo", + "description": "Aplicar fondo mientras navegas por las tarjetas" + }, + "default-filter": { + "label": "Filtro predeterminado", + "description": "Filtro de tipo de archivo inicial al abrir" + }, + "hide-shortcuts": { + "label": "Ocultar atajos", + "description": "Ocultar el panel de atajos de teclado" + }, + "hide-top-bar": { + "label": "Ocultar barra superior", + "description": "Ocultar la barra de herramientas sobre las tarjetas" + }, + "center-card-width": { + "label": "Ancho de tarjeta central", + "description": "Ancho de la tarjeta central como porcentaje de la pantalla" + }, + "card-height": { + "label": "Altura de tarjeta", + "description": "Altura de las tarjetas de fondo" + }, + "strip-width": { + "label": "Ancho de franja", + "description": "Ancho de las franjas laterales" + }, + "card-spacing": { + "label": "Espaciado de tarjetas", + "description": "Espacio entre tarjetas" + }, + "cards-shown": { + "label": "Tarjetas visibles", + "description": "Número de tarjetas visibles (se recomiendan números impares)" + }, + "shear-factor": { + "label": "Factor de inclinación", + "description": "Ángulo de inclinación del diseño de tarjetas" + }, + "card-animation": { + "label": "Animación de tarjetas", + "description": "Velocidad de las transiciones de tarjetas" + }, + "background-color": { + "label": "Color de fondo", + "description": "Color de la superposición oscurecida" + }, + "background-opacity": { + "label": "Opacidad del fondo", + "description": "Opacidad de la superposición oscurecida" + }, + "window-animation-speed": { + "label": "Velocidad de animación de ventana", + "description": "Velocidad de las transiciones de abrir y cerrar" + }, + "animate-window": { + "label": "Animación de ventana", + "description": "Animar las transiciones de abrir y cerrar" + }, + "top-bar-height": { + "label": "Altura de barra superior", + "description": "Altura de la barra de herramientas" + } + }, + "shortcuts": { + "key-separator": "/", + "header": { + "navigation": "NAVEGACIÓN", + "actions": "ACCIONES", + "filters": "FILTROS", + "view": "VISTA", + "layout": "DISEÑO" + }, + "label": { + "help-title": "Atajos", + "navigate": "Navegar", + "jump": "Saltar", + "shuffle": "Mezclar", + "apply-quit": "Aplicar + Cerrar", + "apply": "Aplicar", + "quit": "Cerrar", + "filter-all": "Todos", + "filter-images": "Imágenes", + "filter-videos": "Vídeos", + "filter-colors": "Filtro de color", + "top-bar": "Barra superior", + "live-preview": "Vista previa", + "center-height": "Altura central", + "center-width": "Ancho central", + "cards-shown": "Tarjetas visibles", + "cards-spacing": "Espaciado", + "cards-width": "Ancho de tarjetas", + "save": "Guardar ajustes", + "hide": "Ocultar" + } + } +} diff --git a/noctalia/plugins/wallcards/i18n/fr.json b/noctalia/plugins/wallcards/i18n/fr.json new file mode 100644 index 0000000..9fa5902 --- /dev/null +++ b/noctalia/plugins/wallcards/i18n/fr.json @@ -0,0 +1,116 @@ +{ + "buttons": { + "all": "Tous", + "images": "Images", + "videos": "Vidéos", + "shuffle": "Mélanger", + "live-preview": "En direct", + "color-na": "✕" + }, + "widget": { + "tooltip": "Ouvrir Wallcards", + "generate-thumbs-message": "Génération des miniatures…" + }, + "settings": { + "icon-color": { + "label": "Couleur de l'icône", + "description": "Couleur de l'icône du widget de barre" + }, + "live-preview": { + "label": "Aperçu en direct", + "description": "Appliquer le fond d'écran en parcourant les cartes" + }, + "default-filter": { + "label": "Filtre par défaut", + "description": "Filtre de type de fichier initial à l'ouverture" + }, + "hide-shortcuts": { + "label": "Masquer les raccourcis", + "description": "Masquer le panneau des raccourcis clavier" + }, + "hide-top-bar": { + "label": "Masquer la barre supérieure", + "description": "Masquer la barre d'outils au-dessus des cartes" + }, + "center-card-width": { + "label": "Largeur de la carte centrale", + "description": "Largeur de la carte centrale en pourcentage de l'écran" + }, + "card-height": { + "label": "Hauteur des cartes", + "description": "Hauteur des cartes de fond d'écran" + }, + "strip-width": { + "label": "Largeur des bandes", + "description": "Largeur des bandes latérales" + }, + "card-spacing": { + "label": "Espacement des cartes", + "description": "Espace entre les cartes" + }, + "cards-shown": { + "label": "Cartes affichées", + "description": "Nombre de cartes visibles (nombres impairs recommandés)" + }, + "shear-factor": { + "label": "Facteur de cisaillement", + "description": "Angle d'inclinaison de la disposition des cartes" + }, + "card-animation": { + "label": "Animation des cartes", + "description": "Vitesse des transitions de cartes" + }, + "background-color": { + "label": "Couleur de fond", + "description": "Couleur de la superposition assombrie" + }, + "background-opacity": { + "label": "Opacité du fond", + "description": "Opacité de la superposition assombrie" + }, + "window-animation-speed": { + "label": "Vitesse d'animation de la fenêtre", + "description": "Vitesse des transitions d'ouverture et de fermeture" + }, + "animate-window": { + "label": "Animation de la fenêtre", + "description": "Animer les transitions d'ouverture et de fermeture" + }, + "top-bar-height": { + "label": "Hauteur de la barre supérieure", + "description": "Hauteur de la barre d'outils" + } + }, + "shortcuts": { + "key-separator": "/", + "header": { + "navigation": "NAVIGATION", + "actions": "ACTIONS", + "filters": "FILTRES", + "view": "AFFICHAGE", + "layout": "DISPOSITION" + }, + "label": { + "help-title": "Raccourcis", + "navigate": "Naviguer", + "jump": "Sauter", + "shuffle": "Mélanger", + "apply-quit": "Appliquer + Quitter", + "apply": "Appliquer", + "quit": "Quitter", + "filter-all": "Tous", + "filter-images": "Images", + "filter-videos": "Vidéos", + "filter-colors": "Filtre couleur", + "top-bar": "Barre supérieure", + "live-preview": "Aperçu en direct", + "center-height": "Hauteur centrale", + "center-width": "Largeur centrale", + "cards-shown": "Cartes affichées", + "cards-spacing": "Espacement", + "cards-width": "Largeur des cartes", + "save": "Enregistrer", + "hide": "Masquer" + } + } +} diff --git a/noctalia/plugins/wallcards/i18n/hu.json b/noctalia/plugins/wallcards/i18n/hu.json new file mode 100644 index 0000000..ebb36aa --- /dev/null +++ b/noctalia/plugins/wallcards/i18n/hu.json @@ -0,0 +1,116 @@ +{ + "buttons": { + "all": "Összes", + "images": "Képek", + "videos": "Videók", + "shuffle": "Keverés", + "live-preview": "Élő", + "color-na": "✕" + }, + "widget": { + "tooltip": "Wallcards megnyitása", + "generate-thumbs-message": "Miniatűrök létrehozása…" + }, + "settings": { + "icon-color": { + "label": "Ikon színe", + "description": "A sáv widget ikonjának színe" + }, + "live-preview": { + "label": "Élő előnézet", + "description": "Háttérkép alkalmazása böngészés közben" + }, + "default-filter": { + "label": "Alapértelmezett szűrő", + "description": "Kezdeti fájltípus-szűrő megnyitáskor" + }, + "hide-shortcuts": { + "label": "Gyorsbillentyűk elrejtése", + "description": "A gyorsbillentyűk panel elrejtése" + }, + "hide-top-bar": { + "label": "Felső sáv elrejtése", + "description": "Az eszköztár elrejtése a kártyák felett" + }, + "center-card-width": { + "label": "Középső kártya szélessége", + "description": "A középső kártya szélessége a képernyő százalékában" + }, + "card-height": { + "label": "Kártya magassága", + "description": "A háttérkép-kártyák magassága" + }, + "strip-width": { + "label": "Csík szélessége", + "description": "Az oldalsó kártyacsíkok szélessége" + }, + "card-spacing": { + "label": "Kártyák térköze", + "description": "Kártyák közötti rés" + }, + "cards-shown": { + "label": "Megjelenített kártyák", + "description": "Látható kártyák száma (páratlan számok ajánlottak)" + }, + "shear-factor": { + "label": "Nyírási tényező", + "description": "A kártya-elrendezés döntési szöge" + }, + "card-animation": { + "label": "Kártya animáció", + "description": "Kártyaátmenetek sebessége" + }, + "background-color": { + "label": "Háttérszín", + "description": "A sötétített fedőréteg színe" + }, + "background-opacity": { + "label": "Háttér átlátszatlansága", + "description": "A sötétített fedőréteg átlátszatlansága" + }, + "window-animation-speed": { + "label": "Ablak animáció sebessége", + "description": "A nyitás és zárás átmenetek sebessége" + }, + "animate-window": { + "label": "Ablak animáció", + "description": "Nyitás és zárás átmenetek animálása" + }, + "top-bar-height": { + "label": "Felső sáv magassága", + "description": "Az eszköztár magassága" + } + }, + "shortcuts": { + "key-separator": "/", + "header": { + "navigation": "NAVIGÁCIÓ", + "actions": "MŰVELETEK", + "filters": "SZŰRŐK", + "view": "NÉZET", + "layout": "ELRENDEZÉS" + }, + "label": { + "help-title": "Gyorsbillentyűk", + "navigate": "Navigálás", + "jump": "Ugrás", + "shuffle": "Keverés", + "apply-quit": "Alkalmazás + Kilépés", + "apply": "Alkalmazás", + "quit": "Kilépés", + "filter-all": "Összes", + "filter-images": "Képek", + "filter-videos": "Videók", + "filter-colors": "Színszűrő", + "top-bar": "Felső sáv", + "live-preview": "Élő előnézet", + "center-height": "Középső magasság", + "center-width": "Középső szélesség", + "cards-shown": "Megjelenített kártyák", + "cards-spacing": "Térköz", + "cards-width": "Kártyák szélessége", + "save": "Beállítások mentése", + "hide": "Elrejtés" + } + } +} diff --git a/noctalia/plugins/wallcards/i18n/it.json b/noctalia/plugins/wallcards/i18n/it.json new file mode 100644 index 0000000..e2a7c25 --- /dev/null +++ b/noctalia/plugins/wallcards/i18n/it.json @@ -0,0 +1,116 @@ +{ + "buttons": { + "all": "Tutti", + "images": "Immagini", + "videos": "Video", + "shuffle": "Mescola", + "live-preview": "Live", + "color-na": "✕" + }, + "widget": { + "tooltip": "Apri Wallcards", + "generate-thumbs-message": "Generazione miniature…" + }, + "settings": { + "icon-color": { + "label": "Colore icona", + "description": "Colore dell'icona del widget della barra" + }, + "live-preview": { + "label": "Anteprima dal vivo", + "description": "Applica lo sfondo durante la navigazione delle schede" + }, + "default-filter": { + "label": "Filtro predefinito", + "description": "Filtro tipo file iniziale all'apertura" + }, + "hide-shortcuts": { + "label": "Nascondi scorciatoie", + "description": "Nascondi il pannello delle scorciatoie da tastiera" + }, + "hide-top-bar": { + "label": "Nascondi barra superiore", + "description": "Nascondi la barra degli strumenti sopra le schede" + }, + "center-card-width": { + "label": "Larghezza scheda centrale", + "description": "Larghezza della scheda centrale in percentuale dello schermo" + }, + "card-height": { + "label": "Altezza scheda", + "description": "Altezza delle schede sfondo" + }, + "strip-width": { + "label": "Larghezza striscia", + "description": "Larghezza delle strisce laterali" + }, + "card-spacing": { + "label": "Spaziatura schede", + "description": "Distanza tra le schede" + }, + "cards-shown": { + "label": "Schede visibili", + "description": "Numero di schede visibili (numeri dispari consigliati)" + }, + "shear-factor": { + "label": "Fattore di taglio", + "description": "Angolo di inclinazione del layout delle schede" + }, + "card-animation": { + "label": "Animazione schede", + "description": "Velocità delle transizioni delle schede" + }, + "background-color": { + "label": "Colore di sfondo", + "description": "Colore della sovrapposizione oscurata" + }, + "background-opacity": { + "label": "Opacità dello sfondo", + "description": "Opacità della sovrapposizione oscurata" + }, + "window-animation-speed": { + "label": "Velocità animazione finestra", + "description": "Velocità delle transizioni di apertura e chiusura" + }, + "animate-window": { + "label": "Animazione finestra", + "description": "Anima le transizioni di apertura e chiusura" + }, + "top-bar-height": { + "label": "Altezza barra superiore", + "description": "Altezza della barra degli strumenti" + } + }, + "shortcuts": { + "key-separator": "/", + "header": { + "navigation": "NAVIGAZIONE", + "actions": "AZIONI", + "filters": "FILTRI", + "view": "VISTA", + "layout": "LAYOUT" + }, + "label": { + "help-title": "Scorciatoie", + "navigate": "Naviga", + "jump": "Salta", + "shuffle": "Mescola", + "apply-quit": "Applica + Esci", + "apply": "Applica", + "quit": "Esci", + "filter-all": "Tutti", + "filter-images": "Immagini", + "filter-videos": "Video", + "filter-colors": "Filtro colore", + "top-bar": "Barra superiore", + "live-preview": "Anteprima dal vivo", + "center-height": "Altezza centrale", + "center-width": "Larghezza centrale", + "cards-shown": "Schede visibili", + "cards-spacing": "Spaziatura", + "cards-width": "Larghezza schede", + "save": "Salva impostazioni", + "hide": "Nascondi" + } + } +} diff --git a/noctalia/plugins/wallcards/i18n/ja.json b/noctalia/plugins/wallcards/i18n/ja.json new file mode 100644 index 0000000..02f9f64 --- /dev/null +++ b/noctalia/plugins/wallcards/i18n/ja.json @@ -0,0 +1,116 @@ +{ + "buttons": { + "all": "すべて", + "images": "画像", + "videos": "動画", + "shuffle": "シャッフル", + "live-preview": "ライブ", + "color-na": "✕" + }, + "widget": { + "tooltip": "Wallcardsを開く", + "generate-thumbs-message": "サムネイルを生成中…" + }, + "settings": { + "icon-color": { + "label": "アイコンの色", + "description": "バーウィジェットのアイコンの色" + }, + "live-preview": { + "label": "ライブプレビュー", + "description": "カードを閲覧しながら壁紙を適用" + }, + "default-filter": { + "label": "デフォルトフィルター", + "description": "起動時のファイルタイプフィルター" + }, + "hide-shortcuts": { + "label": "ショートカットを非表示", + "description": "キーボードショートカットパネルを非表示" + }, + "hide-top-bar": { + "label": "トップバーを非表示", + "description": "カード上部のツールバーを非表示" + }, + "center-card-width": { + "label": "中央カードの幅", + "description": "画面に対する中央カードの幅の割合" + }, + "card-height": { + "label": "カードの高さ", + "description": "壁紙カードの高さ" + }, + "strip-width": { + "label": "ストリップ幅", + "description": "サイドカードストリップの幅" + }, + "card-spacing": { + "label": "カード間隔", + "description": "カード間のスペース" + }, + "cards-shown": { + "label": "表示カード数", + "description": "表示するカードの数(奇数推奨)" + }, + "shear-factor": { + "label": "せん断係数", + "description": "カードレイアウトの傾斜角度" + }, + "card-animation": { + "label": "カードアニメーション", + "description": "カード遷移の速度" + }, + "background-color": { + "label": "背景色", + "description": "暗転オーバーレイの色" + }, + "background-opacity": { + "label": "背景の不透明度", + "description": "暗転オーバーレイの不透明度" + }, + "window-animation-speed": { + "label": "ウィンドウアニメーション速度", + "description": "開閉時の遷移速度" + }, + "animate-window": { + "label": "ウィンドウアニメーション", + "description": "開閉時の遷移をアニメーション化" + }, + "top-bar-height": { + "label": "トップバーの高さ", + "description": "ツールバーの高さ" + } + }, + "shortcuts": { + "key-separator": "/", + "header": { + "navigation": "ナビゲーション", + "actions": "アクション", + "filters": "フィルター", + "view": "表示", + "layout": "レイアウト" + }, + "label": { + "help-title": "ショートカット", + "navigate": "移動", + "jump": "ジャンプ", + "shuffle": "シャッフル", + "apply-quit": "適用して終了", + "apply": "適用", + "quit": "終了", + "filter-all": "すべて", + "filter-images": "画像", + "filter-videos": "動画", + "filter-colors": "カラーフィルター", + "top-bar": "トップバー切替", + "live-preview": "ライブプレビュー切替", + "center-height": "中央の高さ", + "center-width": "中央の幅", + "cards-shown": "表示カード数", + "cards-spacing": "間隔", + "cards-width": "カード幅", + "save": "設定を保存", + "hide": "非表示" + } + } +} diff --git a/noctalia/plugins/wallcards/i18n/ku.json b/noctalia/plugins/wallcards/i18n/ku.json new file mode 100644 index 0000000..6907416 --- /dev/null +++ b/noctalia/plugins/wallcards/i18n/ku.json @@ -0,0 +1,116 @@ +{ + "buttons": { + "all": "Hemû", + "images": "Wêne", + "videos": "Vîdeo", + "shuffle": "Tevlihev", + "live-preview": "Zindî", + "color-na": "✕" + }, + "widget": { + "tooltip": "Wallcards veke", + "generate-thumbs-message": "Wêneyên biçûk têne çêkirin…" + }, + "settings": { + "icon-color": { + "label": "Rengê îkonê", + "description": "Rengê îkona wîceta barê" + }, + "live-preview": { + "label": "Pêşdîtina zindî", + "description": "Dîwarê bi kar bîne dema ku kartên digerin" + }, + "default-filter": { + "label": "Parzûna standard", + "description": "Parzûna celebê pelê ya destpêkê dema vekirinê" + }, + "hide-shortcuts": { + "label": "Kurterêyan veşêre", + "description": "Panela kurterêyên klavyeyê veşêre" + }, + "hide-top-bar": { + "label": "Barê jorîn veşêre", + "description": "Amûrbarê li ser kartan veşêre" + }, + "center-card-width": { + "label": "Firehiya karta navendî", + "description": "Firehiya karta navendî wek sedî ya ekranê" + }, + "card-height": { + "label": "Bilindahiya kartê", + "description": "Bilindahiya kartên dîwarê" + }, + "strip-width": { + "label": "Firehiya şirîtê", + "description": "Firehiya şirîtên kartên alî" + }, + "card-spacing": { + "label": "Navbera kartan", + "description": "Valahiya di navbera kartan de" + }, + "cards-shown": { + "label": "Kartên xuya", + "description": "Hejmara kartên xuya (jimarên taq têne pêşniyar kirin)" + }, + "shear-factor": { + "label": "Faktora qutkirinê", + "description": "Goşeya meylkirina sêwirana kartan" + }, + "card-animation": { + "label": "Anîmasyona kartê", + "description": "Leza veguheztinên kartan" + }, + "background-color": { + "label": "Rengê paşperdê", + "description": "Rengê tebeqa tarîkirî" + }, + "background-opacity": { + "label": "Zelalbûna paşperdê", + "description": "Zelalbûna tebeqa tarîkirî" + }, + "window-animation-speed": { + "label": "Leza anîmasyona pencereyê", + "description": "Leza veguheztinên vekirî û girtî" + }, + "animate-window": { + "label": "Anîmasyona pencereyê", + "description": "Veguheztinên vekirî û girtî bianîme" + }, + "top-bar-height": { + "label": "Bilindahiya barê jorîn", + "description": "Bilindahiya amûrbarê" + } + }, + "shortcuts": { + "key-separator": "/", + "header": { + "navigation": "NAVÎGASYON", + "actions": "ÇALAKÎ", + "filters": "PARZÛN", + "view": "DÎTIN", + "layout": "SÊWIRAN" + }, + "label": { + "help-title": "Kurterê", + "navigate": "Navîgasyon", + "jump": "Bazdan", + "shuffle": "Tevlihev", + "apply-quit": "Bicîh bîne + Derkeve", + "apply": "Bicîh bîne", + "quit": "Derkeve", + "filter-all": "Hemû", + "filter-images": "Wêne", + "filter-videos": "Vîdeo", + "filter-colors": "Parzûna rengan", + "top-bar": "Barê jorîn", + "live-preview": "Pêşdîtina zindî", + "center-height": "Bilindahiya navendî", + "center-width": "Firehiya navendî", + "cards-shown": "Kartên xuya", + "cards-spacing": "Navber", + "cards-width": "Firehiya kartan", + "save": "Mîhengan tomar bike", + "hide": "Veşêre" + } + } +} diff --git a/noctalia/plugins/wallcards/i18n/nl.json b/noctalia/plugins/wallcards/i18n/nl.json new file mode 100644 index 0000000..c70e6d2 --- /dev/null +++ b/noctalia/plugins/wallcards/i18n/nl.json @@ -0,0 +1,116 @@ +{ + "buttons": { + "all": "Alles", + "images": "Afbeeldingen", + "videos": "Video's", + "shuffle": "Willekeurig", + "live-preview": "Live", + "color-na": "✕" + }, + "widget": { + "tooltip": "Wallcards openen", + "generate-thumbs-message": "Miniaturen genereren…" + }, + "settings": { + "icon-color": { + "label": "Pictogramkleur", + "description": "Kleur van het balkwidget-pictogram" + }, + "live-preview": { + "label": "Live voorbeeld", + "description": "Achtergrond toepassen tijdens het bladeren" + }, + "default-filter": { + "label": "Standaardfilter", + "description": "Initieel bestandstypefilter bij openen" + }, + "hide-shortcuts": { + "label": "Sneltoetsen verbergen", + "description": "Het sneltoetsenpaneel verbergen" + }, + "hide-top-bar": { + "label": "Bovenste balk verbergen", + "description": "De werkbalk boven de kaarten verbergen" + }, + "center-card-width": { + "label": "Breedte middelste kaart", + "description": "Breedte van de middelste kaart als percentage van het scherm" + }, + "card-height": { + "label": "Kaarthoogte", + "description": "Hoogte van de achtergrondkaarten" + }, + "strip-width": { + "label": "Stripbreedte", + "description": "Breedte van de zijstroken" + }, + "card-spacing": { + "label": "Kaartafstand", + "description": "Ruimte tussen kaarten" + }, + "cards-shown": { + "label": "Zichtbare kaarten", + "description": "Aantal zichtbare kaarten (oneven aantallen aanbevolen)" + }, + "shear-factor": { + "label": "Schuinfactor", + "description": "Hellingshoek van de kaartindeling" + }, + "card-animation": { + "label": "Kaartanimatie", + "description": "Snelheid van kaartovergangen" + }, + "background-color": { + "label": "Achtergrondkleur", + "description": "Kleur van de verduisterde overlay" + }, + "background-opacity": { + "label": "Achtergrond-dekking", + "description": "Dekking van de verduisterde overlay" + }, + "window-animation-speed": { + "label": "Vensteranimatiesnelheid", + "description": "Snelheid van open- en sluitovergangen" + }, + "animate-window": { + "label": "Vensteranimatie", + "description": "Open- en sluitovergangen animeren" + }, + "top-bar-height": { + "label": "Hoogte bovenste balk", + "description": "Hoogte van de werkbalk" + } + }, + "shortcuts": { + "key-separator": "/", + "header": { + "navigation": "NAVIGATIE", + "actions": "ACTIES", + "filters": "FILTERS", + "view": "WEERGAVE", + "layout": "INDELING" + }, + "label": { + "help-title": "Sneltoetsen", + "navigate": "Navigeren", + "jump": "Springen", + "shuffle": "Willekeurig", + "apply-quit": "Toepassen + Sluiten", + "apply": "Toepassen", + "quit": "Sluiten", + "filter-all": "Alles", + "filter-images": "Afbeeldingen", + "filter-videos": "Video's", + "filter-colors": "Kleurfilter", + "top-bar": "Bovenste balk", + "live-preview": "Live voorbeeld", + "center-height": "Middenhoogte", + "center-width": "Middenbreedte", + "cards-shown": "Zichtbare kaarten", + "cards-spacing": "Afstand", + "cards-width": "Kaartbreedte", + "save": "Instellingen opslaan", + "hide": "Verbergen" + } + } +} diff --git a/noctalia/plugins/wallcards/i18n/pl.json b/noctalia/plugins/wallcards/i18n/pl.json new file mode 100644 index 0000000..8817350 --- /dev/null +++ b/noctalia/plugins/wallcards/i18n/pl.json @@ -0,0 +1,116 @@ +{ + "buttons": { + "all": "Wszystkie", + "images": "Obrazy", + "videos": "Filmy", + "shuffle": "Losuj", + "live-preview": "Na żywo", + "color-na": "✕" + }, + "widget": { + "tooltip": "Otwórz Wallcards", + "generate-thumbs-message": "Generowanie miniatur…" + }, + "settings": { + "icon-color": { + "label": "Kolor ikony", + "description": "Kolor ikony widżetu paska" + }, + "live-preview": { + "label": "Podgląd na żywo", + "description": "Zastosuj tapetę podczas przeglądania kart" + }, + "default-filter": { + "label": "Domyślny filtr", + "description": "Początkowy filtr typu pliku przy otwieraniu" + }, + "hide-shortcuts": { + "label": "Ukryj skróty", + "description": "Ukryj panel skrótów klawiszowych" + }, + "hide-top-bar": { + "label": "Ukryj górny pasek", + "description": "Ukryj pasek narzędzi nad kartami" + }, + "center-card-width": { + "label": "Szerokość środkowej karty", + "description": "Szerokość środkowej karty jako procent ekranu" + }, + "card-height": { + "label": "Wysokość karty", + "description": "Wysokość kart tapet" + }, + "strip-width": { + "label": "Szerokość paska", + "description": "Szerokość bocznych pasków kart" + }, + "card-spacing": { + "label": "Odstęp kart", + "description": "Przerwa między kartami" + }, + "cards-shown": { + "label": "Widoczne karty", + "description": "Liczba widocznych kart (zalecane liczby nieparzyste)" + }, + "shear-factor": { + "label": "Współczynnik ścinania", + "description": "Kąt pochylenia układu kart" + }, + "card-animation": { + "label": "Animacja kart", + "description": "Szybkość przejść kart" + }, + "background-color": { + "label": "Kolor tła", + "description": "Kolor przyciemnionej nakładki" + }, + "background-opacity": { + "label": "Nieprzezroczystość tła", + "description": "Nieprzezroczystość przyciemnionej nakładki" + }, + "window-animation-speed": { + "label": "Szybkość animacji okna", + "description": "Szybkość przejść otwierania i zamykania" + }, + "animate-window": { + "label": "Animacja okna", + "description": "Animuj przejścia otwierania i zamykania" + }, + "top-bar-height": { + "label": "Wysokość górnego paska", + "description": "Wysokość paska narzędzi" + } + }, + "shortcuts": { + "key-separator": "/", + "header": { + "navigation": "NAWIGACJA", + "actions": "AKCJE", + "filters": "FILTRY", + "view": "WIDOK", + "layout": "UKŁAD" + }, + "label": { + "help-title": "Skróty", + "navigate": "Nawiguj", + "jump": "Skocz", + "shuffle": "Losuj", + "apply-quit": "Zastosuj + Zamknij", + "apply": "Zastosuj", + "quit": "Zamknij", + "filter-all": "Wszystkie", + "filter-images": "Obrazy", + "filter-videos": "Filmy", + "filter-colors": "Filtr kolorów", + "top-bar": "Górny pasek", + "live-preview": "Podgląd na żywo", + "center-height": "Wysokość środka", + "center-width": "Szerokość środka", + "cards-shown": "Widoczne karty", + "cards-spacing": "Odstęp", + "cards-width": "Szerokość kart", + "save": "Zapisz ustawienia", + "hide": "Ukryj" + } + } +} diff --git a/noctalia/plugins/wallcards/i18n/pt.json b/noctalia/plugins/wallcards/i18n/pt.json new file mode 100644 index 0000000..a47abc6 --- /dev/null +++ b/noctalia/plugins/wallcards/i18n/pt.json @@ -0,0 +1,116 @@ +{ + "buttons": { + "all": "Todos", + "images": "Imagens", + "videos": "Vídeos", + "shuffle": "Aleatório", + "live-preview": "Ao vivo", + "color-na": "✕" + }, + "widget": { + "tooltip": "Abrir Wallcards", + "generate-thumbs-message": "Gerando miniaturas…" + }, + "settings": { + "icon-color": { + "label": "Cor do ícone", + "description": "Cor do ícone do widget da barra" + }, + "live-preview": { + "label": "Pré-visualização ao vivo", + "description": "Aplicar papel de parede enquanto navega pelos cartões" + }, + "default-filter": { + "label": "Filtro padrão", + "description": "Filtro de tipo de arquivo inicial ao abrir" + }, + "hide-shortcuts": { + "label": "Ocultar atalhos", + "description": "Ocultar o painel de atalhos de teclado" + }, + "hide-top-bar": { + "label": "Ocultar barra superior", + "description": "Ocultar a barra de ferramentas acima dos cartões" + }, + "center-card-width": { + "label": "Largura do cartão central", + "description": "Largura do cartão central como porcentagem da tela" + }, + "card-height": { + "label": "Altura do cartão", + "description": "Altura dos cartões de papel de parede" + }, + "strip-width": { + "label": "Largura da faixa", + "description": "Largura das faixas laterais dos cartões" + }, + "card-spacing": { + "label": "Espaçamento dos cartões", + "description": "Espaço entre os cartões" + }, + "cards-shown": { + "label": "Cartões visíveis", + "description": "Número de cartões visíveis (números ímpares recomendados)" + }, + "shear-factor": { + "label": "Fator de cisalhamento", + "description": "Ângulo de inclinação do layout dos cartões" + }, + "card-animation": { + "label": "Animação dos cartões", + "description": "Velocidade das transições dos cartões" + }, + "background-color": { + "label": "Cor de fundo", + "description": "Cor da sobreposição escurecida" + }, + "background-opacity": { + "label": "Opacidade do fundo", + "description": "Opacidade da sobreposição escurecida" + }, + "window-animation-speed": { + "label": "Velocidade da animação da janela", + "description": "Velocidade das transições de abrir e fechar" + }, + "animate-window": { + "label": "Animação da janela", + "description": "Animar as transições de abrir e fechar" + }, + "top-bar-height": { + "label": "Altura da barra superior", + "description": "Altura da barra de ferramentas" + } + }, + "shortcuts": { + "key-separator": "/", + "header": { + "navigation": "NAVEGAÇÃO", + "actions": "AÇÕES", + "filters": "FILTROS", + "view": "VISUALIZAÇÃO", + "layout": "LAYOUT" + }, + "label": { + "help-title": "Atalhos", + "navigate": "Navegar", + "jump": "Saltar", + "shuffle": "Aleatório", + "apply-quit": "Aplicar + Fechar", + "apply": "Aplicar", + "quit": "Fechar", + "filter-all": "Todos", + "filter-images": "Imagens", + "filter-videos": "Vídeos", + "filter-colors": "Filtro de cor", + "top-bar": "Barra superior", + "live-preview": "Pré-visualização", + "center-height": "Altura central", + "center-width": "Largura central", + "cards-shown": "Cartões visíveis", + "cards-spacing": "Espaçamento", + "cards-width": "Largura dos cartões", + "save": "Salvar configurações", + "hide": "Ocultar" + } + } +} diff --git a/noctalia/plugins/wallcards/i18n/ru.json b/noctalia/plugins/wallcards/i18n/ru.json new file mode 100644 index 0000000..74b58dd --- /dev/null +++ b/noctalia/plugins/wallcards/i18n/ru.json @@ -0,0 +1,116 @@ +{ + "buttons": { + "all": "Все", + "images": "Изображения", + "videos": "Видео", + "shuffle": "Перемешать", + "live-preview": "Живой", + "color-na": "✕" + }, + "widget": { + "tooltip": "Открыть Wallcards", + "generate-thumbs-message": "Создание миниатюр…" + }, + "settings": { + "icon-color": { + "label": "Цвет значка", + "description": "Цвет значка виджета панели" + }, + "live-preview": { + "label": "Предпросмотр", + "description": "Применять обои при просмотре карточек" + }, + "default-filter": { + "label": "Фильтр по умолчанию", + "description": "Начальный фильтр типа файлов при открытии" + }, + "hide-shortcuts": { + "label": "Скрыть горячие клавиши", + "description": "Скрыть панель горячих клавиш" + }, + "hide-top-bar": { + "label": "Скрыть верхнюю панель", + "description": "Скрыть панель инструментов над карточками" + }, + "center-card-width": { + "label": "Ширина центральной карточки", + "description": "Ширина центральной карточки в процентах от экрана" + }, + "card-height": { + "label": "Высота карточки", + "description": "Высота карточек обоев" + }, + "strip-width": { + "label": "Ширина полосы", + "description": "Ширина боковых полос карточек" + }, + "card-spacing": { + "label": "Расстояние между карточками", + "description": "Зазор между карточками" + }, + "cards-shown": { + "label": "Видимые карточки", + "description": "Количество видимых карточек (рекомендуются нечётные числа)" + }, + "shear-factor": { + "label": "Коэффициент сдвига", + "description": "Угол наклона расположения карточек" + }, + "card-animation": { + "label": "Анимация карточек", + "description": "Скорость переходов карточек" + }, + "background-color": { + "label": "Цвет фона", + "description": "Цвет затемнённого наложения" + }, + "background-opacity": { + "label": "Прозрачность фона", + "description": "Прозрачность затемнённого наложения" + }, + "window-animation-speed": { + "label": "Скорость анимации окна", + "description": "Скорость переходов открытия и закрытия" + }, + "animate-window": { + "label": "Анимация окна", + "description": "Анимировать переходы открытия и закрытия" + }, + "top-bar-height": { + "label": "Высота верхней панели", + "description": "Высота панели инструментов" + } + }, + "shortcuts": { + "key-separator": "/", + "header": { + "navigation": "НАВИГАЦИЯ", + "actions": "ДЕЙСТВИЯ", + "filters": "ФИЛЬТРЫ", + "view": "ВИД", + "layout": "КОМПОНОВКА" + }, + "label": { + "help-title": "Горячие клавиши", + "navigate": "Навигация", + "jump": "Перейти", + "shuffle": "Перемешать", + "apply-quit": "Применить + Выйти", + "apply": "Применить", + "quit": "Выйти", + "filter-all": "Все", + "filter-images": "Изображения", + "filter-videos": "Видео", + "filter-colors": "Цветовой фильтр", + "top-bar": "Верхняя панель", + "live-preview": "Предпросмотр", + "center-height": "Высота центра", + "center-width": "Ширина центра", + "cards-shown": "Видимые карточки", + "cards-spacing": "Расстояние", + "cards-width": "Ширина карточек", + "save": "Сохранить настройки", + "hide": "Скрыть" + } + } +} diff --git a/noctalia/plugins/wallcards/i18n/tr.json b/noctalia/plugins/wallcards/i18n/tr.json new file mode 100644 index 0000000..1bfefc6 --- /dev/null +++ b/noctalia/plugins/wallcards/i18n/tr.json @@ -0,0 +1,116 @@ +{ + "buttons": { + "all": "Tümü", + "images": "Resimler", + "videos": "Videolar", + "shuffle": "Karıştır", + "live-preview": "Canlı", + "color-na": "✕" + }, + "widget": { + "tooltip": "Wallcards'ı aç", + "generate-thumbs-message": "Küçük resimler oluşturuluyor…" + }, + "settings": { + "icon-color": { + "label": "Simge rengi", + "description": "Çubuk widget simgesinin rengi" + }, + "live-preview": { + "label": "Canlı önizleme", + "description": "Kartlara göz atarken duvar kağıdını uygula" + }, + "default-filter": { + "label": "Varsayılan filtre", + "description": "Açılışta başlangıç dosya türü filtresi" + }, + "hide-shortcuts": { + "label": "Kısayolları gizle", + "description": "Klavye kısayolları panelini gizle" + }, + "hide-top-bar": { + "label": "Üst çubuğu gizle", + "description": "Kartların üzerindeki araç çubuğunu gizle" + }, + "center-card-width": { + "label": "Orta kart genişliği", + "description": "Orta kartın ekran yüzdesi olarak genişliği" + }, + "card-height": { + "label": "Kart yüksekliği", + "description": "Duvar kağıdı kartlarının yüksekliği" + }, + "strip-width": { + "label": "Şerit genişliği", + "description": "Yan kart şeritlerinin genişliği" + }, + "card-spacing": { + "label": "Kart aralığı", + "description": "Kartlar arasındaki boşluk" + }, + "cards-shown": { + "label": "Görünen kartlar", + "description": "Görünür kart sayısı (tek sayılar önerilir)" + }, + "shear-factor": { + "label": "Kayma faktörü", + "description": "Kart düzeninin eğim açısı" + }, + "card-animation": { + "label": "Kart animasyonu", + "description": "Kart geçişlerinin hızı" + }, + "background-color": { + "label": "Arka plan rengi", + "description": "Karartılmış katmanın rengi" + }, + "background-opacity": { + "label": "Arka plan opaklığı", + "description": "Karartılmış katmanın opaklığı" + }, + "window-animation-speed": { + "label": "Pencere animasyon hızı", + "description": "Açma ve kapama geçişlerinin hızı" + }, + "animate-window": { + "label": "Pencere animasyonu", + "description": "Açma ve kapama geçişlerini animasyonla göster" + }, + "top-bar-height": { + "label": "Üst çubuk yüksekliği", + "description": "Araç çubuğunun yüksekliği" + } + }, + "shortcuts": { + "key-separator": "/", + "header": { + "navigation": "GEZİNME", + "actions": "EYLEMLER", + "filters": "FİLTRELER", + "view": "GÖRÜNÜM", + "layout": "DÜZEN" + }, + "label": { + "help-title": "Kısayollar", + "navigate": "Gezin", + "jump": "Atla", + "shuffle": "Karıştır", + "apply-quit": "Uygula + Kapat", + "apply": "Uygula", + "quit": "Kapat", + "filter-all": "Tümü", + "filter-images": "Resimler", + "filter-videos": "Videolar", + "filter-colors": "Renk filtresi", + "top-bar": "Üst çubuk", + "live-preview": "Canlı önizleme", + "center-height": "Orta yükseklik", + "center-width": "Orta genişlik", + "cards-shown": "Görünen kartlar", + "cards-spacing": "Aralık", + "cards-width": "Kart genişliği", + "save": "Ayarları kaydet", + "hide": "Gizle" + } + } +} diff --git a/noctalia/plugins/wallcards/i18n/uk-UA.json b/noctalia/plugins/wallcards/i18n/uk-UA.json new file mode 100644 index 0000000..7e2bf77 --- /dev/null +++ b/noctalia/plugins/wallcards/i18n/uk-UA.json @@ -0,0 +1,116 @@ +{ + "buttons": { + "all": "Усі", + "images": "Зображення", + "videos": "Відео", + "shuffle": "Перемішати", + "live-preview": "Наживо", + "color-na": "✕" + }, + "widget": { + "tooltip": "Відкрити Wallcards", + "generate-thumbs-message": "Створення мініатюр…" + }, + "settings": { + "icon-color": { + "label": "Колір значка", + "description": "Колір значка віджета панелі" + }, + "live-preview": { + "label": "Попередній перегляд", + "description": "Застосовувати шпалери під час перегляду карток" + }, + "default-filter": { + "label": "Фільтр за замовчуванням", + "description": "Початковий фільтр типу файлів при відкритті" + }, + "hide-shortcuts": { + "label": "Сховати гарячі клавіші", + "description": "Сховати панель гарячих клавіш" + }, + "hide-top-bar": { + "label": "Сховати верхню панель", + "description": "Сховати панель інструментів над картками" + }, + "center-card-width": { + "label": "Ширина центральної картки", + "description": "Ширина центральної картки у відсотках екрана" + }, + "card-height": { + "label": "Висота картки", + "description": "Висота карток шпалер" + }, + "strip-width": { + "label": "Ширина смуги", + "description": "Ширина бічних смуг карток" + }, + "card-spacing": { + "label": "Відстань між картками", + "description": "Проміжок між картками" + }, + "cards-shown": { + "label": "Видимі картки", + "description": "Кількість видимих карток (рекомендуються непарні числа)" + }, + "shear-factor": { + "label": "Коефіцієнт зсуву", + "description": "Кут нахилу розташування карток" + }, + "card-animation": { + "label": "Анімація карток", + "description": "Швидкість переходів карток" + }, + "background-color": { + "label": "Колір фону", + "description": "Колір затемненого накладання" + }, + "background-opacity": { + "label": "Прозорість фону", + "description": "Прозорість затемненого накладання" + }, + "window-animation-speed": { + "label": "Швидкість анімації вікна", + "description": "Швидкість переходів відкриття та закриття" + }, + "animate-window": { + "label": "Анімація вікна", + "description": "Анімувати переходи відкриття та закриття" + }, + "top-bar-height": { + "label": "Висота верхньої панелі", + "description": "Висота панелі інструментів" + } + }, + "shortcuts": { + "key-separator": "/", + "header": { + "navigation": "НАВІГАЦІЯ", + "actions": "ДІЇ", + "filters": "ФІЛЬТРИ", + "view": "ВИГЛЯД", + "layout": "КОМПОНУВАННЯ" + }, + "label": { + "help-title": "Гарячі клавіші", + "navigate": "Навігація", + "jump": "Перейти", + "shuffle": "Перемішати", + "apply-quit": "Застосувати + Вийти", + "apply": "Застосувати", + "quit": "Вийти", + "filter-all": "Усі", + "filter-images": "Зображення", + "filter-videos": "Відео", + "filter-colors": "Кольоровий фільтр", + "top-bar": "Верхня панель", + "live-preview": "Попередній перегляд", + "center-height": "Висота центру", + "center-width": "Ширина центру", + "cards-shown": "Видимі картки", + "cards-spacing": "Відстань", + "cards-width": "Ширина карток", + "save": "Зберегти налаштування", + "hide": "Сховати" + } + } +} diff --git a/noctalia/plugins/wallcards/i18n/vi.json b/noctalia/plugins/wallcards/i18n/vi.json new file mode 100644 index 0000000..949b110 --- /dev/null +++ b/noctalia/plugins/wallcards/i18n/vi.json @@ -0,0 +1,116 @@ +{ + "buttons": { + "all": "Tất cả", + "images": "Hình ảnh", + "videos": "Video", + "shuffle": "Xáo trộn", + "live-preview": "Trực tiếp", + "color-na": "✕" + }, + "widget": { + "tooltip": "Mở Wallcards", + "generate-thumbs-message": "Đang tạo hình thu nhỏ…" + }, + "settings": { + "icon-color": { + "label": "Màu biểu tượng", + "description": "Màu của biểu tượng widget thanh" + }, + "live-preview": { + "label": "Xem trước trực tiếp", + "description": "Áp dụng hình nền khi duyệt thẻ" + }, + "default-filter": { + "label": "Bộ lọc mặc định", + "description": "Bộ lọc loại tệp ban đầu khi mở" + }, + "hide-shortcuts": { + "label": "Ẩn phím tắt", + "description": "Ẩn bảng phím tắt bàn phím" + }, + "hide-top-bar": { + "label": "Ẩn thanh trên", + "description": "Ẩn thanh công cụ phía trên các thẻ" + }, + "center-card-width": { + "label": "Chiều rộng thẻ giữa", + "description": "Chiều rộng thẻ giữa tính theo phần trăm màn hình" + }, + "card-height": { + "label": "Chiều cao thẻ", + "description": "Chiều cao của các thẻ hình nền" + }, + "strip-width": { + "label": "Chiều rộng dải", + "description": "Chiều rộng các dải thẻ bên" + }, + "card-spacing": { + "label": "Khoảng cách thẻ", + "description": "Khoảng cách giữa các thẻ" + }, + "cards-shown": { + "label": "Số thẻ hiển thị", + "description": "Số lượng thẻ hiển thị (khuyến nghị số lẻ)" + }, + "shear-factor": { + "label": "Hệ số nghiêng", + "description": "Góc nghiêng của bố cục thẻ" + }, + "card-animation": { + "label": "Hoạt ảnh thẻ", + "description": "Tốc độ chuyển đổi thẻ" + }, + "background-color": { + "label": "Màu nền", + "description": "Màu của lớp phủ tối" + }, + "background-opacity": { + "label": "Độ mờ nền", + "description": "Độ mờ của lớp phủ tối" + }, + "window-animation-speed": { + "label": "Tốc độ hoạt ảnh cửa sổ", + "description": "Tốc độ chuyển đổi mở và đóng" + }, + "animate-window": { + "label": "Hoạt ảnh cửa sổ", + "description": "Tạo hoạt ảnh cho chuyển đổi mở và đóng" + }, + "top-bar-height": { + "label": "Chiều cao thanh trên", + "description": "Chiều cao của thanh công cụ" + } + }, + "shortcuts": { + "key-separator": "/", + "header": { + "navigation": "ĐIỀU HƯỚNG", + "actions": "HÀNH ĐỘNG", + "filters": "BỘ LỌC", + "view": "HIỂN THỊ", + "layout": "BỐ CỤC" + }, + "label": { + "help-title": "Phím tắt", + "navigate": "Di chuyển", + "jump": "Nhảy", + "shuffle": "Xáo trộn", + "apply-quit": "Áp dụng + Thoát", + "apply": "Áp dụng", + "quit": "Thoát", + "filter-all": "Tất cả", + "filter-images": "Hình ảnh", + "filter-videos": "Video", + "filter-colors": "Lọc màu", + "top-bar": "Thanh trên", + "live-preview": "Xem trước trực tiếp", + "center-height": "Chiều cao giữa", + "center-width": "Chiều rộng giữa", + "cards-shown": "Số thẻ hiển thị", + "cards-spacing": "Khoảng cách", + "cards-width": "Chiều rộng thẻ", + "save": "Lưu cài đặt", + "hide": "Ẩn" + } + } +} diff --git a/noctalia/plugins/wallcards/i18n/zh-CN.json b/noctalia/plugins/wallcards/i18n/zh-CN.json new file mode 100644 index 0000000..bbf436f --- /dev/null +++ b/noctalia/plugins/wallcards/i18n/zh-CN.json @@ -0,0 +1,116 @@ +{ + "buttons": { + "all": "全部", + "images": "图片", + "videos": "视频", + "shuffle": "随机", + "live-preview": "实时", + "color-na": "✕" + }, + "widget": { + "tooltip": "打开 Wallcards", + "generate-thumbs-message": "正在生成缩略图…" + }, + "settings": { + "icon-color": { + "label": "图标颜色", + "description": "栏小部件图标的颜色" + }, + "live-preview": { + "label": "实时预览", + "description": "浏览卡片时应用壁纸" + }, + "default-filter": { + "label": "默认筛选", + "description": "打开时的初始文件类型筛选" + }, + "hide-shortcuts": { + "label": "隐藏快捷键", + "description": "隐藏键盘快捷键面板" + }, + "hide-top-bar": { + "label": "隐藏顶栏", + "description": "隐藏卡片上方的工具栏" + }, + "center-card-width": { + "label": "中央卡片宽度", + "description": "中央卡片占屏幕的宽度百分比" + }, + "card-height": { + "label": "卡片高度", + "description": "壁纸卡片的高度" + }, + "strip-width": { + "label": "条带宽度", + "description": "侧面卡片条带的宽度" + }, + "card-spacing": { + "label": "卡片间距", + "description": "卡片之间的间隔" + }, + "cards-shown": { + "label": "显示卡片数", + "description": "可见卡片数量(建议使用奇数)" + }, + "shear-factor": { + "label": "剪切系数", + "description": "卡片布局的倾斜角度" + }, + "card-animation": { + "label": "卡片动画", + "description": "卡片过渡的速度" + }, + "background-color": { + "label": "背景颜色", + "description": "暗化覆盖层的颜色" + }, + "background-opacity": { + "label": "背景不透明度", + "description": "暗化覆盖层的不透明度" + }, + "window-animation-speed": { + "label": "窗口动画速度", + "description": "打开和关闭过渡的速度" + }, + "animate-window": { + "label": "窗口动画", + "description": "为打开和关闭过渡添加动画" + }, + "top-bar-height": { + "label": "顶栏高度", + "description": "工具栏的高度" + } + }, + "shortcuts": { + "key-separator": "/", + "header": { + "navigation": "导航", + "actions": "操作", + "filters": "筛选", + "view": "视图", + "layout": "布局" + }, + "label": { + "help-title": "快捷键", + "navigate": "导航", + "jump": "跳转", + "shuffle": "随机", + "apply-quit": "应用并退出", + "apply": "应用", + "quit": "退出", + "filter-all": "全部", + "filter-images": "图片", + "filter-videos": "视频", + "filter-colors": "颜色筛选", + "top-bar": "顶栏", + "live-preview": "实时预览", + "center-height": "中央高度", + "center-width": "中央宽度", + "cards-shown": "显示卡片数", + "cards-spacing": "间距", + "cards-width": "卡片宽度", + "save": "保存设置", + "hide": "隐藏" + } + } +} diff --git a/noctalia/plugins/wallcards/manifest.json b/noctalia/plugins/wallcards/manifest.json new file mode 100644 index 0000000..6bfc193 --- /dev/null +++ b/noctalia/plugins/wallcards/manifest.json @@ -0,0 +1,41 @@ +{ + "id": "wallcards", + "name": "Wallcards", + "version": "0.1.1", + "minNoctaliaVersion": "4.7.0", + "author": "tonigineer", + "license": "MIT", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "description": "A `lively` wallpaper selector.", + "tags": ["Desktop", "Theming"], + "entryPoints": { + "main": "Main.qml", + "barWidget": "BarWidget.qml", + "settings": "Settings.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "defaultSettings": { + "animation_cards_duration": 750, + "animation_window_duration": 500, + "animate_window": true, + "background_color": null, + "background_opacity": null, + "card_height": 350, + "card_spacing": 6, + "card_strip_width": 90, + "cards_shown": 11, + "center_width_ratio": 0.33, + "image_filter": ["png", "jpg", "jpeg"], + "video_filter": ["mp4", "avi"], + "hide_help": false, + "hide_top_bar": false, + "live_preview": false, + "selected_filter": "images", + "shear_factor": -0.25, + "top_bar_height": 35 + } + } +} diff --git a/noctalia/plugins/wallcards/preview.png b/noctalia/plugins/wallcards/preview.png new file mode 100644 index 0000000..219ef50 Binary files /dev/null and b/noctalia/plugins/wallcards/preview.png differ diff --git a/noctalia/plugins/wallcards/src/Card.qml b/noctalia/plugins/wallcards/src/Card.qml new file mode 100644 index 0000000..4accb06 --- /dev/null +++ b/noctalia/plugins/wallcards/src/Card.qml @@ -0,0 +1,153 @@ +import qs.Commons +import qs.Widgets +import QtQuick +import QtMultimedia +import Qt5Compat.GraphicalEffects + +Item { + id: cardContent + + required property int animationDuration + required property real centerWidth + required property string filePath + required property bool isCenter + required property bool isVideo + required property real radius + required property string thumbnailPath + + anchors.fill: parent + clip: true + + Item { + id: imgComposite + + height: cardContent.height + visible: false + width: cardContent.centerWidth + x: (cardContent.width - cardContent.centerWidth) / 2 + + Image { + id: img + + anchors.fill: parent + // asynchronous: true // TODO: flickering when going backwards + cache: true + fillMode: Image.PreserveAspectCrop + smooth: true + source: cardContent.thumbnailPath ? "file://" + cardContent.thumbnailPath : "" + sourceSize.height: parent.height + sourceSize.width: cardContent.centerWidth + } + } + + Rectangle { + id: border + + anchors.fill: parent + border.color: isCenter ? Color.mOutline : Qt.alpha(Color.mOutlineVariant, 0.5) + border.width: Style.borderS + color: "transparent" + opacity: 0.75 + radius: cardContent.radius + z: 20 + } + + Rectangle { + id: mask + + anchors.fill: parent + radius: cardContent.radius + visible: false + } + + OpacityMask { + anchors.fill: parent + maskSource: mask + + source: ShaderEffectSource { + sourceItem: imgComposite + sourceRect: Qt.rect(-imgComposite.x, 0, cardContent.width, cardContent.height) + } + } + + Loader { + id: videoLoader + + property bool shouldLoad: false + property string videoPath: cardContent.isCenter && cardContent.isVideo ? cardContent.filePath : "" + + active: shouldLoad && videoPath !== "" + anchors.fill: parent + z: 5 + + sourceComponent: Component { + Item { + id: videoContainer + + anchors.fill: parent + layer.enabled: true + opacity: 0 + + layer.effect: OpacityMask { + maskSource: Rectangle { + height: videoContainer.height + radius: cardContent.radius + width: videoContainer.width + } + } + + MediaPlayer { + id: mediaPlayer + + loops: MediaPlayer.Infinite + source: "file://" + videoLoader.videoPath + videoOutput: videoOutput + + audioOutput: AudioOutput { + volume: 0 + } + + Component.onCompleted: play() + onPlayingChanged: function () { + if (mediaPlayer.playing) + videoFadeIn.start(); + } + } + + VideoOutput { + id: videoOutput + + anchors.fill: parent + fillMode: VideoOutput.PreserveAspectCrop + } + + NumberAnimation { + id: videoFadeIn + + duration: cardContent.animationDuration + easing.type: Easing.OutCubic + from: 0 + property: "opacity" + target: videoContainer + to: 1 + } + } + } + + onVideoPathChanged: { + shouldLoad = false; + if (videoPath !== "") + videoDelayTimer.restart(); + else + videoDelayTimer.stop(); + } + + Timer { + id: videoDelayTimer + + interval: cardContent.animationDuration + + onTriggered: videoLoader.shouldLoad = true + } + } +} diff --git a/noctalia/plugins/wallcards/src/CardDeck.qml b/noctalia/plugins/wallcards/src/CardDeck.qml new file mode 100644 index 0000000..8d7be8d --- /dev/null +++ b/noctalia/plugins/wallcards/src/CardDeck.qml @@ -0,0 +1,162 @@ +import qs.Commons +import qs.Widgets +import QtQuick + +Item { + id: cardDeck + + required property int animationDuration + property real animationIndex: 0 + required property int cardRadius + required property int cardSpacing + required property int cardStripWidth + required property int cardsShown + property real centerWidth: parent.width * centerWidthRatio + required property real centerWidthRatio + property real centerX: width / 2 - centerWidth / 2 + property int currentIndex: 0 + required property int filteredCount + required property var filteredModel + property int halfVisible: Math.floor(visibleCount / 2) + property real runningIndex: 0 + required property real shearFactor + property int sideCount: Math.floor(cardsShown / 2) - 1 + property int visibleCount: cardsShown + + signal applyRequested(int index) + + function navigateTo(idx) { + var maxLead = Math.max(1, halfVisible - 1); + if (Math.abs(runningIndex - animationIndex) >= maxLead) + return; + + var newIdx = wrappedIndex(idx); + var diff = 0; + + if (filteredCount > 0) { + diff = newIdx - currentIndex; + var half = filteredCount / 2; + if (diff > half) + diff -= filteredCount; + else if (diff < -half) + diff += filteredCount; + } + + runningIndex += diff; + animationIndex = runningIndex; + currentIndex = newIdx; + } + function randomJump() { + var rnd = Math.floor(Math.random() * filteredCount); + if (rnd === currentIndex) + rnd = (rnd + 1) % filteredCount; + navigateTo(rnd); + } + function slotToWidth(slot) { + var t = Math.min(Math.abs(slot), 1); + return centerWidth + (cardStripWidth - centerWidth) * t; + } + function slotToX(slot) { + var rightEdge = centerX + centerWidth + cardSpacing; + var leftEdge = centerX - cardSpacing - cardStripWidth; + + if (slot >= 0 && slot <= 1) + return centerX + (rightEdge - centerX) * slot; + if (slot > 1) + return rightEdge + (slot - 1) * (cardStripWidth + cardSpacing); + if (slot >= -1 && slot < 0) + return centerX + (leftEdge - centerX) * (-slot); + if (slot < -1) + return leftEdge + (slot + 1) * (cardStripWidth + cardSpacing); + return 0; + } + function wrappedIndex(idx) { + return ((idx % filteredCount) + filteredCount) % filteredCount; + } + + function jumpTo(idx) { + animBehavior.enabled = false; + runningIndex = idx; + animationIndex = idx; + currentIndex = idx; + reenableTimer.restart(); + } + + Timer { + id: reenableTimer + + interval: 0 + + onTriggered: animBehavior.enabled = true + } + + width: (cardsShown - 3) * cardStripWidth + (cardsShown - 3) * cardSpacing + centerWidth + + Behavior on animationIndex { + id: animBehavior + + NumberAnimation { + duration: cardDeck.animationDuration + easing.type: Easing.OutCubic + } + } + transform: Matrix4x4 { + property real s: cardDeck.shearFactor + + matrix: Qt.matrix4x4(1, s, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) + } + + Repeater { + model: cardDeck.filteredCount > 0 ? cardDeck.visibleCount : 0 + + delegate: Rectangle { + id: card + + property real fractionalSlot: offset + (cardDeck.runningIndex - cardDeck.animationIndex) + property bool isCenter: offset === 0 + property int modelIndex: cardDeck.wrappedIndex(Math.round(cardDeck.runningIndex) + offset) + property int offset: index - cardDeck.halfVisible + + signal applyRequested + + border.color: isCenter ? Color.mOutline : Color.mSurface + border.width: isCenter ? Style.borderM : Style.borderS + color: "transparent" + height: cardDeck.height + opacity: Math.max(0, Math.min(1, cardDeck.halfVisible - Math.abs(fractionalSlot))) + radius: cardDeck.cardRadius + visible: (x + width) > 0 && x < cardDeck.width + width: cardDeck.slotToWidth(fractionalSlot) + x: cardDeck.slotToX(fractionalSlot) + y: 0 + z: isCenter ? 100 : cardDeck.visibleCount - Math.abs(offset) + + Card { + animationDuration: cardDeck.animationDuration + centerWidth: cardDeck.centerWidth + filePath: cardDeck.filteredModel[card.modelIndex]?.filePath ?? "" + isCenter: card.isCenter + isVideo: cardDeck.filteredModel[card.modelIndex]?.isVideo ?? false + radius: cardDeck.cardRadius + thumbnailPath: cardDeck.filteredModel[card.modelIndex]?.thumbnail ?? "" + } + MouseArea { + anchors.fill: parent + cursorShape: card.isCenter ? Qt.PointingHandCursor : Qt.ArrowCursor + + onClicked: { + if (card.isCenter) + cardDeck.applyRequested(card.modelIndex); + } + + // + onWheel: function (wheel) { + if (wheel.angleDelta.y > 0) + cardDeck.navigateTo(cardDeck.currentIndex - 1); + else if (wheel.angleDelta.y < 0) + cardDeck.navigateTo(cardDeck.currentIndex + 1); + } + } + } + } +} diff --git a/noctalia/plugins/wallcards/src/LoadingBar.qml b/noctalia/plugins/wallcards/src/LoadingBar.qml new file mode 100644 index 0000000..427ac59 --- /dev/null +++ b/noctalia/plugins/wallcards/src/LoadingBar.qml @@ -0,0 +1,101 @@ +import qs.Commons +import qs.Widgets +import QtQuick + +Rectangle { + id: loadingBar + + property bool loading: pending > 0 + required property int pending + property real progress: total > 0 ? 1 - (pending / total) : 0 + required property int total + + color: Qt.alpha(Color.mSurface, 0.9) + height: upperRow.height + Style.margin2S + progressBar.height + radius: Style.radiusXS + visible: loading + width: upperRow.width + Style.margin2L + + Behavior on opacity { + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + } + } + + Row { + id: upperRow + + anchors.centerIn: parent + anchors.verticalCenterOffset: -progressBar.height / 2 + + Item { + anchors.verticalCenter: parent.verticalCenter + + Repeater { + model: 3 + + Rectangle { + property real angle: index * (2 * Math.PI / 3) + + color: Color.mPrimary + height: Style.marginXS + radius: Style.marginXXS + width: Style.marginXS + x: Style.marginS + Style.marginS * Math.cos(angle + spinAnimation.value) + y: Style.marginS + Style.marginS * Math.sin(angle + spinAnimation.value) + + NumberAnimation on opacity { + duration: 600 + from: 0.3 + loops: Animation.Infinite + running: loadingBar.loading + to: 1 + } + } + } + NumberAnimation { + id: spinAnimation + + property real value: 0 + + duration: 1200 + from: 0 + loops: Animation.Infinite + property: "value" + running: loadingBar.loading + target: spinAnimation + to: 2 * Math.PI + } + } + + NText { + text: `${root.pluginApi?.tr("widget.generate-thumbs-message")} ${String(Math.max(loadingBar.total - loadingBar.pending, 0)).padStart(String(loadingBar.total).length, " ")} / ${loadingBar.total}` + } + } + + Rectangle { + id: progressBar + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + color: Qt.alpha(Color.mOnSurface, 0.1) + height: Style.marginS + radius: loadingBar.radius + + Rectangle { + color: Color.mPrimary + height: parent.height + radius: parent.radius + width: parent.width * loadingBar.progress + + Behavior on width { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + } + } +} diff --git a/noctalia/plugins/wallcards/src/ShortcutHint.qml b/noctalia/plugins/wallcards/src/ShortcutHint.qml new file mode 100644 index 0000000..20f00e4 --- /dev/null +++ b/noctalia/plugins/wallcards/src/ShortcutHint.qml @@ -0,0 +1,75 @@ +import QtQuick +import qs.Commons +import qs.Widgets + +Row { + id: hint + + required property string keys + required property string label + + spacing: Style.marginXS + + Row { + anchors.verticalCenter: parent.verticalCenter + spacing: Style.marginXXS + + Repeater { + model: hint.keys.split(" / ") + + Row { + required property int index + required property var modelData + + anchors.verticalCenter: parent.verticalCenter + spacing: Style.marginXXS + + // Separator between keys + NText { + anchors.verticalCenter: parent.verticalCenter + color: Qt.alpha(Color.mOnSurface, 0.3) + font.pointSize: Style.fontSizeXXS + text: root.pluginApi?.tr("shortcuts.key-separator") + visible: index > 0 + } + + // Keycap + Rectangle { + anchors.verticalCenter: parent.verticalCenter + border.color: Qt.alpha(Color.mOnSurface, 0.2) + border.width: Style.borderS + color: Qt.alpha(Color.mOnSurface, 0.08) + height: Style.marginL + 2 + radius: Style.radiusXS + width: Math.max(Style.marginL + 2, keycapText.width + Style.marginS) + + Rectangle { + anchors.bottomMargin: 2 + anchors.fill: parent + border.color: Qt.alpha(Color.mOnSurface, 0.15) + border.width: Style.borderS + color: Qt.alpha(Color.mOnSurface, 0.05) + radius: Style.radiusXS + + NText { + id: keycapText + + anchors.centerIn: parent + color: Qt.alpha(Color.mOnSurface, 0.6) + font.bold: true + font.pointSize: Style.fontSizeXXS + text: modelData.trim() + } + } + } + } + } + } + + NText { + anchors.verticalCenter: parent.verticalCenter + color: Qt.alpha(Color.mOnSurface, 0.4) + font.pointSize: Style.fontSizeXXS + text: hint.label + } +} diff --git a/noctalia/plugins/wallcards/src/SideBar.qml b/noctalia/plugins/wallcards/src/SideBar.qml new file mode 100644 index 0000000..3021545 --- /dev/null +++ b/noctalia/plugins/wallcards/src/SideBar.qml @@ -0,0 +1,203 @@ +import QtQuick +import qs.Commons +import qs.Widgets + +Rectangle { + id: sideBar + + property bool expanded: false + property bool hideHelp: true + + color: Qt.alpha(Color.mSurface, 0.9) + radius: Style.radiusS + width: expanded ? shortcutColumn.width + Style.margin2L : !hideHelp ? collapsedColumn.width + Style.margin2L : 0 + height: expanded ? shortcutColumn.height + Style.margin2L : !hideHelp ? collapsedColumn.height + Style.margin2L : 0 + + Behavior on width { + NumberAnimation { + duration: 150 + easing.type: Easing.OutCubic + } + } + Behavior on height { + NumberAnimation { + duration: 150 + easing.type: Easing.OutCubic + } + } + + clip: true + + Column { + id: collapsedColumn + + anchors.centerIn: parent + spacing: Style.marginS + visible: !sideBar.expanded && !sideBar.hideHelp + + ShortcutHint { + keys: "?" + label: root.pluginApi?.tr("shortcuts.label.help-title") + } + } + + Column { + id: shortcutColumn + + anchors.centerIn: parent + spacing: Style.marginXS + visible: sideBar.expanded + + // Navigation + NText { + color: Qt.alpha(Color.mOnSurface, 0.35) + font.bold: true + font.pointSize: Style.fontSizeXXS + text: root.pluginApi?.tr("shortcuts.header.navigation") + } + ShortcutHint { + keys: "J / K" + label: root.pluginApi?.tr("shortcuts.label.navigate") + } + ShortcutHint { + keys: "H / L" + label: root.pluginApi?.tr("shortcuts.label.jump") + } + ShortcutHint { + keys: "R" + label: root.pluginApi?.tr("shortcuts.label.shuffle") + } + + // Separator + Rectangle { + color: Qt.alpha(Color.mOnSurface, 0.1) + height: 1 + width: shortcutColumn.width + } + + // Actions + NText { + color: Qt.alpha(Color.mOnSurface, 0.35) + font.bold: true + font.pointSize: Style.fontSizeXXS + text: root.pluginApi?.tr("shortcuts.header.actions") + } + ShortcutHint { + keys: "ENTER" + label: root.pluginApi?.tr("shortcuts.label.apply-quit") + } + ShortcutHint { + keys: "SPACE" + label: root.pluginApi?.tr("shortcuts.label.apply") + } + ShortcutHint { + keys: "ESC / Q" + label: root.pluginApi?.tr("shortcuts.label.quit") + } + + // Separator + Rectangle { + color: Qt.alpha(Color.mOnSurface, 0.1) + height: 1 + width: shortcutColumn.width + } + + // Filters + NText { + color: Qt.alpha(Color.mOnSurface, 0.35) + font.bold: true + font.pointSize: Style.fontSizeXXS + text: root.pluginApi?.tr("shortcuts.header.filters") + } + ShortcutHint { + keys: "A" + label: root.pluginApi?.tr("shortcuts.label.filter-all") + } + ShortcutHint { + keys: "I" + label: root.pluginApi?.tr("shortcuts.label.filter-images") + } + ShortcutHint { + keys: "V" + label: root.pluginApi?.tr("shortcuts.label.filter-videos") + } + ShortcutHint { + keys: "F" + label: root.pluginApi?.tr("shortcuts.label.filter-colors") + } + + // Separator + Rectangle { + color: Qt.alpha(Color.mOnSurface, 0.1) + height: 1 + width: shortcutColumn.width + } + + // View + NText { + color: Qt.alpha(Color.mOnSurface, 0.35) + font.bold: true + font.pointSize: Style.fontSizeXXS + text: root.pluginApi?.tr("shortcuts.header.view") + } + ShortcutHint { + keys: "T" + label: root.pluginApi?.tr("shortcuts.label.top-bar") + } + ShortcutHint { + keys: "P" + label: root.pluginApi?.tr("shortcuts.label.live-preview") + } + + // Separator + Rectangle { + color: Qt.alpha(Color.mOnSurface, 0.1) + height: 1 + width: shortcutColumn.width + } + + // Layout + NText { + color: Qt.alpha(Color.mOnSurface, 0.35) + font.bold: true + font.pointSize: Style.fontSizeXXS + text: root.pluginApi?.tr("shortcuts.header.layout") + } + ShortcutHint { + keys: "SHIFT + H / L" + label: root.pluginApi?.tr("shortcuts.label.center-height") + } + ShortcutHint { + keys: "SHIFT + J / K" + label: root.pluginApi?.tr("shortcuts.label.center-width") + } + ShortcutHint { + keys: "SHIFT + N / P" + label: root.pluginApi?.tr("shortcuts.label.cards-shown") + } + ShortcutHint { + keys: "CTRL + J / K" + label: root.pluginApi?.tr("shortcuts.label.cards-spacing") + } + ShortcutHint { + keys: "CTRL + H / L" + label: root.pluginApi?.tr("shortcuts.label.cards-width") + } + + // Separator + Rectangle { + color: Qt.alpha(Color.mOnSurface, 0.1) + height: 1 + width: shortcutColumn.width + } + + ShortcutHint { + keys: "CTRL + S" + label: root.pluginApi?.tr("shortcuts.label.save") + } + ShortcutHint { + keys: "?" + label: root.pluginApi?.tr("shortcuts.label.hide") + } + } +} diff --git a/noctalia/plugins/wallcards/src/ThumbnailService.qml b/noctalia/plugins/wallcards/src/ThumbnailService.qml new file mode 100644 index 0000000..a2c2cea --- /dev/null +++ b/noctalia/plugins/wallcards/src/ThumbnailService.qml @@ -0,0 +1,275 @@ +import "Utils.js" as Utils +import QtQuick +import Quickshell.Io +import Qt.labs.folderlistmodel + +Item { + id: service + + property int maxThumbnailJobs: 4 + property var pendingJobs: [] + property int activeJobs: 0 + required property string cacheDir + required property var imageFilter + required property var videoFilter + required property string wallpaperDir + property var colorOrder: ["Red", "Orange", "Green", "Teal", "Blue", "Purple", "Pink", "Monochrome"] + property var colorOrderColors: ["#FF4500", "#FFA500", "#32CD32", "#2EC4B6", "#1E90FF", "#8A2BE2", "#FF69B4", "#A9A9A9"] + property int fileCount: files.length + property var files: [] + property bool loading: true + property int pendingProcesses: 0 + property int thumbnailRevision: 0 + + signal ready + + function toLocalPath(urlStr) { + return String(urlStr).replace(/^file:\/\//, ""); + } + + FolderListModel { + id: thumbnailModel + + folder: service.wallpaperDir ? Qt.resolvedUrl("file://" + service.wallpaperDir) : "" + nameFilters: Utils.nameFilters(service.imageFilter, service.videoFilter) + showDirs: false + sortField: FolderListModel.Name + + onStatusChanged: { + if (status === FolderListModel.Ready) { + service.createThumbnails(); + } + } + } + +function createThumbnails() { + var proc = processComponent.createObject(null, { + command: ["mkdir", "-p", cacheDir] + }); + proc.running = true; + + var items = []; + var jobs = []; + + for (var i = 0; i < thumbnailModel.count; i++) { + (function (idx) { + var filePath = toLocalPath(thumbnailModel.get(idx, "filePath")); + var fileName = thumbnailModel.get(idx, "fileName"); + var isVid = Utils.isVideo(fileName, service.videoFilter); + var thumbName = isVid ? fileName + ".jpg" : fileName; + var thumbnailPath = cacheDir + "/" + thumbName; + + var thumbnailCmd = isVid ? videoToThumbnailCmd(filePath, thumbnailPath) : imageToThumbnailCmd(filePath, thumbnailPath); + var hexCmd = thumbnailHexValueCmd(thumbnailPath); + + const script = ` + [ -f "${thumbnailPath}"* ] && exit 0 + ${thumbnailCmd} + mv "${thumbnailPath}" "${thumbnailPath}__x$(${hexCmd})" + `; + + jobs.push({ + script: script, + thumbnailPath: thumbnailPath + }); + + items.push({}); + })(i); + } + + pendingJobs = jobs; + activeJobs = 0; + processNextJob(); + + if (thumbnailModel.count === 0) { + service.loading = false; + } + + files = items; +} +function processNextJob() { + while (activeJobs < maxThumbnailJobs && pendingJobs.length > 0) { + var job = pendingJobs.shift(); + activeJobs++; + service.pendingProcesses++; + createSingleThumbnail(job, function() { + activeJobs--; + processNextJob(); + }); + } +} + +function createSingleThumbnail(job, onFinished) { + var proc = processComponent.createObject(null, { + command: ["bash", "-c", job.script] + }); + + proc.exited.connect(function () { + service.pendingProcesses--; + service.thumbnailRevision++; + + if (service.pendingProcesses === 0) { + filesModel.running = true; + } + + proc.destroy(); + if (onFinished) onFinished(); + }); + + proc.running = true; +} + function imageToThumbnailCmd(filePath, thumbnailPath) { + return `magick "${filePath}" \ + -resize x500 \ + -quality 95 \ + "${thumbnailPath}" + `; + } + + function videoToThumbnailCmd(filePath, thumbnailPath) { + return `ffmpeg -y -i \ + "${filePath}" \ + -vf "select=eq(n\\,0),scale=-1:1080" \ + -frames:v 1 \ + -q:v 2 \ + "${thumbnailPath}" /dev/null`; + } + + function thumbnailHexValueCmd(thumbnailPath) { + return `magick "${thumbnailPath}" \ + -resize "1x1^" \ + -gravity center \ + -extent 1x1 \ + -depth 8 \ + -format "%[hex:p{0,0}]" info:- 2>/dev/null \ + | grep -oE '[0-9A-Fa-f]{6}' \ + | head -n 1`; + } + + FolderListModel { + id: filesModel + + nameFilters: ["*__x*"] + showDirs: false + sortField: FolderListModel.Name + + property bool running: false + + onRunningChanged: { + if (running) + folder = Qt.resolvedUrl("file://" + service.cacheDir); + } + + onStatusChanged: { + if (status === FolderListModel.Ready && running) { + service.buildFileList(); + running = false; + } + } + } + + function buildFileList() { + // Ground truth needed to avoid showing thumbnails for files + // that are no longer in the wallpaper directory. + var existingFiles = new Set(); + for (let j = 0; j < thumbnailModel.count; j++) { + existingFiles.add(thumbnailModel.get(j, "fileName")); + } + + var items = []; + + for (let i = 0; i < filesModel.count; i++) { + const filePath = toLocalPath(filesModel.get(i, "filePath")); + const fileName = filesModel.get(i, "fileName"); + + const idx = fileName.lastIndexOf("__x"); + if (idx === -1) + continue; + + const thumbBase = fileName.substring(0, idx); + const hexColor = fileName.substring(idx + 3); + + // Video thumbnails are named ..jpg__x + // Strip trailing .jpg to recover the original video filename. + var isVid = thumbBase.toLowerCase().endsWith(".jpg") && Utils.isVideo(thumbBase.substring(0, thumbBase.lastIndexOf(".")), service.videoFilter); + var wallpaperName = isVid ? thumbBase.substring(0, thumbBase.lastIndexOf(".")) : thumbBase; + + if (!existingFiles.has(wallpaperName)) { + continue; + } + + items.push({ + fileName: wallpaperName, + filePath: wallpaperDir + "/" + wallpaperName, + thumbnail: filePath, + hexCode: hexColor, + filterColor: getFilterColor(hexColor), + isVideo: isVid + }); + } + + items.sort((a, b) => colorOrder.indexOf(a.filterColor) - colorOrder.indexOf(b.filterColor)); + + files = items; + service.loading = false; + service.ready(); + } + + function getFilterColor(hexColor) { + if (!hexColor) + return "Monochrome"; + + const cleaned = String(hexColor).trim().replace(/x/g, '').substring(0, 6); + if (cleaned.length !== 6) + return "Monochrome"; + + const r = parseInt(cleaned.substring(0, 2), 16) / 255; + const g = parseInt(cleaned.substring(2, 4), 16) / 255; + const b = parseInt(cleaned.substring(4, 6), 16) / 255; + if ([r, g, b].some(isNaN)) + return "Monochrome"; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const d = max - min; + + let h = 0; + let s = max === 0 ? 0 : d / max; + let v = max; + + if (d !== 0) { + if (max === r) + h = (g - b) / d + (g < b ? 6 : 0); + else if (max === g) + h = (b - r) / d + 2; + else + h = (r - g) / d + 4; + h = (h / 6) * 360; + } + + if (s < 0.15 || v < 0.10) + return "Monochrome"; + if (h >= 345 || h < 15) + return "Red"; + if (h < 50) + return "Orange"; + if (h < 160) + return "Green"; + if (h < 200) + return "Teal"; + if (h < 260) + return "Blue"; + if (h < 315) + return "Purple"; + if (h < 345) + return "Pink"; + + return "Monochrome"; + } + + Component { + id: processComponent + + Process {} + } +} diff --git a/noctalia/plugins/wallcards/src/TopBar.qml b/noctalia/plugins/wallcards/src/TopBar.qml new file mode 100644 index 0000000..972d186 --- /dev/null +++ b/noctalia/plugins/wallcards/src/TopBar.qml @@ -0,0 +1,418 @@ +import qs.Commons +import qs.Widgets + +import QtQuick + +Rectangle { + id: topBar + + required property int animationDuration + required property var availableColors + required property var colorOrder + required property var colorOrderColors + required property string currentCardColor + required property int currentIndex + property real entryOffset: parent.width / 2 + required property int filteredCount + required property bool livePreview + required property var pluginApi + required property string selectedColorFilter + required property string selectedFilter + required property real shearFactor + + signal colorFilterSelected(string key) + signal filterSelected(string key) + signal livePreviewToggled + signal shuffleRequested + + function flashShuffle() { + shuffleBtn.flash(); + } + + color: Color.mSurface + + Behavior on entryOffset { + NumberAnimation { + duration: topBar.animationDuration + easing.overshoot: 1.0 + easing.type: Easing.OutBack + } + } + + transform: Matrix4x4 { + property real s: topBar.shearFactor + + matrix: Qt.matrix4x4(1, s, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) + } + + Component.onCompleted: entryOffset = 0 + + // ── Inline Components ── + + component TButton: Rectangle { + id: btn + + property color accentColor: active ? Color.mOnSurface : Color.mOnSurfaceVariant + property bool active: false + property string hotkey: "" + property string icon: "" + property string label: "" + property bool pulsing: false + + signal clicked + + border.color: active ? Qt.alpha(accentColor, Style.opacityMedium) : Qt.alpha(Color.mOutline, 0.3) + border.width: Style.borderS + color: active ? Qt.alpha(accentColor, 0.15) : Qt.alpha(Color.mOnSurface, 0.06) + height: Style.margin2L + radius: Style.radiusM + width: btnRow.width + Style.margin2M + + Behavior on border.color { + ColorAnimation { + duration: Style.animationFast + } + } + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + + Row { + id: btnRow + + anchors.centerIn: parent + spacing: Style.marginS + + PulsingDot { + anchors.verticalCenter: parent.verticalCenter + pulsing: btn.pulsing + visible: btn.pulsing + } + NIcon { + color: btn.accentColor + icon: btn.icon + visible: btn.icon !== "" + } + NText { + anchors.verticalCenter: parent.verticalCenter + color: btn.accentColor + font.pointSize: Style.fontSizeXS + text: btn.label + visible: btn.label !== "" + } + Rectangle { + anchors.verticalCenter: parent.verticalCenter + border.color: Qt.alpha(btn.accentColor, 0.35) + border.width: Style.borderM + color: Qt.alpha(btn.accentColor, 0.12) + height: Style.marginL + 2 + opacity: 0.25 + radius: Style.radiusXS + visible: btn.hotkey !== "" + width: Style.marginL + 5 + + Rectangle { + anchors.bottomMargin: 2 + anchors.fill: parent + border.color: Qt.alpha(btn.accentColor, 0.25) + border.width: Style.borderS + color: Qt.alpha(btn.accentColor, 0.1) + radius: Style.radiusXS + + NText { + anchors.centerIn: parent + color: Qt.alpha(btn.accentColor, 0.7) + font.bold: true + font.pointSize: Style.fontSizeXXS + text: btn.hotkey + } + } + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + + onClicked: btn.clicked() + } + } + + component CButton: Rectangle { + id: colorBtn + + property bool active: false + property bool available: true + property bool current: false + required property color faceColor + + signal clicked + + border.color: active ? Color.mOnSurface : Qt.alpha(Color.mOutline, 0.3) + border.width: Style.borderS + color: faceColor + height: Style.margin2L + opacity: active ? 1.0 : available ? 0.4 : 0.12 + radius: Style.radiusM + width: height + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + Behavior on border.color { + ColorAnimation { + duration: Style.animationFast + } + } + + Rectangle { + anchors.bottom: parent.bottom + anchors.bottomMargin: -height - 2 + anchors.horizontalCenter: parent.horizontalCenter + color: colorBtn.current ? colorBtn.faceColor : "transparent" + height: 3 + radius: height / 2 + width: colorBtn.current ? parent.width * 0.6 : 0 + + Behavior on width { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutCubic + } + } + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + } + + NText { + anchors.centerIn: parent + color: Color.mOnSurface + font.bold: true + font.pointSize: Style.fontSizeS + text: topBar.pluginApi?.tr("buttons.color-na") + visible: !colorBtn.available + } + + MouseArea { + anchors.fill: parent + cursorShape: colorBtn.available ? Qt.PointingHandCursor : Qt.ForbiddenCursor + enabled: colorBtn.available + + onClicked: colorBtn.clicked() + } + } + + component SButton: Item { + id: shuffleItem + + property string hotkey: "" + property string icon: "" + property string label: "" + + signal clicked + + function flash() { + flashAnim.restart(); + } + + height: innerBtn.height + width: innerBtn.width + + TButton { + id: innerBtn + + hotkey: shuffleItem.hotkey + icon: shuffleItem.icon + label: shuffleItem.label + + onClicked: { + shuffleItem.clicked(); + shuffleItem.flash(); + } + } + + Rectangle { + id: flashOverlay + + anchors.fill: innerBtn + color: Color.mPrimary + opacity: 0 + radius: innerBtn.radius + } + + SequentialAnimation { + id: flashAnim + + NumberAnimation { + duration: 80 + easing.type: Easing.OutCubic + property: "opacity" + target: flashOverlay + to: 0.3 + } + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + property: "opacity" + target: flashOverlay + to: 0 + } + } + } + + component PulsingDot: Rectangle { + id: root + + property color dotColor: Color.mError + property bool pulsing: false + + color: dotColor + height: Style.marginS + radius: Style.marginXXXS + width: Style.marginS + + SequentialAnimation { + id: pulseAnimation + + loops: Animation.Infinite + running: root.pulsing + + onRunningChanged: { + if (!running) + root.opacity = 1.0; + } + + NumberAnimation { + duration: 800 + easing.type: Easing.InOutSine + from: 1.0 + property: "opacity" + target: root + to: 0.3 + } + NumberAnimation { + duration: 800 + easing.type: Easing.InOutSine + from: 0.3 + property: "opacity" + target: root + to: 1.0 + } + } + } + + // ── Left ── + + NText { + anchors.left: parent.left + anchors.leftMargin: Style.marginL + anchors.verticalCenter: parent.verticalCenter + text: (topBar.currentIndex + 1) + " / " + topBar.filteredCount + } + + // ── Center ── + + Row { + anchors.centerIn: parent + spacing: Style.marginXS + + Repeater { + model: [ + { + key: "all", + label: topBar.pluginApi?.tr("buttons.all"), + icon: "wallpaper", + hotkey: "A" + }, + { + key: "images", + label: topBar.pluginApi?.tr("buttons.images"), + icon: "image", + hotkey: "I" + }, + { + key: "videos", + label: topBar.pluginApi?.tr("buttons.videos"), + icon: "video", + hotkey: "V" + } + ] + + TButton { + required property var modelData + + active: topBar.selectedFilter === modelData.key + hotkey: modelData.hotkey + icon: modelData.icon + label: modelData.label || "" + + onClicked: topBar.filterSelected(modelData.key) + } + } + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + color: Qt.alpha(Color.mOnSurface, 0.15) + height: parent.height * 0.5 + width: 1 + } + + Repeater { + model: topBar.colorOrder + + CButton { + required property int index + required property string modelData + + active: topBar.selectedColorFilter === modelData + anchors.verticalCenter: parent.verticalCenter + available: topBar.availableColors.indexOf(modelData) !== -1 + current: topBar.selectedColorFilter === "" && topBar.currentCardColor === modelData + faceColor: topBar.colorOrderColors[index] + + onClicked: { + if (active) + topBar.colorFilterSelected(""); + else + topBar.colorFilterSelected(modelData); + } + } + } + } + + // ── Right ── + + Row { + anchors.right: parent.right + anchors.rightMargin: Style.marginL + anchors.verticalCenter: parent.verticalCenter + spacing: Style.marginXS + + SButton { + id: shuffleBtn + + hotkey: "R" + icon: "arrows-random" + label: topBar.pluginApi?.tr("buttons.shuffle") + + onClicked: topBar.shuffleRequested() + } + + TButton { + accentColor: topBar.livePreview ? Color.mTertiary : Color.mOnSurfaceVariant + active: topBar.livePreview + hotkey: "P" + label: topBar.pluginApi?.tr("buttons.live-preview") + pulsing: topBar.livePreview + + onClicked: topBar.livePreviewToggled() + } + } +} diff --git a/noctalia/plugins/wallcards/src/Utils.js b/noctalia/plugins/wallcards/src/Utils.js new file mode 100644 index 0000000..7a45180 --- /dev/null +++ b/noctalia/plugins/wallcards/src/Utils.js @@ -0,0 +1,37 @@ +function getExtension(fileName) { + return fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase(); +} + +function isVideo(fileName, filterVideos) { + return filterVideos.indexOf(getExtension(fileName)) !== -1; +} + +function isImage(fileName, filterImages) { + return filterImages.indexOf(getExtension(fileName)) !== -1; +} + +function nameFilters(filterImages, filterVideos) { + return (filterImages || []).concat(filterVideos || []).map((ext) => "*." + ext); +} + +var mpvpaperKill = "killall -9 mpvpaper 2>/dev/null || true"; + +var mpvpaperOptions = [ + "loop", + "--no-audio", + "--hwdec=auto", + "--profile=high-quality", + "--video-sync=display-resample", + "--interpolation", + "--tscale=oversample" +].join(" "); + +function mpvpaperRun(filePath) { + return "mpvpaper -o '" + mpvpaperOptions + "' '*' \"" + filePath + "\" >/dev/null 2>&1 & disown"; +} + +function wallpaperCommand(entry) { + if (entry.isVideo) + return mpvpaperKill + "; " + mpvpaperRun(entry.filePath); + return mpvpaperKill; +} diff --git a/noctalia/plugins/workspace-overview/BarWidget.qml b/noctalia/plugins/workspace-overview/BarWidget.qml new file mode 100644 index 0000000..7adc6ad --- /dev/null +++ b/noctalia/plugins/workspace-overview/BarWidget.qml @@ -0,0 +1,44 @@ +import QtQuick +import QtQuick.Layouts +import qs.Commons +import qs.Widgets + +Item { + id: root + property var pluginApi: null + property var screen: null + property string widgetId: "" + property string section: "" + property int sectionWidgetIndex: -1 + property int sectionWidgetsCount: 0 + + // Ensures the widget occupies space in the bar + implicitWidth: Style.baseWidgetSize + implicitHeight: Style.baseWidgetSize + + Layout.preferredWidth: implicitWidth + Layout.preferredHeight: implicitHeight + + // Use NIcon instead of NIconButton to avoid circular background with inverted colors + NIcon { + id: widgetIcon + anchors.centerIn: parent + icon: "layout-dashboard" + // Default icon color: white/light gray, like the others. + // Add hover color if the mouse is over. + color: mouseArea.containsMouse ? Color.mPrimary : Color.mOnSurface + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + if (pluginApi) { + pluginApi.openPanel(root.screen, root) + } + } + } +} diff --git a/noctalia/plugins/workspace-overview/Main.qml b/noctalia/plugins/workspace-overview/Main.qml new file mode 100644 index 0000000..5b0842c --- /dev/null +++ b/noctalia/plugins/workspace-overview/Main.qml @@ -0,0 +1,31 @@ +import QtQuick +import Quickshell.Io +import qs.Commons + +Item { + id: root + + property var pluginApi: null + + IpcHandler { + target: "plugin:workspace-overview" + + function toggle() { + root.showOverview(); + } + } + + // This function will be exposed + function showOverview() { + Logger.i("Workspace Overview", "-> Received command to open the Overview"); + if (pluginApi) { + pluginApi.withCurrentScreen(screen => { + pluginApi.togglePanel(screen); + }); + } + } + + Component.onCompleted: { + Logger.i("Workspace Overview", "-> [Main] Workspace Overview is ready and listening."); + } +} diff --git a/noctalia/plugins/workspace-overview/Panel.qml b/noctalia/plugins/workspace-overview/Panel.qml new file mode 100644 index 0000000..bfd28e0 --- /dev/null +++ b/noctalia/plugins/workspace-overview/Panel.qml @@ -0,0 +1,253 @@ +import QtQuick +import QtQuick.Layouts +import qs.Commons +import qs.Widgets +import qs.Services.UI +import Quickshell.Hyprland +import Quickshell.Wayland + +Item { + id: root + + // --- Mandatory Panel Properties (Injected by Noctalia) --- + property var pluginApi: null + readonly property var geometryPlaceholder: panelContainer + readonly property bool allowAttach: true + + // Recommended dimensions adapted to the interface scale + property real contentPreferredWidth: 840 * Style.uiScaleRatio + property real contentPreferredHeight: Math.max(320 * Style.uiScaleRatio, (workspaceGrid.computedRows * workspaceGrid.cellHeight) + 100 * Style.uiScaleRatio) + + anchors.fill: parent + + Rectangle { + id: panelContainer + anchors.fill: parent + color: "transparent" + + ColumnLayout { + anchors { + fill: parent + margins: Style.marginL + } + spacing: Style.marginL + + // --- Header --- + RowLayout { + Layout.fillWidth: true + NText { + text: "Workspace Overview" + pointSize: Style.fontSizeXL + font.weight: Font.Bold + color: Color.mOnSurface + Layout.fillWidth: true + } + NIconButton { + icon: "x" + onClicked: { + if (pluginApi) { + pluginApi.closePanel(pluginApi.panelOpenScreen) + } + } + } + } + + // --- Workspaces Area (Grid) --- + NBox { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true // Enable clipping to hide the overflow + + NGridView { + id: workspaceGrid + anchors { + left: parent.left + top: parent.top + bottom: parent.bottom + } + // Widen the grid and push its internal scrollbar beyond the clipped parent edge + width: parent.width + (40 * Style.uiScaleRatio) + + anchors.topMargin: Style.marginM + anchors.bottomMargin: Style.marginM + + // Use parent.width (the visible area) for cells, not its own widened width + cellWidth: parent.width / columns + cellHeight: (194.002 + 13.284) * Style.uiScaleRatio + + // The distribution is now handled by the dynamic cellWidth relative to the visible panel + property int columns: Math.max(1, Math.min(count, 3)) + property int computedRows: Math.max(1, Math.ceil(count / columns)) + + leftMargin: 0 + topMargin: Math.max(0, (height - (computedRows * cellHeight)) / 2) + + clip: true + + // Using the real Hyprland model provided by Quickshell + model: Hyprland.workspaces + + // --- Workspace Component --- + delegate: DropArea { + width: workspaceGrid.cellWidth + height: workspaceGrid.cellHeight + + // The "required property" tells QML: "I expect the model to send me a modelData" + required property var modelData + + // Now you use it directly, without fear of being undefined + property int targetWorkspaceId: modelData.id + + // Action when dropping the window in this workspace + onDropped: (drop) => { + if (drop.hasText && drop.text !== "") { + let windowData = JSON.parse(drop.text); + Logger.i("Workspace Overview", "Move window " + windowData.winId + " to workspace " + targetWorkspaceId); + + // Hyprland command via Quickshell + Hyprland.dispatch("movetoworkspacesilent " + targetWorkspaceId + ",address:" + windowData.winId); + } + } + + NBox { + id: workspaceBg + width: 228.646 * Style.uiScaleRatio // Keep fixed width + height: 194.002 * Style.uiScaleRatio // Keep fixed height + anchors.centerIn: parent + + // Visual highlight if it is the active workspace or if it contains drag + readonly property bool isActiveWorkspace: Hyprland.focusedWorkspace && Hyprland.focusedWorkspace.id === modelData.id + + // Using conditional color while still being an NBox for theme consistency + color: isActiveWorkspace ? Color.mPrimary : (parent.containsDrag ? Color.mSurfaceVariant : Color.mSurface) + border.color: isActiveWorkspace ? Color.mOnPrimary : Color.mOutline + border.width: parent.containsDrag ? 4 : 0 + opacity: parent.containsDrag ? 0.8 : 1.0 + + // MouseArea to click on the workspace and switch to it + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + if (targetWorkspaceId !== undefined) { + Hyprland.dispatch("workspace " + targetWorkspaceId); + if (pluginApi) { + pluginApi.closePanel(pluginApi.panelOpenScreen); + } + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginM / 2 + spacing: Style.marginS + + NText { + text: modelData.name !== "" ? modelData.name : "Workspace " + modelData.id + font.weight: Font.Bold + color: workspaceBg.isActiveWorkspace ? Color.mOnPrimary : Color.mOnSurface + Layout.alignment: Qt.AlignHCenter + } + + // List of active windows (Titles) + Column { + Layout.fillWidth: true + Layout.preferredHeight: 20 * Style.uiScaleRatio + spacing: 1 * Style.uiScaleRatio + clip: true + + Repeater { + model: modelData.toplevels || null + delegate: NText { + width: 199.663 * Style.uiScaleRatio // Precise width + anchors.horizontalCenter: parent.horizontalCenter + text: "• " + (modelData.title || "App") + pointSize: 8 * Style.uiScaleRatio + elide: Text.ElideRight + color: workspaceBg.isActiveWorkspace ? Color.mOnPrimary : Color.mOnSurfaceVariant + horizontalAlignment: Text.AlignHCenter + } + } + } + + // Mini-monitor background (Real wallpaper or solid color) + Rectangle { + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + Layout.preferredWidth: 199.663 * Style.uiScaleRatio + Layout.preferredHeight: 112.256 * Style.uiScaleRatio + color: Qt.rgba(0, 0, 0, 0.4) + border.color: parent.parent.isActiveWorkspace ? Color.mOnPrimary : Color.mOutline + border.width: 2 * Style.uiScaleRatio + radius: Style.radiusS + clip: true + + // Wallpaper + Image { + anchors.fill: parent + source: typeof WallpaperService !== "undefined" ? WallpaperService.getWallpaper(modelData.monitor.name) : "" + fillMode: Image.PreserveAspectCrop + visible: source != "" + opacity: 0.8 + } + + // Actual dimensions and positions come from the workspace monitor. + // Use monitor scale to convert from physical to logical pixels. + property var wsMonitor: modelData.monitor || null + property real monitorX: wsMonitor ? wsMonitor.x : 0 + property real monitorY: wsMonitor ? wsMonitor.y : 0 + property real monitorScale: wsMonitor && wsMonitor.scale > 0 ? wsMonitor.scale : 1.0 + property real monitorW: (wsMonitor && wsMonitor.width > 0 ? wsMonitor.width : 1920) / monitorScale + property real monitorH: (wsMonitor && wsMonitor.height > 0 ? wsMonitor.height : 1080) / monitorScale + property real scaleX: width / monitorW + property real scaleY: height / monitorH + + Repeater { + model: modelData.toplevels || null + delegate: Rectangle { + // Using 'required property var modelData' forces QML to get the modelData from the internal Repeater + // escaping the scope shadowing generated by the external GridView. + required property var modelData + + // Geometric information comes from the lastIpcObject associated with the window (inner modelData) + property var ipcObj: modelData.lastIpcObject || null + property real winX: ipcObj && ipcObj.at ? ipcObj.at[0] : 0 + property real winY: ipcObj && ipcObj.at ? ipcObj.at[1] : 0 + property real winW: ipcObj && ipcObj.size ? ipcObj.size[0] : 0 + property real winH: ipcObj && ipcObj.size ? ipcObj.size[1] : 0 + + // Position relative to the monitor + x: (winX - parent.monitorX) * parent.scaleX + y: (winY - parent.monitorY) * parent.scaleY + width: Math.max(2, winW * parent.scaleX) + height: Math.max(2, winH * parent.scaleY) + + // Ignore unmapped (hidden) windows + visible: modelData.mapped !== undefined ? modelData.mapped : (ipcObj !== null && ipcObj.mapped !== undefined ? ipcObj.mapped : true) + + color: Color.mPrimary + border.color: Color.mBackground + border.width: Math.max(1, 1 * Style.uiScaleRatio) + radius: 2 * Style.uiScaleRatio + clip: true + + ScreencopyView { + anchors.fill: parent + captureSource: modelData.wayland + live: true + paintCursor: true + + // Optimization: Only capture at the resolution we are displaying + constraintSize: Qt.size(parent.width, parent.height) + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/noctalia/plugins/workspace-overview/README.md b/noctalia/plugins/workspace-overview/README.md new file mode 100644 index 0000000..34288ec --- /dev/null +++ b/noctalia/plugins/workspace-overview/README.md @@ -0,0 +1,42 @@ +# Workspace Overview Plugin for Noctalia + +A visually stunning workspace overview with real-time window previews and wallpaper integration, designed perfectly for Hyprland and Noctalia Shell. + +## ✨ Features + +- **Panoramic View**: A balanced 3-column grid that fills your panel with a professional, symmetrical layout. +- **Live Previews**: Real-time rendering of all your open windows using `ScreencopyView`. +- **Dynamic Sizing**: The panel height automatically adjusts to the number of active workspaces. +- **Wallpaper Backgrounds**: Automatically pulls your desktop wallpaper to show "life" in every workspace preview. +- **Drag & Drop**: Effortlessly move windows between workspaces by dragging their previews. +- **Clean Aesthetic**: No visible scrollbars—just your windows and your work. + +## Installation + +Ensure you have the plugin files in: +`~/.config/noctalia/plugins/workspace-overview/` + +## Usage + +### Via Bar Widget +Add the "Workspace Overview" widget to your Noctalia bar. + +### Via IPC (Keybindings) +You can toggle the overview using the Noctalia IPC interface. This is ideal for assigning to a keyboard shortcut. + +**Command:** +```bash +qs -c noctalia-shell ipc call plugin:workspace-overview toggle +``` + +#### Hyprland Keybind Example +Add the following to your `hyprland.conf`: +```bash +bind = SUPER, TAB, exec, qs -c noctalia-shell ipc call plugin:workspace-overview toggle +``` + +## Requirements + +- **Noctalia Shell**: 3.6.0 or later +- **Hyprland**: For workspace and window tracking +- **Quickshell**: The framework powering Noctalia diff --git a/noctalia/plugins/workspace-overview/manifest.json b/noctalia/plugins/workspace-overview/manifest.json new file mode 100644 index 0000000..bd8cfcb --- /dev/null +++ b/noctalia/plugins/workspace-overview/manifest.json @@ -0,0 +1,27 @@ +{ + "id": "workspace-overview", + "name": "Workspace Overview for Hyprland", + "version": "1.0.3", + "minNoctaliaVersion": "3.6.0", + "author": "doughbran", + "license": "MIT", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "description": "Workspace overview with live window previews and wallpaper backgrounds for Hyprland.", + "tags": [ + "Hyprland", + "Bar", + "Panel", + "Utility" + ], + "entryPoints": { + "panel": "Panel.qml", + "barWidget": "BarWidget.qml", + "main": "Main.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "defaultSettings": {} + } +} diff --git a/noctalia/plugins/workspace-overview/preview.png b/noctalia/plugins/workspace-overview/preview.png new file mode 100644 index 0000000..96c2cce Binary files /dev/null and b/noctalia/plugins/workspace-overview/preview.png differ diff --git a/noctalia/settings.json b/noctalia/settings.json new file mode 100644 index 0000000..e14b620 --- /dev/null +++ b/noctalia/settings.json @@ -0,0 +1,754 @@ +{ + "appLauncher": { + "autoPasteClipboard": false, + "clipboardWatchImageCommand": "wl-paste --type image --watch cliphist store", + "clipboardWatchTextCommand": "wl-paste --type text --watch cliphist store", + "clipboardWrapText": true, + "customLaunchPrefix": "", + "customLaunchPrefixEnabled": false, + "density": "default", + "enableClipPreview": true, + "enableClipboardChips": true, + "enableClipboardHistory": true, + "enableClipboardSmartIcons": true, + "enableSessionSearch": true, + "enableSettingsSearch": true, + "enableWindowsSearch": true, + "iconMode": "tabler", + "ignoreMouseInput": false, + "overviewLayer": false, + "pinnedApps": [ + ], + "position": "center", + "screenshotAnnotationTool": "", + "showCategories": true, + "showIconBackground": false, + "sortByMostUsed": true, + "terminalCommand": "kitty -e", + "viewMode": "list" + }, + "audio": { + "mprisBlacklist": [ + ], + "preferredPlayer": "spotify", + "spectrumFrameRate": 30, + "spectrumMirrored": true, + "visualizerType": "linear", + "volumeFeedback": false, + "volumeFeedbackSoundFile": "", + "volumeOverdrive": false, + "volumeStep": 5 + }, + "bar": { + "autoHideDelay": 500, + "autoShowDelay": 150, + "backgroundOpacity": 0.93, + "barType": "simple", + "capsuleColorKey": "none", + "capsuleOpacity": 1, + "contentPadding": 2, + "density": "default", + "displayMode": "always_visible", + "enableExclusionZoneInset": true, + "fontScale": 1, + "frameRadius": 12, + "frameThickness": 8, + "hideOnOverview": false, + "marginHorizontal": 4, + "marginVertical": 4, + "middleClickAction": "none", + "middleClickCommand": "", + "middleClickFollowMouse": false, + "monitors": [ + ], + "mouseWheelAction": "none", + "mouseWheelWrap": true, + "outerCorners": true, + "position": "top", + "reverseScroll": false, + "rightClickAction": "controlCenter", + "rightClickCommand": "", + "rightClickFollowMouse": true, + "screenOverrides": [ + ], + "showCapsule": true, + "showOnWorkspaceSwitch": true, + "showOutline": false, + "useSeparateOpacity": false, + "widgetSpacing": 6, + "widgets": { + "center": [ + { + "characterCount": 4, + "colorizeIcons": false, + "emptyColor": "secondary", + "enableScrollWheel": true, + "focusedColor": "primary", + "followFocusedScreen": false, + "fontWeight": "bold", + "groupedBorderOpacity": 1, + "hideUnoccupied": false, + "iconScale": 0.8, + "id": "Workspace", + "labelMode": "name", + "occupiedColor": "secondary", + "pillSize": 0.6, + "showApplications": true, + "showApplicationsHover": true, + "showBadge": true, + "showLabelsOnlyWhenOccupied": true, + "unfocusedIconsOpacity": 1 + } + ], + "left": [ + { + "colorizeSystemIcon": "none", + "colorizeSystemText": "none", + "customIconPath": "", + "enableColorization": false, + "icon": "rocket", + "iconColor": "none", + "id": "Launcher", + "useDistroLogo": false + }, + { + "clockColor": "primary", + "customFont": "", + "formatHorizontal": "HH:mm ddd, MMM dd", + "formatVertical": "HH mm - dd MM", + "id": "Clock", + "tooltipFormat": "HH:mm ddd, MMM dd", + "useCustomFont": false + }, + { + "compactMode": true, + "diskPath": "/", + "iconColor": "none", + "id": "SystemMonitor", + "showCpuCores": false, + "showCpuFreq": false, + "showCpuTemp": true, + "showCpuUsage": true, + "showDiskAvailable": false, + "showDiskUsage": false, + "showDiskUsageAsPercent": false, + "showGpuTemp": false, + "showLoadAverage": false, + "showMemoryAsPercent": false, + "showMemoryUsage": true, + "showNetworkStats": true, + "showSwapUsage": false, + "textColor": "none", + "useMonospaceFont": true, + "usePadding": false + }, + { + "colorizeIcons": false, + "hideMode": "hidden", + "id": "ActiveWindow", + "maxWidth": 145, + "scrollingMode": "hover", + "showIcon": true, + "showText": true, + "textColor": "none", + "useFixedWidth": false + } + ], + "right": [ + { + "compactMode": false, + "hideMode": "hidden", + "hideWhenIdle": false, + "id": "MediaMini", + "maxWidth": 145, + "panelShowAlbumArt": true, + "scrollingMode": "hover", + "showAlbumArt": true, + "showArtistFirst": true, + "showProgressRing": true, + "showVisualizer": false, + "textColor": "none", + "useFixedWidth": false, + "visualizerType": "linear" + }, + { + "blacklist": [ + "nm-applet" + ], + "chevronColor": "none", + "colorizeIcons": false, + "drawerEnabled": true, + "hidePassive": false, + "id": "Tray", + "pinned": [ + ] + }, + { + "hideWhenZero": false, + "hideWhenZeroUnread": false, + "iconColor": "none", + "id": "NotificationHistory", + "showUnreadBadge": true, + "unreadBadgeColor": "primary" + }, + { + "deviceNativePath": "__default__", + "displayMode": "graphic-clean", + "hideIfIdle": false, + "hideIfNotDetected": true, + "id": "Battery", + "showNoctaliaPerformance": false, + "showPowerProfiles": false + }, + { + "displayMode": "onhover", + "iconColor": "none", + "id": "Volume", + "middleClickCommand": "pwvucontrol || pavucontrol", + "textColor": "none" + }, + { + "applyToAllMonitors": false, + "displayMode": "onhover", + "iconColor": "none", + "id": "Brightness", + "textColor": "none" + }, + { + "colorizeDistroLogo": false, + "colorizeSystemIcon": "none", + "colorizeSystemText": "none", + "customIconPath": "", + "enableColorization": false, + "icon": "noctalia", + "id": "ControlCenter", + "useDistroLogo": false + } + ] + } + }, + "brightness": { + "backlightDeviceMappings": [ + { + "device": "/sys/class/backlight/intel_backlight", + "output": "eDP-1" + } + ], + "brightnessStep": 5, + "enableDdcSupport": false, + "enforceMinimum": true + }, + "calendar": { + "cards": [ + { + "enabled": true, + "id": "calendar-header-card" + }, + { + "enabled": true, + "id": "calendar-month-card" + }, + { + "enabled": true, + "id": "weather-card" + } + ] + }, + "colorSchemes": { + "darkMode": true, + "generationMethod": "tonal-spot", + "manualSunrise": "06:30", + "manualSunset": "18:30", + "monitorForColors": "", + "predefinedScheme": "Catppuccin", + "schedulingMode": "off", + "syncGsettings": true, + "useWallpaperColors": false + }, + "controlCenter": { + "cards": [ + { + "enabled": true, + "id": "profile-card" + }, + { + "enabled": true, + "id": "shortcuts-card" + }, + { + "enabled": true, + "id": "audio-card" + }, + { + "enabled": false, + "id": "brightness-card" + }, + { + "enabled": true, + "id": "weather-card" + }, + { + "enabled": true, + "id": "media-sysmon-card" + } + ], + "diskPath": "/", + "position": "close_to_bar_button", + "shortcuts": { + "left": [ + { + "id": "Network" + }, + { + "defaultSettings": { + "connectedColor": "primary", + "disableToastNotifications": false, + "disconnectedColor": "none", + "displayMode": "onhover" + }, + "id": "plugin:network-manager-vpn" + }, + { + "id": "Bluetooth" + }, + { + "id": "NoctaliaPerformance" + } + ], + "right": [ + { + "id": "Notifications" + }, + { + "id": "PowerProfile" + }, + { + "id": "KeepAwake" + }, + { + "id": "NightLight" + } + ] + } + }, + "desktopWidgets": { + "enabled": true, + "gridSnap": false, + "gridSnapScale": true, + "monitorWidgets": [ + ], + "overviewEnabled": true + }, + "dock": { + "animationSpeed": 1, + "backgroundOpacity": 1, + "colorizeIcons": false, + "deadOpacity": 0.6, + "displayMode": "auto_hide", + "dockType": "floating", + "enabled": false, + "floatingRatio": 1, + "groupApps": false, + "groupClickAction": "cycle", + "groupContextMenuMode": "extended", + "groupIndicatorStyle": "dots", + "inactiveIndicators": false, + "indicatorColor": "primary", + "indicatorOpacity": 0.6, + "indicatorThickness": 3, + "launcherIcon": "", + "launcherIconColor": "none", + "launcherPosition": "end", + "launcherUseDistroLogo": false, + "monitors": [ + ], + "onlySameOutput": true, + "pinnedApps": [ + ], + "pinnedStatic": false, + "position": "bottom", + "showDockIndicator": false, + "showLauncherIcon": false, + "sitOnFrame": false, + "size": 1 + }, + "general": { + "allowPanelsOnScreenWithoutBar": true, + "allowPasswordWithFprintd": false, + "animationDisabled": false, + "animationSpeed": 1, + "autoStartAuth": false, + "avatarImage": "/home/domenico/.face", + "boxRadiusRatio": 1, + "clockFormat": "hh\\nmm", + "clockStyle": "custom", + "compactLockScreen": false, + "dimmerOpacity": 0.2, + "enableBlurBehind": true, + "enableLockScreenCountdown": true, + "enableLockScreenMediaControls": false, + "enableShadows": true, + "forceBlackScreenCorners": false, + "iRadiusRatio": 1, + "keybinds": { + "keyDown": [ + "Down" + ], + "keyEnter": [ + "Return", + "Enter" + ], + "keyEscape": [ + "Esc" + ], + "keyLeft": [ + "Left" + ], + "keyRemove": [ + "Del" + ], + "keyRight": [ + "Right" + ], + "keyUp": [ + "Up" + ] + }, + "language": "", + "lockOnSuspend": true, + "lockScreenAnimations": false, + "lockScreenBlur": 0, + "lockScreenCountdownDuration": 10000, + "lockScreenMonitors": [ + ], + "lockScreenTint": 0, + "passwordChars": false, + "radiusRatio": 1, + "reverseScroll": false, + "scaleRatio": 1, + "screenRadiusRatio": 1, + "shadowDirection": "bottom_right", + "shadowOffsetX": 2, + "shadowOffsetY": 3, + "showChangelogOnStartup": true, + "showHibernateOnLockScreen": false, + "showScreenCorners": false, + "showSessionButtonsOnLockScreen": true, + "smoothScrollEnabled": true, + "telemetryEnabled": true + }, + "hooks": { + "colorGeneration": "", + "darkModeChange": "", + "enabled": false, + "performanceModeDisabled": "", + "performanceModeEnabled": "", + "screenLock": "", + "screenUnlock": "", + "session": "", + "startup": "", + "wallpaperChange": "" + }, + "idle": { + "customCommands": "[]", + "enabled": false, + "fadeDuration": 5, + "lockCommand": "", + "lockTimeout": 660, + "resumeLockCommand": "", + "resumeScreenOffCommand": "", + "resumeSuspendCommand": "", + "screenOffCommand": "", + "screenOffTimeout": 600, + "suspendCommand": "", + "suspendTimeout": 1800 + }, + "location": { + "analogClockInCalendar": false, + "autoLocate": false, + "firstDayOfWeek": -1, + "hideWeatherCityName": false, + "hideWeatherTimezone": false, + "name": "Mercato San Severino", + "showCalendarEvents": true, + "showCalendarWeather": true, + "showWeekNumberInCalendar": false, + "use12hourFormat": false, + "useFahrenheit": false, + "weatherEnabled": true, + "weatherShowEffects": true, + "weatherTaliaMascotAlways": false + }, + "network": { + "bluetoothAutoConnect": true, + "bluetoothDetailsViewMode": "list", + "bluetoothHideUnnamedDevices": false, + "bluetoothRssiPollIntervalMs": 60000, + "bluetoothRssiPollingEnabled": false, + "disableDiscoverability": false, + "networkPanelView": "wifi", + "wifiDetailsViewMode": "grid" + }, + "nightLight": { + "autoSchedule": true, + "dayTemp": "6500", + "enabled": false, + "forced": false, + "manualSunrise": "06:30", + "manualSunset": "18:30", + "nightTemp": "4000" + }, + "noctaliaPerformance": { + "disableDesktopWidgets": true, + "disableWallpaper": true + }, + "notifications": { + "backgroundOpacity": 0.75, + "clearDismissed": true, + "criticalUrgencyDuration": 15, + "density": "default", + "enableBatteryToast": true, + "enableKeyboardLayoutToast": true, + "enableMarkdown": true, + "enableMediaToast": false, + "enabled": true, + "location": "top_right", + "lowUrgencyDuration": 3, + "monitors": [ + ], + "normalUrgencyDuration": 8, + "overlayLayer": true, + "respectExpireTimeout": false, + "saveToHistory": { + "critical": true, + "low": true, + "normal": true + }, + "sounds": { + "criticalSoundFile": "", + "enabled": false, + "excludedApps": "discord,firefox,chrome,chromium,edge", + "lowSoundFile": "", + "normalSoundFile": "", + "separateSounds": false, + "volume": 0.5 + } + }, + "osd": { + "autoHideMs": 2000, + "backgroundOpacity": 1, + "enabled": true, + "enabledTypes": [ + 0, + 1, + 2 + ], + "location": "top_right", + "monitors": [ + ], + "overlayLayer": true + }, + "plugins": { + "autoUpdate": false, + "notifyUpdates": true + }, + "sessionMenu": { + "countdownDuration": 10000, + "enableCountdown": true, + "largeButtonsLayout": "single-row", + "largeButtonsStyle": true, + "position": "center", + "powerOptions": [ + { + "action": "lock", + "command": "", + "countdownEnabled": true, + "enabled": true, + "keybind": "1" + }, + { + "action": "suspend", + "command": "", + "countdownEnabled": true, + "enabled": true, + "keybind": "2" + }, + { + "action": "hibernate", + "command": "", + "countdownEnabled": true, + "enabled": true, + "keybind": "3" + }, + { + "action": "reboot", + "command": "", + "countdownEnabled": true, + "enabled": true, + "keybind": "4" + }, + { + "action": "logout", + "command": "", + "countdownEnabled": true, + "enabled": true, + "keybind": "5" + }, + { + "action": "shutdown", + "command": "", + "countdownEnabled": true, + "enabled": true, + "keybind": "6" + }, + { + "action": "rebootToUefi", + "command": "", + "countdownEnabled": true, + "enabled": true, + "keybind": "7" + }, + { + "action": "userspaceReboot", + "command": "", + "countdownEnabled": true, + "enabled": false, + "keybind": "" + } + ], + "showHeader": true, + "showKeybinds": true + }, + "settingsVersion": 59, + "systemMonitor": { + "batteryCriticalThreshold": 5, + "batteryWarningThreshold": 20, + "cpuCriticalThreshold": 90, + "cpuWarningThreshold": 80, + "criticalColor": "#f38ba8", + "diskAvailCriticalThreshold": 10, + "diskAvailWarningThreshold": 20, + "diskCriticalThreshold": 90, + "diskWarningThreshold": 80, + "enableDgpuMonitoring": false, + "externalMonitor": "resources || missioncenter || jdsystemmonitor || corestats || system-monitoring-center || gnome-system-monitor || plasma-systemmonitor || mate-system-monitor || ukui-system-monitor || deepin-system-monitor || pantheon-system-monitor", + "gpuCriticalThreshold": 90, + "gpuWarningThreshold": 80, + "memCriticalThreshold": 90, + "memWarningThreshold": 80, + "swapCriticalThreshold": 90, + "swapWarningThreshold": 80, + "tempCriticalThreshold": 90, + "tempWarningThreshold": 80, + "useCustomColors": false, + "warningColor": "#94e2d5" + }, + "templates": { + "activeTemplates": [ + { + "enabled": true, + "id": "btop" + }, + { + "enabled": true, + "id": "discord" + }, + { + "enabled": true, + "id": "hyprland" + }, + { + "enabled": true, + "id": "hyprtoolkit" + }, + { + "enabled": true, + "id": "kitty" + }, + { + "enabled": true, + "id": "starship" + }, + { + "enabled": true, + "id": "code" + }, + { + "enabled": true, + "id": "telegram" + }, + { + "enabled": true, + "id": "qt" + }, + { + "enabled": true, + "id": "steam" + } + ], + "enableUserTheming": false + }, + "ui": { + "boxBorderEnabled": false, + "fontDefault": "Noto Sans", + "fontDefaultScale": 1, + "fontFixed": "monospace", + "fontFixedScale": 1, + "panelBackgroundOpacity": 0.93, + "panelsAttachedToBar": true, + "scrollbarAlwaysVisible": true, + "settingsPanelMode": "attached", + "settingsPanelSideBarCardStyle": false, + "tooltipsEnabled": true, + "translucentWidgets": false + }, + "wallpaper": { + "automationEnabled": false, + "directory": "/home/domenico/Immagini/Wallpapers", + "enableMultiMonitorDirectories": false, + "enabled": true, + "favorites": [ + ], + "fillColor": "#000000", + "fillMode": "crop", + "hideWallpaperFilenames": false, + "linkLightAndDarkWallpapers": true, + "monitorDirectories": [ + ], + "overviewBlur": 0.15, + "overviewEnabled": true, + "overviewTint": 0.6, + "panelPosition": "center", + "randomIntervalSec": 300, + "setWallpaperOnAllMonitors": true, + "showHiddenFiles": false, + "skipStartupTransition": false, + "solidColor": "#1a1a2e", + "sortOrder": "name", + "transitionDuration": 1500, + "transitionEdgeSmoothness": 0.05, + "transitionType": [ + "fade", + "disc", + "stripes", + "wipe", + "pixelate", + "honeycomb" + ], + "useOriginalImages": false, + "useSolidColor": false, + "useWallhaven": false, + "viewMode": "single", + "wallhavenApiKey": "", + "wallhavenCategories": "111", + "wallhavenOrder": "desc", + "wallhavenPurity": "100", + "wallhavenQuery": "", + "wallhavenRatios": "", + "wallhavenResolutionHeight": "", + "wallhavenResolutionMode": "atleast", + "wallhavenResolutionWidth": "", + "wallhavenSorting": "relevance", + "wallpaperChangeMode": "random" + } +}