From 22b843587c25c81b8c777f53bacd1bdcfb0bc508 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Thu, 25 Sep 2025 20:59:50 -0400 Subject: [PATCH] FilePicker: back to our custom file picker. --- Commons/Settings.qml | 4 +- Commons/TablerIcons.qml | 27 +- .../Settings/Bar/BarWidgetSettingsDialog.qml | 4 - .../WidgetSettings/ControlCenterSettings.qml | 15 +- Modules/Settings/Tabs/GeneralTab.qml | 15 +- Modules/Settings/Tabs/ScreenRecorderTab.qml | 11 +- Modules/Settings/Tabs/WallpaperTab.qml | 20 +- Services/WallpaperService.qml | 1 + Widgets/NFilePicker.qml | 962 ++++++++++++++---- 9 files changed, 864 insertions(+), 195 deletions(-) diff --git a/Commons/Settings.qml b/Commons/Settings.qml index e7279585..0aaee7e3 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -467,7 +467,9 @@ Singleton { if (!gotControlCenter) { //const obj = JSON.parse('{"id": "ControlCenter"}'); - adapter.bar.widgets["right"].push(({"id": "ControlCenter"})) + adapter.bar.widgets["right"].push(({ + "id": "ControlCenter" + })) Logger.warn("Settings", "Added a ControlCenter widget to the right section") } } diff --git a/Commons/TablerIcons.qml b/Commons/TablerIcons.qml index 9fa469ed..c2154b0f 100644 --- a/Commons/TablerIcons.qml +++ b/Commons/TablerIcons.qml @@ -122,7 +122,32 @@ Singleton { "bt-device-watch": "device-watch", "bt-device-speaker": "device-speaker", "bt-device-tv": "device-tv", - "noctalia": "noctalia" + "noctalia": "noctalia", + "hyprland": "hyprland", + "filepicker-folder": "folder", + "filepicker-refresh": "refresh", + "filepicker-close": "x", + "filepicker-arrow-left": "arrow-left", + "filepicker-arrow-up": "arrow-up", + "filepicker-home": "home", + "filepicker-layout-grid": "layout-grid", + "filepicker-list": "list", + "filepicker-search": "search", + "filepicker-x": "x", + "filepicker-photo": "photo", + "filepicker-check": "check", + "filepicker-file-text": "file-text", + "filepicker-video": "video", + "filepicker-music": "music", + "filepicker-archive": "archive", + "filepicker-table": "table", + "filepicker-presentation": "presentation", + "filepicker-code": "code", + "filepicker-settings": "settings", + "filepicker-file": "file", + "filepicker-text": "file-text", + "filepicker-eye": "eye", + "filepicker-eye-off": "eye-off" } // Fonts Codepoints - do not change! diff --git a/Modules/Settings/Bar/BarWidgetSettingsDialog.qml b/Modules/Settings/Bar/BarWidgetSettingsDialog.qml index cc7fe1f9..68310021 100644 --- a/Modules/Settings/Bar/BarWidgetSettingsDialog.qml +++ b/Modules/Settings/Bar/BarWidgetSettingsDialog.qml @@ -15,8 +15,6 @@ Popup { property var widgetData: null property string widgetId: "" - property bool isMasked: false - // Center popup in parent x: (parent.width - width) * 0.5 y: (parent.height - height) * 0.5 @@ -43,7 +41,6 @@ Popup { background: Rectangle { id: bgRect - opacity: widgetSettings.isMasked ? 0 : 1.0 color: Color.mSurface radius: Style.radiusL * scaling border.color: Color.mPrimary @@ -53,7 +50,6 @@ Popup { contentItem: ColumnLayout { id: content - opacity: widgetSettings.isMasked ? 0 : 1.0 width: parent.width spacing: Style.marginM * scaling diff --git a/Modules/Settings/Bar/WidgetSettings/ControlCenterSettings.qml b/Modules/Settings/Bar/WidgetSettings/ControlCenterSettings.qml index c3dcabfc..75aacb97 100644 --- a/Modules/Settings/Bar/WidgetSettings/ControlCenterSettings.qml +++ b/Modules/Settings/Bar/WidgetSettings/ControlCenterSettings.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import Quickshell import qs.Commons import qs.Widgets import qs.Services @@ -73,7 +74,7 @@ ColumnLayout { NButton { enabled: !valueUseDistroLogo text: I18n.tr("bar.widget-settings.control-center.browse-file") - onClicked: filePicker.open() + onClicked: imagePicker.openFilePicker() } } @@ -87,8 +88,16 @@ ColumnLayout { } NFilePicker { - id: filePicker + id: imagePicker title: I18n.tr("bar.widget-settings.control-center.select-custom-icon") - onAccepted: paths => valueCustomIconPath = paths[0] + selectFiles: true + selectFolders: false + nameFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.pnm", "*.bmp"] + initialPath: Quickshell.env("HOME") + onAccepted: paths => { + if (paths.length > 0) { + valueCustomIconPath = paths[0] // Use first selected file + } + } } } diff --git a/Modules/Settings/Tabs/GeneralTab.qml b/Modules/Settings/Tabs/GeneralTab.qml index edd0fc67..c2e08693 100644 --- a/Modules/Settings/Tabs/GeneralTab.qml +++ b/Modules/Settings/Tabs/GeneralTab.qml @@ -41,18 +41,23 @@ ColumnLayout { buttonTooltip: "Browse for avatar image" onInputEditingFinished: Settings.data.general.avatarImage = text onButtonClicked: { - filePicker.open() + avatarPicker.openFilePicker() } } } NFilePicker { - id: filePicker - pickerType: "file" + id: avatarPicker title: I18n.tr("settings.general.profile.select-avatar") + selectFiles: true + selectFolders: true initialPath: Settings.data.general.avatarImage.substr(0, Settings.data.general.avatarImage.lastIndexOf("/")) || Quickshell.env("HOME") - nameFilters: ["Image files (*.jpg *.jpeg *.png *.gif *.pnm *.bmp *.face)", "All files (*)"] - onAccepted: paths => Settings.data.general.avatarImage = paths[0] + nameFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.pnm", "*.bmp"] + onAccepted: paths => { + if (paths.length > 0) { + Settings.data.general.avatarImage = paths[0] + } + } } NDivider { diff --git a/Modules/Settings/Tabs/ScreenRecorderTab.qml b/Modules/Settings/Tabs/ScreenRecorderTab.qml index cc194a84..cdd2be99 100644 --- a/Modules/Settings/Tabs/ScreenRecorderTab.qml +++ b/Modules/Settings/Tabs/ScreenRecorderTab.qml @@ -29,7 +29,7 @@ ColumnLayout { buttonIcon: "folder-open" buttonTooltip: I18n.tr("settings.screen-recorder.general.output-folder.tooltip") onInputEditingFinished: Settings.data.screenRecorder.directory = text - onButtonClicked: folderPicker.open() + onButtonClicked: folderPicker.openFilePicker() } // Show Cursor @@ -229,9 +229,14 @@ ColumnLayout { NFilePicker { id: folderPicker - pickerType: "folder" + selectFiles: false + selectFolders: true title: I18n.tr("settings.screen-recorder.general.select-output-folder") initialPath: Settings.data.screenRecorder.directory || Quickshell.env("HOME") + "/Videos" - onAccepted: paths => Settings.data.screenRecorder.directory = paths[0] + onAccepted: paths => { + if (paths.length > 0) { + Settings.data.screenRecorder.directory = paths[0] // Use first selected file + } + } } } diff --git a/Modules/Settings/Tabs/WallpaperTab.qml b/Modules/Settings/Tabs/WallpaperTab.qml index aff20c32..fa22c89a 100644 --- a/Modules/Settings/Tabs/WallpaperTab.qml +++ b/Modules/Settings/Tabs/WallpaperTab.qml @@ -340,15 +340,27 @@ ColumnLayout { NFilePicker { id: mainFolderPicker - pickerType: "folder" + selectFiles: false + selectFolders: true title: I18n.tr("settings.wallpaper.settings.select-folder") - onAccepted: paths => Settings.data.wallpaper.directory = paths[0] + initialPath: Settings.data.wallpaper.directory || Quickshell.env("HOME") + "/Pictures" + onAccepted: paths => { + if (paths.length > 0) { + Settings.data.wallpaper.directory = paths[0] + } + } } NFilePicker { id: monitorFolderPicker - pickerType: "folder" + selectFiles: false + selectFolders: true title: I18n.tr("settings.wallpaper.settings.select-monitor-folder") - onAccepted: paths => WallpaperService.setMonitorDirectory(specificFolderMonitorName, paths[0]) + initialPath: WallpaperService.getMonitorDirectory(specificFolderMonitorName) || Quickshell.env("HOME") + "/Pictures" + onAccepted: paths => { + if (paths.length > 0) { + WallpaperService.setMonitorDirectory(specificFolderMonitorName, paths[0]) + } + } } } diff --git a/Services/WallpaperService.qml b/Services/WallpaperService.qml index b09a7839..584c2e94 100644 --- a/Services/WallpaperService.qml +++ b/Services/WallpaperService.qml @@ -63,6 +63,7 @@ Singleton { } } + // TODO Translate readonly property ListModel fillModeModel: ListModel { // Centers image without resizing // Pads with fillColor if image is smaller than screen diff --git a/Widgets/NFilePicker.qml b/Widgets/NFilePicker.qml index 54072943..b1b8e90d 100644 --- a/Widgets/NFilePicker.qml +++ b/Widgets/NFilePicker.qml @@ -1,206 +1,820 @@ -import QtCore import QtQuick -import QtQuick.Dialogs +import QtQuick.Layouts import QtQuick.Controls +import Qt.labs.folderlistmodel +import Quickshell +import Quickshell.Io import qs.Commons import qs.Services +import qs.Widgets +import "../Helpers/FuzzySort.js" as FuzzySort -Item { +Popup { id: root - // Public API Properties - property string initialPath: "" + // Properties + property string title: "File Picker" + property string initialPath: Quickshell.env("HOME") || "/home" + property bool selectFiles: true + property bool selectFolders: true + property var nameFilters: ["*"] + property bool showDirs: true + property bool showHiddenFiles: false + property real scaling: 1.0 property var selectedPaths: [] - property string selectedPath: "" - property bool multipleSelection: false - property string pickerType: "file" // "file" or "folder" - property var nameFilters: ["All files (*)"] // e.g., ["Image files (*.png *.jpg)", "Text files (*.txt)"] - property string title: pickerType === "folder" ? I18n.tr("widgets.file-picker.select-folder") : I18n.tr("widgets.file-picker.select-file") - property string acceptLabel: I18n.tr("placeholders.select") - property string rejectLabel: I18n.tr("placeholders.cancel") + property string currentPath: initialPath + property bool shouldResetSelection: false - // State properties - property bool isOpen: false - - // Signals for external connections + // Signals signal accepted(var paths) - signal rejected - signal pathSelected(string path) - signal pathsSelected(var paths) - signal beforeOpen - signal afterClose + signal cancelled - // Public functions - function open() { - beforeOpen() - - if (PanelService.openedPanel !== null) { - PanelService.openedPanel.isMasked = true - } - - for (var i = 0; i < PanelService.openedPopups.length; i++) { - PanelService.openedPopups[i].isMasked = true - } - - isOpen = true - - // Small delay to ensure panel changes happen first - Qt.callLater(function () { - if (pickerType === "folder") { - folderDialog.open() - } else { - fileDialog.open() - } - }) + onOpened: { + PanelService.willOpenPopup(root) } - function close() { - if (pickerType === "folder") { - folderDialog.close() - } else { - fileDialog.close() - } - - handleClose() + onClosed: { + PanelService.willClosePopup(root) } - function handleClose() { - isOpen = false - - if (PanelService.openedPanel !== null) { - PanelService.openedPanel.isMasked = false - } - - for (var i = 0; i < PanelService.openedPopups.length; i++) { - PanelService.openedPopups[i].isMasked = false - } - - afterClose() + function openFilePicker() { + if (!root.currentPath) + root.currentPath = root.initialPath + shouldResetSelection = true + open() } - function reset() { - selectedPaths = [] - selectedPath = "" + function getFileIcon(fileName) { + const ext = fileName.split('.').pop().toLowerCase() + const iconMap = { + "txt": 'filepicker-file-text', + "md": 'filepicker-file-text', + "log": 'filepicker-file-text', + "jpg": 'filepicker-photo', + "jpeg": 'filepicker-photo', + "png": 'filepicker-photo', + "gif": 'filepicker-photo', + "bmp": 'filepicker-photo', + "svg": 'filepicker-photo', + "mp4": 'filepicker-video', + "avi": 'filepicker-video', + "mkv": 'filepicker-video', + "mov": 'filepicker-video', + "mp3": 'filepicker-music', + "wav": 'filepicker-music', + "flac": 'filepicker-music', + "ogg": 'filepicker-music', + "zip": 'filepicker-archive', + "tar": 'filepicker-archive', + "gz": 'filepicker-archive', + "rar": 'filepicker-archive', + "7z": 'filepicker-archive', + "pdf": 'filepicker-text', + "doc": 'filepicker-text', + "docx": 'filepicker-text', + "xls": 'filepicker-table', + "xlsx": 'filepicker-table', + "ppt": 'filepicker-presentation', + "pptx": 'filepicker-presentation', + "html": 'filepicker-code', + "htm": 'filepicker-code', + "css": 'filepicker-code', + "js": 'filepicker-code', + "json": 'filepicker-code', + "xml": 'filepicker-code', + "exe": 'filepicker-settings', + "app": 'filepicker-settings', + "deb": 'filepicker-settings', + "rpm": 'filepicker-settings' + } + return iconMap[ext] || 'filepicker-file' } - // Helper function to set file extensions easily - function setFileExtensions(extensions) { - if (!extensions || extensions.length === 0) { - nameFilters = ["All files (*)"] + function formatFileSize(bytes) { + if (bytes === 0) + return "0 B" + const k = 1024, sizes = ["B", "KB", "MB", "GB", "TB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i] + } + + function confirmSelection() { + if (filePickerPanel.currentSelection.length === 0) return - } - var filters = [] - for (var i = 0; i < extensions.length; i++) { - var ext = extensions[i] - if (typeof ext === "string") { - // Simple extension like "png" - filters.push(ext.toUpperCase() + " files (*." + ext + ")") - } else if (typeof ext === "object" && ext.label && ext.extensions) { - // Complex filter like {label: "Images", extensions: ["png", "jpg", "jpeg"]} - var filterStr = ext.label + " (" - for (var j = 0; j < ext.extensions.length; j++) { - filterStr += "*." + ext.extensions[j] - if (j < ext.extensions.length - 1) - filterStr += " " + root.selectedPaths = filePickerPanel.currentSelection + root.accepted(filePickerPanel.currentSelection) + root.close() + } + + function updateFilteredModel() { + filteredModel.clear() + const searchText = filePickerPanel.filterText.toLowerCase() + + for (var i = 0; i < folderModel.count; i++) { + const fileName = folderModel.get(i, "fileName") + const filePath = folderModel.get(i, "filePath") + const fileIsDir = folderModel.get(i, "fileIsDir") + const fileSize = folderModel.get(i, "fileSize") + + if (root.selectFolders && !root.selectFiles && !fileIsDir) + continue + if (searchText === "" || fileName.toLowerCase().includes(searchText)) { + filteredModel.append({ + "fileName": fileName, + "filePath": filePath, + "fileIsDir": fileIsDir, + "fileSize": fileSize + }) + } + } + } + + width: 900 * scaling + height: 700 * scaling + modal: true + closePolicy: Popup.CloseOnEscape + anchors.centerIn: Overlay.overlay + + background: Rectangle { + color: Color.mSurfaceVariant + radius: Style.radiusL * scaling + border.color: Color.mOutline + border.width: Math.max(1, Style.borderS * scaling) + } + + Rectangle { + id: filePickerPanel + anchors.fill: parent + anchors.margins: Style.marginL * scaling + color: Color.transparent + + property string filterText: "" + property var currentSelection: [] + property bool viewMode: true // true = grid, false = list + property string searchText: "" + property bool showSearchBar: false + + focus: true + + Keys.onPressed: event => { + if (event.modifiers & Qt.ControlModifier && event.key === Qt.Key_F) { + filePickerPanel.showSearchBar = !filePickerPanel.showSearchBar + if (filePickerPanel.showSearchBar) + Qt.callLater(() => searchInput.forceActiveFocus()) + event.accepted = true + } else if (event.key === Qt.Key_Escape && filePickerPanel.showSearchBar) { + filePickerPanel.showSearchBar = false + filePickerPanel.searchText = "" + filePickerPanel.filterText = "" + root.updateFilteredModel() + event.accepted = true + } + } + + ColumnLayout { + anchors.fill: parent + spacing: Style.marginM * scaling + + // Header + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM * scaling + + NIcon { + icon: "filepicker-folder" + color: Color.mPrimary + font.pointSize: Style.fontSizeXXL * scaling + } + NText { + text: root.title + font.pointSize: Style.fontSizeXL * scaling + font.weight: Style.fontWeightBold + color: Color.mPrimary + Layout.fillWidth: true + } + NIconButton { + icon: "filepicker-refresh" + tooltipText: "Refresh" + onClicked: folderModel.refresh() + } + NIconButton { + icon: "filepicker-close" + tooltipText: "Close" + onClicked: { + root.cancelled() + root.close() + } + } + } + + NDivider { + Layout.fillWidth: true + } + + // Navigation toolbar + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 45 * scaling + color: Color.mSurfaceVariant + radius: Style.radiusS * scaling + border.color: Color.mOutline + border.width: Math.max(1, Style.borderS * scaling) + + RowLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.marginS * scaling + anchors.rightMargin: Style.marginS * scaling + spacing: Style.marginS * scaling + + NIconButton { + icon: "filepicker-arrow-up" + tooltipText: "Up" + baseSize: Style.baseWidgetSize * 0.8 + enabled: folderModel.folder.toString() !== "file:///" + onClicked: { + const parentPath = folderModel.parentFolder.toString().replace("file://", "") + folderModel.folder = "file://" + parentPath + root.currentPath = parentPath + } + } + + NIconButton { + icon: "filepicker-home" + tooltipText: "Home" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: { + const homePath = Quickshell.env("HOME") || "/home" + folderModel.folder = "file://" + homePath + root.currentPath = homePath + } + } + + NIconButton { + icon: filePickerPanel.viewMode ? "filepicker-list" : "filepicker-layout-grid" + tooltipText: filePickerPanel.viewMode ? "List View" : "Grid View" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: filePickerPanel.viewMode = !filePickerPanel.viewMode + } + + NIconButton { + icon: filePickerPanel.showSearchBar ? "filepicker-x" : "filepicker-search" + tooltipText: filePickerPanel.showSearchBar ? "Close Search" : "Search" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: { + filePickerPanel.showSearchBar = !filePickerPanel.showSearchBar + if (!filePickerPanel.showSearchBar) { + filePickerPanel.searchText = "" + filePickerPanel.filterText = "" + root.updateFilteredModel() + } + } + } + + NIconButton { + icon: root.showHiddenFiles ? "filepicker-eye-off" : "filepicker-eye" + tooltipText: root.showHiddenFiles ? "Hide Hidden Files" : "Show Hidden Files" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: { + root.showHiddenFiles = !root.showHiddenFiles + root.updateFilteredModel() + } + } + + NTextInput { + id: locationInput + text: root.currentPath + placeholderText: "Enter path..." + Layout.fillWidth: true + onEditingFinished: { + const newPath = text.trim() + if (newPath !== "" && newPath !== root.currentPath) { + folderModel.folder = "file://" + newPath + root.currentPath = newPath + } else { + text = root.currentPath + } + } + Connections { + target: root + function onCurrentPathChanged() { + if (!locationInput.activeFocus) + locationInput.text = root.currentPath + } + } + } + } + } + + // Search bar + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 45 * scaling + color: Color.mSurfaceVariant + radius: Style.radiusS * scaling + border.color: Color.mOutline + border.width: Math.max(1, Style.borderS * scaling) + visible: filePickerPanel.showSearchBar + + RowLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.marginS * scaling + anchors.rightMargin: Style.marginS * scaling + spacing: Style.marginS * scaling + + NIcon { + icon: "filepicker-search" + color: Color.mOnSurfaceVariant + font.pointSize: Style.fontSizeS * scaling + } + NTextInput { + id: searchInput + placeholderText: "Search files and folders..." + Layout.fillWidth: true + text: filePickerPanel.searchText + onTextChanged: { + filePickerPanel.searchText = text + filePickerPanel.filterText = text + root.updateFilteredModel() + } + Keys.onEscapePressed: { + filePickerPanel.showSearchBar = false + filePickerPanel.searchText = "" + filePickerPanel.filterText = "" + root.updateFilteredModel() + } + } + NIconButton { + icon: "filepicker-x" + tooltipText: "Clear" + baseSize: Style.baseWidgetSize * 0.6 + visible: filePickerPanel.searchText.length > 0 + onClicked: { + searchInput.text = "" + filePickerPanel.searchText = "" + filePickerPanel.filterText = "" + root.updateFilteredModel() + } + } + } + } + + // File list area + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: Color.mSurface + radius: Style.radiusM * scaling + border.color: Color.mOutline + border.width: Math.max(1, Style.borderS * scaling) + clip: true + + FolderListModel { + id: folderModel + folder: "file://" + root.currentPath + nameFilters: root.nameFilters + showDirs: root.showDirs + showHidden: root.showHiddenFiles + sortField: FolderListModel.Name + sortReversed: false + onFolderChanged: { + root.currentPath = folder.toString().replace("file://", "") + filePickerPanel.currentSelection = [] + } + onStatusChanged: { + if (status === FolderListModel.Error) { + if (root.currentPath !== Quickshell.env("HOME")) { + folder = "file://" + Quickshell.env("HOME") + root.currentPath = Quickshell.env("HOME") + } + } else if (status === FolderListModel.Ready) { + root.updateFilteredModel() + } + } + } + + ListModel { + id: filteredModel + } + + // Common scroll bar component + Component { + id: scrollBarComponent + ScrollBar { + policy: ScrollBar.AsNeeded + contentItem: Rectangle { + implicitWidth: 6 * scaling + implicitHeight: 100 + radius: Style.radiusM * scaling + color: parent.pressed ? Qt.alpha(Color.mTertiary, 0.8) : parent.hovered ? Qt.alpha(Color.mTertiary, 0.8) : Qt.alpha(Color.mTertiary, 0.8) + opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 1.0 : 0.0 + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + } + background: Rectangle { + implicitWidth: 6 * scaling + implicitHeight: 100 + color: Color.transparent + opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 0.3 : 0.0 + radius: (Style.radiusM * scaling) / 2 + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + } + } + } + + // Grid view + GridView { + id: gridView + anchors.fill: parent + anchors.margins: Style.marginM * scaling + model: filteredModel + visible: filePickerPanel.viewMode + clip: true + + property int columns: Math.max(1, Math.floor(width / (120 * scaling))) + property int itemSize: Math.floor((width - leftMargin - rightMargin - (columns * Style.marginS * scaling)) / columns) + + cellWidth: Math.floor((width - leftMargin - rightMargin) / columns) + cellHeight: Math.floor(itemSize * 0.8) + Style.marginXS * scaling + Style.fontSizeS * scaling + Style.marginM * scaling + + leftMargin: Style.marginS * scaling + rightMargin: Style.marginS * scaling + topMargin: Style.marginS * scaling + bottomMargin: Style.marginS * scaling + + ScrollBar.vertical: scrollBarComponent.createObject(gridView, { + "parent": gridView, + "x": gridView.mirrored ? 0 : gridView.width - width, + "y": 0, + "height": gridView.height + }) + + delegate: Rectangle { + id: gridItem + width: gridView.itemSize + height: gridView.cellHeight + color: Color.transparent + radius: Style.radiusM * scaling + + property bool isSelected: filePickerPanel.currentSelection.includes(model.filePath) + + Rectangle { + anchors.fill: parent + color: Color.transparent + radius: parent.radius + border.color: isSelected ? Color.mSecondary : Color.mSurface + border.width: Math.max(1, Style.borderL * scaling) + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + } + + Rectangle { + anchors.fill: parent + color: (mouseArea.containsMouse && !isSelected) ? Color.mTertiary : Color.transparent + radius: parent.radius + border.color: (mouseArea.containsMouse && !isSelected) ? Color.mTertiary : Color.transparent + border.width: Math.max(1, Style.borderS * scaling) + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + Behavior on border.color { + ColorAnimation { + duration: Style.animationFast + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginS * scaling + spacing: Style.marginXS * scaling + + Rectangle { + id: iconContainer + Layout.fillWidth: true + Layout.preferredHeight: Math.round(gridView.itemSize * 0.67) + color: Color.transparent + + property bool isImage: { + if (model.fileIsDir) + return false + const ext = model.fileName.split('.').pop().toLowerCase() + return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'].includes(ext) + } + + Image { + id: thumbnail + anchors.fill: parent + anchors.margins: Style.marginXS * scaling + source: iconContainer.isImage ? "file://" + model.filePath : "" + fillMode: Image.PreserveAspectFit + visible: iconContainer.isImage && status === Image.Ready + smooth: false + cache: true + asynchronous: true + sourceSize.width: 120 * scaling + sourceSize.height: 120 * scaling + onStatusChanged: { + if (status === Image.Error) + visible = false + } + + Rectangle { + anchors.fill: parent + color: Color.mSurfaceVariant + radius: Style.radiusS * scaling + visible: thumbnail.status === Image.Loading + NIcon { + icon: "filepicker-photo" + font.pointSize: Style.fontSizeL * scaling + color: Color.mOnSurfaceVariant + anchors.centerIn: parent + } + } + } + + NIcon { + icon: model.fileIsDir ? "filepicker-folder" : root.getFileIcon(model.fileName) + font.pointSize: Style.fontSizeXXL * 2 * scaling + color: { + if (isSelected) + return Color.mSecondary + else if (mouseArea.containsMouse) + return model.fileIsDir ? Color.mOnTertiary : Color.mOnTertiary + else + return model.fileIsDir ? Color.mPrimary : Color.mOnSurfaceVariant + } + anchors.centerIn: parent + visible: !iconContainer.isImage || thumbnail.status !== Image.Ready + } + + Rectangle { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Style.marginS * scaling + width: 24 * scaling + height: 24 * scaling + radius: width / 2 + color: Color.mSecondary + border.color: Color.mOutline + border.width: Math.max(1, Style.borderS * scaling) + visible: isSelected + NIcon { + icon: "filepicker-check" + font.pointSize: Style.fontSizeS * scaling + font.weight: Style.fontWeightBold + color: Color.mOnSecondary + anchors.centerIn: parent + } + } + } + + NText { + text: model.fileName + color: { + if (isSelected) + return Color.mSecondary + else if (mouseArea.containsMouse) + return Color.mOnTertiary + else + return Color.mOnSurface + } + font.pointSize: Style.fontSizeS * scaling + font.weight: isSelected ? Style.fontWeightBold : Style.fontWeightRegular + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + elide: Text.ElideRight + maximumLineCount: 2 + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + + onClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + if (model.fileIsDir) { + if (root.selectFolders && !root.selectFiles) { + filePickerPanel.currentSelection = [model.filePath] + } else { + folderModel.folder = "file://" + model.filePath + root.currentPath = model.filePath + } + } else { + if (root.selectFiles) + filePickerPanel.currentSelection = [model.filePath] + } + } + } + + onDoubleClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + if (model.fileIsDir) { + if (root.selectFolders && !root.selectFiles) { + filePickerPanel.currentSelection = [model.filePath] + root.confirmSelection() + } else { + folderModel.folder = "file://" + model.filePath + root.currentPath = model.filePath + } + } else { + if (root.selectFiles) { + filePickerPanel.currentSelection = [model.filePath] + root.confirmSelection() + } + } + } + } + } + } + } + + // List view + NListView { + id: listView + anchors.fill: parent + anchors.margins: Style.marginS * scaling + model: filteredModel + visible: !filePickerPanel.viewMode + clip: true + + delegate: Rectangle { + id: listItem + width: listView.width + height: 40 * scaling + color: { + if (filePickerPanel.currentSelection.includes(model.filePath)) + return Color.mSecondary + if (mouseArea.containsMouse) + return Color.mTertiary + return Color.transparent + } + radius: Style.radiusS * scaling + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.marginM * scaling + anchors.rightMargin: Style.marginM * scaling + spacing: Style.marginM * scaling + + NIcon { + icon: model.fileIsDir ? "filepicker-folder" : root.getFileIcon(model.fileName) + font.pointSize: Style.fontSizeL * scaling + color: model.fileIsDir ? (filePickerPanel.currentSelection.includes(model.filePath) ? Color.mOnSecondary : Color.mPrimary) : Color.mOnSurfaceVariant + } + + NText { + text: model.fileName + color: filePickerPanel.currentSelection.includes(model.filePath) ? Color.mOnSecondary : Color.mOnSurface + font.pointSize: Style.fontSizeM * scaling + font.weight: filePickerPanel.currentSelection.includes(model.filePath) ? Style.fontWeightBold : Style.fontWeightRegular + Layout.fillWidth: true + elide: Text.ElideRight + } + + NText { + text: model.fileIsDir ? "" : root.formatFileSize(model.fileSize) + color: filePickerPanel.currentSelection.includes(model.filePath) ? Color.mOnSecondary : Color.mOnSurfaceVariant + font.pointSize: Style.fontSizeS * scaling + visible: !model.fileIsDir + Layout.preferredWidth: implicitWidth + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + + onClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + if (model.fileIsDir) { + if (root.selectFolders && !root.selectFiles) { + filePickerPanel.currentSelection = [model.filePath] + } else { + folderModel.folder = "file://" + model.filePath + root.currentPath = model.filePath + } + } else { + if (root.selectFiles) + filePickerPanel.currentSelection = [model.filePath] + } + } + } + + onDoubleClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + if (model.fileIsDir) { + if (root.selectFolders && !root.selectFiles) { + filePickerPanel.currentSelection = [model.filePath] + root.confirmSelection() + } else { + folderModel.folder = "file://" + model.filePath + root.currentPath = model.filePath + } + } else { + if (root.selectFiles) { + filePickerPanel.currentSelection = [model.filePath] + root.confirmSelection() + } + } + } + } + } + } + } + } + + // Footer + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM * scaling + + NText { + text: { + if (filePickerPanel.searchText.length > 0) { + return "Searching for: \"" + filePickerPanel.searchText + "\" (" + filteredModel.count + " matches)" + } else if (filePickerPanel.currentSelection.length > 0) { + return filePickerPanel.currentSelection.length + " item(s) selected" + } else { + return filteredModel.count + " items" + } + } + color: filePickerPanel.searchText.length > 0 ? Color.mPrimary : Color.mOnSurfaceVariant + font.pointSize: Style.fontSizeS * scaling + Layout.fillWidth: true + } + + NButton { + text: "Cancel" + outlined: true + onClicked: { + root.cancelled() + root.close() + } + } + + NButton { + text: { + if (root.selectFolders && !root.selectFiles) + return "Select Folder" + else if (root.selectFiles && !root.selectFolders) + return "Select File" + else + return "Select" + } + icon: "filepicker-check" + enabled: filePickerPanel.currentSelection.length > 0 + onClicked: root.confirmSelection() } - filterStr += ")" - filters.push(filterStr) } } - if (filters.length > 0) { - filters.push("All files (*)") - nameFilters = filters - } - } - - // Helper function to convert URL to local path - function urlToPath(url) { - var path = url.toString() - // Remove file:// prefix (works for both Windows and Unix) - path = path.replace(/^file:\/\/\//, "/") // Unix - path = path.replace(/^file:\/\//, "") // Windows - // Handle Windows drive letters - if (Qt.platform.os === "windows") { - path = path.replace(/^\/([A-Z]:)/, "$1") - } - return path - } - - // Get default folder with proper fallback - function getDefaultFolder() { - if (root.initialPath) { - return "file:///" + root.initialPath.replace(/^\//, "") - } - - // Fallback to home directory - try { - return StandardPaths.writableLocation(StandardPaths.HomeLocation) - } catch (e) { - // Final fallback if StandardPaths fails - return "file:///" + (Qt.platform.os === "windows" ? "C:/Users" : "/home") - } - } - - // FileDialog for file selection (Qt 6.x) - FileDialog { - id: fileDialog - title: root.title - currentFolder: getDefaultFolder() - fileMode: root.multipleSelection ? FileDialog.OpenFiles : FileDialog.OpenFile - nameFilters: root.nameFilters - acceptLabel: root.acceptLabel - rejectLabel: root.rejectLabel - modality: Qt.WindowModal - - onAccepted: { - if (fileMode === FileDialog.OpenFiles) { - var paths = [] - for (var i = 0; i < fileDialog.selectedFiles.length; i++) { - paths.push(urlToPath(fileDialog.selectedFiles[i])) + Connections { + target: root + function onShouldResetSelectionChanged() { + if (root.shouldResetSelection) { + filePickerPanel.currentSelection = [] + root.shouldResetSelection = false } - root.selectedPaths = paths - root.selectedPath = paths.length > 0 ? paths[0] : "" - root.pathsSelected(paths) - root.accepted(paths) - } else { - var singlePath = urlToPath(fileDialog.selectedFile) - root.selectedPath = singlePath - root.selectedPaths = [singlePath] - root.pathSelected(singlePath) - root.accepted([singlePath]) } - root.handleClose() } - onRejected: { - root.rejected() - root.handleClose() - } - } - - // FolderDialog for folder selection (Qt 6.x) - FolderDialog { - id: folderDialog - title: root.title - currentFolder: getDefaultFolder() - acceptLabel: root.acceptLabel - rejectLabel: root.rejectLabel - modality: Qt.WindowModal - - onAccepted: { - var folderPath = urlToPath(folderDialog.selectedFolder) - root.selectedPath = folderPath - root.selectedPaths = [folderPath] - root.pathSelected(folderPath) - root.accepted([folderPath]) - root.handleClose() - } - - onRejected: { - root.rejected() - root.handleClose() + Component.onCompleted: { + if (!root.currentPath) + root.currentPath = root.initialPath + folderModel.folder = "file://" + root.currentPath } } }