diff --git a/.clang-tidy b/.clang-tidy index 6362e662..ca6c9549 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -5,6 +5,8 @@ Checks: > -*, bugprone-*, -bugprone-easily-swappable-parameters, + -bugprone-forward-declararion-namespace, + -bugprone-forward-declararion-namespace, concurrency-*, cppcoreguidelines-*, -cppcoreguidelines-owning-memory, @@ -13,8 +15,10 @@ Checks: > -cppcoreguidelines-avoid-const-or-ref-data-members, -cppcoreguidelines-non-private-member-variables-in-classes, -cppcoreguidelines-avoid-goto, - google-build-using-namespace. - google-explicit-constructor, + -cppcoreguidelines-pro-bounds-array-to-pointer-decay, + -cppcoreguidelines-avoid-do-while, + -cppcoreguidelines-pro-type-reinterpret-cast, + -cppcoreguidelines-pro-type-vararg, google-global-names-in-headers, google-readability-casting, google-runtime-int, @@ -26,6 +30,7 @@ Checks: > -modernize-return-braced-init-list, -modernize-use-trailing-return-type, performance-*, + -performance-avoid-endl, portability-std-allocator-const, readability-*, -readability-function-cognitive-complexity, @@ -37,6 +42,8 @@ Checks: > -readability-redundant-access-specifiers, -readability-else-after-return, -readability-container-data-pointer, + -readability-implicit-bool-conversion, + -readability-avoid-nested-conditional-operator, tidyfox-*, CheckOptions: performance-for-range-copy.WarnOnAllAutoCopies: true diff --git a/.editorconfig b/.editorconfig index 6b1b58df..439ba6b7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,3 +9,7 @@ indent_style = tab [*.nix] indent_style = space indent_size = 2 + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..0086358d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/crash.yml b/.github/ISSUE_TEMPLATE/crash.yml new file mode 100644 index 00000000..c8b4804e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/crash.yml @@ -0,0 +1,82 @@ +name: Crash Report +description: Quickshell has crashed +labels: ["bug", "crash"] +body: + - type: textarea + id: crashinfo + attributes: + label: General crash information + description: | + Paste the contents of the `info.txt` file in your crash folder here. + value: "
General information + + + ``` + + + + ``` + + +
" + validations: + required: true + - type: textarea + id: userinfo + attributes: + label: What caused the crash + description: | + Any information likely to help debug the crash. What were you doing when the crash occurred, + what changes did you make, can you get it to happen again? + - type: textarea + id: dump + attributes: + label: Minidump + description: | + Attach `minidump.dmp.log` here. If it is too big to upload, compress it. + + You may skip this step if quickshell crashed while processing a password + or other sensitive information. If you skipped it write why instead. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Log file + description: | + Attach `log.qslog.log` here. If it is too big to upload, compress it. + + You can preview the log if you'd like using `quickshell read-log `. + validations: + required: true + - type: textarea + id: config + attributes: + label: Configuration + description: | + Attach your configuration here, preferrably in full (not just one file). + Compress it into a zip, tar, etc. + + This will help us reproduce the crash ourselves. + - type: textarea + id: bt + attributes: + label: Backtrace + description: | + If you have gdb installed and use systemd, or otherwise know how to get a backtrace, + we would appreciate one. (You may have gdb installed without knowing it) + + 1. Run `coredumpctl debug ` where `pid` is the number shown after "Crashed process ID" + in the crash reporter. + 2. Once it loads, type `bt -full` (then enter) + 3. Copy the output and attach it as a file or in a spoiler. + - type: textarea + id: exe + attributes: + label: Executable + description: | + If the crash folder contains a executable.txt file, upload it here. If not you can ignore this field. + If it is too big to upload, compress it. + + Note: executable.txt is the quickshell binary. It has a .txt extension due to github's limitations on + filetypes. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..b176e982 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,55 @@ +name: Build +on: [push, pull_request, workflow_dispatch] + +jobs: + nix: + name: Nix + strategy: + matrix: + qtver: [qt6.8.0, qt6.7.3, qt6.7.2, qt6.7.1, qt6.7.0, qt6.6.3, qt6.6.2, qt6.6.1, qt6.6.0] + compiler: [clang, gcc] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # Use cachix action over detsys for testing with act. + # - uses: cachix/install-nix-action@v27 + - uses: DeterminateSystems/nix-installer-action@main + + - name: Download Dependencies + run: nix-build --no-out-link --expr '((import ./ci/matrix.nix) { qtver = "${{ matrix.qtver }}"; compiler = "${{ matrix.compiler }}"; }).inputDerivation' + + - name: Build + run: nix-build --no-out-link --expr '(import ./ci/matrix.nix) { qtver = "${{ matrix.qtver }}"; compiler = "${{ matrix.compiler }}"; }' + + archlinux: + name: Archlinux + runs-on: ubuntu-latest + container: archlinux + steps: + - uses: actions/checkout@v4 + + - name: Download Dependencies + run: | + pacman --noconfirm --noprogressbar -Syyu + pacman --noconfirm --noprogressbar -Sy \ + base-devel \ + cmake \ + ninja \ + pkgconf \ + qt6-base \ + qt6-declarative \ + qt6-svg \ + qt6-wayland \ + qt6-shadertools \ + wayland-protocols \ + wayland \ + libxcb \ + libpipewire \ + cli11 \ + jemalloc + + - name: Build + # breakpad is annoying to build in ci due to makepkg not running as root + run: | + cmake -GNinja -B build -DCRASH_REPORTER=OFF + cmake --build build diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..a53221cb --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,25 @@ +name: Lint +on: [push, pull_request, workflow_dispatch] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # Use cachix action over detsys for testing with act. + # - uses: cachix/install-nix-action@v27 + - uses: DeterminateSystems/nix-installer-action@main + - uses: nicknovitski/nix-develop@v1 + + - name: Check formatting + run: clang-format -Werror --dry-run src/**/*.{cpp,hpp} + + # required for lint + - name: Build + run: | + just configure debug -DNO_PCH=ON -DBUILD_TESTING=ON + just build + + - name: Run lints + run: just lint-ci diff --git a/.gitignore b/.gitignore index 1933837e..dcdefe39 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# related repos +/docs +/examples + # build files /result /build/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 74013769..00000000 --- a/.gitmodules +++ /dev/null @@ -1,6 +0,0 @@ -[submodule "docs"] - path = docs - url = https://git.outfoxxed.me/outfoxxed/quickshell-docs -[submodule "examples"] - path = examples - url = https://git.outfoxxed.me/outfoxxed/quickshell-examples diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 00000000..cf6b3a03 --- /dev/null +++ b/BUILD.md @@ -0,0 +1,225 @@ +# Build instructions +Instructions for building from source and distro packagers. We highly recommend +distro packagers read through this page fully. + +## Packaging +If you are packaging quickshell for official or unofficial distribution channels, +such as a distro package repository, user repository, or other shared build location, +please set the following CMake flags. + +`-DDISTRIBUTOR="your distribution platform"` + +Please make this descriptive enough to identify your specific package, for example: +- `Official Nix Flake` +- `AUR (quickshell-git)` +- `Nixpkgs` +- `Fedora COPR (errornointernet/quickshell)` + +`-DDISTRIBUTOR_DEBUGINFO_AVAILABLE=YES/NO` + +If we can retrieve binaries and debug information for the package without actually running your +distribution (e.g. from an website), and you would like to strip the binary, please set this to `YES`. + +If we cannot retrieve debug information, please set this to `NO` and +**ensure you aren't distributing stripped (non debuggable) binaries**. + +In both cases you should build with `-DCMAKE_BUILD_TYPE=RelWithDebInfo` (then split or keep the debuginfo). + +### QML Module dir +Currently all QML modules are statically linked to quickshell, but this is where +tooling information will go. + +`-DINSTALL_QML_PREFIX="path/to/qml"` + +`-DINSTALL_QMLDIR="/full/path/to/qml"` + +`INSTALL_QML_PREFIX` works the same as `INSTALL_QMLDIR`, except it prepends `CMAKE_INSTALL_PREFIX`. You usually want this. + +## Dependencies +Quickshell has a set of base dependencies you will always need, names vary by distro: + +- `cmake` +- `qt6base` +- `qt6declarative` +- `qtshadertools` (build-time only) +- `spirv-tools` (build-time only) +- `pkg-config` (build-time only) +- `cli11` + +On some distros, private Qt headers are in separate packages which you may have to install. +We currently require private headers for the following libraries: + +- `qt6declarative` +- `qt6wayland` + +We recommend an implicit dependency on `qt6svg`. If it is not installed, svg images and +svg icons will not work, including system ones. + +At least Qt 6.6 is required. + +All features are enabled by default and some have their own dependencies. + +### Crash Reporter +The crash reporter catches crashes, restarts quickshell when it crashes, +and collects useful crash information in one place. Leaving this enabled will +enable us to fix bugs far more easily. + +To disable: `-DCRASH_REPORTER=OFF` + +Dependencies: `google-breakpad` + +### Jemalloc +We recommend leaving Jemalloc enabled as it will mask memory fragmentation caused +by the QML engine, which results in much lower memory usage. Without this you +will get a perceived memory leak. + +To disable: `-DUSE_JEMALLOC=OFF` + +Dependencies: `jemalloc` + +### Unix Sockets +This feature allows interaction with unix sockets and creating socket servers +which is useful for IPC and has no additional dependencies. + +WARNING: Disabling unix sockets will NOT make it safe to run arbitrary code using quickshell. +There are many vectors which mallicious code can use to escape into your system. + +To disable: `-DSOCKETS=OFF` + +### Wayland +This feature enables wayland support. Subfeatures exist for each particular wayland integration. + +WARNING: Wayland integration relies on features that are not part of the public Qt API and which +may break in minor releases. Updating quickshell's dependencies without ensuring without ensuring +that the current Qt version is supported WILL result in quickshell failing to build or misbehaving +at runtime. + +Currently supported Qt versions: `6.6`, `6.7`. + +To disable: `-DWAYLAND=OFF` + +Dependencies: + - `qt6wayland` + - `wayland` (libwayland-client) + - `wayland-scanner` (may be part of your distro's wayland package) + - `wayland-protocols` + +#### Wlroots Layershell +Enables wlroots layershell integration through the [zwlr-layer-shell-v1] protocol, +enabling use cases such as bars overlays and backgrounds. +This feature has no extra dependencies. + +To disable: `-DWAYLAND_WLR_LAYERSHELL=OFF` + +[zwlr-layer-shell-v1]: https://wayland.app/protocols/wlr-layer-shell-unstable-v1 + +#### Session Lock +Enables session lock support through the [ext-session-lock-v1] protocol, +which allows quickshell to be used as a session lock under compatible wayland compositors. + +To disable: `-DWAYLAND_SESSION_LOCK=OFF` + +[ext-session-lock-v1]: https://wayland.app/protocols/ext-session-lock-v1 + + +#### Foreign Toplevel Management +Enables management of windows of other clients through the [zwlr-foreign-toplevel-management-v1] protocol, +which allows quickshell to be used as a session lock under compatible wayland compositors. + +[zwlr-foreign-toplevel-management-v1]: https://wayland.app/protocols/wlr-foreign-toplevel-management-unstable-v1 + +To disable: `-DWAYLAND_TOPLEVEL_MANAGEMENT=OFF` + +### X11 +This feature enables x11 support. Currently this implements panel windows for X11 similarly +to the wlroots layershell above. + +To disable: `-DX11=OFF` + +Dependencies: `libxcb` + +### Pipewire +This features enables viewing and management of pipewire nodes. + +To disable: `-DSERVICE_PIPEWIRE=OFF` + +Dependencies: `libpipewire` + +### StatusNotifier / System Tray +This feature enables system tray support using the status notifier dbus protocol. + +To disable: `-DSERVICE_STATUS_NOTIFIER=OFF` + +Dependencies: `qt6dbus` (usually part of qt6base) + +### MPRIS +This feature enables access to MPRIS compatible media players using its dbus protocol. + +To disable: `-DSERVICE_MPRIS=OFF` + +Dependencies: `qt6dbus` (usually part of qt6base) + +### PAM +This feature enables PAM integration for user authentication. + +To disable: `-DSERVICE_PAM=OFF` + +Dependencies: `pam` + +### Hyprland +This feature enables hyprland specific integrations. It requires wayland support +but has no extra dependencies. + +To disable: `-DHYPRLAND=OFF` + +#### Hyprland Global Shortcuts +Enables creation of global shortcuts under hyprland through the [hyprland-global-shortcuts-v1] +protocol. Generally a much nicer alternative to using unix sockets to implement the same thing. +This feature has no extra dependencies. + +To disable: `-DHYPRLAND_GLOBAL_SHORTCUTS=OFF` + +[hyprland-global-shortcuts-v1]: https://github.com/hyprwm/hyprland-protocols/blob/main/protocols/hyprland-global-shortcuts-v1.xml + +#### Hyprland Focus Grab +Enables windows to grab focus similarly to a context menu under hyprland through the +[hyprland-focus-grab-v1] protocol. This feature has no extra dependencies. + +To disable: `-DHYPRLAND_FOCUS_GRAB=OFF` + +[hyprland-focus-grab-v1]: https://github.com/hyprwm/hyprland-protocols/blob/main/protocols/hyprland-focus-grab-v1.xml + +### i3/Sway +Enables i3 and Sway specific features, does not have any dependency on Wayland or x11. + +To disable: `-DI3=OFF` + +#### i3/Sway IPC +Enables interfacing with i3 and Sway's IPC. + +To disable: `-DI3_IPC=OFF` + +## Building +*For developers and prospective contributors: See [CONTRIBUTING.md](CONTRIBUTING.md).* + +We highly recommend using `ninja` to run the build, but you can use makefiles if you must. + +#### Configuring the build +```sh +$ cmake -GNinja -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo [additional disable flags from above here] +``` + +Note that features you do not supply dependencies for MUST be disabled with their associated flags +or quickshell will fail to build. + +Additionally, note that clang builds much faster than gcc if you care. + +#### Building +```sh +$ cmake --build build +``` + +#### Installing +```sh +$ cmake --install build +``` diff --git a/CMakeLists.txt b/CMakeLists.txt index 159acd49..a4919952 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,50 +5,76 @@ set(QT_MIN_VERSION "6.6.0") set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) -option(BUILD_TESTING "Build tests" OFF) -option(ASAN "Enable ASAN" OFF) -option(FRAME_POINTERS "Always keep frame pointers" ${ASAN}) +set(QS_BUILD_OPTIONS "") -option(NVIDIA_COMPAT "Workarounds for nvidia gpus" OFF) -option(SOCKETS "Enable unix socket support" ON) -option(WAYLAND "Enable wayland support" ON) -option(WAYLAND_WLR_LAYERSHELL "Support the zwlr_layer_shell_v1 wayland protocol" ON) -option(WAYLAND_SESSION_LOCK "Support the ext_session_lock_v1 wayland protocol" ON) -option(HYPRLAND "Support hyprland specific features" ON) -option(HYPRLAND_GLOBAL_SHORTCUTS "Hyprland Global Shortcuts" ON) -option(HYPRLAND_FOCUS_GRAB "Hyprland Focus Grabbing" ON) -option(SERVICE_STATUS_NOTIFIER "StatusNotifierItem service" ON) -option(SERVICE_PIPEWIRE "PipeWire service" ON) +function(boption VAR NAME DEFAULT) + cmake_parse_arguments(PARSE_ARGV 3 arg "" "REQUIRES" "") + + option(${VAR} ${NAME} ${DEFAULT}) + + set(STATUS "${VAR}_status") + set(EFFECTIVE "${VAR}_effective") + set(${STATUS} ${${VAR}}) + set(${EFFECTIVE} ${${VAR}}) + + if (${${VAR}} AND DEFINED arg_REQUIRES) + set(REQUIRED_EFFECTIVE "${arg_REQUIRES}_effective") + if (NOT ${${REQUIRED_EFFECTIVE}}) + set(${STATUS} "OFF (Requires ${arg_REQUIRES})") + set(${EFFECTIVE} OFF) + endif() + endif() + + set(${EFFECTIVE} "${${EFFECTIVE}}" PARENT_SCOPE) + + message(STATUS " ${NAME}: ${${STATUS}}") + + string(APPEND QS_BUILD_OPTIONS "\\n ${NAME}: ${${STATUS}}") + set(QS_BUILD_OPTIONS "${QS_BUILD_OPTIONS}" PARENT_SCOPE) +endfunction() + +set(DISTRIBUTOR "Unset" CACHE STRING "Distributor") +string(APPEND QS_BUILD_OPTIONS " Distributor: ${DISTRIBUTOR}") message(STATUS "Quickshell configuration") -message(STATUS " NVIDIA workarounds: ${NVIDIA_COMPAT}") -message(STATUS " Build tests: ${BUILD_TESTING}") -message(STATUS " Sockets: ${SOCKETS}") -message(STATUS " Wayland: ${WAYLAND}") -if (WAYLAND) - message(STATUS " Wlroots Layershell: ${WAYLAND_WLR_LAYERSHELL}") - message(STATUS " Session Lock: ${WAYLAND_SESSION_LOCK}") -endif () -message(STATUS " Services") -message(STATUS " StatusNotifier: ${SERVICE_STATUS_NOTIFIER}") -message(STATUS " PipeWire: ${SERVICE_PIPEWIRE}") -message(STATUS " Hyprland: ${HYPRLAND}") -if (HYPRLAND) - message(STATUS " Focus Grabbing: ${HYPRLAND_FOCUS_GRAB}") - message(STATUS " Global Shortcuts: ${HYPRLAND_GLOBAL_SHORTCUTS}") -endif() +message(STATUS " Distributor: ${DISTRIBUTOR}") +boption(DISTRIBUTOR_DEBUGINFO_AVAILABLE "Distributor provided debuginfo" NO) +boption(NO_PCH "Disable precompild headers (dev)" OFF) +boption(BUILD_TESTING "Build tests (dev)" OFF) +boption(ASAN "ASAN (dev)" OFF) # note: better output with gcc than clang +boption(FRAME_POINTERS "Keep Frame Pointers (dev)" ${ASAN}) -if (NOT DEFINED GIT_REVISION) - execute_process( - COMMAND git rev-parse HEAD - WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - OUTPUT_VARIABLE GIT_REVISION - OUTPUT_STRIP_TRAILING_WHITESPACE - ) -endif() +boption(CRASH_REPORTER "Crash Handling" ON) +boption(USE_JEMALLOC "Use jemalloc" ON) +boption(SOCKETS "Unix Sockets" ON) +boption(WAYLAND "Wayland" ON) +boption(WAYLAND_WLR_LAYERSHELL " Wlroots Layer-Shell" ON REQUIRES WAYLAND) +boption(WAYLAND_SESSION_LOCK " Session Lock" ON REQUIRES WAYLAND) +boption(WAYLAND_TOPLEVEL_MANAGEMENT " Foreign Toplevel Management" ON REQUIRES WAYLAND) +boption(HYPRLAND " Hyprland" ON REQUIRES WAYLAND) +boption(HYPRLAND_IPC " Hyprland IPC" ON REQUIRES HYPRLAND) +boption(HYPRLAND_GLOBAL_SHORTCUTS " Hyprland Global Shortcuts" ON REQUIRES HYPRLAND) +boption(HYPRLAND_FOCUS_GRAB " Hyprland Focus Grabbing" ON REQUIRES HYPRLAND) +boption(HYPRLAND_SURFACE_EXTENSIONS " Hyprland Surface Extensions" ON REQUIRES HYPRLAND) +boption(X11 "X11" ON) +boption(I3 "I3/Sway" ON) +boption(I3_IPC " I3/Sway IPC" ON REQUIRES I3) +boption(SERVICE_STATUS_NOTIFIER "System Tray" ON) +boption(SERVICE_PIPEWIRE "PipeWire" ON) +boption(SERVICE_MPRIS "Mpris" ON) +boption(SERVICE_PAM "Pam" ON) +boption(SERVICE_GREETD "Greetd" ON) +boption(SERVICE_UPOWER "UPower" ON) +boption(SERVICE_NOTIFICATIONS "Notifications" ON) + +include(cmake/install-qml-module.cmake) +include(cmake/util.cmake) add_compile_options(-Wall -Wextra) +# pipewire defines this, breaking PCH +add_compile_definitions(_REENTRANT) + if (FRAME_POINTERS) add_compile_options(-fno-omit-frame-pointer) endif() @@ -68,8 +94,9 @@ if (NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Debug) endif() -set(QT_DEPS Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2 Qt6::Widgets) -set(QT_FPDEPS Gui Qml Quick QuickControls2 Widgets) +set(QT_FPDEPS Gui Qml Quick QuickControls2 Widgets ShaderTools) + +include(cmake/pch.cmake) if (BUILD_TESTING) enable_testing() @@ -78,58 +105,39 @@ if (BUILD_TESTING) endif() if (SOCKETS) - list(APPEND QT_DEPS Qt6::Network) list(APPEND QT_FPDEPS Network) endif() if (WAYLAND) - list(APPEND QT_DEPS Qt6::WaylandClient Qt6::WaylandClientPrivate) list(APPEND QT_FPDEPS WaylandClient) endif() -if (SERVICE_STATUS_NOTIFIER) +if (SERVICE_STATUS_NOTIFIER OR SERVICE_MPRIS OR SERVICE_UPOWER OR SERVICE_NOTIFICATIONS) set(DBUS ON) endif() if (DBUS) - list(APPEND QT_DEPS Qt6::DBus) list(APPEND QT_FPDEPS DBus) endif() find_package(Qt6 REQUIRED COMPONENTS ${QT_FPDEPS}) +set(CMAKE_AUTOUIC OFF) qt_standard_project_setup(REQUIRES 6.6) set(QT_QML_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/qml_modules) -# pch breaks clang-tidy..... somehow -if (NOT NO_PCH) - file(GENERATE - OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/pchstub.cpp - CONTENT "// intentionally empty" - ) - - add_library(qt-pch ${CMAKE_CURRENT_BINARY_DIR}/pchstub.cpp) - target_link_libraries(qt-pch PRIVATE ${QT_DEPS}) - target_precompile_headers(qt-pch PUBLIC - - - - - - - - ) -endif() - -function (qs_pch target) - if (NOT NO_PCH) - target_precompile_headers(${target} REUSE_FROM qt-pch) - target_link_libraries(${target} PRIVATE ${QT_DEPS}) # required for gcc to accept the pch on plugin targets - endif() -endfunction() - -if (NVIDIA_COMPAT) - add_compile_definitions(NVIDIA_COMPAT) -endif() - add_subdirectory(src) + +if (USE_JEMALLOC) + find_package(PkgConfig REQUIRED) + # IMPORTED_TARGET not working for some reason + pkg_check_modules(JEMALLOC REQUIRED jemalloc) + target_link_libraries(quickshell PRIVATE ${JEMALLOC_LIBRARIES}) +endif() + +install(CODE " + execute_process( + COMMAND ${CMAKE_COMMAND} -E create_symlink \ + ${CMAKE_INSTALL_FULL_BINDIR}/quickshell \$ENV{DESTDIR}${CMAKE_INSTALL_FULL_BINDIR}/qs + ) +") diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..feeb746b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,102 @@ +# Contributing / Development +Instructions for development setup and upstreaming patches. + +If you just want to build or package quickshell see [BUILD.md](BUILD.md). + +## Development + +Install the dependencies listed in [BUILD.md](BUILD.md). +You probably want all of them even if you don't use all of them +to ensure tests work correctly and avoid passing a bunch of configure +flags when you need to wipe the build directory. + +Quickshell also uses `just` for common development command aliases. + +The dependencies are also available as a nix shell or nix flake which we recommend +using with nix-direnv. + +Common aliases: +- `just configure [ [extra cmake args]]` (note that you must specify debug/release to specify extra args) +- `just build` - runs the build, configuring if not configured already. +- `just run [args]` - runs quickshell with the given arguments +- `just clean` - clean up build artifacts. `just clean build` is somewhat common. + +### Formatting +All contributions should be formatted similarly to what already exists. +Group related functionality together. + +Run the formatter using `just fmt`. +If the results look stupid, fix the clang-format file if possible, +or disable clang-format in the affected area +using `// clang-format off` and `// clang-format on`. + +### Linter +All contributions should pass the linter. + +Note that running the linter requires disabling precompiled +headers and including the test codepaths: +```sh +$ just configure debug -DNO_PCH=ON -DBUILD_TESTING=ON +$ just lint +``` + +If the linter is complaining about something that you think it should not, +please disable the lint in your MR and explain your reasoning. + +### Tests +If you feel like the feature you are working on is very complex or likely to break, +please write some tests. We will ask you to directly if you send in an MR for an +overly complex or breakable feature. + +At least all tests that passed before your changes should still be passing +by the time your contribution is ready. + +You can run the tests using `just test` but you must enable them first +using `-DBUILD_TESTING=ON`. + +### Documentation +Most of quickshell's documentation is automatically generated from the source code. +You should annotate `Q_PROPERTY`s and `Q_INVOKABLE`s with doc comments. Note that the parser +cannot handle random line breaks and will usually require you to disable clang-format if the +lines are too long. + +Before submitting an MR, if adding new features please make sure the documentation is generated +reasonably using the `quickshell-docs` repo. We recommend checking it out at `/docs` in this repo. + +Doc comments take the form `///` or `///!` (summary) and work with markdown. +You can reference other types using the `@@[Module.][Type.][member]` shorthand +where all parts are optional. If module or type are not specified they will +be inferred as the current module. Member can be a `property`, `function()` or `signal(s)`. +Look at existing code for how it works. + +Quickshell modules additionally have a `module.md` file which contains a summary, description, +and list of headers to scan for documentation. + +## Contributing + +### Commits +Please structure your commit messages as `scope[!]: commit` where +the scope is something like `core` or `service/mpris`. (pick what has been +used historically or what makes sense if new.) Add `!` for changes that break +existing APIs or functionality. + +Commit descriptions should contain a summary of the changes if they are not +sufficiently addressed in the commit message. + +Please squash/rebase additions or edits to previous changes and follow the +commit style to keep the history easily searchable at a glance. +Depending on the change, it is often reasonable to squash it into just +a single commit. (If you do not follow this we will squash your changes +for you.) + +### Sending patches +You may contribute by submitting a pull request on github, asking for +an account on our git server, or emailing patches / git bundles +directly to `outfoxxed@outfoxxed.me`. + +### Getting help +If you're getting stuck, you can come talk to us in the +[quickshell-development matrix room](https://matrix.to/#/#quickshell-development:outfoxxed.me) +for help on implementation, conventions, etc. +Feel free to ask for advice early in your implementation if you are +unsure. diff --git a/Justfile b/Justfile index 69fdff70..b4fe87ec 100644 --- a/Justfile +++ b/Justfile @@ -4,7 +4,13 @@ fmt: find src -type f \( -name "*.cpp" -o -name "*.hpp" \) -print0 | xargs -0 clang-format -i lint: - find src -type f -name "*.cpp" -print0 | parallel -q0 --eta clang-tidy --load={{ env_var("TIDYFOX") }} + find src -type f -name "*.cpp" -print0 | parallel -q0 --no-notice --will-cite --tty --bar clang-tidy --load={{ env_var("TIDYFOX") }} + +lint-ci: + find src -type f -name "*.cpp" -print0 | parallel -q0 --no-notice --will-cite --tty clang-tidy --load={{ env_var("TIDYFOX") }} + +lint-changed: + git diff --name-only HEAD | grep "^.*\.cpp\$" | parallel --no-notice --will-cite --tty --bar clang-tidy --load={{ env_var("TIDYFOX") }} configure target='debug' *FLAGS='': cmake -GNinja -B {{builddir}} \ diff --git a/README.md b/README.md index d05e3347..82f912fd 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # quickshell -Simple and flexbile QtQuick based desktop shell toolkit. +Flexbile QtQuick based desktop shell toolkit. Hosted on: [outfoxxed's gitea], [github] @@ -11,21 +11,14 @@ Hosted on: [outfoxxed's gitea], [github] Documentation available at [quickshell.outfoxxed.me](https://quickshell.outfoxxed.me) or can be built from the [quickshell-docs](https://git.outfoxxed.me/outfoxxed/quickshell-docs) repo. -Some fully working examples can be found in the [quickshell-examples](https://git.outfoxxed.me/outfoxxed/quickshell-examples) +Some fully working examples can be found in the [quickshell-examples](https://git.outfoxxed.me/outfoxxed/quickshell-examples) repo. -Both the documentation and examples are included as submodules with revisions that work with the current -version of quickshell. +# Breaking Changes +Quickshell is still in alpha and there will be breaking changes. -You can clone everything with -``` -$ git clone --recursive https://git.outfoxxed.me/outfoxxed/quickshell.git -``` - -Or clone missing submodules later with -``` -$ git submodule update --init --recursive -``` +Commits with breaking qml api changes will contain a `!` at the end of the scope +(`thing!: foo`) and the commit description will contain details about the broken api. # Installation @@ -39,6 +32,9 @@ This repo has a nix flake you can use to install the package directly: quickshell = { url = "git+https://git.outfoxxed.me/outfoxxed/quickshell"; + + # THIS IS IMPORTANT + # Mismatched system dependencies will lead to crashes and other issues. inputs.nixpkgs.follows = "nixpkgs"; }; }; @@ -48,75 +44,45 @@ This repo has a nix flake you can use to install the package directly: Quickshell's binary is available at `quickshell.packages..default` to be added to lists such as `environment.systemPackages` or `home.packages`. -`quickshell.packages..nvidia` is also available for nvidia users which fixes some -common crashes. +The package contains several features detailed in [BUILD.md](BUILD.md) which can be enabled +or disabled with overrides: + +```nix +quickshell.packages..default.override { + withJemalloc = true; + withQtSvg = true; + withWayland = true; + withX11 = true; + withPipewire = true; + withPam = true; + withHyprland = true; +} +``` Note: by default this package is built with clang as it is significantly faster. -## Manual +## Arch (AUR) +Quickshell has a third party [AUR package] available under the same name. +It is not managed by us and should be looked over before use. -If not using nix, you'll have to build from source. +[AUR package]: https://aur.archlinux.org/packages/quickshell -### Dependencies -To build quickshell at all, you will need the following packages (names may vary by distro) +> [!CAUTION] +> The AUR provides no way to force the quickshell package to rebuild when the Qt version changes. +> If you experience crashes after updating Qt, please try rebuilding Quickshell against the +> current Qt version before opening an issue. -- just -- cmake -- pkg-config -- ninja -- Qt6 [ QtBase, QtDeclarative ] +## Fedora (COPR) +Quickshell has a third party [Fedora COPR package] available under the same name. +It is not managed by us and should be looked over before use. -To build with wayland support you will additionally need: -- wayland -- wayland-scanner (may be part of wayland on some distros) -- wayland-protocols -- Qt6 [ QtWayland ] +[Fedora COPR package]: https://copr.fedorainfracloud.org/coprs/errornointernet/quickshell -### Building +## Anything else +See [BUILD.md](BUILD.md) for instructions on building and packaging quickshell. -To make a release build of quickshell run: -```sh -$ just release -``` - -If running an nvidia GPU, instead run: -```sh -$ just configure release -DNVIDIA_COMPAT=ON -$ just build -``` - -(These commands are just aliases for cmake commands you can run directly, -see the Justfile for more information.) - -If you have all the dependencies installed and they are in expected -locations this will build correctly. - -To install to /usr/local/bin run as root (usually `sudo`) in the same folder: -``` -$ just install -``` - -### Building (Nix) - -You can build directly using the provided nix flake or nix package. -``` -nix build -nix build -f package.nix # calls default.nix with a basic callPackage expression -``` - -# Development - -For nix there is a devshell available from `shell.nix` and as a devShell -output from the flake. - -The Justfile contains various useful aliases: -- `just configure [ [extra cmake args]]` -- `just build` (runs configure for debug mode) -- `just run [args]` -- `just clean` -- `just test [args]` (configure with `-DBUILD_TESTING=ON` first) -- `just fmt` -- `just lint` +# Contributing / Development +See [CONTRIBUTING.md](CONTRIBUTING.md) for details. #### License diff --git a/ci/matrix.nix b/ci/matrix.nix new file mode 100644 index 00000000..be2da616 --- /dev/null +++ b/ci/matrix.nix @@ -0,0 +1,8 @@ +{ + qtver, + compiler, +}: let + nixpkgs = (import ./nix-checkouts.nix).${builtins.replaceStrings ["."] ["_"] qtver}; + compilerOverride = (nixpkgs.callPackage ./variations.nix {}).${compiler}; + pkg = (nixpkgs.callPackage ../default.nix {}).override compilerOverride; +in pkg diff --git a/ci/nix-checkouts.nix b/ci/nix-checkouts.nix new file mode 100644 index 00000000..3c99ab11 --- /dev/null +++ b/ci/nix-checkouts.nix @@ -0,0 +1,58 @@ +let + byCommit = { + commit, + sha256, + }: import (builtins.fetchTarball { + name = "nixpkgs-${commit}"; + url = "https://github.com/nixos/nixpkgs/archive/${commit}.tar.gz"; + inherit sha256; + }) {}; +in { + # For old qt versions, grab the commit before the version bump that has all the patches + # instead of the bumped version. + + qt6_8_0 = byCommit { + commit = "23e89b7da85c3640bbc2173fe04f4bd114342367"; + sha256 = "1b2v6y3bja4br5ribh9lj6xzz2k81dggz708b2mib83rwb509wyb"; + }; + + qt6_7_3 = byCommit { + commit = "273673e839189c26130d48993d849a84199523e6"; + sha256 = "0aca369hdxb8j0vx9791anyzy4m65zckx0lriicqhp95kv9q6m7z"; + }; + + qt6_7_2 = byCommit { + commit = "841f166ff96fc2f3ecd1c0cc08072633033d41bf"; + sha256 = "0d7p0cp7zjiadhpa6sdafxvrpw4lnmb1h673w17q615vm1yaasvy"; + }; + + qt6_7_1 = byCommit { + commit = "69bee9866a4e2708b3153fdb61c1425e7857d6b8"; + sha256 = "1an4sha4jsa29dvc4n9mqxbq8jjwg7frl0rhy085g73m7l1yx0lj"; + }; + + qt6_7_0 = byCommit { + commit = "4fbbc17ccf11bc80002b19b31387c9c80276f076"; + sha256 = "09lhgdqlx8j9a7vpdcf8sddlhbzjq0s208spfmxfjdn14fvx8k0j"; + }; + + qt6_6_3 = byCommit { + commit = "8f1a3fbaa92f1d59b09f2d24af6a607b5a280071"; + sha256 = "0322zwxvmg8v2wkm03xpk6mqmmbfjgrhc9prcx0zd36vjl6jmi18"; + }; + + qt6_6_2 = byCommit { + commit = "0bb9cfbd69459488576a0ef3c0e0477bedc3a29e"; + sha256 = "172ww486jm1mczk9id78s32p7ps9m9qgisml286flc8jffb6yad8"; + }; + + qt6_6_1 = byCommit { + commit = "8eecc3342103c38eea666309a7c0d90d403a039a"; + sha256 = "1lakc0immsgrpz3basaysdvd0sx01r0mcbyymx6id12fk0404z5r"; + }; + + qt6_6_0 = byCommit { + commit = "1ded005f95a43953112ffc54b39593ea2f16409f"; + sha256 = "1xvyd3lj81hak9j53mrhdsqx78x5v2ppv8m2s54qa2099anqgm0f"; + }; +} diff --git a/ci/variations.nix b/ci/variations.nix new file mode 100644 index 00000000..b0889be6 --- /dev/null +++ b/ci/variations.nix @@ -0,0 +1,7 @@ +{ + clangStdenv, + gccStdenv, +}: { + clang = { buildStdenv = clangStdenv; }; + gcc = { buildStdenv = gccStdenv; }; +} diff --git a/cmake/install-qml-module.cmake b/cmake/install-qml-module.cmake new file mode 100644 index 00000000..5c95531c --- /dev/null +++ b/cmake/install-qml-module.cmake @@ -0,0 +1,89 @@ +set(INSTALL_QMLDIR "" CACHE STRING "QML install dir") +set(INSTALL_QML_PREFIX "" CACHE STRING "QML install prefix") + +# There doesn't seem to be a standard cross-distro qml install path. +if ("${INSTALL_QMLDIR}" STREQUAL "" AND "${INSTALL_QML_PREFIX}" STREQUAL "") + message(WARNING "Neither INSTALL_QMLDIR nor INSTALL_QML_PREFIX is set. QML modules will not be installed.") +else() + if ("${INSTALL_QMLDIR}" STREQUAL "") + set(QML_FULL_INSTALLDIR "${CMAKE_INSTALL_PREFIX}/${INSTALL_QML_PREFIX}") + else() + set(QML_FULL_INSTALLDIR "${INSTALL_QMLDIR}") + endif() + + message(STATUS "QML install dir: ${QML_FULL_INSTALLDIR}") +endif() + +# Install a given target as a QML module. This is mostly pulled from ECM, as there does not seem +# to be an official way to do it. +# see https://github.com/KDE/extra-cmake-modules/blob/fe0f606bf7f222e36f7560fd7a2c33ef993e23bb/modules/ECMQmlModule6.cmake#L160 +function(install_qml_module arg_TARGET) + if (NOT DEFINED QML_FULL_INSTALLDIR) + return() + endif() + + 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 + QML_FILES module_qml_files + RESOURCES module_resources + ) + + set(module_dir "${QML_FULL_INSTALLDIR}/${module_target_path}") + + if (NOT TARGET "${module_plugin_target}") + message(FATAL_ERROR "install_qml_modules called for a target without a plugin") + endif() + + get_target_property(target_type "${arg_TARGET}" TYPE) + if (NOT "${target_type}" STREQUAL "STATIC_LIBRARY") + 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}" + ) + endif() + + install(FILES "${module_qmldir}" DESTINATION "${module_dir}") + install(FILES "${module_typeinfo}" DESTINATION "${module_dir}") + + # Install QML files + list(LENGTH module_qml_files num_files) + if (NOT "${module_qml_files}" MATCHES "NOTFOUND" AND ${num_files} GREATER 0) + qt_query_qml_module(${arg_TARGET} QML_FILES_DEPLOY_PATHS qml_files_deploy_paths) + + math(EXPR last_index "${num_files} - 1") + foreach(i RANGE 0 ${last_index}) + list(GET module_qml_files ${i} src_file) + list(GET qml_files_deploy_paths ${i} deploy_path) + get_filename_component(dst_name "${deploy_path}" NAME) + get_filename_component(dest_dir "${deploy_path}" DIRECTORY) + install(FILES "${src_file}" DESTINATION "${module_dir}/${dest_dir}" RENAME "${dst_name}") + endforeach() + endif() + + # Install resources + list(LENGTH module_resources num_files) + if (NOT "${module_resources}" MATCHES "NOTFOUND" AND ${num_files} GREATER 0) + qt_query_qml_module(${arg_TARGET} RESOURCES_DEPLOY_PATHS resources_deploy_paths) + + math(EXPR last_index "${num_files} - 1") + foreach(i RANGE 0 ${last_index}) + list(GET module_resources ${i} src_file) + list(GET resources_deploy_paths ${i} deploy_path) + get_filename_component(dst_name "${deploy_path}" NAME) + get_filename_component(dest_dir "${deploy_path}" DIRECTORY) + install(FILES "${src_file}" DESTINATION "${module_dir}/${dest_dir}" RENAME "${dst_name}") + endforeach() + endif() +endfunction() diff --git a/cmake/pch.cmake b/cmake/pch.cmake new file mode 100644 index 00000000..e136015e --- /dev/null +++ b/cmake/pch.cmake @@ -0,0 +1,85 @@ +# pch breaks clang-tidy..... somehow +if (NOT NO_PCH) + file(GENERATE + OUTPUT ${CMAKE_BINARY_DIR}/pchstub.cpp + CONTENT "// intentionally empty" + ) +endif() + +function (qs_pch target) + if (NO_PCH) + return() + endif() + + cmake_parse_arguments(PARSE_ARGV 1 arg "" "SET" "") + + if ("${arg_SET}" STREQUAL "") + set(arg_SET "common") + endif() + + target_precompile_headers(${target} REUSE_FROM "qs-pchset-${arg_SET}") +endfunction() + +function (qs_module_pch target) + qs_pch(${target} ${ARGN}) + qs_pch("${target}plugin" SET plugin) + qs_pch("${target}plugin_init" SET plugin) +endfunction() + +function (qs_add_pchset SETNAME) + if (NO_PCH) + return() + endif() + + cmake_parse_arguments(PARSE_ARGV 1 arg "" "" "HEADERS;DEPENDENCIES") + + set(LIBNAME "qs-pchset-${SETNAME}") + + add_library(${LIBNAME} ${CMAKE_BINARY_DIR}/pchstub.cpp) + target_link_libraries(${LIBNAME} ${arg_DEPENDENCIES}) + target_precompile_headers(${LIBNAME} PUBLIC ${arg_HEADERS}) +endfunction() + +set(COMMON_PCH_SET + + + + + + + + + + +) + +qs_add_pchset(common + DEPENDENCIES Qt::Quick + HEADERS ${COMMON_PCH_SET} +) + +qs_add_pchset(large + DEPENDENCIES Qt::Quick + HEADERS + ${COMMON_PCH_SET} + + + + + + + + + + +) + + +# including qplugin.h directly will cause required symbols to disappear +qs_add_pchset(plugin + DEPENDENCIES Qt::Qml + HEADERS + + + +) diff --git a/cmake/util.cmake b/cmake/util.cmake new file mode 100644 index 00000000..14fa7c2d --- /dev/null +++ b/cmake/util.cmake @@ -0,0 +1,29 @@ +# Adds a dependency hint to the link order, but does not block build on the dependency. +function (qs_add_link_dependencies target) + set_property( + TARGET ${target} + APPEND PROPERTY INTERFACE_LINK_LIBRARIES + ${ARGN} + ) +endfunction() + +function (qs_append_qmldir target text) + get_property(qmldir_content TARGET ${target} PROPERTY _qt_internal_qmldir_content) + + if ("${qmldir_content}" STREQUAL "") + message(WARNING "qs_append_qmldir depends on private Qt cmake code, which has broken.") + return() + endif() + + set_property(TARGET ${target} APPEND_STRING PROPERTY _qt_internal_qmldir_content ${text}) +endfunction() + +# DEPENDENCIES introduces a cmake dependency which we don't need with static modules. +# This greatly improves comp speed by not introducing those dependencies. +function (qs_add_module_deps_light target) + foreach (dep IN LISTS ARGN) + string(APPEND qmldir_extra "depends ${dep}\n") + endforeach() + + qs_append_qmldir(${target} "${qmldir_extra}") +endfunction() diff --git a/default.nix b/default.nix index 514c7946..fab038a7 100644 --- a/default.nix +++ b/default.nix @@ -3,13 +3,20 @@ nix-gitignore, pkgs, keepDebugInfo, - buildStdenv ? pkgs.clang17Stdenv, + buildStdenv ? pkgs.clangStdenv, cmake, ninja, qt6, + spirv-tools, + cli11, + breakpad, + jemalloc, wayland, wayland-protocols, + xorg, + pipewire, + pam, gitRev ? (let headExists = builtins.pathExists ./.git/HEAD; @@ -23,10 +30,15 @@ else "unknown"), debug ? false, - enableWayland ? true, - enablePipewire ? true, - nvidiaCompat ? false, - svgSupport ? true, # you almost always want this + withCrashReporter ? true, + withJemalloc ? true, # masks heap fragmentation + withQtSvg ? true, + withWayland ? true, + withX11 ? true, + withPipewire ? true, + withPam ? true, + withHyprland ? true, + withI3 ? true, }: buildStdenv.mkDerivation { pname = "quickshell${lib.optionalString debug "-debug"}"; version = "0.1.0"; @@ -35,45 +47,54 @@ nativeBuildInputs = with pkgs; [ cmake ninja + qt6.qtshadertools + spirv-tools qt6.wrapQtAppsHook - ] ++ (lib.optionals enableWayland [ pkg-config + ] ++ (lib.optionals withWayland [ wayland-protocols wayland-scanner ]); - buildInputs = with pkgs; [ + buildInputs = [ qt6.qtbase qt6.qtdeclarative + cli11 ] - ++ (lib.optionals enableWayland [ qt6.qtwayland wayland ]) - ++ (lib.optionals svgSupport [ qt6.qtsvg ]) - ++ (lib.optionals enablePipewire [ pipewire ]); + ++ lib.optional withCrashReporter breakpad + ++ lib.optional withJemalloc jemalloc + ++ lib.optional withQtSvg qt6.qtsvg + ++ lib.optionals withWayland [ qt6.qtwayland wayland ] + ++ lib.optional withX11 xorg.libxcb + ++ lib.optional withPam pam + ++ lib.optional withPipewire pipewire; - QTWAYLANDSCANNER = lib.optionalString enableWayland "${qt6.qtwayland}/libexec/qtwaylandscanner"; - - configurePhase = let - cmakeBuildType = if debug - then "Debug" - else "RelWithDebInfo"; - in '' - cmakeBuildType=${cmakeBuildType} # qt6 setup hook resets this for some godforsaken reason - cmakeConfigurePhase - ''; + cmakeBuildType = if debug then "Debug" else "RelWithDebInfo"; cmakeFlags = [ - "-DGIT_REVISION=${gitRev}" - ] ++ lib.optional (!enableWayland) "-DWAYLAND=OFF" - ++ lib.optional nvidiaCompat "-DNVIDIA_COMPAT=ON" - ++ lib.optional (!enablePipewire) "-DSERVICE_PIPEWIRE=OFF"; + (lib.cmakeFeature "DISTRIBUTOR" "Official-Nix-Flake") + (lib.cmakeFeature "INSTALL_QML_PREFIX" qt6.qtbase.qtQmlPrefix) + (lib.cmakeBool "DISTRIBUTOR_DEBUGINFO_AVAILABLE" true) + (lib.cmakeFeature "GIT_REVISION" gitRev) + (lib.cmakeBool "CRASH_REPORTER" withCrashReporter) + (lib.cmakeBool "USE_JEMALLOC" withJemalloc) + (lib.cmakeBool "WAYLAND" withWayland) + (lib.cmakeBool "SERVICE_PIPEWIRE" withPipewire) + (lib.cmakeBool "SERVICE_PAM" withPam) + (lib.cmakeBool "HYPRLAND" withHyprland) + (lib.cmakeBool "I3" withI3) + ]; - buildPhase = "ninjaBuildPhase"; - enableParallelBuilding = true; - dontStrip = true; + # How to get debuginfo in gdb from a release build: + # 1. build `quickshell.debug` + # 2. set NIX_DEBUG_INFO_DIRS="/lib/debug" + # 3. launch gdb / coredumpctl and debuginfo will work + separateDebugInfo = !debug; + dontStrip = debug; meta = with lib; { homepage = "https://git.outfoxxed.me/outfoxxed/quickshell"; - description = "Simple and flexbile QtQuick based desktop shell toolkit"; + description = "Flexbile QtQuick based desktop shell toolkit"; license = licenses.lgpl3Only; platforms = platforms.linux; }; diff --git a/docs b/docs deleted file mode 160000 index ff5da84a..00000000 --- a/docs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ff5da84a8b258a9b2caaf978ddb6de23635d8903 diff --git a/examples b/examples deleted file mode 160000 index b9e744b5..00000000 --- a/examples +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b9e744b50673304dfddb68f3da2a2e906d028b96 diff --git a/flake.lock b/flake.lock index 1527f635..ed928826 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1709237383, - "narHash": "sha256-cy6ArO4k5qTx+l5o+0mL9f5fa86tYUX3ozE1S+Txlds=", + "lastModified": 1732014248, + "narHash": "sha256-y/MEyuJ5oBWrWAic/14LaIr/u5E0wRVzyYsouYY3W6w=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "1536926ef5621b09bba54035ae2bb6d806d72ac8", + "rev": "23e89b7da85c3640bbc2173fe04f4bd114342367", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 5bb5069e..a0bc18d4 100644 --- a/flake.nix +++ b/flake.nix @@ -12,10 +12,8 @@ quickshell = pkgs.callPackage ./default.nix { gitRev = self.rev or self.dirtyRev; }; - quickshell-nvidia = quickshell.override { nvidiaCompat = true; }; default = quickshell; - nvidia = quickshell-nvidia; }); devShells = forEachSystem (system: pkgs: rec { diff --git a/shell.nix b/shell.nix index 07b5b57d..82382f90 100644 --- a/shell.nix +++ b/shell.nix @@ -15,13 +15,12 @@ in pkgs.mkShell.override { stdenv = quickshell.stdenv; } { nativeBuildInputs = with pkgs; [ just - clang-tools_17 + clang-tools parallel makeWrapper ]; TIDYFOX = "${tidyfox}/lib/libtidyfox.so"; - QTWAYLANDSCANNER = quickshell.QTWAYLANDSCANNER; shellHook = '' export CMAKE_BUILD_PARALLEL_LEVEL=$(nproc) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8fe9c651..882d2bae 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -2,8 +2,18 @@ qt_add_executable(quickshell main.cpp) install(TARGETS quickshell RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +add_subdirectory(build) +add_subdirectory(launch) add_subdirectory(core) +add_subdirectory(debug) +add_subdirectory(ipc) +add_subdirectory(window) add_subdirectory(io) +add_subdirectory(widgets) + +if (CRASH_REPORTER) + add_subdirectory(crash) +endif() if (DBUS) add_subdirectory(dbus) @@ -11,6 +21,10 @@ endif() if (WAYLAND) add_subdirectory(wayland) -endif () +endif() + +if (X11) + add_subdirectory(x11) +endif() add_subdirectory(services) diff --git a/src/build/CMakeLists.txt b/src/build/CMakeLists.txt new file mode 100644 index 00000000..bb35da99 --- /dev/null +++ b/src/build/CMakeLists.txt @@ -0,0 +1,26 @@ +add_library(quickshell-build INTERFACE) + +if (NOT DEFINED GIT_REVISION) + execute_process( + COMMAND git rev-parse HEAD + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + OUTPUT_VARIABLE GIT_REVISION + OUTPUT_STRIP_TRAILING_WHITESPACE + ) +endif() + +if (CRASH_REPORTER) + set(CRASH_REPORTER_DEF 1) +else() + set(CRASH_REPORTER_DEF 0) +endif() + +if (DISTRIBUTOR_DEBUGINFO_AVAILABLE) + set(DEBUGINFO_AVAILABLE 1) +else() + set(DEBUGINFO_AVAILABLE 0) +endif() + +configure_file(build.hpp.in build.hpp @ONLY ESCAPE_QUOTES) + +target_include_directories(quickshell-build INTERFACE ${CMAKE_CURRENT_BINARY_DIR}) diff --git a/src/build/build.hpp.in b/src/build/build.hpp.in new file mode 100644 index 00000000..075abd17 --- /dev/null +++ b/src/build/build.hpp.in @@ -0,0 +1,12 @@ +#pragma once + +// NOLINTBEGIN +#define GIT_REVISION "@GIT_REVISION@" +#define DISTRIBUTOR "@DISTRIBUTOR@" +#define DISTRIBUTOR_DEBUGINFO_AVAILABLE @DEBUGINFO_AVAILABLE@ +#define CRASH_REPORTER @CRASH_REPORTER_DEF@ +#define BUILD_TYPE "@CMAKE_BUILD_TYPE@" +#define COMPILER "@CMAKE_CXX_COMPILER_ID@ (@CMAKE_CXX_COMPILER_VERSION@)" +#define COMPILE_FLAGS "@CMAKE_CXX_FLAGS@" +#define BUILD_CONFIGURATION "@QS_BUILD_OPTIONS@" +// NOLINTEND diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index b40b807f..6778e984 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -1,20 +1,14 @@ qt_add_library(quickshell-core STATIC - main.cpp plugin.cpp shell.cpp variants.cpp rootwrapper.cpp - proxywindow.cpp reload.cpp rootwrapper.cpp qmlglobal.cpp qmlscreen.cpp region.cpp persistentprops.cpp - windowinterface.cpp - floatingwindow.cpp - panelinterface.cpp - popupwindow.cpp singleton.cpp generation.cpp scan.cpp @@ -26,13 +20,38 @@ qt_add_library(quickshell-core STATIC imageprovider.cpp transformwatcher.cpp boundcomponent.cpp + model.cpp + elapsedtimer.cpp + desktopentry.cpp + objectrepeater.cpp + platformmenu.cpp + qsmenu.cpp + retainable.cpp + popupanchor.cpp + types.cpp + qsmenuanchor.cpp + clock.cpp + logging.cpp + paths.cpp + instanceinfo.cpp + common.cpp + iconprovider.cpp + scriptmodel.cpp ) -set_source_files_properties(main.cpp PROPERTIES COMPILE_DEFINITIONS GIT_REVISION="${GIT_REVISION}") -qt_add_qml_module(quickshell-core URI Quickshell VERSION 0.1) +qt_add_qml_module(quickshell-core + URI Quickshell + VERSION 0.1 + DEPENDENCIES QtQuick + OPTIONAL_IMPORTS Quickshell._Window + DEFAULT_IMPORTS Quickshell._Window +) -target_link_libraries(quickshell-core PRIVATE ${QT_DEPS}) -qs_pch(quickshell-core) +install_qml_module(quickshell-core) + +target_link_libraries(quickshell-core PRIVATE Qt::Quick Qt::Widgets) + +qs_module_pch(quickshell-core SET large) target_link_libraries(quickshell PRIVATE quickshell-coreplugin) diff --git a/src/core/clock.cpp b/src/core/clock.cpp new file mode 100644 index 00000000..ebb7e92a --- /dev/null +++ b/src/core/clock.cpp @@ -0,0 +1,97 @@ +#include "clock.hpp" + +#include +#include +#include +#include +#include + +#include "util.hpp" + +SystemClock::SystemClock(QObject* parent): QObject(parent) { + QObject::connect(&this->timer, &QTimer::timeout, this, &SystemClock::onTimeout); + this->update(); +} + +bool SystemClock::enabled() const { return this->mEnabled; } + +void SystemClock::setEnabled(bool enabled) { + if (enabled == this->mEnabled) return; + this->mEnabled = enabled; + emit this->enabledChanged(); + this->update(); +} + +SystemClock::Enum SystemClock::precision() const { return this->mPrecision; } + +void SystemClock::setPrecision(SystemClock::Enum precision) { + if (precision == this->mPrecision) return; + this->mPrecision = precision; + emit this->precisionChanged(); + this->update(); +} + +void SystemClock::onTimeout() { + this->setTime(this->targetTime); + this->schedule(this->targetTime); +} + +void SystemClock::update() { + if (this->mEnabled) { + this->setTime(QDateTime::fromMSecsSinceEpoch(0)); + this->schedule(QDateTime::fromMSecsSinceEpoch(0)); + } else { + this->timer.stop(); + } +} + +void SystemClock::setTime(const QDateTime& targetTime) { + auto currentTime = QDateTime::currentDateTime(); + auto offset = currentTime.msecsTo(targetTime); + auto dtime = offset > -500 && offset < 500 ? targetTime : currentTime; + auto time = dtime.time(); + + auto secondPrecision = this->mPrecision >= SystemClock::Seconds; + auto secondChanged = this->setSeconds(secondPrecision ? time.second() : 0); + + auto minutePrecision = this->mPrecision >= SystemClock::Minutes; + auto minuteChanged = this->setMinutes(minutePrecision ? time.minute() : 0); + + auto hourPrecision = this->mPrecision >= SystemClock::Hours; + auto hourChanged = this->setHours(hourPrecision ? time.hour() : 0); + + DropEmitter::call(secondChanged, minuteChanged, hourChanged); +} + +void SystemClock::schedule(const QDateTime& targetTime) { + auto secondPrecision = this->mPrecision >= SystemClock::Seconds; + auto minutePrecision = this->mPrecision >= SystemClock::Minutes; + auto hourPrecision = this->mPrecision >= SystemClock::Hours; + + auto currentTime = QDateTime::currentDateTime(); + + auto offset = currentTime.msecsTo(targetTime); + + // timer skew + auto nextTime = offset > 0 && offset < 500 ? targetTime : currentTime; + + auto baseTimeT = nextTime.time(); + nextTime.setTime( + {hourPrecision ? baseTimeT.hour() : 0, + minutePrecision ? baseTimeT.minute() : 0, + secondPrecision ? baseTimeT.second() : 0} + ); + + if (secondPrecision) nextTime = nextTime.addSecs(1); + else if (minutePrecision) nextTime = nextTime.addSecs(60); + else if (hourPrecision) nextTime = nextTime.addSecs(3600); + + auto delay = currentTime.msecsTo(nextTime); + + this->timer.start(static_cast(delay)); + this->targetTime = nextTime; +} + +DEFINE_MEMBER_GETSET(SystemClock, hours, setHours); +DEFINE_MEMBER_GETSET(SystemClock, minutes, setMinutes); +DEFINE_MEMBER_GETSET(SystemClock, seconds, setSeconds); diff --git a/src/core/clock.hpp b/src/core/clock.hpp new file mode 100644 index 00000000..3e669589 --- /dev/null +++ b/src/core/clock.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "util.hpp" + +///! System clock accessor. +class SystemClock: public QObject { + Q_OBJECT; + /// If the clock should update. Defaults to true. + /// + /// Setting enabled to false pauses the clock. + Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged); + /// The precision the clock should measure at. Defaults to `SystemClock.Seconds`. + Q_PROPERTY(SystemClock::Enum precision READ precision WRITE setPrecision NOTIFY precisionChanged); + /// The current hour. + Q_PROPERTY(quint32 hours READ hours NOTIFY hoursChanged); + /// The current minute, or 0 if @@precision is `SystemClock.Hours`. + Q_PROPERTY(quint32 minutes READ minutes NOTIFY minutesChanged); + /// The current second, or 0 if @@precision is `SystemClock.Hours` or `SystemClock.Minutes`. + Q_PROPERTY(quint32 seconds READ seconds NOTIFY secondsChanged); + QML_ELEMENT; + +public: + // must be named enum until docgen is ready to handle member enums better + enum Enum : quint8 { + Hours = 1, + Minutes = 2, + Seconds = 3, + }; + Q_ENUM(Enum); + + explicit SystemClock(QObject* parent = nullptr); + + [[nodiscard]] bool enabled() const; + void setEnabled(bool enabled); + + [[nodiscard]] SystemClock::Enum precision() const; + void setPrecision(SystemClock::Enum precision); + +signals: + void enabledChanged(); + void precisionChanged(); + void hoursChanged(); + void minutesChanged(); + void secondsChanged(); + +private slots: + void onTimeout(); + +private: + bool mEnabled = true; + SystemClock::Enum mPrecision = SystemClock::Seconds; + quint32 mHours = 0; + quint32 mMinutes = 0; + quint32 mSeconds = 0; + QTimer timer; + QDateTime targetTime; + + void update(); + void setTime(const QDateTime& targetTime); + void schedule(const QDateTime& targetTime); + + DECLARE_PRIVATE_MEMBER(SystemClock, hours, setHours, mHours, hoursChanged); + DECLARE_PRIVATE_MEMBER(SystemClock, minutes, setMinutes, mMinutes, minutesChanged); + DECLARE_PRIVATE_MEMBER(SystemClock, seconds, setSeconds, mSeconds, secondsChanged); +}; diff --git a/src/core/common.cpp b/src/core/common.cpp new file mode 100644 index 00000000..d09661f1 --- /dev/null +++ b/src/core/common.cpp @@ -0,0 +1,9 @@ +#include "common.hpp" + +#include + +namespace qs { + +const QDateTime Common::LAUNCH_TIME = QDateTime::currentDateTime(); + +} diff --git a/src/core/common.hpp b/src/core/common.hpp new file mode 100644 index 00000000..36094f89 --- /dev/null +++ b/src/core/common.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace qs { + +struct Common { + static const QDateTime LAUNCH_TIME; +}; + +} // namespace qs diff --git a/src/core/desktopentry.cpp b/src/core/desktopentry.cpp new file mode 100644 index 00000000..3714df01 --- /dev/null +++ b/src/core/desktopentry.cpp @@ -0,0 +1,389 @@ +#include "desktopentry.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "model.hpp" + +Q_LOGGING_CATEGORY(logDesktopEntry, "quickshell.desktopentry", QtWarningMsg); + +struct Locale { + explicit Locale() = default; + + explicit Locale(const QString& string) { + auto territoryIdx = string.indexOf('_'); + auto codesetIdx = string.indexOf('.'); + auto modifierIdx = string.indexOf('@'); + + auto parseEnd = string.length(); + + if (modifierIdx != -1) { + this->modifier = string.sliced(modifierIdx + 1, parseEnd - modifierIdx - 1); + parseEnd = modifierIdx; + } + + if (codesetIdx != -1) { + parseEnd = codesetIdx; + } + + if (territoryIdx != -1) { + this->territory = string.sliced(territoryIdx + 1, parseEnd - territoryIdx - 1); + parseEnd = territoryIdx; + } + + this->language = string.sliced(0, parseEnd); + } + + [[nodiscard]] bool isValid() const { return !this->language.isEmpty(); } + + [[nodiscard]] int matchScore(const Locale& other) const { + if (this->language != other.language) return 0; + auto territoryMatches = !this->territory.isEmpty() && this->territory == other.territory; + auto modifierMatches = !this->modifier.isEmpty() && this->modifier == other.modifier; + + auto score = 1; + if (territoryMatches) score += 2; + if (modifierMatches) score += 1; + + return score; + } + + static const Locale& system() { + static Locale* locale = nullptr; // NOLINT + + if (locale == nullptr) { + auto lstr = qEnvironmentVariable("LC_MESSAGES"); + if (lstr.isEmpty()) lstr = qEnvironmentVariable("LANG"); + locale = new Locale(lstr); + } + + return *locale; + } + + QString language; + QString territory; + QString modifier; +}; + +QDebug operator<<(QDebug debug, const Locale& locale) { + auto saver = QDebugStateSaver(debug); + debug.nospace() << "Locale(language=" << locale.language << ", territory=" << locale.territory + << ", modifier" << locale.modifier << ')'; + + return debug; +} + +void DesktopEntry::parseEntry(const QString& text) { + const auto& system = Locale::system(); + + auto groupName = QString(); + auto entries = QHash>(); + + auto finishCategory = [this, &groupName, &entries]() { + if (groupName == "Desktop Entry") { + if (entries["Type"].second != "Application") return; + if (entries.contains("Hidden") && entries["Hidden"].second == "true") return; + + for (const auto& [key, pair]: entries.asKeyValueRange()) { + auto& [_, value] = pair; + this->mEntries.insert(key, value); + + if (key == "Name") this->mName = value; + else if (key == "GenericName") this->mGenericName = value; + else if (key == "NoDisplay") this->mNoDisplay = value == "true"; + else if (key == "Comment") this->mComment = value; + else if (key == "Icon") this->mIcon = value; + else if (key == "Exec") this->mExecString = value; + else if (key == "Path") this->mWorkingDirectory = value; + else if (key == "Terminal") this->mTerminal = value == "true"; + else if (key == "Categories") this->mCategories = value.split(u';', Qt::SkipEmptyParts); + else if (key == "Keywords") this->mKeywords = value.split(u';', Qt::SkipEmptyParts); + } + } else if (groupName.startsWith("Desktop Action ")) { + auto actionName = groupName.sliced(16); + auto* action = new DesktopAction(actionName, this); + + for (const auto& [key, pair]: entries.asKeyValueRange()) { + const auto& [_, value] = pair; + action->mEntries.insert(key, value); + + if (key == "Name") action->mName = value; + else if (key == "Icon") action->mIcon = value; + else if (key == "Exec") action->mExecString = value; + } + + this->mActions.insert(actionName, action); + } + + entries.clear(); + }; + + for (auto& line: text.split(u'\n', Qt::SkipEmptyParts)) { + if (line.startsWith(u'#')) continue; + + if (line.startsWith(u'[') && line.endsWith(u']')) { + finishCategory(); + groupName = line.sliced(1, line.length() - 2); + continue; + } + + auto splitIdx = line.indexOf(u'='); + if (splitIdx == -1) { + qCWarning(logDesktopEntry) << "Encountered invalid line in desktop entry (no =)" << line; + continue; + } + + auto key = line.sliced(0, splitIdx); + const auto& value = line.sliced(splitIdx + 1); + + auto localeIdx = key.indexOf('['); + Locale locale; + if (localeIdx != -1 && localeIdx != key.length() - 1) { + locale = Locale(key.sliced(localeIdx + 1, key.length() - localeIdx - 2)); + key = key.sliced(0, localeIdx); + } + + if (entries.contains(key)) { + const auto& old = entries.value(key); + + auto oldScore = system.matchScore(old.first); + auto newScore = system.matchScore(locale); + + if (newScore > oldScore || (oldScore == 0 && !locale.isValid())) { + entries.insert(key, qMakePair(locale, value)); + } + } else { + entries.insert(key, qMakePair(locale, value)); + } + } + + finishCategory(); +} + +void DesktopEntry::execute() const { + DesktopEntry::doExec(this->mExecString, this->mWorkingDirectory); +} + +bool DesktopEntry::isValid() const { return !this->mName.isEmpty(); } +bool DesktopEntry::noDisplay() const { return this->mNoDisplay; } + +QVector DesktopEntry::actions() const { return this->mActions.values(); } + +QVector DesktopEntry::parseExecString(const QString& execString) { + QVector arguments; + QString currentArgument; + auto parsingString = false; + auto escape = 0; + auto percent = false; + + for (auto c: execString) { + if (escape == 0 && c == u'\\') { + escape = 1; + } else if (parsingString) { + if (c == '\\') { + escape++; + if (escape == 4) { + currentArgument += '\\'; + escape = 0; + } + } else if (escape != 0) { + if (escape != 2) { + // Technically this is an illegal state, but the spec has a terrible double escape + // rule in strings for no discernable reason. Assuming someone might understandably + // misunderstand it, treat it as a normal escape and log it. + qCWarning(logDesktopEntry).noquote() + << "Illegal escape sequence in desktop entry exec string:" << execString; + } + + currentArgument += c; + escape = 0; + } else if (c == u'"') { + parsingString = false; + } else { + currentArgument += c; + } + } else if (escape != 0) { + currentArgument += c; + escape = 0; + } else if (percent) { + if (c == '%') { + currentArgument += '%'; + } // else discard + + percent = false; + } else if (c == '%') { + percent = true; + } else if (c == u'"') { + parsingString = true; + } else if (c == u' ') { + if (!currentArgument.isEmpty()) { + arguments.push_back(currentArgument); + currentArgument.clear(); + } + } else { + currentArgument += c; + } + } + + if (!currentArgument.isEmpty()) { + arguments.push_back(currentArgument); + currentArgument.clear(); + } + + return arguments; +} + +void DesktopEntry::doExec(const QString& execString, const QString& workingDirectory) { + auto args = DesktopEntry::parseExecString(execString); + if (args.isEmpty()) { + qCWarning(logDesktopEntry) << "Tried to exec string" << execString << "which parsed as empty."; + return; + } + + auto process = QProcess(); + process.setProgram(args.at(0)); + process.setArguments(args.sliced(1)); + if (!workingDirectory.isEmpty()) process.setWorkingDirectory(workingDirectory); + process.startDetached(); +} + +void DesktopAction::execute() const { + DesktopEntry::doExec(this->mExecString, this->entry->mWorkingDirectory); +} + +DesktopEntryManager::DesktopEntryManager() { + this->scanDesktopEntries(); + this->populateApplications(); +} + +void DesktopEntryManager::scanDesktopEntries() { + QList dataPaths; + + if (qEnvironmentVariableIsSet("XDG_DATA_DIRS")) { + auto var = qEnvironmentVariable("XDG_DATA_DIRS"); + dataPaths = var.split(u':', Qt::SkipEmptyParts); + } else { + dataPaths.push_back("/usr/local/share"); + dataPaths.push_back("/usr/share"); + } + + qCDebug(logDesktopEntry) << "Creating desktop entry scanners"; + + for (auto& path: std::ranges::reverse_view(dataPaths)) { + auto p = QDir(path).filePath("applications"); + auto file = QFileInfo(p); + + if (!file.isDir()) { + qCDebug(logDesktopEntry) << "Not scanning path" << p << "as it is not a directory"; + continue; + } + + qCDebug(logDesktopEntry) << "Scanning path" << p; + this->scanPath(p); + } +} + +void DesktopEntryManager::populateApplications() { + for (auto& entry: this->desktopEntries.values()) { + if (!entry->noDisplay()) this->mApplications.insertObject(entry); + } +} + +void DesktopEntryManager::scanPath(const QDir& dir, const QString& prefix) { + auto entries = dir.entryInfoList(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot); + + for (auto& entry: entries) { + if (entry.isDir()) this->scanPath(entry.path(), prefix + dir.dirName() + "-"); + else if (entry.isFile()) { + auto path = entry.filePath(); + if (!path.endsWith(".desktop")) { + qCDebug(logDesktopEntry) << "Skipping file" << path << "as it has no .desktop extension"; + continue; + } + + auto* file = new QFile(path); + + if (!file->open(QFile::ReadOnly)) { + qCDebug(logDesktopEntry) << "Could not open file" << path; + continue; + } + + auto id = prefix + entry.fileName().sliced(0, entry.fileName().length() - 8); + auto lowerId = id.toLower(); + + auto text = QString::fromUtf8(file->readAll()); + auto* dentry = new DesktopEntry(id, this); + dentry->parseEntry(text); + + if (!dentry->isValid()) { + qCDebug(logDesktopEntry) << "Skipping desktop entry" << path; + delete dentry; + continue; + } + + qCDebug(logDesktopEntry) << "Found desktop entry" << id << "at" << path; + + auto conflictingId = this->desktopEntries.contains(id); + + if (conflictingId) { + qCDebug(logDesktopEntry) << "Replacing old entry for" << id; + delete this->desktopEntries.value(id); + this->desktopEntries.remove(id); + this->lowercaseDesktopEntries.remove(lowerId); + } + + this->desktopEntries.insert(id, dentry); + + if (this->lowercaseDesktopEntries.contains(lowerId)) { + qCInfo(logDesktopEntry).nospace() + << "Multiple desktop entries have the same lowercased id " << lowerId + << ". This can cause ambiguity when byId requests are not made with the correct case " + "already."; + + this->lowercaseDesktopEntries.remove(lowerId); + } + + this->lowercaseDesktopEntries.insert(lowerId, dentry); + } + } +} + +DesktopEntryManager* DesktopEntryManager::instance() { + static auto* instance = new DesktopEntryManager(); // NOLINT + return instance; +} + +DesktopEntry* DesktopEntryManager::byId(const QString& id) { + if (auto* entry = this->desktopEntries.value(id)) { + return entry; + } else if (auto* entry = this->lowercaseDesktopEntries.value(id.toLower())) { + return entry; + } else { + return nullptr; + } +} + +ObjectModel* DesktopEntryManager::applications() { return &this->mApplications; } + +DesktopEntries::DesktopEntries() { DesktopEntryManager::instance(); } + +DesktopEntry* DesktopEntries::byId(const QString& id) { + return DesktopEntryManager::instance()->byId(id); +} + +ObjectModel* DesktopEntries::applications() { + return DesktopEntryManager::instance()->applications(); +} diff --git a/src/core/desktopentry.hpp b/src/core/desktopentry.hpp new file mode 100644 index 00000000..3871181b --- /dev/null +++ b/src/core/desktopentry.hpp @@ -0,0 +1,155 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +#include "doc.hpp" +#include "model.hpp" + +class DesktopAction; + +/// A desktop entry. See @@DesktopEntries for details. +class DesktopEntry: public QObject { + Q_OBJECT; + Q_PROPERTY(QString id MEMBER mId CONSTANT); + /// Name of the specific application, such as "Firefox". + Q_PROPERTY(QString name MEMBER mName CONSTANT); + /// Short description of the application, such as "Web Browser". May be empty. + Q_PROPERTY(QString genericName MEMBER mGenericName CONSTANT); + /// If true, this application should not be displayed in menus and launchers. + Q_PROPERTY(bool noDisplay MEMBER mNoDisplay CONSTANT); + /// Long description of the application, such as "View websites on the internet". May be empty. + Q_PROPERTY(QString comment MEMBER mComment CONSTANT); + /// Name of the icon associated with this application. May be empty. + Q_PROPERTY(QString icon MEMBER mIcon CONSTANT); + /// The raw `Exec` string from the desktop entry. You probably want @@execute(). + Q_PROPERTY(QString execString MEMBER mExecString CONSTANT); + /// The working directory to execute from. + Q_PROPERTY(QString workingDirectory MEMBER mWorkingDirectory CONSTANT); + /// If the application should run in a terminal. + Q_PROPERTY(bool runInTerminal MEMBER mTerminal CONSTANT); + Q_PROPERTY(QVector categories MEMBER mCategories CONSTANT); + Q_PROPERTY(QVector keywords MEMBER mKeywords CONSTANT); + Q_PROPERTY(QVector actions READ actions CONSTANT); + QML_ELEMENT; + QML_UNCREATABLE("DesktopEntry instances must be retrieved from DesktopEntries"); + +public: + explicit DesktopEntry(QString id, QObject* parent): QObject(parent), mId(std::move(id)) {} + + void parseEntry(const QString& text); + + /// Run the application. Currently ignores @@runInTerminal and field codes. + Q_INVOKABLE void execute() const; + + [[nodiscard]] bool isValid() const; + [[nodiscard]] bool noDisplay() const; + [[nodiscard]] QVector actions() const; + + // currently ignores all field codes. + static QVector parseExecString(const QString& execString); + static void doExec(const QString& execString, const QString& workingDirectory); + +public: + QString mId; + QString mName; + QString mGenericName; + bool mNoDisplay = false; + QString mComment; + QString mIcon; + QString mExecString; + QString mWorkingDirectory; + bool mTerminal = false; + QVector mCategories; + QVector mKeywords; + +private: + QHash mEntries; + QHash mActions; + + friend class DesktopAction; +}; + +/// An action of a @@DesktopEntry$. +class DesktopAction: public QObject { + Q_OBJECT; + Q_PROPERTY(QString id MEMBER mId CONSTANT); + Q_PROPERTY(QString name MEMBER mName CONSTANT); + Q_PROPERTY(QString icon MEMBER mIcon CONSTANT); + /// The raw `Exec` string from the desktop entry. You probably want @@execute(). + Q_PROPERTY(QString execString MEMBER mExecString CONSTANT); + QML_ELEMENT; + QML_UNCREATABLE("DesktopAction instances must be retrieved from a DesktopEntry"); + +public: + explicit DesktopAction(QString id, DesktopEntry* entry) + : QObject(entry) + , entry(entry) + , mId(std::move(id)) {} + + /// Run the application. Currently ignores @@DesktopEntry.runInTerminal and field codes. + Q_INVOKABLE void execute() const; + +private: + DesktopEntry* entry; + QString mId; + QString mName; + QString mIcon; + QString mExecString; + QHash mEntries; + + friend class DesktopEntry; +}; + +class DesktopEntryManager: public QObject { + Q_OBJECT; + +public: + void scanDesktopEntries(); + + [[nodiscard]] DesktopEntry* byId(const QString& id); + + [[nodiscard]] ObjectModel* applications(); + + static DesktopEntryManager* instance(); + +private: + explicit DesktopEntryManager(); + + void populateApplications(); + void scanPath(const QDir& dir, const QString& prefix = QString()); + + QHash desktopEntries; + QHash lowercaseDesktopEntries; + ObjectModel mApplications {this}; +}; + +///! Desktop entry index. +/// Index of desktop entries according to the [desktop entry specification]. +/// +/// Primarily useful for looking up icons and metadata from an id, as there is +/// currently no mechanism for usage based sorting of entries and other launcher niceties. +/// +/// [desktop entry specification]: https://specifications.freedesktop.org/desktop-entry-spec/latest/ +class DesktopEntries: public QObject { + Q_OBJECT; + /// All desktop entries of type Application that are not Hidden or NoDisplay. + QSDOC_TYPE_OVERRIDE(ObjectModel*); + Q_PROPERTY(UntypedObjectModel* applications READ applications CONSTANT); + QML_ELEMENT; + QML_SINGLETON; + +public: + explicit DesktopEntries(); + + /// Look up a desktop entry by name. Includes NoDisplay entries. May return null. + Q_INVOKABLE [[nodiscard]] static DesktopEntry* byId(const QString& id); + + [[nodiscard]] static ObjectModel* applications(); +}; diff --git a/src/core/doc.hpp b/src/core/doc.hpp index b619b0a6..fbb21400 100644 --- a/src/core/doc.hpp +++ b/src/core/doc.hpp @@ -10,5 +10,14 @@ #define QSDOC_ELEMENT #define QSDOC_NAMED_ELEMENT(name) +// unmark uncreatable (will be overlayed by other types) +#define QSDOC_CREATABLE + +// change the cname used for this type +#define QSDOC_CNAME(name) + // overridden properties #define QSDOC_PROPERTY_OVERRIDE(...) + +// override types of properties for docs +#define QSDOC_TYPE_OVERRIDE(type) diff --git a/src/core/elapsedtimer.cpp b/src/core/elapsedtimer.cpp new file mode 100644 index 00000000..91321122 --- /dev/null +++ b/src/core/elapsedtimer.cpp @@ -0,0 +1,22 @@ +#include "elapsedtimer.hpp" + +#include + +ElapsedTimer::ElapsedTimer() { this->timer.start(); } + +qreal ElapsedTimer::elapsed() { return static_cast(this->elapsedNs()) / 1000000000.0; } + +qreal ElapsedTimer::restart() { return static_cast(this->restartNs()) / 1000000000.0; } + +qint64 ElapsedTimer::elapsedMs() { return this->timer.elapsed(); } + +qint64 ElapsedTimer::restartMs() { return this->timer.restart(); } + +qint64 ElapsedTimer::elapsedNs() { return this->timer.nsecsElapsed(); } + +qint64 ElapsedTimer::restartNs() { + // see qelapsedtimer.cpp + auto old = this->timer; + this->timer.start(); + return old.durationTo(this->timer).count(); +} diff --git a/src/core/elapsedtimer.hpp b/src/core/elapsedtimer.hpp new file mode 100644 index 00000000..85850963 --- /dev/null +++ b/src/core/elapsedtimer.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include +#include + +///! Measures time between events +/// The ElapsedTimer measures time since its last restart, and is useful +/// for determining the time between events that don't supply it. +class ElapsedTimer: public QObject { + Q_OBJECT; + QML_ELEMENT; + +public: + explicit ElapsedTimer(); + + /// Return the number of seconds since the timer was last + /// started or restarted, with nanosecond precision. + Q_INVOKABLE qreal elapsed(); + + /// Restart the timer, returning the number of seconds since + /// the timer was last started or restarted, with nanosecond precision. + Q_INVOKABLE qreal restart(); + + /// Return the number of milliseconds since the timer was last + /// started or restarted. + Q_INVOKABLE qint64 elapsedMs(); + + /// Restart the timer, returning the number of milliseconds since + /// the timer was last started or restarted. + Q_INVOKABLE qint64 restartMs(); + + /// Return the number of nanoseconds since the timer was last + /// started or restarted. + Q_INVOKABLE qint64 elapsedNs(); + + /// Restart the timer, returning the number of nanoseconds since + /// the timer was last started or restarted. + Q_INVOKABLE qint64 restartNs(); + +private: + QElapsedTimer timer; +}; diff --git a/src/core/generation.cpp b/src/core/generation.cpp index 77e4a9cb..ef4449b3 100644 --- a/src/core/generation.cpp +++ b/src/core/generation.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include #include #include @@ -12,7 +14,6 @@ #include #include #include -#include #include #include "iconimageprovider.hpp" @@ -23,10 +24,12 @@ #include "reload.hpp" #include "scan.hpp" -static QHash g_generations; // NOLINT +static QHash g_generations; // NOLINT -EngineGeneration::EngineGeneration(QmlScanner scanner) - : scanner(std::move(scanner)) +EngineGeneration::EngineGeneration(const QDir& rootPath, QmlScanner scanner) + : rootPath(rootPath) + , scanner(std::move(scanner)) + , urlInterceptor(this->rootPath) , interceptNetFactory(this->scanner.qmldirIntercepts) , engine(new QQmlEngine()) { g_generations.insert(this->engine, this); @@ -39,56 +42,93 @@ EngineGeneration::EngineGeneration(QmlScanner scanner) this->engine->addImageProvider("qsimage", new QsImageProvider()); this->engine->addImageProvider("qspixmap", new QsPixmapProvider()); - QuickshellPlugin::runConstructGeneration(*this); + QsEnginePlugin::runConstructGeneration(*this); } EngineGeneration::~EngineGeneration() { - g_generations.remove(this->engine); - delete this->engine; + if (this->engine != nullptr) { + qFatal() << this << "destroyed without calling destroy()"; + } } void EngineGeneration::destroy() { - // Multiple generations can detect a reload at the same time. - delete this->watcher; - this->watcher = nullptr; + if (this->destroying) return; + this->destroying = true; - // Yes all of this is actually necessary. - if (this->engine != nullptr && this->root != nullptr) { + if (this->watcher != nullptr) { + // Multiple generations can detect a reload at the same time. + QObject::disconnect(this->watcher, nullptr, this, nullptr); + this->watcher->deleteLater(); + this->watcher = nullptr; + } + + for (auto* extension: this->extensions.values()) { + delete extension; + } + + if (this->root != nullptr) { QObject::connect(this->root, &QObject::destroyed, this, [this]() { - // The timer seems to fix *one* of the possible qml item destructor crashes. - QTimer::singleShot(0, [this]() { - // Garbage is not collected during engine destruction. - this->engine->collectGarbage(); + // prevent further js execution between garbage collection and engine destruction. + this->engine->setInterrupted(true); - QObject::connect(this->engine, &QObject::destroyed, this, [this]() { delete this; }); + g_generations.remove(this->engine); - // Even after all of that there's still multiple failing assertions and segfaults. - // Pray you don't hit one. - // Note: it appeats *some* of the crashes are related to values owned by the generation. - // Test by commenting the connect() above. - this->engine->deleteLater(); - this->engine = nullptr; - }); + // Garbage is not collected during engine destruction. + this->engine->collectGarbage(); + + delete this->engine; + this->engine = nullptr; + + auto terminate = this->shouldTerminate; + auto code = this->exitCode; + delete this; + + if (terminate) QCoreApplication::exit(code); }); this->root->deleteLater(); this->root = nullptr; + } else { + g_generations.remove(this->engine); + + // the engine has never been used, no need to clean up + delete this->engine; + this->engine = nullptr; + + auto terminate = this->shouldTerminate; + auto code = this->exitCode; + delete this; + + if (terminate) QCoreApplication::exit(code); } } +void EngineGeneration::shutdown() { + if (this->destroying) return; + + delete this->root; + this->root = nullptr; + delete this->engine; + this->engine = nullptr; + delete this; +} + void EngineGeneration::onReload(EngineGeneration* old) { if (old != nullptr) { // if the old generation holds the window incubation controller as the // new generation acquires it then incubators will hang intermittently - old->incubationControllers.clear(); + qCDebug(logIncubator) << "Locking incubation controllers of old generation" << old; + old->incubationControllersLocked = true; old->assignIncubationController(); } - auto* app = QCoreApplication::instance(); - QObject::connect(this->engine, &QQmlEngine::quit, app, &QCoreApplication::quit); - QObject::connect(this->engine, &QQmlEngine::exit, app, &QCoreApplication::exit); + QObject::connect(this->engine, &QQmlEngine::quit, this, &EngineGeneration::quit); + QObject::connect(this->engine, &QQmlEngine::exit, this, &EngineGeneration::exit); + + if (auto* reloadable = qobject_cast(this->root)) { + reloadable->reload(old ? old->root : nullptr); + } - this->root->reload(old == nullptr ? nullptr : old->root); this->singletonRegistry.onReload(old == nullptr ? nullptr : &old->singletonRegistry); this->reloadComplete = true; emit this->reloadFinished(); @@ -105,7 +145,7 @@ void EngineGeneration::postReload() { // This can be called on a generation during its destruction. if (this->engine == nullptr || this->root == nullptr) return; - QuickshellPlugin::runOnReload(); + QsEnginePlugin::runOnReload(); PostReloadHook::postReloadTree(this->root); this->singletonRegistry.onPostReload(); } @@ -117,13 +157,21 @@ void EngineGeneration::setWatchingFiles(bool watching) { for (auto& file: this->scanner.scannedFiles) { this->watcher->addPath(file); + this->watcher->addPath(QFileInfo(file).dir().absolutePath()); } QObject::connect( this->watcher, &QFileSystemWatcher::fileChanged, this, - &EngineGeneration::filesChanged + &EngineGeneration::onFileChanged + ); + + QObject::connect( + this->watcher, + &QFileSystemWatcher::directoryChanged, + this, + &EngineGeneration::onDirectoryChanged ); } } else { @@ -134,28 +182,44 @@ void EngineGeneration::setWatchingFiles(bool watching) { } } -void EngineGeneration::registerIncubationController(QQmlIncubationController* controller) { - auto* obj = dynamic_cast(controller); +void EngineGeneration::onFileChanged(const QString& name) { + if (!this->watcher->files().contains(name)) { + this->deletedWatchedFiles.push_back(name); + } else { + emit this->filesChanged(); + } +} +void EngineGeneration::onDirectoryChanged() { + // try to find any files that were just deleted from a replace operation + for (auto& file: this->deletedWatchedFiles) { + if (QFileInfo(file).exists()) { + emit this->filesChanged(); + break; + } + } +} + +void EngineGeneration::registerIncubationController(QQmlIncubationController* controller) { // We only want controllers that we can swap out if destroyed. // This happens if the window owning the active controller dies. - if (obj == nullptr) { - qCDebug(logIncubator) << "Could not register incubation controller as it is not a QObject" - << controller; + if (auto* obj = dynamic_cast(controller)) { + QObject::connect( + obj, + &QObject::destroyed, + this, + &EngineGeneration::incubationControllerDestroyed + ); + } else { + qCWarning(logIncubator) << "Could not register incubation controller as it is not a QObject" + << controller; return; } - this->incubationControllers.push_back({controller, obj}); - - QObject::connect( - obj, - &QObject::destroyed, - this, - &EngineGeneration::incubationControllerDestroyed - ); - - qCDebug(logIncubator) << "Registered incubation controller" << controller; + this->incubationControllers.push_back(controller); + qCDebug(logIncubator) << "Registered incubation controller" << controller << "to generation" + << this; // This function can run during destruction. if (this->engine == nullptr) return; @@ -166,22 +230,20 @@ void EngineGeneration::registerIncubationController(QQmlIncubationController* co } void EngineGeneration::deregisterIncubationController(QQmlIncubationController* controller) { - QObject* obj = nullptr; - this->incubationControllers.removeIf([&](QPair other) { - if (controller == other.first) { - obj = other.second; - return true; - } else return false; - }); - - if (obj == nullptr) { - qCWarning(logIncubator) << "Failed to deregister incubation controller" << controller - << "as it was not registered to begin with"; - qCWarning(logIncubator) << "Current registered incuabation controllers" - << this->incubationControllers; - } else { + if (auto* obj = dynamic_cast(controller)) { QObject::disconnect(obj, nullptr, this, nullptr); - qCDebug(logIncubator) << "Deregistered incubation controller" << controller; + } else { + qCCritical(logIncubator) << "Deregistering incubation controller which is not a QObject, " + "however only QObject controllers should be registered."; + } + + if (!this->incubationControllers.removeOne(controller)) { + qCCritical(logIncubator) << "Failed to deregister incubation controller" << controller << "from" + << this << "as it was not registered to begin with"; + qCCritical(logIncubator) << "Current registered incuabation controllers" + << this->incubationControllers; + } else { + qCDebug(logIncubator) << "Deregistered incubation controller" << controller << "from" << this; } // This function can run during destruction. @@ -196,22 +258,25 @@ void EngineGeneration::deregisterIncubationController(QQmlIncubationController* void EngineGeneration::incubationControllerDestroyed() { auto* sender = this->sender(); - QQmlIncubationController* controller = nullptr; - - this->incubationControllers.removeIf([&](QPair other) { - if (sender == other.second) { - controller = other.first; - return true; - } else return false; - }); + auto* controller = dynamic_cast(sender); if (controller == nullptr) { - qCCritical(logIncubator) << "Destroyed incubation controller" << this->sender() - << "could not be identified, this may cause memory corruption"; + qCCritical(logIncubator) << "Destroyed incubation controller" << sender << "is not known to" + << this << ", this may cause memory corruption"; qCCritical(logIncubator) << "Current registered incuabation controllers" << this->incubationControllers; + + return; + } + + if (this->incubationControllers.removeOne(controller)) { + qCDebug(logIncubator) << "Destroyed incubation controller" << controller << "deregistered from" + << this; } else { - qCDebug(logIncubator) << "Destroyed incubation controller" << controller << "deregistered"; + qCCritical(logIncubator) << "Destroyed incubation controller" << controller + << "was not registered, but its destruction was observed by" << this; + + return; } // This function can run during destruction. @@ -224,23 +289,64 @@ void EngineGeneration::incubationControllerDestroyed() { } } +void EngineGeneration::registerExtension(const void* key, EngineGenerationExt* extension) { + if (this->extensions.contains(key)) { + delete this->extensions.value(key); + } + + this->extensions.insert(key, extension); +} + +EngineGenerationExt* EngineGeneration::findExtension(const void* key) { + return this->extensions.value(key); +} + +void EngineGeneration::quit() { + this->shouldTerminate = true; + this->destroy(); +} + +void EngineGeneration::exit(int code) { + this->shouldTerminate = true; + this->exitCode = code; + this->destroy(); +} + void EngineGeneration::assignIncubationController() { QQmlIncubationController* controller = nullptr; - if (this->incubationControllers.isEmpty()) controller = &this->delayedIncubationController; - else controller = this->incubationControllers.first().first; - qCDebug(logIncubator) << "Assigning incubation controller to engine:" << controller + if (this->incubationControllersLocked || this->incubationControllers.isEmpty()) { + controller = &this->delayedIncubationController; + } else { + controller = this->incubationControllers.first(); + } + + qCDebug(logIncubator) << "Assigning incubation controller" << controller << "to generation" + << this << "fallback:" << (controller == &this->delayedIncubationController); this->engine->setIncubationController(controller); } -EngineGeneration* EngineGeneration::findObjectGeneration(QObject* object) { +EngineGeneration* EngineGeneration::currentGeneration() { + if (g_generations.size() == 1) { + return *g_generations.begin(); + } else return nullptr; +} + +EngineGeneration* EngineGeneration::findEngineGeneration(const QQmlEngine* engine) { + return g_generations.value(engine); +} + +EngineGeneration* EngineGeneration::findObjectGeneration(const QObject* object) { + // Objects can still attempt to find their generation after it has been destroyed. + // if (g_generations.size() == 1) return EngineGeneration::currentGeneration(); + while (object != nullptr) { auto* context = QQmlEngine::contextForObject(object); if (context != nullptr) { - if (auto* generation = g_generations.value(context->engine())) { + if (auto* generation = EngineGeneration::findEngineGeneration(context->engine())) { return generation; } } diff --git a/src/core/generation.hpp b/src/core/generation.hpp index 11ebf0be..632bd8a5 100644 --- a/src/core/generation.hpp +++ b/src/core/generation.hpp @@ -1,25 +1,34 @@ #pragma once #include +#include #include +#include #include -#include +#include #include #include #include "incubator.hpp" #include "qsintercept.hpp" #include "scan.hpp" -#include "shell.hpp" #include "singleton.hpp" class RootWrapper; +class QuickshellGlobal; + +class EngineGenerationExt { +public: + EngineGenerationExt() = default; + virtual ~EngineGenerationExt() = default; + Q_DISABLE_COPY_MOVE(EngineGenerationExt); +}; class EngineGeneration: public QObject { Q_OBJECT; public: - explicit EngineGeneration(QmlScanner scanner); + explicit EngineGeneration(const QDir& rootPath, QmlScanner scanner); ~EngineGeneration() override; Q_DISABLE_COPY_MOVE(EngineGeneration); @@ -30,30 +39,55 @@ public: void registerIncubationController(QQmlIncubationController* controller); void deregisterIncubationController(QQmlIncubationController* controller); - static EngineGeneration* findObjectGeneration(QObject* object); + // takes ownership + void registerExtension(const void* key, EngineGenerationExt* extension); + EngineGenerationExt* findExtension(const void* key); + + static EngineGeneration* findEngineGeneration(const QQmlEngine* engine); + static EngineGeneration* findObjectGeneration(const QObject* object); + + // Returns the current generation if there is only one generation, + // otherwise null. + static EngineGeneration* currentGeneration(); RootWrapper* wrapper = nullptr; + QDir rootPath; QmlScanner scanner; QsUrlInterceptor urlInterceptor; QsInterceptNetworkAccessManagerFactory interceptNetFactory; QQmlEngine* engine = nullptr; - ShellRoot* root = nullptr; + QObject* root = nullptr; SingletonRegistry singletonRegistry; QFileSystemWatcher* watcher = nullptr; + QVector deletedWatchedFiles; DelayedQmlIncubationController delayedIncubationController; bool reloadComplete = false; + QuickshellGlobal* qsgInstance = nullptr; void destroy(); + void shutdown(); signals: void filesChanged(); void reloadFinished(); +public slots: + void quit(); + void exit(int code); + private slots: + void onFileChanged(const QString& name); + void onDirectoryChanged(); void incubationControllerDestroyed(); private: void postReload(); void assignIncubationController(); - QVector> incubationControllers; + QVector incubationControllers; + bool incubationControllersLocked = false; + QHash extensions; + + bool destroying = false; + bool shouldTerminate = false; + int exitCode = 0; }; diff --git a/src/core/iconimageprovider.cpp b/src/core/iconimageprovider.cpp index f4710fb8..cf24d37d 100644 --- a/src/core/iconimageprovider.cpp +++ b/src/core/iconimageprovider.cpp @@ -11,7 +11,9 @@ QPixmap IconImageProvider::requestPixmap(const QString& id, QSize* size, const QSize& requestedSize) { QString iconName; + QString fallbackName; QString path; + auto splitIdx = id.indexOf("?path="); if (splitIdx != -1) { iconName = id.sliced(0, splitIdx); @@ -19,10 +21,17 @@ IconImageProvider::requestPixmap(const QString& id, QSize* size, const QSize& re qWarning() << "Searching custom icon paths is not yet supported. Icon path will be ignored for" << id; } else { - iconName = id; + splitIdx = id.indexOf("?fallback="); + if (splitIdx != -1) { + iconName = id.sliced(0, splitIdx); + fallbackName = id.sliced(splitIdx + 10); + } else { + iconName = id; + } } auto icon = QIcon::fromTheme(iconName); + if (icon.isNull()) icon = QIcon::fromTheme(fallbackName); auto targetSize = requestedSize.isValid() ? requestedSize : QSize(100, 100); if (targetSize.width() == 0 || targetSize.height() == 0) targetSize = QSize(2, 2); @@ -55,12 +64,20 @@ QPixmap IconImageProvider::missingPixmap(const QSize& size) { return pixmap; } -QString IconImageProvider::requestString(const QString& icon, const QString& path) { +QString IconImageProvider::requestString( + const QString& icon, + const QString& path, + const QString& fallback +) { auto req = "image://icon/" + icon; if (!path.isEmpty()) { req += "?path=" + path; } + if (!fallback.isEmpty()) { + req += "?fallback=" + fallback; + } + return req; } diff --git a/src/core/iconimageprovider.hpp b/src/core/iconimageprovider.hpp index 167d93bd..57e26049 100644 --- a/src/core/iconimageprovider.hpp +++ b/src/core/iconimageprovider.hpp @@ -10,5 +10,10 @@ public: QPixmap requestPixmap(const QString& id, QSize* size, const QSize& requestedSize) override; static QPixmap missingPixmap(const QSize& size); - static QString requestString(const QString& icon, const QString& path); + + static QString requestString( + const QString& icon, + const QString& path = QString(), + const QString& fallback = QString() + ); }; diff --git a/src/core/iconprovider.cpp b/src/core/iconprovider.cpp new file mode 100644 index 00000000..99b423ed --- /dev/null +++ b/src/core/iconprovider.cpp @@ -0,0 +1,105 @@ +#include "iconprovider.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "generation.hpp" + +// QMenu re-calls pixmap() every time the mouse moves so its important to cache it. +class PixmapCacheIconEngine: public QIconEngine { + void paint( + QPainter* /*unused*/, + const QRect& /*unused*/, + QIcon::Mode /*unused*/, + QIcon::State /*unused*/ + ) override { + qFatal( + ) << "Unexpected icon paint request bypassed pixmap method. Please report this as a bug."; + } + + QPixmap pixmap(const QSize& size, QIcon::Mode /*unused*/, QIcon::State /*unused*/) override { + if (this->lastPixmap.isNull() || size != this->lastSize) { + this->lastPixmap = this->createPixmap(size); + this->lastSize = size; + } + + return this->lastPixmap; + } + + virtual QPixmap createPixmap(const QSize& size) = 0; + +private: + QSize lastSize; + QPixmap lastPixmap; +}; + +class ImageProviderIconEngine: public PixmapCacheIconEngine { +public: + explicit ImageProviderIconEngine(QQuickImageProvider* provider, QString id) + : provider(provider) + , id(std::move(id)) {} + + QPixmap createPixmap(const QSize& size) override { + if (this->provider->imageType() == QQmlImageProviderBase::Pixmap) { + return this->provider->requestPixmap(this->id, nullptr, size); + } else if (this->provider->imageType() == QQmlImageProviderBase::Image) { + auto image = this->provider->requestImage(this->id, nullptr, size); + return QPixmap::fromImage(image); + } else { + qFatal() << "Unexpected ImageProviderIconEngine image type" << this->provider->imageType(); + return QPixmap(); // never reached, satisfies lint + } + } + + [[nodiscard]] QIconEngine* clone() const override { + return new ImageProviderIconEngine(this->provider, this->id); + } + +private: + QQuickImageProvider* provider; + QString id; +}; + +QIcon getEngineImageAsIcon(QQmlEngine* engine, const QUrl& url) { + if (!engine || url.isEmpty()) return QIcon(); + + auto scheme = url.scheme(); + if (scheme == "image") { + auto providerName = url.authority(); + auto path = url.path(); + if (!path.isEmpty()) path = path.sliced(1); + + auto* provider = qobject_cast(engine->imageProvider(providerName)); + + if (provider == nullptr) { + qWarning() << "iconByUrl failed: no provider found for" << url; + return QIcon(); + } + + if (provider->imageType() == QQmlImageProviderBase::Pixmap + || provider->imageType() == QQmlImageProviderBase::Image) + { + return QIcon(new ImageProviderIconEngine(provider, path)); + } + + } else { + qWarning() << "iconByUrl failed: unsupported scheme" << scheme << "in path" << url; + } + + return QIcon(); +} + +QIcon getCurrentEngineImageAsIcon(const QUrl& url) { + auto* generation = EngineGeneration::currentGeneration(); + if (!generation) return QIcon(); + return getEngineImageAsIcon(generation->engine, url); +} diff --git a/src/core/iconprovider.hpp b/src/core/iconprovider.hpp new file mode 100644 index 00000000..173d20e6 --- /dev/null +++ b/src/core/iconprovider.hpp @@ -0,0 +1,8 @@ +#pragma once + +#include +#include +#include + +QIcon getEngineImageAsIcon(QQmlEngine* engine, const QUrl& url); +QIcon getCurrentEngineImageAsIcon(const QUrl& url); diff --git a/src/core/instanceinfo.cpp b/src/core/instanceinfo.cpp new file mode 100644 index 00000000..96097c76 --- /dev/null +++ b/src/core/instanceinfo.cpp @@ -0,0 +1,35 @@ +#include "instanceinfo.hpp" + +#include + +QDataStream& operator<<(QDataStream& stream, const InstanceInfo& info) { + stream << info.instanceId << info.configPath << info.shellId << info.launchTime; + return stream; +} + +QDataStream& operator>>(QDataStream& stream, InstanceInfo& info) { + stream >> info.instanceId >> info.configPath >> info.shellId >> info.launchTime; + return stream; +} + +QDataStream& operator<<(QDataStream& stream, const RelaunchInfo& info) { + stream << info.instance << info.noColor << info.timestamp << info.sparseLogsOnly + << info.defaultLogLevel << info.logRules; + + return stream; +} + +QDataStream& operator>>(QDataStream& stream, RelaunchInfo& info) { + stream >> info.instance >> info.noColor >> info.timestamp >> info.sparseLogsOnly + >> info.defaultLogLevel >> info.logRules; + + return stream; +} + +InstanceInfo InstanceInfo::CURRENT = {}; // NOLINT + +namespace qs::crash { + +CrashInfo CrashInfo::INSTANCE = {}; // NOLINT + +} diff --git a/src/core/instanceinfo.hpp b/src/core/instanceinfo.hpp new file mode 100644 index 00000000..f0fc02a0 --- /dev/null +++ b/src/core/instanceinfo.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +struct InstanceInfo { + QString instanceId; + QString configPath; + QString shellId; + QDateTime launchTime; + + static InstanceInfo CURRENT; // NOLINT +}; + +struct RelaunchInfo { + InstanceInfo instance; + bool noColor = false; + bool timestamp = false; + bool sparseLogsOnly = false; + QtMsgType defaultLogLevel = QtWarningMsg; + QString logRules; +}; + +QDataStream& operator<<(QDataStream& stream, const InstanceInfo& info); +QDataStream& operator>>(QDataStream& stream, InstanceInfo& info); + +QDataStream& operator<<(QDataStream& stream, const RelaunchInfo& info); +QDataStream& operator>>(QDataStream& stream, RelaunchInfo& info); + +namespace qs::crash { + +struct CrashInfo { + int logFd = -1; + + static CrashInfo INSTANCE; // NOLINT +}; + +} // namespace qs::crash diff --git a/src/core/lazyloader.cpp b/src/core/lazyloader.cpp index 76317223..be0eb78b 100644 --- a/src/core/lazyloader.cpp +++ b/src/core/lazyloader.cpp @@ -179,7 +179,9 @@ void LazyLoader::incubateIfReady(bool overrideReloadCheck) { void LazyLoader::onIncubationCompleted() { this->setItem(this->incubator->object()); - delete this->incubator; + // The incubator is not necessarily inert at the time of this callback, + // so deleteLater is required. + this->incubator->deleteLater(); this->incubator = nullptr; this->targetLoading = false; emit this->loadingChanged(); diff --git a/src/core/lazyloader.hpp b/src/core/lazyloader.hpp index 8ef935f6..dbaad4b5 100644 --- a/src/core/lazyloader.hpp +++ b/src/core/lazyloader.hpp @@ -79,7 +79,7 @@ /// > [!WARNING] Components that internally load other components must explicitly /// > support asynchronous loading to avoid blocking. /// > -/// > Notably, [Variants](../variants) does not corrently support asynchronous +/// > Notably, @@Variants does not corrently support asynchronous /// > loading, meaning using it inside a LazyLoader will block similarly to not /// > having a loader to start with. /// @@ -87,8 +87,8 @@ /// > meaning if you create all windows inside of lazy loaders, none of them will ever load. class LazyLoader: public Reloadable { Q_OBJECT; - /// The fully loaded item if the loader is `loading` or `active`, or `null` - /// if neither `loading` or `active`. + /// The fully loaded item if the loader is @@loading or @@active, or `null` + /// if neither @@loading nor @@active. /// /// Note that the item is owned by the LazyLoader, and destroying the LazyLoader /// will destroy the item. @@ -96,7 +96,7 @@ class LazyLoader: public Reloadable { /// > [!WARNING] If you access the `item` of a loader that is currently loading, /// > it will block as if you had set `active` to true immediately beforehand. /// > - /// > You can instead set `loading` and listen to the `activeChanged` signal to + /// > You can instead set @@loading and listen to @@activeChanged(s) signal to /// > ensure loading happens asynchronously. Q_PROPERTY(QObject* item READ item NOTIFY itemChanged); /// If the loader is actively loading. @@ -105,7 +105,7 @@ class LazyLoader: public Reloadable { /// loading it asynchronously. If the component is already loaded, setting /// this property has no effect. /// - /// See also: [activeAsync](#prop.activeAsync). + /// See also: @@activeAsync. Q_PROPERTY(bool loading READ isLoading WRITE setLoading NOTIFY loadingChanged); /// If the component is fully loaded. /// @@ -113,17 +113,17 @@ class LazyLoader: public Reloadable { /// blocking the UI, and setting it to `false` will destroy the component, requiring /// it to be loaded again. /// - /// See also: [activeAsync](#prop.activeAsync). + /// See also: @@activeAsync. Q_PROPERTY(bool active READ isActive WRITE setActive NOTIFY activeChanged); /// If the component is fully loaded. /// /// Setting this property to true will asynchronously load the component similarly to - /// [loading](#prop.loading). Reading it or setting it to false will behanve - /// the same as [active](#prop.active). + /// @@loading. Reading it or setting it to false will behanve + /// the same as @@active. Q_PROPERTY(bool activeAsync READ isActive WRITE setActiveAsync NOTIFY activeChanged); - /// The component to load. Mutually exclusive to `source`. + /// The component to load. Mutually exclusive to @@source. Q_PROPERTY(QQmlComponent* component READ component WRITE setComponent NOTIFY componentChanged); - /// The URI to load the component from. Mutually exclusive to `component`. + /// The URI to load the component from. Mutually exclusive to @@component. Q_PROPERTY(QString source READ source WRITE setSource NOTIFY sourceChanged); Q_CLASSINFO("DefaultProperty", "component"); QML_ELEMENT; diff --git a/src/core/logging.cpp b/src/core/logging.cpp new file mode 100644 index 00000000..57b63e18 --- /dev/null +++ b/src/core/logging.cpp @@ -0,0 +1,937 @@ +#include "logging.hpp" +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "instanceinfo.hpp" +#include "logging_p.hpp" +#include "logging_qtprivate.cpp" // NOLINT +#include "paths.hpp" +#include "ringbuf.hpp" + +Q_LOGGING_CATEGORY(logBare, "quickshell.bare"); + +namespace qs::log { +using namespace qt_logging_registry; + +Q_LOGGING_CATEGORY(logLogging, "quickshell.logging", QtWarningMsg); + +bool LogMessage::operator==(const LogMessage& other) const { + // note: not including time + return this->type == other.type && this->category == other.category && this->body == other.body; +} + +size_t qHash(const LogMessage& message) { + return qHash(message.type) ^ qHash(message.category) ^ qHash(message.body); +} + +void LogMessage::formatMessage( + QTextStream& stream, + const LogMessage& msg, + bool color, + bool timestamp, + const QString& prefix +) { + if (!prefix.isEmpty()) { + if (color) stream << "\033[90m"; + stream << '[' << prefix << ']'; + if (timestamp) stream << ' '; + if (color) stream << "\033[0m"; + } + + if (timestamp) { + if (color) stream << "\033[90m"; + stream << msg.time.toString("yyyy-MM-dd hh:mm:ss.zzz"); + } + + if (msg.category == "quickshell.bare") { + if (!prefix.isEmpty()) stream << ' '; + stream << msg.body; + } else { + if (color) { + switch (msg.type) { + case QtDebugMsg: stream << "\033[34m DEBUG"; break; + case QtInfoMsg: stream << "\033[32m INFO"; break; + case QtWarningMsg: stream << "\033[33m WARN"; break; + case QtCriticalMsg: stream << "\033[31m ERROR"; break; + case QtFatalMsg: stream << "\033[31m FATAL"; break; + } + } else { + switch (msg.type) { + case QtDebugMsg: stream << " DEBUG"; break; + case QtInfoMsg: stream << " INFO"; break; + case QtWarningMsg: stream << " WARN"; break; + case QtCriticalMsg: stream << " ERROR"; break; + case QtFatalMsg: stream << " FATAL"; break; + } + } + + const auto isDefault = msg.category == "default"; + + if (color && !isDefault && msg.type != QtFatalMsg) stream << "\033[97m"; + + if (!isDefault) { + stream << ' ' << msg.category; + } + + if (color && msg.type != QtFatalMsg) stream << "\033[0m"; + + stream << ": " << msg.body; + + if (color && msg.type == QtFatalMsg) stream << "\033[0m"; + } +} + +bool CategoryFilter::shouldDisplay(QtMsgType type) const { + switch (type) { + case QtDebugMsg: return this->debug; + case QtInfoMsg: return this->info; + case QtWarningMsg: return this->warn; + case QtCriticalMsg: return this->critical; + default: return true; + } +} + +void CategoryFilter::apply(QLoggingCategory* category) const { + category->setEnabled(QtDebugMsg, this->debug); + category->setEnabled(QtInfoMsg, this->info); + category->setEnabled(QtWarningMsg, this->warn); + category->setEnabled(QtCriticalMsg, this->critical); +} + +void CategoryFilter::applyRule( + QLatin1StringView category, + const qt_logging_registry::QLoggingRule& rule +) { + auto filterpass = rule.pass(category, QtDebugMsg); + if (filterpass != 0) this->debug = filterpass > 0; + + filterpass = rule.pass(category, QtInfoMsg); + if (filterpass != 0) this->info = filterpass > 0; + + filterpass = rule.pass(category, QtWarningMsg); + if (filterpass != 0) this->warn = filterpass > 0; + + filterpass = rule.pass(category, QtCriticalMsg); + if (filterpass != 0) this->critical = filterpass > 0; +} + +LogManager::LogManager(): stdoutStream(stdout) {} + +void LogManager::messageHandler( + QtMsgType type, + const QMessageLogContext& context, + const QString& msg +) { + auto message = LogMessage(type, QLatin1StringView(context.category), msg.toUtf8()); + + auto* self = LogManager::instance(); + + auto display = true; + + const auto* key = static_cast(context.category); + + if (self->sparseFilters.contains(key)) { + display = self->sparseFilters.value(key).shouldDisplay(type); + } + + if (display) { + LogMessage::formatMessage( + self->stdoutStream, + message, + self->colorLogs, + self->timestampLogs, + self->prefix + ); + + self->stdoutStream << Qt::endl; + } + + emit self->logMessage(message, display); +} + +void LogManager::filterCategory(QLoggingCategory* category) { + auto* instance = LogManager::instance(); + + auto categoryName = QLatin1StringView(category->categoryName()); + auto isQs = categoryName.startsWith(QLatin1StringView("quickshell.")); + + if (instance->lastCategoryFilter) { + instance->lastCategoryFilter(category); + } + + auto filter = CategoryFilter(category); + + if (isQs) { + filter.debug = filter.debug || instance->mDefaultLevel == QtDebugMsg; + filter.info = filter.debug || instance->mDefaultLevel == QtInfoMsg; + filter.warn = filter.info || instance->mDefaultLevel == QtWarningMsg; + filter.critical = filter.warn || instance->mDefaultLevel == QtCriticalMsg; + } + + for (const auto& rule: *instance->rules) { + filter.applyRule(categoryName, rule); + } + + if (isQs && !instance->sparse) { + // We assume the category name pointer will always be the same and be comparable in the message handler. + instance->sparseFilters.insert(static_cast(category->categoryName()), filter); + + // all enabled by default + CategoryFilter().apply(category); + } else { + filter.apply(category); + } + + instance->allFilters.insert(categoryName, filter); +} + +LogManager* LogManager::instance() { + static auto* instance = new LogManager(); // NOLINT + return instance; +} + +void LogManager::init( + bool color, + bool timestamp, + bool sparseOnly, + QtMsgType defaultLevel, + const QString& rules, + const QString& prefix +) { + auto* instance = LogManager::instance(); + instance->colorLogs = color; + instance->timestampLogs = timestamp; + instance->sparse = sparseOnly; + instance->prefix = prefix; + instance->mDefaultLevel = defaultLevel; + instance->mRulesString = rules; + + { + QLoggingSettingsParser parser; + parser.setContent(rules); + instance->rules = new QList(parser.rules()); + } + + qInstallMessageHandler(&LogManager::messageHandler); + + instance->lastCategoryFilter = QLoggingCategory::installFilter(&LogManager::filterCategory); + + qCDebug(logLogging) << "Creating offthread logger..."; + auto* thread = new QThread(); + instance->threadProxy.moveToThread(thread); + thread->start(); + + QMetaObject::invokeMethod( + &instance->threadProxy, + &LoggingThreadProxy::initInThread, + Qt::BlockingQueuedConnection + ); + + qCDebug(logLogging) << "Logger initialized."; +} + +void LogManager::initFs() { + QMetaObject::invokeMethod( + &LogManager::instance()->threadProxy, + "initFs", + Qt::BlockingQueuedConnection + ); +} + +QString LogManager::rulesString() const { return this->mRulesString; } +QtMsgType LogManager::defaultLevel() const { return this->mDefaultLevel; } +bool LogManager::isSparse() const { return this->sparse; } + +CategoryFilter LogManager::getFilter(QLatin1StringView category) { + return this->allFilters.value(category); +} + +void LoggingThreadProxy::initInThread() { + this->logging = new ThreadLogging(this); + this->logging->init(); +} + +void LoggingThreadProxy::initFs() { this->logging->initFs(); } + +void ThreadLogging::init() { + auto logMfd = memfd_create("quickshell:logs", 0); + + if (logMfd == -1) { + qCCritical(logLogging) << "Failed to create memfd for initial log storage" + << qt_error_string(-1); + } + + auto dlogMfd = memfd_create("quickshell:detailedlogs", 0); + + if (dlogMfd == -1) { + qCCritical(logLogging) << "Failed to create memfd for initial detailed log storage" + << qt_error_string(-1); + } + + if (logMfd != -1) { + this->file = new QFile(); + this->file->open(logMfd, QFile::ReadWrite, QFile::AutoCloseHandle); + this->fileStream.setDevice(this->file); + } + + if (dlogMfd != -1) { + crash::CrashInfo::INSTANCE.logFd = dlogMfd; + + this->detailedFile = new QFile(); + // buffered by WriteBuffer + this->detailedFile->open(dlogMfd, QFile::ReadWrite | QFile::Unbuffered, QFile::AutoCloseHandle); + this->detailedWriter.setDevice(this->detailedFile); + + if (!this->detailedWriter.writeHeader()) { + qCCritical(logLogging) << "Could not write header for detailed logs."; + this->detailedWriter.setDevice(nullptr); + delete this->detailedFile; + this->detailedFile = nullptr; + } + } + + // This connection is direct so it works while the event loop is destroyed between + // QCoreApplication delete and Q(Gui)Application launch. + QObject::connect( + LogManager::instance(), + &LogManager::logMessage, + this, + &ThreadLogging::onMessage, + Qt::DirectConnection + ); + + qCDebug(logLogging) << "Created memfd" << logMfd << "for early logs."; + qCDebug(logLogging) << "Created memfd" << dlogMfd << "for early detailed logs."; +} + +void ThreadLogging::initFs() { + qCDebug(logLogging) << "Starting filesystem logging..."; + auto* runDir = QsPaths::instance()->instanceRunDir(); + + if (!runDir) { + qCCritical(logLogging + ) << "Could not start filesystem logging as the runtime directory could not be created."; + return; + } + + auto path = runDir->filePath("log.log"); + auto detailedPath = runDir->filePath("log.qslog"); + auto* file = new QFile(path); + auto* detailedFile = new QFile(detailedPath); + + if (!file->open(QFile::ReadWrite | QFile::Truncate)) { + qCCritical(logLogging + ) << "Could not start filesystem logger as the log file could not be created:" + << path; + delete file; + file = nullptr; + } else { + qInfo() << "Saving logs to" << path; + } + + // buffered by WriteBuffer + if (!detailedFile->open(QFile::ReadWrite | QFile::Truncate | QFile::Unbuffered)) { + qCCritical(logLogging + ) << "Could not start detailed filesystem logger as the log file could not be created:" + << detailedPath; + delete detailedFile; + detailedFile = nullptr; + } else { + auto lock = flock { + .l_type = F_WRLCK, + .l_whence = SEEK_SET, + .l_start = 0, + .l_len = 0, + .l_pid = 0, + }; + + if (fcntl(detailedFile->handle(), F_SETLK, &lock) != 0) { // NOLINT + qCWarning(logLogging) << "Unable to set lock marker on detailed log file. --follow from " + "other instances will not work."; + } + + qCInfo(logLogging) << "Saving detailed logs to" << path; + } + + qCDebug(logLogging) << "Copying memfd logs to log file..."; + + if (file) { + auto* oldFile = this->file; + if (oldFile) { + oldFile->seek(0); + sendfile(file->handle(), oldFile->handle(), nullptr, oldFile->size()); + } + + this->file = file; + this->fileStream.setDevice(file); + delete oldFile; + } + + if (detailedFile) { + auto* oldFile = this->detailedFile; + if (oldFile) { + oldFile->seek(0); + sendfile(detailedFile->handle(), oldFile->handle(), nullptr, oldFile->size()); + } + + crash::CrashInfo::INSTANCE.logFd = detailedFile->handle(); + + this->detailedFile = detailedFile; + this->detailedWriter.setDevice(detailedFile); + + if (!oldFile) { + if (!this->detailedWriter.writeHeader()) { + qCCritical(logLogging) << "Could not write header for detailed logs."; + this->detailedWriter.setDevice(nullptr); + delete this->detailedFile; + this->detailedFile = nullptr; + } + } + + delete oldFile; + } + + qCDebug(logLogging) << "Switched logging to disk logs."; + + auto* logManager = LogManager::instance(); + QObject::disconnect(logManager, &LogManager::logMessage, this, &ThreadLogging::onMessage); + + QObject::connect( + logManager, + &LogManager::logMessage, + this, + &ThreadLogging::onMessage, + Qt::QueuedConnection + ); + + qCDebug(logLogging) << "Switched threaded logger to queued eventloop connection."; +} + +void ThreadLogging::onMessage(const LogMessage& msg, bool showInSparse) { + if (showInSparse) { + if (this->fileStream.device() == nullptr) return; + LogMessage::formatMessage(this->fileStream, msg, false, true); + this->fileStream << Qt::endl; + } + + if (this->detailedWriter.write(msg)) { + this->detailedFile->flush(); + } else if (this->detailedFile != nullptr) { + qCCritical(logLogging) << "Detailed logger failed to write. Ending detailed logs."; + } +} + +CompressedLogType compressedTypeOf(QtMsgType type) { + switch (type) { + case QtDebugMsg: return CompressedLogType::Debug; + case QtInfoMsg: return CompressedLogType::Info; + case QtWarningMsg: return CompressedLogType::Warn; + case QtCriticalMsg: + case QtFatalMsg: return CompressedLogType::Critical; + } + + return CompressedLogType::Info; // unreachable under normal conditions +} + +QtMsgType typeOfCompressed(CompressedLogType type) { + switch (type) { + case CompressedLogType::Debug: return QtDebugMsg; + case CompressedLogType::Info: return QtInfoMsg; + case CompressedLogType::Warn: return QtWarningMsg; + case CompressedLogType::Critical: return QtCriticalMsg; + } + + return QtInfoMsg; // unreachable under normal conditions +} + +void WriteBuffer::setDevice(QIODevice* device) { this->device = device; } +bool WriteBuffer::hasDevice() const { return this->device; } + +bool WriteBuffer::flush() { + auto written = this->device->write(this->buffer); + auto success = written == this->buffer.length(); + this->buffer.clear(); + return success; +} + +void WriteBuffer::writeBytes(const char* data, qsizetype length) { + this->buffer.append(data, length); +} + +void WriteBuffer::writeU8(quint8 data) { this->writeBytes(reinterpret_cast(&data), 1); } + +void WriteBuffer::writeU16(quint16 data) { + data = qToLittleEndian(data); + this->writeBytes(reinterpret_cast(&data), 2); +} + +void WriteBuffer::writeU32(quint32 data) { + data = qToLittleEndian(data); + this->writeBytes(reinterpret_cast(&data), 4); +} + +void WriteBuffer::writeU64(quint64 data) { + data = qToLittleEndian(data); + this->writeBytes(reinterpret_cast(&data), 8); +} + +void DeviceReader::setDevice(QIODevice* device) { this->device = device; } +bool DeviceReader::hasDevice() const { return this->device; } + +bool DeviceReader::readBytes(char* data, qsizetype length) { + return this->device->read(data, length) == length; +} + +qsizetype DeviceReader::peekBytes(char* data, qsizetype length) { + return this->device->peek(data, length); +} + +bool DeviceReader::skip(qsizetype length) { return this->device->skip(length) == length; } + +bool DeviceReader::readU8(quint8* data) { + return this->readBytes(reinterpret_cast(data), 1); +} + +bool DeviceReader::readU16(quint16* data) { + return this->readBytes(reinterpret_cast(data), 2); +} + +bool DeviceReader::readU32(quint32* data) { + return this->readBytes(reinterpret_cast(data), 4); +} + +bool DeviceReader::readU64(quint64* data) { + return this->readBytes(reinterpret_cast(data), 8); +} + +void EncodedLogWriter::setDevice(QIODevice* target) { this->buffer.setDevice(target); } +void EncodedLogReader::setDevice(QIODevice* source) { this->reader.setDevice(source); } + +constexpr quint8 LOG_VERSION = 2; + +bool EncodedLogWriter::writeHeader() { + this->buffer.writeU8(LOG_VERSION); + return this->buffer.flush(); +} + +bool EncodedLogReader::readHeader(bool* success, quint8* version, quint8* readerVersion) { + if (!this->reader.readU8(version)) return false; + *success = *version == LOG_VERSION; + *readerVersion = LOG_VERSION; + return true; +} + +bool EncodedLogWriter::write(const LogMessage& message) { + if (!this->buffer.hasDevice()) return false; + + LogMessage* prevMessage = nullptr; + auto index = this->recentMessages.indexOf(message, &prevMessage); + + // If its a dupe, save memory by reusing the buffer of the first message and letting + // the new one be deallocated. + auto body = prevMessage ? prevMessage->body : message.body; + this->recentMessages.emplace(message.type, message.category, body, message.time); + + if (index != -1) { + auto secondDelta = this->lastMessageTime.secsTo(message.time); + + if (secondDelta < 16 && index < 16) { + this->writeOp(EncodedLogOpcode::RecentMessageShort); + this->buffer.writeU8(index | (secondDelta << 4)); + } else { + this->writeOp(EncodedLogOpcode::RecentMessageLong); + this->buffer.writeU8(index); + this->writeVarInt(secondDelta); + } + + goto finish; + } else { + auto categoryId = this->getOrCreateCategory(message.category); + this->writeVarInt(categoryId); + + auto writeFullTimestamp = [this, &message]() { + this->buffer.writeU64(message.time.toSecsSinceEpoch()); + }; + + if (message.type == QtFatalMsg) { + this->buffer.writeU8(0xff); + writeFullTimestamp(); + } else { + quint8 field = compressedTypeOf(message.type); + + auto secondDelta = this->lastMessageTime.secsTo(message.time); + if (secondDelta >= 0x1d) { + // 0x1d = followed by delta int + // 0x1e = followed by epoch delta int + field |= (secondDelta < 0xffff ? 0x1d : 0x1e) << 3; + } else { + field |= secondDelta << 3; + } + + this->buffer.writeU8(field); + + if (secondDelta >= 0x1d) { + if (secondDelta > 0xffff) { + writeFullTimestamp(); + } else { + this->writeVarInt(secondDelta); + } + } + } + + this->writeString(message.body); + } + +finish: + // copy with second precision + this->lastMessageTime = QDateTime::fromSecsSinceEpoch(message.time.toSecsSinceEpoch()); + return this->buffer.flush(); +} + +bool EncodedLogReader::read(LogMessage* slot) { +start: + quint32 next = 0; + if (!this->readVarInt(&next)) return false; + + if (next < EncodedLogOpcode::BeginCategories) { + if (next == EncodedLogOpcode::RegisterCategory) { + if (!this->registerCategory()) return false; + goto start; + } else if (next == EncodedLogOpcode::RecentMessageShort + || next == EncodedLogOpcode::RecentMessageLong) + { + quint8 index = 0; + quint32 secondDelta = 0; + + if (next == EncodedLogOpcode::RecentMessageShort) { + quint8 field = 0; + if (!this->reader.readU8(&field)) return false; + index = field & 0xf; + secondDelta = field >> 4; + } else { + if (!this->reader.readU8(&index)) return false; + if (!this->readVarInt(&secondDelta)) return false; + } + + if (index >= this->recentMessages.size()) return false; + *slot = this->recentMessages.at(index); + this->lastMessageTime = this->lastMessageTime.addSecs(static_cast(secondDelta)); + slot->time = this->lastMessageTime; + } + } else { + auto categoryId = next - EncodedLogOpcode::BeginCategories; + auto category = this->categories.value(categoryId); + + quint8 field = 0; + if (!this->reader.readU8(&field)) return false; + + auto msgType = QtDebugMsg; + quint64 secondDelta = 0; + auto needsTimeRead = false; + + if (field == 0xff) { + msgType = QtFatalMsg; + needsTimeRead = true; + } else { + msgType = typeOfCompressed(static_cast(field & 0x07)); + secondDelta = field >> 3; + + if (secondDelta == 0x1d) { + quint32 slot = 0; + if (!this->readVarInt(&slot)) return false; + secondDelta = slot; + } else if (secondDelta == 0x1e) { + needsTimeRead = true; + } + } + + if (needsTimeRead) { + if (!this->reader.readU64(&secondDelta)) return false; + } + + this->lastMessageTime = this->lastMessageTime.addSecs(static_cast(secondDelta)); + + QByteArray body; + if (!this->readString(&body)) return false; + + *slot = LogMessage(msgType, QLatin1StringView(category.first), body, this->lastMessageTime); + slot->readCategoryId = categoryId; + } + + this->recentMessages.emplace(*slot); + return true; +} + +CategoryFilter EncodedLogReader::categoryFilterById(quint16 id) { + return this->categories.value(id).second; +} + +void EncodedLogWriter::writeOp(EncodedLogOpcode opcode) { this->buffer.writeU8(opcode); } + +void EncodedLogWriter::writeVarInt(quint32 n) { + if (n < 0xff) { + this->buffer.writeU8(n); + } else if (n < 0xffff) { + this->buffer.writeU8(0xff); + this->buffer.writeU16(n); + } else { + this->buffer.writeU8(0xff); + this->buffer.writeU16(0xffff); + this->buffer.writeU32(n); + } +} + +bool EncodedLogReader::readVarInt(quint32* slot) { + auto bytes = std::array(); + auto readLength = this->reader.peekBytes(reinterpret_cast(bytes.data()), 7); + + if (bytes[0] != 0xff && readLength >= 1) { + auto n = *reinterpret_cast(bytes.data()); + if (!this->reader.skip(1)) return false; + *slot = qFromLittleEndian(n); + } else if ((bytes[1] != 0xff || bytes[2] != 0xff) && readLength >= 3) { + auto n = *reinterpret_cast(bytes.data() + 1); + if (!this->reader.skip(3)) return false; + *slot = qFromLittleEndian(n); + } else if (readLength == 7) { + auto n = *reinterpret_cast(bytes.data() + 3); + if (!this->reader.skip(7)) return false; + *slot = qFromLittleEndian(n); + } else return false; + + return true; +} + +void EncodedLogWriter::writeString(QByteArrayView bytes) { + this->writeVarInt(bytes.length()); + this->buffer.writeBytes(bytes.constData(), bytes.length()); +} + +bool EncodedLogReader::readString(QByteArray* slot) { + quint32 length = 0; + if (!this->readVarInt(&length)) return false; + + *slot = QByteArray(length, Qt::Uninitialized); + auto r = this->reader.readBytes(slot->data(), slot->size()); + return r; +} + +quint16 EncodedLogWriter::getOrCreateCategory(QLatin1StringView category) { + if (this->categories.contains(category)) { + return this->categories.value(category); + } else { + this->writeOp(EncodedLogOpcode::RegisterCategory); + // id is implicitly the next available id + this->writeString(category); + + auto id = this->nextCategory++; + this->categories.insert(category, id); + + auto filter = LogManager::instance()->getFilter(category); + quint8 flags = 0; + flags |= filter.debug << 0; + flags |= filter.info << 1; + flags |= filter.warn << 2; + flags |= filter.critical << 3; + + this->buffer.writeU8(flags); + return id; + } +} + +bool EncodedLogReader::registerCategory() { + QByteArray name; + quint8 flags = 0; + if (!this->readString(&name)) return false; + if (!this->reader.readU8(&flags)) return false; + + CategoryFilter filter; + filter.debug = (flags >> 0) & 1; + filter.info = (flags >> 1) & 1; + filter.warn = (flags >> 2) & 1; + filter.critical = (flags >> 3) & 1; + + this->categories.append(qMakePair(name, filter)); + return true; +} + +bool LogReader::initialize() { + this->reader.setDevice(this->file); + + bool readable = false; + quint8 logVersion = 0; + quint8 readerVersion = 0; + if (!this->reader.readHeader(&readable, &logVersion, &readerVersion)) { + qCritical() << "Failed to read log header."; + return false; + } + + if (!readable) { + qCritical() << "This log was encoded with version" << logVersion + << "of the quickshell log encoder, which cannot be decoded by the current " + "version of quickshell, with log version" + << readerVersion; + return false; + } + + return true; +} + +bool LogReader::continueReading() { + auto color = LogManager::instance()->colorLogs; + auto tailRing = RingBuffer(this->remainingTail); + + LogMessage message; + auto stream = QTextStream(stdout); + auto readCursor = this->file->pos(); + while (this->reader.read(&message)) { + readCursor = this->file->pos(); + + CategoryFilter filter; + if (this->filters.contains(message.readCategoryId)) { + filter = this->filters.value(message.readCategoryId); + } else { + filter = this->reader.categoryFilterById(message.readCategoryId); + + for (const auto& rule: this->rules) { + filter.applyRule(message.category, rule); + } + + this->filters.insert(message.readCategoryId, filter); + } + + if (filter.shouldDisplay(message.type)) { + if (this->remainingTail == 0) { + LogMessage::formatMessage(stream, message, color, this->timestamps); + stream << '\n'; + } else { + tailRing.emplace(message); + } + } + } + + if (this->remainingTail != 0) { + for (auto i = tailRing.size() - 1; i != -1; i--) { + auto& message = tailRing.at(i); + LogMessage::formatMessage(stream, message, color, this->timestamps); + stream << '\n'; + } + } + + stream << Qt::flush; + + if (this->file->pos() != readCursor) { + qCritical() << "An error occurred parsing the end of this log file."; + qCritical() << "Remaining data:" << this->file->readAll(); + return false; + } + + return true; +} + +void LogFollower::FcntlWaitThread::run() { + auto lock = flock { + .l_type = F_RDLCK, // won't block other read locks when we take it + .l_whence = SEEK_SET, + .l_start = 0, + .l_len = 0, + .l_pid = 0, + }; + + auto r = fcntl(this->follower->reader->file->handle(), F_SETLKW, &lock); // NOLINT + + if (r != 0) { + qCWarning(logLogging).nospace() + << "Failed to wait for write locks to be removed from log file with error code " << errno + << ": " << qt_error_string(); + } +} + +bool LogFollower::follow() { + QObject::connect(&this->waitThread, &QThread::finished, this, &LogFollower::onFileLocked); + + QObject::connect( + &this->fileWatcher, + &QFileSystemWatcher::fileChanged, + this, + &LogFollower::onFileChanged + ); + + this->fileWatcher.addPath(this->path); + this->waitThread.start(); + + auto r = QCoreApplication::exec(); + return r == 0; +} + +void LogFollower::onFileChanged() { + if (!this->reader->continueReading()) { + QCoreApplication::exit(1); + } +} + +void LogFollower::onFileLocked() { + if (!this->reader->continueReading()) { + QCoreApplication::exit(1); + } else { + QCoreApplication::exit(0); + } +} + +bool readEncodedLogs( + QFile* file, + const QString& path, + bool timestamps, + int tail, + bool follow, + const QString& rulespec +) { + QList rules; + + { + QLoggingSettingsParser parser; + parser.setContent(rulespec); + rules = parser.rules(); + } + + auto reader = LogReader(file, timestamps, tail, rules); + + if (!reader.initialize()) return false; + if (!reader.continueReading()) return false; + + if (follow) { + auto follower = LogFollower(&reader, path); + return follower.follow(); + } + + return true; +} + +} // namespace qs::log diff --git a/src/core/logging.hpp b/src/core/logging.hpp new file mode 100644 index 00000000..7ff1b5e0 --- /dev/null +++ b/src/core/logging.hpp @@ -0,0 +1,148 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_DECLARE_LOGGING_CATEGORY(logBare); + +namespace qs::log { + +struct LogMessage { + explicit LogMessage() = default; + + explicit LogMessage( + QtMsgType type, + QLatin1StringView category, + QByteArray body, + QDateTime time = QDateTime::currentDateTime() + ) + : type(type) + , time(std::move(time)) + , category(category) + , body(std::move(body)) {} + + bool operator==(const LogMessage& other) const; + + QtMsgType type = QtDebugMsg; + QDateTime time; + QLatin1StringView category; + QByteArray body; + quint16 readCategoryId = 0; + + static void formatMessage( + QTextStream& stream, + const LogMessage& msg, + bool color, + bool timestamp, + const QString& prefix = "" + ); +}; + +size_t qHash(const LogMessage& message); + +class ThreadLogging; + +class LoggingThreadProxy: public QObject { + Q_OBJECT; + +public: + explicit LoggingThreadProxy() = default; + +public slots: + void initInThread(); + void initFs(); + +private: + ThreadLogging* logging = nullptr; +}; + +namespace qt_logging_registry { +class QLoggingRule; +} + +struct CategoryFilter { + explicit CategoryFilter() = default; + explicit CategoryFilter(QLoggingCategory* category) + : debug(category->isDebugEnabled()) + , info(category->isInfoEnabled()) + , warn(category->isWarningEnabled()) + , critical(category->isCriticalEnabled()) {} + + [[nodiscard]] bool shouldDisplay(QtMsgType type) const; + void apply(QLoggingCategory* category) const; + void applyRule(QLatin1StringView category, const qt_logging_registry::QLoggingRule& rule); + + bool debug = true; + bool info = true; + bool warn = true; + bool critical = true; +}; + +class LogManager: public QObject { + Q_OBJECT; + +public: + static void init( + bool color, + bool timestamp, + bool sparseOnly, + QtMsgType defaultLevel, + const QString& rules, + const QString& prefix = "" + ); + + static void initFs(); + static LogManager* instance(); + + bool colorLogs = true; + bool timestampLogs = false; + + [[nodiscard]] QString rulesString() const; + [[nodiscard]] QtMsgType defaultLevel() const; + [[nodiscard]] bool isSparse() const; + + [[nodiscard]] CategoryFilter getFilter(QLatin1StringView category); + +signals: + void logMessage(LogMessage msg, bool showInSparse); + +private: + explicit LogManager(); + static void messageHandler(QtMsgType type, const QMessageLogContext& context, const QString& msg); + + static void filterCategory(QLoggingCategory* category); + + QLoggingCategory::CategoryFilter lastCategoryFilter = nullptr; + bool sparse = false; + QString prefix; + QString mRulesString; + QList* rules = nullptr; + QtMsgType mDefaultLevel = QtWarningMsg; + QHash sparseFilters; + QHash allFilters; + + QTextStream stdoutStream; + LoggingThreadProxy threadProxy; +}; + +bool readEncodedLogs( + QFile* file, + const QString& path, + bool timestamps, + int tail, + bool follow, + const QString& rulespec +); + +} // namespace qs::log + +using LogManager = qs::log::LogManager; diff --git a/src/core/logging_p.hpp b/src/core/logging_p.hpp new file mode 100644 index 00000000..3297ea1b --- /dev/null +++ b/src/core/logging_p.hpp @@ -0,0 +1,190 @@ +#pragma once +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "logging.hpp" +#include "logging_qtprivate.hpp" +#include "ringbuf.hpp" + +namespace qs::log { + +enum EncodedLogOpcode : quint8 { + RegisterCategory = 0, + RecentMessageShort, + RecentMessageLong, + BeginCategories, +}; + +enum CompressedLogType : quint8 { + Debug = 0, + Info = 1, + Warn = 2, + Critical = 3, +}; + +CompressedLogType compressedTypeOf(QtMsgType type); +QtMsgType typeOfCompressed(CompressedLogType type); + +class WriteBuffer { +public: + void setDevice(QIODevice* device); + [[nodiscard]] bool hasDevice() const; + [[nodiscard]] bool flush(); + void writeBytes(const char* data, qsizetype length); + void writeU8(quint8 data); + void writeU16(quint16 data); + void writeU32(quint32 data); + void writeU64(quint64 data); + +private: + QIODevice* device = nullptr; + QByteArray buffer; +}; + +class DeviceReader { +public: + void setDevice(QIODevice* device); + [[nodiscard]] bool hasDevice() const; + [[nodiscard]] bool readBytes(char* data, qsizetype length); + // peek UP TO length + [[nodiscard]] qsizetype peekBytes(char* data, qsizetype length); + [[nodiscard]] bool skip(qsizetype length); + [[nodiscard]] bool readU8(quint8* data); + [[nodiscard]] bool readU16(quint16* data); + [[nodiscard]] bool readU32(quint32* data); + [[nodiscard]] bool readU64(quint64* data); + +private: + QIODevice* device = nullptr; +}; + +class EncodedLogWriter { +public: + void setDevice(QIODevice* target); + [[nodiscard]] bool writeHeader(); + [[nodiscard]] bool write(const LogMessage& message); + +private: + void writeOp(EncodedLogOpcode opcode); + void writeVarInt(quint32 n); + void writeString(QByteArrayView bytes); + quint16 getOrCreateCategory(QLatin1StringView category); + + WriteBuffer buffer; + + QHash categories; + quint16 nextCategory = EncodedLogOpcode::BeginCategories; + + QDateTime lastMessageTime = QDateTime::fromSecsSinceEpoch(0); + HashBuffer recentMessages {256}; +}; + +class EncodedLogReader { +public: + void setDevice(QIODevice* source); + [[nodiscard]] bool readHeader(bool* success, quint8* logVersion, quint8* readerVersion); + // WARNING: log messages written to the given slot are invalidated when the log reader is destroyed. + [[nodiscard]] bool read(LogMessage* slot); + [[nodiscard]] CategoryFilter categoryFilterById(quint16 id); + +private: + [[nodiscard]] bool readVarInt(quint32* slot); + [[nodiscard]] bool readString(QByteArray* slot); + [[nodiscard]] bool registerCategory(); + + DeviceReader reader; + QVector> categories; + QDateTime lastMessageTime = QDateTime::fromSecsSinceEpoch(0); + RingBuffer recentMessages {256}; +}; + +class ThreadLogging: public QObject { + Q_OBJECT; + +public: + explicit ThreadLogging(QObject* parent): QObject(parent) {} + + void init(); + void initFs(); + void setupFileLogging(); + +private slots: + void onMessage(const LogMessage& msg, bool showInSparse); + +private: + QFile* file = nullptr; + QTextStream fileStream; + QFile* detailedFile = nullptr; + EncodedLogWriter detailedWriter; +}; + +class LogFollower; + +class LogReader { +public: + explicit LogReader( + QFile* file, + bool timestamps, + int tail, + QList rules + ) + : file(file) + , timestamps(timestamps) + , remainingTail(tail) + , rules(std::move(rules)) {} + + bool initialize(); + bool continueReading(); + +private: + QFile* file; + EncodedLogReader reader; + bool timestamps; + int remainingTail; + QHash filters; + QList rules; + + friend class LogFollower; +}; + +class LogFollower: public QObject { + Q_OBJECT; + +public: + explicit LogFollower(LogReader* reader, QString path): reader(reader), path(std::move(path)) {} + + bool follow(); + +private slots: + void onFileChanged(); + void onFileLocked(); + +private: + LogReader* reader; + QString path; + QFileSystemWatcher fileWatcher; + + class FcntlWaitThread: public QThread { + public: + explicit FcntlWaitThread(LogFollower* follower): follower(follower) {} + + protected: + void run() override; + + private: + LogFollower* follower; + }; + + FcntlWaitThread waitThread {this}; +}; + +} // namespace qs::log diff --git a/src/core/logging_qtprivate.cpp b/src/core/logging_qtprivate.cpp new file mode 100644 index 00000000..5078eeb4 --- /dev/null +++ b/src/core/logging_qtprivate.cpp @@ -0,0 +1,138 @@ +// The logging rule parser from qloggingregistry_p.h and qloggingregistry.cpp. + +// Was unable to properly link the functions when directly using the headers (which we depend +// on anyway), so below is a slightly stripped down copy. Making the originals link would +// be preferable. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "logging_qtprivate.hpp" + +namespace qs::log { +Q_DECLARE_LOGGING_CATEGORY(logLogging); + +namespace qt_logging_registry { + +class QLoggingSettingsParser { +public: + void setContent(QStringView content); + + [[nodiscard]] QList rules() const { return this->mRules; } + +private: + void parseNextLine(QStringView line); + +private: + QList mRules; +}; + +void QLoggingSettingsParser::setContent(QStringView content) { + this->mRules.clear(); + for (auto line: qTokenize(content, u';')) this->parseNextLine(line); +} + +void QLoggingSettingsParser::parseNextLine(QStringView line) { + // Remove whitespace at start and end of line: + line = line.trimmed(); + + const qsizetype equalPos = line.indexOf(u'='); + if (equalPos != -1) { + if (line.lastIndexOf(u'=') == equalPos) { + const auto key = line.left(equalPos).trimmed(); + const QStringView pattern = key; + const auto valueStr = line.mid(equalPos + 1).trimmed(); + int value = -1; + if (valueStr == QString("true")) value = 1; + else if (valueStr == QString("false")) value = 0; + QLoggingRule rule(pattern, (value == 1)); + if (rule.flags != 0 && (value != -1)) this->mRules.append(std::move(rule)); + else + qCWarning(logLogging, "Ignoring malformed logging rule: '%s'", line.toUtf8().constData()); + } else { + qCWarning(logLogging, "Ignoring malformed logging rule: '%s'", line.toUtf8().constData()); + } + } +} + +QLoggingRule::QLoggingRule(QStringView pattern, bool enabled): messageType(-1), enabled(enabled) { + this->parse(pattern); +} + +void QLoggingRule::parse(QStringView pattern) { + QStringView p; + + // strip trailing ".messagetype" + if (pattern.endsWith(QString(".debug"))) { + p = pattern.chopped(6); // strlen(".debug") + this->messageType = QtDebugMsg; + } else if (pattern.endsWith(QString(".info"))) { + p = pattern.chopped(5); // strlen(".info") + this->messageType = QtInfoMsg; + } else if (pattern.endsWith(QString(".warning"))) { + p = pattern.chopped(8); // strlen(".warning") + this->messageType = QtWarningMsg; + } else if (pattern.endsWith(QString(".critical"))) { + p = pattern.chopped(9); // strlen(".critical") + this->messageType = QtCriticalMsg; + } else { + p = pattern; + } + + const QChar asterisk = u'*'; + if (!p.contains(asterisk)) { + this->flags = FullText; + } else { + if (p.endsWith(asterisk)) { + this->flags |= LeftFilter; + p = p.chopped(1); + } + if (p.startsWith(asterisk)) { + this->flags |= RightFilter; + p = p.mid(1); + } + if (p.contains(asterisk)) // '*' only supported at start/end + this->flags = PatternFlags(); + } + + this->category = p.toString(); +} + +int QLoggingRule::pass(QLatin1StringView cat, QtMsgType msgType) const { + // check message type + if (this->messageType > -1 && this->messageType != msgType) return 0; + + if (this->flags == FullText) { + // full match + if (this->category == cat) return (this->enabled ? 1 : -1); + else return 0; + } + + const qsizetype idx = cat.indexOf(this->category); + if (idx >= 0) { + if (this->flags == MidFilter) { + // matches somewhere + return (this->enabled ? 1 : -1); + } else if (this->flags == LeftFilter) { + // matches left + if (idx == 0) return (this->enabled ? 1 : -1); + } else if (this->flags == RightFilter) { + // matches right + if (idx == (cat.size() - this->category.size())) return (this->enabled ? 1 : -1); + } + } + return 0; +} + +} // namespace qt_logging_registry + +} // namespace qs::log diff --git a/src/core/logging_qtprivate.hpp b/src/core/logging_qtprivate.hpp new file mode 100644 index 00000000..83c82585 --- /dev/null +++ b/src/core/logging_qtprivate.hpp @@ -0,0 +1,45 @@ +#pragma once + +// The logging rule parser from qloggingregistry_p.h and qloggingregistry.cpp. + +// Was unable to properly link the functions when directly using the headers (which we depend +// on anyway), so below is a slightly stripped down copy. Making the originals link would +// be preferable. + +#include +#include +#include +#include +#include + +namespace qs::log { +Q_DECLARE_LOGGING_CATEGORY(logLogging); + +namespace qt_logging_registry { + +class QLoggingRule { +public: + QLoggingRule(); + QLoggingRule(QStringView pattern, bool enabled); + [[nodiscard]] int pass(QLatin1StringView categoryName, QtMsgType type) const; + + enum PatternFlag : quint8 { + FullText = 0x1, + LeftFilter = 0x2, + RightFilter = 0x4, + MidFilter = LeftFilter | RightFilter + }; + Q_DECLARE_FLAGS(PatternFlags, PatternFlag) + + QString category; + int messageType; + PatternFlags flags; + bool enabled; + +private: + void parse(QStringView pattern); +}; + +} // namespace qt_logging_registry + +} // namespace qs::log diff --git a/src/core/main.cpp b/src/core/main.cpp deleted file mode 100644 index 2cfd4d9c..00000000 --- a/src/core/main.cpp +++ /dev/null @@ -1,331 +0,0 @@ -#include "main.hpp" -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "plugin.hpp" -#include "rootwrapper.hpp" - -int qs_main(int argc, char** argv) { - QString configFilePath; - QString workingDirectory; - - auto useQApplication = false; - auto nativeTextRendering = false; - auto desktopSettingsAware = true; - QHash envOverrides; - - { - const auto app = QCoreApplication(argc, argv); - QCoreApplication::setApplicationName("quickshell"); - QCoreApplication::setApplicationVersion("0.1.0 (" GIT_REVISION ")"); - - QCommandLineParser parser; - parser.addHelpOption(); - parser.addVersionOption(); - - // clang-format off - auto currentOption = QCommandLineOption("current", "Print information about the manifest and defaults."); - auto manifestOption = QCommandLineOption({"m", "manifest"}, "Path to a configuration manifest.", "path"); - auto configOption = QCommandLineOption({"c", "config"}, "Name of a configuration in the manifest.", "name"); - auto pathOption = QCommandLineOption({"p", "path"}, "Path to a configuration file.", "path"); - auto workdirOption = QCommandLineOption({"d", "workdir"}, "Initial working directory.", "path"); - // clang-format on - - parser.addOption(currentOption); - parser.addOption(manifestOption); - parser.addOption(configOption); - parser.addOption(pathOption); - parser.addOption(workdirOption); - parser.process(app); - - { - auto printCurrent = parser.isSet(currentOption); - - // NOLINTBEGIN -#define CHECK(rname, name, level, label, expr) \ - QString name = expr; \ - if (rname.isEmpty() && !name.isEmpty()) { \ - rname = name; \ - rname##Level = level; \ - if (!printCurrent) goto label; \ - } - -#define OPTSTR(name) (name.isEmpty() ? "(unset)" : name.toStdString()) - // NOLINTEND - - QString basePath; - int basePathLevel = 0; - Q_UNUSED(basePathLevel); - { - // NOLINTBEGIN - // clang-format off - CHECK(basePath, envBasePath, 0, foundbase, qEnvironmentVariable("QS_BASE_PATH")); - CHECK(basePath, defaultBasePath, 0, foundbase, QDir(QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)).filePath("quickshell")); - // clang-format on - // NOLINTEND - - if (printCurrent) { - // clang-format off - std::cout << "Base path: " << OPTSTR(basePath) << "\n"; - std::cout << " - Environment (QS_BASE_PATH): " << OPTSTR(envBasePath) << "\n"; - std::cout << " - Default: " << OPTSTR(defaultBasePath) << "\n"; - // clang-format on - } - } - foundbase:; - - QString configPath; - int configPathLevel = 10; - { - // NOLINTBEGIN - CHECK(configPath, optionConfigPath, 0, foundpath, parser.value(pathOption)); - CHECK(configPath, envConfigPath, 1, foundpath, qEnvironmentVariable("QS_CONFIG_PATH")); - // NOLINTEND - - if (printCurrent) { - // clang-format off - std::cout << "\nConfig path: " << OPTSTR(configPath) << "\n"; - std::cout << " - Option: " << OPTSTR(optionConfigPath) << "\n"; - std::cout << " - Environment (QS_CONFIG_PATH): " << OPTSTR(envConfigPath) << "\n"; - // clang-format on - } - } - foundpath:; - - QString manifestPath; - int manifestPathLevel = 10; - { - // NOLINTBEGIN - // clang-format off - CHECK(manifestPath, optionManifestPath, 0, foundmf, parser.value(manifestOption)); - CHECK(manifestPath, envManifestPath, 1, foundmf, qEnvironmentVariable("QS_MANIFEST")); - CHECK(manifestPath, defaultManifestPath, 2, foundmf, QDir(basePath).filePath("manifest.conf")); - // clang-format on - // NOLINTEND - - if (printCurrent) { - // clang-format off - std::cout << "\nManifest path: " << OPTSTR(manifestPath) << "\n"; - std::cout << " - Option: " << OPTSTR(optionManifestPath) << "\n"; - std::cout << " - Environment (QS_MANIFEST): " << OPTSTR(envManifestPath) << "\n"; - std::cout << " - Default: " << OPTSTR(defaultManifestPath) << "\n"; - // clang-format on - } - } - foundmf:; - - QString configName; - int configNameLevel = 10; - { - // NOLINTBEGIN - CHECK(configName, optionConfigName, 0, foundname, parser.value(configOption)); - CHECK(configName, envConfigName, 1, foundname, qEnvironmentVariable("QS_CONFIG_NAME")); - // NOLINTEND - - if (printCurrent) { - // clang-format off - std::cout << "\nConfig name: " << OPTSTR(configName) << "\n"; - std::cout << " - Option: " << OPTSTR(optionConfigName) << "\n"; - std::cout << " - Environment (QS_CONFIG_NAME): " << OPTSTR(envConfigName) << "\n\n"; - // clang-format on - } - } - foundname:; - - if (configPathLevel == 0 && configNameLevel == 0) { - qCritical() << "Pass only one of --path or --config"; - return -1; - } - - if (!configPath.isEmpty() && configPathLevel <= configNameLevel) { - configFilePath = configPath; - } else if (!configName.isEmpty()) { - if (!manifestPath.isEmpty()) { - auto file = QFile(manifestPath); - if (file.open(QIODevice::ReadOnly | QIODevice::Text)) { - auto stream = QTextStream(&file); - while (!stream.atEnd()) { - auto line = stream.readLine(); - if (line.trimmed().startsWith("#")) continue; - if (line.trimmed().isEmpty()) continue; - - auto split = line.split('='); - if (split.length() != 2) { - qCritical() << "manifest line not in expected format 'name = relativepath':" - << line; - return -1; - } - - if (split[0].trimmed() == configName) { - configFilePath = QDir(QFileInfo(file).canonicalPath()).filePath(split[1].trimmed()); - goto haspath; // NOLINT - } - } - - qCritical() << "configuration" << configName << "not found in manifest" << manifestPath; - return -1; - } else if (manifestPathLevel < 2) { - qCritical() << "cannot open config manifest at" << manifestPath; - return -1; - } - } - - { - auto basePathInfo = QFileInfo(basePath); - if (!basePathInfo.exists()) { - qCritical() << "base path does not exist:" << basePath; - return -1; - } else if (!QFileInfo(basePathInfo.canonicalFilePath()).isDir()) { - qCritical() << "base path is not a directory" << basePath; - return -1; - } - - auto dir = QDir(basePath); - for (auto& entry: dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot)) { - if (entry == configName) { - configFilePath = dir.filePath(entry); - goto haspath; // NOLINT - } - } - - qCritical() << "no directory named " << configName << "found in base path" << basePath; - return -1; - } - haspath:; - } else { - configFilePath = basePath; - } - - auto configFile = QFileInfo(configFilePath); - if (!configFile.exists()) { - qCritical() << "config path does not exist:" << configFilePath; - return -1; - } - - if (configFile.isDir()) { - configFilePath = QDir(configFilePath).filePath("shell.qml"); - } - - configFile = QFileInfo(configFilePath); - if (!configFile.exists()) { - qCritical() << "no shell.qml found in config path:" << configFilePath; - return -1; - } else if (configFile.isDir()) { - qCritical() << "shell.qml is a directory:" << configFilePath; - return -1; - } - - configFilePath = QFileInfo(configFilePath).canonicalFilePath(); - configFile = QFileInfo(configFilePath); - if (!configFile.exists()) { - qCritical() << "config file does not exist:" << configFilePath; - return -1; - } else if (configFile.isDir()) { - qCritical() << "config file is a directory:" << configFilePath; - return -1; - } - -#undef CHECK -#undef OPTSTR - - qInfo() << "config file path:" << configFilePath; - - if (printCurrent) return 0; - } - - if (!QFile(configFilePath).exists()) { - qCritical() << "config file does not exist"; - return -1; - } - - if (parser.isSet(workdirOption)) { - workingDirectory = parser.value(workdirOption); - } - - auto file = QFile(configFilePath); - if (!file.open(QFile::ReadOnly | QFile::Text)) { - qCritical() << "could not open config file"; - return -1; - } - - auto stream = QTextStream(&file); - while (!stream.atEnd()) { - auto line = stream.readLine().trimmed(); - if (line.startsWith("//@ pragma ")) { - auto pragma = line.sliced(11).trimmed(); - - if (pragma == "UseQApplication") useQApplication = true; - else if (pragma == "NativeTextRendering") nativeTextRendering = true; - else if (pragma == "IgnoreSystemSettings") desktopSettingsAware = false; - else if (pragma.startsWith("Env ")) { - auto envPragma = pragma.sliced(4); - auto splitIdx = envPragma.indexOf('='); - - if (splitIdx == -1) { - qCritical() << "Env pragma" << pragma << "not in the form 'VAR = VALUE'"; - return -1; - } - - auto var = envPragma.sliced(0, splitIdx).trimmed(); - auto val = envPragma.sliced(splitIdx + 1).trimmed(); - envOverrides.insert(var, val); - } else { - qCritical() << "Unrecognized pragma" << pragma; - return -1; - } - } else if (line.startsWith("import")) break; - } - - file.close(); - } - - for (auto [var, val]: envOverrides.asKeyValueRange()) { - qputenv(var.toUtf8(), val.toUtf8()); - } - - QGuiApplication::setDesktopSettingsAware(desktopSettingsAware); - - QGuiApplication* app = nullptr; - - if (useQApplication) { - app = new QApplication(argc, argv); - } else { - app = new QGuiApplication(argc, argv); - } - - if (!workingDirectory.isEmpty()) { - QDir::setCurrent(workingDirectory); - } - - QuickshellPlugin::initPlugins(); - - // Base window transparency appears to be additive. - // Use a fully transparent window with a colored rect. - QQuickWindow::setDefaultAlphaBuffer(true); - - if (nativeTextRendering) { - QQuickWindow::setTextRenderType(QQuickWindow::NativeTextRendering); - } - - auto root = RootWrapper(configFilePath); - QGuiApplication::setQuitOnLastWindowClosed(false); - - auto code = QGuiApplication::exec(); - delete app; - return code; -} diff --git a/src/core/main.hpp b/src/core/main.hpp deleted file mode 100644 index 33921b40..00000000 --- a/src/core/main.hpp +++ /dev/null @@ -1,3 +0,0 @@ -#pragma once - -int qs_main(int argc, char** argv); // NOLINT diff --git a/src/core/model.cpp b/src/core/model.cpp new file mode 100644 index 00000000..2aba1846 --- /dev/null +++ b/src/core/model.cpp @@ -0,0 +1,98 @@ +#include "model.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +qint32 UntypedObjectModel::rowCount(const QModelIndex& parent) const { + if (parent != QModelIndex()) return 0; + return static_cast(this->valuesList.length()); +} + +QVariant UntypedObjectModel::data(const QModelIndex& index, qint32 role) const { + if (role != Qt::UserRole) return QVariant(); + return QVariant::fromValue(this->valuesList.at(index.row())); +} + +QHash UntypedObjectModel::roleNames() const { + return {{Qt::UserRole, "modelData"}}; +} + +QQmlListProperty UntypedObjectModel::values() { + return QQmlListProperty( + this, + nullptr, + &UntypedObjectModel::valuesCount, + &UntypedObjectModel::valueAt + ); +} + +qsizetype UntypedObjectModel::valuesCount(QQmlListProperty* property) { + return static_cast(property->object)->valuesList.count(); // NOLINT +} + +QObject* UntypedObjectModel::valueAt(QQmlListProperty* property, qsizetype index) { + return static_cast(property->object)->valuesList.at(index); // NOLINT +} + +void UntypedObjectModel::insertObject(QObject* object, qsizetype index) { + auto iindex = index == -1 ? this->valuesList.length() : index; + emit this->objectInsertedPre(object, iindex); + + auto intIndex = static_cast(iindex); + this->beginInsertRows(QModelIndex(), intIndex, intIndex); + this->valuesList.insert(iindex, object); + this->endInsertRows(); + + emit this->valuesChanged(); + emit this->objectInsertedPost(object, iindex); +} + +void UntypedObjectModel::removeAt(qsizetype index) { + auto* object = this->valuesList.at(index); + emit this->objectRemovedPre(object, index); + + auto intIndex = static_cast(index); + this->beginRemoveRows(QModelIndex(), intIndex, intIndex); + this->valuesList.removeAt(index); + this->endRemoveRows(); + + emit this->valuesChanged(); + emit this->objectRemovedPost(object, index); +} + +bool UntypedObjectModel::removeObject(const QObject* object) { + auto index = this->valuesList.indexOf(object); + if (index == -1) return false; + + this->removeAt(index); + return true; +} + +void UntypedObjectModel::diffUpdate(const QVector& newValues) { + for (qsizetype i = 0; i < this->valuesList.length();) { + if (newValues.contains(this->valuesList.at(i))) i++; + else this->removeAt(i); + } + + qsizetype oi = 0; + for (auto* object: newValues) { + if (this->valuesList.length() == oi || this->valuesList.at(oi) != object) { + this->insertObject(object, oi); + } + + oi++; + } +} + +qsizetype UntypedObjectModel::indexOf(QObject* object) { return this->valuesList.indexOf(object); } + +UntypedObjectModel* UntypedObjectModel::emptyInstance() { + static auto* instance = new UntypedObjectModel(nullptr); // NOLINT + return instance; +} diff --git a/src/core/model.hpp b/src/core/model.hpp new file mode 100644 index 00000000..56297bfa --- /dev/null +++ b/src/core/model.hpp @@ -0,0 +1,111 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "doc.hpp" + +///! View into a list of objets +/// Typed view into a list of objects. +/// +/// An ObjectModel works as a QML [Data Model], allowing efficient interaction with +/// components that act on models. It has a single role named `modelData`, to match the +/// behavior of lists. +/// The same information contained in the list model is available as a normal list +/// via the `values` property. +/// +/// #### Differences from a list +/// Unlike with a list, the following property binding will never be updated when `model[3]` changes. +/// ```qml +/// // will not update reactively +/// property var foo: model[3] +/// ``` +/// +/// You can work around this limitation using the @@values property of the model to view it as a list. +/// ```qml +/// // will update reactively +/// property var foo: model.values[3] +/// ``` +/// +/// [Data Model]: https://doc.qt.io/qt-6/qtquick-modelviewsdata-modelview.html#qml-data-models +class UntypedObjectModel: public QAbstractListModel { + QSDOC_CNAME(ObjectModel); + Q_OBJECT; + /// The content of the object model, as a QML list. + /// The values of this property will always be of the type of the model. + Q_PROPERTY(QQmlListProperty values READ values NOTIFY valuesChanged); + QML_NAMED_ELEMENT(ObjectModel); + QML_UNCREATABLE("ObjectModels cannot be created directly."); + +public: + explicit UntypedObjectModel(QObject* parent): QAbstractListModel(parent) {} + + [[nodiscard]] qint32 rowCount(const QModelIndex& parent) const override; + [[nodiscard]] QVariant data(const QModelIndex& index, qint32 role) const override; + [[nodiscard]] QHash roleNames() const override; + + [[nodiscard]] QQmlListProperty values(); + void removeAt(qsizetype index); + + Q_INVOKABLE qsizetype indexOf(QObject* object); + + static UntypedObjectModel* emptyInstance(); + +signals: + void valuesChanged(); + /// Sent immediately before an object is inserted into the list. + void objectInsertedPre(QObject* object, qsizetype index); + /// Sent immediately after an object is inserted into the list. + void objectInsertedPost(QObject* object, qsizetype index); + /// Sent immediately before an object is removed from the list. + void objectRemovedPre(QObject* object, qsizetype index); + /// Sent immediately after an object is removed from the list. + void objectRemovedPost(QObject* object, qsizetype index); + +protected: + void insertObject(QObject* object, qsizetype index = -1); + bool removeObject(const QObject* object); + + // Assumes only one instance of a specific value + void diffUpdate(const QVector& newValues); + + QVector valuesList; + +private: + static qsizetype valuesCount(QQmlListProperty* property); + static QObject* valueAt(QQmlListProperty* property, qsizetype index); +}; + +template +class ObjectModel: public UntypedObjectModel { +public: + explicit ObjectModel(QObject* parent): UntypedObjectModel(parent) {} + + [[nodiscard]] QVector& valueList() { return *std::bit_cast*>(&this->valuesList); } + + [[nodiscard]] const QVector& valueList() const { + return *std::bit_cast*>(&this->valuesList); + } + + void insertObject(T* object, qsizetype index = -1) { + this->UntypedObjectModel::insertObject(object, index); + } + + void removeObject(const T* object) { this->UntypedObjectModel::removeObject(object); } + + // Assumes only one instance of a specific value + void diffUpdate(const QVector& newValues) { + this->UntypedObjectModel::diffUpdate(*std::bit_cast*>(&newValues)); + } + + static ObjectModel* emptyInstance() { + return static_cast*>(UntypedObjectModel::emptyInstance()); + } +}; diff --git a/src/core/module.md b/src/core/module.md index 8eb9b638..831f561b 100644 --- a/src/core/module.md +++ b/src/core/module.md @@ -7,16 +7,26 @@ headers = [ "shell.hpp", "variants.hpp", "region.hpp", - "proxywindow.hpp", + "../window/proxywindow.hpp", "persistentprops.hpp", - "windowinterface.hpp", - "panelinterface.hpp", - "floatingwindow.hpp", - "popupwindow.hpp", + "../window/windowinterface.hpp", + "../window/panelinterface.hpp", + "../window/floatingwindow.hpp", + "../window/popupwindow.hpp", "singleton.hpp", "lazyloader.hpp", "easingcurve.hpp", "transformwatcher.hpp", "boundcomponent.hpp", + "model.hpp", + "elapsedtimer.hpp", + "desktopentry.hpp", + "objectrepeater.hpp", + "qsmenu.hpp", + "retainable.hpp", + "popupanchor.hpp", + "types.hpp", + "qsmenuanchor.hpp", + "clock.hpp", ] ----- diff --git a/src/core/objectrepeater.cpp b/src/core/objectrepeater.cpp new file mode 100644 index 00000000..7971952c --- /dev/null +++ b/src/core/objectrepeater.cpp @@ -0,0 +1,190 @@ +#include "objectrepeater.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +QVariant ObjectRepeater::model() const { return this->mModel; } + +void ObjectRepeater::setModel(QVariant model) { + if (model == this->mModel) return; + + if (this->itemModel != nullptr) { + QObject::disconnect(this->itemModel, nullptr, this, nullptr); + } + + this->mModel = std::move(model); + emit this->modelChanged(); + this->reloadElements(); +} + +void ObjectRepeater::onModelDestroyed() { + this->mModel.clear(); + this->itemModel = nullptr; + emit this->modelChanged(); + this->reloadElements(); +} + +QQmlComponent* ObjectRepeater::delegate() const { return this->mDelegate; } + +void ObjectRepeater::setDelegate(QQmlComponent* delegate) { + if (delegate == this->mDelegate) return; + + if (this->mDelegate != nullptr) { + QObject::disconnect(this->mDelegate, nullptr, this, nullptr); + } + + this->mDelegate = delegate; + + if (delegate != nullptr) { + QObject::connect( + this->mDelegate, + &QObject::destroyed, + this, + &ObjectRepeater::onDelegateDestroyed + ); + } + + emit this->delegateChanged(); + this->reloadElements(); +} + +void ObjectRepeater::onDelegateDestroyed() { + this->mDelegate = nullptr; + emit this->delegateChanged(); + this->reloadElements(); +} + +void ObjectRepeater::reloadElements() { + for (auto i = this->valuesList.length() - 1; i >= 0; i--) { + this->removeComponent(i); + } + + if (this->mDelegate == nullptr || !this->mModel.isValid()) return; + + if (this->mModel.canConvert()) { + auto* model = this->mModel.value(); + this->itemModel = model; + + this->insertModelElements(model, 0, model->rowCount() - 1); // -1 is fine + + // clang-format off + QObject::connect(model, &QObject::destroyed, this, &ObjectRepeater::onModelDestroyed); + QObject::connect(model, &QAbstractItemModel::rowsInserted, this, &ObjectRepeater::onModelRowsInserted); + QObject::connect(model, &QAbstractItemModel::rowsRemoved, this, &ObjectRepeater::onModelRowsRemoved); + QObject::connect(model, &QAbstractItemModel::rowsMoved, this, &ObjectRepeater::onModelRowsMoved); + QObject::connect(model, &QAbstractItemModel::modelAboutToBeReset, this, &ObjectRepeater::onModelAboutToBeReset); + // clang-format on + } else if (this->mModel.canConvert()) { + auto values = this->mModel.value(); + auto len = values.count(); + + for (auto i = 0; i != len; i++) { + this->insertComponent(i, {{"modelData", QVariant::fromValue(values.at(i))}}); + } + } else if (this->mModel.canConvert>()) { + auto values = this->mModel.value>(); + + for (auto& value: values) { + this->insertComponent(this->valuesList.length(), {{"modelData", value}}); + } + } else { + qCritical() << this + << "Cannot create components as the model is not compatible:" << this->mModel; + } +} + +void ObjectRepeater::insertModelElements(QAbstractItemModel* model, int first, int last) { + auto roles = model->roleNames(); + auto roleDataVec = QVector(); + for (auto id: roles.keys()) { + roleDataVec.push_back(QModelRoleData(id)); + } + + auto values = QModelRoleDataSpan(roleDataVec); + auto props = QVariantMap(); + + for (auto i = first; i != last + 1; i++) { + auto index = model->index(i, 0); + model->multiData(index, values); + + for (auto [id, name]: roles.asKeyValueRange()) { + props.insert(name, *values.dataForRole(id)); + } + + this->insertComponent(i, props); + + props.clear(); + } +} + +void ObjectRepeater::onModelRowsInserted(const QModelIndex& parent, int first, int last) { + if (parent != QModelIndex()) return; + + this->insertModelElements(this->itemModel, first, last); +} + +void ObjectRepeater::onModelRowsRemoved(const QModelIndex& parent, int first, int last) { + if (parent != QModelIndex()) return; + + for (auto i = last; i != first - 1; i--) { + this->removeComponent(i); + } +} + +void ObjectRepeater::onModelRowsMoved( + const QModelIndex& sourceParent, + int sourceStart, + int sourceEnd, + const QModelIndex& destParent, + int destStart +) { + auto hasSource = sourceParent != QModelIndex(); + auto hasDest = destParent != QModelIndex(); + + if (!hasSource && !hasDest) return; + + if (hasSource) { + this->onModelRowsRemoved(sourceParent, sourceStart, sourceEnd); + } + + if (hasDest) { + this->onModelRowsInserted(destParent, destStart, destStart + (sourceEnd - sourceStart)); + } +} + +void ObjectRepeater::onModelAboutToBeReset() { + auto last = static_cast(this->valuesList.length() - 1); + this->onModelRowsRemoved(QModelIndex(), 0, last); // -1 is fine +} + +void ObjectRepeater::insertComponent(qsizetype index, const QVariantMap& properties) { + auto* context = QQmlEngine::contextForObject(this); + auto* instance = this->mDelegate->createWithInitialProperties(properties, context); + + if (instance == nullptr) { + qWarning().noquote() << this->mDelegate->errorString(); + qWarning() << this << "failed to create object for model data" << properties; + } else { + QQmlEngine::setObjectOwnership(instance, QQmlEngine::CppOwnership); + instance->setParent(this); + } + + this->insertObject(instance, index); +} + +void ObjectRepeater::removeComponent(qsizetype index) { + auto* instance = this->valuesList.at(index); + this->removeAt(index); + delete instance; +} diff --git a/src/core/objectrepeater.hpp b/src/core/objectrepeater.hpp new file mode 100644 index 00000000..409b12dc --- /dev/null +++ b/src/core/objectrepeater.hpp @@ -0,0 +1,85 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "model.hpp" + +///! A Repeater / for loop / map for non Item derived objects. +/// > [!ERROR] Removed in favor of @@QtQml.Models.Instantiator +/// +/// The ObjectRepeater creates instances of the provided delegate for every entry in the +/// given model, similarly to a @@QtQuick.Repeater but for non visual types. +class ObjectRepeater: public ObjectModel { + Q_OBJECT; + /// The model providing data to the ObjectRepeater. + /// + /// Currently accepted model types are `list` lists, javascript arrays, + /// and [QAbstractListModel] derived models, though only one column will be repeated + /// from the latter. + /// + /// Note: @@ObjectModel is a [QAbstractListModel] with a single column. + /// + /// [QAbstractListModel]: https://doc.qt.io/qt-6/qabstractlistmodel.html + Q_PROPERTY(QVariant model READ model WRITE setModel NOTIFY modelChanged); + /// The delegate component to repeat. + /// + /// The delegate is given the same properties as in a Repeater, except `index` which + /// is not currently implemented. + /// + /// If the model is a `list` or javascript array, a `modelData` property will be + /// exposed containing the entry from the model. If the model is a [QAbstractListModel], + /// the roles from the model will be exposed. + /// + /// Note: @@ObjectModel has a single role named `modelData` for compatibility with normal lists. + /// + /// [QAbstractListModel]: https://doc.qt.io/qt-6/qabstractlistmodel.html + Q_PROPERTY(QQmlComponent* delegate READ delegate WRITE setDelegate NOTIFY delegateChanged); + Q_CLASSINFO("DefaultProperty", "delegate"); + QML_ELEMENT; + QML_UNCREATABLE("ObjectRepeater has been removed in favor of QtQml.Models.Instantiator."); + +public: + explicit ObjectRepeater(QObject* parent = nullptr): ObjectModel(parent) {} + + [[nodiscard]] QVariant model() const; + void setModel(QVariant model); + + [[nodiscard]] QQmlComponent* delegate() const; + void setDelegate(QQmlComponent* delegate); + +signals: + void modelChanged(); + void delegateChanged(); + +private slots: + void onDelegateDestroyed(); + void onModelDestroyed(); + void onModelRowsInserted(const QModelIndex& parent, int first, int last); + void onModelRowsRemoved(const QModelIndex& parent, int first, int last); + + void onModelRowsMoved( + const QModelIndex& sourceParent, + int sourceStart, + int sourceEnd, + const QModelIndex& destParent, + int destStart + ); + + void onModelAboutToBeReset(); + +private: + void reloadElements(); + void insertModelElements(QAbstractItemModel* model, int first, int last); + void insertComponent(qsizetype index, const QVariantMap& properties); + void removeComponent(qsizetype index); + + QVariant mModel; + QAbstractItemModel* itemModel = nullptr; + QQmlComponent* mDelegate = nullptr; +}; diff --git a/src/core/paths.cpp b/src/core/paths.cpp new file mode 100644 index 00000000..e108da03 --- /dev/null +++ b/src/core/paths.cpp @@ -0,0 +1,308 @@ +#include "paths.hpp" +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "instanceinfo.hpp" + +Q_LOGGING_CATEGORY(logPaths, "quickshell.paths", QtWarningMsg); + +QsPaths* QsPaths::instance() { + static auto* instance = new QsPaths(); // NOLINT + return instance; +} + +void QsPaths::init(QString shellId, QString pathId) { + auto* instance = QsPaths::instance(); + instance->shellId = std::move(shellId); + instance->pathId = std::move(pathId); +} + +QDir QsPaths::crashDir(const QString& id) { + auto dir = QDir(QStandardPaths::writableLocation(QStandardPaths::CacheLocation)); + dir = QDir(dir.filePath("crashes")); + dir = QDir(dir.filePath(id)); + + return dir; +} + +QString QsPaths::basePath(const QString& id) { + auto path = QsPaths::instance()->baseRunDir()->filePath("by-id"); + path = QDir(path).filePath(id); + return path; +} + +QString QsPaths::ipcPath(const QString& id) { + return QDir(QsPaths::basePath(id)).filePath("ipc.sock"); +} + +QDir* QsPaths::cacheDir() { + if (this->cacheState == DirState::Unknown) { + auto dir = QDir(QStandardPaths::writableLocation(QStandardPaths::CacheLocation)); + dir = QDir(dir.filePath(this->shellId)); + this->mCacheDir = dir; + + qCDebug(logPaths) << "Initialized cache path:" << dir.path(); + + if (!dir.mkpath(".")) { + qCCritical(logPaths) << "Could not create cache directory at" << dir.path(); + + this->cacheState = DirState::Failed; + } else { + this->cacheState = DirState::Ready; + } + } + + if (this->cacheState == DirState::Failed) return nullptr; + else return &this->mCacheDir; +} + +QDir* QsPaths::baseRunDir() { + if (this->baseRunState == DirState::Unknown) { + auto runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR"); + if (runtimeDir.isEmpty()) { + runtimeDir = QString("/run/user/$1").arg(getuid()); + qCInfo(logPaths) << "XDG_RUNTIME_DIR was not set, defaulting to" << runtimeDir; + } + + this->mBaseRunDir = QDir(runtimeDir); + this->mBaseRunDir = QDir(this->mBaseRunDir.filePath("quickshell")); + qCDebug(logPaths) << "Initialized base runtime path:" << this->mBaseRunDir.path(); + + if (!this->mBaseRunDir.mkpath(".")) { + qCCritical(logPaths) << "Could not create base runtime directory at" + << this->mBaseRunDir.path(); + + this->baseRunState = DirState::Failed; + } else { + this->baseRunState = DirState::Ready; + } + } + + if (this->baseRunState == DirState::Failed) return nullptr; + else return &this->mBaseRunDir; +} + +QDir* QsPaths::shellRunDir() { + if (this->shellRunState == DirState::Unknown) { + if (auto* baseRunDir = this->baseRunDir()) { + this->mShellRunDir = QDir(baseRunDir->filePath("by-shell")); + this->mShellRunDir = QDir(this->mShellRunDir.filePath(this->shellId)); + + qCDebug(logPaths) << "Initialized runtime path:" << this->mShellRunDir.path(); + + if (!this->mShellRunDir.mkpath(".")) { + qCCritical(logPaths) << "Could not create runtime directory at" + << this->mShellRunDir.path(); + this->shellRunState = DirState::Failed; + } else { + this->shellRunState = DirState::Ready; + } + } else { + qCCritical(logPaths) << "Could not create shell runtime path as it was not possible to " + "create the base runtime path."; + + this->shellRunState = DirState::Failed; + } + } + + if (this->shellRunState == DirState::Failed) return nullptr; + else return &this->mShellRunDir; +} + +QDir* QsPaths::instanceRunDir() { + if (this->instanceRunState == DirState::Unknown) { + auto* runDir = this->baseRunDir(); + + if (!runDir) { + qCCritical(logPaths) << "Cannot create instance runtime directory as main runtim directory " + "could not be created."; + this->instanceRunState = DirState::Failed; + } else { + auto byIdDir = QDir(runDir->filePath("by-id")); + + this->mInstanceRunDir = byIdDir.filePath(InstanceInfo::CURRENT.instanceId); + + qCDebug(logPaths) << "Initialized instance runtime path:" << this->mInstanceRunDir.path(); + + if (!this->mInstanceRunDir.mkpath(".")) { + qCCritical(logPaths) << "Could not create instance runtime directory at" + << this->mInstanceRunDir.path(); + this->instanceRunState = DirState::Failed; + } else { + this->instanceRunState = DirState::Ready; + } + } + } + + if (this->shellRunState == DirState::Failed) return nullptr; + else return &this->mInstanceRunDir; +} + +void QsPaths::linkRunDir() { + if (auto* runDir = this->instanceRunDir()) { + auto pidDir = QDir(this->baseRunDir()->filePath("by-pid")); + auto* shellDir = this->shellRunDir(); + + if (!shellDir) { + qCCritical(logPaths + ) << "Could not create by-id symlink as the shell runtime path could not be created."; + } else { + auto shellPath = shellDir->filePath(runDir->dirName()); + + QFile::remove(shellPath); + auto r = + symlinkat(runDir->filesystemCanonicalPath().c_str(), 0, shellPath.toStdString().c_str()); + + if (r != 0) { + qCCritical(logPaths).nospace() + << "Could not create id symlink to " << runDir->path() << " at " << shellPath + << " with error code " << errno << ": " << qt_error_string(); + } else { + qCDebug(logPaths) << "Created shellid symlink" << shellPath << "to instance runtime path" + << runDir->path(); + } + } + + if (!pidDir.mkpath(".")) { + qCCritical(logPaths) << "Could not create PID symlink directory."; + } else { + auto pidPath = pidDir.filePath(QString::number(getpid())); + + QFile::remove(pidPath); + auto r = + symlinkat(runDir->filesystemCanonicalPath().c_str(), 0, pidPath.toStdString().c_str()); + + if (r != 0) { + qCCritical(logPaths).nospace() + << "Could not create PID symlink to " << runDir->path() << " at " << pidPath + << " with error code " << errno << ": " << qt_error_string(); + } else { + qCDebug(logPaths) << "Created PID symlink" << pidPath << "to instance runtime path" + << runDir->path(); + } + } + } else { + qCCritical(logPaths) << "Could not create PID symlink to runtime directory, as the runtime " + "directory could not be created."; + } +} + +void QsPaths::linkPathDir() { + if (auto* runDir = this->shellRunDir()) { + auto pathDir = QDir(this->baseRunDir()->filePath("by-path")); + + if (!pathDir.mkpath(".")) { + qCCritical(logPaths) << "Could not create path symlink directory."; + return; + } + + auto linkPath = pathDir.filePath(this->pathId); + + QFile::remove(linkPath); + auto r = + symlinkat(runDir->filesystemCanonicalPath().c_str(), 0, linkPath.toStdString().c_str()); + + if (r != 0) { + qCCritical(logPaths).nospace() + << "Could not create path symlink to " << runDir->path() << " at " << linkPath + << " with error code " << errno << ": " << qt_error_string(); + } else { + qCDebug(logPaths) << "Created path symlink" << linkPath << "to shell runtime path" + << runDir->path(); + } + } else { + qCCritical(logPaths) << "Could not create path symlink to shell runtime directory, as the " + "shell runtime directory could not be created."; + } +} + +void QsPaths::createLock() { + if (auto* runDir = this->instanceRunDir()) { + auto path = runDir->filePath("instance.lock"); + auto* file = new QFile(path); // leaked + + if (!file->open(QFile::ReadWrite | QFile::Truncate)) { + qCCritical(logPaths) << "Could not create instance lock at" << path; + return; + } + + auto lock = flock { + .l_type = F_WRLCK, + .l_whence = SEEK_SET, + .l_start = 0, + .l_len = 0, + .l_pid = 0, + }; + + if (fcntl(file->handle(), F_SETLK, &lock) != 0) { // NOLINT + qCCritical(logPaths).nospace() << "Could not lock instance lock at " << path + << " with error code " << errno << ": " << qt_error_string(); + } else { + auto stream = QDataStream(file); + stream << InstanceInfo::CURRENT; + file->flush(); + qCDebug(logPaths) << "Created instance lock at" << path; + } + } else { + qCCritical(logPaths + ) << "Could not create instance lock, as the instance runtime directory could not be created."; + } +} + +bool QsPaths::checkLock(const QString& path, InstanceLockInfo* info) { + auto file = QFile(QDir(path).filePath("instance.lock")); + if (!file.open(QFile::ReadOnly)) return false; + + auto lock = flock { + .l_type = F_WRLCK, + .l_whence = SEEK_SET, + .l_start = 0, + .l_len = 0, + .l_pid = 0, + }; + + fcntl(file.handle(), F_GETLK, &lock); // NOLINT + if (lock.l_type == F_UNLCK) return false; + + if (info) { + info->pid = lock.l_pid; + + auto stream = QDataStream(&file); + stream >> info->instance; + } + + return true; +} + +QVector QsPaths::collectInstances(const QString& path) { + qCDebug(logPaths) << "Collecting instances from" << path; + auto instances = QVector(); + auto dir = QDir(path); + + InstanceLockInfo info; + for (auto& entry: dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { + auto path = dir.filePath(entry); + + if (QsPaths::checkLock(path, &info)) { + qCDebug(logPaths).nospace() << "Found live instance " << info.instance.instanceId << " (pid " + << info.pid << ") at " << path; + + instances.push_back(info); + } else { + qCDebug(logPaths) << "Skipped dead instance at" << path; + } + } + + return instances; +} diff --git a/src/core/paths.hpp b/src/core/paths.hpp new file mode 100644 index 00000000..b20d4087 --- /dev/null +++ b/src/core/paths.hpp @@ -0,0 +1,51 @@ +#pragma once +#include +#include +#include + +#include "instanceinfo.hpp" + +struct InstanceLockInfo { + pid_t pid = -1; + InstanceInfo instance; +}; + +QDataStream& operator<<(QDataStream& stream, const InstanceLockInfo& info); +QDataStream& operator>>(QDataStream& stream, InstanceLockInfo& info); + +class QsPaths { +public: + static QsPaths* instance(); + static void init(QString shellId, QString pathId); + static QDir crashDir(const QString& id); + static QString basePath(const QString& id); + static QString ipcPath(const QString& id); + static bool checkLock(const QString& path, InstanceLockInfo* info = nullptr); + static QVector collectInstances(const QString& path); + + QDir* cacheDir(); + QDir* baseRunDir(); + QDir* shellRunDir(); + QDir* instanceRunDir(); + void linkRunDir(); + void linkPathDir(); + void createLock(); + +private: + enum class DirState : quint8 { + Unknown = 0, + Ready = 1, + Failed = 2, + }; + + QString shellId; + QString pathId; + QDir mCacheDir; + QDir mBaseRunDir; + QDir mShellRunDir; + QDir mInstanceRunDir; + DirState cacheState = DirState::Unknown; + DirState baseRunState = DirState::Unknown; + DirState shellRunState = DirState::Unknown; + DirState instanceRunState = DirState::Unknown; +}; diff --git a/src/core/platformmenu.cpp b/src/core/platformmenu.cpp new file mode 100644 index 00000000..427dde08 --- /dev/null +++ b/src/core/platformmenu.cpp @@ -0,0 +1,324 @@ +#include "platformmenu.hpp" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../window/proxywindow.hpp" +#include "../window/windowinterface.hpp" +#include "iconprovider.hpp" +#include "model.hpp" +#include "platformmenu_p.hpp" +#include "popupanchor.hpp" +#include "qsmenu.hpp" + +namespace qs::menu::platform { + +namespace { +QVector> CREATION_HOOKS; // NOLINT +PlatformMenuQMenu* ACTIVE_MENU = nullptr; // NOLINT +} // namespace + +PlatformMenuQMenu::~PlatformMenuQMenu() { + if (this == ACTIVE_MENU) { + ACTIVE_MENU = nullptr; + } +} + +void PlatformMenuQMenu::setVisible(bool visible) { + if (visible) { + for (auto& hook: CREATION_HOOKS) { + hook(this); + } + } else { + if (this == ACTIVE_MENU) { + ACTIVE_MENU = nullptr; + } + } + + this->QMenu::setVisible(visible); +} + +PlatformMenuEntry::PlatformMenuEntry(QsMenuEntry* menu): QObject(menu), menu(menu) { + this->relayout(); + + // clang-format off + QObject::connect(menu, &QsMenuEntry::enabledChanged, this, &PlatformMenuEntry::onEnabledChanged); + QObject::connect(menu, &QsMenuEntry::textChanged, this, &PlatformMenuEntry::onTextChanged); + QObject::connect(menu, &QsMenuEntry::iconChanged, this, &PlatformMenuEntry::onIconChanged); + QObject::connect(menu, &QsMenuEntry::buttonTypeChanged, this, &PlatformMenuEntry::onButtonTypeChanged); + QObject::connect(menu, &QsMenuEntry::checkStateChanged, this, &PlatformMenuEntry::onCheckStateChanged); + QObject::connect(menu, &QsMenuEntry::hasChildrenChanged, this, &PlatformMenuEntry::relayoutParent); + QObject::connect(menu->children(), &UntypedObjectModel::valuesChanged, this, &PlatformMenuEntry::relayout); + // clang-format on +} + +PlatformMenuEntry::~PlatformMenuEntry() { + this->clearChildren(); + delete this->qaction; + delete this->qmenu; +} + +void PlatformMenuEntry::registerCreationHook(std::function hook) { + CREATION_HOOKS.push_back(std::move(hook)); +} + +bool PlatformMenuEntry::display(QObject* parentWindow, int relativeX, int relativeY) { + QWindow* window = nullptr; + + if (qobject_cast(QCoreApplication::instance()) == nullptr) { + qCritical() << "Cannot display PlatformMenuEntry as quickshell was not started in " + "QApplication mode."; + qCritical() << "To use platform menus, add `//@ pragma UseQApplication` to the top of your " + "root QML file and restart quickshell."; + return false; + } else if (this->qmenu == nullptr) { + qCritical() << "Cannot display PlatformMenuEntry as it is not a menu."; + return false; + } else if (parentWindow == nullptr) { + qCritical() << "Cannot display PlatformMenuEntry with null parent window."; + return false; + } else if (auto* proxy = qobject_cast(parentWindow)) { + window = proxy->backingWindow(); + } else if (auto* interface = qobject_cast(parentWindow)) { + window = interface->proxyWindow()->backingWindow(); + } else { + qCritical() << "PlatformMenuEntry.display() must be called with a window."; + return false; + } + + if (window == nullptr) { + qCritical() << "Cannot display PlatformMenuEntry from a parent window that is not visible."; + return false; + } + + if (ACTIVE_MENU && this->qmenu != ACTIVE_MENU) { + ACTIVE_MENU->close(); + } + + ACTIVE_MENU = this->qmenu; + + auto point = window->mapToGlobal(QPoint(relativeX, relativeY)); + + this->qmenu->createWinId(); + this->qmenu->windowHandle()->setTransientParent(window); + + // Skips screen edge repositioning so it can be left to the compositor on wayland. + this->qmenu->targetPosition = point; + this->qmenu->popup(point); + + return true; +} + +bool PlatformMenuEntry::display(PopupAnchor* anchor) { + if (qobject_cast(QCoreApplication::instance()) == nullptr) { + qCritical() << "Cannot display PlatformMenuEntry as quickshell was not started in " + "QApplication mode."; + qCritical() << "To use platform menus, add `//@ pragma UseQApplication` to the top of your " + "root QML file and restart quickshell."; + return false; + } else if (!anchor->backingWindow() || !anchor->backingWindow()->isVisible()) { + qCritical() << "Cannot display PlatformMenuEntry on anchor without visible window."; + return false; + } + + if (ACTIVE_MENU && this->qmenu != ACTIVE_MENU) { + ACTIVE_MENU->close(); + } + + ACTIVE_MENU = this->qmenu; + + this->qmenu->createWinId(); + this->qmenu->windowHandle()->setTransientParent(anchor->backingWindow()); + + // Update the window geometry to the menu's actual dimensions so reposition + // can accurately adjust it if applicable for the current platform. + this->qmenu->windowHandle()->setGeometry({{0, 0}, this->qmenu->sizeHint()}); + + PopupPositioner::instance()->reposition(anchor, this->qmenu->windowHandle(), false); + + // Open the menu at the position determined by the popup positioner. + this->qmenu->popup(this->qmenu->windowHandle()->position()); + + return true; +} + +void PlatformMenuEntry::relayout() { + if (qobject_cast(QCoreApplication::instance()) == nullptr) { + return; + } + + if (this->menu->hasChildren()) { + delete this->qaction; + this->qaction = nullptr; + + if (this->qmenu == nullptr) { + this->qmenu = new PlatformMenuQMenu(); + QObject::connect(this->qmenu, &QMenu::aboutToShow, this, &PlatformMenuEntry::onAboutToShow); + QObject::connect(this->qmenu, &QMenu::aboutToHide, this, &PlatformMenuEntry::onAboutToHide); + } else { + this->clearChildren(); + } + + this->qmenu->setTitle(this->menu->text()); + + auto icon = this->menu->icon(); + if (!icon.isEmpty()) { + this->qmenu->setIcon(getCurrentEngineImageAsIcon(icon)); + } + + const auto& children = this->menu->children()->valueList(); + auto len = children.count(); + for (auto i = 0; i < len; i++) { + auto* child = children.at(i); + + auto* instance = new PlatformMenuEntry(child); + QObject::connect(instance, &QObject::destroyed, this, &PlatformMenuEntry::onChildDestroyed); + + QObject::connect( + instance, + &PlatformMenuEntry::relayoutParent, + this, + &PlatformMenuEntry::relayout + ); + + this->childEntries.push_back(instance); + instance->addToQMenu(this->qmenu); + } + } else if (!this->menu->isSeparator()) { + this->clearChildren(); + delete this->qmenu; + this->qmenu = nullptr; + + if (this->qaction == nullptr) { + this->qaction = new QAction(this); + + QObject::connect( + this->qaction, + &QAction::triggered, + this, + &PlatformMenuEntry::onActionTriggered + ); + } + + this->qaction->setText(this->menu->text()); + + auto icon = this->menu->icon(); + if (!icon.isEmpty()) { + this->qaction->setIcon(getCurrentEngineImageAsIcon(icon)); + } + + this->qaction->setEnabled(this->menu->enabled()); + this->qaction->setCheckable(this->menu->buttonType() != QsMenuButtonType::None); + + if (this->menu->buttonType() == QsMenuButtonType::RadioButton) { + if (!this->qactiongroup) this->qactiongroup = new QActionGroup(this); + this->qaction->setActionGroup(this->qactiongroup); + } + + this->qaction->setChecked(this->menu->checkState() != Qt::Unchecked); + } else { + delete this->qmenu; + delete this->qaction; + this->qmenu = nullptr; + this->qaction = nullptr; + } +} + +void PlatformMenuEntry::onAboutToShow() { this->menu->ref(); } + +void PlatformMenuEntry::onAboutToHide() { + this->menu->unref(); + emit this->closed(); +} + +void PlatformMenuEntry::onActionTriggered() { + auto* action = qobject_cast(this->sender()->parent()); + emit action->menu->triggered(); +} + +void PlatformMenuEntry::onChildDestroyed() { this->childEntries.removeOne(this->sender()); } + +void PlatformMenuEntry::onEnabledChanged() { + if (this->qaction != nullptr) { + this->qaction->setEnabled(this->menu->enabled()); + } +} + +void PlatformMenuEntry::onTextChanged() { + if (this->qmenu != nullptr) { + this->qmenu->setTitle(this->menu->text()); + } else if (this->qaction != nullptr) { + this->qaction->setText(this->menu->text()); + } +} + +void PlatformMenuEntry::onIconChanged() { + if (this->qmenu == nullptr && this->qaction == nullptr) return; + + auto iconName = this->menu->icon(); + QIcon icon; + + if (!iconName.isEmpty()) { + icon = getCurrentEngineImageAsIcon(iconName); + } + + if (this->qmenu != nullptr) { + this->qmenu->setIcon(icon); + } else if (this->qaction != nullptr) { + this->qaction->setIcon(icon); + } +} + +void PlatformMenuEntry::onButtonTypeChanged() { + if (this->qaction != nullptr) { + QActionGroup* group = nullptr; + + if (this->menu->buttonType() == QsMenuButtonType::RadioButton) { + if (!this->qactiongroup) this->qactiongroup = new QActionGroup(this); + group = this->qactiongroup; + } + + this->qaction->setActionGroup(group); + } +} + +void PlatformMenuEntry::onCheckStateChanged() { + if (this->qaction != nullptr) { + this->qaction->setChecked(this->menu->checkState() != Qt::Unchecked); + } +} + +void PlatformMenuEntry::clearChildren() { + for (auto* child: this->childEntries) { + delete child; + } + + this->childEntries.clear(); +} + +void PlatformMenuEntry::addToQMenu(PlatformMenuQMenu* menu) { + if (this->qmenu != nullptr) { + menu->addMenu(this->qmenu); + this->qmenu->containingMenu = menu; + } else if (this->qaction != nullptr) { + menu->addAction(this->qaction); + } else { + menu->addSeparator(); + } +} + +} // namespace qs::menu::platform diff --git a/src/core/platformmenu.hpp b/src/core/platformmenu.hpp new file mode 100644 index 00000000..5979f90e --- /dev/null +++ b/src/core/platformmenu.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/popupanchor.hpp" +#include "qsmenu.hpp" + +namespace qs::menu::platform { + +class PlatformMenuQMenu; + +class PlatformMenuEntry: public QObject { + Q_OBJECT; + +public: + explicit PlatformMenuEntry(QsMenuEntry* menu); + ~PlatformMenuEntry() override; + Q_DISABLE_COPY_MOVE(PlatformMenuEntry); + + bool display(QObject* parentWindow, int relativeX, int relativeY); + bool display(PopupAnchor* anchor); + + static void registerCreationHook(std::function hook); + +signals: + void closed(); + void relayoutParent(); + +public slots: + void relayout(); + +private slots: + void onAboutToShow(); + void onAboutToHide(); + void onActionTriggered(); + void onChildDestroyed(); + void onEnabledChanged(); + void onTextChanged(); + void onIconChanged(); + void onButtonTypeChanged(); + void onCheckStateChanged(); + +private: + void clearChildren(); + void addToQMenu(PlatformMenuQMenu* menu); + + QsMenuEntry* menu; + PlatformMenuQMenu* qmenu = nullptr; + QAction* qaction = nullptr; + QActionGroup* qactiongroup = nullptr; + QVector childEntries; +}; + +} // namespace qs::menu::platform diff --git a/src/core/platformmenu_p.hpp b/src/core/platformmenu_p.hpp new file mode 100644 index 00000000..9109959d --- /dev/null +++ b/src/core/platformmenu_p.hpp @@ -0,0 +1,19 @@ +#pragma once +#include +#include + +namespace qs::menu::platform { + +class PlatformMenuQMenu: public QMenu { +public: + explicit PlatformMenuQMenu() = default; + ~PlatformMenuQMenu() override; + Q_DISABLE_COPY_MOVE(PlatformMenuQMenu); + + void setVisible(bool visible) override; + + PlatformMenuQMenu* containingMenu = nullptr; + QPoint targetPosition; +}; + +} // namespace qs::menu::platform diff --git a/src/core/plugin.cpp b/src/core/plugin.cpp index 8f1d0e96..c6ceb13e 100644 --- a/src/core/plugin.cpp +++ b/src/core/plugin.cpp @@ -5,37 +5,41 @@ #include "generation.hpp" -static QVector plugins; // NOLINT +static QVector plugins; // NOLINT -void QuickshellPlugin::registerPlugin(QuickshellPlugin& plugin) { plugins.push_back(&plugin); } +void QsEnginePlugin::registerPlugin(QsEnginePlugin& plugin) { plugins.push_back(&plugin); } -void QuickshellPlugin::initPlugins() { +void QsEnginePlugin::initPlugins() { plugins.erase( std::remove_if( plugins.begin(), plugins.end(), - [](QuickshellPlugin* plugin) { return !plugin->applies(); } + [](QsEnginePlugin* plugin) { return !plugin->applies(); } ), plugins.end() ); - for (QuickshellPlugin* plugin: plugins) { + std::sort(plugins.begin(), plugins.end(), [](QsEnginePlugin* a, QsEnginePlugin* b) { + return b->dependencies().contains(a->name()); + }); + + for (QsEnginePlugin* plugin: plugins) { plugin->init(); } - for (QuickshellPlugin* plugin: plugins) { + for (QsEnginePlugin* plugin: plugins) { plugin->registerTypes(); } } -void QuickshellPlugin::runConstructGeneration(EngineGeneration& generation) { - for (QuickshellPlugin* plugin: plugins) { +void QsEnginePlugin::runConstructGeneration(EngineGeneration& generation) { + for (QsEnginePlugin* plugin: plugins) { plugin->constructGeneration(generation); } } -void QuickshellPlugin::runOnReload() { - for (QuickshellPlugin* plugin: plugins) { +void QsEnginePlugin::runOnReload() { + for (QsEnginePlugin* plugin: plugins) { plugin->onReload(); } } diff --git a/src/core/plugin.hpp b/src/core/plugin.hpp index 38c9ddc2..f0c14dce 100644 --- a/src/core/plugin.hpp +++ b/src/core/plugin.hpp @@ -2,25 +2,28 @@ #include #include +#include class EngineGeneration; -class QuickshellPlugin { +class QsEnginePlugin { public: - QuickshellPlugin() = default; - virtual ~QuickshellPlugin() = default; - QuickshellPlugin(QuickshellPlugin&&) = delete; - QuickshellPlugin(const QuickshellPlugin&) = delete; - void operator=(QuickshellPlugin&&) = delete; - void operator=(const QuickshellPlugin&) = delete; + QsEnginePlugin() = default; + virtual ~QsEnginePlugin() = default; + QsEnginePlugin(QsEnginePlugin&&) = delete; + QsEnginePlugin(const QsEnginePlugin&) = delete; + void operator=(QsEnginePlugin&&) = delete; + void operator=(const QsEnginePlugin&) = delete; + virtual QString name() { return QString(); } + virtual QList dependencies() { return {}; } virtual bool applies() { return true; } virtual void init() {} virtual void registerTypes() {} virtual void constructGeneration(EngineGeneration& /*unused*/) {} // NOLINT virtual void onReload() {} - static void registerPlugin(QuickshellPlugin& plugin); + static void registerPlugin(QsEnginePlugin& plugin); static void initPlugins(); static void runConstructGeneration(EngineGeneration& generation); static void runOnReload(); @@ -30,6 +33,6 @@ public: #define QS_REGISTER_PLUGIN(clazz) \ [[gnu::constructor]] void qsInitPlugin() { \ static clazz plugin; \ - QuickshellPlugin::registerPlugin(plugin); \ + QsEnginePlugin::registerPlugin(plugin); \ } // NOLINTEND diff --git a/src/core/popupanchor.cpp b/src/core/popupanchor.cpp new file mode 100644 index 00000000..2b5b1bae --- /dev/null +++ b/src/core/popupanchor.cpp @@ -0,0 +1,322 @@ +#include "popupanchor.hpp" + +#include +#include +#include +#include +#include +#include + +#include "../window/proxywindow.hpp" +#include "../window/windowinterface.hpp" +#include "types.hpp" + +bool PopupAnchorState::operator==(const PopupAnchorState& other) const { + return this->rect == other.rect && this->edges == other.edges && this->gravity == other.gravity + && this->adjustment == other.adjustment && this->anchorpoint == other.anchorpoint + && this->size == other.size; +} + +bool PopupAnchor::isDirty() const { + return !this->lastState.has_value() || this->state != this->lastState.value(); +} + +void PopupAnchor::markClean() { this->lastState = this->state; } +void PopupAnchor::markDirty() { this->lastState.reset(); } + +QObject* PopupAnchor::window() const { return this->mWindow; } +ProxyWindowBase* PopupAnchor::proxyWindow() const { return this->mProxyWindow; } + +QWindow* PopupAnchor::backingWindow() const { + return this->mProxyWindow ? this->mProxyWindow->backingWindow() : nullptr; +} + +void PopupAnchor::setWindow(QObject* window) { + if (window == this->mWindow) return; + + if (this->mWindow) { + QObject::disconnect(this->mWindow, nullptr, this, nullptr); + QObject::disconnect(this->mProxyWindow, nullptr, this, nullptr); + } + + if (window) { + if (auto* proxy = qobject_cast(window)) { + this->mProxyWindow = proxy; + } else if (auto* interface = qobject_cast(window)) { + this->mProxyWindow = interface->proxyWindow(); + } else { + qWarning() << "Tried to set popup anchor window to" << window + << "which is not a quickshell window."; + goto setnull; + } + + this->mWindow = window; + + QObject::connect(this->mWindow, &QObject::destroyed, this, &PopupAnchor::onWindowDestroyed); + + QObject::connect( + this->mProxyWindow, + &ProxyWindowBase::backerVisibilityChanged, + this, + &PopupAnchor::backingWindowVisibilityChanged + ); + + emit this->windowChanged(); + emit this->backingWindowVisibilityChanged(); + + return; + } + +setnull: + if (this->mWindow) { + this->mWindow = nullptr; + this->mProxyWindow = nullptr; + + emit this->windowChanged(); + emit this->backingWindowVisibilityChanged(); + } +} + +void PopupAnchor::onWindowDestroyed() { + this->mWindow = nullptr; + this->mProxyWindow = nullptr; + emit this->windowChanged(); + emit this->backingWindowVisibilityChanged(); +} + +Box PopupAnchor::rect() const { return this->state.rect; } + +void PopupAnchor::setRect(Box rect) { + if (rect == this->state.rect) return; + if (rect.w <= 0) rect.w = 1; + if (rect.h <= 0) rect.h = 1; + + this->state.rect = rect; + emit this->rectChanged(); +} + +Edges::Flags PopupAnchor::edges() const { return this->state.edges; } + +void PopupAnchor::setEdges(Edges::Flags edges) { + if (edges == this->state.edges) return; + + if (Edges::isOpposing(edges)) { + qWarning() << "Cannot set opposing edges for anchor edges. Tried to set" << edges; + return; + } + + this->state.edges = edges; + emit this->edgesChanged(); +} + +Edges::Flags PopupAnchor::gravity() const { return this->state.gravity; } + +void PopupAnchor::setGravity(Edges::Flags gravity) { + if (gravity == this->state.gravity) return; + + if (Edges::isOpposing(gravity)) { + qWarning() << "Cannot set opposing edges for anchor gravity. Tried to set" << gravity; + return; + } + + this->state.gravity = gravity; + emit this->gravityChanged(); +} + +PopupAdjustment::Flags PopupAnchor::adjustment() const { return this->state.adjustment; } + +void PopupAnchor::setAdjustment(PopupAdjustment::Flags adjustment) { + if (adjustment == this->state.adjustment) return; + this->state.adjustment = adjustment; + emit this->adjustmentChanged(); +} + +void PopupAnchor::updatePlacement(const QPoint& anchorpoint, const QSize& size) { + this->state.anchorpoint = anchorpoint; + this->state.size = size; +} + +static PopupPositioner* POSITIONER = nullptr; // NOLINT + +void PopupPositioner::reposition(PopupAnchor* anchor, QWindow* window, bool onlyIfDirty) { + auto* parentWindow = window->transientParent(); + if (!parentWindow) { + qFatal() << "Cannot reposition popup that does not have a transient parent."; + } + + auto parentGeometry = parentWindow->geometry(); + auto windowGeometry = window->geometry(); + + emit anchor->anchoring(); + anchor->updatePlacement(parentGeometry.topLeft(), windowGeometry.size()); + + if (onlyIfDirty && !anchor->isDirty()) return; + anchor->markClean(); + + auto adjustment = anchor->adjustment(); + auto screenGeometry = parentWindow->screen()->geometry(); + auto anchorRectGeometry = anchor->rect().qrect().translated(parentGeometry.topLeft()); + + auto anchorEdges = anchor->edges(); + auto anchorGravity = anchor->gravity(); + + auto width = windowGeometry.width(); + auto height = windowGeometry.height(); + + auto anchorX = anchorEdges.testFlag(Edges::Left) ? anchorRectGeometry.left() + : anchorEdges.testFlag(Edges::Right) ? anchorRectGeometry.right() + : anchorRectGeometry.center().x(); + + auto anchorY = anchorEdges.testFlag(Edges::Top) ? anchorRectGeometry.top() + : anchorEdges.testFlag(Edges::Bottom) ? anchorRectGeometry.bottom() + : anchorRectGeometry.center().y(); + + auto calcEffectiveX = [&](Edges::Flags anchorGravity, int anchorX) { + auto ex = anchorGravity.testFlag(Edges::Left) ? anchorX - windowGeometry.width() + : anchorGravity.testFlag(Edges::Right) ? anchorX - 1 + : anchorX - windowGeometry.width() / 2; + + return ex + 1; + }; + + auto calcEffectiveY = [&](Edges::Flags anchorGravity, int anchorY) { + auto ey = anchorGravity.testFlag(Edges::Top) ? anchorY - windowGeometry.height() + : anchorGravity.testFlag(Edges::Bottom) ? anchorY - 1 + : anchorY - windowGeometry.height() / 2; + + return ey + 1; + }; + + auto calcRemainingWidth = [&](int effectiveX) { + auto width = windowGeometry.width(); + if (effectiveX < screenGeometry.left()) { + auto diff = screenGeometry.left() - effectiveX; + effectiveX = screenGeometry.left(); + width -= diff; + } + + auto effectiveX2 = effectiveX + width; + if (effectiveX2 > screenGeometry.right()) { + width -= effectiveX2 - screenGeometry.right() - 1; + } + + return QPair(effectiveX, width); + }; + + auto calcRemainingHeight = [&](int effectiveY) { + auto height = windowGeometry.height(); + if (effectiveY < screenGeometry.left()) { + auto diff = screenGeometry.top() - effectiveY; + effectiveY = screenGeometry.top(); + height -= diff; + } + + auto effectiveY2 = effectiveY + height; + if (effectiveY2 > screenGeometry.bottom()) { + height -= effectiveY2 - screenGeometry.bottom() - 1; + } + + return QPair(effectiveY, height); + }; + + auto effectiveX = calcEffectiveX(anchorGravity, anchorX); + auto effectiveY = calcEffectiveY(anchorGravity, anchorY); + + if (adjustment.testFlag(PopupAdjustment::FlipX)) { + const bool flip = (anchorGravity.testFlag(Edges::Left) && effectiveX < screenGeometry.left()) + || (anchorGravity.testFlag(Edges::Right) + && effectiveX + windowGeometry.width() > screenGeometry.right()); + + if (flip) { + auto newAnchorGravity = anchorGravity ^ (Edges::Left | Edges::Right); + + auto newAnchorX = anchorEdges.testFlags(Edges::Left) ? anchorRectGeometry.right() + : anchorEdges.testFlags(Edges::Right) ? anchorRectGeometry.left() + : anchorX; + + auto newEffectiveX = calcEffectiveX(newAnchorGravity, newAnchorX); + + // TODO IN HL: pick constraint monitor based on anchor rect position in window + + // if the available width when flipped is more than the available width without flipping then flip + if (calcRemainingWidth(newEffectiveX).second > calcRemainingWidth(effectiveX).second) { + anchorGravity = newAnchorGravity; + anchorX = newAnchorX; + effectiveX = newEffectiveX; + } + } + } + + if (adjustment.testFlag(PopupAdjustment::FlipY)) { + const bool flip = (anchorGravity.testFlag(Edges::Top) && effectiveY < screenGeometry.top()) + || (anchorGravity.testFlag(Edges::Bottom) + && effectiveY + windowGeometry.height() > screenGeometry.bottom()); + + if (flip) { + auto newAnchorGravity = anchorGravity ^ (Edges::Top | Edges::Bottom); + + auto newAnchorY = anchorEdges.testFlags(Edges::Top) ? anchorRectGeometry.bottom() + : anchorEdges.testFlags(Edges::Bottom) ? anchorRectGeometry.top() + : anchorY; + + auto newEffectiveY = calcEffectiveY(newAnchorGravity, newAnchorY); + + // if the available width when flipped is more than the available width without flipping then flip + if (calcRemainingHeight(newEffectiveY).second > calcRemainingHeight(effectiveY).second) { + anchorGravity = newAnchorGravity; + anchorY = newAnchorY; + effectiveY = newEffectiveY; + } + } + } + + // Slide order is important for the case where the window is too large to fit on screen. + if (adjustment.testFlag(PopupAdjustment::SlideX)) { + if (effectiveX + windowGeometry.width() > screenGeometry.right()) { + effectiveX = screenGeometry.right() - windowGeometry.width() + 1; + } + + if (effectiveX < screenGeometry.left()) { + effectiveX = screenGeometry.left(); + } + } + + if (adjustment.testFlag(PopupAdjustment::SlideY)) { + if (effectiveY + windowGeometry.height() > screenGeometry.bottom()) { + effectiveY = screenGeometry.bottom() - windowGeometry.height() + 1; + } + + if (effectiveY < screenGeometry.top()) { + effectiveY = screenGeometry.top(); + } + } + + if (adjustment.testFlag(PopupAdjustment::ResizeX)) { + auto [newX, newWidth] = calcRemainingWidth(effectiveX); + effectiveX = newX; + width = newWidth; + } + + if (adjustment.testFlag(PopupAdjustment::ResizeY)) { + auto [newY, newHeight] = calcRemainingHeight(effectiveY); + effectiveY = newY; + height = newHeight; + } + + window->setGeometry({effectiveX, effectiveY, width, height}); +} + +bool PopupPositioner::shouldRepositionOnMove() const { return true; } + +PopupPositioner* PopupPositioner::instance() { + if (POSITIONER == nullptr) { + POSITIONER = new PopupPositioner(); + } + + return POSITIONER; +} + +void PopupPositioner::setInstance(PopupPositioner* instance) { + delete POSITIONER; + POSITIONER = instance; +} diff --git a/src/core/popupanchor.hpp b/src/core/popupanchor.hpp new file mode 100644 index 00000000..90ba697f --- /dev/null +++ b/src/core/popupanchor.hpp @@ -0,0 +1,174 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../window/proxywindow.hpp" +#include "doc.hpp" +#include "types.hpp" + +///! Adjustment strategy for popups that do not fit on screen. +/// Adjustment strategy for popups. See @@PopupAnchor.adjustment. +/// +/// Adjustment flags can be combined with the `|` operator. +/// +/// `Flip` will be applied first, then `Slide`, then `Resize`. +namespace PopupAdjustment { // NOLINT +Q_NAMESPACE; +QML_ELEMENT; + +enum Enum : quint8 { + None = 0, + /// If the X axis is constrained, the popup will slide along the X axis until it fits onscreen. + SlideX = 1, + /// If the Y axis is constrained, the popup will slide along the Y axis until it fits onscreen. + SlideY = 2, + /// Alias for `SlideX | SlideY`. + Slide = SlideX | SlideY, + /// If the X axis is constrained, the popup will invert its horizontal gravity if any. + FlipX = 4, + /// If the Y axis is constrained, the popup will invert its vertical gravity if any. + FlipY = 8, + /// Alias for `FlipX | FlipY`. + Flip = FlipX | FlipY, + /// If the X axis is constrained, the width of the popup will be reduced to fit on screen. + ResizeX = 16, + /// If the Y axis is constrained, the height of the popup will be reduced to fit on screen. + ResizeY = 32, + /// Alias for `ResizeX | ResizeY` + Resize = ResizeX | ResizeY, + /// Alias for `Flip | Slide | Resize`. + All = Slide | Flip | Resize, +}; +Q_ENUM_NS(Enum); +Q_DECLARE_FLAGS(Flags, Enum); + +} // namespace PopupAdjustment + +Q_DECLARE_OPERATORS_FOR_FLAGS(PopupAdjustment::Flags); + +struct PopupAnchorState { + bool operator==(const PopupAnchorState& other) const; + + Box rect = {0, 0, 1, 1}; + Edges::Flags edges = Edges::Top | Edges::Left; + Edges::Flags gravity = Edges::Bottom | Edges::Right; + PopupAdjustment::Flags adjustment = PopupAdjustment::Slide; + QPoint anchorpoint; + QSize size; +}; + +///! Anchorpoint or positioner for popup windows. +class PopupAnchor: public QObject { + Q_OBJECT; + // clang-format off + /// The window to anchor / attach the popup to. + Q_PROPERTY(QObject* window READ window WRITE setWindow NOTIFY windowChanged); + /// The anchorpoints the popup will attach to. Which anchors will be used is + /// determined by the @@edges, @@gravity, and @@adjustment. + /// + /// If you leave @@edges, @@gravity and @@adjustment at their default values, + /// setting more than `x` and `y` does not matter. The anchor rect cannot + /// be smaller than 1x1 pixels. + /// + /// > [!INFO] To position a popup relative to an item inside a window, + /// > you can use [coordinate mapping functions] (note the warning below). + /// + /// > [!WARNING] Using [coordinate mapping functions] in a binding to + /// > this property will position the anchor incorrectly. + /// > If you want to use them, do so in @@anchoring(s), or use + /// > @@TransformWatcher if you need real-time updates to mapped coordinates. + /// + /// [coordinate mapping functions]: https://doc.qt.io/qt-6/qml-qtquick-item.html#mapFromItem-method + Q_PROPERTY(Box rect READ rect WRITE setRect NOTIFY rectChanged); + /// The point on the anchor rectangle the popup should anchor to. + /// Opposing edges suchs as `Edges.Left | Edges.Right` are not allowed. + /// + /// Defaults to `Edges.Top | Edges.Left`. + Q_PROPERTY(Edges::Flags edges READ edges WRITE setEdges NOTIFY edgesChanged); + /// The direction the popup should expand towards, relative to the anchorpoint. + /// Opposing edges suchs as `Edges.Left | Edges.Right` are not allowed. + /// + /// Defaults to `Edges.Bottom | Edges.Right`. + Q_PROPERTY(Edges::Flags gravity READ gravity WRITE setGravity NOTIFY gravityChanged); + /// The strategy used to adjust the popup's position if it would otherwise not fit on screen, + /// based on the anchor @@rect, preferred @@edges, and @@gravity. + /// + /// See the documentation for @@PopupAdjustment for details. + Q_PROPERTY(PopupAdjustment::Flags adjustment READ adjustment WRITE setAdjustment NOTIFY adjustmentChanged); + // clang-format on + QML_ELEMENT; + QML_UNCREATABLE(""); + +public: + explicit PopupAnchor(QObject* parent): QObject(parent) {} + + [[nodiscard]] bool isDirty() const; + void markClean(); + void markDirty(); + + [[nodiscard]] QObject* window() const; + [[nodiscard]] ProxyWindowBase* proxyWindow() const; + [[nodiscard]] QWindow* backingWindow() const; + void setWindow(QObject* window); + + [[nodiscard]] Box rect() const; + void setRect(Box rect); + + [[nodiscard]] Edges::Flags edges() const; + void setEdges(Edges::Flags edges); + + [[nodiscard]] Edges::Flags gravity() const; + void setGravity(Edges::Flags gravity); + + [[nodiscard]] PopupAdjustment::Flags adjustment() const; + void setAdjustment(PopupAdjustment::Flags adjustment); + + void updatePlacement(const QPoint& anchorpoint, const QSize& size); + +signals: + /// Emitted when this anchor is about to be used. Mostly useful for modifying + /// the anchor @@rect using [coordinate mapping functions], which are not reactive. + /// + /// [coordinate mapping functions]: https://doc.qt.io/qt-6/qml-qtquick-item.html#mapFromItem-method + void anchoring(); + + void windowChanged(); + QSDOC_HIDE void backingWindowVisibilityChanged(); + void rectChanged(); + void edgesChanged(); + void gravityChanged(); + void adjustmentChanged(); + +private slots: + void onWindowDestroyed(); + +private: + QObject* mWindow = nullptr; + ProxyWindowBase* mProxyWindow = nullptr; + PopupAnchorState state; + std::optional lastState; +}; + +class PopupPositioner { +public: + explicit PopupPositioner() = default; + virtual ~PopupPositioner() = default; + Q_DISABLE_COPY_MOVE(PopupPositioner); + + virtual void reposition(PopupAnchor* anchor, QWindow* window, bool onlyIfDirty = true); + [[nodiscard]] virtual bool shouldRepositionOnMove() const; + + static PopupPositioner* instance(); + static void setInstance(PopupPositioner* instance); +}; diff --git a/src/core/popupwindow.cpp b/src/core/popupwindow.cpp deleted file mode 100644 index 547bbe36..00000000 --- a/src/core/popupwindow.cpp +++ /dev/null @@ -1,161 +0,0 @@ -#include "popupwindow.hpp" - -#include -#include -#include -#include -#include -#include - -#include "proxywindow.hpp" -#include "qmlscreen.hpp" -#include "windowinterface.hpp" - -ProxyPopupWindow::ProxyPopupWindow(QObject* parent): ProxyWindowBase(parent) { - this->mVisible = false; -} - -void ProxyPopupWindow::completeWindow() { - this->ProxyWindowBase::completeWindow(); - - this->window->setFlag(Qt::ToolTip); - this->updateTransientParent(); -} - -void ProxyPopupWindow::postCompleteWindow() { this->ProxyWindowBase::setVisible(this->mVisible); } - -bool ProxyPopupWindow::deleteOnInvisible() const { - // Currently crashes in normal mode, do not have the time to debug it now. - return true; -} - -qint32 ProxyPopupWindow::x() const { - // QTBUG-121550 - auto basepos = this->mParentProxyWindow == nullptr ? 0 : this->mParentProxyWindow->x(); - return basepos + this->mRelativeX; -} - -void ProxyPopupWindow::setParentWindow(QObject* parent) { - if (parent == this->mParentWindow) return; - - if (this->mParentWindow != nullptr) { - QObject::disconnect(this->mParentWindow, nullptr, this, nullptr); - QObject::disconnect(this->mParentProxyWindow, nullptr, this, nullptr); - } - - if (parent == nullptr) { - this->mParentWindow = nullptr; - this->mParentProxyWindow = nullptr; - } else { - if (auto* proxy = qobject_cast(parent)) { - this->mParentProxyWindow = proxy; - } else if (auto* interface = qobject_cast(parent)) { - this->mParentProxyWindow = interface->proxyWindow(); - } else { - qWarning() << "Tried to set popup parent window to something that is not a quickshell window:" - << parent; - this->mParentWindow = nullptr; - this->mParentProxyWindow = nullptr; - this->updateTransientParent(); - return; - } - - this->mParentWindow = parent; - - // clang-format off - QObject::connect(this->mParentWindow, &QObject::destroyed, this, &ProxyPopupWindow::onParentDestroyed); - - QObject::connect(this->mParentProxyWindow, &ProxyWindowBase::xChanged, this, &ProxyPopupWindow::updateX); - QObject::connect(this->mParentProxyWindow, &ProxyWindowBase::yChanged, this, &ProxyPopupWindow::updateY); - QObject::connect(this->mParentProxyWindow, &ProxyWindowBase::backerVisibilityChanged, this, &ProxyPopupWindow::onParentUpdated); - // clang-format on - } - - this->updateTransientParent(); -} - -QObject* ProxyPopupWindow::parentWindow() const { return this->mParentWindow; } - -void ProxyPopupWindow::updateTransientParent() { - this->updateX(); - this->updateY(); - - if (this->window != nullptr) { - this->window->setTransientParent( - this->mParentProxyWindow == nullptr ? nullptr : this->mParentProxyWindow->backingWindow() - ); - } - - this->updateVisible(); -} - -void ProxyPopupWindow::onParentUpdated() { this->updateTransientParent(); } - -void ProxyPopupWindow::onParentDestroyed() { - this->mParentWindow = nullptr; - this->mParentProxyWindow = nullptr; - this->updateVisible(); - emit this->parentWindowChanged(); -} - -void ProxyPopupWindow::setScreen(QuickshellScreenInfo* /*unused*/) { - qWarning() << "Cannot set screen of popup window, as that is controlled by the parent window"; -} - -void ProxyPopupWindow::setVisible(bool visible) { - if (visible == this->wantsVisible) return; - this->wantsVisible = visible; - this->updateVisible(); -} - -void ProxyPopupWindow::updateVisible() { - auto target = this->wantsVisible && this->mParentWindow != nullptr - && this->mParentProxyWindow->isVisibleDirect(); - - if (target && this->window != nullptr && !this->window->isVisible()) { - this->updateX(); // QTBUG-121550 - } - - this->ProxyWindowBase::setVisible(target); -} - -void ProxyPopupWindow::setRelativeX(qint32 x) { - if (x == this->mRelativeX) return; - this->mRelativeX = x; - this->updateX(); -} - -qint32 ProxyPopupWindow::relativeX() const { return this->mRelativeX; } - -void ProxyPopupWindow::setRelativeY(qint32 y) { - if (y == this->mRelativeY) return; - this->mRelativeY = y; - this->updateY(); -} - -qint32 ProxyPopupWindow::relativeY() const { return this->mRelativeY; } - -void ProxyPopupWindow::updateX() { - if (this->mParentWindow == nullptr || this->window == nullptr) return; - - auto target = this->x() - 1; // QTBUG-121550 - - auto reshow = this->isVisibleDirect() && (this->window->x() != target && this->x() != target); - if (reshow) this->setVisibleDirect(false); - if (this->window != nullptr) this->window->setX(target); - if (reshow && this->wantsVisible) this->setVisibleDirect(true); -} - -void ProxyPopupWindow::updateY() { - if (this->mParentWindow == nullptr || this->window == nullptr) return; - - auto target = this->mParentProxyWindow->y() + this->relativeY(); - - auto reshow = this->isVisibleDirect() && this->window->y() != target; - if (reshow) { - this->setVisibleDirect(false); - this->updateX(); // QTBUG-121550 - } - if (this->window != nullptr) this->window->setY(target); - if (reshow && this->wantsVisible) this->setVisibleDirect(true); -} diff --git a/src/core/qmlglobal.cpp b/src/core/qmlglobal.cpp index 70d7b416..23f238da 100644 --- a/src/core/qmlglobal.cpp +++ b/src/core/qmlglobal.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +20,7 @@ #include #include "generation.hpp" +#include "iconimageprovider.hpp" #include "qmlscreen.hpp" #include "rootwrapper.hpp" @@ -165,6 +167,12 @@ void QuickshellGlobal::reload(bool hard) { root->reloadGraph(hard); } +QString QuickshellGlobal::shellRoot() const { + auto* generation = EngineGeneration::findObjectGeneration(this); + // already canonical + return generation->rootPath.path(); +} + QString QuickshellGlobal::workingDirectory() const { // NOLINT return QuickshellSettings::instance()->workingDirectory(); } @@ -187,3 +195,27 @@ QVariant QuickshellGlobal::env(const QString& variable) { // NOLINT return qEnvironmentVariable(vstr.data()); } + +QString QuickshellGlobal::iconPath(const QString& icon) { + return IconImageProvider::requestString(icon); +} + +QString QuickshellGlobal::iconPath(const QString& icon, bool check) { + if (check && QIcon::fromTheme(icon).isNull()) return ""; + return IconImageProvider::requestString(icon); +} + +QString QuickshellGlobal::iconPath(const QString& icon, const QString& fallback) { + return IconImageProvider::requestString(icon, "", fallback); +} + +QuickshellGlobal* QuickshellGlobal::create(QQmlEngine* engine, QJSEngine* /*unused*/) { + auto* qsg = new QuickshellGlobal(); + auto* generation = EngineGeneration::findEngineGeneration(engine); + + if (generation->qsgInstance == nullptr) { + generation->qsgInstance = qsg; + } + + return qsg; +} diff --git a/src/core/qmlglobal.hpp b/src/core/qmlglobal.hpp index 83ef68d4..4f46d238 100644 --- a/src/core/qmlglobal.hpp +++ b/src/core/qmlglobal.hpp @@ -98,6 +98,11 @@ class QuickshellGlobal: public QObject { /// This creates an instance of your window once on every screen. /// As screens are added or removed your window will be created or destroyed on those screens. Q_PROPERTY(QQmlListProperty screens READ screens NOTIFY screensChanged); + /// The full path to the root directory of your shell. + /// + /// The root directory is the folder containing the entrypoint to your shell, often referred + /// to as `shell.qml`. + Q_PROPERTY(QString shellRoot READ shellRoot CONSTANT); /// Quickshell's working directory. Defaults to whereever quickshell was launched from. Q_PROPERTY(QString workingDirectory READ workingDirectory WRITE setWorkingDirectory NOTIFY workingDirectoryChanged); /// If true then the configuration will be reloaded whenever any files change. @@ -110,40 +115,62 @@ class QuickshellGlobal: public QObject { public: [[nodiscard]] qint32 processId() const; - QuickshellGlobal(QObject* parent = nullptr); - QQmlListProperty screens(); - /// Reload the shell from the [ShellRoot]. + /// Reload the shell. /// /// `hard` - perform a hard reload. If this is false, Quickshell will attempt to reuse windows /// that already exist. If true windows will be recreated. /// - /// See [Reloadable] for more information on what can be reloaded and how. - /// - /// [Reloadable]: ../reloadable + /// See @@Reloadable for more information on what can be reloaded and how. Q_INVOKABLE void reload(bool hard); /// Returns the string value of an environment variable or null if it is not set. Q_INVOKABLE QVariant env(const QString& variable); + /// Returns a string usable for a @@QtQuick.Image.source for a given system icon. + /// + /// > [!INFO] By default, icons are loaded from the theme selected by the qt platform theme, + /// > which means they should match with all other qt applications on your system. + /// > + /// > If you want to use a different icon theme, you can put `//@ pragma IconTheme ` + /// > at the top of your root config file or set the `QS_ICON_THEME` variable to the name + /// > of your icon theme. + Q_INVOKABLE static QString iconPath(const QString& icon); + /// Setting the `check` parameter of `iconPath` to true will return an empty string + /// if the icon does not exist, instead of an image showing a missing texture. + Q_INVOKABLE static QString iconPath(const QString& icon, bool check); + /// Setting the `fallback` parameter of `iconPath` will attempt to load the fallback + /// icon if the requested one could not be loaded. + Q_INVOKABLE static QString iconPath(const QString& icon, const QString& fallback); + + [[nodiscard]] QString shellRoot() const; + [[nodiscard]] QString workingDirectory() const; void setWorkingDirectory(QString workingDirectory); [[nodiscard]] bool watchFiles() const; void setWatchFiles(bool watchFiles); + static QuickshellGlobal* create(QQmlEngine* engine, QJSEngine* /*unused*/); + signals: /// Sent when the last window is closed. /// /// To make the application exit when the last window is closed run `Qt.quit()`. void lastWindowClosed(); + /// The reload sequence has completed successfully. + void reloadCompleted(); + /// The reload sequence has failed. + void reloadFailed(QString errorString); void screensChanged(); void workingDirectoryChanged(); void watchFilesChanged(); private: + QuickshellGlobal(QObject* parent = nullptr); + static qsizetype screensCount(QQmlListProperty* prop); static QuickshellScreenInfo* screenAt(QQmlListProperty* prop, qsizetype i); }; diff --git a/src/core/qmlscreen.hpp b/src/core/qmlscreen.hpp index dfebf331..69c0762d 100644 --- a/src/core/qmlscreen.hpp +++ b/src/core/qmlscreen.hpp @@ -12,17 +12,14 @@ // unfortunately QQuickScreenInfo is private. -/// Monitor object useful for setting the monitor for a [ShellWindow] +/// Monitor object useful for setting the monitor for a @@QsWindow /// or querying information about the monitor. /// /// > [!WARNING] If the monitor is disconnected than any stored copies of its ShellMonitor will /// > be marked as dangling and all properties will return default values. /// > Reconnecting the monitor will not reconnect it to the ShellMonitor object. /// -/// Due to some technical limitations, it was not possible to reuse the native qml [Screen] type. -/// -/// [ShellWindow]: ../shellwindow -/// [Screen]: https://doc.qt.io/qt-6/qml-qtquick-screen.html +/// Due to some technical limitations, it was not possible to reuse the native qml @@QtQuick.Screen type. class QuickshellScreenInfo: public QObject { Q_OBJECT; QML_NAMED_ELEMENT(ShellScreen); diff --git a/src/core/qsintercept.cpp b/src/core/qsintercept.cpp index 2eaf498e..ba46ab7b 100644 --- a/src/core/qsintercept.cpp +++ b/src/core/qsintercept.cpp @@ -16,7 +16,22 @@ Q_LOGGING_CATEGORY(logQsIntercept, "quickshell.interceptor", QtWarningMsg); -QUrl QsUrlInterceptor::intercept(const QUrl& url, QQmlAbstractUrlInterceptor::DataType type) { +QUrl QsUrlInterceptor::intercept( + const QUrl& originalUrl, + QQmlAbstractUrlInterceptor::DataType type +) { + auto url = originalUrl; + + if (url.scheme() == "root") { + url.setScheme("qsintercept"); + + auto path = url.path(); + if (path.startsWith('/')) path = path.sliced(1); + url.setPath(this->configRoot.filePath(path)); + + qCDebug(logQsIntercept) << "Rewrote root intercept" << originalUrl << "to" << url; + } + // Some types such as Image take into account where they are loading from, and force // asynchronous loading over a network. qsintercept is considered to be over a network. if (type == QQmlAbstractUrlInterceptor::DataType::UrlString && url.scheme() == "qsintercept") { diff --git a/src/core/qsintercept.hpp b/src/core/qsintercept.hpp index d51b78e6..57923568 100644 --- a/src/core/qsintercept.hpp +++ b/src/core/qsintercept.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -13,7 +14,12 @@ Q_DECLARE_LOGGING_CATEGORY(logQsIntercept); class QsUrlInterceptor: public QQmlAbstractUrlInterceptor { public: - QUrl intercept(const QUrl& url, QQmlAbstractUrlInterceptor::DataType type) override; + explicit QsUrlInterceptor(const QDir& configRoot): configRoot(configRoot) {} + + QUrl intercept(const QUrl& originalUrl, QQmlAbstractUrlInterceptor::DataType type) override; + +private: + QDir configRoot; }; class QsInterceptDataReply: public QNetworkReply { diff --git a/src/core/qsmenu.cpp b/src/core/qsmenu.cpp new file mode 100644 index 00000000..760f7e76 --- /dev/null +++ b/src/core/qsmenu.cpp @@ -0,0 +1,110 @@ +#include "qsmenu.hpp" + +#include +#include +#include +#include + +#include "model.hpp" +#include "platformmenu.hpp" + +using namespace qs::menu::platform; + +namespace qs::menu { + +QString QsMenuButtonType::toString(QsMenuButtonType::Enum value) { + switch (value) { + case QsMenuButtonType::None: return "None"; + case QsMenuButtonType::CheckBox: return "CheckBox"; + case QsMenuButtonType::RadioButton: return "RadioButton"; + default: return "Invalid button type"; + } +} + +QsMenuEntry* QsMenuEntry::menu() { return this; } + +void QsMenuEntry::display(QObject* parentWindow, int relativeX, int relativeY) { + auto* platform = new PlatformMenuEntry(this); + + QObject::connect(platform, &PlatformMenuEntry::closed, platform, [=]() { + platform->deleteLater(); + }); + + auto success = platform->display(parentWindow, relativeX, relativeY); + if (!success) delete platform; +} + +void QsMenuEntry::ref() { + this->refcount++; + if (this->refcount == 1) emit this->opened(); +} + +void QsMenuEntry::unref() { + this->refcount--; + if (this->refcount == 0) emit this->closed(); +} + +ObjectModel* QsMenuEntry::children() { + return ObjectModel::emptyInstance(); +} + +QsMenuOpener::~QsMenuOpener() { + if (this->mMenu) { + if (this->mMenu->menu()) this->mMenu->menu()->unref(); + this->mMenu->unrefHandle(); + } +} + +QsMenuHandle* QsMenuOpener::menu() const { return this->mMenu; } + +void QsMenuOpener::setMenu(QsMenuHandle* menu) { + if (menu == this->mMenu) return; + + if (this->mMenu != nullptr) { + QObject::disconnect(this->mMenu, nullptr, this, nullptr); + + if (this->mMenu->menu()) { + QObject::disconnect(this->mMenu->menu(), nullptr, this, nullptr); + this->mMenu->menu()->unref(); + } + + this->mMenu->unrefHandle(); + } + + this->mMenu = menu; + + if (menu != nullptr) { + auto onMenuChanged = [this, menu]() { + if (menu->menu()) { + menu->menu()->ref(); + } + + emit this->childrenChanged(); + }; + + QObject::connect(menu, &QObject::destroyed, this, &QsMenuOpener::onMenuDestroyed); + QObject::connect(menu, &QsMenuHandle::menuChanged, this, onMenuChanged); + + if (menu->menu()) onMenuChanged(); + menu->refHandle(); + } + + emit this->menuChanged(); + emit this->childrenChanged(); +} + +void QsMenuOpener::onMenuDestroyed() { + this->mMenu = nullptr; + emit this->menuChanged(); + emit this->childrenChanged(); +} + +ObjectModel* QsMenuOpener::children() { + if (this->mMenu && this->mMenu->menu()) { + return this->mMenu->menu()->children(); + } else { + return ObjectModel::emptyInstance(); + } +} + +} // namespace qs::menu diff --git a/src/core/qsmenu.hpp b/src/core/qsmenu.hpp new file mode 100644 index 00000000..6684c686 --- /dev/null +++ b/src/core/qsmenu.hpp @@ -0,0 +1,163 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "doc.hpp" +#include "model.hpp" + +namespace qs::menu { + +///! Button type associated with a QsMenuEntry. +/// See @@QsMenuEntry.buttonType. +class QsMenuButtonType: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + /// This menu item does not have a checkbox or a radiobutton associated with it. + None = 0, + /// This menu item should draw a checkbox. + CheckBox = 1, + /// This menu item should draw a radiobutton. + RadioButton = 2, + }; + Q_ENUM(Enum); + + Q_INVOKABLE static QString toString(qs::menu::QsMenuButtonType::Enum value); +}; + +class QsMenuEntry; + +///! Menu handle for QsMenuOpener +/// See @@QsMenuOpener. +class QsMenuHandle: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE(""); + +public: + explicit QsMenuHandle(QObject* parent): QObject(parent) {} + + virtual void refHandle() {}; + virtual void unrefHandle() {}; + + [[nodiscard]] virtual QsMenuEntry* menu() = 0; + +signals: + void menuChanged(); +}; + +class QsMenuEntry: public QsMenuHandle { + Q_OBJECT; + /// If this menu item should be rendered as a separator between other items. + /// + /// No other properties have a meaningful value when @@isSeparator is true. + Q_PROPERTY(bool isSeparator READ isSeparator NOTIFY isSeparatorChanged); + Q_PROPERTY(bool enabled READ enabled NOTIFY enabledChanged); + /// Text of the menu item. + Q_PROPERTY(QString text READ text NOTIFY textChanged); + /// Url of the menu item's icon or `""` if it doesn't have one. + /// + /// This can be passed to [Image.source](https://doc.qt.io/qt-6/qml-qtquick-image.html#source-prop) + /// as shown below. + /// + /// ```qml + /// Image { + /// source: menuItem.icon + /// // To get the best image quality, set the image source size to the same size + /// // as the rendered image. + /// sourceSize.width: width + /// sourceSize.height: height + /// } + /// ``` + Q_PROPERTY(QString icon READ icon NOTIFY iconChanged); + /// If this menu item has an associated checkbox or radiobutton. + Q_PROPERTY(qs::menu::QsMenuButtonType::Enum buttonType READ buttonType NOTIFY buttonTypeChanged); + /// The check state of the checkbox or radiobutton if applicable, as a + /// [Qt.CheckState](https://doc.qt.io/qt-6/qt.html#CheckState-enum). + Q_PROPERTY(Qt::CheckState checkState READ checkState NOTIFY checkStateChanged); + /// If this menu item has children that can be accessed through a @@QsMenuOpener$. + Q_PROPERTY(bool hasChildren READ hasChildren NOTIFY hasChildrenChanged); + QML_ELEMENT; + QML_UNCREATABLE("QsMenuEntry cannot be directly created"); + +public: + explicit QsMenuEntry(QObject* parent): QsMenuHandle(parent) {} + + [[nodiscard]] QsMenuEntry* menu() override; + + /// Display a platform menu at the given location relative to the parent window. + Q_INVOKABLE void display(QObject* parentWindow, qint32 relativeX, qint32 relativeY); + + [[nodiscard]] virtual bool isSeparator() const { return false; } + [[nodiscard]] virtual bool enabled() const { return true; } + [[nodiscard]] virtual QString text() const { return ""; } + [[nodiscard]] virtual QString icon() const { return ""; } + [[nodiscard]] virtual QsMenuButtonType::Enum buttonType() const { return QsMenuButtonType::None; } + [[nodiscard]] virtual Qt::CheckState checkState() const { return Qt::Unchecked; } + [[nodiscard]] virtual bool hasChildren() const { return false; } + + void ref(); + void unref(); + + [[nodiscard]] virtual ObjectModel* children(); + +signals: + /// Send a trigger/click signal to the menu entry. + void triggered(); + + QSDOC_HIDE void opened(); + QSDOC_HIDE void closed(); + + void isSeparatorChanged(); + void enabledChanged(); + void textChanged(); + void iconChanged(); + void buttonTypeChanged(); + void checkStateChanged(); + void hasChildrenChanged(); + +private: + qsizetype refcount = 0; +}; + +///! Provides access to children of a QsMenuEntry +class QsMenuOpener: public QObject { + Q_OBJECT; + /// The menu to retrieve children from. + Q_PROPERTY(qs::menu::QsMenuHandle* menu READ menu WRITE setMenu NOTIFY menuChanged); + /// The children of the given menu. + QSDOC_TYPE_OVERRIDE(ObjectModel*); + Q_PROPERTY(UntypedObjectModel* children READ children NOTIFY childrenChanged); + QML_ELEMENT; + +public: + explicit QsMenuOpener(QObject* parent = nullptr): QObject(parent) {} + ~QsMenuOpener() override; + Q_DISABLE_COPY_MOVE(QsMenuOpener); + + [[nodiscard]] QsMenuHandle* menu() const; + void setMenu(QsMenuHandle* menu); + + [[nodiscard]] ObjectModel* children(); + +signals: + void menuChanged(); + void childrenChanged(); + +private slots: + void onMenuDestroyed(); + +private: + QsMenuHandle* mMenu = nullptr; +}; + +} // namespace qs::menu diff --git a/src/core/qsmenuanchor.cpp b/src/core/qsmenuanchor.cpp new file mode 100644 index 00000000..ded8ad05 --- /dev/null +++ b/src/core/qsmenuanchor.cpp @@ -0,0 +1,125 @@ +#include "qsmenuanchor.hpp" + +#include +#include +#include +#include +#include + +#include "platformmenu.hpp" +#include "popupanchor.hpp" +#include "qsmenu.hpp" + +using qs::menu::platform::PlatformMenuEntry; + +namespace qs::menu { + +QsMenuAnchor::~QsMenuAnchor() { this->onClosed(); } + +void QsMenuAnchor::open() { + if (qobject_cast(QCoreApplication::instance()) == nullptr) { + qCritical() << "Cannot call QsMenuAnchor.open() as quickshell was not started in " + "QApplication mode."; + qCritical() << "To use platform menus, add `//@ pragma UseQApplication` to the top of your " + "root QML file and restart quickshell."; + return; + } + + if (this->mOpen) { + qCritical() << "Cannot call QsMenuAnchor.open() as it is already open."; + return; + } + + if (!this->mMenu) { + qCritical() << "Cannot open QsMenuAnchor with no menu attached."; + return; + } + + this->mOpen = true; + + if (this->mMenu->menu()) this->onMenuChanged(); + QObject::connect(this->mMenu, &QsMenuHandle::menuChanged, this, &QsMenuAnchor::onMenuChanged); + this->mMenu->refHandle(); + + emit this->visibleChanged(); +} + +void QsMenuAnchor::onMenuChanged() { + // close menu if the path changes + if (this->platformMenu || !this->mMenu->menu()) { + this->onClosed(); + return; + } + + this->platformMenu = new PlatformMenuEntry(this->mMenu->menu()); + QObject::connect(this->platformMenu, &PlatformMenuEntry::closed, this, &QsMenuAnchor::onClosed); + + auto success = this->platformMenu->display(&this->mAnchor); + if (!success) this->onClosed(); + else emit this->opened(); +} + +void QsMenuAnchor::close() { + if (!this->mOpen) { + qCritical() << "Cannot close QsMenuAnchor as it isn't open."; + return; + } + + this->onClosed(); +} + +void QsMenuAnchor::onClosed() { + if (!this->mOpen) return; + + this->mOpen = false; + + if (this->platformMenu) { + this->platformMenu->deleteLater(); + this->platformMenu = nullptr; + } + + if (this->mMenu) { + QObject::disconnect( + this->mMenu, + &QsMenuHandle::menuChanged, + this, + &QsMenuAnchor::onMenuChanged + ); + + this->mMenu->unrefHandle(); + } + + emit this->closed(); + emit this->visibleChanged(); +} + +PopupAnchor* QsMenuAnchor::anchor() { return &this->mAnchor; } + +QsMenuHandle* QsMenuAnchor::menu() const { return this->mMenu; } + +void QsMenuAnchor::setMenu(QsMenuHandle* menu) { + if (menu == this->mMenu) return; + + if (this->mMenu != nullptr) { + if (this->platformMenu != nullptr) this->platformMenu->deleteLater(); + QObject::disconnect(this->mMenu, nullptr, this, nullptr); + } + + this->mMenu = menu; + + if (menu != nullptr) { + QObject::connect(menu, &QObject::destroyed, this, &QsMenuAnchor::onMenuDestroyed); + } + + emit this->menuChanged(); +} + +bool QsMenuAnchor::isVisible() const { return this->mOpen; } + +void QsMenuAnchor::onMenuDestroyed() { + this->mMenu = nullptr; + this->onClosed(); + emit this->menuChanged(); +} + +} // namespace qs::menu diff --git a/src/core/qsmenuanchor.hpp b/src/core/qsmenuanchor.hpp new file mode 100644 index 00000000..14e06c63 --- /dev/null +++ b/src/core/qsmenuanchor.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include + +#include "platformmenu.hpp" +#include "popupanchor.hpp" +#include "qsmenu.hpp" + +namespace qs::menu { + +///! Display anchor for platform menus. +class QsMenuAnchor: public QObject { + Q_OBJECT; + /// The menu's anchor / positioner relative to another window. The menu will not be + /// shown until it has a valid anchor. + /// + /// > [!INFO] *The following is subject to change and NOT a guarantee of future behavior.* + /// > + /// > A snapshot of the anchor at the time @@opened(s) is emitted will be + /// > used to position the menu. Additional changes to the anchor after this point + /// > will not affect the placement of the menu. + /// + /// You can set properties of the anchor like so: + /// ```qml + /// QsMenuAnchor { + /// anchor.window: parentwindow + /// // or + /// anchor { + /// window: parentwindow + /// } + /// } + /// ``` + Q_PROPERTY(PopupAnchor* anchor READ anchor CONSTANT); + /// The menu that should be displayed on this anchor. + /// + /// See also: @@Quickshell.Services.SystemTray.SystemTrayItem.menu. + Q_PROPERTY(qs::menu::QsMenuHandle* menu READ menu WRITE setMenu NOTIFY menuChanged); + /// If the menu is currently open and visible. + /// + /// See also: @@open(), @@close(). + Q_PROPERTY(bool visible READ isVisible NOTIFY visibleChanged); + QML_ELEMENT; + +public: + explicit QsMenuAnchor(QObject* parent = nullptr): QObject(parent) {} + ~QsMenuAnchor() override; + Q_DISABLE_COPY_MOVE(QsMenuAnchor); + + /// Open the given menu on this menu Requires that @@anchor is valid. + Q_INVOKABLE void open(); + /// Close the open menu. + Q_INVOKABLE void close(); + + [[nodiscard]] PopupAnchor* anchor(); + + [[nodiscard]] QsMenuHandle* menu() const; + void setMenu(QsMenuHandle* menu); + + [[nodiscard]] bool isVisible() const; + +signals: + /// Sent when the menu is displayed onscreen which may be after @@visible + /// becomes true. + void opened(); + /// Sent when the menu is closed. + void closed(); + + void menuChanged(); + void visibleChanged(); + +private slots: + void onMenuChanged(); + void onMenuDestroyed(); + +private: + void onClosed(); + + PopupAnchor mAnchor {this}; + QsMenuHandle* mMenu = nullptr; + bool mOpen = false; + platform::PlatformMenuEntry* platformMenu = nullptr; +}; + +} // namespace qs::menu diff --git a/src/core/region.hpp b/src/core/region.hpp index 35f2736c..02d7a26b 100644 --- a/src/core/region.hpp +++ b/src/core/region.hpp @@ -9,12 +9,13 @@ #include #include -/// Shape of a Region. +///! Shape of a Region. +/// See @@Region.shape. namespace RegionShape { // NOLINT Q_NAMESPACE; QML_ELEMENT; -enum Enum { +enum Enum : quint8 { Rect = 0, Ellipse = 1, }; @@ -23,11 +24,12 @@ Q_ENUM_NS(Enum); } // namespace RegionShape ///! Intersection strategy for Regions. +/// See @@Region.intersection. namespace Intersection { // NOLINT Q_NAMESPACE; QML_ELEMENT; -enum Enum { +enum Enum : quint8 { /// Combine this region, leaving a union of this and the other region. (opposite of `Subtract`) Combine = 0, /// Subtract this region, cutting this region out of the other. (opposite of `Combine`) @@ -44,6 +46,7 @@ Q_ENUM_NS(Enum); } // namespace Intersection ///! A composable region used as a mask. +/// See @@QsWindow.mask. class PendingRegion: public QObject { Q_OBJECT; /// Defaults to `Rect`. @@ -52,16 +55,16 @@ class PendingRegion: public QObject { Q_PROPERTY(Intersection::Enum intersection MEMBER mIntersection NOTIFY intersectionChanged); /// The item that determines the geometry of the region. - /// `item` overrides `x`, `y`, `width` and `height`. + /// `item` overrides @@x, @@y, @@width and @@height. Q_PROPERTY(QQuickItem* item MEMBER mItem WRITE setItem NOTIFY itemChanged); - /// Defaults to 0. Does nothing if `item` is set. + /// Defaults to 0. Does nothing if @@item is set. Q_PROPERTY(qint32 x MEMBER mX NOTIFY xChanged); - /// Defaults to 0. Does nothing if `item` is set. + /// Defaults to 0. Does nothing if @@item is set. Q_PROPERTY(qint32 y MEMBER mY NOTIFY yChanged); - /// Defaults to 0. Does nothing if `item` is set. + /// Defaults to 0. Does nothing if @@item is set. Q_PROPERTY(qint32 width MEMBER mWidth NOTIFY widthChanged); - /// Defaults to 0. Does nothing if `item` is set. + /// Defaults to 0. Does nothing if @@item is set. Q_PROPERTY(qint32 height MEMBER mHeight NOTIFY heightChanged); /// Regions to apply on top of this region. diff --git a/src/core/reload.cpp b/src/core/reload.cpp index 4940ddc8..25ab33f3 100644 --- a/src/core/reload.cpp +++ b/src/core/reload.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include "generation.hpp" @@ -12,8 +13,19 @@ void Reloadable::componentComplete() { if (this->engineGeneration != nullptr) { // When called this way there is no chance a reload will have old data, // but this will at least help prevent weird behaviors due to never getting a reload. - if (this->engineGeneration->reloadComplete) this->reload(); - else { + if (this->engineGeneration->reloadComplete) { + // Delayed due to Component.onCompleted running after QQmlParserStatus::componentComplete. + QTimer::singleShot(0, this, &Reloadable::onReloadFinished); + + // This only matters for preventing the above timer from UAFing the generation, + // so it isn't connected anywhere else. + QObject::connect( + this->engineGeneration, + &QObject::destroyed, + this, + &Reloadable::onGenerationDestroyed + ); + } else { QObject::connect( this->engineGeneration, &EngineGeneration::reloadFinished, @@ -40,6 +52,7 @@ void Reloadable::reload(QObject* oldInstance) { } void Reloadable::onReloadFinished() { this->reload(nullptr); } +void Reloadable::onGenerationDestroyed() { this->engineGeneration = nullptr; } void ReloadPropagator::onReload(QObject* oldInstance) { auto* old = qobject_cast(oldInstance); @@ -86,7 +99,7 @@ void Reloadable::reloadRecursive(QObject* newObj, QObject* oldRoot) { // pass handling to the child's onReload, which should call back into reloadRecursive, // with its oldInstance becoming the new oldRoot. - reloadable->onReload(oldInstance); + reloadable->reload(oldInstance); } else if (newObj != nullptr) { Reloadable::reloadChildrenRecursive(newObj, oldRoot); } diff --git a/src/core/reload.hpp b/src/core/reload.hpp index 0d33e2b6..560c8bd0 100644 --- a/src/core/reload.hpp +++ b/src/core/reload.hpp @@ -11,10 +11,7 @@ class EngineGeneration; ///! The base class of all types that can be reloaded. /// Reloadables will attempt to take specific state from previous config revisions if possible. -/// Some examples are [ProxyWindowBase] and [PersistentProperties] -/// -/// [ProxyWindowBase]: ../proxywindowbase -/// [PersistentProperties]: ../persistentproperties +/// Some examples are @@ProxyWindowBase and @@PersistentProperties class Reloadable : public QObject , public QQmlParserStatus { @@ -28,7 +25,7 @@ class Reloadable /// this object in the current revision, and facilitate smoother reloading. /// /// Note that identifiers are scoped, and will try to do the right thing in context. - /// For example if you have a `Variants` wrapping an object with an identified element inside, + /// For example if you have a @@Variants wrapping an object with an identified element inside, /// a scope is created at the variant level. /// /// ```qml @@ -74,6 +71,7 @@ public: private slots: void onReloadFinished(); + void onGenerationDestroyed(); protected: // Called unconditionally in the reload phase, with nullptr if no source could be determined. @@ -86,10 +84,9 @@ private: }; ///! Scope that propagates reloads to child items in order. -/// Convenience type equivalent to setting `reloadableId` on properties in a -/// QtObject instance. +/// Convenience type equivalent to setting @@Reloadable.reloadableId for all children. /// -/// Note that this does not work for visible `Item`s (all widgets). +/// Note that this does not work for visible @@QtQuick.Item$s (all widgets). /// /// ```qml /// ShellRoot { diff --git a/src/core/retainable.cpp b/src/core/retainable.cpp new file mode 100644 index 00000000..4e77e051 --- /dev/null +++ b/src/core/retainable.cpp @@ -0,0 +1,163 @@ +#include "retainable.hpp" + +#include +#include +#include +#include + +RetainableHook* RetainableHook::getHook(QObject* object, bool create) { + auto v = object->property("__qs_retainable"); + + if (v.canConvert()) { + return v.value(); + } else if (create) { + auto* retainable = dynamic_cast(object); + if (!retainable) return nullptr; + + auto* hook = new RetainableHook(object); + hook->retainableFacet = retainable; + retainable->hook = hook; + + object->setProperty("__qs_retainable", QVariant::fromValue(hook)); + + return hook; + } else return nullptr; +} + +RetainableHook* RetainableHook::qmlAttachedProperties(QObject* object) { + return RetainableHook::getHook(object, true); +} + +void RetainableHook::ref() { this->refcount++; } + +void RetainableHook::unref() { + this->refcount--; + if (this->refcount == 0) this->unlocked(); +} + +void RetainableHook::lock() { + this->explicitRefcount++; + this->ref(); +} + +void RetainableHook::unlock() { + if (this->explicitRefcount < 1) { + qWarning() << "Retainable object" << this->parent() + << "unlocked more times than it was locked!"; + } else { + this->explicitRefcount--; + this->unref(); + } +} + +void RetainableHook::forceUnlock() { this->unlocked(); } + +bool RetainableHook::isRetained() const { return !this->inactive; } + +void RetainableHook::unlocked() { + if (this->inactive) return; + + emit this->aboutToDestroy(); + this->retainableFacet->retainFinished(); +} + +void Retainable::retainedDestroy() { + this->retaining = true; + + auto* hook = RetainableHook::getHook(dynamic_cast(this), false); + + if (hook) { + // let all signal handlers run before acting on changes + emit hook->dropped(); + hook->inactive = false; + + if (hook->refcount == 0) hook->unlocked(); + else emit hook->retainedChanged(); + } else { + this->retainFinished(); + } +} + +bool Retainable::isRetained() const { return this->retaining; } + +void Retainable::retainFinished() { + // a normal delete tends to cause deref errors in a listview. + dynamic_cast(this)->deleteLater(); +} + +RetainableLock::~RetainableLock() { + if (this->mEnabled && this->mObject) { + this->hook->unref(); + } +} + +QObject* RetainableLock::object() const { return this->mObject; } + +void RetainableLock::setObject(QObject* object) { + if (object == this->mObject) return; + + if (this->mObject) { + QObject::disconnect(this->mObject, nullptr, this, nullptr); + if (this->hook->isRetained()) emit this->retainedChanged(); + this->hook->unref(); + } + + this->mObject = nullptr; + this->hook = nullptr; + + if (object) { + if (auto* hook = RetainableHook::getHook(object, true)) { + this->mObject = object; + this->hook = hook; + + QObject::connect(object, &QObject::destroyed, this, &RetainableLock::onObjectDestroyed); + QObject::connect(hook, &RetainableHook::dropped, this, &RetainableLock::dropped); + QObject::connect( + hook, + &RetainableHook::aboutToDestroy, + this, + &RetainableLock::aboutToDestroy + ); + QObject::connect( + hook, + &RetainableHook::retainedChanged, + this, + &RetainableLock::retainedChanged + ); + if (hook->isRetained()) emit this->retainedChanged(); + + hook->ref(); + } else { + qCritical() << "Tried to set non retainable object" << object << "as the target of" << this; + } + } + + emit this->objectChanged(); +} + +void RetainableLock::onObjectDestroyed() { + this->mObject = nullptr; + this->hook = nullptr; + + emit this->objectChanged(); +} + +bool RetainableLock::locked() const { return this->mEnabled; } + +void RetainableLock::setLocked(bool locked) { + if (locked == this->mEnabled) return; + + this->mEnabled = locked; + + if (this->mObject) { + if (locked) this->hook->ref(); + else { + if (this->hook->isRetained()) emit this->retainedChanged(); + this->hook->unref(); + } + } + + emit this->lockedChanged(); +} + +bool RetainableLock::isRetained() const { return this->mObject && this->hook->isRetained(); } diff --git a/src/core/retainable.hpp b/src/core/retainable.hpp new file mode 100644 index 00000000..dfe2e794 --- /dev/null +++ b/src/core/retainable.hpp @@ -0,0 +1,162 @@ +#pragma once + +#include +#include +#include +#include + +class Retainable; + +///! Attached object for types that can have delayed destruction. +/// Retainable works as an attached property that allows objects to be +/// kept around (retained) after they would normally be destroyed, which +/// is especially useful for things like exit transitions. +/// +/// An object that is retainable will have @@Retainable as an attached property. +/// All retainable objects will say that they are retainable on their respective +/// typeinfo pages. +/// +/// > [!INFO] Working directly with @@Retainable is often overly complicated and +/// > error prone. For this reason @@RetainableLock should +/// > usually be used instead. +class RetainableHook: public QObject { + Q_OBJECT; + /// If the object is currently in a retained state. + Q_PROPERTY(bool retained READ isRetained NOTIFY retainedChanged); + QML_ATTACHED(RetainableHook); + QML_NAMED_ELEMENT(Retainable); + QML_UNCREATABLE("Retainable can only be used as an attached object."); + +public: + static RetainableHook* getHook(QObject* object, bool create = false); + + void destroyOnRelease(); + + void ref(); + void unref(); + + /// Hold a lock on the object so it cannot be destroyed. + /// + /// A counter is used to ensure you can lock the object from multiple places + /// and it will not be unlocked until the same number of unlocks as locks have occurred. + /// + /// > [!WARNING] It is easy to forget to unlock a locked object. + /// > Doing so will create what is effectively a memory leak. + /// > + /// > Using @@RetainableLock is recommended as it will help + /// > avoid this scenario and make misuse more obvious. + Q_INVOKABLE void lock(); + /// Remove a lock on the object. See @@lock() for more information. + Q_INVOKABLE void unlock(); + /// Forcibly remove all locks, destroying the object. + /// + /// @@unlock() should usually be preferred. + Q_INVOKABLE void forceUnlock(); + + [[nodiscard]] bool isRetained() const; + + static RetainableHook* qmlAttachedProperties(QObject* object); + +signals: + /// This signal is sent when the object would normally be destroyed. + /// + /// If all signal handlers return and no locks are in place, the object will be destroyed. + /// If at least one lock is present the object will be retained until all are removed. + void dropped(); + /// This signal is sent immediately before the object is destroyed. + /// At this point destruction cannot be interrupted. + void aboutToDestroy(); + + void retainedChanged(); + +private: + explicit RetainableHook(QObject* parent): QObject(parent) {} + + void unlocked(); + + uint refcount = 0; + // tracked separately so a warning can be given when unlock is called too many times, + // without affecting other lock sources such as RetainableLock. + uint explicitRefcount = 0; + Retainable* retainableFacet = nullptr; + bool inactive = true; + + friend class Retainable; +}; + +class Retainable { +public: + Retainable() = default; + virtual ~Retainable() = default; + Q_DISABLE_COPY_MOVE(Retainable); + + void retainedDestroy(); + [[nodiscard]] bool isRetained() const; + +protected: + virtual void retainFinished(); + +private: + RetainableHook* hook = nullptr; + bool retaining = false; + + friend class RetainableHook; +}; + +///! A helper for easily using Retainable. +/// A RetainableLock provides extra safety and ease of use for locking +/// @@Retainable objects. A retainable object can be locked by multiple +/// locks at once, and each lock re-exposes relevant properties +/// of the retained objects. +/// +/// #### Example +/// The code below will keep a retainable object alive for as long as the +/// RetainableLock exists. +/// +/// ```qml +/// RetainableLock { +/// object: aRetainableObject +/// locked: true +/// } +/// ``` +class RetainableLock: public QObject { + Q_OBJECT; + /// The object to lock. Must be @@Retainable. + Q_PROPERTY(QObject* object READ object WRITE setObject NOTIFY objectChanged); + /// If the object should be locked. + Q_PROPERTY(bool locked READ locked WRITE setLocked NOTIFY lockedChanged); + /// If the object is currently in a retained state. + Q_PROPERTY(bool retained READ isRetained NOTIFY retainedChanged); + QML_ELEMENT; + +public: + explicit RetainableLock(QObject* parent = nullptr): QObject(parent) {} + ~RetainableLock() override; + Q_DISABLE_COPY_MOVE(RetainableLock); + + [[nodiscard]] QObject* object() const; + void setObject(QObject* object); + + [[nodiscard]] bool locked() const; + void setLocked(bool locked); + + [[nodiscard]] bool isRetained() const; + +signals: + /// Rebroadcast of the object's @@Retainable.dropped(s). + void dropped(); + /// Rebroadcast of the object's @@Retainable.aboutToDestroy(s). + void aboutToDestroy(); + void retainedChanged(); + + void objectChanged(); + void lockedChanged(); + +private slots: + void onObjectDestroyed(); + +private: + QObject* mObject = nullptr; + RetainableHook* hook = nullptr; + bool mEnabled = false; +}; diff --git a/src/core/ringbuf.hpp b/src/core/ringbuf.hpp new file mode 100644 index 00000000..a50a56da --- /dev/null +++ b/src/core/ringbuf.hpp @@ -0,0 +1,169 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include + +// NOLINTBEGIN(cppcoreguidelines-pro-bounds-pointer-arithmetic) + +// capacity 0 buffer cannot be inserted into, only replaced with = +// this is NOT exception safe for constructors +template +class RingBuffer { +public: + explicit RingBuffer() = default; + explicit RingBuffer(qsizetype capacity): mCapacity(capacity) { + if (capacity > 0) this->createData(); + } + + ~RingBuffer() { this->deleteData(); } + + Q_DISABLE_COPY(RingBuffer); + + explicit RingBuffer(RingBuffer&& other) noexcept { *this = std::move(other); } + + RingBuffer& operator=(RingBuffer&& other) noexcept { + this->deleteData(); + this->data = other.data; + this->head = other.head; + this->mSize = other.mSize; + this->mCapacity = other.mCapacity; + other.data = nullptr; + other.head = -1; + return *this; + } + + // undefined if capacity is 0 + template + T& emplace(Args&&... args) { + auto i = (this->head + 1) % this->mCapacity; + + if (this->indexIsAllocated(i)) { + this->data[i].~T(); + } + + auto* slot = &this->data[i]; + new (&this->data[i]) T(std::forward(args)...); + + this->head = i; + if (this->mSize != this->mCapacity) this->mSize = i + 1; + + return *slot; + } + + void clear() { + if (this->head == -1) return; + + auto i = this->head; + + do { + i = (i + 1) % this->mSize; + this->data[i].~T(); + } while (i != this->head); + + this->mSize = 0; + this->head = -1; + } + + // negative indexes and >size indexes are undefined + [[nodiscard]] T& at(qsizetype i) { + auto bufferI = (this->head - i) % this->mCapacity; + if (bufferI < 0) bufferI += this->mCapacity; + return this->data[bufferI]; + } + + [[nodiscard]] const T& at(qsizetype i) const { + return const_cast*>(this)->at(i); // NOLINT + } + + [[nodiscard]] qsizetype size() const { return this->mSize; } + [[nodiscard]] qsizetype capacity() const { return this->mCapacity; } + +private: + void createData() { + if (this->data != nullptr) return; + this->data = + static_cast(::operator new(this->mCapacity * sizeof(T), std::align_val_t {alignof(T)})); + } + + void deleteData() { + this->clear(); + ::operator delete(this->data, std::align_val_t {alignof(T)}); + this->data = nullptr; + } + + bool indexIsAllocated(qsizetype index) { + return this->mSize == this->mCapacity || index <= this->head; + } + + T* data = nullptr; + qsizetype mCapacity = 0; + qsizetype head = -1; + qsizetype mSize = 0; +}; + +// ring buffer with the ability to look up elements by hash (single bucket) +template +class HashBuffer { +public: + explicit HashBuffer() = default; + explicit HashBuffer(qsizetype capacity): ring(capacity) {} + ~HashBuffer() = default; + + Q_DISABLE_COPY(HashBuffer); + explicit HashBuffer(HashBuffer&& other) noexcept: ring(other.ring) {} + + HashBuffer& operator=(HashBuffer&& other) noexcept { + this->ring = other.ring; + return *this; + } + + // returns the index of the given value or -1 if missing + [[nodiscard]] qsizetype indexOf(const T& value, T** slot = nullptr) { + auto hash = qHash(value); + + for (auto i = 0; i < this->size(); i++) { + auto& v = this->ring.at(i); + if (hash == v.first && value == v.second) { + if (slot != nullptr) *slot = &v.second; + return i; + } + } + + return -1; + } + + [[nodiscard]] qsizetype indexOf(const T& value, T const** slot = nullptr) const { + return const_cast*>(this)->indexOf(value, slot); // NOLINT + } + + template + T& emplace(Args&&... args) { + auto& entry = this->ring.emplace( + std::piecewise_construct, + std::forward_as_tuple(0), + std::forward_as_tuple(std::forward(args)...) + ); + + entry.first = qHash(entry.second); + return entry.second; + } + + void clear() { this->ring.clear(); } + + // negative indexes and >size indexes are undefined + [[nodiscard]] T& at(qsizetype i) { return this->ring.at(i).second; } + [[nodiscard]] const T& at(qsizetype i) const { return this->ring.at(i).second; } + [[nodiscard]] qsizetype size() const { return this->ring.size(); } + [[nodiscard]] qsizetype capacity() const { return this->ring.capacity(); } + +private: + RingBuffer> ring; +}; + +// NOLINTEND(cppcoreguidelines-pro-bounds-pointer-arithmetic) diff --git a/src/core/rootwrapper.cpp b/src/core/rootwrapper.cpp index ed2ef4b7..b394af58 100644 --- a/src/core/rootwrapper.cpp +++ b/src/core/rootwrapper.cpp @@ -8,16 +8,19 @@ #include #include #include +#include +#include #include +#include "../window/floatingwindow.hpp" #include "generation.hpp" #include "qmlglobal.hpp" #include "scan.hpp" -#include "shell.hpp" -RootWrapper::RootWrapper(QString rootPath) +RootWrapper::RootWrapper(QString rootPath, QString shellId) : QObject(nullptr) , rootPath(std::move(rootPath)) + , shellId(std::move(shellId)) , originalWorkingDirectory(QDir::current().absolutePath()) { // clang-format off QObject::connect(QuickshellSettings::instance(), &QuickshellSettings::watchFilesChanged, this, &RootWrapper::onWatchFilesChanged); @@ -34,18 +37,16 @@ RootWrapper::RootWrapper(QString rootPath) RootWrapper::~RootWrapper() { // event loop may no longer be running so deleteLater is not an option if (this->generation != nullptr) { - delete this->generation->root; - this->generation->root = nullptr; + this->generation->shutdown(); } - - delete this->generation; } void RootWrapper::reloadGraph(bool hard) { - auto scanner = QmlScanner(); + auto rootPath = QFileInfo(this->rootPath).dir(); + auto scanner = QmlScanner(rootPath); scanner.scanQmlFile(this->rootPath); - auto* generation = new EngineGeneration(std::move(scanner)); + auto* generation = new EngineGeneration(rootPath, std::move(scanner)); generation->wrapper = this; // todo: move into EngineGeneration @@ -60,33 +61,49 @@ void RootWrapper::reloadGraph(bool hard) { url.setScheme("qsintercept"); auto component = QQmlComponent(generation->engine, url); - auto* obj = component.beginCreate(generation->engine->rootContext()); + auto* newRoot = component.beginCreate(generation->engine->rootContext()); + + if (newRoot == nullptr) { + const QString error = "failed to create root component\n" + component.errorString(); + qWarning().noquote() << error; + generation->destroy(); + + if (this->generation != nullptr && this->generation->qsgInstance != nullptr) { + emit this->generation->qsgInstance->reloadFailed(error); + } - if (obj == nullptr) { - qWarning() << component.errorString().toStdString().c_str(); - qWarning() << "failed to create root component"; - delete generation; return; } - auto* newRoot = qobject_cast(obj); - if (newRoot == nullptr) { - qWarning() << "root component was not a Quickshell.ShellRoot"; - delete obj; - delete generation; - return; + if (auto* item = qobject_cast(newRoot)) { + auto* window = new FloatingWindowInterface(); + item->setParent(window); + item->setParentItem(window->contentItem()); + window->setWidth(static_cast(item->width())); + window->setHeight(static_cast(item->height())); + newRoot = window; } generation->root = newRoot; component.completeCreate(); + if (this->generation) { + QObject::disconnect(this->generation, nullptr, this, nullptr); + } + + auto isReload = this->generation != nullptr; generation->onReload(hard ? nullptr : this->generation); - if (hard) delete this->generation; + + if (hard && this->generation) { + this->generation->destroy(); + } + this->generation = generation; qInfo() << "Configuration Loaded"; + QObject::connect(this->generation, &QObject::destroyed, this, &RootWrapper::generationDestroyed); QObject::connect( this->generation, &EngineGeneration::filesChanged, @@ -95,8 +112,14 @@ void RootWrapper::reloadGraph(bool hard) { ); this->onWatchFilesChanged(); + + if (isReload && this->generation->qsgInstance != nullptr) { + emit this->generation->qsgInstance->reloadCompleted(); + } } +void RootWrapper::generationDestroyed() { this->generation = nullptr; } + void RootWrapper::onWatchFilesChanged() { auto watchFiles = QuickshellSettings::instance()->watchFiles(); if (this->generation != nullptr) { diff --git a/src/core/rootwrapper.hpp b/src/core/rootwrapper.hpp index 7958ee5c..02d7a143 100644 --- a/src/core/rootwrapper.hpp +++ b/src/core/rootwrapper.hpp @@ -12,18 +12,20 @@ class RootWrapper: public QObject { Q_OBJECT; public: - explicit RootWrapper(QString rootPath); + explicit RootWrapper(QString rootPath, QString shellId); ~RootWrapper() override; Q_DISABLE_COPY_MOVE(RootWrapper); void reloadGraph(bool hard); private slots: + void generationDestroyed(); void onWatchFilesChanged(); void onWatchedFilesChanged(); private: QString rootPath; + QString shellId; EngineGeneration* generation = nullptr; QString originalWorkingDirectory; }; diff --git a/src/core/scan.cpp b/src/core/scan.cpp index f5f078aa..59ec05b6 100644 --- a/src/core/scan.cpp +++ b/src/core/scan.cpp @@ -103,7 +103,15 @@ bool QmlScanner::scanQmlFile(const QString& path) { this->scanDir(currentdir.path()); for (auto& import: imports) { - auto ipath = currentdir.filePath(import); + QString ipath; + if (import.startsWith("root:")) { + auto path = import.sliced(5); + if (path.startsWith('/')) path = path.sliced(1); + ipath = this->rootPath.filePath(path); + } else { + ipath = currentdir.filePath(import); + } + auto cpath = QFileInfo(ipath).canonicalFilePath(); if (cpath.isEmpty()) { diff --git a/src/core/scan.hpp b/src/core/scan.hpp index 32a6166d..e3071a88 100644 --- a/src/core/scan.hpp +++ b/src/core/scan.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -10,6 +11,8 @@ Q_DECLARE_LOGGING_CATEGORY(logQmlScanner); // expects canonical paths class QmlScanner { public: + QmlScanner(const QDir& rootPath): rootPath(rootPath) {} + void scanDir(const QString& path); // returns if the file has a singleton bool scanQmlFile(const QString& path); @@ -17,4 +20,7 @@ public: QVector scannedDirs; QVector scannedFiles; QHash qmldirIntercepts; + +private: + QDir rootPath; }; diff --git a/src/core/scriptmodel.cpp b/src/core/scriptmodel.cpp new file mode 100644 index 00000000..259587c1 --- /dev/null +++ b/src/core/scriptmodel.cpp @@ -0,0 +1,133 @@ +#include "scriptmodel.hpp" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +void ScriptModel::updateValuesUnique(const QVariantList& newValues) { + this->mValues.reserve(newValues.size()); + + auto iter = this->mValues.begin(); + auto newIter = newValues.begin(); + + while (true) { + if (newIter == newValues.end()) { + if (iter == this->mValues.end()) break; + + auto startIndex = static_cast(newValues.length()); + auto endIndex = static_cast(this->mValues.length() - 1); + + this->beginRemoveRows(QModelIndex(), startIndex, endIndex); + this->mValues.erase(iter, this->mValues.end()); + this->endRemoveRows(); + + break; + } else if (iter == this->mValues.end()) { + // Prior branch ensures length is at least 1. + auto startIndex = static_cast(this->mValues.length()); + auto endIndex = static_cast(newValues.length() - 1); + + this->beginInsertRows(QModelIndex(), startIndex, endIndex); + this->mValues.append(newValues.sliced(startIndex)); + this->endInsertRows(); + + break; + } else if (*newIter != *iter) { + auto oldIter = std::find(iter, this->mValues.end(), *newIter); + + if (oldIter != this->mValues.end()) { + if (std::find(newIter, newValues.end(), *iter) == newValues.end()) { + // Remove any entries we would otherwise move around that aren't in the new list. + auto startIter = iter; + + do { + ++iter; + } while (iter != this->mValues.end() + && std::find(newIter, newValues.end(), *iter) == newValues.end()); + + auto index = static_cast(std::distance(this->mValues.begin(), iter)); + auto startIndex = static_cast(std::distance(this->mValues.begin(), startIter)); + + this->beginRemoveRows(QModelIndex(), startIndex, index - 1); + iter = this->mValues.erase(startIter, iter); + this->endRemoveRows(); + } else { + // Advance iters to capture a whole move sequence as a single operation if possible. + auto oldStartIter = oldIter; + do { + ++oldIter; + ++newIter; + } while (oldIter != this->mValues.end() && newIter != newValues.end() + && *oldIter == *newIter); + + auto index = static_cast(std::distance(this->mValues.begin(), iter)); + auto oldStartIndex = + static_cast(std::distance(this->mValues.begin(), oldStartIter)); + auto oldIndex = static_cast(std::distance(this->mValues.begin(), oldIter)); + auto len = oldIndex - oldStartIndex; + + this->beginMoveRows(QModelIndex(), oldStartIndex, oldIndex - 1, QModelIndex(), index); + + // While it is possible to optimize this further, it is currently not worth the time. + for (auto i = 0; i != len; i++) { + this->mValues.move(oldStartIndex + i, index + i); + } + + iter = this->mValues.begin() + (index + len); + this->endMoveRows(); + } + } else { + auto startNewIter = newIter; + + do { + newIter++; + } while (newIter != newValues.end() + && std::find(iter, this->mValues.end(), *newIter) == this->mValues.end()); + + auto index = static_cast(std::distance(this->mValues.begin(), iter)); + auto newIndex = static_cast(std::distance(newValues.begin(), newIter)); + auto startNewIndex = static_cast(std::distance(newValues.begin(), startNewIter)); + auto len = newIndex - startNewIndex; + + this->beginInsertRows(QModelIndex(), index, index + len - 1); +#if QT_VERSION <= QT_VERSION_CHECK(6, 8, 0) + this->mValues.resize(this->mValues.length() + len); +#else + this->mValues.resizeForOverwrite(this->mValues.length() + len); +#endif + iter = this->mValues.begin() + index; // invalidated + std::move_backward(iter, this->mValues.end() - len, this->mValues.end()); + iter = std::copy(startNewIter, newIter, iter); + this->endInsertRows(); + } + } else { + ++iter; + ++newIter; + } + } +} + +void ScriptModel::setValues(const QVariantList& newValues) { + if (newValues == this->mValues) return; + this->updateValuesUnique(newValues); + emit this->valuesChanged(); +} + +qint32 ScriptModel::rowCount(const QModelIndex& parent) const { + if (parent != QModelIndex()) return 0; + return static_cast(this->mValues.length()); +} + +QVariant ScriptModel::data(const QModelIndex& index, qint32 role) const { + if (role != Qt::UserRole) return QVariant(); + return this->mValues.at(index.row()); +} + +QHash ScriptModel::roleNames() const { return {{Qt::UserRole, "modelData"}}; } diff --git a/src/core/scriptmodel.hpp b/src/core/scriptmodel.hpp new file mode 100644 index 00000000..b57456b3 --- /dev/null +++ b/src/core/scriptmodel.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include +#include +#include + +///! QML model reflecting a javascript expression +/// ScriptModel is a QML [Data Model] that generates model operations based on changes +/// to a javascript expression attached to @@values. +/// +/// ### When should I use this +/// ScriptModel should be used when you would otherwise use a javascript expression as a model, +/// [QAbstractItemModel] is accepted, and the data is likely to change over the lifetime of the program. +/// +/// When directly using a javascript expression as a model, types like @@QtQuick.Repeater or @@QtQuick.ListView +/// will destroy all created delegates, and re-create the entire list. In the case of @@QtQuick.ListView this +/// will also prevent animations from working. If you wrap your expression with ScriptModel, only new items +/// will be created, and ListView animations will work as expected. +/// +/// ### Example +/// ```qml +/// // Will cause all delegates to be re-created every time filterText changes. +/// @@QtQuick.Repeater { +/// model: myList.filter(entry => entry.name.startsWith(filterText)) +/// delegate: // ... +/// } +/// +/// // Will add and remove delegates only when required. +/// @@QtQuick.Repeater { +/// model: ScriptModel { +/// values: myList.filter(entry => entry.name.startsWith(filterText)) +/// } +/// +/// delegate: // ... +/// } +/// ``` +class ScriptModel: public QAbstractListModel { + Q_OBJECT; + /// The list of values to reflect in the model. + /// > [!WARNING] ScriptModel currently only works with lists of *unique* values. + /// > There must not be any duplicates in the given list, or behavior of the model is undefined. + /// + /// > [!TIP] @@ObjectModel$s supplied by Quickshell types will only contain unique values, + /// > and can be used like so: + /// > + /// > ```qml + /// > ScriptModel { + /// > values: DesktopEntries.applications.values.filter(...) + /// > } + /// > ``` + /// > + /// > Note that we are using @@DesktopEntries.values because it will cause @@ScriptModel.values + /// > to receive an update on change. + Q_PROPERTY(QVariantList values READ values WRITE setValues NOTIFY valuesChanged); + QML_ELEMENT; + +public: + [[nodiscard]] const QVariantList& values() const { return this->mValues; } + void setValues(const QVariantList& newValues); + + [[nodiscard]] qint32 rowCount(const QModelIndex& parent) const override; + [[nodiscard]] QVariant data(const QModelIndex& index, qint32 role) const override; + [[nodiscard]] QHash roleNames() const override; + +signals: + void valuesChanged(); + +private: + QVariantList mValues; + + void updateValuesUnique(const QVariantList& newValues); +}; diff --git a/src/core/shell.hpp b/src/core/shell.hpp index 807f0275..66b6ef6a 100644 --- a/src/core/shell.hpp +++ b/src/core/shell.hpp @@ -8,7 +8,7 @@ #include "qmlglobal.hpp" #include "reload.hpp" -///! Root config element +///! Optional root config element, allowing some settings to be specified inline. class ShellRoot: public ReloadPropagator { Q_OBJECT; Q_PROPERTY(QuickshellSettings* settings READ settings CONSTANT); diff --git a/src/core/test/CMakeLists.txt b/src/core/test/CMakeLists.txt index d0191ee9..d38c2868 100644 --- a/src/core/test/CMakeLists.txt +++ b/src/core/test/CMakeLists.txt @@ -1,8 +1,9 @@ function (qs_test name) add_executable(${name} ${ARGN}) - target_link_libraries(${name} PRIVATE ${QT_DEPS} Qt6::Test quickshell-core) + target_link_libraries(${name} PRIVATE Qt::Quick Qt::Test quickshell-core quickshell-window) add_test(NAME ${name} WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" COMMAND $) endfunction() -qs_test(popupwindow popupwindow.cpp) qs_test(transformwatcher transformwatcher.cpp) +qs_test(ringbuffer ringbuf.cpp) +qs_test(scriptmodel scriptmodel.cpp) diff --git a/src/core/test/popupwindow.hpp b/src/core/test/popupwindow.hpp index bebc5154..e69de29b 100644 --- a/src/core/test/popupwindow.hpp +++ b/src/core/test/popupwindow.hpp @@ -1,18 +0,0 @@ -#pragma once - -#include -#include - -class TestPopupWindow: public QObject { - Q_OBJECT; - -private slots: - void initiallyVisible(); - void reloadReparent(); - void reloadUnparent(); - void invisibleWithoutParent(); - void moveWithParent(); - void attachParentLate(); - void reparentLate(); - void xMigrationFix(); -}; diff --git a/src/core/test/ringbuf.cpp b/src/core/test/ringbuf.cpp new file mode 100644 index 00000000..4f114796 --- /dev/null +++ b/src/core/test/ringbuf.cpp @@ -0,0 +1,125 @@ +#include "ringbuf.hpp" +#include + +#include +#include +#include +#include + +#include "../ringbuf.hpp" + +TestObject::TestObject(quint32* count): count(count) { + (*this->count)++; + qDebug() << "Created TestObject" << this << "- count is now" << *this->count; +} + +TestObject::~TestObject() { + (*this->count)--; + qDebug() << "Destroyed TestObject" << this << "- count is now" << *this->count; +} + +void TestRingBuffer::fill() { + quint32 counter = 0; + auto rb = RingBuffer(3); + QCOMPARE(rb.capacity(), 3); + + qInfo() << "adding test objects"; + auto* n1 = &rb.emplace(&counter); + auto* n2 = &rb.emplace(&counter); + auto* n3 = &rb.emplace(&counter); + QCOMPARE(counter, 3); + QCOMPARE(rb.size(), 3); + QCOMPARE(&rb.at(0), n3); + QCOMPARE(&rb.at(1), n2); + QCOMPARE(&rb.at(2), n1); + + qInfo() << "replacing last object with new one"; + auto* n4 = &rb.emplace(&counter); + QCOMPARE(counter, 3); + QCOMPARE(rb.size(), 3); + QCOMPARE(&rb.at(0), n4); + QCOMPARE(&rb.at(1), n3); + QCOMPARE(&rb.at(2), n2); + + qInfo() << "replacing the rest"; + auto* n5 = &rb.emplace(&counter); + auto* n6 = &rb.emplace(&counter); + QCOMPARE(counter, 3); + QCOMPARE(rb.size(), 3); + QCOMPARE(&rb.at(0), n6); + QCOMPARE(&rb.at(1), n5); + QCOMPARE(&rb.at(2), n4); + + qInfo() << "clearing buffer"; + rb.clear(); + QCOMPARE(counter, 0); + QCOMPARE(rb.size(), 0); +} + +void TestRingBuffer::clearPartial() { + quint32 counter = 0; + auto rb = RingBuffer(2); + + qInfo() << "adding object to buffer"; + auto* n1 = &rb.emplace(&counter); + QCOMPARE(counter, 1); + QCOMPARE(rb.size(), 1); + QCOMPARE(&rb.at(0), n1); + + qInfo() << "clearing buffer"; + rb.clear(); + QCOMPARE(counter, 0); + QCOMPARE(rb.size(), 0); +} + +void TestRingBuffer::move() { + quint32 counter = 0; + + { + auto rb1 = RingBuffer(1); + + qInfo() << "adding object to first buffer"; + auto* n1 = &rb1.emplace(&counter); + QCOMPARE(counter, 1); + QCOMPARE(rb1.size(), 1); + QCOMPARE(&rb1.at(0), n1); + + qInfo() << "move constructing new buffer"; + auto rb2 = RingBuffer(std::move(rb1)); + QCOMPARE(counter, 1); + QCOMPARE(rb2.size(), 1); + QCOMPARE(&rb2.at(0), n1); + + qInfo() << "move assigning new buffer"; + auto rb3 = RingBuffer(); + rb3 = std::move(rb2); + QCOMPARE(counter, 1); + QCOMPARE(rb3.size(), 1); + QCOMPARE(&rb3.at(0), n1); + } + + QCOMPARE(counter, 0); +} + +void TestRingBuffer::hashLookup() { + auto hb = HashBuffer(3); + + qInfo() << "inserting 1,2,3 into HashBuffer"; + hb.emplace(1); + hb.emplace(2); + hb.emplace(3); + + qInfo() << "checking lookups"; + QCOMPARE(hb.indexOf(3), 0); + QCOMPARE(hb.indexOf(2), 1); + QCOMPARE(hb.indexOf(1), 2); + + qInfo() << "adding 4"; + hb.emplace(4); + QCOMPARE(hb.indexOf(4), 0); + QCOMPARE(hb.indexOf(3), 1); + QCOMPARE(hb.indexOf(2), 2); + QCOMPARE(hb.indexOf(1), -1); +} + +QTEST_MAIN(TestRingBuffer); diff --git a/src/core/test/ringbuf.hpp b/src/core/test/ringbuf.hpp new file mode 100644 index 00000000..1413031c --- /dev/null +++ b/src/core/test/ringbuf.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include +#include + +class TestObject { +public: + explicit TestObject(quint32* count); + ~TestObject(); + Q_DISABLE_COPY_MOVE(TestObject); + +private: + quint32* count; +}; + +class TestRingBuffer: public QObject { + Q_OBJECT; + +private slots: + static void fill(); + static void clearPartial(); + static void move(); + + static void hashLookup(); +}; diff --git a/src/core/test/scriptmodel.cpp b/src/core/test/scriptmodel.cpp new file mode 100644 index 00000000..bdf9c709 --- /dev/null +++ b/src/core/test/scriptmodel.cpp @@ -0,0 +1,179 @@ +#include "scriptmodel.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../scriptmodel.hpp" + +using OpList = QList; + +bool ModelOperation::operator==(const ModelOperation& other) const { + return other.operation == this->operation && other.index == this->index + && other.length == this->length && other.destIndex == this->destIndex; +} + +QDebug& operator<<(QDebug& debug, const ModelOperation& op) { + auto saver = QDebugStateSaver(debug); + debug.nospace(); + + switch (op.operation) { + case ModelOperation::Insert: debug << "Insert"; break; + case ModelOperation::Remove: debug << "Remove"; break; + case ModelOperation::Move: debug << "Move"; break; + } + + debug << "(i: " << op.index << ", l: " << op.length; + + if (op.destIndex != -1) { + debug << ", d: " << op.destIndex; + } + + debug << ')'; + + return debug; +} + +QDebug& operator<<(QDebug& debug, const QVariantList& list) { + auto str = QString(); + + for (const auto& var: list) { + if (var.canConvert()) { + str += var.value(); + } else { + qFatal() << "QVariantList debug overridden in test"; + } + } + + debug << str; + return debug; +} + +void TestScriptModel::unique_data() { + QTest::addColumn("oldstr"); + QTest::addColumn("newstr"); + QTest::addColumn("operations"); + + QTest::addRow("append") << "ABCD" << "ABCDEFG" << OpList({{ModelOperation::Insert, 4, 3}}); + + QTest::addRow("prepend") << "EFG" << "ABCDEFG" << OpList({{ModelOperation::Insert, 0, 4}}); + + QTest::addRow("insert") << "ABFG" << "ABCDEFG" << OpList({{ModelOperation::Insert, 2, 3}}); + + QTest::addRow("chop") << "ABCDEFG" << "ABCD" << OpList({{ModelOperation::Remove, 4, 3}}); + + QTest::addRow("slice") << "ABCDEFG" << "DEFG" << OpList({{ModelOperation::Remove, 0, 3}}); + + QTest::addRow("remove_mid") << "ABCDEFG" << "ABFG" << OpList({{ModelOperation::Remove, 2, 3}}); + + QTest::addRow("move_single") << "ABCDEFG" << "AFBCDEG" + << OpList({{ModelOperation::Move, 5, 1, 1}}); + + QTest::addRow("move_range") << "ABCDEFG" << "ADEFBCG" + << OpList({{ModelOperation::Move, 3, 3, 1}}); + + // beginning to end is the same operation + QTest::addRow("move_end_to_beginning") + << "ABCDEFG" << "EFGABCD" << OpList({{ModelOperation::Move, 4, 3, 0}}); + + QTest::addRow("move_overlapping") + << "ABCDEFG" << "ABDEFCG" << OpList({{ModelOperation::Move, 3, 3, 2}}); + + // Ensure iterators arent skipping anything at the end of operations by performing + // multiple back to back. + + QTest::addRow("insert_state_ok") << "ABCDEFG" << "ABXXEFG" + << OpList({ + {ModelOperation::Insert, 2, 2}, // ABXXCDEFG + {ModelOperation::Remove, 4, 2}, // ABXXEFG + }); + + QTest::addRow("remove_state_ok") << "ABCDEFG" << "ABFGE" + << OpList({ + {ModelOperation::Remove, 2, 2}, // ABEFG + {ModelOperation::Move, 3, 2, 2}, // ABFGE + }); + + QTest::addRow("move_state_ok") << "ABCDEFG" << "ABEFXYCDG" + << OpList({ + {ModelOperation::Move, 4, 2, 2}, // ABEFCDG + {ModelOperation::Insert, 4, 2}, // ABEFXYCDG + }); +} + +void TestScriptModel::unique() { + QFETCH(const QString, oldstr); + QFETCH(const QString, newstr); + QFETCH(OpList, operations); + + auto strToVariantList = [](const QString& str) -> QVariantList { + QVariantList list; + + for (auto c: str) { + list.emplace_back(c); + } + + return list; + }; + + auto oldlist = strToVariantList(oldstr); + auto newlist = strToVariantList(newstr); + + auto model = ScriptModel(); + auto modelTester = QAbstractItemModelTester(&model); + + OpList actualOperations; + + auto onInsert = [&](const QModelIndex& parent, int first, int last) { + QCOMPARE(parent, QModelIndex()); + actualOperations << ModelOperation(ModelOperation::Insert, first, last - first + 1); + }; + + auto onRemove = [&](const QModelIndex& parent, int first, int last) { + QCOMPARE(parent, QModelIndex()); + actualOperations << ModelOperation(ModelOperation::Remove, first, last - first + 1); + }; + + auto onMove = [&](const QModelIndex& sourceParent, + int sourceStart, + int sourceEnd, + const QModelIndex& destParent, + int destStart) { + QCOMPARE(sourceParent, QModelIndex()); + QCOMPARE(destParent, QModelIndex()); + actualOperations << ModelOperation( + ModelOperation::Move, + sourceStart, + sourceEnd - sourceStart + 1, + destStart + ); + }; + + QObject::connect(&model, &QAbstractItemModel::rowsInserted, &model, onInsert); + QObject::connect(&model, &QAbstractItemModel::rowsRemoved, &model, onRemove); + QObject::connect(&model, &QAbstractItemModel::rowsMoved, &model, onMove); + + model.setValues(oldlist); + QCOMPARE_EQ(model.values(), oldlist); + QCOMPARE_EQ( + actualOperations, + OpList({{ModelOperation::Insert, 0, static_cast(oldlist.length())}}) + ); + + actualOperations.clear(); + + model.setValues(newlist); + QCOMPARE_EQ(model.values(), newlist); + QCOMPARE_EQ(actualOperations, operations); +} + +QTEST_MAIN(TestScriptModel); diff --git a/src/core/test/scriptmodel.hpp b/src/core/test/scriptmodel.hpp new file mode 100644 index 00000000..3b50b328 --- /dev/null +++ b/src/core/test/scriptmodel.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include + +struct ModelOperation { + enum Enum : quint8 { + Insert, + Remove, + Move, + }; + + ModelOperation(Enum operation, qint32 index, qint32 length, qint32 destIndex = -1) + : operation(operation) + , index(index) + , length(length) + , destIndex(destIndex) {} + + Enum operation; + qint32 index = 0; + qint32 length = 0; + qint32 destIndex = -1; + + [[nodiscard]] bool operator==(const ModelOperation& other) const; +}; + +QDebug& operator<<(QDebug& debug, const ModelOperation& op); + +class TestScriptModel: public QObject { + Q_OBJECT; + +private slots: + static void unique_data(); // NOLINT + static void unique(); +}; diff --git a/src/core/transformwatcher.cpp b/src/core/transformwatcher.cpp index 697dfc56..6fc7c34a 100644 --- a/src/core/transformwatcher.cpp +++ b/src/core/transformwatcher.cpp @@ -7,6 +7,7 @@ #include #include #include +#include void TransformWatcher::resolveChains(QQuickItem* a, QQuickItem* b, QQuickItem* commonParent) { if (a == nullptr || b == nullptr) return; @@ -82,7 +83,10 @@ void TransformWatcher::linkItem(QQuickItem* item) const { QObject::connect(item, &QQuickItem::parentChanged, this, &TransformWatcher::recalcChains); QObject::connect(item, &QQuickItem::windowChanged, this, &TransformWatcher::recalcChains); - QObject::connect(item, &QObject::destroyed, this, &TransformWatcher::recalcChains); + + if (item != this->mA && item != this->mB) { + QObject::connect(item, &QObject::destroyed, this, &TransformWatcher::itemDestroyed); + } } void TransformWatcher::linkChains() { @@ -103,6 +107,18 @@ void TransformWatcher::unlinkChains() { for (auto* item: this->childChain) { QObject::disconnect(item, nullptr, this, nullptr); } + + // relink a and b destruction notifications + if (this->mA != nullptr) { + QObject::connect(this->mA, &QObject::destroyed, this, &TransformWatcher::aDestroyed); + } + + if (this->mB != nullptr) { + QObject::connect(this->mB, &QObject::destroyed, this, &TransformWatcher::bDestroyed); + } + + this->parentChain.clear(); + this->childChain.clear(); } void TransformWatcher::recalcChains() { @@ -111,26 +127,57 @@ void TransformWatcher::recalcChains() { this->linkChains(); } +void TransformWatcher::itemDestroyed() { + auto destroyed = + this->parentChain.removeOne(this->sender()) || this->childChain.removeOne(this->sender()); + + if (destroyed) this->recalcChains(); +} + QQuickItem* TransformWatcher::a() const { return this->mA; } void TransformWatcher::setA(QQuickItem* a) { if (this->mA == a) return; + if (this->mA != nullptr) QObject::disconnect(this->mA, nullptr, this, nullptr); this->mA = a; + + if (this->mA != nullptr) { + QObject::connect(this->mA, &QObject::destroyed, this, &TransformWatcher::aDestroyed); + } + this->recalcChains(); } +void TransformWatcher::aDestroyed() { + this->mA = nullptr; + this->unlinkChains(); + emit this->aChanged(); +} + QQuickItem* TransformWatcher::b() const { return this->mB; } void TransformWatcher::setB(QQuickItem* b) { if (this->mB == b) return; + if (this->mB != nullptr) QObject::disconnect(this->mB, nullptr, this, nullptr); this->mB = b; + + if (this->mB != nullptr) { + QObject::connect(this->mB, &QObject::destroyed, this, &TransformWatcher::bDestroyed); + } + this->recalcChains(); } +void TransformWatcher::bDestroyed() { + this->mB = nullptr; + this->unlinkChains(); + emit this->bChanged(); +} + QQuickItem* TransformWatcher::commonParent() const { return this->mCommonParent; } void TransformWatcher::setCommonParent(QQuickItem* commonParent) { if (this->mCommonParent == commonParent) return; this->mCommonParent = commonParent; - this->resolveChains(); + this->recalcChains(); } diff --git a/src/core/transformwatcher.hpp b/src/core/transformwatcher.hpp index d7174e4c..8efa9399 100644 --- a/src/core/transformwatcher.hpp +++ b/src/core/transformwatcher.hpp @@ -13,7 +13,7 @@ class TestTransformWatcher; ///! Monitor of all geometry changes between two objects. /// The TransformWatcher monitors all properties that affect the geometry -/// of two `Item`s relative to eachother. +/// of two @@QtQuick.Item$s relative to eachother. /// /// > [!INFO] The algorithm responsible for determining the relationship /// > between `a` and `b` is biased towards `a` being a parent of `b`, @@ -60,6 +60,9 @@ signals: private slots: void recalcChains(); + void itemDestroyed(); + void aDestroyed(); + void bDestroyed(); private: void resolveChains(QQuickItem* a, QQuickItem* b, QQuickItem* commonParent); diff --git a/src/core/types.cpp b/src/core/types.cpp new file mode 100644 index 00000000..5ed63a02 --- /dev/null +++ b/src/core/types.cpp @@ -0,0 +1,23 @@ +#include "types.hpp" + +#include +#include +#include + +QRect Box::qrect() const { return {this->x, this->y, this->w, this->h}; } + +bool Box::operator==(const Box& other) const { + return this->x == other.x && this->y == other.y && this->w == other.w && this->h == other.h; +} + +QDebug operator<<(QDebug debug, const Box& box) { + auto saver = QDebugStateSaver(debug); + debug.nospace() << "Box(" << box.x << ',' << box.y << ' ' << box.w << 'x' << box.h << ')'; + return debug; +} + +Qt::Edges Edges::toQt(Edges::Flags edges) { return Qt::Edges(edges.toInt()); } + +bool Edges::isOpposing(Edges::Flags edges) { + return edges.testFlags(Edges::Top | Edges::Bottom) || edges.testFlags(Edges::Left | Edges::Right); +} diff --git a/src/core/types.hpp b/src/core/types.hpp new file mode 100644 index 00000000..0adc85c0 --- /dev/null +++ b/src/core/types.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class Box { + Q_GADGET; + Q_PROPERTY(qint32 x MEMBER x); + Q_PROPERTY(qint32 y MEMBER y); + Q_PROPERTY(qint32 w MEMBER w); + Q_PROPERTY(qint32 h MEMBER h); + Q_PROPERTY(qint32 width MEMBER w); + Q_PROPERTY(qint32 height MEMBER h); + QML_CONSTRUCTIBLE_VALUE; + QML_VALUE_TYPE(box); + +public: + explicit Box() = default; + Box(qint32 x, qint32 y, qint32 w, qint32 h): x(x), y(y), w(w), h(h) {} + + Q_INVOKABLE Box(const QRect& rect): x(rect.x()), y(rect.y()), w(rect.width()), h(rect.height()) {} + Q_INVOKABLE Box(const QPoint& rect): x(rect.x()), y(rect.y()) {} + + Q_INVOKABLE Box(const QRectF& rect) + : x(static_cast(rect.x())) + , y(static_cast(rect.y())) + , w(static_cast(rect.width())) + , h(static_cast(rect.height())) {} + + Q_INVOKABLE Box(const QPointF& rect) + : x(static_cast(rect.x())) + , y(static_cast(rect.y())) {} + + bool operator==(const Box& other) const; + + qint32 x = 0; + qint32 y = 0; + qint32 w = 0; + qint32 h = 0; + + [[nodiscard]] QRect qrect() const; +}; + +QDebug operator<<(QDebug debug, const Box& box); + +///! Top Left Right Bottom flags. +/// Edge flags can be combined with the `|` operator. +namespace Edges { // NOLINT +Q_NAMESPACE; +QML_NAMED_ELEMENT(Edges); + +enum Enum : quint8 { + None = 0, + Top = Qt::TopEdge, + Left = Qt::LeftEdge, + Right = Qt::RightEdge, + Bottom = Qt::BottomEdge, +}; +Q_ENUM_NS(Enum); +Q_DECLARE_FLAGS(Flags, Enum); + +Qt::Edges toQt(Flags edges); +bool isOpposing(Flags edges); + +}; // namespace Edges + +Q_DECLARE_OPERATORS_FOR_FLAGS(Edges::Flags); diff --git a/src/core/util.hpp b/src/core/util.hpp new file mode 100644 index 00000000..719c9201 --- /dev/null +++ b/src/core/util.hpp @@ -0,0 +1,313 @@ +#pragma once +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +template +struct StringLiteral { + constexpr StringLiteral(const char (&str)[length]) { // NOLINT + std::copy_n(str, length, this->value); + } + + constexpr operator const char*() const noexcept { return this->value; } + operator QLatin1StringView() const { return QLatin1String(this->value, length); } + + char value[length]; // NOLINT +}; + +template +struct StringLiteral16 { + constexpr StringLiteral16(const char16_t (&str)[length]) { // NOLINT + std::copy_n(str, length, this->value); + } + + [[nodiscard]] constexpr const QChar* qCharPtr() const noexcept { + return std::bit_cast(&this->value); + } + + [[nodiscard]] Q_ALWAYS_INLINE operator QString() const noexcept { + return QString::fromRawData(this->qCharPtr(), static_cast(length - 1)); + } + + [[nodiscard]] Q_ALWAYS_INLINE operator QStringView() const noexcept { + return QStringView(this->qCharPtr(), static_cast(length - 1)); + } + + char16_t value[length]; // NOLINT +}; + +// NOLINTBEGIN +#define DROP_EMIT(object, func) \ + DropEmitter(object, static_cast([](typeof(object) o) { o->func(); })) + +#define DROP_EMIT_IF(cond, object, func) (cond) ? DROP_EMIT(object, func) : DropEmitter() + +#define DEFINE_DROP_EMIT_IF(cond, object, func) DropEmitter func = DROP_EMIT_IF(cond, object, func) + +#define DROP_EMIT_SET(object, local, member, signal) \ + auto signal = DropEmitter(); \ + if (local == object->member) { \ + object->member = local; \ + signal = DROP_EMIT(object, signal); \ + } +// NOLINTEND + +class DropEmitter { +public: + Q_DISABLE_COPY(DropEmitter); + template + DropEmitter(O* object, void (*signal)(O*)) + : object(object) + , signal(*reinterpret_cast(signal)) {} + + DropEmitter() = default; + + DropEmitter(DropEmitter&& other) noexcept: object(other.object), signal(other.signal) { + other.object = nullptr; + } + + ~DropEmitter() { this->call(); } + + DropEmitter& operator=(DropEmitter&& other) noexcept { + this->object = other.object; + this->signal = other.signal; + other.object = nullptr; + return *this; + } + + explicit operator bool() const noexcept { return this->object; } + + void call() { + if (!this->object) return; + this->signal(this->object); + this->object = nullptr; + } + + // orders calls for multiple emitters (instead of reverse definition order) + template + static void call(Args&... args) { + (args.call(), ...); + } + +private: + void* object = nullptr; + void (*signal)(void*) = nullptr; +}; + +// NOLINTBEGIN +#define DECLARE_MEMBER(class, name, member, signal) \ + using M_##name = MemberMetadata<&class ::member, &class ::signal> + +#define DECLARE_MEMBER_NS(class, name, member) using M_##name = MemberMetadata<&class ::member> + +#define DECLARE_MEMBER_GET(name) [[nodiscard]] M_##name::Ref name() const +#define DECLARE_MEMBER_SET(name, setter) M_##name::Ret setter(M_##name::Ref value) + +#define DECLARE_MEMBER_GETSET(name, setter) \ + DECLARE_MEMBER_GET(name); \ + DECLARE_MEMBER_SET(name, setter) + +#define DECLARE_MEMBER_SETONLY(class, name, setter, member, signal) DECLARE_MEMBER(cl + +#define DECLARE_MEMBER_FULL(class, name, setter, member, signal) \ + DECLARE_MEMBER(class, name, member, signal); \ + DECLARE_MEMBER_GETSET(name, setter) + +#define DECLARE_MEMBER_WITH_GET(class, name, member, signal) \ + DECLARE_MEMBER(class, name, member, signal); \ + \ +public: \ + DECLARE_MEMBER_GET(name); \ + \ +private: + +#define DECLARE_PRIVATE_MEMBER(class, name, setter, member, signal) \ + DECLARE_MEMBER_WITH_GET(class, name, member, signal); \ + DECLARE_MEMBER_SET(name, setter); + +#define DECLARE_PMEMBER(type, name) using M_##name = PseudomemberMetadata; +#define DECLARE_PMEMBER_NS(type, name) using M_##name = PseudomemberMetadata; + +#define DECLARE_PMEMBER_FULL(type, name, setter) \ + DECLARE_PMEMBER(type, name); \ + DECLARE_MEMBER_GETSET(name, setter) + +#define DECLARE_PMEMBER_WITH_GET(type, name) \ + DECLARE_PMEMBER(type, name); \ + \ +public: \ + DECLARE_MEMBER_GET(name); \ + \ +private: + +#define DECLARE_PRIVATE_PMEMBER(type, name, setter) \ + DECLARE_PMEMBER_WITH_GET(type, name); \ + DECLARE_MEMBER_SET(name, setter); + +#define DEFINE_PMEMBER_GET_M(Class, Member, name) Member::Ref Class::name() const +#define DEFINE_PMEMBER_GET(Class, name) DEFINE_PMEMBER_GET_M(Class, Class::M_##name, name) + +#define DEFINE_MEMBER_GET_M(Class, Member, name) \ + DEFINE_PMEMBER_GET_M(Class, Member, name) { return Member::get(this); } + +#define DEFINE_MEMBER_GET(Class, name) DEFINE_MEMBER_GET_M(Class, Class::M_##name, name) + +#define DEFINE_MEMBER_SET_M(Class, Member, setter) \ + Member::Ret Class::setter(Member::Ref value) { return Member::set(this, value); } + +#define DEFINE_MEMBER_SET(Class, name, setter) DEFINE_MEMBER_SET_M(Class, Class::M_##name, setter) + +#define DEFINE_MEMBER_GETSET(Class, name, setter) \ + DEFINE_MEMBER_GET(Class, name) \ + DEFINE_MEMBER_SET(Class, name, setter) + +#define MEMBER_EMIT(name) std::remove_reference_t::M_##name::emitter(this) +// NOLINTEND + +template +struct MemberPointerTraits; + +template +struct MemberPointerTraits { + using Class = C; + using Type = T; +}; + +template +class MemberMetadata { + using Traits = MemberPointerTraits; + using Class = Traits::Class; + +public: + using Type = Traits::Type; + using Ref = const Type&; + using Ret = std::conditional_t, void, DropEmitter>; + + static Ref get(const Class* obj) { return obj->*member; } + + static Ret set(Class* obj, Ref value) { + if constexpr (signal == nullptr) { + if (MemberMetadata::get(obj) == value) return; + obj->*member = value; + } else { + if (MemberMetadata::get(obj) == value) return DropEmitter(); + obj->*member = value; + return MemberMetadata::emitter(obj); + } + } + + static Ret emitter(Class* obj) { + if constexpr (signal != nullptr) { + return DropEmitter(obj, &MemberMetadata::emitForObject); + } + } + +private: + static void emitForObject(Class* obj) { (obj->*signal)(); } +}; + +// allows use of member macros without an actual field backing them +template +class PseudomemberMetadata { +public: + using Type = T; + using Ref = const Type&; + using Ret = std::conditional_t; +}; + +class GuardedEmitBlocker { +public: + explicit GuardedEmitBlocker(bool* var): var(var) { *this->var = true; } + ~GuardedEmitBlocker() { *this->var = false; } + Q_DISABLE_COPY_MOVE(GuardedEmitBlocker); + +private: + bool* var; +}; + +template +class GuardedEmitter { + using Traits = MemberPointerTraits; + using Class = Traits::Class; + + bool blocked = false; + +public: + GuardedEmitter() = default; + ~GuardedEmitter() = default; + Q_DISABLE_COPY_MOVE(GuardedEmitter); + + void call(Class* obj) { + if (!this->blocked) (obj->*signal)(); + } + + GuardedEmitBlocker block() { return GuardedEmitBlocker(&this->blocked); } +}; + +template +class SimpleObjectHandleOps { + using Traits = MemberPointerTraits; + +public: + static bool setObject(Traits::Class* parent, Traits::Type value) { + if (value == parent->*member) return false; + + if (parent->*member != nullptr) { + QObject::disconnect(parent->*member, &QObject::destroyed, parent, destroyedSlot); + } + + parent->*member = value; + + if (value != nullptr) { + QObject::connect(parent->*member, &QObject::destroyed, parent, destroyedSlot); + } + + if constexpr (changedSignal != nullptr) { + emit(parent->*changedSignal)(); + } + + return true; + } +}; + +template +bool setSimpleObjectHandle(auto* parent, auto* value) { + return SimpleObjectHandleOps::setObject(parent, value); +} + +// NOLINTBEGIN +#define QS_TRIVIAL_GETTER(Type, member, getter) \ + [[nodiscard]] Type getter() { return this->member; } + +#define QS_BINDABLE_GETTER(Type, member, getter, bindable) \ + [[nodiscard]] Type getter() { return this->member.value(); } \ + [[nodiscard]] QBindable bindable() { return &this->member; } +// NOLINTEND + +template +class MethodFunctor { + using PtrMeta = MemberPointerTraits; + using Class = PtrMeta::Class; + +public: + MethodFunctor(Class* obj): obj(obj) {} + + void operator()() { (this->obj->*methodPtr)(); } + +private: + Class* obj; +}; + +// NOLINTBEGIN +#define QS_BINDING_SUBSCRIBE_METHOD(Class, bindable, method, strategy) \ + QPropertyChangeHandler> \ + _qs_method_subscribe_##bindable##_##method = \ + (bindable).strategy(MethodFunctor<&Class::method>(this)); +// NOLINTEND diff --git a/src/core/variants.hpp b/src/core/variants.hpp index a9071cf8..ebf87ae1 100644 --- a/src/core/variants.hpp +++ b/src/core/variants.hpp @@ -28,26 +28,23 @@ public: ///! Creates instances of a component based on a given model. /// Creates and destroys instances of the given component when the given property changes. /// -/// `Variants` is similar to [Repeater] except it is for *non Item* objects, and acts as +/// `Variants` is similar to @@QtQuick.Repeater except it is for *non @@QtQuick.Item$* objects, and acts as /// a reload scope. /// -/// Each non duplicate value passed to [model](#prop.model) will create a new instance of -/// [delegate](#prop.delegate) with its `modelData` property set to that value. +/// Each non duplicate value passed to @@model will create a new instance of +/// @@delegate with a `modelData` property set to that value. /// -/// See [Quickshell.screens] for an example of using `Variants` to create copies of a window per +/// See @@Quickshell.screens for an example of using `Variants` to create copies of a window per /// screen. /// /// > [!WARNING] BUG: Variants currently fails to reload children if the variant set is changed as /// > it is instantiated. (usually due to a mutation during variant creation) -/// -/// [Repeater]: https://doc.qt.io/qt-6/qml-qtquick-repeater.html -/// [Quickshell.screens]: ../quickshell#prop.screens class Variants: public Reloadable { Q_OBJECT; /// The component to create instances of. /// /// The delegate should define a `modelData` property that will be popuplated with a value - /// from the [model](#prop.model). + /// from the @@model. Q_PROPERTY(QQmlComponent* delegate MEMBER mDelegate); /// The list of sets of properties to create instances with. /// Each set creates an instance of the component, which are updated when the input sets update. diff --git a/src/core/windowinterface.cpp b/src/core/windowinterface.cpp deleted file mode 100644 index a29bd599..00000000 --- a/src/core/windowinterface.cpp +++ /dev/null @@ -1 +0,0 @@ -#include "windowinterface.hpp" // NOLINT diff --git a/src/crash/CMakeLists.txt b/src/crash/CMakeLists.txt new file mode 100644 index 00000000..7fdd8305 --- /dev/null +++ b/src/crash/CMakeLists.txt @@ -0,0 +1,17 @@ +qt_add_library(quickshell-crash STATIC + main.cpp + interface.cpp + handler.cpp +) + +qs_pch(quickshell-crash SET large) + +find_package(PkgConfig REQUIRED) +pkg_check_modules(breakpad REQUIRED IMPORTED_TARGET breakpad) +# only need client?? take only includes from pkg config todo +target_link_libraries(quickshell-crash PRIVATE PkgConfig::breakpad -lbreakpad_client) + +# quick linked for pch compat +target_link_libraries(quickshell-crash PRIVATE quickshell-build Qt::Quick Qt::Widgets) + +target_link_libraries(quickshell PRIVATE quickshell-crash) diff --git a/src/crash/handler.cpp b/src/crash/handler.cpp new file mode 100644 index 00000000..1f300cc9 --- /dev/null +++ b/src/crash/handler.cpp @@ -0,0 +1,182 @@ +#include "handler.hpp" +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/instanceinfo.hpp" + +extern char** environ; // NOLINT + +using namespace google_breakpad; + +namespace qs::crash { + +Q_LOGGING_CATEGORY(logCrashHandler, "quickshell.crashhandler", QtWarningMsg); + +struct CrashHandlerPrivate { + ExceptionHandler* exceptionHandler = nullptr; + int minidumpFd = -1; + int infoFd = -1; + + static bool minidumpCallback(const MinidumpDescriptor& descriptor, void* context, bool succeeded); +}; + +CrashHandler::CrashHandler(): d(new CrashHandlerPrivate()) {} + +void CrashHandler::init() { + // MinidumpDescriptor has no move constructor and the copy constructor breaks fds. + auto createHandler = [this](const MinidumpDescriptor& desc) { + this->d->exceptionHandler = new ExceptionHandler( + desc, + nullptr, + &CrashHandlerPrivate::minidumpCallback, + this->d, + true, + -1 + ); + }; + + qCDebug(logCrashHandler) << "Starting crash handler..."; + + this->d->minidumpFd = memfd_create("quickshell:minidump", MFD_CLOEXEC); + + if (this->d->minidumpFd == -1) { + qCCritical(logCrashHandler + ) << "Failed to allocate minidump memfd, minidumps will be saved in the working directory."; + createHandler(MinidumpDescriptor(".")); + } else { + qCDebug(logCrashHandler) << "Created memfd" << this->d->minidumpFd + << "for holding possible minidumps."; + createHandler(MinidumpDescriptor(this->d->minidumpFd)); + } + + qCInfo(logCrashHandler) << "Crash handler initialized."; +} + +void CrashHandler::setRelaunchInfo(const RelaunchInfo& info) { + this->d->infoFd = memfd_create("quickshell:instance_info", MFD_CLOEXEC); + + if (this->d->infoFd == -1) { + qCCritical(logCrashHandler + ) << "Failed to allocate instance info memfd, crash recovery will not work."; + return; + } + + QFile file; + file.open(this->d->infoFd, QFile::ReadWrite); + + QDataStream ds(&file); + ds << info; + file.flush(); + + qCDebug(logCrashHandler) << "Stored instance info in memfd" << this->d->infoFd; +} + +CrashHandler::~CrashHandler() { + delete this->d->exceptionHandler; + delete this->d; +} + +bool CrashHandlerPrivate::minidumpCallback( + const MinidumpDescriptor& /*descriptor*/, + void* context, + bool /*success*/ +) { + // A fork that just dies to ensure the coredump is caught by the system. + auto coredumpPid = fork(); + + if (coredumpPid == 0) { + return false; + } + + auto* self = static_cast(context); + + auto exe = std::array(); + if (readlink("/proc/self/exe", exe.data(), exe.size() - 1) == -1) { + perror("Failed to find crash reporter executable.\n"); + _exit(-1); + } + + auto arg = std::array {exe.data(), nullptr}; + + auto env = std::array(); + auto envi = 0; + + auto infoFd = dup(self->infoFd); + auto infoFdStr = std::array(); + memcpy(infoFdStr.data(), "__QUICKSHELL_CRASH_INFO_FD=-1" /*\0*/, 30); + if (infoFd != -1) my_uitos(&infoFdStr[27], infoFd, 10); + env[envi++] = infoFdStr.data(); + + auto corePidStr = std::array(); + memcpy(corePidStr.data(), "__QUICKSHELL_CRASH_DUMP_PID=-1" /*\0*/, 31); + if (coredumpPid != -1) my_uitos(&corePidStr[28], coredumpPid, 10); + env[envi++] = corePidStr.data(); + + auto populateEnv = [&]() { + auto senvi = 0; + while (envi != 4095) { + auto var = environ[senvi++]; // NOLINT + if (var == nullptr) break; + env[envi++] = var; + } + + env[envi] = nullptr; + }; + + sigset_t sigset; + sigemptyset(&sigset); // NOLINT (include) + sigprocmask(SIG_SETMASK, &sigset, nullptr); // NOLINT + + auto pid = fork(); + + if (pid == -1) { + perror("Failed to fork and launch crash reporter.\n"); + return false; + } else if (pid == 0) { + // dup to remove CLOEXEC + // if already -1 will return -1 + auto dumpFd = dup(self->minidumpFd); + auto logFd = dup(CrashInfo::INSTANCE.logFd); + + // allow up to 10 digits, which should never happen + auto dumpFdStr = std::array(); + auto logFdStr = std::array(); + + memcpy(dumpFdStr.data(), "__QUICKSHELL_CRASH_DUMP_FD=-1" /*\0*/, 30); + memcpy(logFdStr.data(), "__QUICKSHELL_CRASH_LOG_FD=-1" /*\0*/, 29); + + if (dumpFd != -1) my_uitos(&dumpFdStr[27], dumpFd, 10); + if (logFd != -1) my_uitos(&logFdStr[26], logFd, 10); + + env[envi++] = dumpFdStr.data(); + env[envi++] = logFdStr.data(); + + populateEnv(); + execve(exe.data(), arg.data(), env.data()); + + perror("Failed to launch crash reporter.\n"); + _exit(-1); + } else { + populateEnv(); + execve(exe.data(), arg.data(), env.data()); + + perror("Failed to relaunch quickshell.\n"); + _exit(-1); + } + + return false; // should make sure it hits the system coredump handler +} + +} // namespace qs::crash diff --git a/src/crash/handler.hpp b/src/crash/handler.hpp new file mode 100644 index 00000000..2a1d86fa --- /dev/null +++ b/src/crash/handler.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include "../core/instanceinfo.hpp" +namespace qs::crash { + +struct CrashHandlerPrivate; + +class CrashHandler { +public: + explicit CrashHandler(); + ~CrashHandler(); + Q_DISABLE_COPY_MOVE(CrashHandler); + + void init(); + void setRelaunchInfo(const RelaunchInfo& info); + +private: + CrashHandlerPrivate* d; +}; + +} // namespace qs::crash diff --git a/src/crash/interface.cpp b/src/crash/interface.cpp new file mode 100644 index 00000000..c6334401 --- /dev/null +++ b/src/crash/interface.cpp @@ -0,0 +1,120 @@ +#include "interface.hpp" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "build.hpp" + +class ReportLabel: public QWidget { +public: + ReportLabel(const QString& label, const QString& content, QWidget* parent): QWidget(parent) { + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(new QLabel(label, this)); + + auto* cl = new QLabel(content, this); + cl->setTextInteractionFlags(Qt::TextSelectableByMouse); + layout->addWidget(cl); + + layout->addStretch(); + } +}; + +CrashReporterGui::CrashReporterGui(QString reportFolder, int pid) + : reportFolder(std::move(reportFolder)) { + this->setWindowFlags(Qt::Window); + + auto textHeight = QFontInfo(QFont()).pixelSize(); + + auto* mainLayout = new QVBoxLayout(this); + + auto qtVersionMatches = strcmp(qVersion(), QT_VERSION_STR) == 0; + if (qtVersionMatches) { + mainLayout->addWidget(new QLabel( + "Quickshell has crashed. Please submit a bug report to help us fix it.", + this + )); + } else { + mainLayout->addWidget( + new QLabel("Quickshell has crashed, likely due to a Qt version mismatch.", this) + ); + } + + mainLayout->addSpacing(textHeight); + + mainLayout->addWidget(new QLabel("General information", this)); + mainLayout->addWidget(new ReportLabel("Git Revision:", GIT_REVISION, this)); + mainLayout->addWidget(new QLabel( + QString::fromLatin1("Runtime Qt version: ") % qVersion() % ", Buildtime Qt version: " + % QT_VERSION_STR, + this + )); + mainLayout->addWidget(new ReportLabel("Crashed process ID:", QString::number(pid), this)); + mainLayout->addWidget(new ReportLabel("Crash report folder:", this->reportFolder, this)); + mainLayout->addSpacing(textHeight); + + if (qtVersionMatches) { + mainLayout->addWidget(new QLabel("Please open a bug report for this issue via github or email.") + ); + } else { + mainLayout->addWidget(new QLabel( + "Please rebuild Quickshell against the current Qt version.\n" + "If this does not solve the problem, please open a bug report via github or email." + )); + } + + mainLayout->addWidget(new ReportLabel( + "Github:", + "https://github.com/quickshell-mirror/quickshell/issues/new?template=crash.yml", + this + )); + + mainLayout->addWidget(new ReportLabel("Email:", "quickshell-bugs@outfoxxed.me", this)); + + auto* buttons = new QWidget(this); + buttons->setMinimumWidth(900); + auto* buttonLayout = new QHBoxLayout(buttons); + buttonLayout->setContentsMargins(0, 0, 0, 0); + + auto* reportButton = new QPushButton("Open report page", buttons); + reportButton->setDefault(true); + QObject::connect(reportButton, &QPushButton::clicked, this, &CrashReporterGui::openReportUrl); + buttonLayout->addWidget(reportButton); + + auto* openFolderButton = new QPushButton("Open crash folder", buttons); + QObject::connect(openFolderButton, &QPushButton::clicked, this, &CrashReporterGui::openFolder); + buttonLayout->addWidget(openFolderButton); + + auto* cancelButton = new QPushButton("Exit", buttons); + QObject::connect(cancelButton, &QPushButton::clicked, this, &CrashReporterGui::cancel); + buttonLayout->addWidget(cancelButton); + + mainLayout->addWidget(buttons); + + mainLayout->addStretch(); + this->setFixedSize(this->sizeHint()); +} + +void CrashReporterGui::openFolder() { + QDesktopServices::openUrl(QUrl::fromLocalFile(this->reportFolder)); +} + +void CrashReporterGui::openReportUrl() { + QDesktopServices::openUrl( + QUrl("https://github.com/outfoxxed/quickshell/issues/new?template=crash.yml") + ); +} + +void CrashReporterGui::cancel() { QApplication::quit(); } diff --git a/src/crash/interface.hpp b/src/crash/interface.hpp new file mode 100644 index 00000000..d7800435 --- /dev/null +++ b/src/crash/interface.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include + +class CrashReporterGui: public QWidget { +public: + CrashReporterGui(QString reportFolder, int pid); + +private slots: + void openFolder(); + + static void openReportUrl(); + static void cancel(); + +private: + QString reportFolder; +}; diff --git a/src/crash/main.cpp b/src/crash/main.cpp new file mode 100644 index 00000000..1beb6749 --- /dev/null +++ b/src/crash/main.cpp @@ -0,0 +1,186 @@ +#include "main.hpp" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/instanceinfo.hpp" +#include "../core/logging.hpp" +#include "../core/paths.hpp" +#include "build.hpp" +#include "interface.hpp" + +Q_LOGGING_CATEGORY(logCrashReporter, "quickshell.crashreporter", QtWarningMsg); + +void recordCrashInfo(const QDir& crashDir, const InstanceInfo& instance); + +void qsCheckCrash(int argc, char** argv) { + auto fd = qEnvironmentVariable("__QUICKSHELL_CRASH_DUMP_FD"); + if (fd.isEmpty()) return; + auto app = QApplication(argc, argv); + + RelaunchInfo info; + + auto crashProc = qEnvironmentVariable("__QUICKSHELL_CRASH_DUMP_PID").toInt(); + + { + auto infoFd = qEnvironmentVariable("__QUICKSHELL_CRASH_INFO_FD").toInt(); + + QFile file; + file.open(infoFd, QFile::ReadOnly, QFile::AutoCloseHandle); + file.seek(0); + + auto ds = QDataStream(&file); + ds >> info; + } + + LogManager::init( + !info.noColor, + info.timestamp, + info.sparseLogsOnly, + info.defaultLogLevel, + info.logRules + ); + + auto crashDir = QsPaths::crashDir(info.instance.instanceId); + + qCInfo(logCrashReporter) << "Starting crash reporter..."; + + recordCrashInfo(crashDir, info.instance); + + auto gui = CrashReporterGui(crashDir.path(), crashProc); + gui.show(); + exit(QApplication::exec()); // NOLINT +} + +int tryDup(int fd, const QString& path) { + QFile sourceFile; + if (!sourceFile.open(fd, QFile::ReadOnly, QFile::AutoCloseHandle)) { + qCCritical(logCrashReporter) << "Failed to open source fd for duplication."; + return 1; + } + + auto destFile = QFile(path); + if (!destFile.open(QFile::WriteOnly)) { + qCCritical(logCrashReporter) << "Failed to open dest file for duplication."; + return 2; + } + + auto size = sourceFile.size(); + off_t offset = 0; + ssize_t count = 0; + + sourceFile.seek(0); + + while (count != size) { + auto r = sendfile(destFile.handle(), sourceFile.handle(), &offset, sourceFile.size()); + if (r == -1) { + qCCritical(logCrashReporter).nospace() + << "Failed to duplicate fd " << fd << " with error code " << errno + << ". Error: " << qt_error_string(); + return 3; + } else { + count += r; + } + } + + return 0; +} + +void recordCrashInfo(const QDir& crashDir, const InstanceInfo& instance) { + qCDebug(logCrashReporter) << "Recording crash information at" << crashDir.path(); + + if (!crashDir.mkpath(".")) { + qCCritical(logCrashReporter) << "Failed to create folder" << crashDir + << "to save crash information."; + return; + } + + auto crashProc = qEnvironmentVariable("__QUICKSHELL_CRASH_DUMP_PID").toInt(); + auto dumpFd = qEnvironmentVariable("__QUICKSHELL_CRASH_DUMP_FD").toInt(); + auto logFd = qEnvironmentVariable("__QUICKSHELL_CRASH_LOG_FD").toInt(); + + qCDebug(logCrashReporter) << "Saving minidump from fd" << dumpFd; + auto dumpDupStatus = tryDup(dumpFd, crashDir.filePath("minidump.dmp.log")); + if (dumpDupStatus != 0) { + qCCritical(logCrashReporter) << "Failed to write minidump:" << dumpDupStatus; + } + + qCDebug(logCrashReporter) << "Saving log from fd" << logFd; + auto logDupStatus = tryDup(logFd, crashDir.filePath("log.qslog.log")); + if (logDupStatus != 0) { + qCCritical(logCrashReporter) << "Failed to save log:" << logDupStatus; + } + + auto copyBinStatus = 0; + if (!DISTRIBUTOR_DEBUGINFO_AVAILABLE) { + qCDebug(logCrashReporter) << "Copying binary to crash folder"; + if (!QFile(QCoreApplication::applicationFilePath()).copy(crashDir.filePath("executable.txt"))) { + copyBinStatus = 1; + qCCritical(logCrashReporter) << "Failed to copy binary."; + } + } + + { + auto extraInfoFile = QFile(crashDir.filePath("info.txt")); + if (!extraInfoFile.open(QFile::WriteOnly)) { + qCCritical(logCrashReporter) << "Failed to open crash info file for writing."; + } else { + auto stream = QTextStream(&extraInfoFile); + stream << "===== Build Information =====\n"; + stream << "Git Revision: " << GIT_REVISION << '\n'; + stream << "Buildtime Qt Version: " << QT_VERSION_STR << "\n"; + stream << "Build Type: " << BUILD_TYPE << '\n'; + stream << "Compiler: " << COMPILER << '\n'; + stream << "Complie Flags: " << COMPILE_FLAGS << "\n\n"; + stream << "Build configuration:\n" << BUILD_CONFIGURATION << "\n"; + + stream << "\n===== Runtime Information =====\n"; + stream << "Runtime Qt Version: " << qVersion() << '\n'; + stream << "Crashed process ID: " << crashProc << '\n'; + stream << "Run ID: " << instance.instanceId << '\n'; + stream << "Shell ID: " << instance.shellId << '\n'; + stream << "Config Path: " << instance.configPath << '\n'; + + stream << "\n===== Report Integrity =====\n"; + stream << "Minidump save status: " << dumpDupStatus << '\n'; + stream << "Log save status: " << logDupStatus << '\n'; + stream << "Binary copy status: " << copyBinStatus << '\n'; + + stream << "\n===== System Information =====\n\n"; + stream << "/etc/os-release:"; + auto osReleaseFile = QFile("/etc/os-release"); + if (osReleaseFile.open(QFile::ReadOnly)) { + stream << '\n' << osReleaseFile.readAll() << '\n'; + osReleaseFile.close(); + } else { + stream << "FAILED TO OPEN\n"; + } + + stream << "/etc/lsb-release:"; + auto lsbReleaseFile = QFile("/etc/lsb-release"); + if (lsbReleaseFile.open(QFile::ReadOnly)) { + stream << '\n' << lsbReleaseFile.readAll(); + lsbReleaseFile.close(); + } else { + stream << "FAILED TO OPEN\n"; + } + + extraInfoFile.close(); + } + } + + qCDebug(logCrashReporter) << "Recorded crash information."; +} diff --git a/src/crash/main.hpp b/src/crash/main.hpp new file mode 100644 index 00000000..b6a282cf --- /dev/null +++ b/src/crash/main.hpp @@ -0,0 +1,3 @@ +#pragma once + +void qsCheckCrash(int argc, char** argv); diff --git a/src/dbus/CMakeLists.txt b/src/dbus/CMakeLists.txt index ee6df30a..9948ea74 100644 --- a/src/dbus/CMakeLists.txt +++ b/src/dbus/CMakeLists.txt @@ -9,14 +9,25 @@ qt_add_dbus_interface(DBUS_INTERFACES qt_add_library(quickshell-dbus STATIC properties.cpp + bus.cpp ${DBUS_INTERFACES} ) # dbus headers target_include_directories(quickshell-dbus PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) -target_link_libraries(quickshell-dbus PRIVATE ${QT_DEPS}) +target_link_libraries(quickshell-dbus PRIVATE Qt::Core Qt::DBus) +# todo: link dbus to quickshell here instead of in modules that use it directly +# linker does not like this as is -qs_pch(quickshell-dbus) +qs_add_pchset(dbus + DEPENDENCIES Qt::DBus + HEADERS + + + +) + +qs_pch(quickshell-dbus SET dbus) add_subdirectory(dbusmenu) diff --git a/src/dbus/bus.cpp b/src/dbus/bus.cpp new file mode 100644 index 00000000..6f560e9e --- /dev/null +++ b/src/dbus/bus.cpp @@ -0,0 +1,57 @@ +#include "bus.hpp" // NOLINT +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace qs::dbus { + +Q_LOGGING_CATEGORY(logDbus, "quickshell.dbus", QtWarningMsg); + +void tryLaunchService( + QObject* parent, + QDBusConnection& connection, + const QString& serviceName, + const std::function& callback +) { + qCDebug(logDbus) << "Attempting to launch service" << serviceName; + + auto message = QDBusMessage::createMethodCall( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "StartServiceByName" + ); + + message << serviceName << 0u; + auto pendingCall = connection.asyncCall(message); + + auto* call = new QDBusPendingCallWatcher(pendingCall, parent); + + auto responseCallback = [callback, serviceName](QDBusPendingCallWatcher* call) { + const QDBusPendingReply reply = *call; + + if (reply.isError()) { + qCWarning(logDbus).noquote().nospace() + << "Could not launch service " << serviceName << ": " << reply.error(); + callback(false); + } else { + qCDebug(logDbus) << "Service launch successful for" << serviceName; + callback(true); + } + + delete call; + }; + + QObject::connect(call, &QDBusPendingCallWatcher::finished, parent, responseCallback); +} + +} // namespace qs::dbus diff --git a/src/dbus/bus.hpp b/src/dbus/bus.hpp new file mode 100644 index 00000000..1c4c71e4 --- /dev/null +++ b/src/dbus/bus.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include +#include +#include + +namespace qs::dbus { + +void tryLaunchService( + QObject* parent, + QDBusConnection& connection, + const QString& serviceName, + const std::function& callback +); + +} diff --git a/src/dbus/dbusmenu/CMakeLists.txt b/src/dbus/dbusmenu/CMakeLists.txt index ab222e5a..61cee42c 100644 --- a/src/dbus/dbusmenu/CMakeLists.txt +++ b/src/dbus/dbusmenu/CMakeLists.txt @@ -14,12 +14,22 @@ qt_add_library(quickshell-dbusmenu STATIC ${DBUS_INTERFACES} ) -qt_add_qml_module(quickshell-dbusmenu URI Quickshell.DBusMenu VERSION 0.1) +qt_add_qml_module(quickshell-dbusmenu + URI Quickshell.DBusMenu + VERSION 0.1 + DEPENDENCIES QtQml +) + +qs_add_module_deps_light(quickshell-dbusmenu Quickshell) + +install_qml_module(quickshell-dbusmenu) # dbus headers target_include_directories(quickshell-dbusmenu PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) -target_link_libraries(quickshell-dbusmenu PRIVATE ${QT_DEPS}) +target_link_libraries(quickshell-dbusmenu PRIVATE Qt::Quick Qt::DBus) +qs_add_link_dependencies(quickshell-dbusmenu quickshell-dbus) -qs_pch(quickshell-dbusmenu) -qs_pch(quickshell-dbusmenuplugin) +qs_module_pch(quickshell-dbusmenu SET dbus) + +target_link_libraries(quickshell PRIVATE quickshell-dbusmenuplugin) diff --git a/src/dbus/dbusmenu/dbusmenu.cpp b/src/dbus/dbusmenu/dbusmenu.cpp index 7484d849..e86b580d 100644 --- a/src/dbus/dbusmenu/dbusmenu.cpp +++ b/src/dbus/dbusmenu/dbusmenu.cpp @@ -21,70 +21,63 @@ #include #include "../../core/iconimageprovider.hpp" +#include "../../core/model.hpp" +#include "../../core/qsmenu.hpp" #include "../../dbus/properties.hpp" #include "dbus_menu.h" #include "dbus_menu_types.hpp" Q_LOGGING_CATEGORY(logDbusMenu, "quickshell.dbus.dbusmenu", QtWarningMsg); +using namespace qs::menu; + namespace qs::dbus::dbusmenu { DBusMenuItem::DBusMenuItem(qint32 id, DBusMenu* menu, DBusMenuItem* parentMenu) - : QObject(menu) + : QsMenuEntry(menu) , id(id) , menu(menu) , parentMenu(parentMenu) { - QObject::connect( - &this->menu->iconThemePath, - &AbstractDBusProperty::changed, - this, - &DBusMenuItem::iconChanged - ); + QObject::connect(this, &QsMenuEntry::opened, this, &DBusMenuItem::sendOpened); + QObject::connect(this, &QsMenuEntry::closed, this, &DBusMenuItem::sendClosed); + QObject::connect(this, &QsMenuEntry::triggered, this, &DBusMenuItem::sendTriggered); + + QObject::connect(this->menu, &DBusMenu::iconThemePathChanged, this, &DBusMenuItem::iconChanged); } -void DBusMenuItem::click() { - if (this->displayChildren) { - this->setShowChildren(!this->mShowChildren); - } else { - this->menu->sendEvent(this->id, "clicked"); - } -} - -void DBusMenuItem::hover() const { this->menu->sendEvent(this->id, "hovered"); } +void DBusMenuItem::sendOpened() const { this->menu->sendEvent(this->id, "opened"); } +void DBusMenuItem::sendClosed() const { this->menu->sendEvent(this->id, "closed"); } +void DBusMenuItem::sendTriggered() const { this->menu->sendEvent(this->id, "clicked"); } DBusMenu* DBusMenuItem::menuHandle() const { return this->menu; } -QString DBusMenuItem::label() const { return this->mLabel; } -QString DBusMenuItem::cleanLabel() const { return this->mCleanLabel; } bool DBusMenuItem::enabled() const { return this->mEnabled; } +QString DBusMenuItem::text() const { return this->mCleanLabel; } QString DBusMenuItem::icon() const { if (!this->iconName.isEmpty()) { return IconImageProvider::requestString( this->iconName, - this->menu->iconThemePath.get().join(':') + this->menu->iconThemePath.value().join(':') ); } else if (this->image != nullptr) { return this->image->url(); } else return nullptr; } -ToggleButtonType::Enum DBusMenuItem::toggleType() const { return this->mToggleType; }; +QsMenuButtonType::Enum DBusMenuItem::buttonType() const { return this->mButtonType; }; Qt::CheckState DBusMenuItem::checkState() const { return this->mCheckState; } bool DBusMenuItem::isSeparator() const { return this->mSeparator; } bool DBusMenuItem::isShowingChildren() const { return this->mShowChildren && this->childrenLoaded; } -void DBusMenuItem::setShowChildren(bool showChildren) { +void DBusMenuItem::setShowChildrenRecursive(bool showChildren) { if (showChildren == this->mShowChildren) return; this->mShowChildren = showChildren; this->childrenLoaded = false; if (showChildren) { - this->menu->prepareToShow(this->id, true); + this->menu->prepareToShow(this->id, -1); } else { - this->menu->sendEvent(this->id, "closed"); - emit this->showingChildrenChanged(); - if (!this->mChildren.isEmpty()) { for (auto child: this->mChildren) { this->menu->removeRecursive(child); @@ -96,24 +89,15 @@ void DBusMenuItem::setShowChildren(bool showChildren) { } } -bool DBusMenuItem::hasChildren() const { return this->displayChildren; } - -QQmlListProperty DBusMenuItem::children() { - return QQmlListProperty( - this, - nullptr, - &DBusMenuItem::childrenCount, - &DBusMenuItem::childAt - ); +void DBusMenuItem::updateLayout() const { + if (!this->isShowingChildren()) return; + this->menu->updateLayout(this->id, -1); } -qsizetype DBusMenuItem::childrenCount(QQmlListProperty* property) { - return reinterpret_cast(property->object)->enabledChildren.count(); // NOLINT -} +bool DBusMenuItem::hasChildren() const { return this->displayChildren || this->id == 0; } -DBusMenuItem* DBusMenuItem::childAt(QQmlListProperty* property, qsizetype index) { - auto* item = reinterpret_cast(property->object); // NOLINT - return item->menu->items.value(item->enabledChildren.at(index)); +ObjectModel* DBusMenuItem::children() { + return reinterpret_cast*>(&this->enabledChildren); } void DBusMenuItem::updateProperties(const QVariantMap& properties, const QStringList& removed) { @@ -124,30 +108,30 @@ void DBusMenuItem::updateProperties(const QVariantMap& properties, const QString return; } - auto originalLabel = this->mLabel; + auto originalText = this->mText; //auto originalMnemonic = this->mnemonic; auto originalEnabled = this->mEnabled; auto originalVisible = this->visible; auto originalIconName = this->iconName; auto* originalImage = this->image; auto originalIsSeparator = this->mSeparator; - auto originalToggleType = this->mToggleType; + auto originalButtonType = this->mButtonType; auto originalToggleState = this->mCheckState; auto originalDisplayChildren = this->displayChildren; auto label = properties.value("label"); if (label.canConvert()) { auto text = label.value(); - this->mLabel = text; + this->mText = text; this->mCleanLabel = text; //this->mnemonic = QChar(); - for (auto i = 0; i < this->mLabel.length() - 1;) { - if (this->mLabel.at(i) == '_') { + for (auto i = 0; i < this->mText.length() - 1;) { + if (this->mText.at(i) == '_') { //if (this->mnemonic == QChar()) this->mnemonic = this->mLabel.at(i + 1); - this->mLabel.remove(i, 1); - this->mLabel.insert(i + 1, ""); - this->mLabel.insert(i, ""); + this->mText.remove(i, 1); + this->mText.insert(i + 1, ""); + this->mText.insert(i, ""); i += 8; } else { i++; @@ -160,7 +144,7 @@ void DBusMenuItem::updateProperties(const QVariantMap& properties, const QString } } } else if (removed.isEmpty() || removed.contains("label")) { - this->mLabel = ""; + this->mText = ""; //this->mnemonic = QChar(); } @@ -208,15 +192,15 @@ void DBusMenuItem::updateProperties(const QVariantMap& properties, const QString if (toggleType.canConvert()) { auto toggleTypeStr = toggleType.value(); - if (toggleTypeStr == "") this->mToggleType = ToggleButtonType::None; - else if (toggleTypeStr == "checkmark") this->mToggleType = ToggleButtonType::CheckBox; - else if (toggleTypeStr == "radio") this->mToggleType = ToggleButtonType::RadioButton; + if (toggleTypeStr == "") this->mButtonType = QsMenuButtonType::None; + else if (toggleTypeStr == "checkmark") this->mButtonType = QsMenuButtonType::CheckBox; + else if (toggleTypeStr == "radio") this->mButtonType = QsMenuButtonType::RadioButton; else { qCWarning(logDbusMenu) << "Unrecognized toggle type" << toggleTypeStr << "for" << this; - this->mToggleType = ToggleButtonType::None; + this->mButtonType = QsMenuButtonType::None; } } else if (removed.isEmpty() || removed.contains("toggle-type")) { - this->mToggleType = ToggleButtonType::None; + this->mButtonType = QsMenuButtonType::None; } auto toggleState = properties.value("toggle-state"); @@ -227,7 +211,7 @@ void DBusMenuItem::updateProperties(const QVariantMap& properties, const QString else if (toggleStateInt == 1) this->mCheckState = Qt::Checked; else this->mCheckState = Qt::PartiallyChecked; } else if (removed.isEmpty() || removed.contains("toggle-state")) { - this->mCheckState = Qt::PartiallyChecked; + this->mCheckState = Qt::Unchecked; } auto childrenDisplay = properties.value("children-display"); @@ -245,14 +229,14 @@ void DBusMenuItem::updateProperties(const QVariantMap& properties, const QString this->displayChildren = false; } - if (this->mLabel != originalLabel) emit this->labelChanged(); + if (this->mText != originalText) emit this->textChanged(); //if (this->mnemonic != originalMnemonic) emit this->labelChanged(); if (this->mEnabled != originalEnabled) emit this->enabledChanged(); if (this->visible != originalVisible && this->parentMenu != nullptr) this->parentMenu->onChildrenUpdated(); - if (this->mToggleType != originalToggleType) emit this->toggleTypeChanged(); + if (this->mButtonType != originalButtonType) emit this->buttonTypeChanged(); if (this->mCheckState != originalToggleState) emit this->checkStateChanged(); - if (this->mSeparator != originalIsSeparator) emit this->separatorChanged(); + if (this->mSeparator != originalIsSeparator) emit this->isSeparatorChanged(); if (this->displayChildren != originalDisplayChildren) emit this->hasChildrenChanged(); if (this->iconName != originalIconName || this->image != originalImage) { @@ -263,24 +247,23 @@ void DBusMenuItem::updateProperties(const QVariantMap& properties, const QString emit this->iconChanged(); } - qCDebug(logDbusMenu).nospace() << "Updated properties of " << this << " { label=" << this->mLabel + qCDebug(logDbusMenu).nospace() << "Updated properties of " << this << " { label=" << this->mText << ", enabled=" << this->mEnabled << ", visible=" << this->visible << ", iconName=" << this->iconName << ", iconData=" << this->image << ", separator=" << this->mSeparator - << ", toggleType=" << this->mToggleType + << ", toggleType=" << this->mButtonType << ", toggleState=" << this->mCheckState << ", displayChildren=" << this->displayChildren << " }"; } void DBusMenuItem::onChildrenUpdated() { - this->enabledChildren.clear(); - + QVector children; for (auto child: this->mChildren) { auto* item = this->menu->items.value(child); - if (item->visible) this->enabledChildren.push_back(child); + if (item->visible) children.append(item); } - emit this->childrenChanged(); + this->enabledChildren.diffUpdate(children); } QDebug operator<<(QDebug debug, DBusMenuItem* item) { @@ -291,20 +274,7 @@ QDebug operator<<(QDebug debug, DBusMenuItem* item) { auto saver = QDebugStateSaver(debug); debug.nospace() << "DBusMenuItem(" << static_cast(item) << ", id=" << item->id - << ", label=" << item->mLabel << ", menu=" << item->menu << ")"; - return debug; -} - -QDebug operator<<(QDebug debug, const ToggleButtonType::Enum& toggleType) { - auto saver = QDebugStateSaver(debug); - debug.nospace() << "ToggleType::"; - - switch (toggleType) { - case ToggleButtonType::None: debug << "None"; break; - case ToggleButtonType::CheckBox: debug << "Checkbox"; break; - case ToggleButtonType::RadioButton: debug << "Radiobutton"; break; - } - + << ", label=" << item->mText << ", menu=" << item->menu << ")"; return debug; } @@ -334,19 +304,18 @@ DBusMenu::DBusMenu(const QString& service, const QString& path, QObject* parent) this->properties.updateAllViaGetAll(); } -void DBusMenu::prepareToShow(qint32 item, bool sendOpened) { +void DBusMenu::prepareToShow(qint32 item, qint32 depth) { auto pending = this->interface->AboutToShow(item); auto* call = new QDBusPendingCallWatcher(pending, this); - auto responseCallback = [this, item, sendOpened](QDBusPendingCallWatcher* call) { + auto responseCallback = [this, item, depth](QDBusPendingCallWatcher* call) { const QDBusPendingReply reply = *call; if (reply.isError()) { qCWarning(logDbusMenu) << "Error in AboutToShow, but showing anyway for menu" << item << "of" << this << reply.error(); } - this->updateLayout(item, 1); - if (sendOpened) this->sendEvent(item, "opened"); + this->updateLayout(item, depth); delete call; }; @@ -385,6 +354,7 @@ void DBusMenu::updateLayoutRecursive( // there is an actual nullptr in the map and not no entry if (this->items.contains(layout.id)) { item = new DBusMenuItem(layout.id, this, parent); + item->mShowChildren = parent != nullptr && parent->mShowChildren; this->items.insert(layout.id, item); } } @@ -404,7 +374,7 @@ void DBusMenu::updateLayoutRecursive( [&](const DBusMenuLayout& layout) { return layout.id == *iter; } ); - if (existing == layout.children.end()) { + if (!item->mShowChildren || existing == layout.children.end()) { qCDebug(logDbusMenu) << "Removing missing layout item" << this->items.value(*iter) << "from" << item; this->removeRecursive(*iter); @@ -418,7 +388,7 @@ void DBusMenu::updateLayoutRecursive( for (const auto& child: layout.children) { if (item->mShowChildren && !item->mChildren.contains(child.id)) { qCDebug(logDbusMenu) << "Creating new layout item" << child.id << "in" << item; - item->mChildren.push_back(child.id); + // item->mChildren.push_back(child.id); this->items.insert(child.id, nullptr); childrenChanged = true; } @@ -426,13 +396,22 @@ void DBusMenu::updateLayoutRecursive( this->updateLayoutRecursive(child, item, depth - 1); } - if (childrenChanged) item->onChildrenUpdated(); + if (childrenChanged) { + // reset to preserve order + item->mChildren.clear(); + for (const auto& child: layout.children) { + item->mChildren.push_back(child.id); + } + + item->onChildrenUpdated(); + } } if (item->mShowChildren && !item->childrenLoaded) { item->childrenLoaded = true; - emit item->showingChildrenChanged(); } + + emit item->layoutUpdated(); } void DBusMenu::removeRecursive(qint32 id) { @@ -522,4 +501,74 @@ DBusMenuPngImage::requestImage(const QString& /*unused*/, QSize* size, const QSi return image; } +void DBusMenuHandle::setAddress(const QString& service, const QString& path) { + if (service == this->service && path == this->path) return; + this->service = service; + this->path = path; + this->onMenuPathChanged(); +} + +void DBusMenuHandle::refHandle() { + this->refcount++; + qCDebug(logDbusMenu) << this << "gained a reference. Refcount is now" << this->refcount; + + if (this->refcount == 1 || !this->mMenu) { + this->onMenuPathChanged(); + } else { + // Refresh the layout when opening a menu in case a bad client isn't updating it + // and another ref is open somewhere. + this->mMenu->rootItem.updateLayout(); + } +} + +void DBusMenuHandle::unrefHandle() { + this->refcount--; + qCDebug(logDbusMenu) << this << "lost a reference. Refcount is now" << this->refcount; + + if (this->refcount == 0) { + this->onMenuPathChanged(); + } +} + +void DBusMenuHandle::onMenuPathChanged() { + qCDebug(logDbusMenu) << "Updating" << this << "with refcount" << this->refcount; + + if (this->mMenu) { + // Without this, layout updated can be sent after mMenu is set to null, + // leaving loaded = true while mMenu = nullptr. + QObject::disconnect(&this->mMenu->rootItem, nullptr, this, nullptr); + this->mMenu->deleteLater(); + this->mMenu = nullptr; + this->loaded = false; + emit this->menuChanged(); + } + + if (this->refcount > 0 && !this->service.isEmpty() && !this->path.isEmpty()) { + this->mMenu = new DBusMenu(this->service, this->path); + this->mMenu->setParent(this); + + QObject::connect(&this->mMenu->rootItem, &DBusMenuItem::layoutUpdated, this, [this]() { + QObject::disconnect(&this->mMenu->rootItem, &DBusMenuItem::layoutUpdated, this, nullptr); + this->loaded = true; + emit this->menuChanged(); + }); + + this->mMenu->rootItem.setShowChildrenRecursive(true); + } +} + +QsMenuEntry* DBusMenuHandle::menu() { return this->loaded ? &this->mMenu->rootItem : nullptr; } + +QDebug operator<<(QDebug debug, const DBusMenuHandle* handle) { + if (handle) { + auto saver = QDebugStateSaver(debug); + debug.nospace() << "DBusMenuHandle(" << static_cast(handle) + << ", service=" << handle->service << ", path=" << handle->path << ')'; + } else { + debug << "DBusMenuHandle(nullptr)"; + } + + return debug; +} + } // namespace qs::dbus::dbusmenu diff --git a/src/dbus/dbusmenu/dbusmenu.hpp b/src/dbus/dbusmenu/dbusmenu.hpp index b07919ad..35afa98e 100644 --- a/src/dbus/dbusmenu/dbusmenu.hpp +++ b/src/dbus/dbusmenu/dbusmenu.hpp @@ -13,33 +13,21 @@ #include #include +#include "../../core/doc.hpp" #include "../../core/imageprovider.hpp" +#include "../../core/model.hpp" +#include "../../core/qsmenu.hpp" #include "../properties.hpp" #include "dbus_menu_types.hpp" Q_DECLARE_LOGGING_CATEGORY(logDbusMenu); -namespace ToggleButtonType { // NOLINT -Q_NAMESPACE; -QML_ELEMENT; - -enum Enum { - /// This menu item does not have a checkbox or a radiobutton associated with it. - None = 0, - /// This menu item should draw a checkbox. - CheckBox = 1, - /// This menu item should draw a radiobutton. - RadioButton = 2, -}; -Q_ENUM_NS(Enum); - -} // namespace ToggleButtonType - class DBusMenuInterface; namespace qs::dbus::dbusmenu { -QDebug operator<<(QDebug debug, const ToggleButtonType::Enum& toggleType); +// hack because docgen can't take namespaces in superclasses +using menu::QsMenuEntry; class DBusMenu; class DBusMenuPngImage; @@ -47,113 +35,56 @@ class DBusMenuPngImage; ///! Menu item shared by an external program. /// Menu item shared by an external program via the /// [DBusMenu specification](https://github.com/AyatanaIndicators/libdbusmenu/blob/master/libdbusmenu-glib/dbus-menu.xml). -class DBusMenuItem: public QObject { +class DBusMenuItem: public QsMenuEntry { Q_OBJECT; - // clang-format off /// Handle to the root of this menu. - Q_PROPERTY(DBusMenu* menuHandle READ menuHandle CONSTANT); - /// Text of the menu item, including hotkey markup. - Q_PROPERTY(QString label READ label NOTIFY labelChanged); - /// Text of the menu item without hotkey markup. - Q_PROPERTY(QString cleanLabel READ cleanLabel NOTIFY labelChanged); - /// If the menu item should be shown as enabled. - /// - /// > [!INFO] Disabled menu items are often used as headers in addition - /// > to actual disabled entries. - Q_PROPERTY(bool enabled READ enabled NOTIFY enabledChanged); - /// Url of the menu item's icon or `""` if it doesn't have one. - /// - /// This can be passed to [Image.source](https://doc.qt.io/qt-6/qml-qtquick-image.html#source-prop) - /// as shown below. - /// - /// ```qml - /// Image { - /// source: menuItem.icon - /// // To get the best image quality, set the image source size to the same size - /// // as the rendered image. - /// sourceSize.width: width - /// sourceSize.height: height - /// } - /// ``` - Q_PROPERTY(QString icon READ icon NOTIFY iconChanged); - /// If this menu item has an associated checkbox or radiobutton. - /// - /// > [!INFO] It is the responsibility of the remote application to update the state of - /// > checkboxes and radiobuttons via [checkState](#prop.checkState). - Q_PROPERTY(ToggleButtonType::Enum toggleType READ toggleType NOTIFY toggleTypeChanged); - /// The check state of the checkbox or radiobutton if applicable, as a - /// [Qt.CheckState](https://doc.qt.io/qt-6/qt.html#CheckState-enum). - Q_PROPERTY(Qt::CheckState checkState READ checkState NOTIFY checkStateChanged); - /// If this menu item should be rendered as a separator between other items. - /// - /// No other properties have a meaningful value when `isSeparator` is true. - Q_PROPERTY(bool isSeparator READ isSeparator NOTIFY separatorChanged); - /// If this menu item reveals a submenu containing more items. - /// - /// Any submenu items must be requested by setting [showChildren](#prop.showChildren). - Q_PROPERTY(bool hasChildren READ hasChildren NOTIFY hasChildrenChanged); - /// If submenu entries of this item should be shown. - /// - /// When true, children of this menu item will be exposed via [children](#prop.children). - /// Setting this property will additionally send the `opened` and `closed` events to the - /// process that provided the menu. - Q_PROPERTY(bool showChildren READ isShowingChildren WRITE setShowChildren NOTIFY showingChildrenChanged); - /// Children of this menu item. Only populated when [showChildren](#prop.showChildren) is true. - /// - /// > [!INFO] Use [hasChildren](#prop.hasChildren) to check if this item should reveal a submenu - /// > instead of checking if `children` is empty. - Q_PROPERTY(QQmlListProperty children READ children NOTIFY childrenChanged); - // clang-format on + Q_PROPERTY(qs::dbus::dbusmenu::DBusMenu* menuHandle READ menuHandle CONSTANT); QML_ELEMENT; QML_UNCREATABLE("DBusMenus can only be acquired from a DBusMenuHandle"); public: explicit DBusMenuItem(qint32 id, DBusMenu* menu, DBusMenuItem* parentMenu); - /// Send a `clicked` event to the remote application for this menu item. - Q_INVOKABLE void click(); - - /// Send a `hovered` event to the remote application for this menu item. + /// Refreshes the menu contents. /// - /// Note: we are not aware of any programs that use this in any meaningful way. - Q_INVOKABLE void hover() const; + /// Usually you shouldn't need to call this manually but some applications providing + /// menus do not update them correctly. Call this if menus don't update their state. + /// + /// The @@layoutUpdated(s) signal will be sent when a response is received. + Q_INVOKABLE void updateLayout() const; [[nodiscard]] DBusMenu* menuHandle() const; - [[nodiscard]] QString label() const; - [[nodiscard]] QString cleanLabel() const; - [[nodiscard]] bool enabled() const; - [[nodiscard]] QString icon() const; - [[nodiscard]] ToggleButtonType::Enum toggleType() const; - [[nodiscard]] Qt::CheckState checkState() const; - [[nodiscard]] bool isSeparator() const; - [[nodiscard]] bool hasChildren() const; + + [[nodiscard]] bool isSeparator() const override; + [[nodiscard]] bool enabled() const override; + [[nodiscard]] QString text() const override; + [[nodiscard]] QString icon() const override; + [[nodiscard]] menu::QsMenuButtonType::Enum buttonType() const override; + [[nodiscard]] Qt::CheckState checkState() const override; + [[nodiscard]] bool hasChildren() const override; [[nodiscard]] bool isShowingChildren() const; - void setShowChildren(bool showChildren); + void setShowChildrenRecursive(bool showChildren); - [[nodiscard]] QQmlListProperty children(); + [[nodiscard]] ObjectModel* children() override; void updateProperties(const QVariantMap& properties, const QStringList& removed = {}); void onChildrenUpdated(); qint32 id = 0; - QString mLabel; + QString mText; QVector mChildren; bool mShowChildren = false; bool childrenLoaded = false; DBusMenu* menu = nullptr; signals: - void labelChanged(); - //void mnemonicChanged(); - void enabledChanged(); - void iconChanged(); - void separatorChanged(); - void toggleTypeChanged(); - void checkStateChanged(); - void hasChildrenChanged(); - void showingChildrenChanged(); - void childrenChanged(); + void layoutUpdated(); + +private slots: + void sendOpened() const; + void sendClosed() const; + void sendTriggered() const; private: QString mCleanLabel; @@ -163,14 +94,11 @@ private: bool mSeparator = false; QString iconName; DBusMenuPngImage* image = nullptr; - ToggleButtonType::Enum mToggleType = ToggleButtonType::None; - Qt::CheckState mCheckState = Qt::Checked; + menu::QsMenuButtonType::Enum mButtonType = menu::QsMenuButtonType::None; + Qt::CheckState mCheckState = Qt::Unchecked; bool displayChildren = false; - QVector enabledChildren; + ObjectModel enabledChildren {this}; DBusMenuItem* parentMenu = nullptr; - - static qsizetype childrenCount(QQmlListProperty* property); - static DBusMenuItem* childAt(QQmlListProperty* property, qsizetype index); }; QDebug operator<<(QDebug debug, DBusMenuItem* item); @@ -179,20 +107,22 @@ QDebug operator<<(QDebug debug, DBusMenuItem* item); /// Handle to a menu tree provided by a remote process. class DBusMenu: public QObject { Q_OBJECT; - Q_PROPERTY(DBusMenuItem* menu READ menu CONSTANT); + Q_PROPERTY(qs::dbus::dbusmenu::DBusMenuItem* menu READ menu CONSTANT); QML_NAMED_ELEMENT(DBusMenuHandle); QML_UNCREATABLE("Menu handles cannot be directly created"); public: explicit DBusMenu(const QString& service, const QString& path, QObject* parent = nullptr); - dbus::DBusPropertyGroup properties; - dbus::DBusProperty version {this->properties, "Version"}; - dbus::DBusProperty textDirection {this->properties, "TextDirection"}; - dbus::DBusProperty status {this->properties, "Status"}; - dbus::DBusProperty iconThemePath {this->properties, "IconThemePath"}; + QS_DBUS_BINDABLE_PROPERTY_GROUP(DBusMenu, properties); - void prepareToShow(qint32 item, bool sendOpened); +signals: + QSDOC_HIDE void iconThemePathChanged(); + +public: + Q_OBJECT_BINDABLE_PROPERTY(DBusMenu, QStringList, iconThemePath, &DBusMenu::iconThemePathChanged); + + void prepareToShow(qint32 item, qint32 depth); void updateLayout(qint32 parent, qint32 depth); void removeRecursive(qint32 id); void sendEvent(qint32 item, const QString& event); @@ -212,6 +142,15 @@ private slots: private: void updateLayoutRecursive(const DBusMenuLayout& layout, DBusMenuItem* parent, qint32 depth); + QS_DBUS_PROPERTY_BINDING( + DBusMenu, + pIconThemePath, + iconThemePath, + properties, + "IconThemePath", + false + ); + DBusMenuInterface* interface = nullptr; }; @@ -228,4 +167,31 @@ public: QByteArray data; }; +class DBusMenuHandle; + +QDebug operator<<(QDebug debug, const DBusMenuHandle* handle); + +class DBusMenuHandle: public menu::QsMenuHandle { +public: + explicit DBusMenuHandle(QObject* parent): menu::QsMenuHandle(parent) {} + + void setAddress(const QString& service, const QString& path); + + void refHandle() override; + void unrefHandle() override; + + [[nodiscard]] QsMenuEntry* menu() override; + +private: + void onMenuPathChanged(); + + QString service; + QString path; + DBusMenu* mMenu = nullptr; + bool loaded = false; + quint32 refcount = 0; + + friend QDebug operator<<(QDebug debug, const DBusMenuHandle* handle); +}; + } // namespace qs::dbus::dbusmenu diff --git a/src/dbus/properties.cpp b/src/dbus/properties.cpp index 1e5e0bd1..a9ef2d27 100644 --- a/src/dbus/properties.cpp +++ b/src/dbus/properties.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #include #include @@ -38,7 +37,7 @@ QDBusError demarshallVariant(const QVariant& variant, const QMetaType& type, voi if (variant.metaType() == type) { if (type.id() == QMetaType::QVariant) { - *reinterpret_cast(slot) = variant; // NOLINT + *reinterpret_cast(slot) = variant; } else { type.destruct(slot); type.construct(slot, variant.constData()); @@ -111,60 +110,7 @@ void asyncReadPropertyInternal( QObject::connect(call, &QDBusPendingCallWatcher::finished, &interface, responseCallback); } -void AbstractDBusProperty::tryUpdate(const QVariant& variant) { - auto error = this->read(variant); - if (error.isValid()) { - qCWarning(logDbusProperties).noquote() - << "Error demarshalling property update for" << this->toString(); - qCWarning(logDbusProperties) << error; - } else { - qCDebug(logDbusProperties).noquote() - << "Updated property" << this->toString() << "to" << this->valueString(); - } -} - -void AbstractDBusProperty::update() { - if (this->group == nullptr) { - qFatal(logDbusProperties) << "Tried to update dbus property" << this->name - << "which is not attached to a group"; - } else { - const QString propStr = this->toString(); - - if (this->group->interface == nullptr) { - qFatal(logDbusProperties).noquote() - << "Tried to update property" << propStr << "of a disconnected interface"; - } - - qCDebug(logDbusProperties).noquote() << "Updating property" << propStr; - - auto pendingCall = - this->group->propertyInterface->Get(this->group->interface->interface(), this->name); - - auto* call = new QDBusPendingCallWatcher(pendingCall, this); - - auto responseCallback = [this, propStr](QDBusPendingCallWatcher* call) { - const QDBusPendingReply reply = *call; - - if (reply.isError()) { - qCWarning(logDbusProperties).noquote() << "Error updating property" << propStr; - qCWarning(logDbusProperties) << reply.error(); - } else { - this->tryUpdate(reply.value().variant()); - } - - delete call; - }; - - QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); - } -} - -QString AbstractDBusProperty::toString() const { - const QString group = this->group == nullptr ? "{ NO GROUP }" : this->group->toString(); - return group + ':' + this->name; -} - -DBusPropertyGroup::DBusPropertyGroup(QVector properties, QObject* parent) +DBusPropertyGroup::DBusPropertyGroup(QVector properties, QObject* parent) : QObject(parent) , properties(std::move(properties)) {} @@ -193,9 +139,8 @@ void DBusPropertyGroup::setInterface(QDBusAbstractInterface* interface) { } } -void DBusPropertyGroup::attachProperty(AbstractDBusProperty* property) { +void DBusPropertyGroup::attachProperty(DBusPropertyCore* property) { this->properties.append(property); - property->group = this; } void DBusPropertyGroup::updateAllDirect() { @@ -207,7 +152,7 @@ void DBusPropertyGroup::updateAllDirect() { } for (auto* property: this->properties) { - property->update(); + this->requestPropertyUpdate(property); } } @@ -229,33 +174,119 @@ void DBusPropertyGroup::updateAllViaGetAll() { qCWarning(logDbusProperties).noquote() << "Error updating properties of" << this->toString() << "via GetAll"; qCWarning(logDbusProperties) << reply.error(); + emit this->getAllFailed(reply.error()); } else { qCDebug(logDbusProperties).noquote() << "Received GetAll property set for" << this->toString(); - this->updatePropertySet(reply.value()); + this->updatePropertySet(reply.value(), true); + emit this->getAllFinished(); } delete call; - emit this->getAllFinished(); }; QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); } -void DBusPropertyGroup::updatePropertySet(const QVariantMap& properties) { +void DBusPropertyGroup::updatePropertySet(const QVariantMap& properties, bool complainMissing) { for (const auto [name, value]: properties.asKeyValueRange()) { auto prop = std::find_if( this->properties.begin(), this->properties.end(), - [&name](AbstractDBusProperty* prop) { return prop->name == name; } + [&name](DBusPropertyCore* prop) { return prop->nameRef() == name; } ); if (prop == this->properties.end()) { - qCDebug(logDbusProperties) << "Ignoring untracked property update" << name << "for" << this; + qCDebug(logDbusProperties) << "Ignoring untracked property update" << name << "for" + << this->toString(); } else { - (*prop)->tryUpdate(value); + this->tryUpdateProperty(*prop, value); } } + + if (complainMissing) { + for (const auto* prop: this->properties) { + if (prop->isRequired() && !properties.contains(prop->name())) { + qCWarning(logDbusProperties) + << prop->nameRef() << "missing from property set for" << this->toString(); + } + } + } +} + +void DBusPropertyGroup::tryUpdateProperty(DBusPropertyCore* property, const QVariant& variant) + const { + property->mExists = true; + + auto error = property->store(variant); + if (error.isValid()) { + qCWarning(logDbusProperties).noquote() + << "Error demarshalling property update for" << this->propertyString(property); + qCWarning(logDbusProperties) << error; + } else { + qCDebug(logDbusProperties).noquote() + << "Updated property" << this->propertyString(property) << "to" << property->valueString(); + } +} + +void DBusPropertyGroup::requestPropertyUpdate(DBusPropertyCore* property) { + const QString propStr = this->propertyString(property); + + if (this->interface == nullptr) { + qFatal(logDbusProperties).noquote() + << "Tried to update property" << propStr << "of a disconnected interface"; + } + + qCDebug(logDbusProperties).noquote() << "Updating property" << propStr; + + auto pendingCall = this->propertyInterface->Get(this->interface->interface(), property->name()); + auto* call = new QDBusPendingCallWatcher(pendingCall, this); + + auto responseCallback = [this, propStr, property](QDBusPendingCallWatcher* call) { + const QDBusPendingReply reply = *call; + + if (reply.isError()) { + qCWarning(logDbusProperties).noquote() << "Error updating property" << propStr; + qCWarning(logDbusProperties) << reply.error(); + } else { + this->tryUpdateProperty(property, reply.value().variant()); + } + + delete call; + }; + + QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); +} + +void DBusPropertyGroup::pushPropertyUpdate(DBusPropertyCore* property) { + const QString propStr = this->propertyString(property); + + if (this->interface == nullptr) { + qFatal(logDbusProperties).noquote() + << "Tried to write property" << propStr << "of a disconnected interface"; + } + + qCDebug(logDbusProperties).noquote() << "Writing property" << propStr; + + auto pendingCall = this->propertyInterface->Set( + this->interface->interface(), + property->name(), + QDBusVariant(property->serialize()) + ); + + auto* call = new QDBusPendingCallWatcher(pendingCall, this); + + auto responseCallback = [propStr](QDBusPendingCallWatcher* call) { + const QDBusPendingReply<> reply = *call; + + if (reply.isError()) { + qCWarning(logDbusProperties).noquote() << "Error writing property" << propStr; + qCWarning(logDbusProperties) << reply.error(); + } + delete call; + }; + + QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); } QString DBusPropertyGroup::toString() const { @@ -267,6 +298,10 @@ QString DBusPropertyGroup::toString() const { } } +QString DBusPropertyGroup::propertyString(const DBusPropertyCore* property) const { + return this->toString() % ':' % property->nameRef(); +} + void DBusPropertyGroup::onPropertiesChanged( const QString& interfaceName, const QVariantMap& changedProperties, @@ -280,18 +315,18 @@ void DBusPropertyGroup::onPropertiesChanged( auto prop = std::find_if( this->properties.begin(), this->properties.end(), - [&name](AbstractDBusProperty* prop) { return prop->name == name; } + [&name](DBusPropertyCore* prop) { return prop->nameRef() == name; } ); if (prop == this->properties.end()) { qCDebug(logDbusProperties) << "Ignoring untracked property invalidation" << name << "for" << this; } else { - (*prop)->update(); + this->requestPropertyUpdate(*prop); } } - this->updatePropertySet(changedProperties); + this->updatePropertySet(changedProperties, false); } } // namespace qs::dbus diff --git a/src/dbus/properties.hpp b/src/dbus/properties.hpp index 3aac07f6..846f70f2 100644 --- a/src/dbus/properties.hpp +++ b/src/dbus/properties.hpp @@ -1,8 +1,10 @@ #pragma once #include +#include #include +#include #include #include #include @@ -14,9 +16,14 @@ #include #include #include +#include +#include +#include #include #include +#include "../core/util.hpp" + class DBusPropertiesInterface; Q_DECLARE_LOGGING_CATEGORY(logDbusProperties); @@ -37,7 +44,7 @@ public: bool isValid() { return !this->error.isValid(); } - T value; + T value {}; QDBusError error; }; @@ -75,55 +82,166 @@ void asyncReadProperty( class DBusPropertyGroup; -class AbstractDBusProperty: public QObject { - Q_OBJECT; - +class DBusPropertyCore { public: - explicit AbstractDBusProperty(QString name, const QMetaType& type, QObject* parent = nullptr) - : QObject(parent) - , name(std::move(name)) - , type(type) {} + DBusPropertyCore() = default; + virtual ~DBusPropertyCore() = default; + Q_DISABLE_COPY_MOVE(DBusPropertyCore); - [[nodiscard]] QString toString() const; + [[nodiscard]] virtual QString name() const = 0; + [[nodiscard]] virtual QStringView nameRef() const = 0; [[nodiscard]] virtual QString valueString() = 0; - -public slots: - void update(); - -signals: - void changed(); + [[nodiscard]] virtual bool isRequired() const = 0; + [[nodiscard]] bool exists() const { return this->mExists; } protected: - virtual QDBusError read(const QVariant& variant) = 0; + virtual QDBusError store(const QVariant& variant) = 0; + [[nodiscard]] virtual QVariant serialize() = 0; private: - void tryUpdate(const QVariant& variant); - - DBusPropertyGroup* group = nullptr; - - QString name; - QMetaType type; + bool mExists : 1 = false; friend class DBusPropertyGroup; }; +// Default implementation with no transformation +template +struct DBusDataTransform { + using Wire = T; + using Data = T; +}; + +namespace bindable_p { + +template +struct BindableParams; + +template