diff --git a/plans/folio.md b/plans/folio.md index fade4dca..d1955fe6 100644 --- a/plans/folio.md +++ b/plans/folio.md @@ -34,7 +34,7 @@ Done, tested, and verified offscreen: - `/` searches this folder and every folder below it by name as you type: a live breadth-first walk, nearest first, that stays on the folder's drive and doesn't follow symlinked folders. - Three views, remembered between launches: grid (Ctrl+1), list with name, size and date (Ctrl+2; no headings; the date then the size step aside when narrow), and Finder-style columns (Ctrl+3). - Preview pane: large preview, the system's type name, size, modified, dimensions, opening lines of text files, Open. -- Sidebar: a Home section (its label goes home) listing every folder in home (XDG folders and Dropbox with their own glyphs, `omarchy` shown as Omarchy); Pinned folders (Ctrl+D or drag onto the sidebar) stored as GTK bookmarks; Dropbox (with `dropbox-cli` sync status), Trash, physical drives (removable and extra internal disks, not the system's own), online tailnet machines from `tailscale status --json`. +- Sidebar: a Home section holding Home, every folder in home (XDG folders and Dropbox with their own glyphs, `omarchy` shown as Omarchy), Recents and Trash; Pinned folders (Ctrl+D or drag onto the sidebar) stored as GTK bookmarks; Dropbox (with `dropbox-cli` sync status), physical drives (removable and extra internal disks, not the system's own), online tailnet machines from `tailscale status --json`. - Tailnet machines open over SFTP through GIO, landing in the matching home folder; their host keys are accepted without asking (Tailscale already checked the peer), and a password, when keys aren't set up, is asked in the window. - Mouse back/forward buttons move through history (in the preview: previous/next file). - Space opens a large preview in its own process (`folio preview`), like Sushi: pictures at full size with zoom, animated GIF/WebP, video and audio playing (Qt Multimedia's FFmpeg backend), PDFs scrolling (Qt PDF), text and rendered Markdown, folders with their pictures, and the largest cached thumbnail for anything else. Arrows step through the folder and the grid follows; a crash in the preview is reported in the Folio window. Ctrl+I toggles the details pane. diff --git a/src/Band.qml b/src/Band.qml index 7275034c..d9d6384f 100644 --- a/src/Band.qml +++ b/src/Band.qml @@ -81,6 +81,6 @@ MouseArea { height: Math.abs(band.end.y - band.start.y) color: Qt.alpha(theme.accent, 0.12) border.width: 1 - border.color: theme.accent + border.color: theme.accentMark } } diff --git a/src/ClashDialog.qml b/src/ClashDialog.qml index e5a02a9c..ccbaf502 100644 --- a/src/ClashDialog.qml +++ b/src/ClashDialog.qml @@ -56,7 +56,7 @@ FolioDialog { radius: Math.min(3, theme.radius) color: forAll.checked ? theme.accent : "transparent" border.width: forAll.checked ? 0 : 1 - border.color: forAll.visualFocus ? theme.accent : Qt.alpha(theme.foreground, 0.4) + border.color: forAll.visualFocus ? theme.accentMark : Qt.alpha(theme.foreground, 0.4) Glyph { anchors.centerIn: parent visible: forAll.checked 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/FileMenu.qml b/src/FileMenu.qml index dcee506e..37f910f3 100644 --- a/src/FileMenu.qml +++ b/src/FileMenu.qml @@ -123,6 +123,8 @@ FolioMenu { FolioMenuItem { required property var modelData text: modelData.isDefault ? qsTr("%1 (default)").arg(modelData.name) : modelData.name + iconColumn: true + icon.source: modelData.icon ? "image://appicon/" + encodeURIComponent(modelData.icon) : "" onTriggered: fileOps.openWith(fileMenu.target.path, modelData.id) } } diff --git a/src/FileRow.qml b/src/FileRow.qml index a8dab284..1f9ba8dd 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 @@ -44,16 +50,17 @@ Item { : row.kind === "video" ? "videos" : row.kind === "audio" ? "music" : row.kind === "pdf" || row.kind === "text" ? "documents" + : row.kind === "archive" ? "archive" : "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 +68,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 ? theme.quietMark : theme.accentMark + 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" } @@ -113,23 +120,37 @@ Item { // mirrored: left as they are, they'd follow their own script, and a // Latin name would sit at the far end of a right-to-left row. Text { + // A search result lights the run of its name that matched; only the folder being shown is searched. + readonly property string lit: row.ListView.view.model === backend.folder + ? Format.lit(row.fileName, backend.folder.matchStart(row.fileName), backend.folder.query.length, theme.brightForeground) : "" Layout.fillWidth: true Layout.minimumWidth: 120 horizontalAlignment: Text.AlignLeft - text: row.name - color: row.current ? theme.accent : theme.foreground + text: lit || row.fileName + textFormat: lit ? Text.StyledText : Text.PlainText + 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 + textFormat: Text.PlainText + 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 +158,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.foreground + 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..fe0d1aed 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 @@ -69,12 +75,15 @@ Item { Text { id: label + // A search result lights the run of its name that matched. + readonly property string lit: Format.lit(tile.fileName, backend.folder.matchStart(tile.fileName), backend.folder.query.length, theme.brightForeground) anchors.top: artBox.bottom anchors.topMargin: 12 anchors.horizontalCenter: parent.horizontalCenter width: parent.width - 28 - text: tile.name - color: tile.current ? theme.accent : theme.foreground + text: lit || tile.fileName + textFormat: lit ? Text.StyledText : Text.PlainText + color: tile.current ? theme.brightForeground : theme.foreground font.pixelSize: 12 horizontalAlignment: Text.AlignHCenter wrapMode: Text.WrapAtWordBoundaryOrAnywhere @@ -95,13 +104,18 @@ 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 + textFormat: Text.PlainText + color: tile.current ? theme.foreground : theme.secondaryForeground font.pixelSize: 10 horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight @@ -110,13 +124,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 +189,13 @@ Item { height: 92 preferredRendererType: Shape.CurveRenderer ShapePath { - fillColor: tile.current || folderDrop.containsDrag ? Qt.tint(theme.lighterBackground, Qt.alpha(theme.accent, 0.3)) - : 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 + fillColor: tile.current && !tile.inactive || folderDrop.containsDrag ? Qt.tint(theme.folderFill, Qt.alpha(theme.accent, 0.3)) + : tile.current ? Qt.tint(theme.folderFill, Qt.alpha(theme.foreground, 0.08)) + : hover.hovered ? Qt.tint(theme.folderFill, Qt.alpha(theme.foreground, 0.06)) + : theme.folderFill + strokeColor: (tile.current || tile.cursor) && !tile.inactive || folderDrop.containsDrag ? theme.accentMark + : tile.current || tile.cursor ? theme.quietMark : "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 +273,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.accentMark : tile.current || tile.cursor ? theme.quietMark : Qt.alpha(theme.foreground, 0.2) radius: tile.iconRadius > 0 ? tile.iconRadius + 3 : 0 } } @@ -280,15 +296,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.accentMark : tile.current || tile.cursor ? theme.quietMark : Qt.alpha(theme.foreground, 0.2) radius: tile.iconRadius > 0 ? tile.iconRadius + 3 : 0 } } @@ -307,6 +323,7 @@ Item { border.color: Qt.alpha(theme.foreground, 0.16) radius: tile.iconRadius Column { + visible: tile.kind !== "archive" x: 9; y: 11 spacing: 6 Rectangle { width: 34; height: 4; color: tile.kindColor } @@ -315,16 +332,31 @@ Item { Rectangle { required property int modelData; width: modelData; height: 2; color: Qt.alpha(theme.foreground, 0.3) } } } + // An archive is the lidded box its row draws, not a page of text. + Glyph { anchors.centerIn: parent; visible: tile.kind === "archive"; name: "archive"; size: 32; color: tile.kindColor } } - Rectangle { - visible: tile.extension.length > 0 - anchors.left: page.left - anchors.bottom: page.bottom - anchors.bottomMargin: 6 - width: badge.implicitWidth + 8 - height: badge.implicitHeight + 2 - color: tile.kindColor - Text { id: badge; anchors.centerIn: parent; text: tile.extension.slice(0, 4); font.pixelSize: 9; font.bold: true; color: theme.background } + ExtensionBadge { anchors.left: page.left } + } + } + + // The extension on a stand-in, as a chip in its kind's color at the foot's left. + component ExtensionBadge: Rectangle { + visible: tile.extension.length > 0 + anchors.bottom: parent.bottom + anchors.bottomMargin: 6 + width: badge.implicitWidth + 8 + height: badge.implicitHeight + 2 + color: tile.kindColor + Text { + id: badge + anchors.centerIn: parent + text: tile.extension.slice(0, 4) + textFormat: Text.PlainText + font.pixelSize: 9 + font.bold: true + color: { + theme.background // Re-read when the theme changes, not only the fill. + return theme.inkOn(tile.kindColor) } } } @@ -335,6 +367,12 @@ Item { // Decorative, not the real waveform: bars seeded from the name so each // file keeps its own shape. Album art replaces it when the file has some. Rectangle { + id: wave + readonly property int seed: { + let h = 0 + for (let i = 0; i < tile.name.length; ++i) h = (h * 31 + tile.name.charCodeAt(i)) % 997 + return h + } color: theme.lighterBackground radius: tile.iconRadius Row { @@ -344,18 +382,15 @@ Item { model: 18 Rectangle { required property int index + readonly property int step: (wave.seed + index * 389) % 997 anchors.verticalCenter: parent.verticalCenter width: 3 - height: { - let h = 0 - for (let i = 0; i < tile.name.length; ++i) h = (h * 31 + tile.name.charCodeAt(i) + index * 7) % 997 - return 10 + (h % 40) - } + height: 20 + (step * step % 997) % 29 color: tile.kindColor } } } - Text { anchors.right: parent.right; anchors.bottom: parent.bottom; anchors.margins: 5; text: tile.extension; font.pixelSize: 9; color: theme.secondaryForeground } + ExtensionBadge { anchors.left: parent.left } } } diff --git a/src/FolderColumns.qml b/src/FolderColumns.qml index 7e802fff..4f98d277 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) } @@ -184,6 +187,15 @@ Item { visible: here.count === 0 && !backend.folder.loading && backend.folder.error.length === 0 text: backend.folder.query ? qsTr("No matches") : qsTr("No items") color: theme.secondaryForeground + Text { + anchors.top: parent.bottom + anchors.topMargin: 6 + anchors.left: parent.left + visible: backend.folder.query.length > 0 + text: qsTr("Escape ends the search") + color: theme.secondaryForeground + font.pixelSize: 12 + } } } Separator { } diff --git a/src/FolderDrop.qml b/src/FolderDrop.qml index 4eb6d09d..2ce80b87 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 { @@ -34,7 +31,17 @@ DropArea { visible: area.containsDrag color: Qt.alpha(theme.accent, 0.05) border.width: 2 - border.color: theme.accent + border.color: theme.accentMark radius: Math.min(6, theme.radius) + Text { + // A dragged tile's picture hangs 52 px below the pointer, so the label sits 8 px under that. + readonly property int belowPointer: 60 + readonly property int edgeMargin: 12 + x: Math.max(edgeMargin, Math.min(parent.width - width - edgeMargin, area.drag.x - parent.x - width / 2)) + y: Math.max(edgeMargin, Math.min(parent.height - height - edgeMargin, area.drag.y - parent.y + belowPointer)) + text: area.copies ? qsTr("Copy here") : qsTr("Move here") + color: theme.foreground + font.pixelSize: 12 + } } } diff --git a/src/FolderGrid.qml b/src/FolderGrid.qml index 37385b8a..e6ad0e10 100644 --- a/src/FolderGrid.qml +++ b/src/FolderGrid.qml @@ -5,6 +5,11 @@ 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 + // Keeps the empty caption on the pane's middle line, level with the details pane's, while bands push the view down. + property real captionOffset: 0 + 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,15 +100,28 @@ GridView { height: grid.cellHeight zoom: grid.tileZoom current: selected + inactive: grid.inactive onClicked: (modifiers) => { selecting.click(index, modifiers); grid.forceActiveFocus() } onActivated: backend.open(path) } Text { anchors.centerIn: parent + anchors.verticalCenterOffset: grid.captionOffset visible: grid.count === 0 && !backend.folder.loading && backend.folder.error.length === 0 text: backend.folder.query ? qsTr("Nothing matches “%1”").arg(backend.folder.query) : plugins.views[backend.path] ? plugins.views[backend.path].empty : qsTr("This folder is empty") + textFormat: Text.PlainText color: theme.secondaryForeground + // The way out of a search that found nothing. + Text { + anchors.top: parent.bottom + anchors.topMargin: 6 + anchors.horizontalCenter: parent.horizontalCenter + visible: backend.folder.query.length > 0 + text: qsTr("Escape ends the search") + color: theme.secondaryForeground + font.pixelSize: 12 + } } } diff --git a/src/FolderList.qml b/src/FolderList.qml index 8e93a633..3f36172a 100644 --- a/src/FolderList.qml +++ b/src/FolderList.qml @@ -6,6 +6,11 @@ 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 + // Keeps the empty caption on the pane's middle line, level with the details pane's, while bands push the view down. + property real captionOffset: 0 + 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,15 +62,28 @@ ListView { width: list.width detailed: true current: selected + inactive: list.inactive onClicked: (modifiers) => { selecting.click(index, modifiers); list.forceActiveFocus() } onActivated: backend.open(path) } Text { anchors.centerIn: parent + anchors.verticalCenterOffset: list.captionOffset visible: list.count === 0 && !backend.folder.loading && backend.folder.error.length === 0 text: backend.folder.query ? qsTr("Nothing matches “%1”").arg(backend.folder.query) : plugins.views[backend.path] ? plugins.views[backend.path].empty : qsTr("This folder is empty") + textFormat: Text.PlainText color: theme.secondaryForeground + // The way out of a search that found nothing. + Text { + anchors.top: parent.bottom + anchors.topMargin: 6 + anchors.horizontalCenter: parent.horizontalCenter + visible: backend.folder.query.length > 0 + text: qsTr("Escape ends the search") + color: theme.secondaryForeground + font.pixelSize: 12 + } } } diff --git a/src/FolderPane.qml b/src/FolderPane.qml index 1c3d5315..f34b3c34 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() { @@ -84,6 +88,8 @@ FocusScope { readonly property bool inView: backend.path.startsWith("plugin:") readonly property bool fileKeys: keys && !inView Binding { target: backend.folder; property: "items"; value: (plugins.views[backend.path] || {}).items || [] } + // The bands showing above the files, which push the view down. + readonly property int bandsHeight: (banner.visible ? banner.height : 0) + (trashBand.visible ? trashBand.height : 0) function deleteSelected() { const paths = pane.selectedPaths() if (pane.inTrash) @@ -95,9 +101,8 @@ FocusScope { if (paths.length === 0) return const name = paths[0].slice(paths[0].lastIndexOf("/") + 1) - confirm.ask(qsTr("Delete for good?"), - paths.length === 1 ? qsTr("%1 will be deleted permanently. This can't be undone.").arg(name) - : qsTr("%Ln items will be deleted permanently. This can't be undone.", "", paths.length), + confirm.ask(paths.length === 1 ? qsTr("Delete “%1” permanently?").arg(name) : qsTr("Delete %Ln items permanently?", "", paths.length), + qsTr("This can't be undone."), qsTr("Delete"), () => fileOps.deleteForever(paths)) } function emptyTrash() { @@ -122,6 +127,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 +234,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 @@ -333,6 +347,7 @@ FocusScope { } // Problems and slow work are said in the window, not swallowed. Rectangle { + id: banner Layout.fillWidth: true implicitHeight: 40 visible: bannerText.text.length > 0 @@ -345,8 +360,9 @@ FocusScope { id: bannerText Layout.fillWidth: true text: backend.message || backend.folder.error || (slowWork.shown ? slowWork.working : "") || backend.notice + textFormat: Text.PlainText 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 @@ -369,6 +385,7 @@ FocusScope { // The trash offers one thing: emptying it. Rectangle { + id: trashBand Layout.fillWidth: true implicitHeight: 48 visible: pane.inTrash && backend.folder.count > 0 @@ -412,9 +429,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; captionOffset: -pane.bandsHeight / 2; onZoomRequested: (step) => pane.zoomBy(step) } } + Component { id: listView; FolderList { inactive: pane.keysElsewhere; captionOffset: -pane.bandsHeight / 2 } } + Component { id: columnsView; FolderColumns { inactive: pane.keysElsewhere } } } // Drawn over the window's top bar. It reaches back over the sidebar for @@ -433,6 +450,8 @@ FocusScope { onTogglePreview: if (pane.host) pane.host.previewVisible = !pane.host.previewVisible onCycleView: pane.cycleView() onCloseRequested: pane.closeRequested() + split: !!pane.host && pane.host.split + onToggleSplit: if (pane.host) pane.host.split ? pane.host.closeSplit() : pane.host.openSplit() } // With two panes, a line along the top marks the one keys go to. @@ -441,7 +460,7 @@ FocusScope { y: pathBar.height - 1 width: parent.width height: 2 - color: pane.active ? theme.accent : "transparent" + color: pane.active ? theme.accentMark : "transparent" } // The mouse's back and forward thumb buttons move through history, as in @@ -456,31 +475,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..8da183b9 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. @@ -61,6 +61,7 @@ FocusScope { Layout.fillWidth: true text: dialog.title wrapMode: Text.Wrap + textFormat: Text.PlainText color: theme.brightForeground font.pixelSize: 16 font.weight: Font.DemiBold 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..83652d22 100644 --- a/src/FolioMenuItem.qml +++ b/src/FolioMenuItem.qml @@ -19,6 +19,8 @@ MenuItem { // skips only entries that are disabled or out of the tab chain, so // Return would otherwise run one nobody can see. property bool shown: true + // Room for a 16 px icon.source before the label, kept when there's none so labels line up. + property bool iconColumn: false // Hover is off by default here; with it, the pointer highlights an // entry and opens a submenu on its own, as menus elsewhere do. @@ -35,17 +37,32 @@ 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 { - implicitWidth: label.implicitWidth + hint.implicitWidth + 40 + // The gap before a key or arrow only when there is one, so a plain row's width is its label and padding. + implicitWidth: label.anchors.leftMargin + label.implicitWidth + (entry.keys || entry.subMenu ? hint.implicitWidth + 40 : 0) implicitHeight: label.implicitHeight + Image { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + visible: entry.iconColumn + width: 16 + height: 16 + sourceSize: Qt.size(16, 16) + fillMode: Image.PreserveAspectFit + // Synchronous: AppIconProvider calls QIcon::fromTheme, which belongs to the GUI thread. + source: entry.iconColumn ? entry.icon.source : "" + } Text { id: label anchors.left: parent.left + anchors.leftMargin: entry.iconColumn ? 26 : 0 anchors.verticalCenter: parent.verticalCenter text: entry.text + textFormat: Text.PlainText color: !entry.enabled ? theme.secondaryForeground : entry.highlighted ? theme.brightForeground : theme.foreground } Text { 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..21bc73b9 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. @@ -38,6 +48,14 @@ function count(n) { return n === 0 ? qsTr("No items") : n > 0 ? qsTr("%Ln items", "", n) : "" } +// A search result's name as StyledText, its matched run bold in `color`; "" for no match or for white space StyledText folds. +function lit(name, start, length, color) { + if (start < 0 || /^\s|\s$|\s\s|[^\S ]|\u0085/.test(name)) + return "" + const escaped = text => text.replace(/&/g, "&").replace(//g, ">") + return escaped(name.slice(0, start)) + "" + escaped(name.slice(start, start + length)) + "" + escaped(name.slice(start + length)) +} + // What an item is and how big, under its name in the details and the // preview; for a plugin's item, its subtitle. function summary(item, type) { diff --git a/src/FormatDialog.qml b/src/FormatDialog.qml index a51b2559..017aca94 100644 --- a/src/FormatDialog.qml +++ b/src/FormatDialog.qml @@ -54,12 +54,14 @@ FolioDialog { Layout.fillWidth: true visible: !!dialog.drive && dialog.drive.label.length > 0 text: dialog.drive ? dialog.drive.label : "" + textFormat: Text.PlainText color: theme.brightForeground elide: Text.ElideRight } Text { Layout.fillWidth: true text: dialog.drive ? dialog.drive.title : "" + textFormat: Text.PlainText color: dialog.drive && dialog.drive.label.length > 0 ? theme.secondaryForeground : theme.brightForeground elide: Text.ElideRight } @@ -113,7 +115,7 @@ FolioDialog { Text { Layout.fillWidth: true text: qsTr("Everything on this drive will be erased. This can't be undone.") - color: theme.red + color: theme.redText wrapMode: Text.Wrap } RowLayout { diff --git a/src/Glyph.qml b/src/Glyph.qml index dc33af44..9bc4a460 100644 --- a/src/Glyph.qml +++ b/src/Glyph.qml @@ -59,7 +59,8 @@ Item { case "videos": return box(3, 5, 18, 14, r) + "M10 9v6l5-3z" case "recent": return circle(12, 12, 9) + "M12 7v5l3 2" case "trash": return "M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" - case "drive": return box(3, 6, 18, 12, r) + "M7 14h2" + // A drive seen from the front: a sloped top over a box with a light. + case "drive": return "M3 12l4-7h10l4 7" + box(3, 12, 18, 7, r) + "M7 15.5h2" case "eject": return "M12 5l7 8H5zM5 18h14" case "peer": return box(4, 4, 16, 11, r) + "M9 20h6M12 15v5" // Machines joined to one another: a network of them. @@ -68,9 +69,12 @@ Item { case "back": return "M15 6l-6 6 6 6" case "forward": return "M9 6l6 6-6 6" case "grid": return box(4, 4, 6, 6, r / 2) + box(14, 4, 6, 6, r / 2) + box(4, 14, 6, 6, r / 2) + box(14, 14, 6, 6, r / 2) - case "list": return "M4 6h16M4 12h16M4 18h16" + // Bulleted, so it reads as a list of files rather than a menu. + case "list": return "M9 6h11M9 12h11M9 18h11M4 6h1M4 12h1M4 18h1" case "columns": return box(3, 4, 18, 16, r) + "M9 4v16M15 4v16" - case "preview": return box(3, 5, 18, 14, r) + "M14 5v14" + // Facts in a side panel, against split's two bare boxes with a gap between them. + case "preview": return box(3, 5, 18, 14, r) + "M14 5v14M16.5 9h2M16.5 12h2" + case "split": return box(3, 5, 7, 14, r / 2) + box(14, 5, 7, 14, r / 2) // One per sort order: a slim arrow on the left (down for the usual // direction, up for oldest first), and what it sorts by on the right, // kept apart so both read at toolbar size. @@ -82,9 +86,12 @@ Item { case "play": return "M8 5v14l11-7z" case "pause": return "M8 5v14M16 5v14" case "open": return "M14 4h6v6M20 4l-9 9M18 14v6H4V6h6" + // A storage box: a lid over an open body with a handle. + case "archive": return box(3, 4, 18, 4, r / 2) + (r > 0 ? "M5 8V17Q5 19 7 19H17Q19 19 19 17V8" : "M5 8v11h14V8") + "M10 12h4" case "audio": return "M4 10v4M8 7v10M12 4v16M16 8v8M20 11v2" case "check": return "M5 12l5 5 9-10" case "close": return "M6 6l12 12M18 6L6 18" + case "home": return "M4 10l8-6 8 6v10H4zM10 20v-6h4v6" default: return page() } } diff --git a/src/HelpOverlay.qml b/src/HelpOverlay.qml index 388be554..cf25a091 100644 --- a/src/HelpOverlay.qml +++ b/src/HelpOverlay.qml @@ -52,6 +52,7 @@ Rectangle { ["Ctrl+I", qsTr("Show or hide the details pane")], ["Ctrl+P", qsTr("Pin or unpin the folder (or drag it to the sidebar)")], ["Ctrl+C / X / V", qsTr("Copy, cut, paste files")], + [qsTr("Drag / Ctrl+drag"), qsTr("Move files to a folder / copy them")], ["Ctrl+Shift+C", qsTr("Copy the path as text")], ["F2 / Ctrl+Shift+D", qsTr("Rename / duplicate")], ["Ctrl+N", qsTr("New window")], @@ -74,6 +75,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 +92,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/IconButton.qml b/src/IconButton.qml index 3ca21db3..e98a5bf3 100644 --- a/src/IconButton.qml +++ b/src/IconButton.qml @@ -8,6 +8,11 @@ AbstractButton { property string glyph property bool active: false + // An on/off switch: off draws in the secondary ink, so on and off differ by brightness alone. + property bool toggle: false + // Some themes set brightForeground dimmer than foreground; on takes whichever stands out more. + readonly property color onInk: (theme.brightForeground.hslLightness > theme.foreground.hslLightness) !== theme.light + ? theme.brightForeground : theme.foreground implicitWidth: 36 implicitHeight: 36 @@ -26,7 +31,8 @@ AbstractButton { background: Rectangle { radius: Math.min(4, theme.radius) - color: button.active ? Qt.alpha(theme.accent, 0.16) + // A toggle says on by its ink alone; any other active button keeps the plate. + color: button.active && !button.toggle ? Qt.alpha(theme.accent, 0.16) : button.hovered && button.enabled ? Qt.alpha(theme.foreground, 0.08) : "transparent" } @@ -35,7 +41,9 @@ AbstractButton { Glyph { anchors.centerIn: parent name: button.glyph - color: !button.enabled ? Qt.alpha(theme.foreground, 0.35) + // Opaque on the bar: a translucent stroke blends twice, so 0.35 drew at 4.0:1, next to off's 5.1:1. + color: !button.enabled ? theme.disabledForeground + : button.toggle ? (button.active ? button.onInk : theme.secondaryForeground) : button.active ? theme.brightForeground : theme.foreground } } 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/MediaPreview.qml b/src/MediaPreview.qml index 91c0e258..401d305b 100644 --- a/src/MediaPreview.qml +++ b/src/MediaPreview.qml @@ -78,6 +78,7 @@ Item { Text { Layout.alignment: Qt.AlignHCenter text: player.metaData.stringValue(MediaMetaData.Title) || previewer.item.name || "" + textFormat: Text.PlainText color: theme.brightForeground font.pixelSize: 16 font.weight: Font.DemiBold diff --git a/src/PathBar.qml b/src/PathBar.qml index 47c25d28..7832ea2b 100644 --- a/src/PathBar.qml +++ b/src/PathBar.qml @@ -1,4 +1,5 @@ import QtQuick +import QtQuick.Controls import QtQuick.Layouts // The breadcrumb path from the left edge; view buttons on the right. Back and @@ -14,7 +15,10 @@ Rectangle { property bool editing: false // The right pane of a split has a close button (Ctrl+W). property bool closable: false + // The window is split; the first pane's bar has the button that splits and joins it. + property bool split: false signal closeRequested() + signal toggleSplit() signal togglePreview() signal cycleView() signal editDone() @@ -85,6 +89,12 @@ Rectangle { cursorPosition = text.length } onActiveFocusChanged: if (!activeFocus && bar.editing) bar.stopEditing() + // A double click on the empty bar lands its second click here: keep the whole path selected, not a word. + MouseArea { + anchors.fill: parent + onPressed: mouse => mouse.accepted = (mouse.flags & Qt.MouseEventCreatedDoubleClick) !== 0 && pathField.selectedText === pathField.text + onDoubleClicked: mouse => mouse.accepted = pressed + } } PlainField { @@ -92,9 +102,11 @@ Rectangle { Layout.fillWidth: true visible: bar.searching && !bar.editing font.pixelSize: 14 - // Room at the end for where it searches; padding isn't mirrored. - leftPadding: LayoutMirroring.enabled ? scope.implicitWidth + 24 : 12 - rightPadding: LayoutMirroring.enabled ? 12 : scope.implicitWidth + 24 + clip: true + // Room at the end for where it searches, while it fits; padding isn't mirrored. + readonly property real hintRoom: scope.fits ? scope.implicitWidth + 24 : 12 + leftPadding: LayoutMirroring.enabled ? hintRoom : 12 + rightPadding: LayoutMirroring.enabled ? 12 : hintRoom // Where it searches, at the end of the field. Unlike a // placeholder, it stays while typing. Plugins' views have // nothing below them. @@ -106,12 +118,31 @@ Rectangle { translator.language return backend.crumbs.length > 0 ? backend.crumbs[backend.crumbs.length - 1].name : "" } + // Once the results for what is typed are in, how many there are. + readonly property bool settled: backend.folder.query.length > 0 && backend.folder.query === searchField.text.trim() + && !backend.folder.loading && backend.folder.error.length === 0 + readonly property string hint: backend.path.startsWith("plugin:") ? qsTr("Search %1") + : !settled ? qsTr("Search %1 and below") + : backend.folder.count === 0 ? qsTr("Searched %1 and below") + : backend.folder.count >= backend.folder.searchLimit ? qsTr("%Ln or more found in %1 and below", "", backend.folder.count) + : qsTr("%Ln found in %1 and below", "", backend.folder.count) + // In a narrow field it gives way to what is typed: the place elides, then the hint hides. + readonly property bool fits: placeFit.elidedText === here || placeFit.elidedText.length > 3 + visible: fits anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter - text: backend.path.startsWith("plugin:") ? qsTr("Search %1").arg(here) : qsTr("Search %1 and below").arg(here) + textFormat: Text.PlainText + text: hint.arg(placeFit.elidedText) color: theme.secondaryForeground font.pixelSize: 12 + // The field's width kept for typing, a dozen characters. + readonly property int typingRoom: 96 + TextMetrics { id: hintRest; font: scope.font; text: scope.hint.arg("") } + // The padding on the side the typing starts from, which mirroring swaps. + readonly property real startPadding: LayoutMirroring.enabled ? searchField.rightPadding : searchField.leftPadding + // The hint keeps its margin on both sides, as rightPadding does. + TextMetrics { id: placeFit; font: scope.font; text: scope.here; elide: Text.ElideRight; elideWidth: searchField.width - scope.startPadding - 2 * scope.anchors.rightMargin - scope.typingRoom - hintRest.advanceWidth } } onTextEdited: searchDelay.restart() // Enter or ↓ goes to the results, leaving the search up. @@ -128,29 +159,90 @@ Rectangle { Keys.onEscapePressed: bar.endSearch() } - Flickable { + Item { + id: trail visible: !bar.editing && !bar.searching Layout.fillWidth: true Layout.fillHeight: true - contentWidth: Math.max(width, crumbs.implicitWidth) clip: true - boundsBehavior: Flickable.StopAtBounds - // Long paths keep their end in view; that's where you are. - // Mirrored, the end is on the left. - function revealEnd() { contentX = LayoutMirroring.enabled ? 0 : Math.max(0, contentWidth - width) } - onContentWidthChanged: revealEnd() - onWidthChanged: revealEnd() - LayoutMirroring.onEnabledChanged: revealEnd() + // The end stays in view; crumbs that don't fit whole fold into "…" at the reading start (the right, when mirrored). + property int first: 0 + property real offset: 0 + // The parent's width while it elides to stay beside the current folder; 0 when it shows whole. + property real squeeze: 0 + // The narrowest the parent elides to before it folds, about six characters. + readonly property int squeezeFloor: 56 + // The squeezed parent's crumb is as wide as its elided name, so the next separator keeps the usual gap. + FontMetrics { id: crumbFont; font.pixelSize: 14 } + // Measured from each crumb's full width, so the current folder's own elide never changes what folds. + function fit() { + let rest = 0 + for (let k = 0; k < crumbRepeater.count; ++k) + rest += crumbRepeater.itemAt(k).fullWidth + (k > 0 ? crumbs.spacing : 0) + let i = 0 + if (rest > width) + while (i < crumbRepeater.count - 1 && rest > width - more.implicitWidth - crumbs.spacing) + rest -= crumbRepeater.itemAt(i++).fullWidth + crumbs.spacing + // Before the parent folds too, it elides, down to squeezeFloor. + const room = width - rest - crumbs.spacing - (i > 1 ? more.implicitWidth + crumbs.spacing : 0) + squeeze = i > 0 && i === crumbRepeater.count - 1 && room >= Math.min(crumbRepeater.itemAt(i - 1).fullWidth, squeezeFloor) ? room : 0 + if (squeeze > 0) + --i + first = i + // How far the first shown crumb is from the row's start, which a mirrored Row puts at its right. + const shown = crumbRepeater.itemAt(i) + const lead = LayoutMirroring.enabled ? crumbs.width - shown.x - shown.width : shown.x + offset = i > 0 ? lead - more.implicitWidth - crumbs.spacing : 0 + } + onWidthChanged: fit() + LayoutMirroring.onEnabledChanged: fit() + + // A click on the empty part of the bar edits the path, as Ctrl+L does. + MouseArea { + anchors.fill: parent + onClicked: bar.editPath() + } + + Text { + id: more + // Above the folded crumbs, which still lie under it and would take its hover. + z: 1 + visible: trail.first > 0 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "…" + color: moreMouse.containsMouse ? theme.foreground : theme.secondaryForeground + font.pixelSize: 14 + // A crumb's hit margin, except toward the bar's edge, which would fall outside the clip. + MouseArea { + id: moreMouse + anchors.fill: parent + anchors.margins: -4 + anchors.leftMargin: 0 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: backend.navigate(backend.crumbs[trail.first - 1].path) + // The folded path as the crumbs write it: "/" root, "~" home. + ToolTip { + visible: moreMouse.containsMouse + delay: 600 + text: crumbRepeater.model.slice(0, trail.first).map(c => c.name).join("/").replace("//", "/") + enter: null + exit: null + } + } + } Row { id: crumbs - // A Row isn't placed by its Flickable, so it's moved to the - // right edge by hand when mirrored. - x: LayoutMirroring.enabled ? parent.width - width : 0 + // A Row isn't placed by its parent, so it's moved to the right edge by hand when mirrored. + x: LayoutMirroring.enabled ? trail.width - width + trail.offset : -trail.offset anchors.verticalCenter: parent.verticalCenter spacing: 10 + onPositioningComplete: trail.fit() Repeater { + id: crumbRepeater // Recents, Trash and plugins' views are named in C++, which // retranslating QML doesn't redo. model: { translator.language; return backend.crumbs } @@ -159,8 +251,13 @@ Rectangle { required property var modelData required property int index readonly property bool last: index === backend.crumbs.length - 1 + readonly property real fullWidth: folderName.implicitWidth + (separator.visible ? separator.implicitWidth + spacing : 0) + // Folded crumbs keep their place so the others don't move. + opacity: index < trail.first ? 0 : 1 + enabled: index >= trail.first spacing: 10 Text { + id: separator // No separator after / itself: "/ run", not "/ / run". visible: crumb.index > 0 && !(crumb.index === 1 && backend.crumbs[0].name === "/") text: "/" @@ -168,7 +265,17 @@ Rectangle { font.pixelSize: 14 } Text { + id: folderName + // The current folder always shows; alone too wide, it elides. + width: crumb.last ? Math.max(0, Math.min(implicitWidth, trail.width - (separator.visible ? separator.implicitWidth + crumb.spacing : 0) + - (trail.first > 0 ? more.implicitWidth + crumbs.spacing : 0))) + : crumb.index === trail.first && trail.squeeze > 0 + ? Math.ceil(crumbFont.advanceWidth(crumbFont.elidedText(crumb.modelData.name, Qt.ElideRight, + trail.squeeze - (separator.visible ? separator.implicitWidth + crumb.spacing : 0)))) + : implicitWidth + elide: Text.ElideRight text: crumb.modelData.name + textFormat: Text.PlainText color: crumb.last ? theme.brightForeground : crumbMouse.containsMouse ? theme.foreground : theme.secondaryForeground font.pixelSize: 14 font.weight: crumb.last ? Font.DemiBold : Font.Normal @@ -209,12 +316,21 @@ Rectangle { SortMenu { id: sortMenu } IconButton { glyph: "preview" + toggle: true // Only where the window has room to show them. visible: bar.previewFits text: qsTr("Details") active: bar.previewVisible onClicked: bar.togglePreview() } + IconButton { + visible: !bar.closable + glyph: "split" + toggle: true + text: bar.split ? qsTr("Close the split") : qsTr("Split") + active: bar.split + onClicked: bar.toggleSplit() + } IconButton { visible: bar.closable glyph: "close" diff --git a/src/PlainButton.qml b/src/PlainButton.qml index 72cd2132..7e715c6c 100644 --- a/src/PlainButton.qml +++ b/src/PlainButton.qml @@ -40,12 +40,13 @@ AbstractButton { radius: parent.radius + 3 color: "transparent" border.width: 2 - border.color: button.destructive ? theme.red : theme.accent + border.color: button.destructive ? theme.red : theme.accentMark } } contentItem: Text { id: label text: button.text + textFormat: Text.PlainText color: button.destructive ? theme.background : button.primary ? theme.accentForeground : theme.foreground font.weight: button.filled ? Font.DemiBold : Font.Normal horizontalAlignment: Text.AlignHCenter diff --git a/src/PlainField.qml b/src/PlainField.qml index eef8cd62..670fe2da 100644 --- a/src/PlainField.qml +++ b/src/PlainField.qml @@ -20,7 +20,7 @@ TextField { implicitHeight: 38 color: theme.darkBackground border.width: 1 - border.color: field.activeFocus ? theme.accent : Qt.alpha(theme.foreground, 0.18) + border.color: field.activeFocus ? theme.accentMark : Qt.alpha(theme.foreground, 0.18) radius: Math.min(4, theme.radius) } diff --git a/src/PreviewPane.qml b/src/PreviewPane.qml index 76e7aea0..f8a9e1cf 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,19 +94,45 @@ Rectangle { text: pane.info.text || "" color: theme.foreground font.pixelSize: 10 - wrapMode: Text.WrapAnywhere + wrapMode: Text.WrapAtWordBoundaryOrAnywhere textFormat: Text.PlainText elide: Text.ElideRight } + // A Markdown file, rendered. Rich text can't elide, so the frame ends on the last line that fits whole. + Item { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + readonly property real room: parent.height - 28 + height: { + markdown.length, markdown.width, markdown.contentHeight + const line = markdown.positionToRectangle(markdown.positionAt(0, room)) + return line.y + line.height > room ? Math.min(line.y, room) : room + } + clip: true + TextEdit { + id: markdown + width: parent.width + text: pane.info.markdown || "" + textFormat: TextEdit.RichText + color: theme.foreground + font.pixelSize: 10 + wrapMode: TextEdit.WrapAtWordBoundaryOrAnywhere + readOnly: true + // Only shown: nothing to select, and its links aren't followed from here. + enabled: false + } + } } 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 +144,12 @@ Rectangle { Text { Layout.fillWidth: true horizontalAlignment: Text.AlignLeft - text: pane.item.name || "" + text: pane.many ? qsTr("%Ln items selected", "", pane.many) : pane.item.fileName || "" + textFormat: Text.PlainText color: theme.brightForeground font.pixelSize: 15 font.weight: Font.DemiBold - wrapMode: Text.WrapAnywhere + wrapMode: Text.WrapAtWordBoundaryOrAnywhere maximumLineCount: 3 elide: Text.ElideRight } @@ -123,7 +158,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 +171,7 @@ Rectangle { GridLayout { Layout.fillWidth: true + visible: !pane.many columns: 2 rowSpacing: 12 columnSpacing: 16 @@ -143,6 +182,18 @@ Rectangle { Layout.fillWidth: true horizontalAlignment: Text.AlignRight text: pane.trashed ? pane.homeless(pane.origin.replace(/\/[^\/]*$/, "") || "/") : "" + textFormat: Text.PlainText + color: theme.foreground + 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 || "" + textFormat: Text.PlainText color: theme.foreground font.pixelSize: 12 elide: Text.ElideMiddle @@ -155,7 +206,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 +293,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 @@ -264,7 +317,7 @@ Rectangle { } contentItem: RowLayout { // A long app name ends in … rather than running past the button. - Text { Layout.leftMargin: 14; Layout.fillWidth: true; text: openButton.text; elide: Text.ElideRight; color: theme.accentForeground; font.weight: Font.DemiBold } + Text { Layout.leftMargin: 14; Layout.fillWidth: true; text: openButton.text; textFormat: Text.PlainText; elide: Text.ElideRight; color: theme.accentForeground; font.weight: Font.DemiBold } Text { Layout.rightMargin: 14; text: "⏎"; color: theme.accentForeground } } } @@ -272,7 +325,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..bf3a3eaa 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") } @@ -92,6 +94,7 @@ ApplicationWindow { Layout.fillWidth: true horizontalAlignment: Text.AlignLeft text: win.item.name || "" + textFormat: Text.PlainText color: theme.brightForeground font.pixelSize: 15 font.weight: Font.DemiBold @@ -178,6 +181,7 @@ ApplicationWindow { } HelpOverlay { + id: help anchors.fill: parent visible: win.helpVisible onDismissed: win.helpVisible = false @@ -298,8 +302,8 @@ ApplicationWindow { selectByMouse: true activeFocusOnPress: false wrapMode: TextEdit.Wrap - textFormat: win.viewer === "markdown" ? TextEdit.MarkdownText : TextEdit.PlainText - text: previewer.text + textFormat: win.viewer === "markdown" ? TextEdit.RichText : TextEdit.PlainText + text: win.viewer === "markdown" ? previewer.html : previewer.text color: theme.foreground selectionColor: theme.accent selectedTextColor: theme.accentForeground @@ -380,7 +384,8 @@ ApplicationWindow { Text { Layout.alignment: Qt.AlignHCenter text: win.item.extension || "" - color: theme.accent + textFormat: Text.PlainText + color: theme.accentText font.weight: Font.DemiBold } Text { diff --git a/src/RenameDialog.qml b/src/RenameDialog.qml index 8e8aea4c..85586a08 100644 --- a/src/RenameDialog.qml +++ b/src/RenameDialog.qml @@ -48,7 +48,8 @@ FolioDialog { horizontalAlignment: Text.AlignLeft visible: dialog.problem.length > 0 text: dialog.problem - color: theme.red + textFormat: Text.PlainText + color: theme.redText wrapMode: Text.Wrap } RowLayout { 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..4bb906eb 100644 --- a/src/Sidebar.qml +++ b/src/Sidebar.qml @@ -32,11 +32,12 @@ Rectangle { // Each plugin section's rows, then the row for its view. function sectionRows(section) { return section.view ? section.rows.concat([section.view]) : section.rows } readonly property var pluginRows: plugins.sections.reduce((all, section) => all.concat(sectionRows(section)), []) - readonly property var rows: [].concat(places.places, places.pinned, places.views, places.devices, pluginRows) - readonly property int pinnedAt: places.places.length - readonly property int viewsAt: pinnedAt + places.pinned.length - readonly property int devicesAt: viewsAt + places.views.length + readonly property var rows: [].concat([homeRow], places.places, places.views, places.pinned, places.devices, pluginRows) + readonly property int pinnedAt: viewsAt + places.views.length + readonly property int viewsAt: 1 + places.places.length + readonly property int devicesAt: pinnedAt + places.pinned.length readonly property int pluginsAt: devicesAt + places.devices.length + readonly property var homeRow: ({ path: places.home }) // Where a plugin section's rows start in `rows`. function sectionAt(index) { let at = pluginsAt @@ -89,7 +90,7 @@ Rectangle { // A pinned folder is unpinned, a plugin's item taken off the sidebar. Keys.onDeletePressed: { const row = rows[cursor] - if (cursor >= pinnedAt && cursor < viewsAt) + if (cursor >= pinnedAt && cursor < devicesAt) places.unpin(row.path) else if (row && row.forgettable) plugins.forget(row.plugin, row.id) @@ -125,7 +126,7 @@ Rectangle { readonly property string activePath: { let best = "" const here = sidebar.target.path - for (const list of [places.places, places.pinned, places.views, places.devices, + for (const list of [[homeRow], places.places, places.pinned, places.views, places.devices, plugins.sections.filter(section => section.view).map(section => section.view)]) { for (const p of list) { if (p.path !== "/" && (here === p.path || here.startsWith(p.path + "/")) && p.path.length > best.length) @@ -183,25 +184,27 @@ Rectangle { background: Rectangle { radius: Math.min(4, theme.radius) - color: row.active || row.dropHover ? Qt.alpha(theme.accent, 0.16) + // Accent only while the sidebar holds the keyboard; grey while the files do. + color: row.dropHover || row.active && sidebar.activeFocus ? Qt.alpha(theme.accent, 0.16) + : row.active ? Qt.alpha(theme.foreground, 0.10) : row.hovered && row.enabled ? Qt.alpha(theme.foreground, 0.06) : "transparent" border.width: row.dropHover ? 2 : row.keyed ? 1 : 0 - border.color: theme.accent + border.color: theme.accentMark } - 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 { @@ -222,6 +225,7 @@ Rectangle { Layout.fillWidth: true horizontalAlignment: Text.AlignLeft text: row.text + textFormat: Text.PlainText elide: Text.ElideRight color: row.active ? theme.brightForeground : theme.foreground } @@ -239,10 +243,11 @@ Rectangle { readonly property string said: row.mark ? row.mark.status : row.status visible: said.length > 0 text: said - color: !row.mark ? row.statusColor : row.mark.tone === "error" ? theme.red - : row.mark.tone === "warning" ? theme.yellow : theme.secondaryForeground + color: !row.mark ? row.statusColor : row.mark.tone === "error" ? theme.redText + : row.mark.tone === "warning" ? theme.yellowText : theme.secondaryForeground font.pixelSize: 11 } + Text { visible: rowDrop.containsDrag && !row.dropTrashes; text: rowDrop.copies ? qsTr("Copy here") : qsTr("Move here"); color: theme.foreground; font.pixelSize: 11 } } } @@ -274,15 +279,15 @@ Rectangle { width: sidebar.width - 24 spacing: 1 - // The section's label is home itself: click it to go there. - SectionLabel { - Layout.topMargin: 0 + PlaceRow { + dropPath: places.home + onDropHoverChanged: sidebar.dropHovers += dropHover ? 1 : -1 + keyed: sidebar.activeFocus && sidebar.cursor === 0 + onKeyedChanged: if (keyed) sidebar.reveal(this) text: qsTr("Home") - color: sidebar.target.path === places.home ? theme.accent - : homeHover.hovered ? theme.foreground : theme.secondaryForeground - Accessible.role: Accessible.Button - HoverHandler { id: homeHover; cursorShape: Qt.PointingHandCursor } - TapHandler { onTapped: sidebar.target.navigate(places.home) } + glyph: "home" + active: sidebar.activePath === places.home + onClicked: sidebar.target.navigate(places.home) } Repeater { model: places.places @@ -291,7 +296,7 @@ Rectangle { required property int index dropPath: modelData.path onDropHoverChanged: sidebar.dropHovers += dropHover ? 1 : -1 - keyed: sidebar.activeFocus && sidebar.cursor === 0 + index + keyed: sidebar.activeFocus && sidebar.cursor === 1 + index onKeyedChanged: if (keyed) sidebar.reveal(this) text: modelData.name glyph: modelData.icon @@ -300,6 +305,23 @@ Rectangle { } } + // Views: lists that aren't a folder of their own. + Repeater { + model: places.views + PlaceRow { + required property var modelData + required property int index + dropTrashes: modelData.icon === "trash" + onDropHoverChanged: sidebar.dropHovers += dropHover ? 1 : -1 + text: modelData.icon === "trash" ? qsTr("Trash") : qsTr("Recents") + glyph: modelData.icon + active: sidebar.activePath === modelData.path + onClicked: sidebar.target.navigate(modelData.path) + keyed: sidebar.activeFocus && sidebar.cursor === sidebar.viewsAt + index + onKeyedChanged: if (keyed) sidebar.reveal(this) + } + } + // Pinned folders from anywhere, kept in GTK's bookmarks. While a // folder is dragged over the sidebar, the section shows (even // empty) as the place to drop it. @@ -339,14 +361,15 @@ Rectangle { Layout.fillWidth: true implicitHeight: 34 visible: sidebar.dragging + // Theme's accentText floors on this 0.22 tint, so the two change together. color: Qt.alpha(theme.accent, pinDrop.containsDrag ? 0.22 : 0.08) border.width: pinDrop.containsDrag ? 2 : 1 - border.color: theme.accent + border.color: theme.accentMark radius: Math.min(4, theme.radius) Text { anchors.centerIn: parent text: qsTr("Drop to pin") - color: theme.accent + color: theme.accentText font.pixelSize: 12 } DropArea { @@ -364,24 +387,6 @@ Rectangle { } } - // Views: lists that aren't a folder of their own. - SectionLabel { text: qsTr("Views") } - Repeater { - model: places.views - PlaceRow { - required property var modelData - required property int index - dropTrashes: modelData.icon === "trash" - onDropHoverChanged: sidebar.dropHovers += dropHover ? 1 : -1 - text: modelData.icon === "trash" ? qsTr("Trash") : qsTr("Recents") - glyph: modelData.icon - active: sidebar.activePath === modelData.path - onClicked: sidebar.target.navigate(modelData.path) - keyed: sidebar.activeFocus && sidebar.cursor === sidebar.viewsAt + index - onKeyedChanged: if (keyed) sidebar.reveal(this) - } - } - // Each drive says how much is free under its name, where there's // room for it, and while it's hovered or has the keyboard its icon // becomes the button that ejects it. Right-clicked, it has a menu diff --git a/src/backend.cpp b/src/backend.cpp index 6224ee0c..3909d380 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -393,7 +393,7 @@ void Backend::describe(const QVariantMap &item, bool inTrash) { // Stepping through files faster than they're read queues readings; the // ones overtaken before they start are skipped rather than left to tie // up the threads the next folder is listed on. - watcher->setFuture(QtConcurrent::run([path, kind, inTrash, latest = m_describing, generation] { + watcher->setFuture(QtConcurrent::run([path, kind, mime, inTrash, latest = m_describing, generation] { QVariantMap read; if (*latest != generation) return read; @@ -416,7 +416,14 @@ void Backend::describe(const QVariantMap &item, bool inTrash) { } else if (kind == "text") { // Enough of the top of the file to recognize it, never the whole // thing: a log can be gigabytes. - read.insert("text", Previewer::readText(path, 6000).section('\n', 0, 40)); + const QString text = Previewer::readText(path, 6000).section('\n', 0, 40); + // Table cells cost the UI thread most to lay out; this many keep the card under a 10 ms frame. + constexpr int markdownCells = 300; + // A Markdown file with no text to show gets no page, as in the preview window. + if (mime != "text/markdown") + read.insert("text", text); + else if (const QString opening = Previewer::markdownOpening(text, markdownCells); !opening.isEmpty()) + read.insert("markdown", Previewer::markdownHtml(opening, Previewer::cardPixels)); } return read; })); diff --git a/src/backend.h b/src/backend.h index 7d0f582d..54f24624 100644 --- a/src/backend.h +++ b/src/backend.h @@ -29,9 +29,10 @@ class Backend : public QObject { Q_PROPERTY(bool previewing READ previewing NOTIFY previewingChanged) // What the details pane shows beyond the listing, for the item last // given to describe(), once they've been read in the background: a - // picture's dimensions, a text's opening lines, or a trashed item's - // origin and deletion time; and under `plugins`, plugins' rows about a - // file or folder, [[label, value]...]. + // picture's dimensions, a text's opening lines (a Markdown file's as + // HTML, markdown), or a trashed item's origin and deletion time; and + // under `plugins`, plugins' rows about a file or folder, + // [[label, value]...]. Q_PROPERTY(QVariantMap details READ details NOTIFY detailsChanged) public: diff --git a/src/fileops.cpp b/src/fileops.cpp index da94370e..557799be 100644 --- a/src/fileops.cpp +++ b/src/fileops.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -757,10 +758,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 +808,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. @@ -1998,8 +2036,11 @@ QVariantList FileOps::appsFor(const QString &path) const { // even a hidden one (Omarchy's editor handler), so it can be named. if (!g_app_info_should_show(info) && id != defaultId) continue; + GIcon *icon = g_app_info_get_icon(info); + char *iconName = icon ? g_icon_to_string(icon) : nullptr; const QVariantMap app{{"id", id}, {"name", QString::fromUtf8(g_app_info_get_name(info))}, - {"isDefault", id == defaultId}}; + {"icon", QString::fromUtf8(iconName)}, {"isDefault", id == defaultId}}; + g_free(iconName); if (id == defaultId) apps.prepend(app); else @@ -2010,6 +2051,21 @@ QVariantList FileOps::appsFor(const QString &path) const { return apps; } +QImage AppIconProvider::requestImage(const QString &id, QSize *size, const QSize &requestedSize) { + // Open With's icon column, for an Image that names no size. + constexpr int menuIconSize = 16; + const QSize want = requestedSize.isValid() ? requestedSize : QSize(menuIconSize, menuIconSize); + const QString name = QUrl::fromPercentEncoding(id.toUtf8()); + const QIcon icon = QFileInfo(name).isAbsolute() ? QIcon(name) : QIcon::fromTheme(name); + QImage image = icon.pixmap(want).toImage(); + if (image.isNull()) { + image = QImage(want, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + } + *size = image.size(); + return image; +} + void FileOps::openWith(const QString &path, const QString &appId) { GDesktopAppInfo *app = g_desktop_app_info_new(appId.toUtf8().constData()); if (!app) diff --git a/src/fileops.h b/src/fileops.h index 9a87ecd8..831954dc 100644 --- a/src/fileops.h +++ b/src/fileops.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -93,10 +94,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; @@ -143,7 +146,7 @@ class FileOps : public QObject { Q_INVOKABLE void newFolder(const QString &folder); // The apps that can open a file, from the desktop's MIME database: - // {id, name, isDefault}, the default first. + // {id, name, icon (Icon=, a theme name or a path), isDefault}, the default first. Q_INVOKABLE QVariantList appsFor(const QString &path) const; Q_INVOKABLE void openWith(const QString &path, const QString &appId); // Makes the app what the file's type opens with from now on. @@ -324,6 +327,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. @@ -340,3 +346,10 @@ class FileOps : public QObject { }; std::optional m_pending; }; + +// Serves image://appicon/ from the icon theme, blank when the theme has no such icon. +class AppIconProvider : public QQuickImageProvider { +public: + AppIconProvider() : QQuickImageProvider(QQuickImageProvider::Image) {} + QImage requestImage(const QString &id, QSize *size, const QSize &requestedSize) override; +}; diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 2cce5a3f..18152116 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) @@ -634,6 +653,10 @@ int FolderModel::indexOfPath(const QString &path) const { return -1; } +int FolderModel::matchStart(const QString &fileName) const { + return m_query.isEmpty() ? -1 : fileName.indexOf(m_query, 0, Qt::CaseInsensitive); +} + void FolderModel::setLoading(bool loading) { if (loading == m_loading) return; diff --git a/src/foldermodel.h b/src/foldermodel.h index 681a2c7e..0aabe638 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -30,8 +30,12 @@ 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) + // A search stops at this many matches, so a count this high may be cut. + Q_PROPERTY(int searchLimit READ searchLimit CONSTANT) public: enum Role { @@ -46,6 +50,8 @@ class FolderModel : public QAbstractListModel { ItemCountRole, SelectedRole, IconRole, + FileNameRole, + FolderRole, }; struct Entry { @@ -64,6 +70,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); @@ -93,6 +101,8 @@ class FolderModel : public QAbstractListModel { // By path, for Recents, where two files from different folders can // share a name. Q_INVOKABLE int indexOfPath(const QString &path) const; + // Where the query starts in a file's name, matched as the search matches it; -1 for none. + Q_INVOKABLE int matchStart(const QString &fileName) const; // The selection, kept by path so it survives a re-read or a re-sort. // The views keep the cursor (the current item) themselves. @@ -105,6 +115,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 @@ -140,8 +153,10 @@ class FolderModel : public QAbstractListModel { // named by its path from `root`. Stops at `limit` matches, at other // filesystems and at symlinks, and as soon as `stop` returns true. static QVector search(const QString &root, const QString &query, bool showHidden, const QString &sort, - const std::function &stop = {}, int limit = 1000, + const std::function &stop = {}, int limit = maxSearchResults, const QLocale &locale = QLocale()); + static constexpr int maxSearchResults = 1000; + int searchLimit() const { return maxSearchResults; } signals: void pathChanged(); diff --git a/src/i18n/en.json b/src/i18n/en.json index 369dbda5..33227db4 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -28,18 +28,33 @@ "%1 of %2": "%1 of %2", "%1 printed too much.": "%1 printed too much.", "%1 stopped with code %2.": "%1 stopped with code %2.", - "%1 will be deleted permanently. This can't be undone.": "%1 will be deleted permanently. This can't be undone.", "%1 · %2": "%1 · %2", "%1 × %2": "%1 × %2", "%1: %2": "%1: %2", + "%Ln folders": { + "one": "%Ln folder", + "other": "%Ln folders" + }, + "%Ln found in %1 and below": { + "one": "%Ln found in %1 and below", + "other": "%Ln found in %1 and below" + }, "%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." }, + "%Ln or more found in %1 and below": { + "one": "%Ln or more found in %1 and below", + "other": "%Ln or more found in %1 and below" + }, "A folder, or a server's address like sftp://host/": "A folder, or a server's address like sftp://host/", "A name can't contain \"/\".": "A name can't contain \"/\".", "Add or remove one from the selection": "Add or remove one from the selection", @@ -65,6 +80,7 @@ "Check the server": "Check the server", "Choose": "Choose", "Close": "Close", + "Close the split": "Close the split", "Close the window": "Close the window", "Close this pane": "Close this pane", "Compress": "Compress", @@ -80,6 +96,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…", @@ -127,9 +144,13 @@ "Couldn't unmount %1: %2": "Couldn't unmount %1: %2", "Cut": "Cut", "Delete": "Delete", - "Delete for good?": "Delete for good?", + "Delete %Ln items permanently?": { + "one": "Delete %Ln item permanently?", + "other": "Delete %Ln items permanently?" + }, "Delete in the sidebar": "Delete in the sidebar", "Delete permanently (asks first)": "Delete permanently (asks first)", + "Delete “%1” permanently?": "Delete “%1” permanently?", "Deleted": "Deleted", "Delete…": "Delete…", "Deleting %L1 of %Ln items…": { @@ -148,6 +169,7 @@ "one": "Do this for the other %Ln clash", "other": "Do this for the other %Ln clashes" }, + "Drag / Ctrl+drag": "Drag / Ctrl+drag", "Drag on empty space": "Drag on empty space", "Drives can't be changed without udisks2.": "Drives can't be changed without udisks2.", "Drop to pin": "Drop to pin", @@ -167,6 +189,7 @@ "other": "Emptying the trash: %L1 of %Ln items…" }, "Emptying the trash…": "Emptying the trash…", + "Escape ends the search": "Escape ends the search", "Everything else, for files and for drives in the sidebar": "Everything else, for files and for drives in the sidebar", "Everything on this drive will be erased. This can't be undone.": "Everything on this drive will be erased. This can't be undone.", "Extract": "Extract", @@ -220,6 +243,8 @@ "Modified": "Modified", "Mount": "Mount", "Move between the sidebar, the panes and the details": "Move between the sidebar, the panes and the details", + "Move files to a folder / copy them": "Move files to a folder / copy them", + "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", @@ -346,6 +371,7 @@ "Search %1": "Search %1", "Search %1 and below": "Search %1 and below", "Search this folder and below": "Search this folder and below", + "Searched %1 and below": "Searched %1 and below", "Select All": "Select All", "Select a range": "Select a range", "Select everything": "Select everything", @@ -366,6 +392,7 @@ "Sort: name, kind, newest, oldest, size": "Sort: name, kind, newest, oldest, size", "Sorted by kind": "Sorted by kind", "Sorted by name": "Sorted by name", + "Split": "Split", "Split into two panes / close the right one": "Split into two panes / close the right one", "Stop the file work under way, or dismiss what went wrong": "Stop the file work under way, or dismiss what went wrong", "Stopped after %L1 of %Ln items.": { @@ -390,6 +417,7 @@ "There's nothing in %1 to extract.": "There's nothing in %1 to extract.", "There's nothing to redo.": "There's nothing to redo.", "There's nothing to undo.": "There's nothing to undo.", + "This can't be undone.": "This can't be undone.", "This computer can't make %1 drives.": "This computer can't make %1 drives.", "This folder is empty": "This folder is empty", "This folder no longer exists.": "This folder no longer exists.", @@ -438,7 +466,6 @@ "Untitled Folder": "Untitled Folder", "Untitled Folder %1": "Untitled Folder %1", "User": "User", - "Views": "Views", "Works almost anywhere, old cameras and car stereos too, but no file can be over 4 GB.": "Works almost anywhere, old cameras and car stereos too, but no file can be over 4 GB.", "Works with Mac, Windows and Linux, and holds big files.": "Works with Mac, Windows and Linux, and holds big files.", "You don't have permission to open this folder.": "You don't have permission to open this folder.", diff --git a/src/main.cpp b/src/main.cpp index 9228dca5..014859dc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -13,6 +13,7 @@ #include #include "filemanager1.h" +#include "fileops.h" #include "foldermodel.h" #include "places.h" #include "plugins.h" @@ -107,7 +108,7 @@ static int preview(QGuiApplication &app) { engine.rootContext()->setContextProperty("translator", &translator); engine.rootContext()->setContextProperty("theme", &theme); engine.rootContext()->setContextProperty("previewer", &previewer); - engine.load(QUrl("qrc:/PreviewWindow.qml")); + Theme::grayText([&engine] { engine.load(QUrl("qrc:/PreviewWindow.qml")); }); if (engine.rootObjects().isEmpty()) return 1; return app.exec(); @@ -142,6 +143,7 @@ int main(int argc, char *argv[]) { QObject::connect(&translator, &Translator::changed, &engine, &QQmlEngine::retranslate); engine.addImageProvider("thumb", new ThumbnailProvider(&thumbnails)); engine.addImageProvider("plugin-icon", new PluginIconProvider); + engine.addImageProvider("appicon", new AppIconProvider); // Creatable too: the column view lists each folder on the way here. qmlRegisterType("Folio", 1, 0, "FolderModel"); engine.rootContext()->setContextProperty("translator", &translator); @@ -150,7 +152,9 @@ int main(int argc, char *argv[]) { engine.rootContext()->setContextProperty("plugins", &plugins); Windows windows(&engine, &plugins); - if (!windows.open(start, select)) + bool opened = false; + Theme::grayText([&] { opened = windows.open(start, select) != nullptr; }); + if (!opened) return 1; FileManager1::registerOn(&windows); diff --git a/src/previewer.cpp b/src/previewer.cpp index 7ac8bf0e..89bf924b 100644 --- a/src/previewer.cpp +++ b/src/previewer.cpp @@ -3,12 +3,19 @@ #include "fileops.h" #include #include +#include #include #include #include +#include +#include +#include +#include +#include #include #include #include +#include #include Previewer::Previewer(QObject *parent) : QObject(parent) {} @@ -108,6 +115,104 @@ QString Previewer::markdownOpening(const QString &text, int cells) { return text; } +QString Previewer::markdownHtml(const QString &markdown, int pixels) { + QTextDocument document; + document.setMarkdown(markdown, QTextDocument::MarkdownFeatures(QTextDocument::MarkdownDialectGitHub | QTextDocument::MarkdownNoHTML)); + // Drawing a picture would fetch it, a remote one too, just because the file was selected. + QTextCursor cursor(&document); + while (!(cursor = document.find(QString(QChar::ObjectReplacementCharacter), cursor)).isNull()) { + const QTextCharFormat format = cursor.charFormat(); + if (format.isImageFormat()) + cursor.insertText(format.property(QTextFormat::ImageAltText).toString(), QTextCharFormat()); + } + // The room Qt leaves between Markdown blocks follows neither their kind nor the text's size, so each kind is given its own here. + const auto part = [pixels](qreal of) { return qRound(of * pixels); }; + // Greys let through enough to stay quiet on a dark ground and on a light one: a table's rules, and the ground under code. + const QColor rule(128, 128, 128, 96), ground(128, 128, 128, 36); + constexpr int linePercent = 120; + // The room above and below each kind of block, in parts of the text's size. + constexpr qreal headingAbove = 1.1, smallHeadingAbove = 0.8, headingBelow = 0.55, listAbove = 0.45, itemBelow = 0.15, textBelow = 0.75, cellRoom = 0.5, tableAbove = 0.3, tableBelow = 0.9; + // Headings from this level on are the smaller ones. + constexpr int smallHeading = 3; + // The font Qt sets code in when it reads Markdown, which is how a run of code is told from the text around it. + const QStringList codeFont = QFontDatabase::systemFont(QFontDatabase::FixedFont).families(); + const auto isCode = [](const QTextBlockFormat &of) { return of.hasProperty(QTextFormat::BlockCodeFence) || of.hasProperty(QTextFormat::BlockCodeLanguage); }; + const QTextList *list = nullptr, *edge = nullptr; + for (QTextBlock block = document.begin(); block.isValid(); block = block.next()) { + QTextCursor at(block); + QTextBlockFormat format = block.blockFormat(); + const QTextList *before = std::exchange(list, block.textList()); + const bool item = list != nullptr, wasListed = before != nullptr; + // A list at the page's edge that follows another there is a list of its own, whatever the one before ended on; one further in belongs to the item above it. + const bool atEdge = item && list->format().indent() <= 1; + const bool nextList = atEdge && wasListed && edge && list != edge; + if (atEdge) + edge = list; + const bool code = isCode(format); + // Code in a line gets the ground a code block has, and a link the text's own color: it can't be followed from here, and Qt's blue is lost on a dark ground. + struct Run { int position, length; QTextCharFormat shown; }; + QVector runs; + for (QTextBlock::iterator run = block.begin(); !code && !run.atEnd(); ++run) { + QTextCharFormat shown = run.fragment().charFormat(); + if (shown.isAnchor()) { + shown.clearProperty(QTextFormat::AnchorHref); + shown.clearForeground(); + shown.setFontUnderline(true); + } else if (shown.fontFamilies().toStringList() == codeFont) { + shown.setBackground(ground); + } else { + continue; + } + runs.append({run.fragment().position(), run.fragment().length(), shown}); + } + // Set after the walk: a run given its neighbor's format joins it, and the walk would lose its place. + for (const Run &run : runs) { + QTextCursor over(&document); + over.setPosition(run.position); + over.setPosition(run.position + run.length, QTextCursor::KeepAnchor); + over.setCharFormat(run.shown); + } + if (at.currentTable()) { + // A cell's room is its padding. + continue; + } else if (format.headingLevel() > 0) { + format.setTopMargin(block == document.begin() ? 0 : part(format.headingLevel() < smallHeading ? headingAbove : smallHeadingAbove)); + format.setBottomMargin(part(headingBelow)); + } else if (item) { + format.setTopMargin(nextList ? part(listAbove) : 0); + format.setBottomMargin(part(itemBelow)); + format.setLineHeight(linePercent, QTextBlockFormat::ProportionalHeight); + } else { + // The lines of one code block touch. Qt files two blocks in a row as one run of lines, so only a change of language tells them apart. + const QTextBlockFormat next = block.next().blockFormat(); + const bool more = code && block.next().isValid() && isCode(next) && next.property(QTextFormat::BlockCodeLanguage) == format.property(QTextFormat::BlockCodeLanguage); + format.setTopMargin(wasListed ? part(listAbove) : 0); + format.setBottomMargin(more ? 0 : part(textBelow)); + if (code) + format.setBackground(ground); + else + format.setLineHeight(linePercent, QTextBlockFormat::ProportionalHeight); + } + at.setBlockFormat(format); + } + for (QTextFrame *frame : document.rootFrame()->childFrames()) { + auto *table = qobject_cast(frame); + if (!table) + continue; + QTextTableFormat format = table->format(); + format.setCellPadding(part(cellRoom)); + format.setCellSpacing(0); + format.setBorderStyle(QTextFrameFormat::BorderStyle_Solid); + format.setBorderBrush(rule); + format.setTopMargin(part(tableAbove)); + format.setBottomMargin(part(tableBelow)); + table->setFormat(format); + } + // toHtml writes . + // Without the document's own font the text takes the one it's shown in. + return document.toHtml().replace(QRegularExpression(QStringLiteral("")), QStringLiteral("")); +} + void Previewer::start(const QString &path, bool showHidden, const QString &sort) { m_showHidden = showHidden; m_sort = sort; @@ -208,6 +313,7 @@ void Previewer::setIndex(int index, bool report) { const FolderModel::Entry e = m_entries.at(index); m_viewer = viewerFor(e.mime, e.isDir); m_text.clear(); + m_html.clear(); // A folder's count and first pictures, and a text's opening part, are // read off the UI thread: a big folder, or any file on a slow mount, @@ -216,14 +322,15 @@ void Previewer::setIndex(int index, bool report) { const bool count = e.isDir && e.itemCount < 0; const bool text = m_viewer == "text" || m_viewer == "markdown"; if (count || text) { - auto *watcher = new QFutureWatcher>(this); + auto *watcher = new QFutureWatcher>(this); connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation, text] { watcher->deleteLater(); if (generation != *m_reading) return; // Stepped on to another item while this was read. const auto [entry, read] = watcher->result(); m_entries[m_index] = entry; - m_text = read; + m_text = read.value(0); + m_html = read.value(1); // A ".txt" that turns out to be binary is shown as a plain file. if (text && m_text.isEmpty() && entry.size > 0) m_viewer = "other"; @@ -234,11 +341,15 @@ void Previewer::setIndex(int index, bool report) { watcher->setFuture(QtConcurrent::run([entry = e, showHidden, count, text, markdown, latest = m_reading, generation]() mutable { if (*latest != generation) - return qMakePair(entry, QString()); + return qMakePair(entry, QStringList()); if (count) FolderModel::summarize(entry, showHidden); const QString read = text ? readText(entry.path) : QString(); - return qMakePair(entry, markdown ? markdownOpening(read) : read); + if (!markdown) + return qMakePair(entry, QStringList{read}); + // The page is made here too, off the UI thread, at the size the window sets its text in; a file with no text to show gets none. + const QString opening = markdownOpening(read); + return qMakePair(entry, QStringList{opening, opening.isEmpty() ? QString() : markdownHtml(opening, windowPixels)}); })); } emit changed(); diff --git a/src/previewer.h b/src/previewer.h index 9aee60e9..bd5a584c 100644 --- a/src/previewer.h +++ b/src/previewer.h @@ -26,6 +26,7 @@ class Previewer : public QObject { Q_PROPERTY(QString viewer READ viewer NOTIFY changed) Q_PROPERTY(QString url READ url NOTIFY changed) Q_PROPERTY(QString text READ text NOTIFY changed) + Q_PROPERTY(QString html READ html NOTIFY changed) Q_PROPERTY(QString type READ type NOTIFY changed) public: @@ -48,6 +49,8 @@ class Previewer : public QObject { QString viewer() const { return m_viewer; } QString url() const; QString text() const { return m_text; } + // A Markdown file's text as the page to show; empty for any other. + QString html() const { return m_html; } QString type() const; Q_INVOKABLE void next(); @@ -65,6 +68,10 @@ class Previewer : public QObject { // `cells` cells: laid out, each costs the UI thread about as much as a // whole line of prose, and a long table froze the preview for seconds. static QString markdownOpening(const QString &text, int cells = 600); + // Markdown as HTML for text `pixels` high, with room between its blocks; a picture is its alt text, so showing it loads nothing, and raw HTML stays text. + static QString markdownHtml(const QString &markdown, int pixels); + // The sizes the preview window and the details pane's card set their text in, which a Markdown page's room is cut to. + static constexpr int windowPixels = 13, cardPixels = 10; signals: void changed(); @@ -77,6 +84,7 @@ class Previewer : public QObject { int m_index = -1; QString m_viewer; QString m_text; + QString m_html; bool m_showHidden = false; QString m_sort = "name"; quint64 m_generation = 0; 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/src/theme.cpp b/src/theme.cpp index c3f44015..4285e48f 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include namespace { @@ -43,6 +44,57 @@ QColor mix(const QColor &a, const QColor &b, double t) { a.blueF() * (1 - t) + b.blueF() * t); } +double ratio(const QColor &a, const QColor &b) { + const double x = luminance(a), y = luminance(b); + return (std::max(x, y) + .05) / (std::min(x, y) + .05); +} + +// WCAG's minimum contrast for text. +constexpr double textFloor = 4.5; +// The ground luminance where black and white read equally. +constexpr double midLuminance = .179; +// Bisection steps in ensureRatio, finer than one eight-bit level. +constexpr int bisectionSteps = 18; +// One eight-bit channel's range, so the rounding repair can never step further. +constexpr int channelRange = 255; +// WCAG's minimum contrast for marks and frames, which aren't text. +constexpr double markFloor = 3; +// The least contrast a folder card's fill keeps on the pane: a surface, so well under markFloor. +constexpr double folderFloor = 1.4; +// The pin box's accent tint in Sidebar.qml while a drop hovers it, the strongest ground under its words. +constexpr double strongestPinTint = 0.22; +// At or under this saturation a theme's red is a grey. +constexpr double greyRedSaturation = 0.2; + +// The ink when it already reads at need, else the least mix toward black or white that still does once rounded. +QColor ensureRatio(const QColor &ink, const QColor &ground, double need) { + if (ratio(ink, ground) >= need) + return ink; + const QColor toward = luminance(ground) > midLuminance ? QColor(Qt::black) : QColor(Qt::white); + double low = 0, high = 1; + QColor best = toward; + for (int i = 0; i < bisectionSteps; ++i) { + const double middle = (low + high) / 2; + const QColor candidate = mix(ink, toward, middle); + if (ratio(candidate, ground) >= need) { + best = candidate; + high = middle; + } else { + low = middle; + } + } + best = QColor(best.red(), best.green(), best.blue()); + const int step = toward == QColor(Qt::white) ? 1 : -1; + for (int i = 0; i < channelRange && ratio(best, ground) < need; ++i) { + const QColor next(qBound(0, best.red() + step, channelRange), qBound(0, best.green() + step, channelRange), + qBound(0, best.blue() + step, channelRange)); + if (next == best) + break; + best = next; + } + return best; +} + struct Palette { QHash colors; bool light = false; @@ -157,11 +209,14 @@ QColor Theme::accentForeground() const { return luminance(accent()) > .179 ? QColor("black") : QColor("white"); } -// Themes' own dim foregrounds are often too faint for small text, so derive a -// secondary color by mixing the foreground toward the background, keeping -// roughly a 4.5:1 contrast for metadata and labels. +// Metadata and labels: the foreground mixed toward the background, raised to textFloor on each ground they're on. QColor Theme::secondaryForeground() const { - return mix(background(), foreground(), 0.68); + // The foreground's share of the secondary ink before the floor. + constexpr double share = 0.68; + QColor ink = mix(background(), foreground(), share); + for (const QColor &ground : {background(), lighterBackground(), darkBackground()}) + ink = ensureRatio(ink, ground, textFloor); + return ink; } void Theme::reload() { @@ -190,3 +245,73 @@ void Theme::reload() { emit changed(); } } + +// The accent as a thin frame or ring, raised to 3:1; fills and tints keep the accent itself. +QColor Theme::accentMark() const { + QColor mark = accent(); + for (const QColor &ground : {background(), lighterBackground(), darkBackground()}) + mark = ensureRatio(mark, ground, markFloor); + return mark; +} + +// The other pane's ring: the foreground at 60% where that reads on background and cards, else an opaque ink that does. +QColor Theme::quietMark() const { + // The foreground's share of the ring, as its alpha or as an opaque mix. + constexpr double share = 0.6; + const QColor bg = background(), card = lighterBackground(), fg = foreground(); + if (ratio(mix(bg, fg, share), bg) >= markFloor && ratio(mix(card, fg, share), card) >= markFloor) { + QColor ring = fg; + ring.setAlphaF(share); + return ring; + } + return ensureRatio(ensureRatio(mix(bg, fg, share), bg, markFloor), card, markFloor); +} + +// A folder card's fill: the lighter background, raised to folderFloor so the card stands off the pane. +QColor Theme::folderFill() const { + return ensureRatio(lighterBackground(), background(), folderFloor); +} + +// A disabled glyph: 3:1 on the bar, while the secondary ink of an off toggle keeps 4.5:1. +QColor Theme::disabledForeground() const { + // The foreground's share of a disabled glyph before the floor. + constexpr double share = 0.5; + return ensureRatio(mix(background(), foreground(), share), background(), markFloor); +} + +// Red as words, raised to textFloor, or the foreground when the theme's red is a grey; fills keep the theme's red. +QColor Theme::redText() const { + return red().hsvSaturationF() <= greyRedSaturation ? foreground() : ensureRatio(red(), background(), textFloor); +} + +// The accent as words, raised to textFloor on the background, the preview window and the pin box's strongest tint. +QColor Theme::accentText() const { + const QColor bg = background(), tint = mix(bg, accent(), strongestPinTint); + QColor ink = accent(); + for (const QColor &ground : {bg, darkBackground(), tint}) + ink = ensureRatio(ink, ground, textFloor); + return ink; +} + +// Yellow as words (a plugin mark's "warning" tone), raised to 4.5:1 on the background. +QColor Theme::yellowText() const { + return ensureRatio(yellow(), background(), textFloor); +} + +// Words on a palette fill (a tile's badge): the background where it reads, else the better of it and the foreground. +QColor Theme::inkOn(const QColor &fill) const { + const QColor bg = background(), fg = foreground(); + if (ratio(bg, fill) >= textFloor) + return bg; + return ensureRatio(ratio(bg, fill) >= ratio(fg, fill) ? bg : fg, fill, textFloor); +} + +void Theme::grayText(const std::function &firstWindow) { + // Qt reads it once per process, as the first window is made, so apps opened from Folio don't inherit it. + const bool ask = !qEnvironmentVariableIsSet("QSG_DISTANCEFIELD_ANTIALIASING"); + if (ask) + qputenv("QSG_DISTANCEFIELD_ANTIALIASING", "gray"); + firstWindow(); + if (ask) + qunsetenv("QSG_DISTANCEFIELD_ANTIALIASING"); +} diff --git a/src/theme.h b/src/theme.h index 221b9de4..f79c3f3a 100644 --- a/src/theme.h +++ b/src/theme.h @@ -6,6 +6,7 @@ #include #include #include +#include // The Omarchy palette, followed live. A file manager draws far more surface // than a one-tool app, so it reads the whole colors.toml rather than only the @@ -28,6 +29,13 @@ class Theme : public QObject { Q_PROPERTY(QColor orange READ orange NOTIFY changed) Q_PROPERTY(bool light READ light NOTIFY changed) Q_PROPERTY(int radius READ radius NOTIFY changed) + Q_PROPERTY(QColor accentMark READ accentMark NOTIFY changed) + Q_PROPERTY(QColor quietMark READ quietMark NOTIFY changed) + Q_PROPERTY(QColor folderFill READ folderFill NOTIFY changed) + Q_PROPERTY(QColor disabledForeground READ disabledForeground NOTIFY changed) + Q_PROPERTY(QColor redText READ redText NOTIFY changed) + Q_PROPERTY(QColor accentText READ accentText NOTIFY changed) + Q_PROPERTY(QColor yellowText READ yellowText NOTIFY changed) public: explicit Theme(const QString ¤tDirectory = {}, QObject *parent = nullptr, const QString &hyprctl = {}); @@ -49,6 +57,17 @@ class Theme : public QObject { QColor orange() const { return color("orange"); } bool light() const { return m_light; } int radius() const { return m_radius; } + QColor accentMark() const; + QColor quietMark() const; + QColor folderFill() const; + QColor disabledForeground() const; + QColor redText() const; + QColor accentText() const; + QColor yellowText() const; + Q_INVOKABLE QColor inkOn(const QColor &fill) const; + + // Makes the first window with gray text asked for, unless the environment chose: Qt Quick draws new glyphs with LCD fringes, reused ones gray. + static void grayText(const std::function &firstWindow); signals: void changed(); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 21e81d07..8357a0e8 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -26,6 +26,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -494,6 +498,10 @@ private slots: { // No test ever reaches the real udisks, which would change real disks. fakeDisks({}); + // GLib reads XDG_DATA_HOME once per process, so GIO's apps come from a made-up one for the whole run. + QVERIFY(gioHome.isValid()); + QVERIFY(QDir().mkpath(gioHome.filePath("data/applications"))); + qputenv("XDG_DATA_HOME", QFile::encodeName(gioHome.filePath("data"))); } void folderListsDirectoriesFirstWithoutHidden() @@ -1376,7 +1384,9 @@ private slots: FileOps ops; const std::unique_ptr theme(QQmlPropertyMap::create()); theme->insert("accent", QColor(Qt::blue)); + theme->insert("accentMark", QColor(Qt::blue)); theme->insert("radius", 0); + theme->insert("foreground", QColor(Qt::gray)); QQmlEngine engine; engine.rootContext()->setContextProperty("fileOps", &ops); engine.rootContext()->setContextProperty("theme", theme.get()); @@ -1422,6 +1432,102 @@ 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()); + // A real model, whose matchStart a search result's name calls. + FolderModel folder; + for (const char *role : {"accent", "accentMark", "foreground", "brightForeground", "secondaryForeground", + "lighterBackground", "darkBackground", "folderFill", "red", "blue", "magenta", + "orange", "yellow", "cyan"}) + theme->insert(role, QColor(Qt::gray)); + theme->insert("radius", 0); + translator->insert("language", "en"); + backend->insert("folder", QVariant::fromValue(&folder)); + 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 @@ -3305,6 +3411,231 @@ private slots: QVERIFY(!backend.details().contains("width")); } + void backendRendersMarkdownWithoutItsPictures() + { + // Drawing a Markdown file's picture would fetch it, a remote one too, just for selecting the file. + QTemporaryDir dir; + const QString notes = dir.filePath("notes.md"), plain = dir.filePath("notes.txt"); + write(notes, "# Field notes\n\nThe *falling* tide, the **long** lens.\n\n- dusk\n- dawn\n\n" + "![shoreline](shore.png) ![tidepool](http://example.com/tide.png) ![clifftop](https://example.com/cliff.png)" + " ![](https://example.com/bare.png) ![emblem][logo]\n\n\n\n" + "[logo]: https://example.com/logo.png\n"); + write(plain, "# Field notes\n![shore](shore.png)\n"); + Backend backend(dir.path()); + backend.describe({{"path", notes}, {"kind", "text"}, {"mime", "text/markdown"}}); + QTRY_VERIFY(backend.details().contains("markdown")); + QVERIFY(!backend.details().contains("text")); + const QString html = backend.details().value("markdown").toString(); + QVERIFY2(!html.contains(" 0); + QVERIFY2(cut.count(" said\n\nA `flag` and a [site](https://example.com) here.\n"; + QTextDocument page; + page.setHtml(Previewer::markdownHtml(markdown, 10)); + const auto block = [&page](const QString &text) { + QTextBlock found = page.begin(); + while (found.isValid() && found.text() != text) + found = found.next(); + return found; + }; + for (const char *text : {"Log", "Prints from the roll.", "Worked", "one", "within", "two", "first of all", "then", "After the lists.", "12", "Dodge the sky.", "Later", "first", "second", "said"}) + QVERIFY2(block(text).isValid(), text); + // The first heading starts the page; a later one stands off what came before, a smaller one by less. + QCOMPARE(block("Log").blockFormat().topMargin(), 0.0); + QCOMPARE(block("Log").blockFormat().bottomMargin(), 6.0); + QCOMPARE(block("Worked").blockFormat().topMargin(), 11.0); + QCOMPARE(block("Later").blockFormat().topMargin(), 8.0); + QCOMPARE(block("Prints from the roll.").blockFormat().bottomMargin(), 8.0); + // A list's items keep close, and what follows the list stands off it. + QCOMPARE(block("one").blockFormat().bottomMargin(), 2.0); + QCOMPARE(block("two").blockFormat().bottomMargin(), 2.0); + QCOMPARE(block("After the lists.").blockFormat().topMargin(), 5.0); + QCOMPARE(block("Dodge the sky.").blockFormat().topMargin(), 0.0); + QVERIFY(block("one").textList()); + // A list inside an item keeps to it, and a second list at the page's edge stands off the first. + QCOMPARE(block("within").blockFormat().topMargin(), 0.0); + QCOMPARE(block("two").blockFormat().topMargin(), 0.0); + QCOMPARE(block("first of all").blockFormat().topMargin(), 5.0); + QCOMPARE(block("then").blockFormat().topMargin(), 0.0); + // A table's cells have room, between quiet rules, and the table stands off the text after it. + const QTextTable *table = QTextCursor(block("12")).currentTable(); + QVERIFY(table); + QCOMPARE(table->format().cellPadding(), 5.0); + // Qt's Markdown reader gives a table its one pixel rule and joins the cells' rules; the look stands on both. + QCOMPARE(table->format().border(), 1.0); + QVERIFY(table->format().borderCollapse()); + QCOMPARE(table->format().borderStyle(), QTextFrameFormat::BorderStyle_Solid); + QCOMPARE(table->format().borderBrush().color().alpha(), 96); + QCOMPARE(table->format().cellSpacing(), 0.0); + QCOMPARE(table->format().topMargin(), 3.0); + QCOMPARE(table->format().bottomMargin(), 9.0); + QCOMPARE(block("12").blockFormat().bottomMargin(), 0.0); + QCOMPARE(block("Dodge the sky.").blockFormat().bottomMargin(), 8.0); + // The lines of a code block touch, on a ground of their own; a quote keeps its own indent. + QCOMPARE(block("first").blockFormat().bottomMargin(), 0.0); + QCOMPARE(block("second").blockFormat().bottomMargin(), 8.0); + QVERIFY(block("first").blockFormat().background().style() != Qt::NoBrush); + QVERIFY(block("Dodge the sky.").blockFormat().background().style() == Qt::NoBrush); + QVERIFY(block("said").blockFormat().leftMargin() > 0); + // Lines of text and of a list stand a little apart; a code block's keep the font's own step. + QCOMPARE(block("Prints from the roll.").blockFormat().lineHeight(), 120.0); + QCOMPARE(block("one").blockFormat().lineHeight(), 120.0); + QCOMPARE(block("first").blockFormat().lineHeight(), 0.0); + // Code in a line stands on the same ground, and a link is underlined text in the text's own color: nothing here follows it. + const QTextBlock line = block("A flag and a site here."); + QVERIFY(line.isValid()); + const auto formatOf = [&line](const QString &word) { + for (QTextBlock::iterator run = line.begin(); !run.atEnd(); ++run) + if (run.fragment().text() == word) + return run.fragment().charFormat(); + return QTextCharFormat(); + }; + QVERIFY(formatOf("flag").background().style() != Qt::NoBrush); + QVERIFY(formatOf("A ").isValid() && formatOf("A ").background().style() == Qt::NoBrush); + QVERIFY(formatOf("site").fontUnderline()); + QVERIFY(!formatOf("site").isAnchor()); + QVERIFY(!formatOf("site").hasProperty(QTextFormat::ForegroundBrush)); + + // A list at the page's edge stands off the one before it also when that one ended on an item further in. + page.setHtml(Previewer::markdownHtml("- outer\n - inner\n\n1. numbered\n", 10)); + QCOMPARE(block("inner").blockFormat().topMargin(), 0.0); + QCOMPARE(block("numbered").blockFormat().topMargin(), 5.0); + // Two code blocks in a row are two when their languages differ; code in a list item and code set in by four spaces get the ground too. + page.setHtml(Previewer::markdownHtml("```sh\nasked\n```\n\n```\nanswered\nagain\n```\n\n- step\n\n ```\n made\n ```\n\nThen:\n\n set in\n", 10)); + QCOMPARE(block("asked").blockFormat().bottomMargin(), 8.0); + QCOMPARE(block("answered").blockFormat().bottomMargin(), 0.0); + for (const char *text : {"asked", "answered", "made", "set in"}) + QVERIFY2(block(text).blockFormat().background().style() != Qt::NoBrush, text); + QCOMPARE(block("made").blockFormat().lineHeight(), 0.0); + + // The room follows the size the text is shown in. + page.setHtml(Previewer::markdownHtml(markdown, 20)); + QCOMPARE(block("Log").blockFormat().bottomMargin(), 11.0); + QCOMPARE(block("Worked").blockFormat().topMargin(), 22.0); + QCOMPARE(QTextCursor(block("12")).currentTable()->format().cellPadding(), 10.0); + } + + void previewShowsMarkdownAsAPage() + { + // The preview window shows a Markdown file as the page made of it, and any other text as written. + QTemporaryDir dir; + write(dir.filePath("a.md"), "# Rows\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nAfter.\n"); + write(dir.filePath("b.txt"), "# Rows\n"); + write(dir.filePath("c.md"), "# Other\n"); + Previewer previewer; + previewer.start(dir.filePath("a.md"), false, "name"); + QTRY_VERIFY(!previewer.html().isEmpty()); + QCOMPARE(previewer.viewer(), QString("markdown")); + QVERIFY(previewer.text().startsWith("# Rows\n")); + QTextDocument page; + page.setHtml(previewer.html()); + const QTextTable *table = nullptr; + for (QTextFrame *frame : page.rootFrame()->childFrames()) + if (auto *found = qobject_cast(frame)) + table = found; + // The cells' room is half the window's text size, rounded: the page was cut for the window, not for the details pane's card. + QVERIFY(table); + QCOMPARE(table->format().cellPadding(), 7.0); + QVERIFY(page.toPlainText().startsWith("Rows")); + QVERIFY(page.toPlainText().contains("After.")); + QVERIFY(!page.toPlainText().contains('|')); + // A step to another Markdown file takes the old page down at once, ahead of the read of the new one. + previewer.show(dir.filePath("c.md")); + QVERIFY(previewer.html().isEmpty()); + QTRY_VERIFY(previewer.html().contains("Other")); + QVERIFY(!previewer.html().contains("After.")); + previewer.show(dir.filePath("b.txt")); + QTRY_COMPARE(previewer.text(), QString("# Rows\n")); + QVERIFY(previewer.html().isEmpty()); + // A binary file named like Markdown is shown as a plain file, with no page of its own. + write(dir.filePath("d.md"), QByteArray("\x00\x01\x02\x03 not text", 13)); + previewer.show(dir.filePath("d.md")); + QTRY_COMPARE(previewer.viewer(), QString("other")); + QVERIFY(previewer.html().isEmpty()); + } + + void previewWindowSetsAMarkdownFileAsItsPage() + { + // The window's text is the page for a Markdown file, in the size the page's room was cut to, and the file as written for any other text. + 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()); + write(home.filePath("a.md"), "# Rows\n\n| a | b |\n|---|---|\n| 1 | 2 |\n"); + write(home.filePath("b.txt"), "# Rows\n"); + Theme theme(home.filePath("theme")); + Translator translator(home.filePath("language"), sourceFolder() + "/i18n"); + Previewer previewer; + previewer.start(home.filePath("a.md"), false, "name"); + QTRY_VERIFY(!previewer.html().isEmpty()); + 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())); + const auto body = [&root] { + QQuickItem *edit = nullptr; + for (QQuickItem *child : root->findChildren()) + if (child->inherits("QQuickTextEdit")) + edit = child; + return edit; + }; + QTRY_VERIFY(body()); + QCOMPARE(body()->property("textFormat").toInt(), int(Qt::RichText)); + QCOMPARE(body()->property("font").value().pixelSize(), Previewer::windowPixels); + // What is drawn is the page: the heading without its mark and the cells without their bars. + QString drawn; + QVERIFY(QMetaObject::invokeMethod(body(), "getText", Q_RETURN_ARG(QString, drawn), Q_ARG(int, 0), Q_ARG(int, body()->property("length").toInt()))); + QVERIFY2(drawn.contains("Rows") && !drawn.contains('#') && !drawn.contains('|') && !drawn.contains('<'), qPrintable(drawn)); + previewer.show(home.filePath("b.txt")); + QTRY_COMPARE(body()->property("text").toString(), QString("# Rows\n")); + QCOMPARE(body()->property("textFormat").toInt(), int(Qt::PlainText)); + } + void backendReadsTrashNotesOffTheUiThread() { // Finding a trashed item's note looks over every mounted drive, so @@ -4329,6 +4660,107 @@ 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 detailsSetAMarkdownFileInTheSizeItsRoomWasCutTo() + { + // The details card draws a Markdown file's page in the size its room was cut to. + QTemporaryDir home; + // 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()); + backend.setPlugins(&plugins); + 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()); + QVERIFY(window); + + // The page is the one rich text inside the pane. + std::function find = [&](QQuickItem *item, bool inPane) -> QQuickItem * { + const QByteArray type = item->metaObject()->className(); + if (inPane && item->inherits("QQuickTextEdit") && item->property("textFormat").toInt() == int(Qt::RichText)) + return item; + for (QQuickItem *child : item->childItems()) + if (QQuickItem *found = find(child, inPane || type.startsWith("PreviewPane"))) + return found; + return nullptr; + }; + QQuickItem *page = find(window->contentItem(), false); + QVERIFY(page); + QCOMPARE(page->property("font").value().pixelSize(), Previewer::cardPixels); + } + void pluginPollsArentHeldUpByActions() { QTemporaryDir folder, home; @@ -4929,6 +5361,144 @@ 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 sidebarDeleteUnpinsOnlyPinnedRows() + { + // Delete unpins the keyed pinned row wherever that section sits, and leaves Recents and Trash alone. + QTemporaryDir home, files; + for (const char *dir : {"Work/folio", "Work/hype"}) + QDir(home.path()).mkpath(dir); + QDir(files.path()).mkpath("plugins"); + const auto dataHome = dataHomeAt(files.filePath("data")); + // The Trash view's own folder, pinned too, so a Delete that reached the views would unpin it. + const QString trashFolder = QDir::cleanPath(FileOps::homeTrash() + "/files"); + QVERIFY(QDir().mkpath(trashFolder)); + Places places(home.path()); + QCOMPARE(places.pin(home.filePath("Work/folio")), QString()); + QCOMPARE(places.pin(home.filePath("Work/hype")), QString()); + QCOMPARE(places.pin(trashFolder), QString()); + Plugins plugins("en", {files.filePath("plugins")}, files.filePath("folio.conf")); + Backend backend(home.path()); + backend.navigate(home.path()); + Theme theme(files.filePath("theme")); + FileOps fileOps; + QQmlEngine engine; + engine.rootContext()->setContextProperty("backend", &backend); + engine.rootContext()->setContextProperty("places", &places); + engine.rootContext()->setContextProperty("plugins", &plugins); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("fileOps", &fileOps); + engine.rootContext()->setContextProperty("translator", QVariantMap{{"language", "en"}}); + QQmlComponent component(&engine); + component.setData("import QtQuick\n" + "Window {\n" + " width: 300; height: 900; visible: true\n" + " Sidebar { id: sidebar; anchors.fill: parent }\n" + " property alias sidebar: sidebar\n" + " function keyed() { return sidebar.rows[sidebar.cursor].path }\n" + "}\n", + QUrl::fromLocalFile(sourceFolder() + "/SidebarDeleteTest.qml")); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + auto *window = qobject_cast(root.get()); + window->requestActivate(); + QVERIFY(QTest::qWaitForWindowFocused(window)); + auto *sidebar = window->property("sidebar").value(); + const auto keyed = [&] { + QVariant path; + QMetaObject::invokeMethod(window, "keyed", Q_RETURN_ARG(QVariant, path)); + return path.toString(); + }; + const auto rows = [&] { return sidebar->property("rows").toList().size(); }; + const auto at = [&](const char *name) { return sidebar->property(name).toInt(); }; + const auto pinnedPath = [&](int i) { return places.pinned().at(i).toMap().value("path").toString(); }; + // At most one key per row, so a row the keys cannot reach fails instead of hanging. + const auto moveTo = [&](int row) { + for (int step = 0; step < rows() && at("cursor") != row; ++step) + QTest::keyClick(window, at("cursor") < row ? Qt::Key_Down : Qt::Key_Up); + return at("cursor") == row; + }; + + QVERIFY(QMetaObject::invokeMethod(sidebar, "focusSidebar")); + QVERIFY(sidebar->property("activeFocus").toBool()); + QCOMPARE(at("cursor"), 0); + QCOMPARE(places.views().size(), 2); + QCOMPARE(places.views().at(1).toMap().value("path").toString(), trashFolder); + QCOMPARE(places.pinned().size(), 3); + + // Recents and Trash are views, not pins. + const int before = rows(); + for (int view = 0; view < 2; ++view) { + QVERIFY(moveTo(at("viewsAt") + view)); + QCOMPARE(keyed(), places.views().at(view).toMap().value("path").toString()); + QTest::keyClick(window, Qt::Key_Delete); + QCOMPARE(places.pinned().size(), 3); + QVERIFY(places.isPinned(trashFolder)); + QCOMPARE(rows(), before); + } + + // The last pinned row, then the first: both ends of the range. + const QString first = pinnedPath(0), middle = pinnedPath(1), last = pinnedPath(2); + QVERIFY(moveTo(at("pinnedAt") + 2)); + QCOMPARE(keyed(), last); + QTest::keyClick(window, Qt::Key_Delete); + QVERIFY(!places.isPinned(last)); + QVERIFY(moveTo(at("pinnedAt"))); + QCOMPARE(keyed(), first); + QTest::keyClick(window, Qt::Key_Delete); + QVERIFY(!places.isPinned(first)); + QVERIFY(places.isPinned(middle)); + } + void formattingOffersWhatThisMachineCanMake() { // exFAT first, and only the filesystems whose tools are installed. @@ -5793,6 +6363,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,7 +6702,538 @@ 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. + QTemporaryDir home; + Theme theme(home.filePath("theme")); + FileOps ops; + const std::unique_ptr translator(QQmlPropertyMap::create()); + const std::unique_ptr backend(QQmlPropertyMap::create()); + FolderModel folder; + translator->insert("language", "en"); + backend->insert("folder", QVariant::fromValue(&folder)); + QQmlEngine engine; + engine.rootContext()->setContextProperty("fileOps", &ops); + engine.rootContext()->setContextProperty("theme", &theme); + 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"))); + } + + void folderSearchLimitIsWhatTheSearchStopsAt() + { + // The search field reads a count at this limit as "or more", so it must be the one the search uses. + QTemporaryDir dir; + for (int i = 0; i < FolderModel::maxSearchResults + 1; ++i) + write(dir.filePath(QString("match-%1.txt").arg(i)), ""); + // Through the model's own query, the count stops at the limit the field reads. + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), model.property("searchLimit").toInt()); + } + + void themeFloorsTheSecondaryInk() + { + // Rose Pine Dawn's mix draws at 3.2:1 and under, Gruvbox's at 3.6:1 on a tile; Catppuccin Mocha's already reads. + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + for (const char *palette : {roseDawn, "background = \"#282828\"\ndark_background = \"#1e1e1e\"\n" + "lighter_background = \"#3c3836\"\nforeground = \"#d4be98\"\n"}) { + write(dir.filePath("theme/colors.toml"), palette); + Theme low(dir.path()); + for (const QColor &ground : {low.background(), low.lighterBackground(), low.darkBackground()}) + QVERIFY(contrast(low.secondaryForeground(), ground) >= 4.5); + QVERIFY(snug(low.secondaryForeground(), {low.background(), low.lighterBackground(), low.darkBackground()}, 4.5)); + } + write(dir.filePath("theme/colors.toml"), mocha); + Theme passing(dir.path()); + QCOMPARE(passing.secondaryForeground(), blend(QColor("#1e1e2e"), QColor("#cdd6f4"), 0.68)); + } + + void themeFloorsAccentMarks() + { + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + write(dir.filePath("theme/colors.toml"), roseDawn); + Theme low(dir.path()); + for (const QColor &ground : {low.background(), low.lighterBackground(), low.darkBackground()}) + QVERIFY(contrast(low.accentMark(), ground) >= 3); + QVERIFY(snug(low.accentMark(), {low.background(), low.lighterBackground(), low.darkBackground()}, 3)); + QCOMPARE(low.accent(), QColor("#56949f")); + write(dir.filePath("theme/colors.toml"), mocha); + Theme passing(dir.path()); + QCOMPARE(passing.accentMark(), QColor("#89b4fa")); + } + + void themeFloorsTheQuietRing() + { + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + write(dir.filePath("theme/colors.toml"), roseDawn); + Theme low(dir.path()); + QCOMPARE(low.quietMark().alpha(), 255); + QVERIFY(contrast(low.quietMark(), low.background()) >= 3); + QVERIFY(contrast(low.quietMark(), low.lighterBackground()) >= 3); + QVERIFY(snug(low.quietMark(), {low.background(), low.lighterBackground()}, 3)); + // A theme where it reads keeps the translucent foreground, so it blends with a card as before. + write(dir.filePath("theme/colors.toml"), mocha); + Theme passing(dir.path()); + QColor ring("#cdd6f4"); + ring.setAlphaF(0.6); + QCOMPARE(passing.quietMark(), ring); + } + + void themeFloorsTheFolderCard() + { + // Rose Pine Dawn's card is 1.1:1 on its background; White's already stands off at 1.8:1. + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + write(dir.filePath("theme/colors.toml"), roseDawn); + Theme low(dir.path()); + QVERIFY(contrast(low.folderFill(), low.background()) >= 1.4); + QVERIFY(snug(low.folderFill(), {low.background()}, 1.4)); + // Tiles and panels keep the theme's own lighter background. + QCOMPARE(low.lighterBackground(), QColor("#f2e9e1")); + write(dir.filePath("theme/colors.toml"), "mode = \"light\"\nbackground = \"#ffffff\"\nlighter_background = \"#c0c0c0\"\nforeground = \"#000000\"\n"); + Theme passing(dir.path()); + QCOMPARE(passing.folderFill(), QColor("#c0c0c0")); + } + + void themeFloorsTheDisabledGlyph() + { + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + write(dir.filePath("theme/colors.toml"), roseDawn); + Theme low(dir.path()); + QVERIFY(contrast(low.disabledForeground(), low.background()) >= 3); + QVERIFY(snug(low.disabledForeground(), {low.background()}, 3)); + // Still dimmer than an off toggle. + QVERIFY(contrast(low.disabledForeground(), low.background()) < contrast(low.secondaryForeground(), low.background()) - 1); + write(dir.filePath("theme/colors.toml"), mocha); + Theme passing(dir.path()); + QCOMPARE(passing.disabledForeground(), blend(QColor("#1e1e2e"), QColor("#cdd6f4"), 0.5)); + } + + void themeFloorsRedText() + { + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + write(dir.filePath("theme/colors.toml"), roseDawn); + Theme low(dir.path()); + QVERIFY(contrast(low.redText(), low.background()) >= 4.5); + QVERIFY(snug(low.redText(), {low.background()}, 4.5)); + // Fills and the confirm card's edge keep the theme's own red. + QCOMPARE(low.red(), QColor("#b4637a")); + write(dir.filePath("theme/colors.toml"), mocha); + Theme passing(dir.path()); + QCOMPARE(passing.redText(), QColor("#f38ba8")); + } + + void themeFloorsAccentText() + { + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + write(dir.filePath("theme/colors.toml"), roseDawn); + Theme low(dir.path()); + const QColor pinTint = blend(low.background(), low.accent(), 0.22); + for (const QColor &ground : {low.background(), low.darkBackground(), pinTint}) + QVERIFY(contrast(low.accentText(), ground) >= 4.5); + QVERIFY(snug(low.accentText(), {low.background(), low.darkBackground(), pinTint}, 4.5)); + QCOMPARE(low.accent(), QColor("#56949f")); + write(dir.filePath("theme/colors.toml"), mocha); + Theme passing(dir.path()); + QCOMPARE(passing.accentText(), QColor("#89b4fa")); + } + + void themeFloorsYellowText() + { + // Rose Pine Dawn's gold is 2.1:1 on its background; Catppuccin Mocha's yellow already reads. + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + write(dir.filePath("theme/colors.toml"), QByteArray(roseDawn) + "yellow = \"#ea9d34\"\n"); + Theme low(dir.path()); + QVERIFY(contrast(low.yellowText(), low.background()) >= 4.5); + QVERIFY(snug(low.yellowText(), {low.background()}, 4.5)); + QCOMPARE(low.yellow(), QColor("#ea9d34")); + write(dir.filePath("theme/colors.toml"), QByteArray(mocha) + "yellow = \"#f9e2af\"\n"); + Theme passing(dir.path()); + QCOMPARE(passing.yellowText(), QColor("#f9e2af")); + } + + void themeWritesAGreyRedInTheForeground() + { + // Vantablack's red is a grey that reads at 8.4:1, Solitude's a grey at 2.8:1; both write in the foreground. + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + write(dir.filePath("theme/colors.toml"), "background = \"#000000\"\nforeground = \"#ffffff\"\nred = \"#a4a4a4\"\n"); + Theme vantablack(dir.path()); + QCOMPARE(vantablack.redText(), QColor("#ffffff")); + QCOMPARE(vantablack.red(), QColor("#a4a4a4")); + write(dir.filePath("theme/colors.toml"), "background = \"#101315\"\nforeground = \"#cacccc\"\nred = \"#565d60\"\n"); + Theme solitude(dir.path()); + QCOMPARE(solitude.redText(), QColor("#cacccc")); + // A red with colour keeps the floor: Miasma's #685742 (saturation 0.37) is raised, not replaced. + write(dir.filePath("theme/colors.toml"), "background = \"#222222\"\nforeground = \"#c2c2b0\"\nred = \"#685742\"\n"); + Theme miasma(dir.path()); + QVERIFY(miasma.redText() != miasma.foreground()); + QVERIFY(contrast(miasma.redText(), miasma.background()) >= 4.5); + QVERIFY(snug(miasma.redText(), {miasma.background()}, 4.5)); + } + + void themeWritesBadgesThatReadOnEveryKindColor() + { + // Every stock Omarchy theme (quattro at ceb30ed, plus osaka-jade): its kind colors and the "other" kind's ink. + const char *palettes[] = { + "background=#1e1e2e foreground=#cdd6f4 red=#f38ba8 blue=#89b4fa magenta=#f5c2e7 orange=#f6b6ab yellow=#f9e2af cyan=#94e2d5", // catppuccin + "background=#eff1f5 foreground=#4c4f69 red=#d20f39 blue=#1e66f5 magenta=#ea76cb orange=#d84e2b yellow=#df8e1d cyan=#179299", // catppuccin-latte + "background=#060B1E foreground=#ffcead red=#ED5B5A blue=#7d82d9 magenta=#c89dc1 orange=#eb8b54 yellow=#E9BB4F cyan=#a3bfd1", // ethereal + "background=#2d353b foreground=#d3c6aa red=#e67e80 blue=#7fbbb3 magenta=#d699b6 orange=#e09d7f yellow=#dbbc7f cyan=#83c092", // everforest + "background=#FFFCF0 foreground=#100F0F red=#D14D41 blue=#205EA6 magenta=#CE5D97 orange=#d0772b yellow=#D0A215 cyan=#3AA99F", // flexoki-light + "background=#282828 foreground=#d4be98 red=#ea6962 blue=#7daea3 magenta=#d3869b orange=#e1875c yellow=#d8a657 cyan=#89b482", // gruvbox + "background=#0B0C16 foreground=#ddf7ff red=#50f872 blue=#829dd4 magenta=#86a7df orange=#50f7a3 yellow=#50f7d4 cyan=#7cf8f7", // hackerman + "background=#142620 foreground=#dfede5 red=#d88d91 blue=#96b7d0 magenta=#b7a1c8 orange=#d2ab8d yellow=#c5cc91 cyan=#86c7c5", // jade-horse + "background=#1f1f28 foreground=#dcd7ba red=#c34043 blue=#7e9cd8 magenta=#957fb8 orange=#c17158 yellow=#c0a36e cyan=#6a9589", // kanagawa + "background=#0c0b0c foreground=#FAFCFB red=#c38b7b blue=#b59790 magenta=#c4d8e2 yellow=#6B5E73 cyan=#a5a0b6", // last-horizon + "background=#16242d foreground=#d6e2ee red=#4d86b0 blue=#6fb8e3 magenta=#8bc9eb orange=#8bc9eb yellow=#6fa4c9 cyan=#b4e4f6", // lumon + "background=#fafafa foreground=#212121 red=#c900c4 blue=#3264eb magenta=#8a4ad7 orange=#026fde yellow=#026fde cyan=#0c67de", // lupine + "background=#121212 foreground=#bebebe red=#D35F5F blue=#e68e0d magenta=#D35F5F orange=#c63d3d yellow=#b91c1c cyan=#bebebe", // matte-black + "background=#222222 foreground=#c2c2b0 red=#685742 blue=#78824b magenta=#bb7744 orange=#8d6242 yellow=#b36d43 cyan=#c9a554", // miasma + "background=#2e3440 foreground=#d8dee9 red=#bf616a blue=#81a1c1 magenta=#b48ead orange=#d5967a yellow=#ebcb8b cyan=#88c0d0", // nord + "background=#111c18 foreground=#C1C497 red=#FF5345 blue=#509475 magenta=#D2689C orange=#a2734b yellow=#459451 cyan=#2DD5B7", // osaka-jade + "background=#05182e foreground=#f6dcac red=#f85525 blue=#3f8f8a magenta=#3f8f8a orange=#faa968 yellow=#e97b3c cyan=#8cbfb8", // retro-82 + "background=#2c2525 foreground=#e6d9db red=#fd6883 blue=#f38d70 magenta=#a8a9eb orange=#fb9a77 yellow=#f9cc6c cyan=#85dacc", // ristretto + "background=#faf4ed foreground=#575279 red=#b4637a blue=#56949f magenta=#907aa9 orange=#cf8057 yellow=#ea9d34 cyan=#d7827e", // rose-pine + "background=#101315 foreground=#cacccc red=#565d60 blue=#798186 magenta=#aeaeae yellow=#d9dbdc cyan=#707070", // solitude + "background=#1c1c1e foreground=#f5f5f7 red=#ff5f4a blue=#8fb8de magenta=#ff8fa3 orange=#ff6a3d yellow=#ffc27a cyan=#a6dcef", // starship + "background=#1a1b26 foreground=#a9b1d6 red=#f7768e blue=#7aa2f7 magenta=#ad8ee6 orange=#eb927b yellow=#e0af68 cyan=#449dab", // tokyo-night + "background=#000000 foreground=#ffffff red=#a4a4a4 blue=#8d8d8d magenta=#9b9b9b orange=#b9b9b9 yellow=#cecece cyan=#b0b0b0", // vantablack + "background=#ffffff foreground=#000000 red=#2a2a2a blue=#1a1a1a magenta=#2e2e2e yellow=#4a4a4a cyan=#3e3e3e", // white + }; + // Flexoki Light's yellow is one fill where the background fails and the foreground reads; Rose Pine's needs a raised ink. + int foregroundInks = 0, raisedInks = 0; + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + for (const char *palette : palettes) { + QByteArray toml; + for (const QByteArray &pair : QByteArray(palette).split(' ')) { + const QList parts = pair.split('='); + toml += parts[0] + " = \"" + parts[1] + "\"\n"; + } + write(dir.filePath("theme/colors.toml"), toml); + Theme theme(dir.path()); + for (const QColor &fill : {theme.red(), theme.blue(), theme.magenta(), theme.orange(), theme.yellow(), + theme.cyan(), theme.secondaryForeground()}) { + const QColor ink = theme.inkOn(fill); + QVERIFY2(contrast(ink, fill) >= 4.5, palette); + if (contrast(theme.background(), fill) >= 4.5) { + QCOMPARE(ink, theme.background()); + } else if (contrast(theme.foreground(), fill) >= 4.5) { + QCOMPARE(ink, theme.foreground()); + ++foregroundInks; + } else { + QVERIFY2(snug(ink, {fill}, 4.5), palette); + ++raisedInks; + } + } + } + QVERIFY(foregroundInks > 0); + QVERIFY(raisedInks > 0); + } + + void themeAsksForGrayTextOnlyWhileTheFirstWindowIsMade() + { + const char *name = "QSG_DISTANCEFIELD_ANTIALIASING"; + const bool wasSet = qEnvironmentVariableIsSet(name); + const QByteArray was = qgetenv(name); + qunsetenv(name); + QByteArray seen; + Theme::grayText([&] { + seen = qgetenv(name); + QQuickWindow window; + }); + QCOMPARE(seen, QByteArray("gray")); + QVERIFY(!qEnvironmentVariableIsSet(name)); + + // A value set before Folio starts stays, during the first window and after. + qputenv(name, "subpixel"); + Theme::grayText([&] { seen = qgetenv(name); }); + QCOMPARE(seen, QByteArray("subpixel")); + QCOMPARE(qgetenv(name), QByteArray("subpixel")); + if (wasSet) + qputenv(name, was); + else + qunsetenv(name); + } + + void folderMatchStartIsWhereTheSearchMatched() + { + QTemporaryDir dir; + QDir(dir.path()).mkpath("Shots"); + write(dir.filePath("Field-NOTES.md"), "x"); + write(dir.filePath("Shots/notes and notes.txt"), "x"); + write(dir.filePath(QString::fromUtf8("\u00c9T\u00c9 notes.txt")), "x"); + write(dir.filePath("plan.txt"), "x"); + + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + // Nothing is lit until there is a query. + QCOMPARE(model.matchStart("Field-NOTES.md"), -1); + model.setQuery("Notes"); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), 3); + // Every result lights where the search found it, ignoring case, at the first match. + for (int row = 0; row < model.count(); ++row) + QVERIFY(model.matchStart(model.get(row).value("fileName").toString()) >= 0); + QCOMPARE(model.matchStart("Field-NOTES.md"), 6); + QCOMPARE(model.matchStart("notes and notes.txt"), 0); + QCOMPARE(model.matchStart("plan.txt"), -1); + model.setQuery(QString::fromUtf8("\u00e9t\u00e9")); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), 1); + QCOMPARE(model.matchStart(QString::fromUtf8("\u00c9T\u00c9 notes.txt")), 0); + } + + void appsForCarryTheirIcons() + { + // Open With draws each app's Icon= from the pass that lists the apps; one without keeps no icon. + const QString apps = gioHome.filePath("data/applications"); + write(apps + "/folio-test-viewer.desktop", "[Desktop Entry]\nType=Application\nName=Folio Test Viewer\n" + "Exec=true %f\nIcon=folio-test-viewer\nMimeType=text/markdown;\n"); + write(apps + "/folio-test-bare.desktop", "[Desktop Entry]\nType=Application\nName=Folio Test Bare\n" + "Exec=true %f\nMimeType=text/markdown;\n"); + // GIO finds an app by type through the cache update-desktop-database writes. + write(apps + "/mimeinfo.cache", "[MIME Cache]\ntext/markdown=folio-test-viewer.desktop;folio-test-bare.desktop;\n"); + QTemporaryDir dir; + write(dir.filePath("notes.md"), "# notes"); + FileOps ops; + auto iconOf = [&](const QString &id) { + for (const QVariant &app : ops.appsFor(dir.filePath("notes.md"))) + if (app.toMap().value("id") == id) + return app.toMap().value("icon").toString(); + return QString("missing"); + }; + QTRY_COMPARE(iconOf("folio-test-viewer.desktop"), QString("folio-test-viewer")); + QCOMPARE(iconOf("folio-test-bare.desktop"), QString()); + } + + void appIconsComeFromTheThemeOrAPath() + { + // A name the theme lacks leaves the column blank; a path, encoded as FileMenu encodes it, loads that file. + AppIconProvider provider; + QSize size; + const QImage blank = provider.requestImage("folio-test-no-such-icon", &size, QSize()); + QCOMPARE(blank.size(), QSize(16, 16)); + QCOMPARE(size, QSize(16, 16)); + for (int y = 0; y < blank.height(); ++y) + for (int x = 0; x < blank.width(); ++x) + QCOMPARE(qAlpha(blank.pixel(x, y)), 0); + + QTemporaryDir dir; + const QString path = dir.filePath("app icon.png"); + QImage red(48, 48, QImage::Format_ARGB32); + red.fill(Qt::red); + QVERIFY(red.save(path)); + const QString id = QString::fromUtf8(QUrl::toPercentEncoding(path)); + const QImage icon = provider.requestImage(id, &size, QSize(16, 16)); + QCOMPARE(icon.size(), QSize(16, 16)); + QCOMPARE(icon.pixelColor(8, 8), QColor(Qt::red)); + QCOMPARE(provider.requestImage(id, &size, QSize(24, 24)).size(), QSize(24, 24)); + QCOMPARE(size, QSize(24, 24)); + } + private: + QTemporaryDir gioHome; + // Packs `items`, from `folder`, into `archive`, its kind from its name. static void pack(const QString &archive, const QString &folder, const QStringList &items) { @@ -6086,6 +7260,38 @@ private slots: QVERIFY(file.open(QIODevice::WriteOnly)); file.write(contents); } + + // Stock Omarchy palettes: Rose Pine (Dawn, light) and Catppuccin (Mocha, dark). + static constexpr const char *roseDawn = "mode = \"light\"\naccent = \"#56949f\"\nbackground = \"#faf4ed\"\n" + "dark_background = \"#ede7e1\"\nlighter_background = \"#f2e9e1\"\nforeground = \"#575279\"\nred = \"#b4637a\"\n"; + static constexpr const char *mocha = "accent = \"#89b4fa\"\nbackground = \"#1e1e2e\"\ndark_background = \"#161622\"\n" + "lighter_background = \"#313244\"\nforeground = \"#cdd6f4\"\nred = \"#f38ba8\"\n"; + + static double contrast(const QColor &a, const QColor &b) + { + auto luminance = [](const QColor &c) { + auto linear = [](double v) { return v <= .04045 ? v / 12.92 : std::pow((v + .055) / 1.055, 2.4); }; + return .2126 * linear(c.redF()) + .7152 * linear(c.greenF()) + .0722 * linear(c.blueF()); + }; + const double x = luminance(a), y = luminance(b); + return (std::max(x, y) + .05) / (std::min(x, y) + .05); + } + + // A floored ink is the least that reads: it sits within 0.3 of the floor on at least one of its grounds. + static bool snug(const QColor &ink, std::initializer_list grounds, double need) + { + for (const QColor &ground : grounds) + if (contrast(ink, ground) < need + 0.3) + return true; + return false; + } + + // Theme's own mix, unrounded, so a passing theme is pinned to the exact color it had. + static QColor blend(const QColor &a, const QColor &b, double t) + { + return QColor::fromRgbF(a.redF() * (1 - t) + b.redF() * t, a.greenF() * (1 - t) + b.greenF() * t, + a.blueF() * (1 - t) + b.blueF() * t); + } }; QTEST_MAIN(FolioTests)