diff --git a/shell/plugins/image-picker/ImagePicker.qml b/shell/plugins/image-picker/ImagePicker.qml index e3947e78..f9280f2e 100644 --- a/shell/plugins/image-picker/ImagePicker.qml +++ b/shell/plugins/image-picker/ImagePicker.qml @@ -15,6 +15,7 @@ Item { // 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: "" @@ -39,6 +40,13 @@ Item { 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, @@ -58,7 +66,7 @@ Item { 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 } + onOpenedChanged: if (!opened) { layoutSettled = false; renderedFrames = 0; deleteConfirmOpen = false; pendingDeleteTheme = ""; deleteSelectionPath = ""; awaitingDeleteRefresh = false } function scriptPath(name) { return omarchyPath + "/shell/plugins/image-picker/" + name @@ -179,6 +187,7 @@ Item { } function applySelected() { + if (themeMode && (deleteThemeProc.running || awaitingDeleteRefresh)) return var path = currentPath() if (themeMode) { @@ -251,6 +260,10 @@ Item { } function openSelector(nextImageDirs, nextImageRows, nextSelectedImage, nextSelectionFile, nextDoneFile, nextShowLabels, nextFilterable) { + deleteConfirmOpen = false + pendingDeleteTheme = "" + deleteSelectionPath = "" + awaitingDeleteRefresh = false if (requestActive && doneFile && doneFile !== nextDoneFile) finishDoneFile(doneFile) @@ -356,6 +369,8 @@ Item { themeOpenPending = true } refreshThemeRows() + refreshExtraThemes() + refreshStockThemes() } function openThemeRows() { @@ -363,8 +378,53 @@ Item { 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.running = true + 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) { @@ -376,8 +436,11 @@ Item { 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. - selectedImage = currentPath() || currentThemePreview() + // 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) } @@ -392,13 +455,62 @@ Item { 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 + } + } } - Component.onCompleted: refreshThemeRows() + // 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) { @@ -564,6 +676,10 @@ Item { 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) { @@ -583,6 +699,9 @@ Item { } 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 @@ -755,5 +874,20 @@ Item { 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() + } } } diff --git a/shell/plugins/image-picker/ImagePickerModel.js b/shell/plugins/image-picker/ImagePickerModel.js index 86c4b07a..f04da5ae 100644 --- a/shell/plugins/image-picker/ImagePickerModel.js +++ b/shell/plugins/image-picker/ImagePickerModel.js @@ -6,6 +6,41 @@ function labelForPath(path) { return nameForPath(path).replace(/[-_]+/g, " ").replace(/\b\w/g, function(match) { return match.toUpperCase() }) } +// Extra (user-installed) theme names, one per line, as listed from the user +// themes directory: the set omarchy-theme-remove can delete. +function parseThemeNames(text) { + var names = [] + var lines = String(text || "").split("\n") + for (var i = 0; i < lines.length; i++) { + var name = lines[i].trim() + if (name) names.push(name) + } + return names +} + +// A user directory shadowing a stock theme holds customizations, not an +// extra theme: removing it from here would silently revert to stock (and a +// shadow can even list twice, once per preview extension), so only themes +// with no stock twin can be deleted from the picker. +function canDeleteTheme(name, extraNames, stockNames) { + if (!name || !Array.isArray(extraNames) || extraNames.indexOf(name) === -1) return false + return !Array.isArray(stockNames) || stockNames.indexOf(name) === -1 +} + +// Where selection lands when the selected image goes away: the previous +// match in list order, or the next one when the first match is deleted. +function replacementSelectionPath(images, selectedIndex, filterText) { + var values = Array.isArray(images) ? images : [] + if (selectedIndex < 0 || selectedIndex >= values.length) return "" + for (var i = selectedIndex - 1; i >= 0; i--) { + if (itemMatches(values, i, filterText)) return values[i].filePath + } + for (var i = selectedIndex + 1; i < values.length; i++) { + if (itemMatches(values, i, filterText)) return values[i].filePath + } + return "" +} + function loadRows(rows) { var images = [] var seen = {} @@ -125,6 +160,9 @@ if (typeof module !== "undefined") { module.exports = { nameForPath: nameForPath, labelForPath: labelForPath, + parseThemeNames: parseThemeNames, + canDeleteTheme: canDeleteTheme, + replacementSelectionPath: replacementSelectionPath, loadRows: loadRows, itemMatches: itemMatches, firstMatchingIndex: firstMatchingIndex, diff --git a/test/shell.d/image-picker-test.sh b/test/shell.d/image-picker-test.sh index 3867c651..3d1e402a 100644 --- a/test/shell.d/image-picker-test.sh +++ b/test/shell.d/image-picker-test.sh @@ -258,4 +258,230 @@ reveal = revealContext([true], 2) reveal.opened = false reveal.settleReveal() assert(!reveal.layoutSettled, 'settle does nothing once the picker is closed') + +assertEqual(typeof picker.parseThemeNames, 'function', 'image picker model parses extra theme listings') +assertDeepEqual(picker.parseThemeNames('fjord\ngiants\n\nspace\n'), ['fjord', 'giants', 'space'], 'image picker parses extra theme names one per line') +assertDeepEqual(picker.parseThemeNames(''), [], 'image picker parses an empty extra theme listing') +assert(picker.canDeleteTheme('fjord', ['fjord', 'giants']), 'an extra theme can be deleted from the picker') +assert(!picker.canDeleteTheme('nord', ['fjord', 'giants']), 'a stock theme cannot be deleted from the picker') +assert(!picker.canDeleteTheme('', ['fjord']), 'no selection cannot be deleted from the picker') +assert(!picker.canDeleteTheme('fjord', []), 'nothing can be deleted before extra themes are known') +assert(!picker.canDeleteTheme('nord', ['nord', 'fjord'], ['nord']), 'a user override of a stock theme is not deleted from the picker') +assert(picker.canDeleteTheme('fjord', ['fjord'], ['nord']), 'an extra theme beside the stock list can be deleted from the picker') +assert( + /command: \["find", root\.userThemesPath, "-mindepth", "1", "-maxdepth", "1", "-type", "d", "!", "-xtype", "l", "-printf", "%f\\n"\]/.test(imagePickerQml) && + /root\.extraThemeNames = ImagePickerModel\.parseThemeNames\(String\(text \|\| ""\)\)/.test(imagePickerQml) && + /function openThemes\(\) \{[\s\S]*?refreshExtraThemes\(\)/.test(imagePickerQml), + 'image picker learns extra themes the way theme remove lists them' +) +assert( + /ConfirmDialog \{\s*id: deleteConfirm\s*anchors\.fill: parent\s*opened: root\.deleteConfirmOpen\s*z: 10\s*message: "Do you want to delete " \+ \(root\.pendingDeleteTheme \? root\.labelForPath\(root\.pendingDeleteTheme\) : ""\) \+ "\?"\s*confirmText: "Delete"/.test(imagePickerQml) && + /onCanceled: root\.cancelDeleteTheme\(\)\s*onConfirmed: root\.confirmDeleteTheme\(\)/.test(imagePickerQml), + 'image picker confirms theme deletion in a faded centered dialog' +) +assert( + /if \(root\.deleteConfirmOpen\) \{\s*if \(deleteConfirm\.handleKey\(event\)\) event\.accepted = true\s*return\s*\}/.test(imagePickerQml) && + imagePickerQml.indexOf('if (root.deleteConfirmOpen) {') < imagePickerQml.indexOf('if (!root.layoutSettled) {') && + /else if \(event\.key === Qt\.Key_Delete && !\(event\.modifiers & \(Qt\.ControlModifier \| Qt\.AltModifier \| Qt\.MetaModifier\)\)\) \{\s*root\.requestDeleteSelectedTheme\(\)\s*event\.accepted = true/.test(imagePickerQml), + 'image picker routes keys to the delete confirmation first and maps Delete to it' +) +assert( + /onOpenedChanged: if \(!opened\) \{ layoutSettled = false; renderedFrames = 0; deleteConfirmOpen = false; pendingDeleteTheme = ""; deleteSelectionPath = ""; awaitingDeleteRefresh = false \}/.test(imagePickerQml) && + /id: deleteThemeProc\s*onExited: function\(exitCode\) \{\s*if \(exitCode === 0\) root\.awaitingDeleteRefresh = true\s*else root\.deleteSelectionPath = ""\s*root\.refreshExtraThemes\(\)\s*root\.refreshThemeRows\(\)/.test(imagePickerQml), + 'image picker drops a pending deletion on close and refreshes after one runs' +) + +// Run the delete flow itself against a mocked selection and remove process. +const deleteFns = [ + imagePickerQml.match(/function selectedThemeName\(\) \{[\s\S]*?\n \}/)[0], + imagePickerQml.match(/function canDeleteSelectedTheme\(\) \{[\s\S]*?\n \}/)[0], + imagePickerQml.match(/function requestDeleteSelectedTheme\(\) \{[\s\S]*?\n \}/)[0], + imagePickerQml.match(/function cancelDeleteTheme\(\) \{[\s\S]*?\n \}/)[0], + imagePickerQml.match(/function confirmDeleteTheme\(\) \{[\s\S]*?\n \}/)[0] +].join('\n') +function deleteContext(path, extras, inThemeMode) { + const ctx = { + themeMode: inThemeMode, + extraThemeNames: extras, + stockThemeNames: [], + stockThemesKnown: true, + awaitingDeleteRefresh: false, + pendingDeleteTheme: '', + deleteSelectionPath: '', + deleteConfirmOpen: false, + imageArray: [{ filePath: path }], + selectedIndex: 0, + filterText: '', + deleteThemeProc: { running: false, command: [] }, + deleteConfirm: { selectedIndex: 0 }, + ImagePickerModel: picker, + nameForPath: picker.nameForPath, + currentPath() { return path } + } + ctx.root = ctx + require('vm').runInNewContext(deleteFns, ctx) + return ctx +} +let deletion = deleteContext('/cache/previews/fjord.jpg', ['fjord', 'giants'], true) +deletion.requestDeleteSelectedTheme() +assert(deletion.deleteConfirmOpen && deletion.pendingDeleteTheme === 'fjord', 'Delete asks to confirm the selected extra theme') +deletion.cancelDeleteTheme() +assert(!deletion.deleteConfirmOpen && deletion.pendingDeleteTheme === '' && deletion.deleteThemeProc.command.length === 0, 'canceling deletion removes nothing') +deletion.requestDeleteSelectedTheme() +deletion.confirmDeleteTheme() +assertDeepEqual(deletion.deleteThemeProc.command, ['omarchy-theme-remove', 'fjord'], 'confirming deletion removes exactly the selected theme') +assert(deletion.deleteThemeProc.running && !deletion.deleteConfirmOpen, 'confirming deletion runs the removal and closes the dialog') +deletion = deleteContext('/cache/previews/nord.png', ['fjord'], true) +deletion.requestDeleteSelectedTheme() +assert(!deletion.deleteConfirmOpen, 'Delete does nothing on a stock theme') +deletion = deleteContext('/cache/previews/fjord.jpg', ['fjord'], false) +deletion.requestDeleteSelectedTheme() +assert(!deletion.deleteConfirmOpen, 'Delete does nothing in the background picker') +deletion = deleteContext('/cache/previews/fjord.jpg', ['fjord'], true) +deletion.requestDeleteSelectedTheme() +deletion.deleteThemeProc.running = true +deletion.confirmDeleteTheme() +assertEqual(deletion.deleteThemeProc.command.length, 0, 'a second removal cannot start while one is running') + +assertEqual(typeof picker.replacementSelectionPath, 'function', 'image picker model finds where to land after a deletion') +const siblings = ['/p/alpha.png', '/p/bravo.png', '/p/charlie.png'].map(filePath => ({ filePath })) +assertEqual(picker.replacementSelectionPath(siblings, 1, ''), '/p/alpha.png', 'deleting a theme lands on the one before it') +assertEqual(picker.replacementSelectionPath(siblings, 2, ''), '/p/bravo.png', 'deleting the last theme lands on the one before it') +assertEqual(picker.replacementSelectionPath(siblings, 0, ''), '/p/bravo.png', 'deleting the first theme lands on the new first theme') +assertEqual(picker.replacementSelectionPath(siblings.slice(0, 1), 0, ''), '', 'deleting the only theme has nowhere to land') +assertEqual(picker.replacementSelectionPath(siblings, 9, ''), '', 'a stale selection has nowhere to land') +const mixed = ['/p/dark-a.png', '/p/light-b.png', '/p/dark-c.png', '/p/dark-d.png'].map(filePath => ({ filePath })) +assertEqual(picker.replacementSelectionPath(mixed, 2, 'dark'), '/p/dark-a.png', 'deleting a filtered theme lands on the previous match') +assertEqual(picker.replacementSelectionPath(mixed, 0, 'dark'), '/p/dark-c.png', 'deleting the first match lands on the next match') +assert( + /function confirmDeleteTheme\(\) \{[\s\S]*?if \(name === selectedThemeName\(\)\) deleteSelectionPath = ImagePickerModel\.replacementSelectionPath\(imageArray, selectedIndex, filterText\)/.test(imagePickerQml) && + /selectedImage = deleteSelectionPath \|\| currentPath\(\) \|\| currentThemePreview\(\)\s*deleteSelectionPath = ""\s*awaitingDeleteRefresh = false/.test(imagePickerQml), + 'image picker retains its place by selecting the previous theme after a deletion' +) + +function retentionContext(paths, selected, filter) { + const ctx = deleteContext(paths[selected], paths.map(filePath => picker.nameForPath(filePath)), true) + ctx.imageArray = paths.map(filePath => ({ filePath })) + ctx.selectedIndex = selected + ctx.filterText = filter + ctx.deleteSelectionPath = '' + return ctx +} +const trio = ['/p/alpha.png', '/p/bravo.png', '/p/charlie.png'] +let retention = retentionContext(trio, 1, '') +retention.requestDeleteSelectedTheme() +retention.confirmDeleteTheme() +assertEqual(retention.deleteSelectionPath, '/p/alpha.png', 'confirming a deletion remembers the theme before it') +retention = retentionContext(trio, 0, '') +retention.requestDeleteSelectedTheme() +retention.confirmDeleteTheme() +assertEqual(retention.deleteSelectionPath, '/p/bravo.png', 'confirming the first theme remembers the one after it') +retention = retentionContext(trio, 1, '') +retention.requestDeleteSelectedTheme() +retention.currentPath = () => '/p/charlie.png' +retention.confirmDeleteTheme() +assertEqual(retention.deleteSelectionPath, '', 'no landing is remembered when the selection moved off the deleted theme') +assertDeepEqual(retention.deleteThemeProc.command, ['omarchy-theme-remove', 'bravo'], 'the confirmed theme is still the one removed') + +assert( + /id: stockThemesProc\s*command: \["find", root\.omarchyPath \+ "\/themes", "-mindepth", "1", "-maxdepth", "1", "-type", "d", "-printf", "%f\\n"\]/.test(imagePickerQml) && + /root\.stockThemeNames = ImagePickerModel\.parseThemeNames\(String\(text \|\| ""\)\)/.test(imagePickerQml) && + /canDeleteTheme\(selectedThemeName\(\), extraThemeNames, stockThemeNames\)/.test(imagePickerQml), + 'image picker knows stock theme names so overrides stay undeletable' +) +assert( + /function refreshThemeRows\(\) \{\s*if \(themeRowsProc\.running\) \{ themeRowsProc\.queued = true; return \}/.test(imagePickerQml) && + /function refreshExtraThemes\(\) \{\s*if \(extraThemesProc\.running\) \{ extraThemesProc\.queued = true; return \}/.test(imagePickerQml) && + (imagePickerQml.match(/property bool queued: false/g) || []).length === 2 && + (imagePickerQml.match(/onExited: \{\s*if \(queued\) \{\s*queued = false\s*running = true\s*\}\s*\}/g) || []).length === 2, + 'theme row and extra theme refreshes queue behind a running refresh' +) +assert( + /function applySelected\(\) \{\s*if \(themeMode && \(deleteThemeProc\.running \|\| awaitingDeleteRefresh\)\) return/.test(imagePickerQml) && + /function canDeleteSelectedTheme\(\) \{\s*return themeMode && !deleteThemeProc\.running && !awaitingDeleteRefresh && stockThemesKnown && ImagePickerModel\.canDeleteTheme/.test(imagePickerQml) && + /function requestDeleteSelectedTheme\(\) \{[\s\S]*?deleteConfirm\.selectedIndex = 1\s*deleteConfirmOpen = true/.test(imagePickerQml) && + /function openSelector\([^)]*\) \{\s*deleteConfirmOpen = false\s*pendingDeleteTheme = ""\s*deleteSelectionPath = ""\s*awaitingDeleteRefresh = false/.test(imagePickerQml), + 'image picker cannot apply or re-ask mid-removal and resets delete state per open' +) + +deletion = deleteContext('/cache/previews/nord.png', ['nord', 'fjord'], true) +deletion.stockThemeNames = ['nord'] +deletion.requestDeleteSelectedTheme() +assert(!deletion.deleteConfirmOpen, 'Delete does nothing on a stock theme override') +deletion = deleteContext('/cache/previews/fjord.jpg', ['fjord'], true) +deletion.stockThemeNames = ['nord'] +deletion.deleteThemeProc.running = true +deletion.requestDeleteSelectedTheme() +assert(!deletion.deleteConfirmOpen, 'Delete does not ask while a removal is running') + +const refreshFn = imagePickerQml.match(/function refreshThemeRows\(\) \{[\s\S]*?\n \}/)[0] +const refreshCtx = { themeRowsProc: { running: true, queued: false } } +require('vm').runInNewContext(refreshFn, refreshCtx) +refreshCtx.refreshThemeRows() +assert(refreshCtx.themeRowsProc.queued && refreshCtx.themeRowsProc.running, 'a refresh asked mid-refresh queues behind it') +refreshCtx.themeRowsProc.running = false +refreshCtx.themeRowsProc.queued = false +refreshCtx.refreshThemeRows() +assert(refreshCtx.themeRowsProc.running, 'a refresh asked while idle starts at once') + +const updateFn = imagePickerQml.match(/function updateThemeRows\(rows\) \{[\s\S]*?\n \}/)[0] +const updateCtx = { + themeRows: 'old', themeOpenPending: false, themeMode: true, opened: true, + deleteSelectionPath: '/p/alpha.png', selectedImage: '', imageRows: '', loaded: null, + currentPath() { return '/p/bravo.png' }, + currentThemePreview() { return '' }, + openThemeRows() {}, + loadRows(rows, reveal) { updateCtx.loaded = [rows, reveal] } +} +require('vm').runInNewContext(updateFn, updateCtx) +updateCtx.updateThemeRows('new-rows') +assertEqual(updateCtx.selectedImage, '/p/alpha.png', 'a row refresh after deletion selects the remembered previous theme') +assertEqual(updateCtx.deleteSelectionPath, '', 'the remembered landing is spent by the refresh using it') +assertDeepEqual(updateCtx.loaded, ['new-rows', false], 'the refresh reloads rows without reopening the picker') +updateCtx.themeRows = 'new-rows' +updateCtx.loaded = null +updateCtx.updateThemeRows('new-rows') +assertEqual(updateCtx.selectedImage, '/p/alpha.png', 'an unchanged refresh leaves the selection alone') + +assert( + /property bool stockThemesKnown: false/.test(imagePickerQml) && + /id: stockThemesProc[\s\S]*?onExited: function\(exitCode\) \{ if \(exitCode === 0\) root\.stockThemesKnown = true \}/.test(imagePickerQml), + 'deletion stays disabled until the stock listing finishes successfully' +) +deletion = deleteContext('/cache/previews/nord.png', ['nord', 'fjord'], true) +deletion.stockThemeNames = [] +deletion.stockThemesKnown = false +deletion.requestDeleteSelectedTheme() +assert(!deletion.deleteConfirmOpen, 'Delete does nothing before stock themes are known') +deletion = deleteContext('/cache/previews/fjord.jpg', ['fjord'], true) +deletion.stockThemeNames = ['nord'] +deletion.awaitingDeleteRefresh = true +deletion.requestDeleteSelectedTheme() +assert(!deletion.deleteConfirmOpen, 'Delete does not ask while the post-delete refresh is pending') + +const applyFn = imagePickerQml.match(/function applySelected\(\) \{[\s\S]*?\n \}/)[0] +function applyContext(awaiting) { + const ctx = { + themeMode: true, opened: true, awaitingDeleteRefresh: awaiting, + deleteThemeProc: { running: false }, + currentPath() { return '/p/fjord.jpg' }, + nameForPath: picker.nameForPath, + Util: { execArgv(argv) { ctx.applied = argv } } + } + ctx.root = ctx + require('vm').runInNewContext(applyFn, ctx) + return ctx +} +let application = applyContext(true) +application.applySelected() +assert(application.applied === undefined && application.opened, 'Enter cannot apply a deleted theme before rows reload') +application = applyContext(false) +application.applySelected() +assertDeepEqual(application.applied, ['omarchy-theme-set', 'fjord'], 'Enter applies the selected theme once no deletion is pending') + +updateCtx.awaitingDeleteRefresh = true +updateCtx.deleteSelectionPath = '/p/alpha.png' +updateCtx.themeRows = 'new-rows' +updateCtx.updateThemeRows('after-delete-rows') +assertEqual(updateCtx.awaitingDeleteRefresh, false, 'the post-delete refresh loading rows releases the apply guard') JS