diff --git a/.gitignore b/.gitignore index dfbea23..17b4a1c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .qmlls.ini +build/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..bdd6504 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,21 @@ +cmake_minimum_required(VERSION 3.19) + +project(ZShell) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") + +set(INSTALL_LIBDIR "usr/lib/ZShell" CACHE STRING "Library install dir") +set(INSTALL_QMLDIR "usr/lib/qt6/qml" CACHE STRING "QML install dir") + +add_compile_options( + -Wall -Wextra -Wpedantic -Wshadow -Wconversion + -Wold-style-cast -Wnull-dereference -Wdouble-promotion + -Wformat=2 -Wfloat-equal -Woverloaded-virtual + -Wsign-conversion -Wredundant-decls -Wswitch + -Wunreachable-code +) + +add_subdirectory(Plugins) diff --git a/Helpers/AreaPicker.qml b/Helpers/AreaPicker.qml new file mode 100644 index 0000000..16c48be --- /dev/null +++ b/Helpers/AreaPicker.qml @@ -0,0 +1,69 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Wayland +import Quickshell.Io +import Quickshell.Hyprland +import ZShell + +Scope { + LazyLoader { + id: root + + property bool freeze + property bool closing + + Variants { + model: Quickshell.screens + PanelWindow { + id: win + color: "transparent" + + required property ShellScreen modelData + + screen: modelData + WlrLayershell.namespace: "areapicker" + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: root.closing ? WlrKeyboardFocus.None : WlrKeyboardFocus.Exclusive + mask: root.closing ? empty : null + + anchors { + top: true + bottom: true + left: true + right: true + } + + Region { + id: empty + } + + Picker { + loader: root + screen: win.modelData + } + } + } + } + + GlobalShortcut { + name: "screenshot" + appid: "ZShell" + onPressed: { + root.freeze = false; + root.closing = false; + root.activeAsync = true; + } + } + + GlobalShortcut { + name: "screenshotFreeze" + appid: "ZShell" + onPressed: { + root.freeze = true; + root.closing = false; + root.activeAsync = true; + } + } +} diff --git a/Helpers/Picker.qml b/Helpers/Picker.qml new file mode 100644 index 0000000..3485b65 --- /dev/null +++ b/Helpers/Picker.qml @@ -0,0 +1,292 @@ +pragma ComponentBehavior: Bound + +import ZShell +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import QtQuick +import QtQuick.Effects +import qs.Modules +import qs.Config + +MouseArea { + id: root + + required property LazyLoader loader + required property ShellScreen screen + + property bool onClient + + property real realRounding: 6 + property real realBorderWidth: 1 + + property real ssx + property real ssy + + property real sx: 0 + property real sy: 0 + property real ex: screen.width + property real ey: screen.height + + property real rsx: Math.min(sx, ex) + property real rsy: Math.min(sy, ey) + property real sw: Math.abs(sx - ex) + property real sh: Math.abs(sy - ey) + + property list clients: { + const mon = Hyprland.monitorFor(screen); + if (!mon) + return []; + + const special = mon.lastIpcObject.specialWorkspace; + const wsId = special.name ? special.id : mon.activeWorkspace.id; + + return Hyprland.toplevels.values.filter(c => c.workspace?.id === wsId).sort((a, b) => { + const ac = a.lastIpcObject; + const bc = b.lastIpcObject; + return (bc.pinned - ac.pinned) || ((bc.fullscreen !== 0) - (ac.fullscreen !== 0)) || (bc.floating - ac.floating); + }); + } + + function checkClientRects(x: real, y: real): void { + for (const client of clients) { + if (!client) + continue; + + let { + at: [cx, cy], + size: [cw, ch] + } = client.lastIpcObject; + cx -= screen.x; + cy -= screen.y; + if (cx <= x && cy <= y && cx + cw >= x && cy + ch >= y) { + onClient = true; + sx = cx; + sy = cy; + ex = cx + cw; + ey = cy + ch; + break; + } + } + } + + function save(): void { + const tmpfile = Qt.resolvedUrl(`/tmp/zshell-picker-${Quickshell.processId}-${Date.now()}.png`); + ZShellIo.saveItem(screencopy, tmpfile, Qt.rect(Math.ceil(rsx), Math.ceil(rsy), Math.floor(sw), Math.floor(sh)), path => Quickshell.execDetached(["swappy", "-f", path])); + closeAnim.start(); + } + + onClientsChanged: checkClientRects(mouseX, mouseY) + + anchors.fill: parent + opacity: 0 + hoverEnabled: true + cursorShape: Qt.CrossCursor + + Component.onCompleted: { + if (loader.freeze) + clients = clients; + + opacity = 1; + + const c = clients[0]; + if (c) { + const cx = c.lastIpcObject.at[0] - screen.x; + const cy = c.lastIpcObject.at[1] - screen.y; + onClient = true; + sx = cx; + sy = cy; + ex = cx + c.lastIpcObject.size[0]; + ey = cy + c.lastIpcObject.size[1]; + } else { + sx = screen.width / 2 - 100; + sy = screen.height / 2 - 100; + ex = screen.width / 2 + 100; + ey = screen.height / 2 + 100; + } + } + + onPressed: event => { + ssx = event.x; + ssy = event.y; + } + + onReleased: { + if (closeAnim.running) + return; + + if (root.loader.freeze) { + save(); + } else { + overlay.visible = border.visible = false; + screencopy.visible = false; + screencopy.active = true; + } + } + + onPositionChanged: event => { + const x = event.x; + const y = event.y; + + if (pressed) { + onClient = false; + sx = ssx; + sy = ssy; + ex = x; + ey = y; + } else { + checkClientRects(x, y); + } + } + + focus: true + Keys.onEscapePressed: closeAnim.start() + + SequentialAnimation { + id: closeAnim + + PropertyAction { + target: root.loader + property: "closing" + value: true + } + ParallelAnimation { + Anim { + target: root + property: "opacity" + to: 0 + duration: 300 + } + ExAnim { + target: root + properties: "rsx,rsy" + to: 0 + } + ExAnim { + target: root + property: "sw" + to: root.screen.width + } + ExAnim { + target: root + property: "sh" + to: root.screen.height + } + } + PropertyAction { + target: root.loader + property: "activeAsync" + value: false + } + } + + Loader { + id: screencopy + + anchors.fill: parent + + active: root.loader.freeze + asynchronous: true + + sourceComponent: ScreencopyView { + captureSource: root.screen + + paintCursor: false + + onHasContentChanged: { + if (hasContent && !root.loader.freeze) { + overlay.visible = border.visible = true; + root.save(); + } + } + } + } + + Rectangle { + id: overlay + + anchors.fill: parent + color: "white" + opacity: 0.3 + + layer.enabled: true + layer.effect: MultiEffect { + maskSource: selectionWrapper + maskEnabled: true + maskInverted: true + maskSpreadAtMin: 1 + maskThresholdMin: 0.5 + } + } + + Item { + id: selectionWrapper + + anchors.fill: parent + layer.enabled: true + visible: false + + Rectangle { + id: selectionRect + + radius: root.realRounding + x: root.rsx + y: root.rsy + implicitWidth: root.sw + implicitHeight: root.sh + } + } + + Rectangle { + id: border + + color: "transparent" + radius: root.realRounding > 0 ? root.realRounding + root.realBorderWidth : 0 + border.width: root.realBorderWidth + border.color: Config.accentColor.accents.primary + + x: selectionRect.x - root.realBorderWidth + y: selectionRect.y - root.realBorderWidth + implicitWidth: selectionRect.implicitWidth + root.realBorderWidth * 2 + implicitHeight: selectionRect.implicitHeight + root.realBorderWidth * 2 + + Behavior on border.color { + Anim {} + } + } + + Behavior on opacity { + Anim { + duration: 300 + } + } + + Behavior on rsx { + enabled: !root.pressed + + ExAnim {} + } + + Behavior on rsy { + enabled: !root.pressed + + ExAnim {} + } + + Behavior on sw { + enabled: !root.pressed + + ExAnim {} + } + + Behavior on sh { + enabled: !root.pressed + + ExAnim {} + } + + component ExAnim: Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } +} diff --git a/Helpers/SearchWallpapers.qml b/Helpers/SearchWallpapers.qml index 0d79662..7b63002 100644 --- a/Helpers/SearchWallpapers.qml +++ b/Helpers/SearchWallpapers.qml @@ -4,7 +4,7 @@ import Quickshell import Quickshell.Io import qs.Config import qs.Modules -import Caelestia.Models +import ZShell.Models Searcher { id: root diff --git a/Modules/GroupListView.qml b/Modules/GroupListView.qml index 9206433..c4140b7 100644 --- a/Modules/GroupListView.qml +++ b/Modules/GroupListView.qml @@ -104,15 +104,17 @@ Repeater { Layout.fillHeight: true Layout.preferredWidth: 30 color: collapseArea.containsMouse ? "#15FFFFFF" : "transparent" - radius: 4 - visible: groupColumn.isExpanded + radius: groupColumn.isExpanded ? 4 : Layout.preferredHeight / 2 + visible: true + Text { anchors.centerIn: parent - text: "\ue944" - font.family: "Material Symbols Rounded" + text: groupColumn.isExpanded ? "\ue944" : groupColumn.notifications.length + font.family: groupColumn.isExpanded ? "Material Symbols Rounded" : "Rubik" font.pointSize: 18 color: "white" } + MouseArea { id: collapseArea anchors.fill: parent diff --git a/Modules/Resources.qml b/Modules/Resources.qml index b9048d9..85f6962 100644 --- a/Modules/Resources.qml +++ b/Modules/Resources.qml @@ -127,6 +127,10 @@ Item { duration: MaterialEasing.expressiveEffectsTime easing.bezierCurve: MaterialEasing.expressiveEffects } + onFinished: { + if ( !mouseArea.containsMouse ) + closeAnim.start(); + } } ParallelAnimation { diff --git a/Modules/Search.qml b/Modules/Search.qml index 74a3d99..12b7173 100644 --- a/Modules/Search.qml +++ b/Modules/Search.qml @@ -1,6 +1,6 @@ pragma Singleton -import Caelestia +import ZShell import Quickshell import Quickshell.Io diff --git a/Modules/WallpaperItem.qml b/Modules/WallpaperItem.qml index bdc16c8..719ab71 100644 --- a/Modules/WallpaperItem.qml +++ b/Modules/WallpaperItem.qml @@ -1,7 +1,7 @@ import Quickshell import Quickshell.Widgets import QtQuick -import Caelestia.Models +import ZShell.Models Item { id: root diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt new file mode 100644 index 0000000..4a2ff2f --- /dev/null +++ b/Plugins/CMakeLists.txt @@ -0,0 +1 @@ +add_subdirectory(ZShell) diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt new file mode 100644 index 0000000..860e67e --- /dev/null +++ b/Plugins/ZShell/CMakeLists.txt @@ -0,0 +1,43 @@ +find_package(Qt6 REQUIRED COMPONENTS Core Qml Gui Quick Concurrent Sql) +find_package(PkgConfig REQUIRED) + +set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml") +qt_standard_project_setup(REQUIRES 6.9) + +function(qml_module arg_TARGET) + cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;LIBRARIES") + qt_add_qml_module(${arg_TARGET} + URI ${arg_URI} + VERSION 1.0 + SOURCES ${arg_SOURCES} + ) + + qt_query_qml_module(${arg_TARGET} + URI module_uri + VERSION module_version + PLUGIN_TARGET module_plugin_target + TARGET_PATH module_target_path + QMLDIR module_qmldir + TYPEINFO module_typeinfo + ) + set(module_dir "/usr/lib/qt6/qml/${module_target_path}") + install(TARGETS ${arg_TARGET} LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}") + install(TARGETS "${module_plugin_target}" LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}") + install(FILES "${module_qmldir}" DESTINATION "${module_dir}") + install(FILES "${module_typeinfo}" DESTINATION "${module_dir}") + target_link_libraries(${arg_TARGET} PRIVATE Qt::Core Qt::Qml ${arg_LIBRARIES}) +endfunction() + +qml_module(ZShell + URI ZShell + SOURCES + writefile.hpp writefile.cpp + appdb.hpp appdb.cpp + LIBRARIES + Qt::Gui + Qt::Quick + Qt::Concurrent + Qt::Sql +) + +add_subdirectory(Models) diff --git a/Plugins/ZShell/Models/CMakeLists.txt b/Plugins/ZShell/Models/CMakeLists.txt new file mode 100644 index 0000000..785db17 --- /dev/null +++ b/Plugins/ZShell/Models/CMakeLists.txt @@ -0,0 +1,8 @@ +qml_module(ZShell-models + URI ZShell.Models + SOURCES + filesystemmodel.hpp filesystemmodel.cpp + LIBRARIES + Qt::Gui + Qt::Concurrent +) diff --git a/Plugins/ZShell/Models/filesystemmodel.cpp b/Plugins/ZShell/Models/filesystemmodel.cpp new file mode 100644 index 0000000..790cefa --- /dev/null +++ b/Plugins/ZShell/Models/filesystemmodel.cpp @@ -0,0 +1,479 @@ +#include "filesystemmodel.hpp" + +#include +#include +#include + +namespace ZShell::models { + +FileSystemEntry::FileSystemEntry(const QString& path, const QString& relativePath, QObject* parent) + : QObject(parent) + , m_fileInfo(path) + , m_path(path) + , m_relativePath(relativePath) + , m_isImageInitialised(false) + , m_mimeTypeInitialised(false) {} + +QString FileSystemEntry::path() const { + return m_path; +}; + +QString FileSystemEntry::relativePath() const { + return m_relativePath; +}; + +QString FileSystemEntry::name() const { + return m_fileInfo.fileName(); +}; + +QString FileSystemEntry::baseName() const { + return m_fileInfo.baseName(); +}; + +QString FileSystemEntry::parentDir() const { + return m_fileInfo.absolutePath(); +}; + +QString FileSystemEntry::suffix() const { + return m_fileInfo.completeSuffix(); +}; + +qint64 FileSystemEntry::size() const { + return m_fileInfo.size(); +}; + +bool FileSystemEntry::isDir() const { + return m_fileInfo.isDir(); +}; + +bool FileSystemEntry::isImage() const { + if (!m_isImageInitialised) { + QImageReader reader(m_path); + m_isImage = reader.canRead(); + m_isImageInitialised = true; + } + return m_isImage; +} + +QString FileSystemEntry::mimeType() const { + if (!m_mimeTypeInitialised) { + const QMimeDatabase db; + m_mimeType = db.mimeTypeForFile(m_path).name(); + m_mimeTypeInitialised = true; + } + return m_mimeType; +} + +void FileSystemEntry::updateRelativePath(const QDir& dir) { + const auto relPath = dir.relativeFilePath(m_path); + if (m_relativePath != relPath) { + m_relativePath = relPath; + emit relativePathChanged(); + } +} + +FileSystemModel::FileSystemModel(QObject* parent) + : QAbstractListModel(parent) + , m_recursive(false) + , m_watchChanges(true) + , m_showHidden(false) + , m_filter(NoFilter) { + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &FileSystemModel::watchDirIfRecursive); + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &FileSystemModel::updateEntriesForDir); +} + +int FileSystemModel::rowCount(const QModelIndex& parent) const { + if (parent != QModelIndex()) { + return 0; + } + return static_cast(m_entries.size()); +} + +QVariant FileSystemModel::data(const QModelIndex& index, int role) const { + if (role != Qt::UserRole || !index.isValid() || index.row() >= m_entries.size()) { + return QVariant(); + } + return QVariant::fromValue(m_entries.at(index.row())); +} + +QHash FileSystemModel::roleNames() const { + return { { Qt::UserRole, "modelData" } }; +} + +QString FileSystemModel::path() const { + return m_path; +} + +void FileSystemModel::setPath(const QString& path) { + if (m_path == path) { + return; + } + + m_path = path; + emit pathChanged(); + + m_dir.setPath(m_path); + + for (const auto& entry : std::as_const(m_entries)) { + entry->updateRelativePath(m_dir); + } + + update(); +} + +bool FileSystemModel::recursive() const { + return m_recursive; +} + +void FileSystemModel::setRecursive(bool recursive) { + if (m_recursive == recursive) { + return; + } + + m_recursive = recursive; + emit recursiveChanged(); + + update(); +} + +bool FileSystemModel::watchChanges() const { + return m_watchChanges; +} + +void FileSystemModel::setWatchChanges(bool watchChanges) { + if (m_watchChanges == watchChanges) { + return; + } + + m_watchChanges = watchChanges; + emit watchChangesChanged(); + + update(); +} + +bool FileSystemModel::showHidden() const { + return m_showHidden; +} + +void FileSystemModel::setShowHidden(bool showHidden) { + if (m_showHidden == showHidden) { + return; + } + + m_showHidden = showHidden; + emit showHiddenChanged(); + + update(); +} + +bool FileSystemModel::sortReverse() const { + return m_sortReverse; +} + +void FileSystemModel::setSortReverse(bool sortReverse) { + if (m_sortReverse == sortReverse) { + return; + } + + m_sortReverse = sortReverse; + emit sortReverseChanged(); + + update(); +} + +FileSystemModel::Filter FileSystemModel::filter() const { + return m_filter; +} + +void FileSystemModel::setFilter(Filter filter) { + if (m_filter == filter) { + return; + } + + m_filter = filter; + emit filterChanged(); + + update(); +} + +QStringList FileSystemModel::nameFilters() const { + return m_nameFilters; +} + +void FileSystemModel::setNameFilters(const QStringList& nameFilters) { + if (m_nameFilters == nameFilters) { + return; + } + + m_nameFilters = nameFilters; + emit nameFiltersChanged(); + + update(); +} + +QQmlListProperty FileSystemModel::entries() { + return QQmlListProperty(this, &m_entries); +} + +void FileSystemModel::watchDirIfRecursive(const QString& path) { + if (m_recursive && m_watchChanges) { + const auto currentDir = m_dir; + const bool showHidden = m_showHidden; + const auto future = QtConcurrent::run([showHidden, path]() { + QDir::Filters filters = QDir::Dirs | QDir::NoDotAndDotDot; + if (showHidden) { + filters |= QDir::Hidden; + } + + QDirIterator iter(path, filters, QDirIterator::Subdirectories); + QStringList dirs; + while (iter.hasNext()) { + dirs << iter.next(); + } + return dirs; + }); + const auto watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, [currentDir, showHidden, watcher, this]() { + const auto paths = watcher->result(); + if (currentDir == m_dir && showHidden == m_showHidden && !paths.isEmpty()) { + // Ignore if dir or showHidden has changed + m_watcher.addPaths(paths); + } + watcher->deleteLater(); + }); + watcher->setFuture(future); + } +} + +void FileSystemModel::update() { + updateWatcher(); + updateEntries(); +} + +void FileSystemModel::updateWatcher() { + if (!m_watcher.directories().isEmpty()) { + m_watcher.removePaths(m_watcher.directories()); + } + + if (!m_watchChanges || m_path.isEmpty()) { + return; + } + + m_watcher.addPath(m_path); + watchDirIfRecursive(m_path); +} + +void FileSystemModel::updateEntries() { + if (m_path.isEmpty()) { + if (!m_entries.isEmpty()) { + beginResetModel(); + qDeleteAll(m_entries); + m_entries.clear(); + endResetModel(); + emit entriesChanged(); + } + + return; + } + + for (auto& future : m_futures) { + future.cancel(); + } + m_futures.clear(); + + updateEntriesForDir(m_path); +} + +void FileSystemModel::updateEntriesForDir(const QString& dir) { + const auto recursive = m_recursive; + const auto showHidden = m_showHidden; + const auto filter = m_filter; + const auto nameFilters = m_nameFilters; + + QSet oldPaths; + for (const auto& entry : std::as_const(m_entries)) { + oldPaths << entry->path(); + } + + const auto future = QtConcurrent::run([=](QPromise, QSet>>& promise) { + const auto flags = recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags; + + std::optional iter; + + if (filter == Images) { + QStringList extraNameFilters = nameFilters; + const auto formats = QImageReader::supportedImageFormats(); + for (const auto& format : formats) { + extraNameFilters << "*." + format; + } + + QDir::Filters filters = QDir::Files; + if (showHidden) { + filters |= QDir::Hidden; + } + + iter.emplace(dir, extraNameFilters, filters, flags); + } else { + QDir::Filters filters; + + if (filter == Files) { + filters = QDir::Files; + } else if (filter == Dirs) { + filters = QDir::Dirs | QDir::NoDotAndDotDot; + } else { + filters = QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot; + } + + if (showHidden) { + filters |= QDir::Hidden; + } + + if (nameFilters.isEmpty()) { + iter.emplace(dir, filters, flags); + } else { + iter.emplace(dir, nameFilters, filters, flags); + } + } + + QSet newPaths; + while (iter->hasNext()) { + if (promise.isCanceled()) { + return; + } + + QString path = iter->next(); + + if (filter == Images) { + QImageReader reader(path); + if (!reader.canRead()) { + continue; + } + } + + newPaths.insert(path); + } + + if (promise.isCanceled() || newPaths == oldPaths) { + return; + } + + promise.addResult(qMakePair(oldPaths - newPaths, newPaths - oldPaths)); + }); + + if (m_futures.contains(dir)) { + m_futures[dir].cancel(); + } + m_futures.insert(dir, future); + + const auto watcher = new QFutureWatcher, QSet>>(this); + + connect(watcher, &QFutureWatcher, QSet>>::finished, this, [dir, watcher, this]() { + m_futures.remove(dir); + + if (!watcher->future().isResultReadyAt(0)) { + watcher->deleteLater(); + return; + } + + const auto result = watcher->result(); + applyChanges(result.first, result.second); + + watcher->deleteLater(); + }); + + watcher->setFuture(future); +} + +void FileSystemModel::applyChanges(const QSet& removedPaths, const QSet& addedPaths) { + QList removedIndices; + for (int i = 0; i < m_entries.size(); ++i) { + if (removedPaths.contains(m_entries[i]->path())) { + removedIndices << i; + } + } + std::sort(removedIndices.begin(), removedIndices.end(), std::greater()); + + // Batch remove old entries + int start = -1; + int end = -1; + for (int idx : std::as_const(removedIndices)) { + if (start == -1) { + start = idx; + end = idx; + } else if (idx == end - 1) { + end = idx; + } else { + beginRemoveRows(QModelIndex(), end, start); + for (int i = start; i >= end; --i) { + m_entries.takeAt(i)->deleteLater(); + } + endRemoveRows(); + + start = idx; + end = idx; + } + } + if (start != -1) { + beginRemoveRows(QModelIndex(), end, start); + for (int i = start; i >= end; --i) { + m_entries.takeAt(i)->deleteLater(); + } + endRemoveRows(); + } + + // Create new entries + QList newEntries; + for (const auto& path : addedPaths) { + newEntries << new FileSystemEntry(path, m_dir.relativeFilePath(path), this); + } + std::sort(newEntries.begin(), newEntries.end(), [this](const FileSystemEntry* a, const FileSystemEntry* b) { + return compareEntries(a, b); + }); + + // Batch insert new entries + int insertStart = -1; + QList batchItems; + for (const auto& entry : std::as_const(newEntries)) { + const auto it = std::lower_bound( + m_entries.begin(), m_entries.end(), entry, [this](const FileSystemEntry* a, const FileSystemEntry* b) { + return compareEntries(a, b); + }); + const auto row = static_cast(it - m_entries.begin()); + + if (insertStart == -1) { + insertStart = row; + batchItems << entry; + } else if (row == insertStart + batchItems.size()) { + batchItems << entry; + } else { + beginInsertRows(QModelIndex(), insertStart, insertStart + static_cast(batchItems.size()) - 1); + for (int i = 0; i < batchItems.size(); ++i) { + m_entries.insert(insertStart + i, batchItems[i]); + } + endInsertRows(); + + insertStart = row; + batchItems.clear(); + batchItems << entry; + } + } + if (!batchItems.isEmpty()) { + beginInsertRows(QModelIndex(), insertStart, insertStart + static_cast(batchItems.size()) - 1); + for (int i = 0; i < batchItems.size(); ++i) { + m_entries.insert(insertStart + i, batchItems[i]); + } + endInsertRows(); + } + + emit entriesChanged(); +} + +bool FileSystemModel::compareEntries(const FileSystemEntry* a, const FileSystemEntry* b) const { + if (a->isDir() != b->isDir()) { + return m_sortReverse ^ a->isDir(); + } + const auto cmp = a->relativePath().localeAwareCompare(b->relativePath()); + return m_sortReverse ? cmp > 0 : cmp < 0; +} + +} // namespace ZShell::models diff --git a/Plugins/ZShell/Models/filesystemmodel.hpp b/Plugins/ZShell/Models/filesystemmodel.hpp new file mode 100644 index 0000000..f2bbfcd --- /dev/null +++ b/Plugins/ZShell/Models/filesystemmodel.hpp @@ -0,0 +1,148 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::models { + +class FileSystemEntry : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("FileSystemEntry instances can only be retrieved from a FileSystemModel") + + Q_PROPERTY(QString path READ path CONSTANT) + Q_PROPERTY(QString relativePath READ relativePath NOTIFY relativePathChanged) + Q_PROPERTY(QString name READ name CONSTANT) + Q_PROPERTY(QString baseName READ baseName CONSTANT) + Q_PROPERTY(QString parentDir READ parentDir CONSTANT) + Q_PROPERTY(QString suffix READ suffix CONSTANT) + Q_PROPERTY(qint64 size READ size CONSTANT) + Q_PROPERTY(bool isDir READ isDir CONSTANT) + Q_PROPERTY(bool isImage READ isImage CONSTANT) + Q_PROPERTY(QString mimeType READ mimeType CONSTANT) + +public: + explicit FileSystemEntry(const QString& path, const QString& relativePath, QObject* parent = nullptr); + + [[nodiscard]] QString path() const; + [[nodiscard]] QString relativePath() const; + [[nodiscard]] QString name() const; + [[nodiscard]] QString baseName() const; + [[nodiscard]] QString parentDir() const; + [[nodiscard]] QString suffix() const; + [[nodiscard]] qint64 size() const; + [[nodiscard]] bool isDir() const; + [[nodiscard]] bool isImage() const; + [[nodiscard]] QString mimeType() const; + + void updateRelativePath(const QDir& dir); + +signals: + void relativePathChanged(); + +private: + const QFileInfo m_fileInfo; + + const QString m_path; + QString m_relativePath; + + mutable bool m_isImage; + mutable bool m_isImageInitialised; + + mutable QString m_mimeType; + mutable bool m_mimeTypeInitialised; +}; + +class FileSystemModel : public QAbstractListModel { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged) + Q_PROPERTY(bool recursive READ recursive WRITE setRecursive NOTIFY recursiveChanged) + Q_PROPERTY(bool watchChanges READ watchChanges WRITE setWatchChanges NOTIFY watchChangesChanged) + Q_PROPERTY(bool showHidden READ showHidden WRITE setShowHidden NOTIFY showHiddenChanged) + Q_PROPERTY(bool sortReverse READ sortReverse WRITE setSortReverse NOTIFY sortReverseChanged) + Q_PROPERTY(Filter filter READ filter WRITE setFilter NOTIFY filterChanged) + Q_PROPERTY(QStringList nameFilters READ nameFilters WRITE setNameFilters NOTIFY nameFiltersChanged) + + Q_PROPERTY(QQmlListProperty entries READ entries NOTIFY entriesChanged) + +public: + enum Filter { + NoFilter, + Images, + Files, + Dirs + }; + Q_ENUM(Filter) + + explicit FileSystemModel(QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + [[nodiscard]] QString path() const; + void setPath(const QString& path); + + [[nodiscard]] bool recursive() const; + void setRecursive(bool recursive); + + [[nodiscard]] bool watchChanges() const; + void setWatchChanges(bool watchChanges); + + [[nodiscard]] bool showHidden() const; + void setShowHidden(bool showHidden); + + [[nodiscard]] bool sortReverse() const; + void setSortReverse(bool sortReverse); + + [[nodiscard]] Filter filter() const; + void setFilter(Filter filter); + + [[nodiscard]] QStringList nameFilters() const; + void setNameFilters(const QStringList& nameFilters); + + [[nodiscard]] QQmlListProperty entries(); + +signals: + void pathChanged(); + void recursiveChanged(); + void watchChangesChanged(); + void showHiddenChanged(); + void sortReverseChanged(); + void filterChanged(); + void nameFiltersChanged(); + void entriesChanged(); + +private: + QDir m_dir; + QFileSystemWatcher m_watcher; + QList m_entries; + QHash, QSet>>> m_futures; + + QString m_path; + bool m_recursive; + bool m_watchChanges; + bool m_showHidden; + bool m_sortReverse; + Filter m_filter; + QStringList m_nameFilters; + + void watchDirIfRecursive(const QString& path); + void update(); + void updateWatcher(); + void updateEntries(); + void updateEntriesForDir(const QString& dir); + void applyChanges(const QSet& removedPaths, const QSet& addedPaths); + [[nodiscard]] bool compareEntries(const FileSystemEntry* a, const FileSystemEntry* b) const; +}; + +} // namespace ZShell::models diff --git a/Plugins/ZShell/appdb.cpp b/Plugins/ZShell/appdb.cpp new file mode 100644 index 0000000..8614df3 --- /dev/null +++ b/Plugins/ZShell/appdb.cpp @@ -0,0 +1,265 @@ +#include "appdb.hpp" + +#include +#include +#include + +namespace ZShell { + +AppEntry::AppEntry(QObject* entry, unsigned int frequency, QObject* parent) + : QObject(parent) + , m_entry(entry) + , m_frequency(frequency) { + const auto mo = m_entry->metaObject(); + const auto tmo = metaObject(); + + for (const auto& prop : + { "name", "comment", "execString", "startupClass", "genericName", "categories", "keywords" }) { + const auto metaProp = mo->property(mo->indexOfProperty(prop)); + const auto thisMetaProp = tmo->property(tmo->indexOfProperty(prop)); + QObject::connect(m_entry, metaProp.notifySignal(), this, thisMetaProp.notifySignal()); + } + + QObject::connect(m_entry, &QObject::destroyed, this, [this]() { + m_entry = nullptr; + deleteLater(); + }); +} + +QObject* AppEntry::entry() const { + return m_entry; +} + +quint32 AppEntry::frequency() const { + return m_frequency; +} + +void AppEntry::setFrequency(unsigned int frequency) { + if (m_frequency != frequency) { + m_frequency = frequency; + emit frequencyChanged(); + } +} + +void AppEntry::incrementFrequency() { + m_frequency++; + emit frequencyChanged(); +} + +QString AppEntry::id() const { + if (!m_entry) { + return ""; + } + return m_entry->property("id").toString(); +} + +QString AppEntry::name() const { + if (!m_entry) { + return ""; + } + return m_entry->property("name").toString(); +} + +QString AppEntry::comment() const { + if (!m_entry) { + return ""; + } + return m_entry->property("comment").toString(); +} + +QString AppEntry::execString() const { + if (!m_entry) { + return ""; + } + return m_entry->property("execString").toString(); +} + +QString AppEntry::startupClass() const { + if (!m_entry) { + return ""; + } + return m_entry->property("startupClass").toString(); +} + +QString AppEntry::genericName() const { + if (!m_entry) { + return ""; + } + return m_entry->property("genericName").toString(); +} + +QString AppEntry::categories() const { + if (!m_entry) { + return ""; + } + return m_entry->property("categories").toStringList().join(" "); +} + +QString AppEntry::keywords() const { + if (!m_entry) { + return ""; + } + return m_entry->property("keywords").toStringList().join(" "); +} + +AppDb::AppDb(QObject* parent) + : QObject(parent) + , m_timer(new QTimer(this)) + , m_uuid(QUuid::createUuid().toString()) { + m_timer->setSingleShot(true); + m_timer->setInterval(300); + QObject::connect(m_timer, &QTimer::timeout, this, &AppDb::updateApps); + + auto db = QSqlDatabase::addDatabase("QSQLITE", m_uuid); + db.setDatabaseName(":memory:"); + db.open(); + + QSqlQuery query(db); + query.exec("CREATE TABLE IF NOT EXISTS frequencies (id TEXT PRIMARY KEY, frequency INTEGER)"); +} + +QString AppDb::uuid() const { + return m_uuid; +} + +QString AppDb::path() const { + return m_path; +} + +void AppDb::setPath(const QString& path) { + auto newPath = path.isEmpty() ? ":memory:" : path; + + if (m_path == newPath) { + return; + } + + m_path = newPath; + emit pathChanged(); + + auto db = QSqlDatabase::database(m_uuid, false); + db.close(); + db.setDatabaseName(newPath); + db.open(); + + QSqlQuery query(db); + query.exec("CREATE TABLE IF NOT EXISTS frequencies (id TEXT PRIMARY KEY, frequency INTEGER)"); + + updateAppFrequencies(); +} + +QObjectList AppDb::entries() const { + return m_entries; +} + +void AppDb::setEntries(const QObjectList& entries) { + if (m_entries == entries) { + return; + } + + m_entries = entries; + emit entriesChanged(); + + m_timer->start(); +} + +QQmlListProperty AppDb::apps() { + return QQmlListProperty(this, &getSortedApps()); +} + +void AppDb::incrementFrequency(const QString& id) { + auto db = QSqlDatabase::database(m_uuid); + QSqlQuery query(db); + + query.prepare("INSERT INTO frequencies (id, frequency) " + "VALUES (:id, 1) " + "ON CONFLICT (id) DO UPDATE SET frequency = frequency + 1"); + query.bindValue(":id", id); + query.exec(); + + auto* app = m_apps.value(id); + if (app) { + const auto before = getSortedApps(); + + app->incrementFrequency(); + + if (before != getSortedApps()) { + emit appsChanged(); + } + } else { + qWarning() << "AppDb::incrementFrequency: could not find app with id" << id; + } +} + +QList& AppDb::getSortedApps() const { + m_sortedApps = m_apps.values(); + std::sort(m_sortedApps.begin(), m_sortedApps.end(), [](AppEntry* a, AppEntry* b) { + if (a->frequency() != b->frequency()) { + return a->frequency() > b->frequency(); + } + return a->name().localeAwareCompare(b->name()) < 0; + }); + return m_sortedApps; +} + +quint32 AppDb::getFrequency(const QString& id) const { + auto db = QSqlDatabase::database(m_uuid); + QSqlQuery query(db); + + query.prepare("SELECT frequency FROM frequencies WHERE id = :id"); + query.bindValue(":id", id); + + if (query.exec() && query.next()) { + return query.value(0).toUInt(); + } + + return 0; +} + +void AppDb::updateAppFrequencies() { + const auto before = getSortedApps(); + + for (auto* app : std::as_const(m_apps)) { + app->setFrequency(getFrequency(app->id())); + } + + if (before != getSortedApps()) { + emit appsChanged(); + } +} + +void AppDb::updateApps() { + bool dirty = false; + + for (const auto& entry : std::as_const(m_entries)) { + const auto id = entry->property("id").toString(); + if (!m_apps.contains(id)) { + dirty = true; + auto* const newEntry = new AppEntry(entry, getFrequency(id), this); + QObject::connect(newEntry, &QObject::destroyed, this, [id, this]() { + if (m_apps.remove(id)) { + emit appsChanged(); + } + }); + m_apps.insert(id, newEntry); + } + } + + QSet newIds; + for (const auto& entry : std::as_const(m_entries)) { + newIds.insert(entry->property("id").toString()); + } + + for (auto it = m_apps.keyBegin(); it != m_apps.keyEnd(); ++it) { + const auto& id = *it; + if (!newIds.contains(id)) { + dirty = true; + m_apps.take(id)->deleteLater(); + } + } + + if (dirty) { + emit appsChanged(); + } +} + +} // namespace ZShell diff --git a/Plugins/ZShell/appdb.hpp b/Plugins/ZShell/appdb.hpp new file mode 100644 index 0000000..cf1b257 --- /dev/null +++ b/Plugins/ZShell/appdb.hpp @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace ZShell { + +class AppEntry : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("AppEntry instances can only be retrieved from an AppDb") + + // The actual DesktopEntry, but we don't have access to the type so it's a QObject + Q_PROPERTY(QObject* entry READ entry CONSTANT) + + Q_PROPERTY(quint32 frequency READ frequency NOTIFY frequencyChanged) + Q_PROPERTY(QString id READ id CONSTANT) + Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(QString comment READ comment NOTIFY commentChanged) + Q_PROPERTY(QString execString READ execString NOTIFY execStringChanged) + Q_PROPERTY(QString startupClass READ startupClass NOTIFY startupClassChanged) + Q_PROPERTY(QString genericName READ genericName NOTIFY genericNameChanged) + Q_PROPERTY(QString categories READ categories NOTIFY categoriesChanged) + Q_PROPERTY(QString keywords READ keywords NOTIFY keywordsChanged) + +public: + explicit AppEntry(QObject* entry, quint32 frequency, QObject* parent = nullptr); + + [[nodiscard]] QObject* entry() const; + + [[nodiscard]] quint32 frequency() const; + void setFrequency(quint32 frequency); + void incrementFrequency(); + + [[nodiscard]] QString id() const; + [[nodiscard]] QString name() const; + [[nodiscard]] QString comment() const; + [[nodiscard]] QString execString() const; + [[nodiscard]] QString startupClass() const; + [[nodiscard]] QString genericName() const; + [[nodiscard]] QString categories() const; + [[nodiscard]] QString keywords() const; + +signals: + void frequencyChanged(); + void nameChanged(); + void commentChanged(); + void execStringChanged(); + void startupClassChanged(); + void genericNameChanged(); + void categoriesChanged(); + void keywordsChanged(); + +private: + QObject* m_entry; + quint32 m_frequency; +}; + +class AppDb : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString uuid READ uuid CONSTANT) + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED) + Q_PROPERTY(QObjectList entries READ entries WRITE setEntries NOTIFY entriesChanged REQUIRED) + Q_PROPERTY(QQmlListProperty apps READ apps NOTIFY appsChanged) + +public: + explicit AppDb(QObject* parent = nullptr); + + [[nodiscard]] QString uuid() const; + + [[nodiscard]] QString path() const; + void setPath(const QString& path); + + [[nodiscard]] QObjectList entries() const; + void setEntries(const QObjectList& entries); + + [[nodiscard]] QQmlListProperty apps(); + + Q_INVOKABLE void incrementFrequency(const QString& id); + +signals: + void pathChanged(); + void entriesChanged(); + void appsChanged(); + +private: + QTimer* m_timer; + + const QString m_uuid; + QString m_path; + QObjectList m_entries; + QHash m_apps; + mutable QList m_sortedApps; + + QList& getSortedApps() const; + quint32 getFrequency(const QString& id) const; + void updateAppFrequencies(); + void updateApps(); +}; + +} // namespace ZShell diff --git a/Plugins/ZShell/writefile.cpp b/Plugins/ZShell/writefile.cpp new file mode 100644 index 0000000..a9d30b5 --- /dev/null +++ b/Plugins/ZShell/writefile.cpp @@ -0,0 +1,131 @@ +#include "writefile.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell { + +void ZShellIo::saveItem(QQuickItem* target, const QUrl& path) { + this->saveItem(target, path, QRect(), QJSValue(), QJSValue()); +} + +void ZShellIo::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect) { + this->saveItem(target, path, rect, QJSValue(), QJSValue()); +} + +void ZShellIo::saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved) { + this->saveItem(target, path, QRect(), onSaved, QJSValue()); +} + +void ZShellIo::saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved, QJSValue onFailed) { + this->saveItem(target, path, QRect(), onSaved, onFailed); +} + +void ZShellIo::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved) { + this->saveItem(target, path, rect, onSaved, QJSValue()); +} + +void ZShellIo::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved, QJSValue onFailed) { + if (!target) { + qWarning() << "ZShellIo::saveItem: a target is required"; + return; + } + + if (!path.isLocalFile()) { + qWarning() << "ZShellIo::saveItem:" << path << "is not a local file"; + return; + } + + if (!target->window()) { + qWarning() << "ZShellIo::saveItem: unable to save target" << target << "without a window"; + return; + } + + auto scaledRect = rect; + const qreal scale = target->window()->devicePixelRatio(); + if (rect.isValid() && !qFuzzyCompare(scale + 1.0, 2.0)) { + scaledRect = + QRectF(rect.left() * scale, rect.top() * scale, rect.width() * scale, rect.height() * scale).toRect(); + } + + const QSharedPointer grabResult = target->grabToImage(); + + QObject::connect(grabResult.data(), &QQuickItemGrabResult::ready, this, + [grabResult, scaledRect, path, onSaved, onFailed, this]() { + const auto future = QtConcurrent::run([=]() { + QImage image = grabResult->image(); + + if (scaledRect.isValid()) { + image = image.copy(scaledRect); + } + + const QString file = path.toLocalFile(); + const QString parent = QFileInfo(file).absolutePath(); + return QDir().mkpath(parent) && image.save(file); + }); + + auto* watcher = new QFutureWatcher(this); + auto* engine = qmlEngine(this); + + QObject::connect(watcher, &QFutureWatcher::finished, this, [=]() { + if (watcher->result()) { + if (onSaved.isCallable()) { + onSaved.call( + { QJSValue(path.toLocalFile()), engine->toScriptValue(QVariant::fromValue(path)) }); + } + } else { + qWarning() << "ZShellIo::saveItem: failed to save" << path; + if (onFailed.isCallable()) { + onFailed.call({ engine->toScriptValue(QVariant::fromValue(path)) }); + } + } + watcher->deleteLater(); + }); + watcher->setFuture(future); + }); +} + +bool ZShellIo::copyFile(const QUrl& source, const QUrl& target, bool overwrite) const { + if (!source.isLocalFile()) { + qWarning() << "ZShellIo::copyFile: source" << source << "is not a local file"; + return false; + } + if (!target.isLocalFile()) { + qWarning() << "ZShellIo::copyFile: target" << target << "is not a local file"; + return false; + } + + if (overwrite) { + if (!QFile::remove(target.toLocalFile())) { + qWarning() << "ZShellIo::copyFile: overwrite was specified but failed to remove" << target.toLocalFile(); + return false; + } + } + + return QFile::copy(source.toLocalFile(), target.toLocalFile()); +} + +bool ZShellIo::deleteFile(const QUrl& path) const { + if (!path.isLocalFile()) { + qWarning() << "ZShellIo::deleteFile: path" << path << "is not a local file"; + return false; + } + + return QFile::remove(path.toLocalFile()); +} + +QString ZShellIo::toLocalFile(const QUrl& url) const { + if (!url.isLocalFile()) { + qWarning() << "ZShellIo::toLocalFile: given url is not a local file" << url; + return QString(); + } + + return url.toLocalFile(); +} + +} // namespace ZShell diff --git a/Plugins/ZShell/writefile.hpp b/Plugins/ZShell/writefile.hpp new file mode 100644 index 0000000..62b5e03 --- /dev/null +++ b/Plugins/ZShell/writefile.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +namespace ZShell { + +class ZShellIo : public QObject { + + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + +public: + // clang-format off + Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path); + Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect); + Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved); + Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved, QJSValue onFailed); + Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved); + Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved, QJSValue onFailed); + // clang-format on + + Q_INVOKABLE bool copyFile(const QUrl& source, const QUrl& target, bool overwrite = true) const; + Q_INVOKABLE bool deleteFile(const QUrl& path) const; + Q_INVOKABLE QString toLocalFile(const QUrl& url) const; +}; + + +} // namespace ZShell diff --git a/shell.qml b/shell.qml index 918ada3..2a7f1ae 100644 --- a/shell.qml +++ b/shell.qml @@ -2,10 +2,11 @@ //@ pragma Env QSG_RENDER_LOOP=threaded import Quickshell import qs.Modules +import qs.Helpers Scope { Bar {} Wallpaper {} - // NotificationCenter {} Launcher {} + AreaPicker {} }