* Delete extra themes from the theme picker Delete on an extra (user-installed) theme opens the shared faded confirmation dialog; confirming runs omarchy-theme-remove and selection lands on the theme before the deleted one. Stock themes and user overrides of them are not offered for deletion, apply/re-ask are blocked while a removal runs, and row refreshes queue so a deletion never leaves the list stale. * Fail closed on theme deletion until stock themes are known Keep Delete disabled until the stock listing finishes successfully, so a stock override cannot look deletable in the startup window. Also hold theme apply (and further delete asks) from removal exit until the refreshed rows load, so Enter cannot apply the just-deleted theme in the gap.
894 lines
31 KiB
QML
894 lines
31 KiB
QML
import Quickshell
|
|
import Quickshell.Io
|
|
import Quickshell.Wayland
|
|
import QtQuick
|
|
import QtQuick.Effects
|
|
import QtQuick.Shapes
|
|
import qs.Commons
|
|
import qs.Commons as Commons
|
|
import qs.Ui
|
|
import "ImagePickerModel.js" as ImagePickerModel
|
|
|
|
Item {
|
|
id: root
|
|
|
|
// Injected by omarchy-shell; defaults to the session OMARCHY_PATH.
|
|
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
|
|
property string stateHome: Quickshell.env("HOME") + "/.local/state"
|
|
property string userThemesPath: Quickshell.env("HOME") + "/.config/omarchy/themes"
|
|
property string imageDirs: Quickshell.env("OMARCHY_IMAGE_SELECTOR_DIRS") || Quickshell.env("OMARCHY_IMAGE_SELECTOR_DIR") || Quickshell.env("OMARCHY_STOCK_BACKGROUNDS_DIR") || (stateHome + "/omarchy/current/theme/backgrounds")
|
|
property string imageRows: ""
|
|
property string loadedImageRows: ""
|
|
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 layoutSettled: false
|
|
property bool neighborImagesEnabled: false
|
|
property int renderedFrames: 0
|
|
property bool requestActive: false
|
|
property int requestSerial: 0
|
|
property int applySerial: 0
|
|
property string doneFile: ""
|
|
property string filterText: ""
|
|
property var doneFilesToRelease: []
|
|
// Themes open from rows the shell already holds, so the picker shows without
|
|
// waiting on omarchy-theme-switcher; each open refreshes them behind it.
|
|
property string themeRows: ""
|
|
property bool themeMode: false
|
|
property bool themeOpenPending: false
|
|
property var extraThemeNames: []
|
|
property var stockThemeNames: []
|
|
property bool stockThemesKnown: false
|
|
property bool awaitingDeleteRefresh: false
|
|
property string pendingDeleteTheme: ""
|
|
property string deleteSelectionPath: ""
|
|
property bool deleteConfirmOpen: false
|
|
// Bound to the central [image-picker] section in shell.toml via Commons.Color.qml.
|
|
// `dimColor` tints unselected slices and text outlines against the desktop
|
|
// behind the picker; it intentionally tracks the foundational background,
|
|
// not a surface role.
|
|
property color dimColor: Commons.Color.background
|
|
property color foreground: Commons.Color.imagePicker.text
|
|
property color selectedBorder: Commons.Color.imagePicker.selectedBorder
|
|
property color unselectedBorder: Commons.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)
|
|
// Render only what fits on this display, plus one prefetch slice per side.
|
|
readonly property int previewRadius: Math.max(1, Math.min(16, Math.ceil((panel.width - expandedWidth) / (2 * (sliceWidth + sliceSpacing))) + 1))
|
|
onPreviewRadiusChanged: updateVisibleItems()
|
|
|
|
onOpenedChanged: if (!opened) { layoutSettled = false; renderedFrames = 0; deleteConfirmOpen = false; pendingDeleteTheme = ""; deleteSelectionPath = ""; awaitingDeleteRefresh = false }
|
|
|
|
function scriptPath(name) {
|
|
return omarchyPath + "/shell/plugins/image-picker/" + name
|
|
}
|
|
|
|
function focusPicker() {
|
|
if (root.opened && root.imagesLoaded && root.layoutSettled)
|
|
carousel.forceActiveFocus()
|
|
}
|
|
|
|
function revealWhenSettled(serial) {
|
|
Qt.callLater(function() {
|
|
if (serial === root.requestSerial) root.maybeReveal()
|
|
})
|
|
}
|
|
|
|
// The card renders at opacity 0 until every visible preview has decoded
|
|
// and its masked layers have presented, so the picker lands in one
|
|
// complete frame instead of flashing its images in as each asynchronous
|
|
// decode and layer upload finishes.
|
|
function allPreviewsSettled() {
|
|
if (!opened || !imagesLoaded || layoutSettled || imageArray.length === 0 || imageCards.count === 0) return false
|
|
for (var i = 0; i < imageCards.count; i++) {
|
|
var item = imageCards.itemAt(i)
|
|
if (!item || !item.previewSettled) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function settleReveal() {
|
|
if (!opened || !imagesLoaded || layoutSettled || imageArray.length === 0) return
|
|
layoutSettled = true
|
|
focusPicker()
|
|
}
|
|
|
|
function maybeReveal() {
|
|
if (!allPreviewsSettled() || renderedFrames < 2) return
|
|
// Confirm on the next tick: delegates inserted later in the same window
|
|
// sync must not pop in after an early all-ready reading.
|
|
Qt.callLater(function() {
|
|
if (root.allPreviewsSettled() && root.renderedFrames >= 2) root.settleReveal()
|
|
})
|
|
}
|
|
|
|
function currentPath() {
|
|
if (imageArray.length === 0 || !itemMatches(selectedIndex)) return ""
|
|
return imageArray[selectedIndex].filePath
|
|
}
|
|
|
|
function nameForPath(path) {
|
|
return ImagePickerModel.nameForPath(path)
|
|
}
|
|
|
|
function labelForPath(path) {
|
|
return ImagePickerModel.labelForPath(path)
|
|
}
|
|
|
|
function currentLabel() {
|
|
var path = currentPath()
|
|
if (!path) return filterText ? "No matches" : ""
|
|
|
|
return labelForPath(path)
|
|
}
|
|
|
|
function itemMatches(index) {
|
|
return ImagePickerModel.itemMatches(imageArray, index, filterText)
|
|
}
|
|
|
|
function firstMatchingIndex() {
|
|
return ImagePickerModel.firstMatchingIndex(imageArray, filterText)
|
|
}
|
|
|
|
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 = ImagePickerModel.nextSelectedIndexForFilter(imageArray, selectedIndex, filterText)
|
|
if (first >= 0) selectedIndex = first
|
|
}
|
|
}
|
|
|
|
function releaseNextDoneFile() {
|
|
if (releaseProc.running || doneFilesToRelease.length === 0) return
|
|
|
|
var path = doneFilesToRelease.shift()
|
|
releaseProc.command = ["bash", "-c", ": > " + Util.shellQuote(path)]
|
|
releaseProc.running = true
|
|
}
|
|
|
|
function finishDoneFile(path) {
|
|
if (!path) return
|
|
doneFilesToRelease.push(path)
|
|
releaseNextDoneFile()
|
|
}
|
|
|
|
function applySelected() {
|
|
if (themeMode && (deleteThemeProc.running || awaitingDeleteRefresh)) return
|
|
var path = currentPath()
|
|
|
|
if (themeMode) {
|
|
themeMode = false
|
|
root.opened = false
|
|
if (path) Util.execArgv(["omarchy-theme-set", nameForPath(path)])
|
|
return
|
|
}
|
|
|
|
if (!path || !selectionFile) {
|
|
cancel()
|
|
return
|
|
}
|
|
|
|
var activeSelectionFile = selectionFile
|
|
var activeDoneFile = doneFile
|
|
applySerial = requestSerial
|
|
requestActive = false
|
|
selectionFile = ""
|
|
doneFile = ""
|
|
|
|
applyProc.command = ["bash", "-c", "printf '%s\\n' " + Util.shellQuote(path) + " > " + Util.shellQuote(activeSelectionFile) + "; : > " + Util.shellQuote(activeDoneFile)]
|
|
applyProc.running = true
|
|
}
|
|
|
|
function cancel() {
|
|
themeOpenPending = false
|
|
|
|
if (requestActive)
|
|
finishDoneFile(doneFile)
|
|
|
|
requestActive = false
|
|
selectionFile = ""
|
|
doneFile = ""
|
|
root.opened = false
|
|
}
|
|
|
|
function closeSelector(nextDoneFile) {
|
|
requestSerial += 1
|
|
themeOpenPending = false
|
|
|
|
if (requestActive)
|
|
finishDoneFile(doneFile)
|
|
|
|
if (nextDoneFile && nextDoneFile !== doneFile)
|
|
finishDoneFile(nextDoneFile)
|
|
|
|
requestActive = false
|
|
selectionFile = ""
|
|
doneFile = ""
|
|
filterText = ""
|
|
root.opened = false
|
|
}
|
|
|
|
function loadRows(rows, reveal) {
|
|
var newImages = ImagePickerModel.loadRows(rows)
|
|
|
|
root.loadedImageRows = rows
|
|
var nextIndex = root.indexForSelectedImage(newImages)
|
|
root.selectedIndex = ImagePickerModel.nextSelectedIndexForFilter(newImages, nextIndex, root.filterText)
|
|
root.neighborImagesEnabled = false
|
|
root.imageArray = newImages
|
|
root.imagesLoaded = true
|
|
Qt.callLater(root.enableNeighborsWhenReady)
|
|
|
|
if (reveal !== false) {
|
|
root.opened = true
|
|
root.revealWhenSettled(root.requestSerial)
|
|
}
|
|
}
|
|
|
|
function openSelector(nextImageDirs, nextImageRows, nextSelectedImage, nextSelectionFile, nextDoneFile, nextShowLabels, nextFilterable) {
|
|
deleteConfirmOpen = false
|
|
pendingDeleteTheme = ""
|
|
deleteSelectionPath = ""
|
|
awaitingDeleteRefresh = false
|
|
if (requestActive && doneFile && doneFile !== nextDoneFile)
|
|
finishDoneFile(doneFile)
|
|
|
|
requestSerial += 1
|
|
themeMode = false
|
|
themeOpenPending = false
|
|
|
|
imageDirs = nextImageDirs
|
|
imageRows = nextImageRows
|
|
selectedImage = nextSelectedImage
|
|
selectionFile = nextSelectionFile
|
|
doneFile = nextDoneFile
|
|
requestActive = !!doneFile
|
|
showLabels = nextShowLabels === true || nextShowLabels === "true"
|
|
filterable = nextFilterable === true || nextFilterable === "true"
|
|
filterText = ""
|
|
layoutSettled = false
|
|
|
|
if (imageRows && imageRows === loadedImageRows && imageArray.length > 0) {
|
|
root.select(root.selectedImageIndex(), true)
|
|
imagesLoaded = true
|
|
opened = true
|
|
root.revealWhenSettled(requestSerial)
|
|
return
|
|
}
|
|
|
|
if (imageRows) {
|
|
var rowsToLoad = imageRows
|
|
var rowsSerial = requestSerial
|
|
imageArray = []
|
|
selectedIndex = 0
|
|
imagesLoaded = true
|
|
opened = true
|
|
Qt.callLater(function() {
|
|
if (rowsSerial === root.requestSerial)
|
|
root.loadRows(rowsToLoad, true)
|
|
})
|
|
return
|
|
}
|
|
|
|
imageArray = []
|
|
selectedIndex = 0
|
|
imagesLoaded = false
|
|
opened = false
|
|
startImageScan(requestSerial, imageDirs)
|
|
}
|
|
|
|
property var imageArray: []
|
|
readonly property var matchingImageIndices: ImagePickerModel.matchingIndices(imageArray, filterText)
|
|
onMatchingImageIndicesChanged: updateVisibleItems()
|
|
onSelectedIndexChanged: {
|
|
updateVisibleItems()
|
|
introPrepareTimer.restart()
|
|
}
|
|
onThemeModeChanged: if (themeMode) introPrepareTimer.restart()
|
|
|
|
Timer {
|
|
id: introPrepareTimer
|
|
interval: 75
|
|
onTriggered: {
|
|
if (root.opened && root.themeMode && !introPrepare.running) {
|
|
introPrepare.theme = root.nameForPath(root.currentPath())
|
|
introPrepare.command = ["omarchy-theme-bg-boot-intro", "--prepare-theme", root.nameForPath(root.currentPath())]
|
|
introPrepare.running = true
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: introPrepare
|
|
property string theme: ""
|
|
onExited: if (root.opened && root.themeMode && theme !== root.nameForPath(root.currentPath())) introPrepareTimer.restart()
|
|
}
|
|
|
|
function updateVisibleItems() {
|
|
ImagePickerModel.syncWindow(visibleImages, ImagePickerModel.visibleWindow(matchingImageIndices, selectedIndex, previewRadius))
|
|
}
|
|
|
|
function enableNeighborsWhenReady() {
|
|
for (var i = 0; i < imageCards.count; i++) {
|
|
var item = imageCards.itemAt(i)
|
|
if (item && item.selected && item.previewReady) neighborImagesEnabled = true
|
|
}
|
|
}
|
|
|
|
ListModel { id: visibleImages }
|
|
|
|
|
|
function currentThemePreview() {
|
|
var name = String(themeNameFile.text() || "").trim()
|
|
var images = ImagePickerModel.loadRows(themeRows)
|
|
for (var i = 0; i < images.length; i++) {
|
|
if (nameForPath(images[i].filePath) === name) return images[i].filePath
|
|
}
|
|
return ""
|
|
}
|
|
|
|
function openThemes() {
|
|
if (themeRows) {
|
|
openThemeRows()
|
|
} else {
|
|
// First open before the startup refresh has landed.
|
|
themeOpenPending = true
|
|
}
|
|
refreshThemeRows()
|
|
refreshExtraThemes()
|
|
refreshStockThemes()
|
|
}
|
|
|
|
function openThemeRows() {
|
|
openSelector("", themeRows, currentThemePreview(), "", "", true, true)
|
|
themeMode = true
|
|
}
|
|
|
|
// A refresh asked mid-refresh (a deletion finishing behind the open-time
|
|
// one) queues a second pass instead of leaving the list stale.
|
|
function refreshThemeRows() {
|
|
if (themeRowsProc.running) { themeRowsProc.queued = true; return }
|
|
themeRowsProc.running = true
|
|
}
|
|
|
|
function refreshExtraThemes() {
|
|
if (extraThemesProc.running) { extraThemesProc.queued = true; return }
|
|
extraThemesProc.running = true
|
|
}
|
|
|
|
function refreshStockThemes() {
|
|
if (!stockThemesProc.running) stockThemesProc.running = true
|
|
}
|
|
|
|
function selectedThemeName() {
|
|
return nameForPath(currentPath())
|
|
}
|
|
|
|
function canDeleteSelectedTheme() {
|
|
return themeMode && !deleteThemeProc.running && !awaitingDeleteRefresh && stockThemesKnown && ImagePickerModel.canDeleteTheme(selectedThemeName(), extraThemeNames, stockThemeNames)
|
|
}
|
|
|
|
function requestDeleteSelectedTheme() {
|
|
if (!canDeleteSelectedTheme()) return
|
|
pendingDeleteTheme = selectedThemeName()
|
|
deleteConfirm.selectedIndex = 1
|
|
deleteConfirmOpen = true
|
|
}
|
|
|
|
function cancelDeleteTheme() {
|
|
deleteConfirmOpen = false
|
|
pendingDeleteTheme = ""
|
|
}
|
|
|
|
// Removal itself is omarchy-theme-remove's job (including its guards and
|
|
// notification); the picker only refreshes its rows once it finishes.
|
|
function confirmDeleteTheme() {
|
|
var name = pendingDeleteTheme
|
|
deleteConfirmOpen = false
|
|
pendingDeleteTheme = ""
|
|
if (!name || deleteThemeProc.running) return
|
|
// Keep the user's place in the list once the deleted theme is gone.
|
|
if (name === selectedThemeName()) deleteSelectionPath = ImagePickerModel.replacementSelectionPath(imageArray, selectedIndex, filterText)
|
|
deleteThemeProc.command = ["omarchy-theme-remove", name]
|
|
deleteThemeProc.running = true
|
|
}
|
|
|
|
function updateThemeRows(rows) {
|
|
var changed = rows !== themeRows
|
|
themeRows = rows
|
|
|
|
if (themeOpenPending) {
|
|
themeOpenPending = false
|
|
if (rows) openThemeRows()
|
|
} else if (changed && rows && themeMode && opened) {
|
|
// A theme was added or removed since the rows were last read. Keep the
|
|
// user's place in the carousel rather than jumping back to the current;
|
|
// a just-deleted selection lands on the theme before it.
|
|
selectedImage = deleteSelectionPath || currentPath() || currentThemePreview()
|
|
deleteSelectionPath = ""
|
|
awaitingDeleteRefresh = false
|
|
imageRows = rows
|
|
loadRows(rows, false)
|
|
}
|
|
}
|
|
|
|
FileView {
|
|
id: themeNameFile
|
|
path: root.stateHome + "/omarchy/current/theme.name"
|
|
watchChanges: true
|
|
onFileChanged: reload()
|
|
}
|
|
|
|
Process {
|
|
id: themeRowsProc
|
|
property bool queued: false
|
|
command: [root.omarchyPath + "/bin/omarchy-theme-switcher", "--print-rows"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.updateThemeRows(String(text || "").trim())
|
|
}
|
|
onExited: {
|
|
if (queued) {
|
|
queued = false
|
|
running = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// The same listing omarchy-theme-remove offers interactively: real
|
|
// directories under the user themes path, never symlinked working copies.
|
|
Process {
|
|
id: extraThemesProc
|
|
property bool queued: false
|
|
command: ["find", root.userThemesPath, "-mindepth", "1", "-maxdepth", "1", "-type", "d", "!", "-xtype", "l", "-printf", "%f\n"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.extraThemeNames = ImagePickerModel.parseThemeNames(String(text || ""))
|
|
}
|
|
onExited: {
|
|
if (queued) {
|
|
queued = false
|
|
running = true
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: stockThemesProc
|
|
command: ["find", root.omarchyPath + "/themes", "-mindepth", "1", "-maxdepth", "1", "-type", "d", "-printf", "%f\n"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.stockThemeNames = ImagePickerModel.parseThemeNames(String(text || ""))
|
|
}
|
|
// Until this lands, stockThemeNames is empty and an override would look
|
|
// deletable: deletion stays disabled unless the listing succeeded.
|
|
onExited: function(exitCode) { if (exitCode === 0) root.stockThemesKnown = true }
|
|
}
|
|
|
|
Process {
|
|
id: deleteThemeProc
|
|
onExited: function(exitCode) {
|
|
if (exitCode === 0) root.awaitingDeleteRefresh = true
|
|
else root.deleteSelectionPath = ""
|
|
root.refreshExtraThemes()
|
|
root.refreshThemeRows()
|
|
}
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
refreshThemeRows()
|
|
refreshExtraThemes()
|
|
refreshStockThemes()
|
|
}
|
|
|
|
function startImageScan(serial, dirs) {
|
|
if (loadImagesProc.running) {
|
|
loadImagesProc.queuedSerial = serial
|
|
loadImagesProc.queuedDirs = dirs
|
|
return
|
|
}
|
|
|
|
loadImagesProc.activeSerial = serial
|
|
loadImagesProc.queuedSerial = 0
|
|
loadImagesProc.queuedDirs = ""
|
|
loadImagesProc.command = [root.scriptPath("list.sh"), dirs]
|
|
loadImagesProc.running = true
|
|
}
|
|
|
|
function indexForSelectedImage(images) {
|
|
return ImagePickerModel.indexForSelectedImage(images, selectedImage)
|
|
}
|
|
|
|
function selectedImageIndex() {
|
|
return indexForSelectedImage(imageArray)
|
|
}
|
|
|
|
Process {
|
|
id: loadImagesProc
|
|
property int activeSerial: 0
|
|
property int queuedSerial: 0
|
|
property string queuedDirs: ""
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: {
|
|
if (loadImagesProc.activeSerial === root.requestSerial)
|
|
root.loadRows(String(text || ""), true)
|
|
}
|
|
}
|
|
onExited: {
|
|
var serial = queuedSerial
|
|
var dirs = queuedDirs
|
|
activeSerial = 0
|
|
queuedSerial = 0
|
|
queuedDirs = ""
|
|
if (serial > 0 && serial === root.requestSerial)
|
|
root.startImageScan(serial, dirs)
|
|
}
|
|
}
|
|
|
|
// Lifecycle hooks invoked by omarchy-shell summon/hide. shell.summon(id,
|
|
// payloadJson) hands the JSON to open() here; shell.hide(id) calls close().
|
|
// The shell host owns the stable `image-selector` IPC target and forwards
|
|
// those lower-level positional calls here.
|
|
function open(payload) {
|
|
var args = {}
|
|
if (payload) {
|
|
try { args = JSON.parse(payload) || {} } catch (e) { args = {} }
|
|
}
|
|
if (args.source === "themes") {
|
|
openThemes()
|
|
return
|
|
}
|
|
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()
|
|
}
|
|
|
|
function preloadRows(nextImageRows, nextSelectedImage, nextShowLabels, nextFilterable) {
|
|
// Theme/background set hooks can warm selector rows after a picker was
|
|
// dismissed. Ignore those preloads while a user-visible request is open;
|
|
// otherwise the preload resets layoutSettled without revealing again,
|
|
// leaving the picker invisible behind its own open overlay.
|
|
if (opened || requestActive) return
|
|
|
|
requestSerial += 1
|
|
imageRows = nextImageRows
|
|
selectedImage = nextSelectedImage
|
|
showLabels = nextShowLabels === true || nextShowLabels === "true"
|
|
filterable = nextFilterable === true || nextFilterable === "true"
|
|
filterText = ""
|
|
layoutSettled = false
|
|
|
|
if (imageRows && imageRows === loadedImageRows && imageArray.length > 0) {
|
|
selectedIndex = selectedImageIndex()
|
|
imagesLoaded = true
|
|
} else if (imageRows) {
|
|
loadRows(imageRows, false)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: applyProc
|
|
onExited: {
|
|
if (root.applySerial === root.requestSerial)
|
|
root.opened = false
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: releaseProc
|
|
onExited: root.releaseNextDoneFile()
|
|
}
|
|
|
|
OverlayWindow {
|
|
id: panel
|
|
shown: root.opened
|
|
shownKeyboardFocus: root.imagesLoaded ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
|
|
WlrLayershell.namespace: "omarchy-image-selector"
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
enabled: root.opened && root.imagesLoaded
|
|
onClicked: root.cancel()
|
|
}
|
|
|
|
// Count presented frames while the card pre-renders below; two frames
|
|
// prove its masked layers uploaded on this fresh surface.
|
|
FrameAnimation {
|
|
running: root.opened && !root.layoutSettled && root.renderedFrames < 2
|
|
onTriggered: {
|
|
root.renderedFrames += 1
|
|
root.maybeReveal()
|
|
}
|
|
}
|
|
|
|
// A preview stuck decoding (slow disk, huge original) must not hold an
|
|
// invisible picker with the keyboard grabbed: reveal anyway shortly.
|
|
Timer {
|
|
interval: 400
|
|
running: root.opened && root.imagesLoaded && !root.layoutSettled && root.imageArray.length > 0
|
|
onTriggered: root.settleReveal()
|
|
}
|
|
|
|
Item {
|
|
id: card
|
|
visible: root.opened && root.imagesLoaded && root.imageArray.length > 0
|
|
opacity: root.layoutSettled ? 1 : 0
|
|
width: Math.min(parent.width - 80, root.expandedWidth + 13 * (root.sliceWidth + root.sliceSpacing) + 40)
|
|
height: root.expandedHeight + Style.space(30) + root.bottomChromeHeight
|
|
anchors.centerIn: parent
|
|
|
|
MouseArea { anchors.fill: parent; enabled: root.layoutSettled; onClicked: {} }
|
|
|
|
Item {
|
|
id: carousel
|
|
anchors.top: parent.top
|
|
anchors.topMargin: Style.space(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 (root.deleteConfirmOpen) {
|
|
if (deleteConfirm.handleKey(event)) event.accepted = true
|
|
return
|
|
}
|
|
// Nothing is visible before the reveal: only let the user back
|
|
// out, never filter into a dead end or apply an unseen pick.
|
|
if (!root.layoutSettled) {
|
|
if (event.key === Qt.Key_Escape) {
|
|
root.cancel()
|
|
event.accepted = true
|
|
}
|
|
return
|
|
}
|
|
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_Delete && !(event.modifiers & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier))) {
|
|
root.requestDeleteSelectedTheme()
|
|
event.accepted = true
|
|
} else if (root.filterable && Util.editsFilter(event, root.filterText)) {
|
|
root.updateFilter(Util.editedFilter(event, root.filterText))
|
|
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 {
|
|
id: imageCards
|
|
model: visibleImages
|
|
onCountChanged: root.maybeReveal()
|
|
|
|
delegate: Item {
|
|
id: item
|
|
required property int imageIndex
|
|
required property int relativeIndex
|
|
|
|
readonly property var imageData: root.imageArray[imageIndex]
|
|
readonly property string filePath: imageData ? imageData.filePath : ""
|
|
readonly property string fileName: imageData ? imageData.fileName : ""
|
|
readonly property string thumbnailPath: imageData ? imageData.thumbnailPath : ""
|
|
|
|
readonly property bool selected: imageIndex === root.selectedIndex
|
|
readonly property bool previewReady: image.status === Image.Ready || image.status === Image.Error
|
|
readonly property bool previewSettled: !thumbnailPath || previewReady
|
|
onPreviewSettledChanged: if (previewSettled) root.maybeReveal()
|
|
onSelectedChanged: if (selected && previewReady) root.neighborImagesEnabled = true
|
|
|
|
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
|
|
}
|
|
|
|
Rectangle { anchors.fill: parent; color: root.dimColor }
|
|
|
|
Image {
|
|
id: image
|
|
anchors.fill: parent
|
|
// Decode at the expanded card's physical size, off the GUI
|
|
// thread. Keep that size during navigation to avoid reloads.
|
|
// Departing cards release their images instead of retaining
|
|
// every preview visited in a large collection.
|
|
// Queue the selected preview first; neighbors must not delay
|
|
// the image the user opened the picker to see.
|
|
source: (item.selected || root.neighborImagesEnabled) && item.thumbnailPath ? Util.fileUrl(item.thumbnailPath) : ""
|
|
sourceSize.width: Math.ceil(root.expandedWidth * Screen.devicePixelRatio)
|
|
sourceSize.height: Math.ceil(root.expandedHeight * Screen.devicePixelRatio)
|
|
fillMode: Image.PreserveAspectCrop
|
|
asynchronous: true
|
|
cache: false
|
|
smooth: true
|
|
opacity: status === Image.Ready ? 1 : 0
|
|
onStatusChanged: if (item.selected && (status === Image.Ready || status === Image.Error)) root.neighborImagesEnabled = true
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: Util.alpha(root.dimColor, item.selected ? 0 : 0.42)
|
|
}
|
|
}
|
|
|
|
Shape {
|
|
anchors.fill: parent
|
|
antialiasing: true
|
|
preferredRendererType: Shape.CurveRenderer
|
|
ShapePath {
|
|
fillColor: "transparent"
|
|
strokeColor: item.selected ? root.selectedBorder : root.unselectedBorder
|
|
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
|
|
enabled: root.layoutSettled
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: item.selected ? root.applySelected() : root.select(item.imageIndex)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: selectedLabel
|
|
textFormat: Text.PlainText
|
|
visible: root.showLabels
|
|
anchors.top: carousel.bottom
|
|
anchors.topMargin: Style.space(16)
|
|
anchors.horizontalCenter: carousel.horizontalCenter
|
|
width: root.expandedWidth
|
|
text: root.currentLabel()
|
|
color: root.foreground
|
|
style: Text.Outline
|
|
styleColor: Util.alpha(root.dimColor, 0.7)
|
|
font.pixelSize: Style.font.display
|
|
font.weight: Font.DemiBold
|
|
horizontalAlignment: Text.AlignHCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: root.filterable && root.filterText
|
|
anchors.top: selectedLabel.bottom
|
|
anchors.topMargin: Style.space(8)
|
|
anchors.horizontalCenter: carousel.horizontalCenter
|
|
width: root.expandedWidth
|
|
text: root.filterText
|
|
color: root.foreground
|
|
opacity: 0.85
|
|
style: Text.Outline
|
|
styleColor: Util.alpha(root.dimColor, 0.7)
|
|
font.pixelSize: Style.font.title
|
|
horizontalAlignment: Text.AlignHCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
|
|
// Unlike the picker itself, the confirmation keeps the faded backdrop:
|
|
// a destructive choice should take over the screen.
|
|
ConfirmDialog {
|
|
id: deleteConfirm
|
|
anchors.fill: parent
|
|
opened: root.deleteConfirmOpen
|
|
z: 10
|
|
message: "Do you want to delete " + (root.pendingDeleteTheme ? root.labelForPath(root.pendingDeleteTheme) : "") + "?"
|
|
confirmText: "Delete"
|
|
background: root.dimColor
|
|
foreground: root.foreground
|
|
onCanceled: root.cancelDeleteTheme()
|
|
onConfirmed: root.confirmDeleteTheme()
|
|
}
|
|
}
|
|
}
|