diff --git a/src/FileDrop.qml b/src/FileDrop.qml new file mode 100644 index 00000000..df553688 --- /dev/null +++ b/src/FileDrop.qml @@ -0,0 +1,19 @@ +import QtQuick + +// A folder files can be dropped on. While they hover it knows whether letting go moves or copies them. +DropArea { + id: area + property string folder + property bool copies: false + // Whether the hovering files all come from other drives. It asks the drives, so it is read once a drag, never on the timer. + property bool otherDrive: false + // The drag loop takes the key events, so Ctrl pressed or let go with the pointer still is seen only by asking again. + readonly property int ctrlCheckMs: 100 + + keys: ["text/uri-list"] + function recheck() { copies = otherDrive || fileOps.ctrlHeld() } + onEntered: (drag) => { otherDrive = fileOps.dropCopies(drag.urls, folder, false); recheck() } + onPositionChanged: recheck() + + Timer { interval: area.ctrlCheckMs; repeat: true; running: area.containsDrag; onTriggered: area.recheck() } +} diff --git a/src/FileRow.qml b/src/FileRow.qml index a8dab284..13fe728d 100644 --- a/src/FileRow.qml +++ b/src/FileRow.qml @@ -20,8 +20,12 @@ Item { required property int itemCount required property string icon required property bool selected + required property string fileName + required property string folder property bool current: false + // In a pane the keys don't go to: the selection is drawn without the accent. + property bool inactive: false // In a column to the left: the folder the next column is showing. property bool trail: false property bool detailed: false @@ -37,6 +41,8 @@ Item { // for it.) readonly property bool showDate: detailed && width >= 480 readonly property bool showSize: detailed && width >= 380 + // With nothing selected, keys act on the cursor's item; only the folder being shown has one. + readonly property bool cursor: ListView.isCurrentItem && ListView.view.model === backend.folder && backend.folder.selectedCount === 0 readonly property string glyph: row.isDir ? "folder" : row.kind === "item" ? row.icon @@ -47,13 +53,13 @@ Item { : "file" // A folder row takes files dropped on it, lighting up while they hover. - DropArea { + FileDrop { id: folderDrop anchors.fill: parent enabled: row.isDir - keys: ["text/uri-list"] + folder: row.path onEntered: (drag) => { drag.accepted = fileOps.canDrop(drag.urls, row.path) } - onDropped: (drop) => { fileOps.dropInto(drop.urls, row.path); drop.accept(Qt.MoveAction) } + onDropped: (drop) => drop.accept(fileOps.dropInto(drop.urls, row.path, fileOps.ctrlHeld())) } Rectangle { @@ -61,10 +67,10 @@ Item { anchors.leftMargin: 4 anchors.rightMargin: 4 radius: Math.min(4, theme.radius) - border.width: folderDrop.containsDrag ? 2 : 0 - border.color: theme.accent - color: row.current || folderDrop.containsDrag ? Qt.alpha(theme.accent, 0.18) - : row.trail ? Qt.alpha(theme.foreground, 0.08) + border.width: folderDrop.containsDrag ? 2 : row.cursor ? 1 : 0 + border.color: row.inactive && !folderDrop.containsDrag ? Qt.alpha(theme.foreground, 0.4) : theme.accent + color: row.current && !row.inactive || folderDrop.containsDrag ? Qt.alpha(theme.accent, 0.18) + : row.trail || row.current ? Qt.alpha(theme.foreground, 0.08) : hover.hovered ? Qt.alpha(theme.foreground, 0.05) : "transparent" } @@ -116,20 +122,29 @@ Item { Layout.fillWidth: true Layout.minimumWidth: 120 horizontalAlignment: Text.AlignLeft - text: row.name - color: row.current ? theme.accent : theme.foreground + text: row.fileName + color: row.current ? theme.brightForeground : theme.foreground elide: Text.ElideMiddle } Text { - visible: row.showSize + visible: row.folder.length > 0 + Layout.maximumWidth: Math.round(row.width / 3) + horizontalAlignment: Text.AlignLeft + text: row.folder + color: theme.secondaryForeground + font.pixelSize: 12 + elide: Text.ElideMiddle + } + Text { + visible: row.showSize && !folderDrop.containsDrag Layout.preferredWidth: 96 horizontalAlignment: Text.AlignRight text: row.kind === "item" ? row.extension : row.isDir ? Format.count(row.itemCount) : Format.size(row.size) - color: theme.secondaryForeground + color: row.current ? theme.foreground : theme.secondaryForeground font.pixelSize: 12 } Text { - visible: row.showDate + visible: row.showDate && !folderDrop.containsDrag Layout.preferredWidth: 96 Layout.leftMargin: 12 horizontalAlignment: Text.AlignLeft @@ -137,10 +152,17 @@ Item { translator.language // Re-read when the language (and so the default locale) changes. return Format.when(row.modified) } - color: theme.secondaryForeground + color: row.current ? theme.foreground : theme.secondaryForeground font.pixelSize: 12 elide: Text.ElideRight } + // While files hover a folder, what the drop will do takes the size and date's place. + Text { + visible: folderDrop.containsDrag + text: folderDrop.copies ? qsTr("Copy here") : qsTr("Move here") + color: theme.secondaryForeground + font.pixelSize: 12 + } // Points to where the next column opens. Glyph { visible: row.chevron && row.isDir diff --git a/src/FileTile.qml b/src/FileTile.qml index 4c85c7a5..840e9b15 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -23,8 +23,12 @@ Item { required property var size required property var modified required property bool selected + required property string fileName + required property string folder property bool current: false + // In a pane the keys don't go to: the selection is drawn without the accent. + property bool inactive: false signal clicked(int modifiers) signal activated() @@ -32,6 +36,8 @@ Item { // Pictures and drawings round their corners with the theme: square on // Omarchy's default, softly rounded on themes that round windows. readonly property real iconRadius: Math.min(10, theme.radius) + // With nothing selected, keys act on the cursor's item. + readonly property bool cursor: GridView.isCurrentItem && backend.folder.selectedCount === 0 // The badge and stand-in color for each kind, from the theme's palette. readonly property color kindColor: kind === "pdf" ? theme.red @@ -73,8 +79,8 @@ Item { anchors.topMargin: 12 anchors.horizontalCenter: parent.horizontalCenter width: parent.width - 28 - text: tile.name - color: tile.current ? theme.accent : theme.foreground + text: tile.fileName + color: tile.current ? theme.brightForeground : theme.foreground font.pixelSize: 12 horizontalAlignment: Text.AlignHCenter wrapMode: Text.WrapAtWordBoundaryOrAnywhere @@ -95,13 +101,17 @@ Item { translator.language // Re-read when the language (and so the default locale) changes. if (tile.kind === "item") return tile.extension + if (folderDrop.containsDrag) + return folderDrop.copies ? qsTr("Copy here") : qsTr("Move here") const size = tile.isDir ? Format.count(tile.itemCount) : Format.size(tile.size) // A folder's own date says little (it changes when anything in // it is added or removed), so only files show one. - const date = tile.isDir ? "" : Format.when(tile.modified) - return size && date ? qsTr("%1 · %2").arg(size).arg(date) : size || date + const date = tile.isDir || tile.folder ? "" : Format.when(tile.modified) // A search result's folder takes the date's room. + const rest = size && date ? qsTr("%1 · %2").arg(size).arg(date) : size || date + // Both in one arg() call, so a "%2" in a folder's name stays as written. + return tile.folder && rest ? qsTr("%1 · %2").arg(tile.folder, rest) : tile.folder || rest } - color: theme.secondaryForeground + color: tile.current ? theme.foreground : theme.secondaryForeground font.pixelSize: 10 horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight @@ -110,13 +120,13 @@ Item { // A folder takes files dropped anywhere on its tile, picture or name, // lighting up while they hover: the view's own drop area refuses the // whole cell, so the tile has to take all of it. - DropArea { + FileDrop { id: folderDrop anchors.fill: parent enabled: tile.isDir - keys: ["text/uri-list"] + folder: tile.path onEntered: (drag) => { drag.accepted = fileOps.canDrop(drag.urls, tile.path) } - onDropped: (drop) => { fileOps.dropInto(drop.urls, tile.path); drop.accept(Qt.MoveAction) } + onDropped: (drop) => drop.accept(fileOps.dropInto(drop.urls, tile.path, fileOps.ctrlHeld())) } HoverHandler { @@ -175,11 +185,13 @@ Item { height: 92 preferredRendererType: Shape.CurveRenderer ShapePath { - fillColor: tile.current || folderDrop.containsDrag ? Qt.tint(theme.lighterBackground, Qt.alpha(theme.accent, 0.3)) + fillColor: tile.current && !tile.inactive || folderDrop.containsDrag ? Qt.tint(theme.lighterBackground, Qt.alpha(theme.accent, 0.3)) + : tile.current ? Qt.tint(theme.lighterBackground, Qt.alpha(theme.foreground, 0.08)) : hover.hovered ? Qt.tint(theme.lighterBackground, Qt.alpha(theme.foreground, 0.06)) : theme.lighterBackground - strokeColor: tile.current || folderDrop.containsDrag ? theme.accent : "transparent" - strokeWidth: folderDrop.containsDrag ? 2.5 : 1.5 + strokeColor: (tile.current || tile.cursor) && !tile.inactive || folderDrop.containsDrag ? theme.accent + : tile.current || tile.cursor ? Qt.alpha(theme.foreground, 0.4) : "transparent" + strokeWidth: folderDrop.containsDrag ? 2.5 : tile.cursor ? 1 : 1.5 joinStyle: tile.iconRadius > 0 ? ShapePath.RoundJoin : ShapePath.MiterJoin PathSvg { path: tile.folderPath(tile.iconRadius) } } @@ -257,14 +269,14 @@ Item { return Qt.rect((w - 66) / 2, 0, 66, h) } Rectangle { - visible: tile.current || hover.hovered + visible: tile.current || tile.cursor || hover.hovered x: parent.shown.x - 3 y: parent.shown.y - 3 width: parent.shown.width + 6 height: parent.shown.height + 6 color: "transparent" border.width: tile.current ? 2 : 1 - border.color: tile.current ? theme.accent : Qt.alpha(theme.foreground, 0.2) + border.color: (tile.current || tile.cursor) && !tile.inactive ? theme.accent : Qt.alpha(theme.foreground, tile.current || tile.cursor ? 0.4 : 0.2) radius: tile.iconRadius > 0 ? tile.iconRadius + 3 : 0 } } @@ -280,15 +292,15 @@ Item { name: tile.icon size: 76 stroke: 1.2 - color: tile.current ? theme.accent : theme.foreground + color: tile.current && !tile.inactive ? theme.accent : theme.foreground } Rectangle { - visible: tile.current || hover.hovered + visible: tile.current || tile.cursor || hover.hovered anchors.fill: computer anchors.margins: -3 color: "transparent" border.width: tile.current ? 2 : 1 - border.color: tile.current ? theme.accent : Qt.alpha(theme.foreground, 0.2) + border.color: (tile.current || tile.cursor) && !tile.inactive ? theme.accent : Qt.alpha(theme.foreground, tile.current || tile.cursor ? 0.4 : 0.2) radius: tile.iconRadius > 0 ? tile.iconRadius + 3 : 0 } } diff --git a/src/FolderColumns.qml b/src/FolderColumns.qml index 7e802fff..b4ec7667 100644 --- a/src/FolderColumns.qml +++ b/src/FolderColumns.qml @@ -10,6 +10,8 @@ Item { id: view property alias currentIndex: here.currentIndex + // In a pane the keys don't go to: the selection is drawn without the accent. + property bool inactive: false readonly property int columnWidth: 260 function select(row, keep) { selecting.select(row, keep) } @@ -173,6 +175,7 @@ Item { width: here.width chevron: true current: selected + inactive: view.inactive onClicked: (modifiers) => { selecting.click(index, modifiers); here.forceActiveFocus() } onActivated: backend.open(path) } diff --git a/src/FolderDrop.qml b/src/FolderDrop.qml index 4eb6d09d..fcea8af5 100644 --- a/src/FolderDrop.qml +++ b/src/FolderDrop.qml @@ -5,10 +5,9 @@ import QtQuick // where it refuses one (it's the folder being dropped, or the one the drop // is already in), this refuses too, rather than catching the drop as it // falls through and landing it in this folder instead. -DropArea { +FileDrop { id: area - property string folder // The list or grid whose items lie over this area. property Item items @@ -18,14 +17,12 @@ DropArea { return !(item && item.isDir) && fileOps.canDrop(drag.urls, folder) } - keys: ["text/uri-list"] onEntered: (drag) => { drag.accepted = takes(drag) } onPositionChanged: (drag) => { drag.accepted = takes(drag) } onDropped: (drop) => { if (!takes(drop)) return - fileOps.dropInto(drop.urls, folder) - drop.accept(Qt.MoveAction) + drop.accept(fileOps.dropInto(drop.urls, folder, fileOps.ctrlHeld())) } Rectangle { @@ -36,5 +33,13 @@ DropArea { border.width: 2 border.color: theme.accent radius: Math.min(6, theme.radius) + Text { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 12 + text: area.copies ? qsTr("Copy here") : qsTr("Move here") + color: theme.secondaryForeground + font.pixelSize: 12 + } } } diff --git a/src/FolderGrid.qml b/src/FolderGrid.qml index 37385b8a..c68e502b 100644 --- a/src/FolderGrid.qml +++ b/src/FolderGrid.qml @@ -5,6 +5,9 @@ import QtQuick.Controls GridView { id: grid + // In a pane the keys don't go to: the selection is drawn without the accent. + property bool inactive: false + function select(row, keep) { selecting.select(row, keep) } // A click on empty space lets go of the cursor; the pane then keeps it // let go of through a re-read or a switch of view. @@ -95,6 +98,7 @@ GridView { height: grid.cellHeight zoom: grid.tileZoom current: selected + inactive: grid.inactive onClicked: (modifiers) => { selecting.click(index, modifiers); grid.forceActiveFocus() } onActivated: backend.open(path) } diff --git a/src/FolderList.qml b/src/FolderList.qml index 8e93a633..c6dfaf7c 100644 --- a/src/FolderList.qml +++ b/src/FolderList.qml @@ -6,6 +6,9 @@ import QtQuick.Controls ListView { id: list + // In a pane the keys don't go to: the selection is drawn without the accent. + property bool inactive: false + function select(row, keep) { selecting.select(row, keep) } // A click on empty space lets go of the cursor; the pane then keeps it // let go of through a re-read or a switch of view. @@ -57,6 +60,7 @@ ListView { width: list.width detailed: true current: selected + inactive: list.inactive onClicked: (modifiers) => { selecting.click(index, modifiers); list.forceActiveFocus() } onActivated: backend.open(path) } diff --git a/src/FolderPane.qml b/src/FolderPane.qml index 1c3d5315..37f79a83 100644 --- a/src/FolderPane.qml +++ b/src/FolderPane.qml @@ -12,6 +12,7 @@ FocusScope { // The window this pane is in, for the state the panes share. readonly property var host: ApplicationWindow.window + readonly property Item dialogLayer: host ? host.dialogLayer : pane readonly property bool active: !!host && host.activePane === pane readonly property var paneBackend: backend readonly property var paneFileOps: fileOps @@ -50,13 +51,16 @@ FocusScope { property bool cursorLetGo: false // While a text field has the keyboard (renaming, a password), single-key // and clipboard shortcuts stand aside so typing works. - readonly property bool typing: rename.visible || confirm.visible || clash.visible || formatDialog.visible || backend.mounter.asking || pathBar.editing || pathBar.searchFocused || (!!host && host.helpVisible) + readonly property bool typing: dialogShowing || pathBar.editing || pathBar.searchFocused || (!!host && (host.dialogOpen || host.helpVisible)) + readonly property bool dialogShowing: rename.visible || confirm.visible || clash.visible || formatDialog.visible || backend.mounter.asking // Keys for the pane as a whole (undo, a new window, typing a path) work // wherever the keyboard is; keys for its files only while the files // have it, not while Tab has put it in the sidebar or the details, where // Delete or Space would act on files out of the keyboard's sight. readonly property bool paneKeys: active && !typing readonly property bool keys: paneKeys && activeFocus + // With the keys in another pane, a search field, the sidebar or the details, the views draw their selection without the accent. + readonly property bool keysElsewhere: !active || pathBar.searchFocused || (!!host && host.keysBesidePanes) // What file actions act on: everything selected, or the item under // the cursor when nothing is. function selectedPaths() { @@ -122,6 +126,14 @@ FocusScope { // details pane follow. onActiveFocusChanged: if (activeFocus && host) host.activePane = pane function focusView() { if (view.item) view.item.focusView() } + // A dialog sits outside this scope, so it makes this pane active, and the last one to close hands the keys back. + function dialogShown(shown) { + if (shown && host) + host.activePane = pane + else if (!shown) + Qt.callLater(pane.handBack) + } + function handBack() { if (!(host && host.dialogOpen)) focusView() } // Pins a folder to the sidebar, or unpins it when it's pinned already. function togglePin(target) { @@ -221,7 +233,8 @@ FocusScope { Connections { target: view.item - function onContextMenu(item, folder, at) { fileMenu.show(item, folder, null, at) } + // A right click in the other pane makes it the one the keys and the menu act on, as a left click does. + function onContextMenu(item, folder, at) { pane.focusView(); fileMenu.show(item, folder, null, at) } function onCursorLetGo() { pane.cursorLetGo = true } // The cursor's item is remembered as the cursor moves, and once the // folder's loaded has placed it. Not when a re-read leaves the cursor @@ -346,7 +359,7 @@ FocusScope { Layout.fillWidth: true text: backend.message || backend.folder.error || (slowWork.shown ? slowWork.working : "") || backend.notice color: theme.brightForeground - elide: Text.ElideRight + elide: Text.ElideMiddle } // File work can be stopped where it's shown. A click leaves // the keyboard with the files, since the button goes once @@ -412,9 +425,9 @@ FocusScope { items: view.item } } - Component { id: gridView; FolderGrid { zoom: pane.zoom; onZoomRequested: (step) => pane.zoomBy(step) } } - Component { id: listView; FolderList { } } - Component { id: columnsView; FolderColumns { } } + Component { id: gridView; FolderGrid { zoom: pane.zoom; inactive: pane.keysElsewhere; onZoomRequested: (step) => pane.zoomBy(step) } } + Component { id: listView; FolderList { inactive: pane.keysElsewhere } } + Component { id: columnsView; FolderColumns { inactive: pane.keysElsewhere } } } // Drawn over the window's top bar. It reaches back over the sidebar for @@ -456,31 +469,36 @@ FocusScope { // A dialog takes the keyboard while it's open; closing only hides it, // so the keyboard is handed back to the files. MountPrompt { + parent: pane.dialogLayer anchors.fill: parent - onVisibleChanged: if (!visible) pane.focusView() + onVisibleChanged: pane.dialogShown(visible) } RenameDialog { id: rename + parent: pane.dialogLayer anchors.fill: parent - onVisibleChanged: if (!visible) pane.focusView() + onVisibleChanged: pane.dialogShown(visible) } ConfirmDialog { id: confirm + parent: pane.dialogLayer anchors.fill: parent - onVisibleChanged: if (!visible) pane.focusView() + onVisibleChanged: pane.dialogShown(visible) } FormatDialog { id: formatDialog + parent: pane.dialogLayer anchors.fill: parent - onVisibleChanged: if (!visible) pane.focusView() + onVisibleChanged: pane.dialogShown(visible) } ClashDialog { id: clash + parent: pane.dialogLayer anchors.fill: parent - onVisibleChanged: if (!visible) pane.focusView() + onVisibleChanged: pane.dialogShown(visible) } } diff --git a/src/FolioDialog.qml b/src/FolioDialog.qml index 1093858e..1895041b 100644 --- a/src/FolioDialog.qml +++ b/src/FolioDialog.qml @@ -2,7 +2,7 @@ import QtQuick import QtQuick.Layouts // The frame Rename, Confirm and the mount prompt share: a scrim over the -// pane and a card in its middle, titled, holding the dialog's own fields +// window and a card in its middle, titled, holding the dialog's own fields // and buttons. A click on the scrim is the dialog's to answer; one on the // card stays there. Tab and Shift+Tab go round the dialog's own fields and // buttons, never on to the buttons behind it. diff --git a/src/FolioMenu.qml b/src/FolioMenu.qml index 1b4a4b70..4d461d37 100644 --- a/src/FolioMenu.qml +++ b/src/FolioMenu.qml @@ -9,6 +9,8 @@ Menu { exit: null topPadding: 4 bottomPadding: 4 + // Material's default of 0 lets a menu touch the window's edge. + margins: 8 // Submenu entries (Open With, Share) are made from this. delegate: FolioMenuItem { } // As wide as its widest entry shown, so a long label (in any language) diff --git a/src/FolioMenuItem.qml b/src/FolioMenuItem.qml index ad3824a2..4176ea49 100644 --- a/src/FolioMenuItem.qml +++ b/src/FolioMenuItem.qml @@ -35,7 +35,8 @@ MenuItem { x: 4 width: parent.width - 8 radius: Math.min(4, theme.radius) - color: entry.highlighted && entry.enabled ? Qt.alpha(theme.accent, 0.18) : "transparent" + color: entry.subMenu && entry.subMenu.visible ? Qt.alpha(theme.foreground, 0.08) + : entry.highlighted && entry.enabled ? Qt.alpha(theme.accent, 0.18) : "transparent" } contentItem: Item { diff --git a/src/FolioScrollBar.qml b/src/FolioScrollBar.qml index f3c861fb..488405b8 100644 --- a/src/FolioScrollBar.qml +++ b/src/FolioScrollBar.qml @@ -10,8 +10,10 @@ ScrollBar { implicitWidth: 4 implicitHeight: 4 radius: Math.min(2, theme.radius) - color: Qt.alpha(theme.foreground, bar.pressed ? 0.5 : 0.28) - visible: bar.size < 1 && (bar.active || bar.hovered) + // Shown at rest it is the only sign of more below, so it takes the theme's quiet text ink (3:1 at least). + color: bar.policy === ScrollBar.AlwaysOn ? (bar.pressed ? theme.foreground : theme.secondaryForeground) + : Qt.alpha(theme.foreground, bar.pressed ? 0.5 : 0.28) + visible: bar.size < 1 && (bar.active || bar.hovered || bar.policy === ScrollBar.AlwaysOn) } background: null // Material fades the bar in and out of its active state; drop that. diff --git a/src/Format.js b/src/Format.js index a359f60e..740ffd94 100644 --- a/src/Format.js +++ b/src/Format.js @@ -30,7 +30,17 @@ function when(date) { const locale = Qt.locale() const now = new Date() const today = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate() - return today ? date.toLocaleTimeString(locale, QtQml.Locale.ShortFormat) : date.toLocaleDateString(locale, QtQml.Locale.ShortFormat) + return spaced(today ? date.toLocaleTimeString(locale, QtQml.Locale.ShortFormat) : date.toLocaleDateString(locale, QtQml.Locale.ShortFormat)) +} + +// Its date and time, in the details. +function stamp(date) { + return date ? spaced(date.toLocaleString(Qt.locale(), QtQml.Locale.ShortFormat)) : "" +} + +// Qt puts a narrow no-break space (U+202F) before AM and PM, which draws as no space at all. +function spaced(text) { + return text.replace(/\u202f/g, " ") } // A folder's contents; nothing until they've been counted. diff --git a/src/HelpOverlay.qml b/src/HelpOverlay.qml index 388be554..7bb64eb7 100644 --- a/src/HelpOverlay.qml +++ b/src/HelpOverlay.qml @@ -74,6 +74,7 @@ Rectangle { // Sized to what it holds: two columns of shortcuts side by side when // the window is wide enough, and scrolling when it's too short for them. Rectangle { + id: card anchors.centerIn: parent width: Math.min(parent.width - 64, content.implicitWidth + 56) height: Math.min(parent.height - 64, content.implicitHeight + 56) @@ -90,7 +91,14 @@ Rectangle { contentHeight: content.implicitHeight clip: true boundsBehavior: Flickable.StopAtBounds - ScrollBar.vertical: FolioScrollBar { } + // In the card's margin, clear of the descriptions; at rest too, so the rows below are known. + ScrollBar.vertical: FolioScrollBar { + policy: ScrollBar.AlwaysOn + parent: card + anchors.right: parent.right + y: flick.y + height: flick.height + } ColumnLayout { id: content diff --git a/src/Main.qml b/src/Main.qml index ff8f4221..ffc812ad 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -66,10 +66,16 @@ ApplicationWindow { // Whether keys are going into a text field. The right pane can go before // the window does when it closes, leaving activePane null. readonly property bool typing: !!activePane && activePane.typing + // Whether Tab has put the keyboard in the sidebar or the details. + readonly property bool keysBesidePanes: sidebar.activeFocus || details.hasKeyboard // The rightmost pane's path bar reaches over the details column. readonly property int detailsWidth: details.visible ? details.width : 0 // Whether the window is wide enough for the details column at all. readonly property bool detailsFit: width >= (split ? 1400 : 960) + // The panes draw their dialogs here, so they dim and centre on the whole window. + readonly property Item dialogLayer: layer + // A dialog dims the whole window, so while one is open no pane takes keys. + readonly property bool dialogOpen: leftPane.dialogShowing || (rightPane !== null && rightPane.dialogShowing) // The process works in the folder the window used last shows, in the // pane used last, for Omarchy's terminal key to open there. @@ -131,6 +137,7 @@ ApplicationWindow { } Item { + id: layer anchors.fill: parent enabled: win.settled opacity: win.settled ? 1 : 0 diff --git a/src/PreviewPane.qml b/src/PreviewPane.qml index 76e7aea0..6a757e47 100644 --- a/src/PreviewPane.qml +++ b/src/PreviewPane.qml @@ -15,6 +15,12 @@ Rectangle { // look for their trash notes or make large thumbnails for nobody. readonly property var item: visible && folderPane ? folderPane.current : ({}) readonly property bool hasItem: item !== undefined && item.path !== undefined + // With several selected, the title and the line under it are theirs. + readonly property var folder: folderPane ? folderPane.paneBackend.folder : null + // A selection with no cursor (let go of by a click on empty space) is counted too. + readonly property int many: visible && folder && (folder.selectedCount > 1 || folder.selectedCount > 0 && !hasItem) ? folder.selectedCount : 0 + // The picture is one item's only while one is shown. + readonly property bool one: hasItem && !many // An item in the trash shows where it came from and when it was // deleted, and offers Restore rather than Open. Its note is read in the // background; until it's in, the item looks trashed, as nearly all there @@ -39,6 +45,8 @@ Rectangle { readonly property bool hasKeyboard: opensWith.activeFocus function focusKeyboard() { opensWith.forceActiveFocus(Qt.TabFocusReason) } signal finished() + // Several selected in a plugin's view are its items, with no size to add up. + readonly property bool pluginItems: many > 0 && folderPane.inView function homeless(path) { return path.startsWith(places.home + "/") ? "~" + path.slice(places.home.length) : path } // A picture's size or a text's opening lines once they've been read off // the UI thread. @@ -57,7 +65,7 @@ Rectangle { anchors.fill: parent anchors.margins: 28 spacing: 22 - visible: pane.hasItem + visible: pane.hasItem || pane.many > 0 Item { Layout.fillWidth: true @@ -68,14 +76,14 @@ Rectangle { anchors.fill: parent asynchronous: true fillMode: Image.PreserveAspectFit - source: pane.hasItem && !pane.item.isDir && pane.item.kind !== "text" && !pane.pluginItem ? Format.thumb(pane.item.path, pane.item.mime, 512, pane.item.modified) : "" + source: pane.one && !pane.item.isDir && pane.item.kind !== "text" && !pane.pluginItem ? Format.thumb(pane.item.path, pane.item.mime, 512, pane.item.modified) : "" visible: status === Image.Ready } // Text files show their opening lines instead of a picture. Rectangle { anchors.fill: parent - visible: pane.hasItem && pane.item.kind === "text" + visible: pane.one && pane.item.kind === "text" color: theme.background border.width: 1 border.color: Qt.alpha(theme.foreground, 0.12) @@ -86,7 +94,7 @@ Rectangle { text: pane.info.text || "" color: theme.foreground font.pixelSize: 10 - wrapMode: Text.WrapAnywhere + wrapMode: Text.WrapAtWordBoundaryOrAnywhere textFormat: Text.PlainText elide: Text.ElideRight } @@ -94,11 +102,11 @@ Rectangle { Glyph { anchors.centerIn: parent - visible: !big.visible && pane.hasItem && pane.item.kind !== "text" - name: pane.pluginItem ? pane.item.icon : pane.hasItem && pane.item.isDir ? "folder" : "file" + visible: !big.visible && (pane.many > 0 || pane.hasItem && pane.item.kind !== "text") + name: pane.one && pane.pluginItem ? pane.item.icon : pane.one && pane.item.isDir ? "folder" : "file" size: 72 stroke: 1.2 - color: pane.hasItem && pane.item.isDir ? theme.accent : theme.secondaryForeground + color: pane.one && pane.item.isDir ? theme.accent : theme.secondaryForeground } } @@ -110,11 +118,11 @@ Rectangle { Text { Layout.fillWidth: true horizontalAlignment: Text.AlignLeft - text: pane.item.name || "" + text: pane.many ? qsTr("%Ln items selected", "", pane.many) : pane.item.fileName || "" color: theme.brightForeground font.pixelSize: 15 font.weight: Font.DemiBold - wrapMode: Text.WrapAnywhere + wrapMode: Text.WrapAtWordBoundaryOrAnywhere maximumLineCount: 3 elide: Text.ElideRight } @@ -123,7 +131,10 @@ Rectangle { horizontalAlignment: Text.AlignLeft text: { translator.language // The type's name comes from the system, in the language of the moment. - return Format.summary(pane.item, backend.typeName(pane.item.mime || "")) + return !pane.many ? Format.summary(pane.item, backend.typeName(pane.item.mime || "")) + : pane.pluginItems ? "" + : pane.folder.selectedFolderCount > 0 ? qsTr("%Ln folders", "", pane.folder.selectedFolderCount) + : Format.size(pane.folder.selectedSize) } color: theme.secondaryForeground font.pixelSize: 12 @@ -133,6 +144,7 @@ Rectangle { GridLayout { Layout.fillWidth: true + visible: !pane.many columns: 2 rowSpacing: 12 columnSpacing: 16 @@ -147,6 +159,16 @@ Rectangle { font.pixelSize: 12 elide: Text.ElideMiddle } + Text { visible: !!pane.item.folder; text: qsTr("Folder"); color: theme.secondaryForeground; font.pixelSize: 12 } + Text { + visible: !!pane.item.folder + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + text: pane.item.folder || "" + color: theme.foreground + font.pixelSize: 12 + elide: Text.ElideMiddle + } Text { visible: !pane.pluginItem; text: pane.trashed || pane.noting ? qsTr("Deleted") : qsTr("Modified"); color: theme.secondaryForeground; font.pixelSize: 12 } Text { visible: !pane.pluginItem @@ -155,7 +177,7 @@ Rectangle { text: { translator.language // Re-read when the language (and so the default locale) changes. const when = pane.trashed ? pane.info.deleted : pane.noting ? null : pane.item.modified - return pane.hasItem && when ? when.toLocaleString(Qt.locale(), Locale.ShortFormat) : "" + return pane.hasItem ? Format.stamp(when) : "" } color: theme.foreground font.pixelSize: 12 @@ -242,6 +264,8 @@ Rectangle { AbstractButton { id: openButton + // It acts on one item, so it stands aside for several. + visible: pane.one Layout.fillWidth: true implicitHeight: 40 focusPolicy: Qt.NoFocus @@ -272,7 +296,9 @@ Rectangle { Text { anchors.centerIn: parent - visible: !pane.hasItem + // On the empty folder's line beside it: that view starts 16 px lower, under a gap below the bar. + anchors.verticalCenterOffset: 8 + visible: !pane.hasItem && !pane.many text: qsTr("Nothing selected") color: theme.secondaryForeground } diff --git a/src/PreviewWindow.qml b/src/PreviewWindow.qml index eb7530e3..cf493eab 100644 --- a/src/PreviewWindow.qml +++ b/src/PreviewWindow.qml @@ -33,6 +33,7 @@ ApplicationWindow { LayoutMirroring.childrenInherit: true property bool helpVisible: false + onHelpVisibleChanged: if (helpVisible) help.forceActiveFocus() readonly property bool rtl: Qt.application.layoutDirection === Qt.RightToLeft readonly property var item: previewer.item @@ -49,14 +50,15 @@ ApplicationWindow { Shortcut { sequences: ["Space", "Escape", "Q"]; context: Qt.ApplicationShortcut; onActivated: win.helpVisible ? win.helpVisible = false : Qt.quit() } Shortcut { sequence: "?"; context: Qt.ApplicationShortcut; onActivated: win.helpVisible = !win.helpVisible } - // The folder's files run right to left when mirrored, so Left is next. - Shortcut { sequence: "Left"; context: Qt.ApplicationShortcut; onActivated: win.rtl ? previewer.next() : previewer.previous() } - Shortcut { sequence: "Right"; context: Qt.ApplicationShortcut; onActivated: win.rtl ? previewer.previous() : previewer.next() } Shortcut { sequences: ["Return", "Enter"]; context: Qt.ApplicationShortcut; onActivated: previewer.open() } - Shortcut { sequences: ["Up"]; context: Qt.ApplicationShortcut; onActivated: win.call("scroll", -1) } - Shortcut { sequences: ["Down"]; context: Qt.ApplicationShortcut; onActivated: win.call("scroll", 1) } - Shortcut { sequences: [StandardKey.MoveToPreviousPage]; context: Qt.ApplicationShortcut; onActivated: win.call("page", -1) } - Shortcut { sequences: [StandardKey.MoveToNextPage]; context: Qt.ApplicationShortcut; onActivated: win.call("page", 1) } + // The folder's files run right to left when mirrored, so Left is next. + Shortcut { sequence: "Left"; context: Qt.ApplicationShortcut; enabled: !win.helpVisible; onActivated: win.rtl ? previewer.next() : previewer.previous() } + Shortcut { sequence: "Right"; context: Qt.ApplicationShortcut; enabled: !win.helpVisible; onActivated: win.rtl ? previewer.previous() : previewer.next() } + // While the shortcut sheet is up, these keys and the two above leave the preview behind it alone. + Shortcut { sequences: ["Up"]; context: Qt.ApplicationShortcut; enabled: !win.helpVisible; onActivated: win.call("scroll", -1) } + Shortcut { sequences: ["Down"]; context: Qt.ApplicationShortcut; enabled: !win.helpVisible; onActivated: win.call("scroll", 1) } + Shortcut { sequences: [StandardKey.MoveToPreviousPage]; context: Qt.ApplicationShortcut; enabled: !win.helpVisible; onActivated: win.call("page", -1) } + Shortcut { sequences: [StandardKey.MoveToNextPage]; context: Qt.ApplicationShortcut; enabled: !win.helpVisible; onActivated: win.call("page", 1) } Shortcut { sequences: [StandardKey.ZoomIn, "Ctrl+="]; context: Qt.ApplicationShortcut; onActivated: win.call("zoomBy", 1.25) } Shortcut { sequences: [StandardKey.ZoomOut]; context: Qt.ApplicationShortcut; onActivated: win.call("zoomBy", 0.8) } Shortcut { sequence: "Ctrl+0"; context: Qt.ApplicationShortcut; onActivated: win.call("fit") } @@ -178,6 +180,7 @@ ApplicationWindow { } HelpOverlay { + id: help anchors.fill: parent visible: win.helpVisible onDismissed: win.helpVisible = false diff --git a/src/Selecting.qml b/src/Selecting.qml index aa06c9e4..87a82eac 100644 --- a/src/Selecting.qml +++ b/src/Selecting.qml @@ -24,6 +24,8 @@ QtObject { return backend.folder.selectOnly(view.currentIndex) anchor = view.currentIndex + if (view.currentIndex >= 0) + view.positionViewAtIndex(view.currentIndex, ListView.Contain) } function click(index, modifiers) { diff --git a/src/Sidebar.qml b/src/Sidebar.qml index 9826bd11..732b9ee6 100644 --- a/src/Sidebar.qml +++ b/src/Sidebar.qml @@ -189,19 +189,19 @@ Rectangle { border.color: theme.accent } - DropArea { + FileDrop { id: rowDrop anchors.fill: parent enabled: row.dropPath.length > 0 || row.dropTrashes - keys: ["text/uri-list"] + folder: row.dropPath onEntered: (drag) => { drag.accepted = row.dropTrashes ? drag.hasUrls : fileOps.canDrop(drag.urls, row.dropPath) } onDropped: (drop) => { if (row.dropTrashes) { fileOps.trash(fileOps.localPaths(drop.urls)) + drop.accept(Qt.MoveAction) } else { - fileOps.dropInto(drop.urls, row.dropPath) + drop.accept(fileOps.dropInto(drop.urls, row.dropPath, fileOps.ctrlHeld())) } - drop.accept(Qt.MoveAction) } } contentItem: RowLayout { @@ -243,6 +243,7 @@ Rectangle { : row.mark.tone === "warning" ? theme.yellow : theme.secondaryForeground font.pixelSize: 11 } + Text { visible: rowDrop.containsDrag && !row.dropTrashes; text: rowDrop.copies ? qsTr("Copy here") : qsTr("Move here"); color: theme.secondaryForeground; font.pixelSize: 11 } } } diff --git a/src/fileops.cpp b/src/fileops.cpp index da94370e..9128e890 100644 --- a/src/fileops.cpp +++ b/src/fileops.cpp @@ -757,10 +757,39 @@ bool FileOps::canDrop(const QList &urls, const QString &folder) const { return any; } -void FileOps::dropInto(const QList &urls, const QString &folder) { +Qt::DropAction FileOps::dropInto(const QList &urls, const QString &folder, bool copy) { if (!canDrop(urls, folder) || !idle()) - return; - transferInBackground(localPaths(urls), folder, Drop, false); + return Qt::IgnoreAction; + // canDrop passed, so at least one item is from elsewhere. + const QStringList paths = fromElsewhere(urls, folder); + // Asked before the work starts, which can move items away. + const Qt::DropAction action = dropCopies(urls, folder, copy) ? Qt::CopyAction : Qt::MoveAction; + transferInBackground(paths, folder, copy ? Copy : Drop, false); + return action; +} + +bool FileOps::ctrlHeld() const { + // Asked of the system, not the last event: the drop's own event doesn't say. + return QGuiApplication::queryKeyboardModifiers().testFlag(Qt::ControlModifier); +} + +bool FileOps::dropCopies(const QList &urls, const QString &folder, bool copy) const { + if (copy) + return true; + const QStringList paths = fromElsewhere(urls, folder); + // One item on the folder's drive makes it a move; a folder may be a mount so asks itself, a file or link asks its folder once. + const QByteArray drive = QStorageInfo(folder).device(); + QSet asked; + for (const QString &path : paths) { + const QFileInfo item(path); + const QString checked = item.isDir() && !item.isSymLink() ? path : item.absolutePath(); + if (asked.contains(checked)) + continue; + asked.insert(checked); + if (QStorageInfo(checked).device() == drive) + return false; + } + return !paths.isEmpty(); } QString FileOps::uriList(const QStringList &paths) const { @@ -778,6 +807,14 @@ QStringList FileOps::localPaths(const QList &urls) const { return paths; } +QStringList FileOps::fromElsewhere(const QList &urls, const QString &folder) const { + QStringList paths; + for (const QString &path : localPaths(urls)) + if (!alreadyIn(path, folder)) + paths << path; + return paths; +} + template bool FileOps::inBackground(const std::function &label, Work work, Done done, Lane lane) { // Asked about first, work can find another pane's under way by now. diff --git a/src/fileops.h b/src/fileops.h index 9a87ecd8..b0c53b2f 100644 --- a/src/fileops.h +++ b/src/fileops.h @@ -93,10 +93,12 @@ class FileOps : public QObject { Q_INVOKABLE void cut(const QStringList &paths); Q_INVOKABLE void yank(const QStringList &paths); Q_INVOKABLE void paste(const QString &folder); - // Files dropped on a folder: moved when they're on the same drive, - // copied from another drive. Dropping something where it already is - // does nothing. - Q_INVOKABLE void dropInto(const QList &urls, const QString &folder); + // Files dropped on a folder: each moved on its drive and copied from another, or all copied with `copy` (Ctrl held); returns CopyAction only when every item is copied, or IgnoreAction when it refuses. + Q_INVOKABLE Qt::DropAction dropInto(const QList &urls, const QString &folder, bool copy = false); + // Whether Ctrl is held right now, which makes a drop copy. + Q_INVOKABLE bool ctrlHeld() const; + // Whether a drop of these on `folder` copies every item: with `copy` (Ctrl held), or all from other drives. + Q_INVOKABLE bool dropCopies(const QList &urls, const QString &folder, bool copy) const; // Whether these can be dropped on `folder`: not onto themselves, and // not into the trash, which only Delete fills. Q_INVOKABLE bool canDrop(const QList &urls, const QString &folder) const; @@ -324,6 +326,9 @@ class FileOps : public QObject { QProcess *runTool(const QString &program, const QStringList &args, const QString &busy, const QString &missing, const QString &stopped, const std::function &finished); + // The local files among `urls` not already in `folder`: a copy of one already there would be a duplicate. + QStringList fromElsewhere(const QList &urls, const QString &folder) const; + QString m_busy; std::shared_ptr m_job; // The lane this pane's work under way is counted in. diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 2cce5a3f..ed843f42 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -109,6 +109,8 @@ QVariant FolderModel::data(const QModelIndex &index, int role) const { case ItemCountRole: return e.itemCount; case SelectedRole: return m_selected.contains(e.path); case IconRole: return e.icon; + case FileNameRole: return e.folder.isEmpty() ? e.name : e.name.mid(e.folder.size() + 1); + case FolderRole: return e.folder; } return {}; } @@ -118,7 +120,7 @@ QHash FolderModel::roleNames() const { {NameRole, "name"}, {PathRole, "path"}, {IsDirRole, "isDir"}, {SizeRole, "size"}, {ModifiedRole, "modified"}, {KindRole, "kind"}, {MimeRole, "mime"}, {ExtensionRole, "extension"}, {ItemCountRole, "itemCount"}, {SelectedRole, "selected"}, - {IconRole, "icon"}, + {IconRole, "icon"}, {FileNameRole, "fileName"}, {FolderRole, "folder"}, }; } @@ -305,6 +307,8 @@ QVector FolderModel::search(const QString &root, const QStri if (info.fileName().contains(query, Qt::CaseInsensitive) && entries.size() < limit) { Entry e = entryFor(info, mimes); e.name = base.relativeFilePath(e.path); + if (const int slash = e.name.lastIndexOf('/'); slash > 0) + e.folder = e.name.left(slash); entries.push_back(e); } // Other filesystems (a mounted drive, another machine, /proc @@ -469,7 +473,8 @@ void FolderModel::reload() { for (const Entry &e : m_entries) if (m_selected.contains(e.path)) still.insert(e.path); - if (still != m_selected) { + // Kept items can still change size, which the totals follow. + if (still != m_selected || !same) { m_selected = still; emit selectionChanged(); } @@ -551,6 +556,7 @@ QVariantMap FolderModel::toMap(const Entry &entry) { {"modified", entry.modified}, {"kind", entry.kind}, {"mime", entry.mime}, {"extension", entry.extension}, {"itemCount", entry.itemCount}, {"icon", entry.icon}, {"details", entry.details}, + {"fileName", entry.folder.isEmpty() ? entry.name : entry.name.mid(entry.folder.size() + 1)}, {"folder", entry.folder}, }; } @@ -620,6 +626,19 @@ QStringList FolderModel::selectedPaths() const { return paths; } +qint64 FolderModel::selectedSize() const { + qint64 total = 0; + for (const Entry &e : m_entries) + if (!e.isDir && m_selected.contains(e.path)) + total += e.size; + return total; +} + +int FolderModel::selectedFolderCount() const { + return std::count_if(m_entries.begin(), m_entries.end(), + [this](const Entry &e) { return e.isDir && m_selected.contains(e.path); }); +} + int FolderModel::indexOf(const QString &name) const { for (int row = 0; row < m_entries.size(); ++row) if (m_entries[row].name == name) diff --git a/src/foldermodel.h b/src/foldermodel.h index 681a2c7e..386cab1b 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -30,6 +30,8 @@ class FolderModel : public QAbstractListModel { Q_PROPERTY(int selectedCount READ selectedCount NOTIFY selectionChanged) // Set, the folder lists what matches it here and in every folder below. Q_PROPERTY(QString query READ query WRITE setQuery NOTIFY queryChanged) + Q_PROPERTY(qint64 selectedSize READ selectedSize NOTIFY selectionChanged) + Q_PROPERTY(int selectedFolderCount READ selectedFolderCount NOTIFY selectionChanged) // The items a plugin's view lists, as Plugins::views gives them. Q_PROPERTY(QVariantList items READ items WRITE setItems NOTIFY itemsChanged) @@ -46,6 +48,8 @@ class FolderModel : public QAbstractListModel { ItemCountRole, SelectedRole, IconRole, + FileNameRole, + FolderRole, }; struct Entry { @@ -64,6 +68,8 @@ class FolderModel : public QAbstractListModel { int itemCount = -1; // The first pictures in a folder, for the preview window. QStringList peek; + // A search result's folder, from the searched one; empty when it is there. + QString folder; }; explicit FolderModel(QObject *parent = nullptr); @@ -105,6 +111,9 @@ class FolderModel : public QAbstractListModel { Q_INVOKABLE void clearSelection(); Q_INVOKABLE bool isSelected(int row) const; Q_INVOKABLE QStringList selectedPaths() const; + // The selected files' bytes together, and how many selected are folders. + qint64 selectedSize() const; + int selectedFolderCount() const; // Synchronous building blocks, used by the background passes and tests. // A listing stops, empty, as soon as `stop` returns true. Names sort diff --git a/src/i18n/en.json b/src/i18n/en.json index 369dbda5..550544ce 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -32,10 +32,18 @@ "%1 · %2": "%1 · %2", "%1 × %2": "%1 × %2", "%1: %2": "%1: %2", + "%Ln folders": { + "one": "%Ln folder", + "other": "%Ln folders" + }, "%Ln items": { "one": "%Ln item", "other": "%Ln items" }, + "%Ln items selected": { + "one": "%Ln item selected", + "other": "%Ln items selected" + }, "%Ln items will be deleted permanently. This can't be undone.": { "one": "%Ln item will be deleted permanently. This can't be undone.", "other": "%Ln items will be deleted permanently. This can't be undone." @@ -80,6 +88,7 @@ "Copied %1, but couldn't remove the original.": "Copied %1, but couldn't remove the original.", "Copy": "Copy", "Copy Path": "Copy Path", + "Copy here": "Copy here", "Copy the path as text": "Copy the path as text", "Copy, cut, paste files": "Copy, cut, paste files", "Copying %1: %2 of %3…": "Copying %1: %2 of %3…", @@ -220,6 +229,7 @@ "Modified": "Modified", "Mount": "Mount", "Move between the sidebar, the panes and the details": "Move between the sidebar, the panes and the details", + "Move here": "Move here", "Move the selection": "Move the selection", "Move to the trash": "Move to the trash", "Moved only part of %1: %2": "Moved only part of %1: %2", diff --git a/src/resources.qrc b/src/resources.qrc index a4118dfc..747fc975 100644 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -34,6 +34,7 @@ PreviewWindow.qml PdfPreview.qml MediaPreview.qml + FileDrop.qml Format.js i18n/en.json i18n/ar.json diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 21e81d07..ee0c9989 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1377,6 +1377,7 @@ private slots: const std::unique_ptr theme(QQmlPropertyMap::create()); theme->insert("accent", QColor(Qt::blue)); theme->insert("radius", 0); + theme->insert("secondaryForeground", QColor(Qt::gray)); QQmlEngine engine; engine.rootContext()->setContextProperty("fileOps", &ops); engine.rootContext()->setContextProperty("theme", theme.get()); @@ -1422,6 +1423,101 @@ private slots: QVERIFY(QFileInfo(home.filePath("install")).isDir()); } + void dropsInAColumnAndOnATilesNameCopyWithCtrl() + { + // A column's drop area and a folder tile's name both say "Copy here" with Ctrl held, and copy. + QTemporaryDir home; + QDir(home.path()).mkpath("column"); + QDir(home.path()).mkpath("project"); + write(home.filePath("a.txt"), "a"); + write(home.filePath("b.txt"), "b"); + FileOps ops; + const std::unique_ptr theme(QQmlPropertyMap::create()); + const std::unique_ptr translator(QQmlPropertyMap::create()); + const std::unique_ptr backend(QQmlPropertyMap::create()); + const std::unique_ptr folder(QQmlPropertyMap::create()); + for (const char *role : {"accent", "foreground", "brightForeground", "secondaryForeground", "lighterBackground", + "darkBackground", "red", "blue", "magenta", "orange", "yellow", "cyan"}) + theme->insert(role, QColor(Qt::gray)); + theme->insert("radius", 0); + translator->insert("language", "en"); + folder->insert("selectedCount", 0); + backend->insert("folder", QVariant::fromValue(folder.get())); + QQmlEngine engine; + engine.rootContext()->setContextProperty("fileOps", &ops); + engine.rootContext()->setContextProperty("theme", theme.get()); + engine.rootContext()->setContextProperty("translator", translator.get()); + engine.rootContext()->setContextProperty("backend", backend.get()); + engine.rootContext()->setContextProperty("home", home.path()); + QQmlComponent component(&engine); + component.setData("import QtQuick\n" + "Window {\n" + " width: 400; height: 300; visible: true\n" + " ListView {\n" + " id: column; width: 200; height: 300; model: 1\n" + " delegate: Item { width: 200; height: 40 }\n" + " }\n" + " FolderDrop { parent: column; anchors.fill: parent; z: -1; items: column; folder: home + '/column' }\n" + " GridView {\n" + " x: 200; width: 200; height: 300; cellWidth: 200; cellHeight: 200; model: 1\n" + " delegate: FileTile {\n" + " width: 200; height: 200; name: 'project'; fileName: 'project'; folder: ''\n" + " path: home + '/project'; isDir: true; kind: 'folder'; mime: 'inode/directory'\n" + " extension: ''; itemCount: 0; icon: ''; size: 0; modified: new Date(); selected: false\n" + " }\n" + " }\n" + "}\n", + QUrl::fromLocalFile(QFINDTESTDATA("../src/FolderDrop.qml") + ".test.qml")); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + auto *window = qobject_cast(root.get()); + QVERIFY(QTest::qWaitForWindowExposed(window)); + + // Whether a shown text anywhere in the window reads `text`. + std::function shows = [&](QQuickItem *item, const QString &text) { + if (item->isVisible() && item->property("text").toString() == text) + return true; + for (QQuickItem *child : item->childItems()) + if (shows(child, text)) + return true; + return false; + }; + QMimeData data; + // Enters at `at`, checks the verb shown, then drops, giving what the drop did. + const auto dropAt = [&](const QString &file, QPointF at, const QString &verb) { + data.setUrls({QUrl::fromLocalFile(home.filePath(file))}); + QDragEnterEvent enter(at, Qt::CopyAction | Qt::MoveAction, &data, Qt::LeftButton, Qt::NoModifier); + QGuiApplication::sendEvent(window, &enter); + const bool said = shows(window->contentItem(), verb); + QDropEvent drop(at, Qt::CopyAction | Qt::MoveAction, &data, Qt::LeftButton, Qt::NoModifier); + QGuiApplication::sendEvent(window, &drop); + return said && drop.isAccepted() ? drop.dropAction() : Qt::IgnoreAction; + }; + QSignalSpy done(&ops, &FileOps::done); + QTest::keyPress(window, Qt::Key_Control, Qt::ControlModifier); + auto ctrl = qScopeGuard([&] { QTest::keyRelease(window, Qt::Key_Control, Qt::NoModifier); }); + + // Below the column's last item. + QCOMPARE(dropAt("a.txt", {100, 150}, "Copy here"), Qt::CopyAction); + QVERIFY(done.wait(3000)); + QVERIFY(QFile::exists(home.filePath("column/a.txt"))); + QVERIFY(QFile::exists(home.filePath("a.txt"))); + + // On the tile's name, under its picture. + QCOMPARE(dropAt("a.txt", {300, 128}, "Copy here"), Qt::CopyAction); + QVERIFY(done.wait(3000)); + QVERIFY(QFile::exists(home.filePath("project/a.txt"))); + QVERIFY(QFile::exists(home.filePath("a.txt"))); + + // Ctrl let go, the name takes a move. + ctrl.dismiss(); + QTest::keyRelease(window, Qt::Key_Control, Qt::NoModifier); + QCOMPARE(dropAt("b.txt", {300, 128}, "Move here"), Qt::MoveAction); + QVERIFY(done.wait(3000)); + QVERIFY(QFile::exists(home.filePath("project/b.txt"))); + QVERIFY(!QFile::exists(home.filePath("b.txt"))); + } + void fileOpsDragUriLists() { // Names holding what means something in a URI ("#", "?", "%") are @@ -4329,6 +4425,65 @@ private slots: QCOMPARE(backend.path(), QDir::cleanPath(home.path())); } + void detailsDrawAPlainGlyphForSeveralPluginItems() + { + // One of a plugin's items shows its own icon in the details; several selected show the plain glyph, as several files do. + QTemporaryDir folder, home; + writePlugin(folder.path(), "servers", R"({"folio": 1, "poll": {"command": ["./servers"]}, + "view": {"title": "Servers", "label": "All", "icon": "drive"}})", + {{"servers", "#!/bin/sh\ncat <("Folio", 1, 0, "FolderModel"); + engine.rootContext()->setContextProperty("translator", &translator); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("places", &places); + engine.rootContext()->setContextProperty("plugins", &plugins); + engine.rootContext()->setContextProperty("backend", &backend); + engine.rootContext()->setContextProperty("fileOps", &ops); + QQmlComponent component(&engine, QUrl::fromLocalFile(sourceFolder() + "/Main.qml")); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + auto *window = qobject_cast(root.get()); + backend.navigate("plugin:servers"); + QTRY_COMPARE(backend.folder()->count(), 2); + window->requestActivate(); + QVERIFY(QTest::qWaitForWindowFocused(window)); + + // The details' large glyph: the one of its size inside the pane. + const int detailsGlyph = 72; + std::function find = [&](QQuickItem *item, bool inPane) -> QQuickItem * { + const QByteArray type = item->metaObject()->className(); + if (inPane && type.startsWith("Glyph") && item->property("size").toInt() == detailsGlyph) + return item; + for (QQuickItem *child : item->childItems()) + if (QQuickItem *found = find(child, inPane || type.startsWith("PreviewPane"))) + return found; + return nullptr; + }; + QQuickItem *glyph = find(window->contentItem(), false); + QVERIFY(glyph); + + // The window takes no keys until it has settled. + QTRY_VERIFY(window->property("settled").toBool()); + QTest::keyClick(window, Qt::Key_Down); + QTRY_COMPARE(glyph->property("name").toString(), QString("peer")); + backend.folder()->selectAll(); + QCOMPARE(backend.folder()->selectedCount(), 2); + QTRY_COMPARE(glyph->property("name").toString(), QString("file")); + } + void pluginPollsArentHeldUpByActions() { QTemporaryDir folder, home; @@ -4929,6 +5084,56 @@ private slots: QCOMPARE(window->property("answer").toString(), QString()); } + void selectionDropsTheAccentWhileTheKeysAreInTheSidebar() + { + // Shift+Tab puts the keyboard in the sidebar, and the files' view draws its selection without the accent until Tab brings it back. + QTemporaryDir home; + write(home.filePath("a.txt"), "a"); + // The window remembers its view in QSettings; here that file lands in the test's home, not the user's. + QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, home.path()); + Theme theme(home.filePath("theme")); + Translator translator(home.filePath("language"), sourceFolder() + "/i18n"); + Places places(home.path()); + Plugins plugins("en", {}, home.filePath("folio.conf")); + Backend backend(home.path()); + FileOps ops; + QQmlEngine engine; + qmlRegisterType("Folio", 1, 0, "FolderModel"); + engine.rootContext()->setContextProperty("translator", &translator); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("places", &places); + engine.rootContext()->setContextProperty("plugins", &plugins); + engine.rootContext()->setContextProperty("backend", &backend); + engine.rootContext()->setContextProperty("fileOps", &ops); + QQmlComponent component(&engine, QUrl::fromLocalFile(sourceFolder() + "/Main.qml")); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + auto *window = qobject_cast(root.get()); + backend.navigate(home.path()); + window->requestActivate(); + QVERIFY(QTest::qWaitForWindowFocused(window)); + + // The files' view, whichever of the three it is. + std::function findView = [&](QQuickItem *item) -> QQuickItem * { + if (QByteArray(item->metaObject()->className()).startsWith("Folder") && item->property("inactive").isValid()) + return item; + for (QQuickItem *child : item->childItems()) + if (QQuickItem *found = findView(child)) + return found; + return nullptr; + }; + QQuickItem *view = nullptr; + QTRY_VERIFY((view = findView(window->contentItem()))); + QTRY_VERIFY(!view->property("inactive").toBool()); + + // The window takes no keys until it has settled. + QTRY_VERIFY(window->property("settled").toBool()); + QTest::keyClick(window, Qt::Key_Backtab, Qt::ShiftModifier); + QTRY_VERIFY(view->property("inactive").toBool()); + QTest::keyClick(window, Qt::Key_Tab); + QTRY_VERIFY(!view->property("inactive").toBool()); + } + void formattingOffersWhatThisMachineCanMake() { // exFAT first, and only the filesystems whose tools are installed. @@ -5793,6 +5998,79 @@ private slots: } } + void previewSheetTakesTheKeysFromThePreview() + { + // With the shortcut sheet up in the preview window, the keys scroll the sheet, not the file behind it, and its bar shows at rest. + QTemporaryDir home; + // Nothing here should write settings; any that did would land in the test's home, not the user's. + QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, home.path()); + QByteArray lines; + for (int i = 0; i < 400; ++i) + lines += "line " + QByteArray::number(i) + "\n"; + write(home.filePath("a.txt"), "a"); + write(home.filePath("b.txt"), lines); + write(home.filePath("c.txt"), "c"); + Theme theme(home.filePath("theme")); + Translator translator(home.filePath("language"), sourceFolder() + "/i18n"); + Previewer previewer; + previewer.start(home.filePath("b.txt"), false, "name"); + QTRY_COMPARE(previewer.count(), 3); + QCOMPARE(previewer.index(), 1); + QQmlEngine engine; + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("translator", &translator); + engine.rootContext()->setContextProperty("previewer", &previewer); + QQmlComponent component(&engine, QUrl::fromLocalFile(sourceFolder() + "/PreviewWindow.qml")); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + auto *window = qobject_cast(root.get()); + window->resize(480, 360); + window->requestActivate(); + QVERIFY(QTest::qWaitForWindowFocused(window)); + + QObject *view = nullptr; + QTRY_VERIFY((view = root->property("view").value()) + && view->property("contentHeight").toDouble() > view->property("height").toDouble()); + QTest::keyClick(window, Qt::Key_Down); + const double read = view->property("contentY").toDouble(); + QVERIFY(read > 0); + + QObject *overlay = nullptr; + for (QObject *child : root->findChildren()) + if (QByteArray(child->metaObject()->className()).startsWith("HelpOverlay")) + overlay = child; + QVERIFY(overlay); + // The preview's own rows fit even its smallest window, so the sheet gets thirty to scroll through. + QVariantList rows; + for (int i = 0; i < 30; ++i) + rows << QVariant(QVariantList{QString("K%1").arg(i), QString("Row %1").arg(i)}); + overlay->setProperty("shortcuts", rows); + QQuickItem *sheet = nullptr, *bar = nullptr; + for (QQuickItem *child : overlay->findChildren()) { + if (child->inherits("QQuickFlickable")) + sheet = child; + if (child->inherits("QQuickScrollBar")) + bar = child; + } + QVERIFY(sheet && bar); + root->setProperty("helpVisible", true); + QTRY_VERIFY(bar->property("size").toDouble() > 0 && bar->property("size").toDouble() < 1); + + for (const Qt::Key key : {Qt::Key_Down, Qt::Key_PageDown, Qt::Key_Up, Qt::Key_Left, Qt::Key_Right}) + QTest::keyClick(window, key); + QVERIFY(sheet->property("contentY").toDouble() > 0); + QCOMPARE(previewer.index(), 1); + QCOMPARE(view->property("contentY").toDouble(), read); + auto *handle = bar->property("contentItem").value(); + QVERIFY(!bar->property("active").toBool() && !bar->property("hovered").toBool()); + QVERIFY(handle && handle->isVisible()); + + QTest::keyClick(window, Qt::Key_Escape); + QVERIFY(!root->property("helpVisible").toBool()); + QTest::keyClick(window, Qt::Key_Right); + QCOMPARE(previewer.index(), 2); + } + void themeFallsBack() { QTemporaryDir dir; @@ -6059,6 +6337,212 @@ private slots: QCOMPARE(QLocale().toString(1.5, 'f', 1), QString("1,5")); } + void folderTotalsTheSelection() + { + QTemporaryDir dir; + QDir(dir.path()).mkdir("c"); + write(dir.filePath("a.txt"), "xx"); + write(dir.filePath("b.txt"), "xxxxx"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + + // Files add up their bytes; folders are only counted. + model.selectRange(1, 2, false); + QCOMPARE(model.selectedSize(), qint64(7)); + QCOMPARE(model.selectedFolderCount(), 0); + model.selectAll(); + QCOMPARE(model.selectedSize(), qint64(7)); + QCOMPARE(model.selectedFolderCount(), 1); + + // A selected file that grows is counted at its new size. + QSignalSpy selection(&model, &FolderModel::selectionChanged); + write(dir.filePath("a.txt"), "xxxx"); + model.reload(); + QVERIFY(loaded.wait(3000)); + QVERIFY(selection.count() > 0); + QCOMPARE(model.selectedSize(), qint64(9)); + } + + void folderSearchNamesTheFileAndItsFolder() + { + QTemporaryDir dir; + QDir(dir.path()).mkpath("Raw/Takes\\One"); + QDir(dir.path()).mkpath(QString::fromUtf8("A\u2215B/Deep/Down")); + write(dir.filePath("harbour.txt"), "x"); + write(dir.filePath("Raw/Takes\\One/harbour.mp4"), "x"); + write(dir.filePath(QString::fromUtf8("A\u2215B/Deep/Down/harbour.jpg")), "x"); + + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + QVERIFY(model.get(0).value("folder").toString().isEmpty()); + model.setQuery("harbour"); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), 3); + + // The name stays the path from the searched folder; fileName and folder split it, no folder for one found here. + auto at = [&](const QString &name) { return model.get(model.indexOf(name)); }; + const QString deep = QString::fromUtf8("A\u2215B/Deep/Down/harbour.jpg"); + QCOMPARE(at(deep).value("fileName").toString(), QString("harbour.jpg")); + QCOMPARE(at(deep).value("folder").toString(), QString::fromUtf8("A\u2215B/Deep/Down")); + QCOMPARE(at("Raw/Takes\\One/harbour.mp4").value("fileName").toString(), QString("harbour.mp4")); + QCOMPARE(at("Raw/Takes\\One/harbour.mp4").value("folder").toString(), QString("Raw/Takes\\One")); + QCOMPARE(at("harbour.txt").value("fileName").toString(), QString("harbour.txt")); + QVERIFY(at("harbour.txt").value("folder").toString().isEmpty()); + const QModelIndex row = model.index(model.indexOf(deep)); + QCOMPARE(model.data(row, FolderModel::FileNameRole).toString(), QString("harbour.jpg")); + QCOMPARE(model.data(row, FolderModel::FolderRole).toString(), QString::fromUtf8("A\u2215B/Deep/Down")); + + // A plugin's item goes by its own name, even one with a slash in it. + const QVariantMap share{{"id", "nas"}, {"name", "nas/backup"}, {"path", "/mnt/nas/backup"}}; + const auto item = FolderModel::toMap(FolderModel::listItems({share}).at(0)); + QCOMPARE(item.value("fileName").toString(), QString("nas/backup")); + QVERIFY(item.value("folder").toString().isEmpty()); + } + + void searchResultTileKeepsAPercentInItsFolder() + { + // A "%2" in a result's folder stays as written, not taken for the size and date's place. + FileOps ops; + const std::unique_ptr theme(QQmlPropertyMap::create()); + const std::unique_ptr translator(QQmlPropertyMap::create()); + const std::unique_ptr backend(QQmlPropertyMap::create()); + const std::unique_ptr folder(QQmlPropertyMap::create()); + for (const char *role : {"accent", "foreground", "brightForeground", "secondaryForeground", "lighterBackground", + "darkBackground", "background", "red", "blue", "magenta", "orange", "yellow", "cyan"}) + theme->insert(role, QColor(Qt::gray)); + theme->insert("radius", 0); + translator->insert("language", "en"); + folder->insert("selectedCount", 0); + backend->insert("folder", QVariant::fromValue(folder.get())); + QQmlEngine engine; + engine.rootContext()->setContextProperty("fileOps", &ops); + engine.rootContext()->setContextProperty("theme", theme.get()); + engine.rootContext()->setContextProperty("translator", translator.get()); + engine.rootContext()->setContextProperty("backend", backend.get()); + QQmlComponent component(&engine); + component.setData("import QtQuick\n" + "Window {\n" + " width: 200; height: 200; visible: true\n" + " GridView {\n" + " anchors.fill: parent; cellWidth: 200; cellHeight: 200; model: 1\n" + " delegate: FileTile {\n" + " width: 200; height: 200; name: 'a%2b/notes.bin'; fileName: 'notes.bin'; folder: 'a%2b'\n" + " path: '/nowhere/a%2b/notes.bin'; isDir: false; kind: 'file'; mime: 'application/octet-stream'\n" + " extension: 'bin'; itemCount: 0; icon: ''; size: 4096; modified: new Date(); selected: false\n" + " }\n" + " }\n" + "}\n", + QUrl::fromLocalFile(QFINDTESTDATA("../src/FileTile.qml") + ".test.qml")); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + auto *window = qobject_cast(root.get()); + QVERIFY(QTest::qWaitForWindowExposed(window)); + + QStringList texts; + std::function collect = [&](QQuickItem *item) { + if (item->isVisible() && !item->property("text").toString().isEmpty()) + texts << item->property("text").toString(); + for (QQuickItem *child : item->childItems()) + collect(child); + }; + collect(window->contentItem()); + const QString line = QString::fromUtf8("a%2b · ") + QLocale().toString(4.096, 'f', 1) + " KB"; + QVERIFY2(texts.contains(line), qPrintable(texts.join(" | "))); + } + + void fileOpsDropCopiesWithCtrl() + { + QTemporaryDir from, to; + write(from.filePath("todo.txt"), "list"); + write(to.filePath("todo.txt"), "already here"); + write(to.filePath("notes.txt"), "kept"); + + // One drive, so only Ctrl copies: the source stays, the taken name is asked about, and what is already there is left. + FileOps ops; + QSignalSpy done(&ops, &FileOps::done); + QCOMPARE(ops.dropInto({QUrl::fromLocalFile(from.filePath("todo.txt")), QUrl::fromLocalFile(to.filePath("notes.txt"))}, to.path(), true), Qt::CopyAction); + QCOMPARE(ops.clash().value("name").toString(), QString("todo.txt")); + // A drop while that copy waits is refused, and says so rather than that it moved. + QCOMPARE(ops.dropInto({QUrl::fromLocalFile(from.filePath("todo.txt"))}, to.path(), false), Qt::IgnoreAction); + ops.answerClash("both", false); + QVERIFY(done.wait(3000)); + QCOMPARE(done.takeFirst().at(1).toString(), QString("todo (copy).txt")); + QVERIFY(QFile::exists(from.filePath("todo.txt"))); + QVERIFY(QFile::exists(to.filePath("todo (copy).txt"))); + QVERIFY(!QFile::exists(to.filePath("notes (copy).txt"))); + + // Without Ctrl it's still a move. + QCOMPARE(ops.dropInto({QUrl::fromLocalFile(from.filePath("todo.txt"))}, to.path(), false), Qt::MoveAction); + ops.answerClash("both", false); + QVERIFY(done.wait(3000)); + QVERIFY(!QFile::exists(from.filePath("todo.txt"))); + QVERIFY(QFile::exists(to.filePath("todo (copy 2).txt"))); + + // Through a link to the folder, what is already there is left out too. + write(from.filePath("plan.txt"), "next"); + QVERIFY(QFile::link(to.path(), from.filePath("there"))); + QCOMPARE(ops.dropInto({QUrl::fromLocalFile(from.filePath("plan.txt")), QUrl::fromLocalFile(to.filePath("notes.txt"))}, from.filePath("there"), true), Qt::CopyAction); + QVERIFY(done.wait(3000)); + QVERIFY(QFile::exists(to.filePath("plan.txt"))); + QVERIFY(!QFile::exists(to.filePath("notes (copy).txt"))); + } + + void fileOpsSaysWhetherADropCopies() + { + QTemporaryDir from, to; + write(from.filePath("todo.txt"), "list"); + const QList urls{QUrl::fromLocalFile(from.filePath("todo.txt"))}; + + // On one drive only Ctrl copies; nothing local to drop never does. + FileOps ops; + QVERIFY(!ops.dropCopies(urls, to.path(), false)); + QVERIFY(ops.dropCopies(urls, to.path(), true)); + QVERIFY(!ops.dropCopies({QUrl("https://example.com/todo.txt")}, to.path(), false)); + + // From another drive it copies without Ctrl. + if (!FileOps::sameDrive(from.filePath("todo.txt"), "/proc")) + QVERIFY(ops.dropCopies(urls, "/proc", false)); + + // What is already there stays out of it, as the drop leaves it, so the rest decide. + write(to.filePath("notes.txt"), "kept"); + if (!FileOps::sameDrive(to.path(), "/proc")) + QVERIFY(ops.dropCopies({QUrl::fromLocalFile(to.filePath("notes.txt")), QUrl::fromLocalFile("/proc/version")}, to.path(), false)); + + // One from the folder's own drive moves, so the drop says so even when another drive's item leads. + if (!FileOps::sameDrive(from.path(), "/proc")) + QVERIFY(!ops.dropCopies({QUrl::fromLocalFile("/proc/version"), urls.first()}, to.path(), false)); + + // A mounted folder is asked itself, as the work asks it, never by the folder it is in; a link by its own folder. + QCOMPARE(ops.dropCopies({QUrl::fromLocalFile("/proc")}, to.path(), false), !FileOps::sameDrive("/proc", to.path())); + QVERIFY(QFile::link("/proc", from.filePath("proc"))); + QCOMPARE(ops.dropCopies({QUrl::fromLocalFile(from.filePath("proc"))}, to.path(), false), + !FileOps::sameDrive(from.filePath("proc"), to.path())); + } + + void fileOpsMixedDropMovesWhatItSaysItMoves() + { + // A drop from two drives, led by the other drive's file, says Move: the one already on this drive is moved. + QTemporaryDir shm("/dev/shm/folio-XXXXXX"), from, to; + QVERIFY(shm.isValid()); + write(shm.filePath("far.txt"), "far"); + write(from.filePath("near.txt"), "near"); + if (FileOps::sameDrive(shm.path(), to.path())) + QSKIP("Needs a second drive."); + FileOps ops; + QSignalSpy done(&ops, &FileOps::done); + QCOMPARE(ops.dropInto({QUrl::fromLocalFile(shm.filePath("far.txt")), QUrl::fromLocalFile(from.filePath("near.txt"))}, to.path()), + Qt::MoveAction); + QVERIFY(done.wait(3000)); + QVERIFY(QFile::exists(to.filePath("far.txt"))); + QVERIFY(QFile::exists(to.filePath("near.txt"))); + QVERIFY(QFile::exists(shm.filePath("far.txt"))); + QVERIFY(!QFile::exists(from.filePath("near.txt"))); + } + private: // Packs `items`, from `folder`, into `archive`, its kind from its name. static void pack(const QString &archive, const QString &folder, const QStringList &items)