// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. import Quickshell import Quickshell.Io import QtQuick import QtQuick.Controls import "../.." import "../appearance" import "Sonix.js" as S import Qt.labs.platform as P // The boot logo editor: the canvas on the left, its controls on the right; // Save and Upload live in the page header (BootlogoModule.qml) and call // save() and upload() here. Row { id: tab spacing: 24 // Edited locally, written to Prefs after a pause, not on every drag step. property var cfg: Object.assign({}, Prefs.bootlogo) function set(ch) { tab.cfg = Object.assign({}, tab.cfg, ch); cfgWrite.restart(); } Timer { id: cfgWrite; interval: 600; onTriggered: Prefs.setBootlogo(tab.cfg) } // Keeps the last edit when the page closes inside the pause. Component.onDestruction: if (cfgWrite.running) Prefs.setBootlogo(tab.cfg) readonly property string folder: tab.cfg.folder || Quickshell.env("HOME") + "/Pictures/sonix-bootlogo" readonly property string cardDir: "/data/mnt/sd_0/Bootlogo" property bool busy: false property bool waiting: false property string status: "" property bool statusError: false readonly property bool canSave: canvas.ready && !tab.busy function say(t, err) { tab.status = t; tab.statusError = !!err; } readonly property var swatches: ["#ffffff", "#000000", "#3584e4", "#ff7800", "#e5a50a", "#2ec27e", "#9141ac", "#d56199", "udt"] readonly property var fontKeys: [["bungee", "Bungee + Inline"], ["mono", "Major Mono"], ["michroma", "Michroma"], ["audiowide", "Audiowide"]] // --- save and upload -------------------------------------------------- // then(file) runs after a successful save. function save(then) { tab.busy = true; mk.then = then || null; mk.command = ["mkdir", "-p", tab.folder]; mk.running = false; mk.running = true; } Process { id: mk property var then: null onExited: code => { if (code !== 0) { tab.busy = false; tab.say(`Cannot create ${tab.folder}`, true); return; } const base = tab.folder + "/" + S.stamp(new Date()); canvas.save(base, (ok, err) => { tab.busy = false; if (!ok) { tab.say(err, true); return; } tab.say(`Saved ${base}.png`); if (mk.then) mk.then(base + ".png"); }); } } // The wait for Wi-Fi transfer is capped by time, 60s from the click. property real deadline: 0 function upload() { tab.save(file => { tab.deadline = Date.now() + 60000; tab.send(file); }); } function send(file) { tab.busy = true; Sonix.upload(file, tab.cardDir, (code, out) => { // busy stays true through the wait; only a final answer clears it. if (code === 3 && Date.now() < tab.deadline) { tab.waiting = true; tab.say("Open Wi-Fi transfer on the player (Control centre) and leave it on screen.", true); retry.file = file; retry.restart(); return; } tab.busy = false; tab.waiting = false; if (code === 0) tab.say("Uploaded. Pick it on the player: Settings > Appearance > Boot logo. You can leave Wi-Fi transfer now."); else if (code === 3) tab.say("Wi-Fi transfer did not answer for 60s. The copy is saved locally.", true); else tab.say(`Upload failed: ${out || "exit " + code}. The copy is saved locally.`, true); }); } Timer { id: retry; property string file: ""; interval: 2000; onTriggered: tab.send(file) } // --- canvas ----------------------------------------------------------- // 0.75 for display; the canvas itself stays 480x800 for the grab. Rectangle { width: 480 * 0.75 + 8 height: 800 * 0.75 + 8 radius: 14 color: "black" border.width: 2 border.color: Qt.alpha(Theme.text, 0.25) Item { x: 4; y: 4 width: 480 * 0.75 height: 800 * 0.75 scale: 0.75 transformOrigin: Item.TopLeft BootlogoCanvas { id: canvas cfg: tab.cfg text: tab.cfg.text || Quickshell.env("USER") moveText: moveTextBtn.chosen onTextMoved: (x, y) => tab.set({ x: Math.round(x), y: Math.round(y) }) } } Text { anchors.centerIn: parent width: parent.width - 40 visible: !canvas.ready wrapMode: Text.Wrap horizontalAlignment: Text.AlignHCenter text: canvas.loadError || "Choose a picture" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: canvas.loadError ? Theme.red : Theme.subtext } } // --- controls --------------------------------------------------------- component Heading: Text { font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } color: Theme.text } component Swatches: Row { id: sw property string value: "" signal picked(string v) spacing: 6 Repeater { model: tab.swatches delegate: Rectangle { required property string modelData width: 24; height: 24; radius: 6 color: S.colour(modelData, Theme.accent) border.width: sw.value === modelData ? 3 : 1 border.color: sw.value === modelData ? Theme.text : Qt.alpha(Theme.text, 0.3) Text { visible: modelData === "udt" anchors.centerIn: parent text: "U" font { family: Theme.fontFamily; pixelSize: 11; bold: true } color: Theme.base } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: sw.picked(modelData) } } } } Column { width: 380 spacing: 12 Heading { text: "Picture" } Row { spacing: 8 Button { text: "Choose picture…"; onClicked: pick.open() } Button { text: "Reset"; enabled: canvas.ready; onClicked: canvas.recentre() } } Row { spacing: 8 Button { text: "Move picture" border.color: !moveTextBtn.chosen ? Theme.accent : Qt.alpha(Theme.text, 0.2) onClicked: moveTextBtn.chosen = false } Button { id: moveTextBtn property bool chosen: false text: "Move text" border.color: chosen ? Theme.accent : Qt.alpha(Theme.text, 0.2) onClicked: chosen = true } } Text { text: "Drag on the preview; the wheel zooms the picture." font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } color: Theme.subtext } Heading { text: "Text" } Field { width: 260 placeholder: Quickshell.env("USER") Component.onCompleted: value = tab.cfg.text onValueChanged: if (value !== tab.cfg.text) tab.set({ text: value }) } Flow { width: parent.width spacing: 6 Repeater { model: tab.fontKeys delegate: Button { required property var modelData text: modelData[1] font.family: canvas.families[modelData[0]] border.color: tab.cfg.font === modelData[0] ? Theme.accent : Qt.alpha(Theme.text, 0.2) onClicked: tab.set({ font: modelData[0] }) } } } Row { spacing: 8 Button { text: "Top"; onClicked: tab.set({ x: 240, y: 120 }) } Button { text: "Centre"; onClicked: tab.set({ x: 240, y: 400 }) } Button { text: "Bottom"; onClicked: tab.set({ x: 240, y: 690 }) } } Row { spacing: 8 Text { width: 60 anchors.verticalCenter: parent.verticalCenter text: "Size" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: Theme.subtext } Slider { width: 260 from: 40; to: 240; stepSize: 1 value: tab.cfg.size onMoved: tab.set({ size: Math.round(value) }) } } Heading { text: "Colours" } Row { spacing: 8 Text { width: 60 anchors.verticalCenter: parent.verticalCenter text: "Fill" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: Theme.subtext } Swatches { value: tab.cfg.fill; onPicked: v => tab.set({ fill: v }) } } Row { spacing: 8 Text { width: 60 anchors.verticalCenter: parent.verticalCenter text: tab.cfg.font === "bungee" ? "Inline" : "Shadow" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: Theme.subtext } Swatches { value: tab.cfg.second; onPicked: v => tab.set({ second: v }) } } Button { text: "Swap colours"; onClicked: tab.set({ fill: tab.cfg.second, second: tab.cfg.fill }) } Row { visible: tab.cfg.font !== "bungee" spacing: 8 Text { width: 60 anchors.verticalCenter: parent.verticalCenter text: "Offset" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: Theme.subtext } Slider { width: 160 from: 0; to: 30; stepSize: 1 value: tab.cfg.offset onMoved: tab.set({ offset: Math.round(value) }) } Repeater { model: [["nw", "↖"], ["ne", "↗"], ["sw", "↙"], ["se", "↘"]] delegate: Button { required property var modelData text: modelData[1] border.color: tab.cfg.dir === modelData[0] ? Theme.accent : Qt.alpha(Theme.text, 0.2) onClicked: tab.set({ dir: modelData[0] }) } } } Heading { text: "Output" } Row { spacing: 8 Text { width: 260 anchors.verticalCenter: parent.verticalCenter elide: Text.ElideMiddle text: tab.folder font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: Theme.text } Button { text: "Change…"; onClicked: pickFolder.open() } } Text { width: parent.width wrapMode: Text.Wrap text: "Save writes the logo and a transparent mask of the text. Upload also sends the logo to the card's Bootlogo folder; Wi-Fi transfer must be open on the player." font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } color: Theme.subtext } } // A FileDialog hands back a file:// URL. function pathOf(u) { return decodeURIComponent(u.toString().replace(/^file:\/\//, "")); } // Qt.labs.platform, not QtQuick.Dialogs: the Quick dialog opens light on // this desktop (AGENTS.md); this one takes the widget style. P.FileDialog { id: pick title: "Choose a picture" fileMode: P.FileDialog.OpenFile folder: "file://" + Quickshell.env("HOME") + "/Pictures" nameFilters: ["Pictures (*.jpg *.jpeg *.png *.JPG *.JPEG *.PNG)"] onAccepted: canvas.source = pick.file } P.FolderDialog { id: pickFolder title: "Output folder" folder: "file://" + tab.folder currentFolder: "file://" + tab.folder onAccepted: tab.set({ folder: tab.pathOf(pickFolder.folder) }) } }