diff --git a/Modules/SettingsPanel/Tabs/GeneralTab.qml b/Modules/SettingsPanel/Tabs/GeneralTab.qml index 2eec600f..775dc831 100644 --- a/Modules/SettingsPanel/Tabs/GeneralTab.qml +++ b/Modules/SettingsPanel/Tabs/GeneralTab.qml @@ -30,15 +30,30 @@ ColumnLayout { Layout.alignment: Qt.AlignTop } - NTextInput { - Layout.fillWidth: true + NInputButton { label: `${Quickshell.env("USER") || "user"}'s profile picture` description: "Your profile picture that appears throughout the interface." text: Settings.data.general.avatarImage placeholderText: "/home/user/.face" - onEditingFinished: { + buttonIcon: "photo" + buttonTooltip: "Browse for avatar image" + + onInputEditingFinished: { Settings.data.general.avatarImage = text } + onButtonClicked: { + FileManagerService.open({ + "title": "Select Avatar Image", + "initialPath": Settings.data.general.avatarImage || Quickshell.env("HOME"), + "selectFiles": true, + "scaling": scaling, + "onSelected": function (path) { + Settings.data.general.avatarImage = path + text = path + }, + "parent": root + }) + } } } diff --git a/Modules/SettingsPanel/Tabs/ScreenRecorderTab.qml b/Modules/SettingsPanel/Tabs/ScreenRecorderTab.qml index 64408d63..a8c9eae5 100644 --- a/Modules/SettingsPanel/Tabs/ScreenRecorderTab.qml +++ b/Modules/SettingsPanel/Tabs/ScreenRecorderTab.qml @@ -20,16 +20,30 @@ ColumnLayout { spacing: Style.marginS * scaling Layout.fillWidth: true - NTextInput { + NInputButton { label: "Output folder" description: "Folder where screen recordings will be saved." placeholderText: "/home/xxx/Videos" text: Settings.data.screenRecorder.directory - onEditingFinished: { + buttonIcon: "folder-open" + buttonTooltip: "Browse for output folder" + + onInputEditingFinished: { Settings.data.screenRecorder.directory = text } - - Layout.maximumWidth: 420 * scaling + onButtonClicked: { + FileManagerService.open({ + "title": "Select Output Folder", + "initialPath": Settings.data.screenRecorder.directory || Quickshell.env("HOME") + "/Videos", + "selectFiles": false, + "scaling": scaling, + "onSelected": function (path) { + Settings.data.screenRecorder.directory = path + text = path + }, + "parent": root + }) + } } ColumnLayout { diff --git a/Modules/SettingsPanel/Tabs/WallpaperTab.qml b/Modules/SettingsPanel/Tabs/WallpaperTab.qml index fbece072..54ad5394 100644 --- a/Modules/SettingsPanel/Tabs/WallpaperTab.qml +++ b/Modules/SettingsPanel/Tabs/WallpaperTab.qml @@ -29,14 +29,21 @@ ColumnLayout { spacing: Style.marginL * scaling Layout.fillWidth: true - NTextInput { + NInputButton { + id: wallpaperPathInput label: "Wallpaper folder" description: "Path to your main wallpaper folder." text: Settings.data.wallpaper.directory - onEditingFinished: { + buttonIcon: "folder-open" + buttonTooltip: "Browse for wallpaper folder" + Layout.fillWidth: true + + onInputEditingFinished: { Settings.data.wallpaper.directory = text } - Layout.maximumWidth: 420 * scaling + onButtonClicked: { + openFileManager() + } } // Monitor-specific directories @@ -73,11 +80,22 @@ ColumnLayout { Layout.preferredWidth: 90 * scaling } NTextInput { + id: monitorPathInput Layout.fillWidth: true text: WallpaperService.getMonitorDirectory(modelData.name) onEditingFinished: WallpaperService.setMonitorDirectory(modelData.name, text) Layout.maximumWidth: 420 * scaling } + + NIconButton { + icon: "folder-open" + tooltipText: "Browse for wallpaper folder" + baseSize: Style.baseWidgetSize * 0.8 + Layout.alignment: Qt.AlignBottom + onClicked: { + openMonitorFileManager(modelData.name) + } + } } } } @@ -324,4 +342,32 @@ ColumnLayout { Layout.topMargin: Style.marginXL * scaling Layout.bottomMargin: Style.marginXL * scaling } + + // File manager functions + function openFileManager() { + FileManagerService.open({ + "title": "Select Wallpaper Folder", + "initialPath": Settings.data.wallpaper.directory || Quickshell.env("HOME"), + "selectFiles": false, + "scaling": scaling, + "onSelected": function (path) { + Settings.data.wallpaper.directory = path + wallpaperPathInput.text = path + }, + "parent": root + }) + } + + function openMonitorFileManager(monitorName) { + FileManagerService.open({ + "title": "Select Monitor Wallpaper Folder", + "initialPath": WallpaperService.getMonitorDirectory(monitorName), + "selectFiles": false, + "scaling": scaling, + "onSelected": function (path) { + WallpaperService.setMonitorDirectory(monitorName, path) + }, + "parent": root + }) + } } diff --git a/Services/FileManagerService.qml b/Services/FileManagerService.qml new file mode 100644 index 00000000..742e4c1c --- /dev/null +++ b/Services/FileManagerService.qml @@ -0,0 +1,36 @@ +pragma Singleton + +import QtQuick +import Quickshell + +QtObject { + id: root + + // Function to open a file manager dialog + function open(options) { + var component = Qt.createComponent(Qt.resolvedUrl(Quickshell.shellDir + "/Widgets/NFileManager.qml")) + if (component.status === Component.Ready) { + var dialog = component.createObject(options.parent || Overlay.overlay, { + "title": options.title || "Select File/Folder", + "initialPath": options.initialPath || Quickshell.env("HOME"), + "selectFiles": options.selectFiles || false, + "selectFolders": !options.selectFiles || false, + "scaling": options.scaling || 1.0 + }) + if (dialog) { + if (options.onSelected) { + if (options.selectFiles) { + dialog.fileSelected.connect(options.onSelected) + } else { + dialog.folderSelected.connect(options.onSelected) + } + } + dialog.open() + return dialog + } + } else { + console.error("Component error:", component.errorString()) + } + return null + } +} diff --git a/Widgets/NFileManager.qml b/Widgets/NFileManager.qml new file mode 100644 index 00000000..38bfaf12 --- /dev/null +++ b/Widgets/NFileManager.qml @@ -0,0 +1,882 @@ +import QtQuick +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 + +Popup { + id: root + + // Public properties + property string title: "File Manager" + property string initialPath: Quickshell.env("HOME") || "/home" + property bool selectFiles: true + property bool selectFolders: true + property var nameFilters: ["*"] // Default to show all files + property bool showDirs: true + property real scaling: 1.0 + + // Selected files/folders + property var selectedPaths: [] + property string currentPath: initialPath + property bool shouldResetSelection: false + + // Signals + signal fileSelected(string path) + signal filesSelected(var paths) + signal folderSelected(string path) + signal cancelled + + // Override the open function to ensure proper initialization + function openFileManager() { + // Ensure we have a valid path + if (!root.currentPath || root.currentPath === "") { + root.currentPath = root.initialPath + } + // Signal that selection should be reset + shouldResetSelection = true + open() + } + + // Helper functions + function getFileIcon(fileName) { + var extension = fileName.split('.').pop().toLowerCase() + switch (extension) { + case 'txt': + case 'md': + case 'log': + return 'file-text' + case 'jpg': + case 'jpeg': + case 'png': + case 'gif': + case 'bmp': + case 'svg': + return 'photo' + case 'mp4': + case 'avi': + case 'mkv': + case 'mov': + return 'video' + case 'mp3': + case 'wav': + case 'flac': + case 'ogg': + return 'music' + case 'zip': + case 'tar': + case 'gz': + case 'rar': + case '7z': + return 'archive' + case 'pdf': + return 'file-text' + case 'doc': + case 'docx': + return 'file-text' + case 'xls': + case 'xlsx': + return 'table' + case 'ppt': + case 'pptx': + return 'presentation' + case 'html': + case 'htm': + case 'css': + case 'js': + case 'json': + case 'xml': + return 'code' + case 'exe': + case 'app': + case 'deb': + case 'rpm': + return 'settings' + default: + return 'file' + } + } + + function formatFileSize(bytes) { + if (bytes === 0) + return "0 B" + var k = 1024 + var sizes = ["B", "KB", "MB", "GB", "TB"] + var i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i] + } + + function confirmSelection() { + if (fileManagerPanel.currentSelection.length === 0) { + return + } + + root.selectedPaths = fileManagerPanel.currentSelection + + if (fileManagerPanel.currentSelection.length === 1) { + var path = fileManagerPanel.currentSelection[0] + if (root.selectFiles && !root.selectFolders) { + root.fileSelected(path) + } else if (root.selectFolders && !root.selectFiles) { + root.folderSelected(path) + } else { + // Both files and folders allowed + var isDir = folderModel.get(folderModel.indexOf(path), "fileIsDir") + if (isDir) { + root.folderSelected(path) + } else { + root.fileSelected(path) + } + } + } else { + root.filesSelected(fileManagerPanel.currentSelection) + } + + root.close() + } + + // Function to update the filtered model + function updateFilteredModel() { + filteredModel.clear() + var searchText = fileManagerPanel.filterText.toLowerCase() + + for (var i = 0; i < folderModel.count; i++) { + var fileName = folderModel.get(i, "fileName") + var filePath = folderModel.get(i, "filePath") + var fileIsDir = folderModel.get(i, "fileIsDir") + var fileSize = folderModel.get(i, "fileSize") + + // If no search text or file name contains search text + if (searchText === "" || fileName.toLowerCase().includes(searchText)) { + filteredModel.append({ + "fileName": fileName, + "filePath": filePath, + "fileIsDir": fileIsDir, + "fileSize": fileSize + }) + } + } + } + + // Function to intelligently truncate filenames + function truncateFileName(fileName, maxLength) { + if (fileName.length <= maxLength) { + return fileName + } + + // For files, try to preserve the extension + var lastDot = fileName.lastIndexOf('.') + if (lastDot > 0 && lastDot < fileName.length - 1) { + var name = fileName.substring(0, lastDot) + var ext = fileName.substring(lastDot) + var availableForName = maxLength - ext.length - 3 // 3 for "..." + + if (availableForName > 0) { + return name.substring(0, availableForName) + "..." + ext + } + } + + // Fallback: just truncate from the end + return fileName.substring(0, maxLength - 3) + "..." + } + + // Popup properties + 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: fileManagerPanel + anchors.fill: parent + anchors.margins: Style.marginL * scaling + color: Color.transparent + + property string filterText: "" + property var currentSelection: [] + property bool isNavigating: false + property bool viewMode: true // true = grid, false = list + property string searchText: "" + property bool isSearching: false + property bool showSearchBar: false + + // Keyboard shortcuts + Keys.onPressed: event => { + if (event.modifiers & Qt.ControlModifier && event.key === Qt.Key_F) { + fileManagerPanel.showSearchBar = !fileManagerPanel.showSearchBar + if (fileManagerPanel.showSearchBar) { + // Focus the search input when opening + Qt.callLater(() => { + searchInput.forceActiveFocus() + }) + } + event.accepted = true + } else if (event.key === Qt.Key_Escape && fileManagerPanel.showSearchBar) { + // Close search bar on Escape + fileManagerPanel.showSearchBar = false + fileManagerPanel.searchText = "" + fileManagerPanel.isSearching = false + fileManagerPanel.filterText = "" + root.updateFilteredModel() + event.accepted = true + } + } + + // Focus the file manager to receive key events + focus: true + + ColumnLayout { + anchors.fill: parent + spacing: Style.marginM * scaling + + // Header row (like SettingsPanel) + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM * scaling + + // Main icon + NIcon { + icon: "folder" + color: Color.mPrimary + font.pointSize: Style.fontSizeXXL * scaling + } + + // Main title + NText { + text: root.title + font.pointSize: Style.fontSizeXL * scaling + font.weight: Style.fontWeightBold + color: Color.mPrimary + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + } + + // Action buttons + NIconButton { + icon: "refresh" + tooltipText: "Refresh" + Layout.alignment: Qt.AlignVCenter + onClicked: { + folderModel.refresh() + } + } + + NIconButton { + icon: "close" + tooltipText: "Close" + Layout.alignment: Qt.AlignVCenter + onClicked: { + root.cancelled() + root.close() + } + } + } + + // Divider + 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.margins: Style.marginS * scaling + spacing: Style.marginS * scaling + + // Navigation buttons + NIconButton { + icon: "arrow-left" + tooltipText: "Back" + baseSize: Style.baseWidgetSize * 0.8 + enabled: folderModel.folder.toString() !== "file://" + root.initialPath + onClicked: { + var parentPath = folderModel.parentFolder.toString().replace("file://", "") + if (parentPath !== folderModel.folder.toString().replace("file://", "")) { + folderModel.folder = "file://" + parentPath + root.currentPath = parentPath + } + } + } + + NIconButton { + icon: "arrow-up" + tooltipText: "Up" + baseSize: Style.baseWidgetSize * 0.8 + enabled: folderModel.folder.toString() !== "file:///" + onClicked: { + var parentPath = folderModel.parentFolder.toString().replace("file://", "") + folderModel.folder = "file://" + parentPath + root.currentPath = parentPath + } + } + + NIconButton { + icon: "home" + tooltipText: "Home" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: { + var homePath = Quickshell.env("HOME") || "/home" + folderModel.folder = "file://" + homePath + root.currentPath = homePath + } + } + + // View mode toggle + NIconButton { + icon: fileManagerPanel.viewMode ? "layout-grid" : "list" + tooltipText: fileManagerPanel.viewMode ? "List View" : "Grid View" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: { + fileManagerPanel.viewMode = !fileManagerPanel.viewMode + } + } + + // Search toggle + NIconButton { + icon: fileManagerPanel.showSearchBar ? "x" : "search" + tooltipText: fileManagerPanel.showSearchBar ? "Close Search" : "Search" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: { + fileManagerPanel.showSearchBar = !fileManagerPanel.showSearchBar + if (!fileManagerPanel.showSearchBar) { + // Clear search when closing + fileManagerPanel.searchText = "" + fileManagerPanel.isSearching = false + fileManagerPanel.filterText = "" + root.updateFilteredModel() + } + } + } + + // Location input + NTextInput { + id: locationInput + text: root.currentPath + placeholderText: "Enter path..." + Layout.fillWidth: true + + onEditingFinished: { + var newPath = text.trim() + if (newPath !== "" && newPath !== root.currentPath) { + // Navigate to the path + folderModel.folder = "file://" + newPath + root.currentPath = newPath + } else { + // Reset to current path if invalid or same + text = root.currentPath + } + } + + // Update text when currentPath changes from navigation (but not when user is typing) + Connections { + target: root + function onCurrentPathChanged() { + if (!locationInput.activeFocus) { + locationInput.text = root.currentPath + } + } + } + } + } + } + + // Search bar (appears when search is toggled) + 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: fileManagerPanel.showSearchBar + + RowLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Style.marginS * scaling + spacing: Style.marginS * scaling + + NIcon { + icon: "search" + color: Color.mOnSurfaceVariant + font.pointSize: Style.fontSizeS * scaling + } + + NTextInput { + id: searchInput + placeholderText: "Search files and folders..." + Layout.fillWidth: true + text: fileManagerPanel.searchText + + onTextChanged: { + fileManagerPanel.searchText = text + fileManagerPanel.isSearching = text.length > 0 + fileManagerPanel.filterText = text + root.updateFilteredModel() + } + + Keys.onEscapePressed: { + fileManagerPanel.showSearchBar = false + fileManagerPanel.searchText = "" + fileManagerPanel.isSearching = false + fileManagerPanel.filterText = "" + root.updateFilteredModel() + } + } + + NIconButton { + icon: "x" + tooltipText: "Clear" + baseSize: Style.baseWidgetSize * 0.6 + visible: fileManagerPanel.searchText.length > 0 + onClicked: { + searchInput.text = "" + fileManagerPanel.searchText = "" + fileManagerPanel.isSearching = false + fileManagerPanel.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: true + sortField: FolderListModel.Name + sortReversed: false + + onFolderChanged: { + root.currentPath = folder.toString().replace("file://", "") + fileManagerPanel.currentSelection = [] + } + + onStatusChanged: { + if (status === FolderListModel.Error) { + console.log("FolderListModel error for path:", root.currentPath) + // Fallback to home directory if there's an error + if (root.currentPath !== Quickshell.env("HOME")) { + folder = "file://" + Quickshell.env("HOME") + root.currentPath = Quickshell.env("HOME") + } + } else if (status === FolderListModel.Ready) { + root.updateFilteredModel() + } + } + } + + // Filtered model for search functionality + ListModel { + id: filteredModel + } + + // Grid view + GridView { + id: gridView + anchors.fill: parent + anchors.margins: Style.marginM * scaling + model: filteredModel + visible: fileManagerPanel.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: ScrollBar { + parent: gridView + x: gridView.mirrored ? 0 : gridView.width - width + y: 0 + height: gridView.height + policy: ScrollBar.AsNeeded + + contentItem: Rectangle { + implicitWidth: 6 * scaling + implicitHeight: 100 + radius: 3 * scaling + color: parent.pressed ? Color.mTertiary : parent.hovered ? Color.mSecondary : Color.mTertiary + opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 1.0 : 0.0 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + } + } + + delegate: Rectangle { + id: gridItem + width: gridView.itemSize + height: gridView.cellHeight + color: Color.transparent + radius: Style.radiusM * scaling + + property string fileName: model.fileName + property string filePath: model.filePath + property bool isDirectory: model.fileIsDir + property bool isSelected: fileManagerPanel.currentSelection.includes(filePath) + + // Selection background (covers entire item) + Rectangle { + anchors.fill: parent + color: isSelected ? Qt.alpha(Color.mSecondary, 0.15) : Color.transparent + radius: parent.radius + border.color: isSelected ? Color.mSecondary : Color.mSurface + border.width: Math.max(1, Style.borderL * 1.5 * scaling) + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + } + + // Hover overlay + Rectangle { + anchors.fill: parent + color: Color.mSurface + opacity: (mouseArea.containsMouse && !isSelected) ? 0.1 : 0 + radius: parent.radius + Behavior on opacity { + NumberAnimation { + 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 + + NIcon { + icon: isDirectory ? "folder" : root.getFileIcon(fileName) + font.pointSize: Style.fontSizeXXL * scaling + color: isDirectory ? Color.mPrimary : Color.mOnSurfaceVariant + anchors.centerIn: parent + } + + // Selection indicator (like WallpaperSelector) + 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: "check" + font.pointSize: Style.fontSizeS * scaling + font.weight: Style.fontWeightBold + color: Color.mOnSecondary + anchors.centerIn: parent + } + } + } + + NText { + text: fileName + color: isSelected ? Color.mPrimary : 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 (isDirectory) { + if (root.selectFolders && !root.selectFiles) { + fileManagerPanel.currentSelection = [filePath] + } else { + folderModel.folder = "file://" + filePath + root.currentPath = filePath + } + } else { + if (root.selectFiles) { + fileManagerPanel.currentSelection = [filePath] + } + } + } + } + + onDoubleClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + if (isDirectory) { + if (root.selectFolders && !root.selectFiles) { + fileManagerPanel.currentSelection = [filePath] + root.confirmSelection() + } else { + folderModel.folder = "file://" + filePath + root.currentPath = filePath + } + } else { + if (root.selectFiles) { + fileManagerPanel.currentSelection = [filePath] + root.confirmSelection() + } + } + } + } + } + } + } + + // List view + ListView { + id: listView + anchors.fill: parent + anchors.margins: Style.marginS * scaling + model: filteredModel + visible: !fileManagerPanel.viewMode + clip: true + + ScrollBar.vertical: ScrollBar { + parent: listView + x: listView.mirrored ? 0 : listView.width - width + y: 0 + height: listView.height + policy: ScrollBar.AsNeeded + + contentItem: Rectangle { + implicitWidth: 6 * scaling + implicitHeight: 100 + radius: 3 * scaling + color: parent.pressed ? Color.mTertiary : parent.hovered ? Color.mSecondary : Color.mTertiary + opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 1.0 : 0.0 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + } + } + + delegate: Rectangle { + id: listItem + width: listView.width + height: 40 * scaling + color: { + if (fileManagerPanel.currentSelection.includes(filePath)) { + return Color.mSecondary + } + if (mouseArea.containsMouse) { + return Qt.alpha(Color.mOnSurface, 0.1) + } + return Color.transparent + } + radius: Style.radiusS * scaling + + property string fileName: model.fileName + property string filePath: model.filePath + property bool isDirectory: model.fileIsDir + + 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: isDirectory ? "folder" : root.getFileIcon(fileName) + font.pointSize: Style.fontSizeL * scaling + color: isDirectory ? Color.mPrimary : Color.mOnSurfaceVariant + } + + NText { + text: fileName + color: fileManagerPanel.currentSelection.includes(filePath) ? Color.mOnSecondary : Color.mOnSurface + font.pointSize: Style.fontSizeM * scaling + font.weight: fileManagerPanel.currentSelection.includes(filePath) ? Style.fontWeightBold : Style.fontWeightRegular + Layout.fillWidth: true + elide: Text.ElideRight + } + + NText { + text: isDirectory ? "" : root.formatFileSize(model.fileSize) + color: fileManagerPanel.currentSelection.includes(filePath) ? Color.mOnSecondary : Color.mOnSurfaceVariant + font.pointSize: Style.fontSizeS * scaling + visible: !isDirectory + Layout.preferredWidth: implicitWidth + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + + onClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + if (isDirectory) { + if (root.selectFolders && !root.selectFiles) { + fileManagerPanel.currentSelection = [filePath] + } else { + folderModel.folder = "file://" + filePath + root.currentPath = filePath + } + } else { + if (root.selectFiles) { + fileManagerPanel.currentSelection = [filePath] + } + } + } + } + + onDoubleClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + if (isDirectory) { + if (root.selectFolders && !root.selectFiles) { + fileManagerPanel.currentSelection = [filePath] + root.confirmSelection() + } else { + folderModel.folder = "file://" + filePath + root.currentPath = filePath + } + } else { + if (root.selectFiles) { + fileManagerPanel.currentSelection = [filePath] + root.confirmSelection() + } + } + } + } + } + } + } + } + + // Status and actions + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM * scaling + + // Status text + NText { + text: { + if (fileManagerPanel.isSearching) { + return "Searching for: \"" + fileManagerPanel.searchText + "\" (" + filteredModel.count + " matches)" + } else if (fileManagerPanel.currentSelection.length > 0) { + return fileManagerPanel.currentSelection.length + " item(s) selected" + } else { + return filteredModel.count + " items" + } + } + color: fileManagerPanel.isSearching ? Color.mPrimary : Color.mOnSurfaceVariant + font.pointSize: Style.fontSizeS * scaling + Layout.fillWidth: true + } + + // Action buttons + NButton { + text: "Cancel" + outlined: true + onClicked: { + root.cancelled() + root.close() + } + } + + NButton { + text: "Select" + icon: "check" + enabled: fileManagerPanel.currentSelection.length > 0 + onClicked: root.confirmSelection() + } + } + } + + // Watch for selection reset flag + Connections { + target: root + function onShouldResetSelectionChanged() { + if (root.shouldResetSelection) { + fileManagerPanel.currentSelection = [] + root.shouldResetSelection = false + } + } + } + + Component.onCompleted: { + // Ensure we have a valid path + if (!root.currentPath || root.currentPath === "") { + root.currentPath = root.initialPath + } + folderModel.folder = "file://" + root.currentPath + } + } +} diff --git a/Widgets/NInputButton.qml b/Widgets/NInputButton.qml new file mode 100644 index 00000000..ed1bafd0 --- /dev/null +++ b/Widgets/NInputButton.qml @@ -0,0 +1,83 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import qs.Commons +import qs.Widgets + +ColumnLayout { + id: root + Layout.fillWidth: true + + property alias text: input.text + property alias placeholderText: input.placeholderText + property string label: "" + property string description: "" + property alias buttonIcon: button.icon + property alias buttonTooltip: button.tooltipText + property alias buttonEnabled: button.enabled + property real maximumWidth: 0 + + signal buttonClicked + signal inputTextChanged(string text) + signal inputEditingFinished + + spacing: Style.marginS * scaling + + // Label and description + NLabel { + label: root.label + description: root.description + visible: root.label !== "" || root.description !== "" + Layout.fillWidth: true + } + + // Input field with button + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM * scaling + + // Input field container + Rectangle { + id: inputContainer + Layout.fillWidth: true + Layout.maximumWidth: root.maximumWidth > 0 ? root.maximumWidth : -1 + implicitHeight: Style.baseWidgetSize * 1.1 * scaling + + radius: Style.radiusM * scaling + color: Color.mSurface + border.color: input.activeFocus ? Color.mSecondary : Color.mOutline + border.width: Math.max(1, Style.borderS * scaling) + + Behavior on border.color { + ColorAnimation { + duration: Style.animationFast + } + } + + TextField { + id: input + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Style.marginM * scaling + + color: Color.mOnSurface + font.pointSize: Style.fontSizeS * scaling + font.family: Settings.data.ui.fontDefault + selectByMouse: true + + background: Item {} // Remove default background since we have our own Rectangle + + onTextChanged: root.inputTextChanged(text) + onEditingFinished: root.inputEditingFinished() + } + } + + // Button + NIconButton { + id: button + baseSize: Style.baseWidgetSize + onClicked: root.buttonClicked() + } + } +}