diff --git a/xtream-desklet-deck@sentinela-one/README.md b/xtream-desklet-deck@sentinela-one/README.md new file mode 100644 index 000000000..2dc70535c --- /dev/null +++ b/xtream-desklet-deck@sentinela-one/README.md @@ -0,0 +1,35 @@ +Xtream Deck +=========== + +![Xtream Deck on the desktop](hero.png) + +![Desklet screenshot](screenshot.png) + +A lightweight, native Stream Deck-style button grid for the Cinnamon desktop. No heavy +background app, no separate process - it's a regular Cinnamon desklet. + +Each instance shows a 5x2 grid of buttons, with up to 5 pages (50 buttons total per instance). +Every button has its own label, command, icon, and background color - all configured directly +inside the panel, no external settings screen needed. Comes with the full Font Awesome Free +icon set bundled in, plus support for uploading your own PNG/JPG/GIF icons and picking any +custom hex color for a button's background. + +You can add multiple instances on your desktop, each keeping its own independent configuration. + +Features +-------- + - Full button editor embedded in the panel (no separate settings window) + - Up to 5 pages per instance, 10 buttons per page + - Bundled Font Awesome icon set with search, plus custom image upload + - Custom background color per button (palette or hex) + - Drag-and-drop reordering between slots + +![Right-click menu](context-menu.png) + +![Button editor](screenshot2.png) + +![Icon picker](screenshot3.png) + +Source +------ +https://github.com/sentinela-one/xtream-desklet-deck diff --git a/xtream-desklet-deck@sentinela-one/context-menu.png b/xtream-desklet-deck@sentinela-one/context-menu.png new file mode 100644 index 000000000..56958fad5 Binary files /dev/null and b/xtream-desklet-deck@sentinela-one/context-menu.png differ diff --git a/xtream-desklet-deck@sentinela-one/files/xtream-desklet-deck@sentinela-one/LICENSE b/xtream-desklet-deck@sentinela-one/files/xtream-desklet-deck@sentinela-one/LICENSE new file mode 100644 index 000000000..fb24ecac1 --- /dev/null +++ b/xtream-desklet-deck@sentinela-one/files/xtream-desklet-deck@sentinela-one/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Wellington Oliveira + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/xtream-desklet-deck@sentinela-one/files/xtream-desklet-deck@sentinela-one/README.md b/xtream-desklet-deck@sentinela-one/files/xtream-desklet-deck@sentinela-one/README.md new file mode 100644 index 000000000..a121ea4cd --- /dev/null +++ b/xtream-desklet-deck@sentinela-one/files/xtream-desklet-deck@sentinela-one/README.md @@ -0,0 +1,154 @@ +# Xtream Deck + +A lightweight, native Stream Deck-style button grid for the **Cinnamon desktop**. No heavy +background app, no separate process — it's a regular Cinnamon desklet, so it's basically free in +terms of CPU/RAM. + +Each instance shows a 5x2 grid of buttons, with up to 5 pages (50 buttons total per instance). +Every button has its own label, command, icon, and background color — all configured directly +inside the panel, no external settings screen needed. Comes with the full [Font Awesome +Free](https://fontawesome.com) icon set (7.3.1, ~2,900 icons) bundled in, so you don't need +anything installed on your system to have icons available, plus support for uploading your own +PNG icons and picking any custom hex color for a button's background. + +You can add **multiple instances** on your desktop — each one keeps its own independent +configuration. + +**Compatibility**: built against and validated on **Linux Mint 21.2 "Victoria" (Cinnamon 5.8.4)**. +It's plain Cinnamon `St`/`Clutter` desklet code with no Mint-specific dependency, so it's expected +to work on other Cinnamon-based distros and other Cinnamon 4.x/5.x versions too — just not +individually tested against every one of them. + +## Why this exists + +Full Stream Deck-style apps (StreamController, etc.) are great but heavy: separate GTK process, +plugin system, always running. If all you need is "click a button on the desktop, run a command", +a native Cinnamon desklet does the same job for a fraction of the resource cost. + +## Requirements + +- A Cinnamon desktop (Linux Mint Cinnamon edition, or any distro running Cinnamon 4.x+ — validated + on Linux Mint 21.2/Cinnamon 5.8.4, see above). +- `git` installed. +- `zenity` installed for the custom PNG icon upload picker (already present by default on most + Cinnamon/GTK desktops, including Linux Mint). + +## Installation + +1. Clone this repository: + ```bash + git clone https://github.com/sentinela-one/xtream-desklet-deck.git + cd xtream-desklet-deck + ``` + +2. Link it into Cinnamon's desklets folder (Cinnamon only loads desklets from this exact path): + ```bash + mkdir -p ~/.local/share/cinnamon/desklets + ln -s "$(pwd)" ~/.local/share/cinnamon/desklets/xtream-desklet-deck@sentinela-one + ``` + +3. Add it to your desktop: + - Right-click on an empty area of the desktop. + - Click **Add Desklets**. + - Find **Xtream Deck** in the list and click **Add** (or double-click it). + - Repeat this step again if you want a second independent deck. + +## Configuring your buttons + +Everything is configured from inside the panel itself — there's no separate settings window and +no edit mode to turn on. Buttons behave the same way at all times: click to run, right-click or +click an empty slot to configure. + +1. **Open a button's editor**: left-click an empty slot to configure it directly, or right-click + any slot — empty or already configured — and pick **Edit** from the small menu: + - **Label** — text shown under the icon. Its color automatically switches between black and + white depending on the button's own background color, so it stays readable either way. + - **Show Title** — toggle next to the label field; turns the label under the icon on/off on + the grid without deleting the text. + - **Command** — any shell command or script path, run when you click the button. + - **Icon** — click **Pick icon…** and either search the bundled Font Awesome set (type e.g. + `microphone`, `camera`, `play`; hover a result to see its full name before picking it), or + upload your own PNG/JPG/GIF (max 2MB) from the panel next to it. + - **Color** — pick a background color from the palette (the selected swatch gets a white + border), clear it back to the default dark background, or type any custom color as a hex + value (e.g. `#CC99CC`) in the field next to the palette. + - **Clear button** wipes the slot back to empty. **Save**/**Cancel**/the **X** close the + editor; closing without saving discards your changes. **Esc** also closes the right-click + menu if one is open. +2. **Run a button**: once it has a command, a plain left-click runs it. +3. **Reorder buttons**: press and hold a button, drag it onto another slot and release — they + swap places. No mode to enable first, this works anywhere on the grid. +4. **Move a button to another page**: right-click a configured slot and pick **Move to next + page** / **Move to previous page** (only shown when the target page has a free slot). +5. Use the numbered dots at the bottom to switch pages. A **+** button next to them adds a new + page (up to 5 pages per deck, confirmation required). Right-click any dot except the first one + to remove that page (confirmation required — this deletes every button configured on it). +6. The **gear icon** in the top-right corner opens the deck's About/Remove/Support menu (same + menu a right-click on the deck itself opens). + +### Example button + +| Field | Value | +|---------|-------| +| Label | `Mute` | +| Command | `amixer set Master toggle` | +| Icon | search `microphone-slash` in the icon picker | + +Any shell command works: launching an app, running a script you wrote, toggling something via +`amixer`/`nmcli`/`obs-cmd`/whatever CLI tool you have — if it runs from a terminal, it runs from a +button here. A common pattern is a button that opens VS Code in a project and, via a +`.vscode/tasks.json` with `"runOptions": {"runOn": "folderOpen"}` in that project, has Claude Code +already running in the integrated terminal by the time the window opens. + +## Uninstalling + +- Right-click the desklet → **Remove**, to remove it from the desktop. +- Delete the symlink to fully remove it from Cinnamon: + ```bash + rm ~/.local/share/cinnamon/desklets/xtream-desklet-deck@sentinela-one + ``` + +## How it works / project layout + +- `metadata.json` — desklet identity (uuid, name, `max-instances: -1` so you can add as many as + you want). +- `desklet.js` — renders the header/grid/pagination, the in-panel button editor and dialogs + (`ButtonEditorDialog`, `IconPickerDialog`, `ConfirmDialog`), drag-to-swap reordering (hand-rolled + on top of `Clutter`'s low-level pointer events, not Cinnamon's `imports.ui.dnd`), and runs the + configured command on click (`Util.spawnCommandLine`), using Cinnamon's own `St`/`Clutter` + toolkit — no extra runtime. +- `icons/fontawesome/` — the bundled Font Awesome Free 7.3.1 icon set (`solid`, `regular`, + `brands`) plus `manifest.json` used to power the in-panel icon search, and the upstream + `LICENSE.txt`. See [Attribution](#attribution) below. + +Each instance's button configuration (labels, commands, icons, colors, show-title flag, pages) is +stored as plain JSON at `~/.config/xtream-desklet-deck/instances/.json` — not inside +this repository, so your personal commands and paths never need to touch this repo or any fork of +it. If an instance's own state file doesn't exist yet (e.g. right after removing and re-adding the +desklet, which Cinnamon gives a new instance id), it falls back to the most recently saved state +file instead of starting empty. Custom PNG icons you upload are copied into +`~/.config/xtream-desklet-deck/custom_icons/` for the same reason — kept alongside your instance +state, not wherever the original file happened to live. + +## Attribution + +Icons: [Font Awesome Free](https://fontawesome.com) by Fonticons, Inc., licensed under +[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) (icons) and +[SIL OFL 1.1](https://scripts.sil.org/OFL) (fonts, unused here — this project only uses the SVG +icon files). Full license text bundled at `icons/fontawesome/LICENSE.txt`. + +## License + +MIT — see [LICENSE](LICENSE). (Note: the bundled Font Awesome assets under `icons/fontawesome/` +keep their own upstream license, see above.) + +## Support + +Xtream Deck was built with care to help professionals be more productive. If it earns a spot on +your desktop, consider [buying me a coffee on Ko-fi](https://ko-fi.com/oliveirawro) — the little +coin icon in the bottom-right corner of the deck (and "❤ Support development" in its right-click +menu) link there too. + +## Author + +Wellington Oliveira — oliveira@woliveira.net diff --git a/xtream-desklet-deck@sentinela-one/files/xtream-desklet-deck@sentinela-one/desklet.js b/xtream-desklet-deck@sentinela-one/files/xtream-desklet-deck@sentinela-one/desklet.js new file mode 100644 index 000000000..9a828c204 --- /dev/null +++ b/xtream-desklet-deck@sentinela-one/files/xtream-desklet-deck@sentinela-one/desklet.js @@ -0,0 +1,1382 @@ +const Desklet = imports.ui.desklet; +const ModalDialog = imports.ui.modalDialog; +const Tooltips = imports.ui.tooltips; +const PopupMenu = imports.ui.popupMenu; +const Main = imports.ui.main; +const Mainloop = imports.mainloop; +const St = imports.gi.St; +const Gio = imports.gi.Gio; +const GLib = imports.gi.GLib; +const Clutter = imports.gi.Clutter; +const Util = imports.misc.util; +const ByteArray = imports.byteArray; + +const COLUMNS = 5; +const ROWS = 2; +const SLOTS_PER_PAGE = COLUMNS * ROWS; +const MAX_PAGES = 5; +const ICON_SIZE = 34; +const BUTTON_SIZE = 64; +const SLOT_MARGIN = 7; +const GRID_EDIT_BORDER_COLOR = "rgba(255,255,255,0.8)"; +const CUSTOM_ICON_MAX_BYTES = 2 * 1024 * 1024; +const DONATE_URL = "https://ko-fi.com/oliveirawro"; +const DONATE_COIN_COLOR = "#FFD729"; + +const PALETTE = [ + "#e6194b", "#e91e8c", "#f39c12", "#9b59b6", "#2D6DD9", + "#19BC97", "#1abc9c", "#ffffff", "#34495e", "#7f8c8d", "#2c2c2c" +]; + +const FOOTER_BUTTON_STYLE_BASE = "border: none; border-radius: 10px; padding: 8px 18px; font-size: 18px; font-weight: normal; color: white; min-width: 120px; background-color: #2D6DD9;"; +const FOOTER_BUTTON_STYLE_HOVER = "border: none; border-radius: 10px; padding: 8px 18px; font-size: 18px; font-weight: normal; color: white; min-width: 120px; background-color: #FF4D00;"; + +// Inline-style the dialog footer buttons (Cinnamon's stylesheet reload for xlets is +// unreliable, so we avoid relying on an external CSS class here) and drive the hover +// color change directly off St.Button's own "hover" state. +function styleFooterButton(button) { + button.style = FOOTER_BUTTON_STYLE_BASE; + button.connect("notify::hover", () => { + button.style = button.hover ? FOOTER_BUTTON_STYLE_HOVER : FOOTER_BUTTON_STYLE_BASE; + }); +} + +function emptySlot() { + return { label: "", command: "", icon: "", color: "", showTitle: true }; +} + +function isEmptySlot(slot) { + return !slot.icon && !slot.command && !slot.label; +} + +function emptyPage() { + let slots = []; + for (let i = 0; i < SLOTS_PER_PAGE; i++) slots.push(emptySlot()); + return { slots: slots }; +} + +// Dialog: search & pick an icon from the bundled Font Awesome set. +class IconPickerDialog extends ModalDialog.ModalDialog { + // parentDialog (optional): the ModalDialog this picker was opened from (e.g. the + // button editor). Needed to fully drop Cinnamon's shell-wide input grab while an + // external file picker is up - see _hideForExternalPicker() below. + constructor(deskletPath, onPick, parentDialog) { + super({ styleClass: "xtream-deck-dialog" }); + this.contentLayout.style = "spacing: 18px; padding: 22px 22px;"; + this._deskletPath = deskletPath; + this._onPick = onPick; + this._parentDialog = parentDialog || null; + this._manifest = this._loadManifest(); + + let titleRow = new St.BoxLayout({ vertical: false }); + let title = new St.Label({ text: "Choose an icon", style: "font-weight: bold; color: white; font-size: 24px;" }); + titleRow.add(title, { expand: true, x_fill: true, x_align: St.Align.START, y_align: St.Align.MIDDLE }); + let closeBtn = new St.Button({ style: "width: 42px; height: 42px; border-radius: 21px; background-color: #2D6DD9; margin-right: 0; margin-top: 0;" }); + let closeGicon = makeWhiteIconFile(deskletPath, "solid", "xmark"); + if (closeGicon) closeBtn.set_child(new St.Icon({ gicon: closeGicon, icon_size: 22 })); + closeBtn.connect("clicked", () => this.close()); + titleRow.add(closeBtn, { y_align: St.Align.START }); + this.contentLayout.add(titleRow); + + let libraryColumn = new St.BoxLayout({ vertical: true, style: "spacing: 10px;" }); + libraryColumn.add(new St.Label({ text: "SELECT FROM LIBRARY", style_class: "xtream-deck-field-label" })); + + this._entry = new St.Entry({ style_class: "xtream-deck-entry", hint_text: "Search icons…" }); + let hint = new St.Label({ text: "Type to search, e.g. \"microphone\", \"camera\", \"play\". Showing first 24 matches.", style_class: "xtream-deck-hint" }); + let searchGroup = new St.BoxLayout({ vertical: true, style: "spacing: 6px;" }); + searchGroup.add(this._entry); + searchGroup.add(hint); + libraryColumn.add(searchGroup); + this.setInitialKeyFocus(this._entry.clutter_text); + + this._resultsBin = new St.Bin({ style: "width: 420px; min-height: 220px;" }); + libraryColumn.add(this._resultsBin); + + this._entry.clutter_text.connect("text-changed", () => this._renderResults(this._entry.get_text())); + this._renderResults(""); + + let divider = new St.Bin({ style: "width: 1px; background-color: rgba(255,255,255,0.15);" }); + + let uploadColumn = new St.BoxLayout({ vertical: true, style: "spacing: 10px; width: 220px;" }); + uploadColumn.add(new St.Label({ text: "UPLOAD CUSTOM ICON", style_class: "xtream-deck-field-label" })); + uploadColumn.add(this._makeUploadDropzone()); + this._uploadStatus = new St.Label({ text: "", style: "color: #e6194b; font-size: 12px;" }); + this._uploadStatus.clutter_text.line_wrap = true; + uploadColumn.add(this._uploadStatus); + + let bodyRow = new St.BoxLayout({ vertical: false, style: "spacing: 22px;" }); + bodyRow.add(libraryColumn); + bodyRow.add(divider, { y_fill: true, y_align: St.Align.START }); + bodyRow.add(uploadColumn); + this.contentLayout.add(bodyRow); + + this.setButtons([ + { label: "No icon", action: () => { this._onPick(""); this.close(); } }, + { label: "Close", action: () => this.close() } + ]); + this._buttonLayout.style = "spacing: 14px;"; + for (let button of this._buttonLayout.get_children()) { + styleFooterButton(button); + } + } + + // Dashed borders don't render on this toolkit when combined with border-radius + // (confirmed limitation - see memory), so the dropzone uses a solid border instead + // of the dashed one from the original mockup. + _makeUploadDropzone() { + let dropzone = new St.Button({ + style: "width: 220px; background-color: rgba(255,255,255,0.05); " + + "border: 2px solid rgba(255,255,255,0.4); border-radius: 8px; padding: 22px 14px;" + }); + dropzone.set_pivot_point(0.5, 0.5); + dropzone.connect("notify::hover", () => { + dropzone.style = "width: 220px; background-color: rgba(255,255,255,0.05); " + + "border-radius: 8px; padding: 22px 14px; border: 2px solid " + + (dropzone.hover ? "white" : "rgba(255,255,255,0.4)") + ";"; + }); + + let content = new St.BoxLayout({ vertical: true, x_align: St.Align.MIDDLE, style: "spacing: 10px;" }); + let cloudGicon = makeWhiteIconFile(this._deskletPath, "solid", "cloud-arrow-up"); + if (cloudGicon) { + content.add(new St.Icon({ gicon: cloudGicon, icon_size: 32, opacity: 220 }), { x_align: St.Align.MIDDLE }); + } + let text = new St.Label({ text: "Click to upload an icon.", style: "color: white; font-size: 14px; text-align: center;" }); + text.clutter_text.line_wrap = true; + content.add(text, { x_align: St.Align.MIDDLE }); + let subtext = new St.Label({ text: "PNG, JPG or GIF. Max size: 2MB.", style_class: "xtream-deck-hint" }); + content.add(subtext, { x_align: St.Align.MIDDLE }); + dropzone.set_child(content); + + dropzone.connect("clicked", () => { + this._uploadStatus.text = ""; + this._hideForExternalPicker(); + pickImageFileAsync((sourcePath) => { + this._showAfterExternalPicker(); + if (!sourcePath) return; + importCustomImageIcon( + sourcePath, + (destPath) => { + this._onPick(destPath); + this.close(); + }, + (message) => { this._uploadStatus.text = message; } + ); + }); + }); + + return dropzone; + } + + // Cinnamon's modal dialogs take a shell-wide input grab (global.begin_modal) that + // stays in effect as long as ANY dialog in the stack is pushed - popping just this + // dialog isn't enough, since the parent (button editor) below it still holds its + // own modal push. Without dropping both, zenity's window renders behind the shell's + // dimmed overlay and can't receive any input. Hide (not close/destroy) so all field + // values in the parent dialog survive. + _hideForExternalPicker() { + this.popModal(); + this._group.hide(); + if (this._parentDialog) { + this._parentDialog.popModal(); + this._parentDialog._group.hide(); + } + } + + _showAfterExternalPicker() { + if (this._parentDialog) { + this._parentDialog._group.show(); + this._parentDialog.pushModal(); + } + this._group.show(); + this.pushModal(); + } + + _loadManifest() { + try { + let path = this._deskletPath + "/icons/fontawesome/manifest.json"; + let [ok, contents] = GLib.file_get_contents(path); + if (!ok) return []; + return JSON.parse(ByteArray.toString(contents)); + } catch (e) { + global.logError("xtream-desklet-deck: failed to load icon manifest: " + e); + return []; + } + } + + _renderResults(query) { + let q = query.trim().toLowerCase(); + let matches = this._manifest.filter(i => !q || i.name.includes(q)); + matches = matches.slice(0, 24); + + if (matches.length === 0) { + this._resultsBin.set_child(new St.Label({ text: q ? "No icons match \"" + q + "\"." : "No icons available.", style: "color: #999; padding: 12px;" })); + return; + } + + let cols = 6; + let grid = new St.Table({ homogeneous: false }); + for (let idx = 0; idx < matches.length; idx++) { + let item = matches[idx]; + let row = Math.floor(idx / cols); + let col = idx % cols; + + let btn = new St.Button({ style: "width: 56px; height: 56px; margin: 3px; background-color: rgba(255,255,255,0.08); border-radius: 6px;" }); + let gicon = makeWhiteIconFile(this._deskletPath, item.style, item.name); + if (gicon) { + btn.set_child(new St.Icon({ gicon: gicon, icon_size: 28 })); + } else { + btn.set_child(new St.Label({ text: "?", style: "color: white;" })); + } + let tooltip = new Tooltips.Tooltip(btn, item.name); + tooltip._tooltip.style = "font-size: 20px; font-weight: bold; padding: 8px 14px;"; + btn.connect("clicked", () => { + this._onPick("fa:" + item.style + ":" + item.name); + this.close(); + }); + grid.add(btn, { row: row, col: col, x_expand: false, y_expand: false }); + } + this._resultsBin.set_child(grid); + } +} + +// Small reusable confirmation dialog, styled to match the rest of Xtream Deck. +class ConfirmDialog extends ModalDialog.ModalDialog { + constructor(deskletPath, titleText, messageText, confirmLabel, onConfirm, confirmColor) { + super({ styleClass: "xtream-deck-dialog" }); + this.contentLayout.style = "spacing: 16px; padding: 22px 22px;"; + + let title = new St.Label({ text: titleText, style: "font-weight: bold; color: white; font-size: 24px;" }); + this.contentLayout.add(title, { x_align: St.Align.START }); + + let message = new St.Label({ text: messageText, style: "color: #cccccc; font-size: 16px; width: 380px;" }); + message.clutter_text.line_wrap = true; + this.contentLayout.add(message); + + this.setButtons([ + { label: "Cancel", action: () => this.close() }, + { + label: confirmLabel, focused: true, action: () => { + this.close(); + onConfirm(); + } + } + ]); + + let children = this._buttonLayout.get_children(); + for (let button of children) { + if (button.label === "Cancel") { + button.style = "padding: 10px 16px; font-size: 15px; border-radius: 6px;"; + } else if (button.label === confirmLabel) { + button.style = "padding: 10px 16px; font-size: 15px; border-radius: 6px; background-color: " + (confirmColor || "#e6194b") + ";"; + } + } + } +} + +// Single-button informational dialog, styled like ConfirmDialog - used for messages +// that don't need a yes/no choice (e.g. "no slot available to move this button to"). +class InfoDialog extends ModalDialog.ModalDialog { + constructor(deskletPath, titleText, messageText) { + super({ styleClass: "xtream-deck-dialog" }); + this.contentLayout.style = "spacing: 16px; padding: 22px 22px;"; + + let title = new St.Label({ text: titleText, style: "font-weight: bold; color: white; font-size: 24px;" }); + this.contentLayout.add(title, { x_align: St.Align.START }); + + let message = new St.Label({ text: messageText, style: "color: #cccccc; font-size: 16px; width: 380px;" }); + message.clutter_text.line_wrap = true; + this.contentLayout.add(message); + + this.setButtons([ + { label: "OK", focused: true, action: () => this.close() } + ]); + + for (let button of this._buttonLayout.get_children()) { + button.style = "padding: 10px 16px; font-size: 15px; border-radius: 6px; background-color: #2D6DD9;"; + } + } +} + +// Dialog: edit a single button's label, command, icon and color. +class ButtonEditorDialog extends ModalDialog.ModalDialog { + constructor(deskletPath, slot, onSave, onClear) { + super({ styleClass: "xtream-deck-dialog" }); + this.contentLayout.style = "spacing: 22px; padding: 22px 22px;"; + this._deskletPath = deskletPath; + this._slot = { label: slot.label || "", command: slot.command || "", icon: slot.icon || "", color: slot.color || "", showTitle: slot.showTitle !== false }; + this._onSave = onSave; + this._onClear = onClear; + + let titleRow = new St.BoxLayout({ vertical: false }); + let title = new St.Label({ text: "Edit button", style: "font-weight: bold; color: white; font-size: 24px;" }); + titleRow.add(title, { expand: true, x_fill: true, x_align: St.Align.START, y_align: St.Align.MIDDLE }); + let closeBtn = new St.Button({ style: "width: 42px; height: 42px; border-radius: 21px; background-color: #2D6DD9; margin-right: 0; margin-top: 0;" }); + let closeGicon = makeWhiteIconFile(this._deskletPath, "solid", "xmark"); + if (closeGicon) closeBtn.set_child(new St.Icon({ gicon: closeGicon, icon_size: 22 })); + closeBtn.connect("clicked", () => this.close()); + titleRow.add(closeBtn, { y_align: St.Align.START }); + this.contentLayout.add(titleRow); + + this._labelEntry = new St.Entry({ style_class: "xtream-deck-entry", hint_text: "Enter button label", style: "width: 300px;" }); + this._labelEntry.set_text(this._slot.label); + this.setInitialKeyFocus(this._labelEntry.clutter_text); + + this._showTitleToggle = this._makeToggle(this._slot.showTitle, (value) => { this._slot.showTitle = value; }, 40); + + // Two BoxLayout columns side by side (not St.Table, which has an + // uncontrollable default gap between columns) - each column has its own + // header + control stacked vertically, so "Label"/"Show Title" line up + // on the same row without any extra space between the two columns. + let labelColumn = new St.BoxLayout({ vertical: true, style: "spacing: 6px;" }); + labelColumn.add(new St.Label({ text: "Label", style_class: "xtream-deck-field-label" })); + labelColumn.add(this._labelEntry); + + let showTitleColumn = new St.BoxLayout({ vertical: true, style: "spacing: 6px;" }); + showTitleColumn.add(new St.Label({ text: "Show Title", style_class: "xtream-deck-field-label" }), { x_align: St.Align.MIDDLE }); + showTitleColumn.add(this._showTitleToggle, { x_align: St.Align.MIDDLE }); + + let fieldRow = new St.BoxLayout({ vertical: false, style: "spacing: 18px;" }); + fieldRow.add(labelColumn); + fieldRow.add(showTitleColumn, { y_align: St.Align.START }); + + let labelGroup = new St.BoxLayout({ vertical: true, style: "spacing: 6px;" }); + labelGroup.add(fieldRow); + labelGroup.add(new St.Label({ text: "This is the text that will appear on the button. \"Show Title\" controls whether it's drawn under the icon on the grid.", style_class: "xtream-deck-hint" })); + this.contentLayout.add(labelGroup); + + this._commandEntry = new St.Entry({ style_class: "xtream-deck-entry", hint_text: "e.g. /home/user/scripts/my-script.sh" }); + this._commandEntry.set_text(this._slot.command); + this.contentLayout.add(this._makeFieldGroup("Command", this._commandEntry, "Command or script to run when the button is clicked.")); + + this._iconDropzone = new St.Button({ + style: "width: 460px; background-color: rgba(255,255,255,0.05); border: 2px solid rgba(255,255,255,0.5); border-radius: 8px; padding: 12px;" + }); + let iconRow = new St.BoxLayout({ vertical: false, style: "spacing: 16px;" }); + this._iconPreview = new St.Bin({ style: "width: 44px; height: 44px; background-color: rgba(255,255,255,0.1); border-radius: 6px;" }); + iconRow.add(this._iconPreview, { y_align: St.Align.MIDDLE, y_fill: false }); + let pickIconLabelBin = new St.Bin({ y_align: St.Align.MIDDLE }); + pickIconLabelBin.set_child(new St.Label({ text: "Pick icon…", style: "color: white; font-size: 16px;" })); + iconRow.add(pickIconLabelBin, { y_align: St.Align.MIDDLE, y_fill: false }); + this._iconDropzone.set_child(iconRow); + this._iconDropzone.connect("clicked", () => { + let picker = new IconPickerDialog(this._deskletPath, (iconRef) => { + this._slot.icon = iconRef; + this._updateIconPreview(); + }, this); + picker.open(); + }); + this._updateIconPreview(); + this.contentLayout.add(this._makeFieldGroup("Icon", this._iconDropzone, "Choose an icon to represent this button.")); + + let colorRow = new St.BoxLayout({ vertical: false, style: "spacing: 10px;" }); + let colorSwatches = []; + + // Every swatch (and the hex preview) always shows a 1px border matching the + // main grid's own edit-mode button border, so they read as the same kind of + // "slot" - the 3px white border on top of that is purely the selection marker. + const applySwatchStyles = () => { + for (let entry of colorSwatches) { + let selected = entry.colorValue === this._slot.color; + let border = selected + ? "border: 3px solid white;" + : "border: 1px solid " + GRID_EDIT_BORDER_COLOR + ";"; + let bg = entry.colorValue ? "background-color: " + entry.colorValue + ";" : ""; + entry.button.style = "width: 34px; height: 34px; border-radius: 8px; " + bg + " " + border; + } + this._hexPreview.style = "width: 34px; height: 34px; border-radius: 8px; border: 1px solid " + + GRID_EDIT_BORDER_COLOR + ";" + (this._slot.color ? " background-color: " + this._slot.color + ";" : ""); + }; + + // Palette/no-color swatches also mirror their value into the hex field, so + // it always reflects whatever is actually selected. + const selectColor = (value) => { + this._slot.color = value; + this._hexEntry.set_text(value); + applySwatchStyles(); + }; + + for (let c of PALETTE) { + let swatch = new St.Button(); + swatch.connect("clicked", () => selectColor(c)); + colorRow.add(swatch, { y_align: St.Align.MIDDLE }); + colorSwatches.push({ button: swatch, colorValue: c }); + } + let noColorBtn = new St.Button(); + noColorBtn.connect("clicked", () => selectColor("")); + colorRow.add(noColorBtn, { y_align: St.Align.MIDDLE }); + colorSwatches.push({ button: noColorBtn, colorValue: "" }); + + let customRow = new St.BoxLayout({ vertical: false, style: "spacing: 10px;" }); + this._hexEntry = new St.Entry({ style_class: "xtream-deck-entry", hint_text: "#RRGGBB", style: "width: 110px;" }); + this._hexEntry.set_text(this._slot.color || ""); + this._hexEntry.clutter_text.connect("text-changed", () => { + let value = this._hexEntry.get_text().trim(); + if (/^#[0-9a-fA-F]{6}$/.test(value)) { + this._slot.color = value; + applySwatchStyles(); + } + }); + customRow.add(this._hexEntry, { y_align: St.Align.MIDDLE }); + this._hexPreview = new St.Bin(); + customRow.add(this._hexPreview, { y_align: St.Align.MIDDLE }); + + applySwatchStyles(); + + // Palette and hex columns side by side, each with its own header label, so + // "Color" and "Hex color:" sit on the same row instead of the hex label + // trailing lower, stacked only above its own entry. + let paletteColumn = new St.BoxLayout({ vertical: true, style: "spacing: 6px;" }); + paletteColumn.add(new St.Label({ text: "Color", style_class: "xtream-deck-field-label" })); + paletteColumn.add(colorRow); + paletteColumn.add(new St.Label({ text: "Select a color for the button, or enter a custom hex value.", style_class: "xtream-deck-hint" })); + + let divider = new St.Bin({ style: "width: 1px; background-color: rgba(255,255,255,0.15);" }); + + let hexColumn = new St.BoxLayout({ vertical: true, style: "spacing: 6px;" }); + hexColumn.add(new St.Label({ text: "Hex color:", style_class: "xtream-deck-field-label" })); + hexColumn.add(customRow); + + let colorGroup = new St.BoxLayout({ vertical: false, style: "spacing: 22px;" }); + colorGroup.add(paletteColumn); + colorGroup.add(divider, { y_fill: true, y_align: St.Align.START }); + colorGroup.add(hexColumn); + this.contentLayout.add(colorGroup); + + this.setButtons([ + { label: "Clear button", action: () => { onClear(); this.close(); } }, + { label: "Cancel", action: () => this.close() }, + { + label: "Save", focused: true, action: () => { + this._slot.label = this._labelEntry.get_text(); + this._slot.command = this._commandEntry.get_text(); + this._onSave(this._slot); + this.close(); + } + } + ]); + this._styleFooterButtons(); + } + + _makeFieldGroup(labelText, contentActor, hintText) { + let group = new St.BoxLayout({ vertical: true, style: "spacing: 6px;" }); + group.add(new St.Label({ text: labelText, style_class: "xtream-deck-field-label" })); + group.add(contentActor); + group.add(new St.Label({ text: hintText, style_class: "xtream-deck-hint" })); + return group; + } + + // Pill-shaped ON/OFF toggle: green with "ON" + a white dot on the right when + // on, red with a white dot + "OFF" on the left when off. + _makeToggle(initialValue, onChange, height) { + let value = !!initialValue; + let btn = new St.Button(); + let h = height || 26; + let thumbSize = h - 6; + + const render = () => { + let bg = value ? "#19BC97" : "#e6194b"; + // Asymmetric padding (CSS "top right bottom left" shorthand): zero on + // whichever side the thumb sits on, so it touches that edge exactly - + // OFF's thumb sits left, ON's sits right. The far side keeps normal + // padding for the ON/OFF text. + let padding = value ? "0 0 0 6px" : "0 6px 0 0"; + btn.style = "width: 84px; height: " + h + "px; border-radius: " + (h / 2) + "px; padding: " + padding + "; background-color: " + bg + ";"; + + let row = new St.BoxLayout({ vertical: false, style: "spacing: 6px;" }); + let thumb = new St.Bin({ style: "width: " + thumbSize + "px; height: " + thumbSize + "px; border-radius: " + (thumbSize / 2) + "px; background-color: white;" }); + let text = new St.Label({ text: value ? "ON" : "OFF", style: "color: white; font-size: 12px; font-weight: bold;" }); + let textBin = new St.Bin({ x_align: St.Align.MIDDLE, y_align: St.Align.MIDDLE }); + textBin.set_child(text); + + if (value) { + row.add(textBin, { expand: true, x_fill: true, y_align: St.Align.MIDDLE }); + row.add(thumb, { y_align: St.Align.MIDDLE }); + } else { + row.add(thumb, { y_align: St.Align.MIDDLE }); + row.add(textBin, { expand: true, x_fill: true, y_align: St.Align.MIDDLE }); + } + btn.set_child(row); + }; + render(); + + btn.connect("clicked", () => { + value = !value; + render(); + onChange(value); + }); + + return btn; + } + + _iconLabelButtonChild(iconName, text) { + let box = new St.BoxLayout({ vertical: false, style: "spacing: 8px;" }); + let gicon = makeWhiteIconFile(this._deskletPath, "solid", iconName); + if (gicon) box.add(new St.Icon({ gicon: gicon, icon_size: 18 }), { y_align: St.Align.MIDDLE }); + box.add(new St.Label({ text: text, style: "color: white; font-size: 18px; font-weight: normal;" }), { y_align: St.Align.MIDDLE }); + return box; + } + + _styleFooterButtons() { + this._buttonLayout.style = "spacing: 14px;"; + let children = this._buttonLayout.get_children(); + for (let button of children) { + let originalLabel = button.label; + styleFooterButton(button); + if (originalLabel === "Clear button") { + button.label = ""; + button.set_child(this._iconLabelButtonChild("trash", "Clear button")); + } else if (originalLabel === "Save") { + button.label = ""; + button.set_child(this._iconLabelButtonChild("floppy-disk", "Save")); + } + } + } + + _updateIconPreview() { + this._iconPreview.destroy_all_children(); + let gicon = resolveIconGicon(this._deskletPath, this._slot.icon); + if (!gicon) { + gicon = makeWhiteIconFile(this._deskletPath, "solid", "image"); + } + if (gicon) { + let opacity = this._slot.icon ? 255 : 130; + this._iconPreview.set_child(new St.Icon({ gicon: gicon, icon_size: 22, opacity: opacity })); + } + } +} + +// Recolor a Font Awesome SVG (fill="currentColor") - white by default, so icons stay +// legible on any background color, or an explicit hexColor (e.g. the donate coin) - +// and cache it. Cache filename only gets a color suffix for non-white recolors, so +// existing white icon caches stay untouched. +function makeWhiteIconFile(deskletPath, style, name, hexColor) { + let color = hexColor || "#ffffff"; + try { + let cacheDir = GLib.get_user_cache_dir() + "/xtream-desklet-deck/icons"; + GLib.mkdir_with_parents(cacheDir, 0o755); + let suffix = color === "#ffffff" ? "" : "-" + color.replace("#", ""); + let cachedPath = cacheDir + "/" + style + "-" + name + suffix + ".svg"; + let cacheFile = Gio.File.new_for_path(cachedPath); + if (!cacheFile.query_exists(null)) { + let srcPath = deskletPath + "/icons/fontawesome/" + style + "/" + name + ".svg"; + let [ok, contents] = GLib.file_get_contents(srcPath); + if (!ok) return null; + let svg = ByteArray.toString(contents).replace(/currentColor/g, color); + GLib.file_set_contents(cachedPath, svg); + } + return Gio.icon_new_for_string(cachedPath); + } catch (e) { + global.logError("xtream-desklet-deck: failed to prepare icon " + style + "/" + name + ": " + e); + return null; + } +} + +// Same recolor-and-cache approach as makeWhiteIconFile, for one-off custom SVGs that +// live outside the bundled Font Awesome set (icons/custom/) - keeps those out of the +// Font Awesome folder so attribution/licensing there stays accurate. +function makeCustomIconFile(deskletPath, name, hexColor) { + let color = hexColor || "#ffffff"; + try { + let cacheDir = GLib.get_user_cache_dir() + "/xtream-desklet-deck/icons"; + GLib.mkdir_with_parents(cacheDir, 0o755); + let cachedPath = cacheDir + "/custom-" + name + "-" + color.replace("#", "") + ".svg"; + let cacheFile = Gio.File.new_for_path(cachedPath); + if (!cacheFile.query_exists(null)) { + let srcPath = deskletPath + "/icons/custom/" + name + ".svg"; + let [ok, contents] = GLib.file_get_contents(srcPath); + if (!ok) return null; + let svg = ByteArray.toString(contents).replace(/currentColor/g, color); + GLib.file_set_contents(cachedPath, svg); + } + return Gio.icon_new_for_string(cachedPath); + } catch (e) { + global.logError("xtream-desklet-deck: failed to prepare custom icon " + name + ": " + e); + return null; + } +} + +// Perceived-brightness (YIQ) check, standard threshold - decides whether a slot's +// label needs dark or light text to stay readable against its own custom background. +function isLightColor(hex) { + if (!hex || hex[0] !== "#" || hex.length !== 7) return false; + let r = parseInt(hex.substr(1, 2), 16); + let g = parseInt(hex.substr(3, 2), 16); + let b = parseInt(hex.substr(5, 2), 16); + let brightness = (r * 299 + g * 587 + b * 114) / 1000; + return brightness > 150; +} + +function labelColorForSlot(slot) { + return (slot.color && isLightColor(slot.color)) ? "#1a1a1a" : "white"; +} + +// Resolve a stored icon reference to a Gio icon. Supports three formats: +// - "fa: