mirror of
https://github.com/arthur-pbty/arthur-os.git
synced 2026-08-06 04:29:34 +02:00
Five plugins each owned their own colors.toml watcher with subtly different parsers, plus two ad-hoc per-theme override files (notifications.json, image-picker-colors.json). This collapses all of that into one source of truth: Commons/Color.qml watches colors.toml + shell.toml and exposes Color.bar.*, Color.popups.*, Color.notifications.*, Color.menu.*, Color.imagePicker.* for every surface to bind to. shell.toml is generated by the existing template pipeline from default/themed/shell.toml.tpl. Themes can ship their own shell.toml to override individual keys; everything missing falls back to the foundational palette via root.pick(). Settings panel is intentionally not themable beyond the foundational tokens. last-horizon and solitude ship a minimal shell.toml to preserve their historical 'notification border matches Hyprland active border' behavior, which previously came from parsing the theme's hyprland.conf (now removed).
589 lines
19 KiB
QML
589 lines
19 KiB
QML
import Quickshell
|
|
import Quickshell.Io
|
|
import Quickshell.Wayland
|
|
import QtQuick
|
|
import QtQuick.Effects
|
|
import QtQuick.Shapes
|
|
import qs.Commons
|
|
|
|
Item {
|
|
id: root
|
|
|
|
// Injected by omarchy-shell. Optional here — the picker doesn't need
|
|
// omarchyPath itself, but every plugin gets it so user-installed scripts
|
|
// referenced by other plugins can stay path-portable.
|
|
property string omarchyPath: ""
|
|
// Set by omarchy-shell when summoning the overlay; not currently consumed but
|
|
// declared so the host's onLoaded injection doesn't trip a missing-property
|
|
// warning.
|
|
property var shell: null
|
|
property var manifest: null
|
|
|
|
property string imageDirs: Quickshell.env("OMARCHY_IMAGE_SELECTOR_DIRS") || Quickshell.env("OMARCHY_IMAGE_SELECTOR_DIR") || Quickshell.env("OMARCHY_STOCK_BACKGROUNDS_DIR") || (Quickshell.env("HOME") + "/.config/omarchy/current/theme/backgrounds")
|
|
property string imageRows: ""
|
|
property string selectionFile: Quickshell.env("OMARCHY_IMAGE_SELECTOR_SELECTION_FILE") || Quickshell.env("OMARCHY_BACKGROUND_SELECTION_FILE")
|
|
property string selectedImage: Quickshell.env("OMARCHY_IMAGE_SELECTOR_SELECTED")
|
|
property int selectedIndex: 0
|
|
property bool imagesLoaded: false
|
|
property bool opened: false
|
|
property bool showLabels: false
|
|
property bool filterable: false
|
|
property bool requestActive: false
|
|
property int requestSerial: 0
|
|
property int applySerial: 0
|
|
property string doneFile: ""
|
|
property string filterText: ""
|
|
property var doneFilesToRelease: []
|
|
// Bound to the central [image-picker] section in shell.toml via Color.qml.
|
|
property color background: Color.imagePicker.background
|
|
property color foreground: Color.imagePicker.text
|
|
property color selectedBorder: Color.imagePicker.selectedBorder
|
|
property color unselectedBorder: Color.imagePicker.unselectedBorder
|
|
property int expandedWidth: 768
|
|
property int expandedHeight: 475
|
|
property int sliceWidth: 108
|
|
property int sliceHeight: 432
|
|
property int sliceSpacing: -30
|
|
property int skewOffset: 28
|
|
property int bottomChromeHeight: showLabels ? (filterable ? 104 : 74) : (filterable ? 60 : 30)
|
|
|
|
function fileUrl(path) {
|
|
return "file://" + path.split("/").map(encodeURIComponent).join("/")
|
|
}
|
|
|
|
function shellQuote(value) {
|
|
return "'" + String(value).replace(/'/g, "'\\''") + "'"
|
|
}
|
|
|
|
// Decode a base64-encoded UTF-8 string sent via IPC. Used for fields that
|
|
// would otherwise carry embedded newlines or tabs (image rows, raw colors
|
|
// JSON) which bash IPC arguments can't reliably round-trip.
|
|
function decodeBase64(value) {
|
|
var s = String(value || "")
|
|
if (!s) return ""
|
|
try { return Qt.atob(s) } catch (e) { return s }
|
|
}
|
|
|
|
function withAlpha(color, alpha) {
|
|
return Qt.rgba(color.r, color.g, color.b, alpha)
|
|
}
|
|
|
|
function currentPath() {
|
|
if (imageArray.length === 0 || !itemMatches(selectedIndex)) return ""
|
|
return imageArray[selectedIndex].filePath
|
|
}
|
|
|
|
function nameForPath(path) {
|
|
return path.split("/").pop().replace(/\.[^/.]+$/, "")
|
|
}
|
|
|
|
function labelForPath(path) {
|
|
return nameForPath(path).replace(/[-_]+/g, " ").replace(/\b\w/g, function(match) { return match.toUpperCase() })
|
|
}
|
|
|
|
function currentLabel() {
|
|
var path = currentPath()
|
|
if (!path) return filterText ? "No matches" : ""
|
|
|
|
return labelForPath(path)
|
|
}
|
|
|
|
function itemMatches(index) {
|
|
if (index < 0 || index >= imageArray.length) return false
|
|
if (!filterText) return true
|
|
|
|
var path = imageArray[index].filePath
|
|
var needle = filterText.toLowerCase()
|
|
return nameForPath(path).toLowerCase().indexOf(needle) !== -1 || labelForPath(path).toLowerCase().indexOf(needle) !== -1
|
|
}
|
|
|
|
function matchingCount() {
|
|
if (!filterText) return imageArray.length
|
|
|
|
var count = 0
|
|
for (var i = 0; i < imageArray.length; i++) {
|
|
if (itemMatches(i)) count++
|
|
}
|
|
|
|
return count
|
|
}
|
|
|
|
function firstMatchingIndex() {
|
|
for (var i = 0; i < imageArray.length; i++) {
|
|
if (itemMatches(i)) return i
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
function filteredPosition(index) {
|
|
if (!filterText) return index
|
|
|
|
var position = 0
|
|
for (var i = 0; i < index; i++) {
|
|
if (itemMatches(i)) position++
|
|
}
|
|
|
|
return position
|
|
}
|
|
|
|
function selectedFilteredPosition() {
|
|
if (!filterText) return selectedIndex
|
|
|
|
return itemMatches(selectedIndex) ? filteredPosition(selectedIndex) : 0
|
|
}
|
|
|
|
function select(index, immediate) {
|
|
if (imageArray.length === 0) return
|
|
if (index < 0) index = 0
|
|
else if (index >= imageArray.length) index = imageArray.length - 1
|
|
if (!itemMatches(index)) return
|
|
if (index === selectedIndex && immediate !== true) return
|
|
|
|
selectedIndex = index
|
|
}
|
|
|
|
function selectAdjacent(direction) {
|
|
var count = imageArray.length
|
|
if (count === 0) return
|
|
|
|
var index = selectedIndex
|
|
for (var i = 0; i < count; i++) {
|
|
index = (index + direction + count) % count
|
|
if (itemMatches(index)) {
|
|
select(index)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateFilter(nextFilterText) {
|
|
filterText = nextFilterText
|
|
|
|
if (!itemMatches(selectedIndex)) {
|
|
var first = firstMatchingIndex()
|
|
if (first >= 0) selectedIndex = first
|
|
}
|
|
}
|
|
|
|
function releaseNextDoneFile() {
|
|
if (releaseProc.running || doneFilesToRelease.length === 0) return
|
|
|
|
var path = doneFilesToRelease.shift()
|
|
releaseProc.command = ["bash", "-lc", ": > " + shellQuote(path)]
|
|
releaseProc.running = true
|
|
}
|
|
|
|
function finishDoneFile(path) {
|
|
if (!path) return
|
|
doneFilesToRelease.push(path)
|
|
releaseNextDoneFile()
|
|
}
|
|
|
|
function applySelected() {
|
|
var path = currentPath()
|
|
if (!path || !selectionFile) {
|
|
cancel()
|
|
return
|
|
}
|
|
|
|
var activeSelectionFile = selectionFile
|
|
var activeDoneFile = doneFile
|
|
applySerial = requestSerial
|
|
requestActive = false
|
|
selectionFile = ""
|
|
doneFile = ""
|
|
|
|
applyProc.command = ["bash", "-lc", "printf '%s\\n' " + shellQuote(path) + " > " + shellQuote(activeSelectionFile) + "; : > " + shellQuote(activeDoneFile)]
|
|
applyProc.running = true
|
|
}
|
|
|
|
function cancel() {
|
|
if (requestActive)
|
|
finishDoneFile(doneFile)
|
|
|
|
requestActive = false
|
|
selectionFile = ""
|
|
doneFile = ""
|
|
root.opened = false
|
|
}
|
|
|
|
function closeSelector(nextDoneFile) {
|
|
requestSerial += 1
|
|
|
|
if (requestActive)
|
|
finishDoneFile(doneFile)
|
|
|
|
if (nextDoneFile && nextDoneFile !== doneFile)
|
|
finishDoneFile(nextDoneFile)
|
|
|
|
requestActive = false
|
|
selectionFile = ""
|
|
doneFile = ""
|
|
filterText = ""
|
|
root.opened = false
|
|
}
|
|
|
|
function loadRows(rows) {
|
|
var newImages = []
|
|
var seen = {}
|
|
var paths = rows.split("\n")
|
|
for (var i = 0; i < paths.length; i++) {
|
|
var row = paths[i]
|
|
if (!row) continue
|
|
|
|
var columns = row.split("\t")
|
|
var path = columns[0]
|
|
if (!path) continue
|
|
var fileName = path.split("/").pop()
|
|
if (seen[fileName]) continue
|
|
seen[fileName] = true
|
|
newImages.push({
|
|
filePath: path,
|
|
fileName: fileName,
|
|
thumbnailPath: columns[1] || path
|
|
})
|
|
}
|
|
|
|
root.imageArray = newImages
|
|
root.select(root.selectedImageIndex(), true)
|
|
root.imagesLoaded = true
|
|
root.opened = true
|
|
carousel.forceActiveFocus()
|
|
}
|
|
|
|
function openSelector(nextImageDirs, nextImageRows, nextSelectedImage, nextSelectionFile, nextDoneFile, nextShowLabels, nextFilterable) {
|
|
if (requestActive && doneFile && doneFile !== nextDoneFile)
|
|
finishDoneFile(doneFile)
|
|
|
|
requestSerial += 1
|
|
|
|
imageDirs = nextImageDirs
|
|
imageRows = nextImageRows
|
|
selectedImage = nextSelectedImage
|
|
selectionFile = nextSelectionFile
|
|
doneFile = nextDoneFile
|
|
requestActive = !!doneFile
|
|
showLabels = nextShowLabels === true || nextShowLabels === "true"
|
|
filterable = nextFilterable === true || nextFilterable === "true"
|
|
filterText = ""
|
|
imageArray = []
|
|
selectedIndex = 0
|
|
imagesLoaded = false
|
|
opened = false
|
|
if (imageRows) {
|
|
loadRows(imageRows)
|
|
} else {
|
|
loadImagesProc.output = ""
|
|
loadImagesProc.running = true
|
|
}
|
|
}
|
|
|
|
property var imageArray: []
|
|
|
|
function selectedImageIndex() {
|
|
for (var i = 0; i < imageArray.length; i++) {
|
|
if (imageArray[i].filePath === selectedImage)
|
|
return i
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
Process {
|
|
id: loadImagesProc
|
|
property string output: ""
|
|
command: ["bash", "-lc", "cache_dir=${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/image-selector; while IFS= read -r dir; do [[ -n $dir && -d $dir ]] && find -L \"$dir\" -maxdepth 1 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \\) -print0; done <<< " + shellQuote(root.imageDirs) + " | sort -z | while IFS= read -r -d '' image; do hash=$(md5sum \"$image\" | cut -d ' ' -f 1); thumb=\"$cache_dir/$hash.jpg\"; [[ -f $thumb ]] || thumb=$image; printf '%s\\t%s\\n' \"$image\" \"$thumb\"; done"]
|
|
stdout: SplitParser {
|
|
onRead: function(data) {
|
|
loadImagesProc.output += data + "\n"
|
|
}
|
|
}
|
|
onExited: {
|
|
root.loadRows(output)
|
|
}
|
|
}
|
|
|
|
// Lifecycle hooks invoked by omarchy-shell summon/hide. shell.summon(id,
|
|
// payloadJson) hands the JSON to open() here; shell.hide(id) calls close().
|
|
// External CLI callers can either go through `shell summon omarchy.image-
|
|
// picker` (JSON payload), or hit the dedicated `image-selector` IpcHandler
|
|
// below for the lower-level positional call that omarchy-menu-images uses.
|
|
function open(payload) {
|
|
var args = {}
|
|
if (payload) {
|
|
try { args = JSON.parse(payload) || {} } catch (e) { args = {} }
|
|
}
|
|
var dirs = String(args.imageDirs || imageDirs)
|
|
var rows = String(args.imageRows || "")
|
|
var sel = String(args.selectedImage || selectedImage)
|
|
var selFile = String(args.selectionFile || "")
|
|
var doneF = String(args.doneFile || "")
|
|
var labels = args.showLabels === true || args.showLabels === "true"
|
|
var filter = args.filterable === true || args.filterable === "true"
|
|
openSelector(dirs, rows, sel, selFile, doneF, labels, filter)
|
|
}
|
|
|
|
function close() {
|
|
cancel()
|
|
}
|
|
|
|
// IPC surface. All arguments are strings (Quickshell IPC marshalling).
|
|
// imageRows can contain newlines/tabs, so the CLI caller base64-encodes
|
|
// it; everything else passes through verbatim. The two boolean-like
|
|
// fields use the literal strings "true" or "false".
|
|
IpcHandler {
|
|
target: "image-selector"
|
|
|
|
function open(imageDirs: string,
|
|
imageRowsB64: string,
|
|
selectedImage: string,
|
|
selectionFile: string,
|
|
doneFile: string,
|
|
showLabels: string,
|
|
filterable: string): string {
|
|
var rows = root.decodeBase64(imageRowsB64)
|
|
root.openSelector(imageDirs, rows, selectedImage, selectionFile, doneFile,
|
|
showLabels, filterable)
|
|
return "ok"
|
|
}
|
|
|
|
function cancel(doneFile: string): void {
|
|
root.closeSelector(doneFile || "")
|
|
}
|
|
|
|
function ping(): string {
|
|
return "ok"
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: applyProc
|
|
onExited: {
|
|
if (root.applySerial === root.requestSerial)
|
|
root.opened = false
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: releaseProc
|
|
onExited: root.releaseNextDoneFile()
|
|
}
|
|
|
|
PanelWindow {
|
|
id: panel
|
|
visible: root.opened && root.imagesLoaded
|
|
anchors { top: true; bottom: true; left: true; right: true }
|
|
color: "transparent"
|
|
WlrLayershell.namespace: "omarchy-image-selector"
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
|
exclusionMode: ExclusionMode.Ignore
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: root.withAlpha(root.background, 0.72)
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: root.cancel()
|
|
}
|
|
|
|
Item {
|
|
id: card
|
|
width: Math.min(parent.width - 80, root.expandedWidth + 13 * (root.sliceWidth + root.sliceSpacing) + 40)
|
|
height: root.expandedHeight + 30 + root.bottomChromeHeight
|
|
anchors.centerIn: parent
|
|
|
|
MouseArea { anchors.fill: parent; onClicked: {} }
|
|
|
|
Item {
|
|
id: carousel
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 30
|
|
anchors.bottom: parent.bottom
|
|
anchors.bottomMargin: root.bottomChromeHeight
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
width: root.expandedWidth + 13 * (root.sliceWidth + root.sliceSpacing)
|
|
clip: false
|
|
focus: true
|
|
|
|
readonly property real itemStep: root.sliceWidth + root.sliceSpacing
|
|
readonly property real previewX: (width - root.expandedWidth) / 2
|
|
|
|
Keys.priority: Keys.BeforeItem
|
|
Keys.onPressed: function(event) {
|
|
if (event.key === Qt.Key_Escape) {
|
|
if (root.filterText) {
|
|
root.updateFilter("")
|
|
} else {
|
|
root.cancel()
|
|
}
|
|
event.accepted = true
|
|
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
|
root.applySelected()
|
|
event.accepted = true
|
|
} else if (event.key === Qt.Key_Backspace && root.filterable) {
|
|
if (root.filterText.length > 0)
|
|
root.updateFilter(root.filterText.slice(0, -1))
|
|
event.accepted = true
|
|
} else if (event.key === Qt.Key_Left || (event.key === Qt.Key_Tab && event.modifiers & Qt.ShiftModifier) || event.key === Qt.Key_Backtab) {
|
|
root.selectAdjacent(-1)
|
|
event.accepted = true
|
|
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_Tab) {
|
|
root.selectAdjacent(1)
|
|
event.accepted = true
|
|
} else if (root.filterable && event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127 && (event.modifiers === Qt.NoModifier || event.modifiers === Qt.ShiftModifier)) {
|
|
root.updateFilter(root.filterText + event.text)
|
|
event.accepted = true
|
|
}
|
|
}
|
|
|
|
Component.onCompleted: forceActiveFocus()
|
|
|
|
Repeater {
|
|
model: root.imageArray.length
|
|
|
|
delegate: Item {
|
|
id: item
|
|
required property int index
|
|
|
|
readonly property var imageData: root.imageArray[index]
|
|
readonly property string filePath: imageData ? imageData.filePath : ""
|
|
readonly property string fileName: imageData ? imageData.fileName : ""
|
|
readonly property string thumbnailPath: imageData ? imageData.thumbnailPath : ""
|
|
|
|
readonly property bool matched: root.itemMatches(index)
|
|
readonly property int relativeIndex: root.filteredPosition(index) - root.selectedFilteredPosition()
|
|
readonly property bool selected: matched && index === root.selectedIndex
|
|
readonly property bool nearby: matched && Math.abs(relativeIndex) <= 16
|
|
|
|
visible: nearby
|
|
x: selected ? carousel.previewX : (relativeIndex < 0 ? carousel.previewX + relativeIndex * carousel.itemStep : carousel.previewX + root.expandedWidth + root.sliceSpacing + (relativeIndex - 1) * carousel.itemStep)
|
|
width: selected ? root.expandedWidth : root.sliceWidth
|
|
height: selected ? root.expandedHeight : root.sliceHeight
|
|
y: selected ? 0 : (root.expandedHeight - root.sliceHeight) / 2
|
|
z: selected ? 100 : 50 - Math.min(Math.abs(relativeIndex), 40)
|
|
|
|
readonly property real skAbs: Math.abs(root.skewOffset)
|
|
readonly property real topLeft: root.skewOffset >= 0 ? skAbs : 0
|
|
readonly property real topRight: root.skewOffset >= 0 ? width : width - skAbs
|
|
readonly property real bottomRight: root.skewOffset >= 0 ? width - skAbs : width
|
|
readonly property real bottomLeft: root.skewOffset >= 0 ? 0 : skAbs
|
|
|
|
Item {
|
|
id: maskShape
|
|
anchors.fill: parent
|
|
visible: false
|
|
layer.enabled: true
|
|
|
|
Shape {
|
|
anchors.fill: parent
|
|
antialiasing: true
|
|
preferredRendererType: Shape.CurveRenderer
|
|
ShapePath {
|
|
fillColor: "white"
|
|
strokeColor: "transparent"
|
|
startX: item.topLeft; startY: 0
|
|
PathLine { x: item.topRight; y: 0 }
|
|
PathLine { x: item.bottomRight; y: item.height }
|
|
PathLine { x: item.bottomLeft; y: item.height }
|
|
PathLine { x: item.topLeft; y: 0 }
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
anchors.fill: parent
|
|
layer.enabled: true
|
|
layer.smooth: true
|
|
layer.effect: MultiEffect {
|
|
maskEnabled: true
|
|
maskSource: maskShape
|
|
maskThresholdMin: 0.3
|
|
maskSpreadAtMin: 0.3
|
|
}
|
|
|
|
Image {
|
|
id: image
|
|
anchors.fill: parent
|
|
// Keep a stable source while delegates move in and out of the
|
|
// nearby window. Clearing/reassigning the source on every
|
|
// selection change makes Qt tear down and reload textures,
|
|
// which shows up as flicker in both the theme and background
|
|
// selectors.
|
|
source: item.thumbnailPath ? root.fileUrl(item.thumbnailPath) : ""
|
|
fillMode: Image.PreserveAspectCrop
|
|
asynchronous: false
|
|
cache: true
|
|
smooth: true
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: root.withAlpha(root.background, item.selected ? 0 : 0.42)
|
|
}
|
|
}
|
|
|
|
Shape {
|
|
anchors.fill: parent
|
|
antialiasing: true
|
|
preferredRendererType: Shape.CurveRenderer
|
|
ShapePath {
|
|
fillColor: "transparent"
|
|
strokeColor: item.selected ? root.selectedBorder : root.withAlpha(root.unselectedBorder, 0.28)
|
|
strokeWidth: item.selected ? 3 : 1
|
|
startX: item.topLeft; startY: 0
|
|
PathLine { x: item.topRight; y: 0 }
|
|
PathLine { x: item.bottomRight; y: item.height }
|
|
PathLine { x: item.bottomLeft; y: item.height }
|
|
PathLine { x: item.topLeft; y: 0 }
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: item.selected ? root.applySelected() : root.select(index)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: selectedLabel
|
|
visible: root.showLabels
|
|
anchors.top: carousel.bottom
|
|
anchors.topMargin: 16
|
|
anchors.horizontalCenter: carousel.horizontalCenter
|
|
width: root.expandedWidth
|
|
text: root.currentLabel()
|
|
color: root.foreground
|
|
style: Text.Outline
|
|
styleColor: root.withAlpha(root.background, 0.7)
|
|
font.pixelSize: 24
|
|
font.weight: Font.DemiBold
|
|
horizontalAlignment: Text.AlignHCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
visible: root.filterable && root.filterText
|
|
anchors.top: selectedLabel.bottom
|
|
anchors.topMargin: 8
|
|
anchors.horizontalCenter: carousel.horizontalCenter
|
|
width: root.expandedWidth
|
|
text: root.filterText
|
|
color: root.foreground
|
|
opacity: 0.85
|
|
style: Text.Outline
|
|
styleColor: root.withAlpha(root.background, 0.7)
|
|
font.pixelSize: 14
|
|
horizontalAlignment: Text.AlignHCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|
|
}
|