diff --git a/.clang-tidy b/.clang-tidy index 6642fa76..002c444d 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -5,6 +5,9 @@ Checks: > -*, bugprone-*, -bugprone-easily-swappable-parameters, + -bugprone-forward-declararion-namespace, + -bugprone-forward-declararion-namespace, + -bugprone-return-const-ref-from-parameter, concurrency-*, cppcoreguidelines-*, -cppcoreguidelines-owning-memory, @@ -12,8 +15,11 @@ Checks: > -cppcoreguidelines-pro-bounds-constant-array-index, -cppcoreguidelines-avoid-const-or-ref-data-members, -cppcoreguidelines-non-private-member-variables-in-classes, - google-build-using-namespace. - google-explicit-constructor, + -cppcoreguidelines-avoid-goto, + -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, @@ -25,6 +31,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, @@ -35,6 +42,10 @@ Checks: > -readability-braces-around-statements, -readability-redundant-access-specifiers, -readability-else-after-return, + -readability-container-data-pointer, + -readability-implicit-bool-conversion, + -readability-avoid-nested-conditional-operator, + -readability-math-missing-parentheses, tidyfox-*, CheckOptions: performance-for-range-copy.WarnOnAllAutoCopies: true diff --git a/.editorconfig b/.editorconfig index 6b1b58df..9de26e09 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,3 +9,10 @@ indent_style = tab [*.nix] indent_style = space indent_size = 2 + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +[*.scm] +indent_style = space \ No newline at end of file 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..93b84585 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,56 @@ +name: Build +on: [push, pull_request, workflow_dispatch] + +jobs: + nix: + name: Nix + strategy: + matrix: + qtver: [qt6.9.0, qt6.8.3, qt6.8.2, qt6.8.1, 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 \ + libdrm \ + 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..da329cc2 --- /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: LC_ALL=en_US.UTF-8 LC_CTYPE=en_US.UTF-8 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..aa7c98ae --- /dev/null +++ b/BUILD.md @@ -0,0 +1,251 @@ +# 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) +- `spirv-tools` (build-time) +- `pkg-config` (build-time) +- `cli11` (static library) + +Build time dependencies and static libraries don't have to exist at runtime, +however build time dependencies must be compiled for the architecture of +the builder, while static libraries must be compiled for the architecture +of the target. + +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` (static library) + +### 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` (build time) + - `wayland-protocols` (static library) + +Note that one or both of `wayland-scanner` and `wayland-protocols` may be bundled +with you distro's wayland package. + +#### 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` + +#### Screencopy +Enables streaming video from monitors and toplevel windows through various protocols. + +To disable: `-DSCREENCOPY=OFF` + +Dependencies: +- `libdrm` +- `libgbm` + +Specific protocols can also be disabled: +- `DSCREENCOPY_ICC=OFF` - Disable screencopy via [ext-image-copy-capture-v1] +- `DSCREENCOPY_WLR=OFF` - Disable screencopy via [zwlr-screencopy-v1] +- `DSCREENCOPY_HYPRLAND_TOPLEVEL=OFF` - Disable screencopy via [hyprland-toplevel-export-v1] + +[ext-image-copy-capture-v1]:https://wayland.app/protocols/ext-image-copy-capture-v1 +[zwlr-screencopy-v1]: https://wayland.app/protocols/wlr-screencopy-unstable-v1 +[hyprland-toplevel-export-v1]: https://wayland.app/protocols/hyprland-toplevel-export-v1 + +### 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).* + +Only `ninja` builds are tested, but makefiles may work. + +#### 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 67f8a1fe..55b5e5d5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,35 +1,93 @@ cmake_minimum_required(VERSION 3.20) -project(quickshell VERSION "0.1.0") +project(quickshell VERSION "0.2.0" LANGUAGES CXX C) set(QT_MIN_VERSION "6.6.0") set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) -option(TESTS "Build tests" OFF) +set(QS_BUILD_OPTIONS "") -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) +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 " 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 " 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 - ) +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(SCREENCOPY " Screencopy" ON REQUIRES WAYLAND) +boption(SCREENCOPY_ICC " Image Copy Capture" ON REQUIRES WAYLAND) +boption(SCREENCOPY_WLR " Wlroots Screencopy" ON REQUIRES WAYLAND) +boption(SCREENCOPY_HYPRLAND_TOPLEVEL " Hyprland Toplevel Export" ON REQUIRES WAYLAND) +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) +boption(BLUETOOTH "Bluetooth" ON) + +include(cmake/install-qml-module.cmake) +include(cmake/util.cmake) + +add_compile_options(-Wall -Wextra -Wno-vla-cxx-extension) + +# pipewire defines this, breaking PCH +add_compile_definitions(_REENTRANT) + +if (FRAME_POINTERS) + add_compile_options(-fno-omit-frame-pointer) endif() -add_compile_options(-Wall -Wextra) +if (ASAN) + add_compile_options(-fsanitize=address) + add_link_options(-fsanitize=address) +endif() # nix workaround if (CMAKE_EXPORT_COMPILE_COMMANDS) @@ -41,34 +99,61 @@ if (NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Debug) endif() -set(QT_DEPS Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2) -set(QT_FPDEPS Gui Qml Quick QuickControls2) +set(QT_FPDEPS Gui Qml Quick QuickControls2 Widgets ShaderTools) + +include(cmake/pch.cmake) if (BUILD_TESTING) enable_testing() + add_definitions(-DQS_TEST) list(APPEND QT_FPDEPS Test) 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 OR SERVICE_MPRIS OR SERVICE_UPOWER OR SERVICE_NOTIFICATIONS OR BLUETOOTH) + set(DBUS ON) +endif() + +if (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) -add_subdirectory(src/core) -add_subdirectory(src/io) +add_subdirectory(src) -if (WAYLAND) - add_subdirectory(src/wayland) -endif () +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(TARGETS quickshell RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +install(CODE " + execute_process( + COMMAND ${CMAKE_COMMAND} -E create_symlink \ + ${CMAKE_INSTALL_FULL_BINDIR}/quickshell \$ENV{DESTDIR}${CMAKE_INSTALL_FULL_BINDIR}/qs + ) +") + +install( + FILES ${CMAKE_SOURCE_DIR}/assets/org.quickshell.desktop + DESTINATION ${CMAKE_INSTALL_DATADIR}/applications +) + +install( + FILES ${CMAKE_SOURCE_DIR}/assets/quickshell.svg + DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps + RENAME org.quickshell.svg +) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..39fab13e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,235 @@ +# 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`. + +#### Style preferences not caught by clang-format +These are flexible. You can ignore them if it looks or works better to +for one reason or another. + +Use `auto` if the type of a variable can be deduced automatically, instead of +redeclaring the returned value's type. Additionally, auto should be used when a +constructor takes arguments. + +```cpp +auto x = ; // ok +auto x = QString::number(3); // ok +QString x; // ok +QString x = "foo"; // ok +auto x = QString("foo"); // ok + +auto x = QString(); // avoid +QString x(); // avoid +QString x("foo"); // avoid +``` + +Put newlines around logical units of code, and after closing braces. If the +most reasonable logical unit of code takes only a single line, it should be +merged into the next single line logical unit if applicable. +```cpp +// multiple units +auto x = ; // unit 1 +auto y = ; // unit 2 + +auto x = ; // unit 1 +emit this->y(); // unit 2 + +auto x1 = ; // unit 1 +auto x2 = ; // unit 1 +auto x3 = ; // unit 1 + +auto y1 = ; // unit 2 +auto y2 = ; // unit 2 +auto y3 = ; // unit 2 + +// one unit +auto x = ; +if (x...) { + // ... +} + +// if more than one variable needs to be used then add a newline +auto x = ; +auto y = ; + +if (x && y) { + // ... +} +``` + +Class formatting: +```cpp +//! Doc comment summary +/// Doc comment body +class Foo: public QObject { + // The Q_OBJECT macro comes first. Macros are ; terminated. + Q_OBJECT; + QML_ELEMENT; + QML_CLASSINFO(...); + // Properties must stay on a single line or the doc generator won't be able to pick them up + Q_PROPERTY(...); + /// Doc comment + Q_PROPERTY(...); + /// Doc comment + Q_PROPERTY(...); + +public: + // Classes should have explicit constructors if they aren't intended to + // implicitly cast. The constructor can be inline in the header if it has no body. + explicit Foo(QObject* parent = nullptr): QObject(parent) {} + + // Instance functions if applicable. + static Foo* instance(); + + // Member functions unrelated to properties come next + void function(); + void function(); + void function(); + + // Then Q_INVOKABLEs + Q_INVOKABLE function(); + /// Doc comment + Q_INVOKABLE function(); + /// Doc comment + Q_INVOKABLE function(); + + // Then property related functions, in the order (bindable, getter, setter). + // Related functions may be included here as well. Function bodies may be inline + // if they are a single expression. There should be a newline between each + // property's methods. + [[nodiscard]] QBindable bindableFoo() { return &this->bFoo; } + [[nodiscard]] T foo() const { return this->foo; } + void setFoo(); + + [[nodiscard]] T bar() const { return this->foo; } + void setBar(); + +signals: + // Signals that are not property change related go first. + // Property change signals go in property definition order. + void asd(); + void asd2(); + void fooChanged(); + void barChanged(); + +public slots: + // generally Q_INVOKABLEs are preferred to public slots. + void slot(); + +private slots: + // ... + +private: + // statics, then functions, then fields + static const foo BAR; + static void foo(); + + void foo(); + void bar(); + + // property related members are prefixed with `m`. + QString mFoo; + QString bar; + + // Bindables go last and should be prefixed with `b`. + Q_OBJECT_BINDABLE_PROPERTY(Foo, QString, bFoo, &Foo::fooChanged); +}; +``` + +### 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-changed +``` + +If the linter is complaining about something that you think it should not, +please disable the lint in your MR and explain your reasoning if it isn't obvious. + +### 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 314bcdd5..f60771aa 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 -j$(nproc) -q0 --no-notice --will-cite --tty --bar clang-tidy --load={{ env_var("TIDYFOX") }} + +lint-ci: + find src -type f -name "*.cpp" -print0 | parallel -j$(nproc) -q0 --no-notice --will-cite --tty clang-tidy --load={{ env_var("TIDYFOX") }} + +lint-changed: + git diff --name-only HEAD | grep "^.*\.cpp\$" | parallel -j$(nproc) --no-notice --will-cite --tty --bar clang-tidy --load={{ env_var("TIDYFOX") }} configure target='debug' *FLAGS='': cmake -GNinja -B {{builddir}} \ @@ -26,7 +32,7 @@ clean: rm -rf {{builddir}} run *ARGS='': build - {{builddir}}/src/core/quickshell {{ARGS}} + {{builddir}}/src/quickshell {{ARGS}} test *ARGS='': build ctest --test-dir {{builddir}} --output-on-failure {{ARGS}} diff --git a/README.md b/README.md index e075a322..4491d24b 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,13 @@ -# quickshell +# Quickshell +See the [website](https://quickshell.outfoxxed.me) for more information +and installation instructions. -Simple and flexbile QtQuick based desktop shell toolkit. +This repo is hosted at: +- https://git.outfoxxed.me/quickshell/quickshell +- https://github.com/quickshell-mirror/quickshell -Hosts: [outfoxxed's gitea], [github] - -[outfoxxed's gitea]: https://git.outfoxxed.me/outfoxxed/quickshell -[github]: https://github.com/outfoxxed/quickshell - -Documentation can be built from the [quickshell-docs](https://git.outfoxxed.me/outfoxxed/quickshell-docs) repo, -though is currently pretty lacking. - -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. - -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 -``` - -# Installation - -## Nix -This repo has a nix flake you can use to install the package directly: - -```nix -{ - inputs = { - nixpkgs.url = "nixpkgs/nixos-unstable"; - - quickshell = { - url = "git+https://git.outfoxxed.me/outfoxxed/quickshell"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; -} -``` - -Quickshell's binary is available at `quickshell.packages..default` to be added to -lists such as `environment.systemPackages` or `home.packages`. - -## Manual - -If not using nix, you'll have to build from source. - -### Dependencies -To build quickshell at all, you will need the following packages (names may vary by distro) - -- just -- cmake -- pkg-config -- ninja -- Qt6 [ QtBase, QtDeclarative ] - -To build with wayland support you will additionally need: -- wayland -- wayland-scanner (may be part of wayland on some distros) -- wayland-protocols -- Qt6 [ QtWayland ] - -### Building - -To make a release build of quickshell run: -```sh -$ just release -``` - -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/assets/org.quickshell.desktop b/assets/org.quickshell.desktop new file mode 100644 index 00000000..63f65fd9 --- /dev/null +++ b/assets/org.quickshell.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Version=1.5 +Type=Application +NoDisplay=true + +Name=Quickshell +Icon=org.quickshell diff --git a/assets/quickshell.svg b/assets/quickshell.svg new file mode 100644 index 00000000..7d0f9481 --- /dev/null +++ b/assets/quickshell.svg @@ -0,0 +1 @@ + diff --git a/changelog/v0.1.0.md b/changelog/v0.1.0.md new file mode 100644 index 00000000..f8a032f2 --- /dev/null +++ b/changelog/v0.1.0.md @@ -0,0 +1 @@ +Initial release diff --git a/changelog/v0.2.0.md b/changelog/v0.2.0.md new file mode 100644 index 00000000..2fbf74d7 --- /dev/null +++ b/changelog/v0.2.0.md @@ -0,0 +1,84 @@ +## Breaking Changes + +- Files outside of the shell directory can no longer be referenced with relative paths, e.g. '../../foo.png'. +- PanelWindow's Automatic exclusion mode now adds an exclusion zone for panels with a single anchor. +- `QT_QUICK_CONTROLS_STYLE` and `QT_STYLE_OVERRIDE` are ignored unless `//@ pragma RespectSystemStyle` is set. + +## New Features + +### Root-Relative Imports + +Quickshell 0.2 comes with a new method to import QML modules which is supported by QMLLS. +This replaces "root:/" imports for QML modules. + +The new syntax is `import qs.path.to.module`, where `path/to/module` is the path to +a module/subdirectory relative to the config root (`qs`). + +### Better LSP support + +LSP support for Singletons and Root-Relative imports can be enabled by creating a file named +`.qmlls.ini` in the shell root directory. Quickshell will detect this file and automatically +populate it with an LSP configuration. This file should be gitignored in your configuration, +as it is system dependent. + +The generated configuration also includes QML import paths available to Quickshell, meaning +QMLLS no longer requires the `-E` flag. + +### Bluetooth Module + +Quickshell can now manage your bluetooth devices through BlueZ. While authenticated pairing +has not landed in 0.2, support for connecting and disconnecting devices, basic device information, +and non-authenticated pairing are now supported. + +### Other Features + +- Added `HyprlandToplevel` and related toplevel/window management APIs in the Hyprland module. +- Added `Quickshell.execDetached()`, which spawns a detached process without a `Process` object. +- Added `Process.exec()` for easier reconfiguration of process commands when starting them. +- Added `FloatingWindow.title`, which allows changing the title of a floating window. +- Added `signal QsWindow.closed()`, fired when a window is closed externally. +- Added support for inline replies in notifications, when supported by applications. +- Added `DesktopEntry.startupWmClass` and `DesktopEntry.heuristicLookup()` to better identify toplevels. +- Added `DesktopEntry.command` which can be run as an alternative to `DesktopEntry.execute()`. +- Added `//@ pragma Internal`, which makes a QML component impossible to import outside of its module. +- Added dead instance selection for some subcommands, such as `qs log` and `qs list`. + +## Other Changes + +- `Quickshell.shellRoot` has been renamed to `Quickshell.shellDir`. +- PanelWindow margins opposite the window's anchorpoint are now added to exclusion zone. +- stdout/stderr or detached processes and executed desktop entries are now hidden by default. +- Various warnings caused by other applications Quickshell communicates with over D-BUS have been hidden in logs. +- Quickshell's new logo is now shown in any floating windows. + +## Bug Fixes + +- Fixed pipewire device volume and mute states not updating before the device has been used. +- Fixed a crash when changing the volume of any pipewire device on a sound card another removed device was using. +- Fixed a crash when accessing a removed previous default pipewire node from the default sink/source changed signals. +- Fixed session locks crashing if all monitors are disconnected. +- Fixed session locks crashing if unsupported by the compositor. +- Fixed a crash when creating a session lock and destroying it before acknowledged by the compositor. +- Fixed window input masks not updating after a reload. +- Fixed PanelWindows being unconfigurable unless `screen` was set under X11. +- Fixed a crash when anchoring a popup to a zero sized `Item`. +- Fixed `FileView` crashing if `watchChanges` was used. +- Fixed `SocketServer` sockets disappearing after a reload. +- Fixed `ScreencopyView` having incorrect rotation when displaying a rotated monitor. +- Fixed `MarginWrapperManager` breaking pixel alignment of child items when centering. +- Fixed `IpcHandler`, `NotificationServer` and `GlobalShortcut` not activating with certain QML structures. +- Fixed tracking of QML incubator destruction and deregistration, which occasionally caused crashes. +- Fixed FloatingWindows being constrained to the smallest window manager supported size unless max size was set. +- Fixed `MprisPlayer.lengthSupported` not updating reactively. +- Fixed normal tray icon being ignored when status is `NeedsAttention` and no attention icon is provided. +- Fixed `HyprlandWorkspace.activate()` sending invalid commands to Hyprland for named or special workspaces. +- Fixed file watcher occasionally breaking when using VSCode to edit QML files. +- Fixed crashes when screencopy buffer creation fails. +- Fixed a crash when wayland layer surfaces are recreated for the same window. +- Fixed the `QsWindow` attached object not working when using `WlrLayershell` directly. +- Fixed a crash when attempting to create a window without available VRAM. +- Fixed OOM crash when failing to write to detailed log file. +- Prevented distro logging configurations for Qt from interfering with Quickshell commands. +- Removed the "QProcess destroyed for running process" warning when destroying `Process` objects. +- Fixed `ColorQuantizer` printing a pointer to an error message instead of an error message. +- Fixed notification pixmap rowstride warning showing for correct rowstrides. 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..73c24156 --- /dev/null +++ b/ci/nix-checkouts.nix @@ -0,0 +1,78 @@ +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_9_0 = byCommit { + commit = "546c545bd0594809a28ab7e869b5f80dd7243ef6"; + sha256 = "0562lbi67a9brfwzpqs4n3l0i8zvgla368aakcy5mghr7ps80567"; + }; + + qt6_8_3 = byCommit { + commit = "374e6bcc403e02a35e07b650463c01a52b13a7c8"; + sha256 = "1ck2d7q1f6k58qg47bc07036h9gmc2mqmqlgrv67k3frgplfhfga"; + }; + + qt6_8_2 = byCommit { + commit = "97be9fbfc7a8a794bb51bd5dfcbfad5fad860512"; + sha256 = "1sqh6kb8yg9yw6brkkb3n4y3vpbx8fnx45skyikqdqj2xs76v559"; + }; + + qt6_8_1 = byCommit { + commit = "4a66c00fcb3f85ddad658b8cfa2e870063ce60b5"; + sha256 = "1fcvr67s7366bk8czzwhr12zsq60izl5iq4znqbm44pzyq9pf8rq"; + }; + + qt6_8_0 = byCommit { + commit = "352f462ad9d2aa2cde75fdd8f1734e86402a3ff6"; + sha256 = "02zfgkr9fpd6iwfh6dcr3m6fnx61jppm3v081f3brvkqwmmz7zq1"; + }; + + 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 4fa9326f..71c949e3 100644 --- a/default.nix +++ b/default.nix @@ -3,13 +3,24 @@ nix-gitignore, pkgs, keepDebugInfo, - stdenv ? (keepDebugInfo pkgs.stdenv), + buildStdenv ? pkgs.clangStdenv, + pkg-config, cmake, ninja, + spirv-tools, qt6, + breakpad, + jemalloc, + cli11, wayland, wayland-protocols, + wayland-scanner, + xorg, + libdrm, + libgbm ? null, + pipewire, + pam, gitRev ? (let headExists = builtins.pathExists ./.git/HEAD; @@ -21,51 +32,101 @@ then builtins.readFile ./.git/refs/heads/${builtins.elemAt matches 0} else headContent) else "unknown"), + debug ? false, - enableWayland ? true, -}: stdenv.mkDerivation { - pname = "quickshell${lib.optionalString debug "-debug"}"; - version = "0.1.0"; - src = nix-gitignore.gitignoreSource [] ./.; + withCrashReporter ? true, + withJemalloc ? true, # masks heap fragmentation + withQtSvg ? true, + withWayland ? true, + withX11 ? true, + withPipewire ? true, + withPam ? true, + withHyprland ? true, + withI3 ? true, +}: let + unwrapped = buildStdenv.mkDerivation { + pname = "quickshell${lib.optionalString debug "-debug"}"; + version = "0.2.0"; + src = nix-gitignore.gitignoreSource "/default.nix\n" ./.; - nativeBuildInputs = with pkgs; [ - cmake - ninja - qt6.wrapQtAppsHook - ] ++ (lib.optionals enableWayland [ - pkg-config - wayland-protocols - wayland-scanner - ]); + dontWrapQtApps = true; # see wrappers - buildInputs = with pkgs; [ - qt6.qtbase - qt6.qtdeclarative - ] ++ (lib.optionals enableWayland [ qt6.qtwayland wayland ]); + nativeBuildInputs = [ + cmake + ninja + qt6.qtshadertools + spirv-tools + pkg-config + ] + ++ lib.optional withWayland wayland-scanner; - QTWAYLANDSCANNER = lib.optionalString enableWayland "${qt6.qtwayland}/libexec/qtwaylandscanner"; + buildInputs = [ + qt6.qtbase + qt6.qtdeclarative + cli11 + ] + ++ lib.optional withQtSvg qt6.qtsvg + ++ lib.optional withCrashReporter breakpad + ++ lib.optional withJemalloc jemalloc + ++ lib.optionals withWayland [ qt6.qtwayland wayland wayland-protocols ] + ++ lib.optionals (withWayland && libgbm != null) [ libdrm libgbm ] + ++ lib.optional withX11 xorg.libxcb + ++ lib.optional withPam pam + ++ lib.optional withPipewire pipewire; - 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"; + cmakeFlags = [ + (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 "SCREENCOPY" (libgbm != null)) + (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"; - license = licenses.lgpl3Only; - platforms = platforms.linux; + meta = with lib; { + homepage = "https://quickshell.org"; + description = "Flexbile QtQuick based desktop shell toolkit"; + license = licenses.lgpl3Only; + platforms = platforms.linux; + mainProgram = "quickshell"; + }; }; -} + + wrapper = unwrapped.stdenv.mkDerivation { + inherit (unwrapped) version meta buildInputs; + pname = "${unwrapped.pname}-wrapped"; + + nativeBuildInputs = unwrapped.nativeBuildInputs ++ [ qt6.wrapQtAppsHook ]; + + dontUnpack = true; + dontConfigure = true; + dontBuild = true; + + installPhase = '' + mkdir -p $out + cp -r ${unwrapped}/* $out + ''; + + passthru = { + unwrapped = unwrapped; + withModules = modules: wrapper.overrideAttrs (prev: { + buildInputs = prev.buildInputs ++ modules; + }); + }; + }; +in wrapper diff --git a/docs b/docs deleted file mode 160000 index 70989dc6..00000000 --- a/docs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 70989dc619bcdc29dc4880b4ff5257d6ad188a18 diff --git a/examples b/examples deleted file mode 160000 index 9c83cc24..00000000 --- a/examples +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9c83cc248c968b18a827b4fa4c616a8d362176e1 diff --git a/flake.lock b/flake.lock index 1527f635..7c25aa23 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1709237383, - "narHash": "sha256-cy6ArO4k5qTx+l5o+0mL9f5fa86tYUX3ozE1S+Txlds=", + "lastModified": 1749285348, + "narHash": "sha256-frdhQvPbmDYaScPFiCnfdh3B/Vh81Uuoo0w5TkWmmjU=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "1536926ef5621b09bba54035ae2bb6d806d72ac8", + "rev": "3e3afe5174c561dee0df6f2c2b2236990146329f", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index b9ba8d1f..5de9c96a 100644 --- a/flake.nix +++ b/flake.nix @@ -4,12 +4,16 @@ }; outputs = { self, nixpkgs }: let - forEachSystem = fn: nixpkgs.lib.genAttrs - [ "x86_64-linux" "aarch64-linux" ] - (system: fn system nixpkgs.legacyPackages.${system}); + forEachSystem = fn: + nixpkgs.lib.genAttrs + nixpkgs.lib.platforms.linux + (system: fn system nixpkgs.legacyPackages.${system}); in { packages = forEachSystem (system: pkgs: rec { - quickshell = import ./package.nix { inherit pkgs; }; + quickshell = pkgs.callPackage ./default.nix { + gitRev = self.rev or self.dirtyRev; + }; + default = quickshell; }); diff --git a/package.nix b/package.nix deleted file mode 100644 index a3e6249e..00000000 --- a/package.nix +++ /dev/null @@ -1 +0,0 @@ -{ pkgs ? import {}, ... }: pkgs.callPackage ./default.nix {} diff --git a/quickshell.scm b/quickshell.scm new file mode 100644 index 00000000..26abdc0b --- /dev/null +++ b/quickshell.scm @@ -0,0 +1,77 @@ +(define-module (quickshell) + #:use-module ((guix licenses) #:prefix license:) + #:use-module (gnu packages cpp) + #:use-module (gnu packages freedesktop) + #:use-module (gnu packages gcc) + #:use-module (gnu packages gl) + #:use-module (gnu packages jemalloc) + #:use-module (gnu packages linux) + #:use-module (gnu packages ninja) + #:use-module (gnu packages pkg-config) + #:use-module (gnu packages qt) + #:use-module (gnu packages vulkan) + #:use-module (gnu packages xdisorg) + #:use-module (gnu packages xorg) + #:use-module (guix build-system cmake) + #:use-module (guix download) + #:use-module (guix gexp) + #:use-module (guix git-download) + #:use-module (guix packages) + #:use-module (guix packages) + #:use-module (guix utils)) + +(define-public quickshell-git + (package + (name "quickshell") + (version "git") + (source (local-file "." "quickshell-checkout" + #:recursive? #t + #:select? (or (git-predicate (current-source-directory)) + (const #t)))) + (build-system cmake-build-system) + (propagated-inputs (list qtbase qtdeclarative qtsvg)) + (native-inputs (list ninja + gcc-14 + pkg-config + qtshadertools + spirv-tools + wayland-protocols + cli11)) + (inputs (list jemalloc + libdrm + libxcb + libxkbcommon + linux-pam + mesa + pipewire + qtbase + qtdeclarative + qtwayland + vulkan-headers + wayland)) + (arguments + (list #:tests? #f + #:configure-flags + #~(list "-GNinja" + "-DDISTRIBUTOR=\"In-tree Guix channel\"" + "-DDISTRIBUTOR_DEBUGINFO_AVAILABLE=NO" + ;; Breakpad is not currently packaged for Guix. + "-DCRASH_REPORTER=OFF") + #:phases + #~(modify-phases %standard-phases + (replace 'build (lambda _ (invoke "cmake" "--build" "."))) + (replace 'install (lambda _ (invoke "cmake" "--install" "."))) + (add-after 'install 'wrap-program + (lambda* (#:key inputs #:allow-other-keys) + (wrap-program (string-append #$output "/bin/quickshell") + `("QML_IMPORT_PATH" ":" + = (,(getenv "QML_IMPORT_PATH"))))))))) + (home-page "https://quickshell.outfoxxed.me") + (synopsis "QtQuick-based desktop shell toolkit") + (description + "Quickshell is a flexible QtQuick-based toolkit for creating and +customizing toolbars, notification centers, and other desktop +environment tools in a live programming environment.") + (license license:lgpl3))) + +quickshell-git diff --git a/shell.nix b/shell.nix index 484bf70a..82382f90 100644 --- a/shell.nix +++ b/shell.nix @@ -10,18 +10,17 @@ rev = "1f062cc198d1112d13e5128fa1f2ee3dbffe613b"; sha256 = "kbt0Zc1qHE5fhqBkKz8iue+B+ZANjF1AR/RdgmX1r0I="; }) { inherit pkgs; }; -in pkgs.mkShell { +in pkgs.mkShell.override { stdenv = quickshell.stdenv; } { inputsFrom = [ quickshell ]; 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 new file mode 100644 index 00000000..52db00a5 --- /dev/null +++ b/src/CMakeLists.txt @@ -0,0 +1,35 @@ +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) +add_subdirectory(ui) + +if (CRASH_REPORTER) + add_subdirectory(crash) +endif() + +if (DBUS) + add_subdirectory(dbus) +endif() + +if (WAYLAND) + add_subdirectory(wayland) +endif() + +if (X11) + add_subdirectory(x11) +endif() + +add_subdirectory(services) + +if (BLUETOOTH) + add_subdirectory(bluetooth) +endif() diff --git a/src/bluetooth/CMakeLists.txt b/src/bluetooth/CMakeLists.txt new file mode 100644 index 00000000..806ff04d --- /dev/null +++ b/src/bluetooth/CMakeLists.txt @@ -0,0 +1,42 @@ +set_source_files_properties(org.bluez.Adapter.xml PROPERTIES + CLASSNAME DBusBluezAdapterInterface +) + +set_source_files_properties(org.bluez.Device.xml PROPERTIES + CLASSNAME DBusBluezDeviceInterface +) + +qt_add_dbus_interface(DBUS_INTERFACES + org.bluez.Adapter.xml + dbus_adapter +) + +qt_add_dbus_interface(DBUS_INTERFACES + org.bluez.Device.xml + dbus_device +) + +qt_add_library(quickshell-bluetooth STATIC + adapter.cpp + bluez.cpp + device.cpp + ${DBUS_INTERFACES} +) + +qt_add_qml_module(quickshell-bluetooth + URI Quickshell.Bluetooth + VERSION 0.1 + DEPENDENCIES QtQml +) + +install_qml_module(quickshell-bluetooth) + +# dbus headers +target_include_directories(quickshell-bluetooth PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) + +target_link_libraries(quickshell-bluetooth PRIVATE Qt::Qml Qt::DBus) +qs_add_link_dependencies(quickshell-bluetooth quickshell-dbus) + +qs_module_pch(quickshell-bluetooth SET dbus) + +target_link_libraries(quickshell PRIVATE quickshell-bluetoothplugin) diff --git a/src/bluetooth/adapter.cpp b/src/bluetooth/adapter.cpp new file mode 100644 index 00000000..0d8a3192 --- /dev/null +++ b/src/bluetooth/adapter.cpp @@ -0,0 +1,224 @@ +#include "adapter.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/logcat.hpp" +#include "../dbus/properties.hpp" +#include "dbus_adapter.h" + +namespace qs::bluetooth { + +namespace { +QS_LOGGING_CATEGORY(logAdapter, "quickshell.bluetooth.adapter", QtWarningMsg); +} + +QString BluetoothAdapterState::toString(BluetoothAdapterState::Enum state) { + switch (state) { + case BluetoothAdapterState::Disabled: return QStringLiteral("Disabled"); + case BluetoothAdapterState::Enabled: return QStringLiteral("Enabled"); + case BluetoothAdapterState::Enabling: return QStringLiteral("Enabling"); + case BluetoothAdapterState::Disabling: return QStringLiteral("Disabling"); + case BluetoothAdapterState::Blocked: return QStringLiteral("Blocked"); + default: return QStringLiteral("Unknown"); + } +} + +BluetoothAdapter::BluetoothAdapter(const QString& path, QObject* parent): QObject(parent) { + this->mInterface = + new DBusBluezAdapterInterface("org.bluez", path, QDBusConnection::systemBus(), this); + + if (!this->mInterface->isValid()) { + qCWarning(logAdapter) << "Could not create DBus interface for adapter at" << path; + this->mInterface = nullptr; + return; + } + + this->properties.setInterface(this->mInterface); +} + +QString BluetoothAdapter::adapterId() const { + auto path = this->path(); + return path.sliced(path.lastIndexOf('/') + 1); +} + +void BluetoothAdapter::setEnabled(bool enabled) { + if (enabled == this->bEnabled) return; + + if (enabled && this->bState == BluetoothAdapterState::Blocked) { + qCCritical(logAdapter) << "Cannot enable adapter because it is blocked by rfkill."; + return; + } + + this->bEnabled = enabled; + this->pEnabled.write(); +} + +void BluetoothAdapter::setDiscoverable(bool discoverable) { + if (discoverable == this->bDiscoverable) return; + this->bDiscoverable = discoverable; + this->pDiscoverable.write(); +} + +void BluetoothAdapter::setDiscovering(bool discovering) { + if (discovering) { + this->startDiscovery(); + } else { + this->stopDiscovery(); + } +} + +void BluetoothAdapter::setDiscoverableTimeout(quint32 timeout) { + if (timeout == this->bDiscoverableTimeout) return; + this->bDiscoverableTimeout = timeout; + this->pDiscoverableTimeout.write(); +} + +void BluetoothAdapter::setPairable(bool pairable) { + if (pairable == this->bPairable) return; + this->bPairable = pairable; + this->pPairable.write(); +} + +void BluetoothAdapter::setPairableTimeout(quint32 timeout) { + if (timeout == this->bPairableTimeout) return; + this->bPairableTimeout = timeout; + this->pPairableTimeout.write(); +} + +void BluetoothAdapter::addInterface(const QString& interface, const QVariantMap& properties) { + if (interface == "org.bluez.Adapter1") { + this->properties.updatePropertySet(properties, false); + qCDebug(logAdapter) << "Updated Adapter properties for" << this; + } +} + +void BluetoothAdapter::removeDevice(const QString& devicePath) { + qCDebug(logAdapter) << "Removing device" << devicePath << "from adapter" << this; + + auto reply = this->mInterface->RemoveDevice(QDBusObjectPath(devicePath)); + + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this, devicePath](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + + if (reply.isError()) { + qCWarning(logAdapter).nospace() + << "Failed to remove device " << devicePath << " from adapter" << this << ": " + << reply.error().message(); + } else { + qCDebug(logAdapter) << "Successfully removed device" << devicePath << "from adapter" + << this; + } + + delete watcher; + } + ); +} + +void BluetoothAdapter::startDiscovery() { + if (this->bDiscovering) return; + qCDebug(logAdapter) << "Starting discovery for adapter" << this; + + auto reply = this->mInterface->StartDiscovery(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + + if (reply.isError()) { + qCWarning(logAdapter).nospace() + << "Failed to start discovery on adapter" << this << ": " << reply.error().message(); + } else { + qCDebug(logAdapter) << "Successfully started discovery on adapter" << this; + } + + delete watcher; + } + ); +} + +void BluetoothAdapter::stopDiscovery() { + if (!this->bDiscovering) return; + qCDebug(logAdapter) << "Stopping discovery for adapter" << this; + + auto reply = this->mInterface->StopDiscovery(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + + if (reply.isError()) { + qCWarning(logAdapter).nospace() + << "Failed to stop discovery on adapter " << this << ": " << reply.error().message(); + } else { + qCDebug(logAdapter) << "Successfully stopped discovery on adapter" << this; + } + + delete watcher; + } + ); +} + +} // namespace qs::bluetooth + +namespace qs::dbus { + +using namespace qs::bluetooth; + +DBusResult +DBusDataTransform::fromWire(const Wire& wire) { + if (wire == QStringLiteral("off")) { + return BluetoothAdapterState::Disabled; + } else if (wire == QStringLiteral("on")) { + return BluetoothAdapterState::Enabled; + } else if (wire == QStringLiteral("off-enabling")) { + return BluetoothAdapterState::Enabling; + } else if (wire == QStringLiteral("on-disabling")) { + return BluetoothAdapterState::Disabling; + } else if (wire == QStringLiteral("off-blocked")) { + return BluetoothAdapterState::Blocked; + } else { + return QDBusError( + QDBusError::InvalidArgs, + QString("Invalid BluetoothAdapterState: %1").arg(wire) + ); + } +} + +} // namespace qs::dbus + +QDebug operator<<(QDebug debug, const qs::bluetooth::BluetoothAdapter* adapter) { + auto saver = QDebugStateSaver(debug); + + if (adapter) { + debug.nospace() << "BluetoothAdapter(" << static_cast(adapter) + << ", path=" << adapter->path() << ")"; + } else { + debug << "BluetoothAdapter(nullptr)"; + } + + return debug; +} diff --git a/src/bluetooth/adapter.hpp b/src/bluetooth/adapter.hpp new file mode 100644 index 00000000..d7f21d7e --- /dev/null +++ b/src/bluetooth/adapter.hpp @@ -0,0 +1,173 @@ +#pragma once + +#include +#include +#include + +#include "../core/doc.hpp" +#include "../core/model.hpp" +#include "../dbus/properties.hpp" +#include "dbus_adapter.h" + +namespace qs::bluetooth { + +///! Power state of a Bluetooth adapter. +class BluetoothAdapterState: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + /// The adapter is powered off. + Disabled = 0, + /// The adapter is powered on. + Enabled = 1, + /// The adapter is transitioning from off to on. + Enabling = 2, + /// The adapter is transitioning from on to off. + Disabling = 3, + /// The adapter is blocked by rfkill. + Blocked = 4, + }; + Q_ENUM(Enum); + + Q_INVOKABLE static QString toString(BluetoothAdapterState::Enum state); +}; + +} // namespace qs::bluetooth + +namespace qs::dbus { + +template <> +struct DBusDataTransform { + using Wire = QString; + using Data = qs::bluetooth::BluetoothAdapterState::Enum; + static DBusResult fromWire(const Wire& wire); +}; + +} // namespace qs::dbus + +namespace qs::bluetooth { + +class BluetoothAdapter; +class BluetoothDevice; + +///! A Bluetooth adapter +class BluetoothAdapter: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE(""); + // clang-format off + /// System provided name of the adapter. See @@adapterId for the internal identifier. + Q_PROPERTY(QString name READ default NOTIFY nameChanged BINDABLE bindableName); + /// True if the adapter is currently enabled. More detailed state is available from @@state. + Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged); + /// Detailed power state of the adapter. + Q_PROPERTY(BluetoothAdapterState::Enum state READ default NOTIFY stateChanged BINDABLE bindableState); + /// True if the adapter can be discovered by other bluetooth devices. + Q_PROPERTY(bool discoverable READ discoverable WRITE setDiscoverable NOTIFY discoverableChanged); + /// Timeout in seconds for how long the adapter stays discoverable after @@discoverable is set to true. + /// A value of 0 means the adapter stays discoverable forever. + Q_PROPERTY(quint32 discoverableTimeout READ discoverableTimeout WRITE setDiscoverableTimeout NOTIFY discoverableTimeoutChanged); + /// True if the adapter is scanning for new devices. + Q_PROPERTY(bool discovering READ discovering WRITE setDiscovering NOTIFY discoveringChanged); + /// True if the adapter is accepting incoming pairing requests. + /// + /// This only affects incoming pairing requests and should typically only be changed + /// by system settings applications. Defaults to true. + Q_PROPERTY(bool pairable READ pairable WRITE setPairable NOTIFY pairableChanged); + /// Timeout in seconds for how long the adapter stays pairable after @@pairable is set to true. + /// A value of 0 means the adapter stays pairable forever. Defaults to 0. + Q_PROPERTY(quint32 pairableTimeout READ pairableTimeout WRITE setPairableTimeout NOTIFY pairableTimeoutChanged); + /// Bluetooth devices connected to this adapter. + QSDOC_TYPE_OVERRIDE(ObjectModel*); + Q_PROPERTY(UntypedObjectModel* devices READ devices CONSTANT); + /// The internal ID of the adapter (e.g., "hci0"). + Q_PROPERTY(QString adapterId READ adapterId CONSTANT); + /// DBus path of the adapter under the `org.bluez` system service. + Q_PROPERTY(QString dbusPath READ path CONSTANT); + // clang-format on + +public: + explicit BluetoothAdapter(const QString& path, QObject* parent = nullptr); + + [[nodiscard]] bool isValid() const { return this->mInterface->isValid(); } + [[nodiscard]] QString path() const { return this->mInterface->path(); } + [[nodiscard]] QString adapterId() const; + + [[nodiscard]] bool enabled() const { return this->bEnabled; } + void setEnabled(bool enabled); + + [[nodiscard]] bool discoverable() const { return this->bDiscoverable; } + void setDiscoverable(bool discoverable); + + [[nodiscard]] bool discovering() const { return this->bDiscovering; } + void setDiscovering(bool discovering); + + [[nodiscard]] quint32 discoverableTimeout() const { return this->bDiscoverableTimeout; } + void setDiscoverableTimeout(quint32 timeout); + + [[nodiscard]] bool pairable() const { return this->bPairable; } + void setPairable(bool pairable); + + [[nodiscard]] quint32 pairableTimeout() const { return this->bPairableTimeout; } + void setPairableTimeout(quint32 timeout); + + [[nodiscard]] QBindable bindableName() { return &this->bName; } + [[nodiscard]] QBindable bindableEnabled() { return &this->bEnabled; } + [[nodiscard]] QBindable bindableState() { return &this->bState; } + [[nodiscard]] QBindable bindableDiscoverable() { return &this->bDiscoverable; } + [[nodiscard]] QBindable bindableDiscoverableTimeout() { + return &this->bDiscoverableTimeout; + } + [[nodiscard]] QBindable bindableDiscovering() { return &this->bDiscovering; } + [[nodiscard]] QBindable bindablePairable() { return &this->bPairable; } + [[nodiscard]] QBindable bindablePairableTimeout() { return &this->bPairableTimeout; } + [[nodiscard]] ObjectModel* devices() { return &this->mDevices; } + + void addInterface(const QString& interface, const QVariantMap& properties); + void removeDevice(const QString& devicePath); + + void startDiscovery(); + void stopDiscovery(); + +signals: + void nameChanged(); + void enabledChanged(); + void stateChanged(); + void discoverableChanged(); + void discoverableTimeoutChanged(); + void discoveringChanged(); + void pairableChanged(); + void pairableTimeoutChanged(); + +private: + DBusBluezAdapterInterface* mInterface = nullptr; + ObjectModel mDevices {this}; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, QString, bName, &BluetoothAdapter::nameChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, bool, bEnabled, &BluetoothAdapter::enabledChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, BluetoothAdapterState::Enum, bState, &BluetoothAdapter::stateChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, bool, bDiscoverable, &BluetoothAdapter::discoverableChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, quint32, bDiscoverableTimeout, &BluetoothAdapter::discoverableTimeoutChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, bool, bDiscovering, &BluetoothAdapter::discoveringChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, bool, bPairable, &BluetoothAdapter::pairableChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, quint32, bPairableTimeout, &BluetoothAdapter::pairableTimeoutChanged); + + QS_DBUS_BINDABLE_PROPERTY_GROUP(BluetoothAdapter, properties); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pName, bName, properties, "Alias"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pEnabled, bEnabled, properties, "Powered"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pState, bState, properties, "PowerState"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pDiscoverable, bDiscoverable, properties, "Discoverable"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pDiscoverableTimeout, bDiscoverableTimeout, properties, "DiscoverableTimeout"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pDiscovering, bDiscovering, properties, "Discovering"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pPairable, bPairable, properties, "Pairable"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pPairableTimeout, bPairableTimeout, properties, "PairableTimeout"); + // clang-format on +}; + +} // namespace qs::bluetooth + +QDebug operator<<(QDebug debug, const qs::bluetooth::BluetoothAdapter* adapter); diff --git a/src/bluetooth/bluez.cpp b/src/bluetooth/bluez.cpp new file mode 100644 index 00000000..f2c4300d --- /dev/null +++ b/src/bluetooth/bluez.cpp @@ -0,0 +1,168 @@ +#include "bluez.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "../core/logcat.hpp" +#include "../dbus/dbus_objectmanager_types.hpp" +#include "../dbus/objectmanager.hpp" +#include "adapter.hpp" +#include "device.hpp" + +namespace qs::bluetooth { + +namespace { +QS_LOGGING_CATEGORY(logBluetooth, "quickshell.bluetooth", QtWarningMsg); +} + +Bluez* Bluez::instance() { + static auto* instance = new Bluez(); + return instance; +} + +Bluez::Bluez() { this->init(); } + +void Bluez::updateDefaultAdapter() { + const auto& adapters = this->mAdapters.valueList(); + this->bDefaultAdapter = adapters.empty() ? nullptr : adapters.first(); +} + +void Bluez::init() { + qCDebug(logBluetooth) << "Connecting to BlueZ"; + + auto bus = QDBusConnection::systemBus(); + + if (!bus.isConnected()) { + qCWarning(logBluetooth) << "Could not connect to DBus. Bluetooth integration is not available."; + return; + } + + this->objectManager = new qs::dbus::DBusObjectManager(this); + + QObject::connect( + this->objectManager, + &qs::dbus::DBusObjectManager::interfacesAdded, + this, + &Bluez::onInterfacesAdded + ); + + QObject::connect( + this->objectManager, + &qs::dbus::DBusObjectManager::interfacesRemoved, + this, + &Bluez::onInterfacesRemoved + ); + + if (!this->objectManager->setInterface("org.bluez", "/", bus)) { + qCDebug(logBluetooth) << "BlueZ is not running. Bluetooth integration will not work."; + return; + } +} + +void Bluez::onInterfacesAdded( + const QDBusObjectPath& path, + const DBusObjectManagerInterfaces& interfaces +) { + if (auto* adapter = this->mAdapterMap.value(path.path())) { + for (const auto& [interface, properties]: interfaces.asKeyValueRange()) { + adapter->addInterface(interface, properties); + } + } else if (auto* device = this->mDeviceMap.value(path.path())) { + for (const auto& [interface, properties]: interfaces.asKeyValueRange()) { + device->addInterface(interface, properties); + } + } else if (interfaces.contains("org.bluez.Adapter1")) { + auto* adapter = new BluetoothAdapter(path.path(), this); + + if (!adapter->isValid()) { + qCWarning(logBluetooth) << "Adapter path is not valid, cannot track: " << device; + delete adapter; + return; + } + + qCDebug(logBluetooth) << "Tracked new adapter" << adapter; + + for (const auto& [interface, properties]: interfaces.asKeyValueRange()) { + adapter->addInterface(interface, properties); + } + + for (auto* device: this->mDevices.valueList()) { + if (device->adapterPath() == path) { + adapter->devices()->insertObject(device); + qCDebug(logBluetooth) << "Added tracked device" << device << "to new adapter" << adapter; + emit device->adapterChanged(); + } + } + + this->mAdapterMap.insert(path.path(), adapter); + this->mAdapters.insertObject(adapter); + this->updateDefaultAdapter(); + } else if (interfaces.contains("org.bluez.Device1")) { + auto* device = new BluetoothDevice(path.path(), this); + + if (!device->isValid()) { + qCWarning(logBluetooth) << "Device path is not valid, cannot track: " << device; + delete device; + return; + } + + qCDebug(logBluetooth) << "Tracked new device" << device; + + for (const auto& [interface, properties]: interfaces.asKeyValueRange()) { + device->addInterface(interface, properties); + } + + if (auto* adapter = device->adapter()) { + adapter->devices()->insertObject(device); + qCDebug(logBluetooth) << "Added device" << device << "to adapter" << adapter; + } + + this->mDeviceMap.insert(path.path(), device); + this->mDevices.insertObject(device); + } +} + +void Bluez::onInterfacesRemoved(const QDBusObjectPath& path, const QStringList& interfaces) { + if (auto* adapter = this->mAdapterMap.value(path.path())) { + if (interfaces.contains("org.bluez.Adapter1")) { + qCDebug(logBluetooth) << "Adapter removed:" << adapter; + + this->mAdapterMap.remove(path.path()); + this->mAdapters.removeObject(adapter); + this->updateDefaultAdapter(); + delete adapter; + } + } else if (auto* device = this->mDeviceMap.value(path.path())) { + if (interfaces.contains("org.bluez.Device1")) { + qCDebug(logBluetooth) << "Device removed:" << device; + + if (auto* adapter = device->adapter()) { + adapter->devices()->removeObject(device); + } + + this->mDeviceMap.remove(path.path()); + this->mDevices.removeObject(device); + delete device; + } else { + for (const auto& interface: interfaces) { + device->removeInterface(interface); + } + } + } +} + +BluezQml::BluezQml() { + QObject::connect( + Bluez::instance(), + &Bluez::defaultAdapterChanged, + this, + &BluezQml::defaultAdapterChanged + ); +} + +} // namespace qs::bluetooth diff --git a/src/bluetooth/bluez.hpp b/src/bluetooth/bluez.hpp new file mode 100644 index 00000000..9d7c93ca --- /dev/null +++ b/src/bluetooth/bluez.hpp @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "../core/doc.hpp" +#include "../core/model.hpp" +#include "../dbus/dbus_objectmanager_types.hpp" +#include "../dbus/objectmanager.hpp" + +namespace qs::bluetooth { + +class BluetoothAdapter; +class BluetoothDevice; + +class Bluez: public QObject { + Q_OBJECT; + +public: + [[nodiscard]] ObjectModel* adapters() { return &this->mAdapters; } + [[nodiscard]] ObjectModel* devices() { return &this->mDevices; } + + [[nodiscard]] BluetoothAdapter* adapter(const QString& path) { + return this->mAdapterMap.value(path); + } + + static Bluez* instance(); + +signals: + void defaultAdapterChanged(); + +private slots: + void + onInterfacesAdded(const QDBusObjectPath& path, const DBusObjectManagerInterfaces& interfaces); + void onInterfacesRemoved(const QDBusObjectPath& path, const QStringList& interfaces); + void updateDefaultAdapter(); + +private: + explicit Bluez(); + void init(); + + qs::dbus::DBusObjectManager* objectManager = nullptr; + QHash mAdapterMap; + QHash mDeviceMap; + ObjectModel mAdapters {this}; + ObjectModel mDevices {this}; + +public: + Q_OBJECT_BINDABLE_PROPERTY( + Bluez, + BluetoothAdapter*, + bDefaultAdapter, + &Bluez::defaultAdapterChanged + ); +}; + +///! Bluetooth manager +/// Provides access to bluetooth devices and adapters. +class BluezQml: public QObject { + Q_OBJECT; + QML_NAMED_ELEMENT(Bluetooth); + QML_SINGLETON; + // clang-format off + /// The default bluetooth adapter. Usually there is only one. + Q_PROPERTY(BluetoothAdapter* defaultAdapter READ default NOTIFY defaultAdapterChanged BINDABLE bindableDefaultAdapter); + QSDOC_TYPE_OVERRIDE(ObjectModel*); + /// A list of all bluetooth adapters. See @@defaultAdapter for the default. + Q_PROPERTY(UntypedObjectModel* adapters READ adapters CONSTANT); + QSDOC_TYPE_OVERRIDE(ObjectModel*); + /// A list of all connected bluetooth devices across all adapters. + /// See @@BluetoothAdapter.devices for the devices connected to a single adapter. + Q_PROPERTY(UntypedObjectModel* devices READ devices CONSTANT); + // clang-format on + +signals: + void defaultAdapterChanged(); + +public: + explicit BluezQml(); + + [[nodiscard]] static ObjectModel* adapters() { + return Bluez::instance()->adapters(); + } + + [[nodiscard]] static ObjectModel* devices() { + return Bluez::instance()->devices(); + } + + [[nodiscard]] static QBindable bindableDefaultAdapter() { + return &Bluez::instance()->bDefaultAdapter; + } +}; + +} // namespace qs::bluetooth diff --git a/src/bluetooth/device.cpp b/src/bluetooth/device.cpp new file mode 100644 index 00000000..7265b241 --- /dev/null +++ b/src/bluetooth/device.cpp @@ -0,0 +1,319 @@ +#include "device.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/logcat.hpp" +#include "../dbus/properties.hpp" +#include "adapter.hpp" +#include "bluez.hpp" +#include "dbus_device.h" + +namespace qs::bluetooth { + +namespace { +QS_LOGGING_CATEGORY(logDevice, "quickshell.bluetooth.device", QtWarningMsg); +} + +QString BluetoothDeviceState::toString(BluetoothDeviceState::Enum state) { + switch (state) { + case BluetoothDeviceState::Disconnected: return QStringLiteral("Disconnected"); + case BluetoothDeviceState::Connected: return QStringLiteral("Connected"); + case BluetoothDeviceState::Disconnecting: return QStringLiteral("Disconnecting"); + case BluetoothDeviceState::Connecting: return QStringLiteral("Connecting"); + default: return QStringLiteral("Unknown"); + } +} + +BluetoothDevice::BluetoothDevice(const QString& path, QObject* parent): QObject(parent) { + this->mInterface = + new DBusBluezDeviceInterface("org.bluez", path, QDBusConnection::systemBus(), this); + + if (!this->mInterface->isValid()) { + qCWarning(logDevice) << "Could not create DBus interface for device at" << path; + delete this->mInterface; + this->mInterface = nullptr; + return; + } + + this->properties.setInterface(this->mInterface); +} + +BluetoothAdapter* BluetoothDevice::adapter() const { + return Bluez::instance()->adapter(this->bAdapterPath.value().path()); +} + +void BluetoothDevice::setConnected(bool connected) { + if (connected == this->bConnected) return; + + if (connected) { + this->connect(); + } else { + this->disconnect(); + } +} + +void BluetoothDevice::setTrusted(bool trusted) { + if (trusted == this->bTrusted) return; + this->bTrusted = trusted; + this->pTrusted.write(); +} + +void BluetoothDevice::setBlocked(bool blocked) { + if (blocked == this->bBlocked) return; + this->bBlocked = blocked; + this->pBlocked.write(); +} + +void BluetoothDevice::setName(const QString& name) { + if (name == this->bName) return; + this->bName = name; + this->pName.write(); +} + +void BluetoothDevice::setWakeAllowed(bool wakeAllowed) { + if (wakeAllowed == this->bWakeAllowed) return; + this->bWakeAllowed = wakeAllowed; + this->pWakeAllowed.write(); +} + +void BluetoothDevice::connect() { + if (this->bConnected) { + qCCritical(logDevice) << "Device" << this << "is already connected"; + return; + } + + if (this->bState == BluetoothDeviceState::Connecting) { + qCCritical(logDevice) << "Device" << this << "is already connecting"; + return; + } + + qCDebug(logDevice) << "Connecting to device" << this; + this->bState = BluetoothDeviceState::Connecting; + + auto reply = this->mInterface->Connect(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + + if (reply.isError()) { + qCWarning(logDevice).nospace() + << "Failed to connect to device " << this << ": " << reply.error().message(); + + this->bState = this->bConnected ? BluetoothDeviceState::Connected + : BluetoothDeviceState::Disconnected; + } else { + qCDebug(logDevice) << "Successfully connected to to device" << this; + } + + delete watcher; + } + ); +} + +void BluetoothDevice::disconnect() { + if (!this->bConnected) { + qCCritical(logDevice) << "Device" << this << "is already disconnected"; + return; + } + + if (this->bState == BluetoothDeviceState::Disconnecting) { + qCCritical(logDevice) << "Device" << this << "is already disconnecting"; + return; + } + + qCDebug(logDevice) << "Disconnecting from device" << this; + this->bState = BluetoothDeviceState::Disconnecting; + + auto reply = this->mInterface->Disconnect(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + + if (reply.isError()) { + qCWarning(logDevice).nospace() + << "Failed to disconnect from device " << this << ": " << reply.error().message(); + + this->bState = this->bConnected ? BluetoothDeviceState::Connected + : BluetoothDeviceState::Disconnected; + } else { + qCDebug(logDevice) << "Successfully disconnected from from device" << this; + } + + delete watcher; + } + ); +} + +void BluetoothDevice::pair() { + if (this->bPaired) { + qCCritical(logDevice) << "Device" << this << "is already paired"; + return; + } + + if (this->bPairing) { + qCCritical(logDevice) << "Device" << this << "is already pairing"; + return; + } + + qCDebug(logDevice) << "Pairing with device" << this; + this->bPairing = true; + + auto reply = this->mInterface->Pair(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + if (reply.isError()) { + qCWarning(logDevice).nospace() + << "Failed to pair with device " << this << ": " << reply.error().message(); + } else { + qCDebug(logDevice) << "Successfully initiated pairing with device" << this; + } + + this->bPairing = false; + delete watcher; + } + ); +} + +void BluetoothDevice::cancelPair() { + if (!this->bPairing) { + qCCritical(logDevice) << "Device" << this << "is not currently pairing"; + return; + } + + qCDebug(logDevice) << "Cancelling pairing with device" << this; + + auto reply = this->mInterface->CancelPairing(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + if (reply.isError()) { + qCWarning(logDevice) << "Failed to cancel pairing with device" << this << ":" + << reply.error().message(); + } else { + qCDebug(logDevice) << "Successfully cancelled pairing with device" << this; + } + + this->bPairing = false; + delete watcher; + } + ); +} + +void BluetoothDevice::forget() { + if (!this->mInterface || !this->mInterface->isValid()) { + qCCritical(logDevice) << "Cannot forget - device interface is invalid"; + return; + } + + if (auto* adapter = Bluez::instance()->adapter(this->bAdapterPath.value().path())) { + qCDebug(logDevice) << "Forgetting device" << this << "via adapter" << adapter; + adapter->removeDevice(this->path()); + } else { + qCCritical(logDevice) << "Could not find adapter for path" << this->bAdapterPath.value().path() + << "to forget from"; + } +} + +void BluetoothDevice::addInterface(const QString& interface, const QVariantMap& properties) { + if (interface == "org.bluez.Device1") { + this->properties.updatePropertySet(properties, false); + qCDebug(logDevice) << "Updated Device properties for" << this; + } else if (interface == "org.bluez.Battery1") { + if (!this->mBatteryInterface) { + this->mBatteryInterface = new QDBusInterface( + "org.bluez", + this->path(), + "org.bluez.Battery1", + QDBusConnection::systemBus(), + this + ); + + if (!this->mBatteryInterface->isValid()) { + qCWarning(logDevice) << "Could not create Battery interface for device at" << this; + delete this->mBatteryInterface; + this->mBatteryInterface = nullptr; + return; + } + } + + this->batteryProperties.setInterface(this->mBatteryInterface); + this->batteryProperties.updatePropertySet(properties, false); + + emit this->batteryAvailableChanged(); + qCDebug(logDevice) << "Updated Battery properties for" << this; + } +} + +void BluetoothDevice::removeInterface(const QString& interface) { + if (interface == "org.bluez.Battery1" && this->mBatteryInterface) { + this->batteryProperties.setInterface(nullptr); + delete this->mBatteryInterface; + this->mBatteryInterface = nullptr; + this->bBattery = 0; + + emit this->batteryAvailableChanged(); + qCDebug(logDevice) << "Battery interface removed from device" << this; + } +} + +void BluetoothDevice::onConnectedChanged() { + this->bState = + this->bConnected ? BluetoothDeviceState::Connected : BluetoothDeviceState::Disconnected; + emit this->connectedChanged(); +} + +} // namespace qs::bluetooth + +namespace qs::dbus { + +using namespace qs::bluetooth; + +DBusResult DBusDataTransform::fromWire(quint8 percentage) { + return DBusResult(percentage * 0.01); +} + +} // namespace qs::dbus + +QDebug operator<<(QDebug debug, const qs::bluetooth::BluetoothDevice* device) { + auto saver = QDebugStateSaver(debug); + + if (device) { + debug.nospace() << "BluetoothDevice(" << static_cast(device) + << ", path=" << device->path() << ")"; + } else { + debug << "BluetoothDevice(nullptr)"; + } + + return debug; +} diff --git a/src/bluetooth/device.hpp b/src/bluetooth/device.hpp new file mode 100644 index 00000000..23f230f5 --- /dev/null +++ b/src/bluetooth/device.hpp @@ -0,0 +1,225 @@ +#pragma once + +#include +#include +#include +#include + +#include "../dbus/properties.hpp" +#include "dbus_device.h" + +namespace qs::bluetooth { + +///! Connection state of a Bluetooth device. +class BluetoothDeviceState: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + /// The device is not connected. + Disconnected = 0, + /// The device is connected. + Connected = 1, + /// The device is disconnecting. + Disconnecting = 2, + /// The device is connecting. + Connecting = 3, + }; + Q_ENUM(Enum); + + Q_INVOKABLE static QString toString(BluetoothDeviceState::Enum state); +}; + +struct BatteryPercentage {}; + +} // namespace qs::bluetooth + +namespace qs::dbus { + +template <> +struct DBusDataTransform { + using Wire = quint8; + using Data = qreal; + static DBusResult fromWire(Wire percentage); +}; + +} // namespace qs::dbus + +namespace qs::bluetooth { + +class BluetoothAdapter; + +///! A tracked Bluetooth device. +class BluetoothDevice: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE(""); + // clang-format off + /// MAC address of the device. + Q_PROPERTY(QString address READ default NOTIFY addressChanged BINDABLE bindableAddress); + /// The name of the Bluetooth device. This property may be written to create an alias, or set to + /// an empty string to fall back to the device provided name. + /// + /// See @@deviceName for the name provided by the device. + Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged); + /// The name of the Bluetooth device, ignoring user provided aliases. See also @@name + /// which returns a user provided alias if set. + Q_PROPERTY(QString deviceName READ default NOTIFY deviceNameChanged BINDABLE bindableDeviceName); + /// System icon representing the device type. Use @@Quickshell.Quickshell.iconPath() to display this in an image. + Q_PROPERTY(QString icon READ default NOTIFY iconChanged BINDABLE bindableIcon); + /// Connection state of the device. + Q_PROPERTY(BluetoothDeviceState::Enum state READ default NOTIFY stateChanged BINDABLE bindableState); + /// True if the device is currently connected to the computer. + /// + /// Setting this property is equivalent to calling @@connect() and @@disconnect(). + /// + /// > [!NOTE] @@state provides more detailed information if required. + Q_PROPERTY(bool connected READ connected WRITE setConnected NOTIFY connectedChanged); + /// True if the device is paired to the computer. + /// + /// > [!NOTE] @@pair() can be used to pair a device, however you must @@forget() the device to unpair it. + Q_PROPERTY(bool paired READ default NOTIFY pairedChanged BINDABLE bindablePaired); + /// True if pairing information is stored for future connections. + Q_PROPERTY(bool bonded READ default NOTIFY bondedChanged BINDABLE bindableBonded); + /// True if the device is currently being paired. + /// + /// > [!NOTE] @@cancelPair() can be used to cancel the pairing process. + Q_PROPERTY(bool pairing READ pairing NOTIFY pairingChanged); + /// True if the device is considered to be trusted by the system. + /// Trusted devices are allowed to reconnect themselves to the system without intervention. + Q_PROPERTY(bool trusted READ trusted WRITE setTrusted NOTIFY trustedChanged); + /// True if the device is blocked from connecting. + /// If a device is blocked, any connection attempts will be immediately rejected by the system. + Q_PROPERTY(bool blocked READ blocked WRITE setBlocked NOTIFY blockedChanged); + /// True if the device is allowed to wake up the host system from suspend. + Q_PROPERTY(bool wakeAllowed READ wakeAllowed WRITE setWakeAllowed NOTIFY wakeAllowedChanged); + /// True if the connected device reports its battery level. Battery level can be accessed via @@battery. + Q_PROPERTY(bool batteryAvailable READ batteryAvailable NOTIFY batteryAvailableChanged); + /// Battery level of the connected device, from `0.0` to `1.0`. Only valid if @@batteryAvailable is true. + Q_PROPERTY(qreal battery READ default NOTIFY batteryChanged BINDABLE bindableBattery); + /// The Bluetooth adapter this device belongs to. + Q_PROPERTY(BluetoothAdapter* adapter READ adapter NOTIFY adapterChanged); + /// DBus path of the device under the `org.bluez` system service. + Q_PROPERTY(QString dbusPath READ path CONSTANT); + // clang-format on + +public: + explicit BluetoothDevice(const QString& path, QObject* parent = nullptr); + + /// Attempt to connect to the device. + Q_INVOKABLE void connect(); + /// Disconnect from the device. + Q_INVOKABLE void disconnect(); + /// Attempt to pair the device. + /// + /// > [!NOTE] @@paired and @@pairing return the current pairing status of the device. + Q_INVOKABLE void pair(); + /// Cancel an active pairing attempt. + Q_INVOKABLE void cancelPair(); + /// Forget the device. + Q_INVOKABLE void forget(); + + [[nodiscard]] bool isValid() const { return this->mInterface && this->mInterface->isValid(); } + [[nodiscard]] QString path() const { + return this->mInterface ? this->mInterface->path() : QString(); + } + + [[nodiscard]] bool batteryAvailable() const { return this->mBatteryInterface != nullptr; } + [[nodiscard]] BluetoothAdapter* adapter() const; + [[nodiscard]] QDBusObjectPath adapterPath() const { return this->bAdapterPath.value(); } + + [[nodiscard]] bool connected() const { return this->bConnected; } + void setConnected(bool connected); + + [[nodiscard]] bool trusted() const { return this->bTrusted; } + void setTrusted(bool trusted); + + [[nodiscard]] bool blocked() const { return this->bBlocked; } + void setBlocked(bool blocked); + + [[nodiscard]] QString name() const { return this->bName; } + void setName(const QString& name); + + [[nodiscard]] bool wakeAllowed() const { return this->bWakeAllowed; } + void setWakeAllowed(bool wakeAllowed); + + [[nodiscard]] bool pairing() const { return this->bPairing; } + + [[nodiscard]] QBindable bindableAddress() { return &this->bAddress; } + [[nodiscard]] QBindable bindableDeviceName() { return &this->bDeviceName; } + [[nodiscard]] QBindable bindableName() { return &this->bName; } + [[nodiscard]] QBindable bindableConnected() { return &this->bConnected; } + [[nodiscard]] QBindable bindablePaired() { return &this->bPaired; } + [[nodiscard]] QBindable bindableBonded() { return &this->bBonded; } + [[nodiscard]] QBindable bindableTrusted() { return &this->bTrusted; } + [[nodiscard]] QBindable bindableBlocked() { return &this->bBlocked; } + [[nodiscard]] QBindable bindableWakeAllowed() { return &this->bWakeAllowed; } + [[nodiscard]] QBindable bindableIcon() { return &this->bIcon; } + [[nodiscard]] QBindable bindableBattery() { return &this->bBattery; } + [[nodiscard]] QBindable bindableState() { return &this->bState; } + + void addInterface(const QString& interface, const QVariantMap& properties); + void removeInterface(const QString& interface); + +signals: + void addressChanged(); + void deviceNameChanged(); + void nameChanged(); + void connectedChanged(); + void stateChanged(); + void pairedChanged(); + void bondedChanged(); + void pairingChanged(); + void trustedChanged(); + void blockedChanged(); + void wakeAllowedChanged(); + void iconChanged(); + void batteryAvailableChanged(); + void batteryChanged(); + void adapterChanged(); + +private: + void onConnectedChanged(); + + DBusBluezDeviceInterface* mInterface = nullptr; + QDBusInterface* mBatteryInterface = nullptr; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, QString, bAddress, &BluetoothDevice::addressChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, QString, bDeviceName, &BluetoothDevice::deviceNameChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, QString, bName, &BluetoothDevice::nameChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bConnected, &BluetoothDevice::onConnectedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bPaired, &BluetoothDevice::pairedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bBonded, &BluetoothDevice::bondedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bTrusted, &BluetoothDevice::trustedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bBlocked, &BluetoothDevice::blockedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bWakeAllowed, &BluetoothDevice::wakeAllowedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, QString, bIcon, &BluetoothDevice::iconChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, QDBusObjectPath, bAdapterPath); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, qreal, bBattery, &BluetoothDevice::batteryChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, BluetoothDeviceState::Enum, bState, &BluetoothDevice::stateChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bPairing, &BluetoothDevice::pairingChanged); + + QS_DBUS_BINDABLE_PROPERTY_GROUP(BluetoothDevice, properties); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pAddress, bAddress, properties, "Address"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pDeviceName, bDeviceName, properties, "Name"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pName, bName, properties, "Alias"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pConnected, bConnected, properties, "Connected"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pPaired, bPaired, properties, "Paired"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pBonded, bBonded, properties, "Bonded"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pTrusted, bTrusted, properties, "Trusted"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pBlocked, bBlocked, properties, "Blocked"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pWakeAllowed, bWakeAllowed, properties, "WakeAllowed"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pIcon, bIcon, properties, "Icon"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pAdapterPath, bAdapterPath, properties, "Adapter"); + + QS_DBUS_BINDABLE_PROPERTY_GROUP(BluetoothDevice, batteryProperties); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, BatteryPercentage, pBattery, bBattery, batteryProperties, "Percentage", true); + // clang-format on +}; + +} // namespace qs::bluetooth + +QDebug operator<<(QDebug debug, const qs::bluetooth::BluetoothDevice* device); diff --git a/src/bluetooth/module.md b/src/bluetooth/module.md new file mode 100644 index 00000000..eb797d93 --- /dev/null +++ b/src/bluetooth/module.md @@ -0,0 +1,12 @@ +name = "Quickshell.Bluetooth" +description = "Bluetooth API" +headers = [ + "bluez.hpp", + "adapter.hpp", + "device.hpp", +] +----- +This module exposes Bluetooth management APIs provided by the BlueZ DBus interface. +Both DBus and BlueZ must be running to use it. + +See the @@Quickshell.Bluetooth.Bluetooth singleton. diff --git a/src/bluetooth/org.bluez.Adapter.xml b/src/bluetooth/org.bluez.Adapter.xml new file mode 100644 index 00000000..286991e1 --- /dev/null +++ b/src/bluetooth/org.bluez.Adapter.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/bluetooth/org.bluez.Device.xml b/src/bluetooth/org.bluez.Device.xml new file mode 100644 index 00000000..274e9fde --- /dev/null +++ b/src/bluetooth/org.bluez.Device.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/bluetooth/test/manual/test.qml b/src/bluetooth/test/manual/test.qml new file mode 100644 index 00000000..21c53b1d --- /dev/null +++ b/src/bluetooth/test/manual/test.qml @@ -0,0 +1,200 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Bluetooth + +FloatingWindow { + color: contentItem.palette.window + + ListView { + anchors.fill: parent + anchors.margins: 5 + model: Bluetooth.adapters + + delegate: WrapperRectangle { + width: parent.width + color: "transparent" + border.color: palette.button + border.width: 1 + margin: 5 + + ColumnLayout { + Label { text: `Adapter: ${modelData.name} (${modelData.adapterId})` } + + RowLayout { + Layout.fillWidth: true + + CheckBox { + text: "Enable" + checked: modelData.enabled + onToggled: modelData.enabled = checked + } + + Label { + color: modelData.state === BluetoothAdapterState.Blocked ? palette.errorText : palette.placeholderText + text: BluetoothAdapterState.toString(modelData.state) + } + + CheckBox { + text: "Discoverable" + checked: modelData.discoverable + onToggled: modelData.discoverable = checked + } + + CheckBox { + text: "Discovering" + checked: modelData.discovering + onToggled: modelData.discovering = checked + } + + CheckBox { + text: "Pairable" + checked: modelData.pairable + onToggled: modelData.pairable = checked + } + } + + RowLayout { + Layout.fillWidth: true + + Label { text: "Discoverable timeout:" } + + SpinBox { + from: 0 + to: 3600 + value: modelData.discoverableTimeout + onValueModified: modelData.discoverableTimeout = value + textFromValue: time => time === 0 ? "∞" : time + "s" + } + + Label { text: "Pairable timeout:" } + + SpinBox { + from: 0 + to: 3600 + value: modelData.pairableTimeout + onValueModified: modelData.pairableTimeout = value + textFromValue: time => time === 0 ? "∞" : time + "s" + } + } + + Repeater { + model: modelData.devices + + WrapperRectangle { + Layout.fillWidth: true + color: palette.button + border.color: palette.mid + border.width: 1 + margin: 5 + + RowLayout { + ColumnLayout { + Layout.fillWidth: true + + RowLayout { + IconImage { + Layout.fillHeight: true + implicitWidth: height + source: Quickshell.iconPath(modelData.icon) + } + + TextField { + text: modelData.name + font.bold: true + background: null + readOnly: false + selectByMouse: true + onEditingFinished: modelData.name = text + } + + Label { + visible: modelData.name && modelData.name !== modelData.deviceName + text: `(${modelData.deviceName})` + color: palette.placeholderText + } + } + + RowLayout { + Label { + text: modelData.address + color: palette.placeholderText + } + + Label { + visible: modelData.batteryAvailable + text: `| Battery: ${Math.round(modelData.battery * 100)}%` + color: palette.placeholderText + } + } + + RowLayout { + Label { + text: BluetoothDeviceState.toString(modelData.state) + + color: modelData.connected ? palette.link : palette.placeholderText + } + + Label { + text: modelData.pairing ? "Pairing" : (modelData.paired ? "Paired" : "Not Paired") + color: modelData.paired || modelData.pairing ? palette.link : palette.placeholderText + } + + Label { + visible: modelData.bonded + text: "| Bonded" + color: palette.link + } + + CheckBox { + text: "Trusted" + checked: modelData.trusted + onToggled: modelData.trusted = checked + } + + CheckBox { + text: "Blocked" + checked: modelData.blocked + onToggled: modelData.blocked = checked + } + + CheckBox { + text: "Wake Allowed" + checked: modelData.wakeAllowed + onToggled: modelData.wakeAllowed = checked + } + } + } + + ColumnLayout { + Layout.alignment: Qt.AlignRight + + Button { + Layout.alignment: Qt.AlignRight + text: modelData.connected ? "Disconnect" : "Connect" + onClicked: modelData.connected = !modelData.connected + } + + Button { + Layout.alignment: Qt.AlignRight + text: modelData.pairing ? "Cancel" : (modelData.paired ? "Forget" : "Pair") + onClicked: { + if (modelData.pairing) { + modelData.cancelPair(); + } else if (modelData.paired) { + modelData.forget(); + } else { + modelData.pair(); + } + } + } + } + } + } + } + } + } + } +} 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 c1fdee8e..7cef987a 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -1,23 +1,62 @@ -qt_add_executable(quickshell - main.cpp +qt_add_library(quickshell-core STATIC plugin.cpp shell.cpp variants.cpp rootwrapper.cpp - proxywindow.cpp reload.cpp rootwrapper.cpp qmlglobal.cpp qmlscreen.cpp - watcher.cpp region.cpp persistentprops.cpp - windowinterface.cpp - floatingwindow.cpp - panelinterface.cpp + singleton.cpp + generation.cpp + scan.cpp + qsintercept.cpp + incubator.cpp + lazyloader.cpp + easingcurve.cpp + iconimageprovider.cpp + 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 + colorquantizer.cpp + toolsupport.cpp ) -set_source_files_properties(main.cpp PROPERTIES COMPILE_DEFINITIONS GIT_REVISION="${GIT_REVISION}") -qt_add_qml_module(quickshell 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 PRIVATE ${QT_DEPS}) +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) + +if (BUILD_TESTING) + add_subdirectory(test) +endif() diff --git a/src/core/boundcomponent.cpp b/src/core/boundcomponent.cpp new file mode 100644 index 00000000..8b1c8284 --- /dev/null +++ b/src/core/boundcomponent.cpp @@ -0,0 +1,258 @@ +#include "boundcomponent.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "incubator.hpp" + +QObject* BoundComponent::item() const { return this->object; } +QQmlComponent* BoundComponent::sourceComponent() const { return this->mComponent; } + +void BoundComponent::setSourceComponent(QQmlComponent* component) { + if (component == this->mComponent) return; + + if (this->componentCompleted) { + qWarning() << "BoundComponent.component cannot be set after creation"; + return; + } + this->disconnectComponent(); + + this->ownsComponent = false; + this->mComponent = component; + if (component != nullptr) { + QObject::connect(component, &QObject::destroyed, this, &BoundComponent::onComponentDestroyed); + } + + emit this->sourceComponentChanged(); +} + +void BoundComponent::disconnectComponent() { + if (this->mComponent == nullptr) return; + + if (this->ownsComponent) { + delete this->mComponent; + } else { + QObject::disconnect(this->mComponent, nullptr, this, nullptr); + } + + this->mComponent = nullptr; +} + +void BoundComponent::onComponentDestroyed() { this->mComponent = nullptr; } +QString BoundComponent::source() const { return this->mSource; } + +void BoundComponent::setSource(QString source) { + if (source == this->mSource) return; + + if (this->componentCompleted) { + qWarning() << "BoundComponent.url cannot be set after creation"; + return; + } + + auto* context = QQmlEngine::contextForObject(this); + auto* component = new QQmlComponent(context->engine(), context->resolvedUrl(source), this); + + if (component->isError()) { + qWarning() << component->errorString().toStdString().c_str(); + delete component; + } else { + this->disconnectComponent(); + this->ownsComponent = true; + this->mSource = std::move(source); + this->mComponent = component; + + emit this->sourceChanged(); + emit this->sourceComponentChanged(); + } +} + +bool BoundComponent::bindValues() const { return this->mBindValues; } + +void BoundComponent::setBindValues(bool bindValues) { + if (this->componentCompleted) { + qWarning() << "BoundComponent.bindValues cannot be set after creation"; + return; + } + + this->mBindValues = bindValues; + emit this->bindValuesChanged(); +} + +void BoundComponent::componentComplete() { + this->QQuickItem::componentComplete(); + this->componentCompleted = true; + this->tryCreate(); +} + +void BoundComponent::tryCreate() { + if (this->mComponent == nullptr) { + qWarning() << "BoundComponent has no component"; + return; + } + + auto initialProperties = QVariantMap(); + + const auto* metaObject = this->metaObject(); + for (auto i = metaObject->propertyOffset(); i < metaObject->propertyCount(); i++) { + const auto prop = metaObject->property(i); + + if (prop.isReadable()) { + initialProperties.insert(prop.name(), prop.read(this)); + } + } + + this->incubator = new QsQmlIncubator(QsQmlIncubator::AsynchronousIfNested, this); + this->incubator->setInitialProperties(initialProperties); + + // clang-format off + QObject::connect(this->incubator, &QsQmlIncubator::completed, this, &BoundComponent::onIncubationCompleted); + QObject::connect(this->incubator, &QsQmlIncubator::failed, this, &BoundComponent::onIncubationFailed); + // clang-format on + + this->mComponent->create(*this->incubator, QQmlEngine::contextForObject(this)); +} + +void BoundComponent::onIncubationCompleted() { + this->object = this->incubator->object(); + delete this->incubator; + this->disconnectComponent(); + + this->object->setParent(this); + this->mItem = qobject_cast(this->object); + + const auto* metaObject = this->metaObject(); + const auto* objectMetaObject = this->object->metaObject(); + + if (this->mBindValues) { + for (auto i = metaObject->propertyOffset(); i < metaObject->propertyCount(); i++) { + const auto prop = metaObject->property(i); + + if (prop.isReadable() && prop.hasNotifySignal()) { + const auto objectPropIndex = objectMetaObject->indexOfProperty(prop.name()); + + if (objectPropIndex == -1) { + qWarning() << "property" << prop.name() + << "defined on BoundComponent but not on its contained object."; + continue; + } + + const auto objectProp = objectMetaObject->property(objectPropIndex); + if (objectProp.isWritable()) { + auto* proxy = new BoundComponentPropertyProxy(this, this->object, prop, objectProp); + proxy->onNotified(); // any changes that might've happened before connection + } else { + qWarning() << "property" << prop.name() + << "defined on BoundComponent is not writable for its contained object."; + } + } + } + } + + for (auto i = metaObject->methodOffset(); i < metaObject->methodCount(); i++) { + const auto method = metaObject->method(i); + + if (method.name().startsWith("on") && method.name().length() > 2) { + auto sig = QString(method.methodSignature()).sliced(2); + if (!sig[0].isUpper()) continue; + sig[0] = sig[0].toLower(); + auto name = sig.sliced(0, sig.indexOf('(')); + + auto mostViableSignal = QMetaMethod(); + for (auto i = 0; i < objectMetaObject->methodCount(); i++) { + const auto method = objectMetaObject->method(i); + if (method.methodSignature() == sig) { + mostViableSignal = method; + break; + } + + if (method.name() == name) { + if (mostViableSignal.isValid()) { + qWarning() << "Multiple candidates, so none will be attached for signal" << name; + goto next; + } + + mostViableSignal = method; + } + } + + if (!mostViableSignal.isValid()) { + qWarning() << "Function" << method.name() << "appears to be a signal handler for" << name + << "but it does not match any signals on the target object"; + goto next; + } + + QMetaObject::connect( + this->object, + mostViableSignal.methodIndex(), + this, + method.methodIndex() + ); + } + + next:; + } + + if (this->mItem != nullptr) { + this->mItem->setParentItem(this); + + // clang-format off + QObject::connect(this, &QQuickItem::widthChanged, this, &BoundComponent::updateSize); + QObject::connect(this, &QQuickItem::heightChanged, this, &BoundComponent::updateSize); + QObject::connect(this->mItem, &QQuickItem::implicitWidthChanged, this, &BoundComponent::updateImplicitSize); + QObject::connect(this->mItem, &QQuickItem::implicitHeightChanged, this, &BoundComponent::updateImplicitSize); + // clang-format on + + this->updateImplicitSize(); + this->updateSize(); + } + + emit this->loaded(); +} + +void BoundComponent::onIncubationFailed() { + qWarning() << "Failed to create BoundComponent"; + + for (auto& error: this->incubator->errors()) { + qWarning() << error; + } + + delete this->incubator; + this->disconnectComponent(); +} + +void BoundComponent::updateSize() { this->mItem->setSize(this->size()); } + +void BoundComponent::updateImplicitSize() { + this->setImplicitWidth(this->mItem->implicitWidth()); + this->setImplicitHeight(this->mItem->implicitHeight()); +} + +BoundComponentPropertyProxy::BoundComponentPropertyProxy( + QObject* from, + QObject* to, + QMetaProperty fromProperty, + QMetaProperty toProperty +) + : QObject(from) + , from(from) + , to(to) + , fromProperty(fromProperty) + , toProperty(toProperty) { + const auto* metaObject = this->metaObject(); + auto method = metaObject->indexOfSlot("onNotified()"); + QMetaObject::connect(from, fromProperty.notifySignal().methodIndex(), this, method); +} + +void BoundComponentPropertyProxy::onNotified() { + this->toProperty.write(this->to, this->fromProperty.read(this->from)); +} diff --git a/src/core/boundcomponent.hpp b/src/core/boundcomponent.hpp new file mode 100644 index 00000000..d47121df --- /dev/null +++ b/src/core/boundcomponent.hpp @@ -0,0 +1,125 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "incubator.hpp" + +///! Component loader that allows setting initial properties. +/// Component loader that allows setting initial properties, primarily useful for +/// escaping cyclic dependency errors. +/// +/// Properties defined on the BoundComponent will be applied to its loaded component, +/// including required properties, and will remain reactive. Functions created with +/// the names of signal handlers will also be attached to signals of the loaded component. +/// +/// ```qml {filename="MyComponent.qml"} +/// MouseArea { +/// required property color color; +/// width: 100 +/// height: 100 +/// +/// Rectangle { +/// anchors.fill: parent +/// color: parent.color +/// } +/// } +/// ``` +/// +/// ```qml +/// BoundComponent { +/// source: "MyComponent.qml" +/// +/// // this is the same as assigning to `color` on MyComponent if loaded normally. +/// property color color: "red"; +/// +/// // this will be triggered when the `clicked` signal from the MouseArea is sent. +/// function onClicked() { +/// color = "blue"; +/// } +/// } +/// ``` +class BoundComponent: public QQuickItem { + Q_OBJECT; + // clang-format off + /// The loaded component. Will be null until it has finished loading. + Q_PROPERTY(QObject* item READ item NOTIFY loaded); + /// The source to load, as a Component. + Q_PROPERTY(QQmlComponent* sourceComponent READ sourceComponent WRITE setSourceComponent NOTIFY sourceComponentChanged); + /// The source to load, as a Url. + Q_PROPERTY(QString source READ source WRITE setSource NOTIFY sourceChanged); + /// If property values should be bound after they are initially set. Defaults to `true`. + Q_PROPERTY(bool bindValues READ bindValues WRITE setBindValues NOTIFY bindValuesChanged); + Q_PROPERTY(qreal implicitWidth READ implicitWidth NOTIFY implicitWidthChanged); + Q_PROPERTY(qreal implicitHeight READ implicitHeight NOTIFY implicitHeightChanged); + // clang-format on + QML_ELEMENT; + +public: + explicit BoundComponent(QQuickItem* parent = nullptr): QQuickItem(parent) {} + + void componentComplete() override; + + [[nodiscard]] QObject* item() const; + + [[nodiscard]] QQmlComponent* sourceComponent() const; + void setSourceComponent(QQmlComponent* sourceComponent); + + [[nodiscard]] QString source() const; + void setSource(QString source); + + [[nodiscard]] bool bindValues() const; + void setBindValues(bool bindValues); + +signals: + void loaded(); + void sourceComponentChanged(); + void sourceChanged(); + void bindValuesChanged(); + +private slots: + void onComponentDestroyed(); + void onIncubationCompleted(); + void onIncubationFailed(); + void updateSize(); + void updateImplicitSize(); + +private: + void disconnectComponent(); + void tryCreate(); + + QString mSource; + bool mBindValues = true; + QQmlComponent* mComponent = nullptr; + bool ownsComponent = false; + QsQmlIncubator* incubator = nullptr; + QObject* object = nullptr; + QQuickItem* mItem = nullptr; + bool componentCompleted = false; +}; + +class BoundComponentPropertyProxy: public QObject { + Q_OBJECT; + +public: + BoundComponentPropertyProxy( + QObject* from, + QObject* to, + QMetaProperty fromProperty, + QMetaProperty toProperty + ); + +public slots: + void onNotified(); + +private: + QObject* from; + QObject* to; + QMetaProperty fromProperty; + QMetaProperty toProperty; +}; diff --git a/src/core/clock.cpp b/src/core/clock.cpp new file mode 100644 index 00000000..90938d21 --- /dev/null +++ b/src/core/clock.cpp @@ -0,0 +1,88 @@ +#include "clock.hpp" + +#include +#include +#include +#include +#include + +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); + this->currentTime = offset > -500 && offset < 500 ? targetTime : currentTime; + + auto time = this->currentTime.time(); + this->currentTime.setTime(QTime( + this->mPrecision >= SystemClock::Hours ? time.hour() : 0, + this->mPrecision >= SystemClock::Minutes ? time.minute() : 0, + this->mPrecision >= SystemClock::Seconds ? time.second() : 0 + )); + + emit this->dateChanged(); +} + +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(QTime( + 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; +} diff --git a/src/core/clock.hpp b/src/core/clock.hpp new file mode 100644 index 00000000..67461911 --- /dev/null +++ b/src/core/clock.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +///! System clock accessor. +/// SystemClock is a view into the system's clock. +/// It updates at hour, minute, or second intervals depending on @@precision. +/// +/// # Examples +/// ```qml +/// SystemClock { +/// id: clock +/// precision: SystemClock.Seconds +/// } +/// +/// @@QtQuick.Text { +/// text: Qt.formatDateTime(clock.date, "hh:mm:ss - yyyy-MM-dd") +/// } +/// ``` +/// +/// > [!WARNING] Clock updates will trigger within 50ms of the system clock changing, +/// > however this can be either before or after the clock changes (+-50ms). If you +/// > need a date object, use @@date instead of constructing a new one, or the time +/// > of the constructed object could be off by up to a second. +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 date and time. + /// + /// > [!TIP] You can use @@QtQml.Qt.formatDateTime() to get the time as a string in + /// > your format of choice. + Q_PROPERTY(QDateTime date READ date NOTIFY dateChanged); + /// The current hour. + Q_PROPERTY(quint32 hours READ hours NOTIFY dateChanged); + /// The current minute, or 0 if @@precision is `SystemClock.Hours`. + Q_PROPERTY(quint32 minutes READ minutes NOTIFY dateChanged); + /// The current second, or 0 if @@precision is `SystemClock.Hours` or `SystemClock.Minutes`. + Q_PROPERTY(quint32 seconds READ seconds NOTIFY dateChanged); + 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); + + [[nodiscard]] QDateTime date() const { return this->currentTime; } + [[nodiscard]] quint32 hours() const { return this->currentTime.time().hour(); } + [[nodiscard]] quint32 minutes() const { return this->currentTime.time().minute(); } + [[nodiscard]] quint32 seconds() const { return this->currentTime.time().second(); } + +signals: + void enabledChanged(); + void precisionChanged(); + void dateChanged(); + +private slots: + void onTimeout(); + +private: + bool mEnabled = true; + SystemClock::Enum mPrecision = SystemClock::Seconds; + QTimer timer; + QDateTime currentTime; + QDateTime targetTime; + + void update(); + void setTime(const QDateTime& targetTime); + void schedule(const QDateTime& targetTime); +}; diff --git a/src/core/colorquantizer.cpp b/src/core/colorquantizer.cpp new file mode 100644 index 00000000..6cfb05db --- /dev/null +++ b/src/core/colorquantizer.cpp @@ -0,0 +1,242 @@ +#include "colorquantizer.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "logcat.hpp" + +namespace { +QS_LOGGING_CATEGORY(logColorQuantizer, "quickshell.colorquantizer", QtWarningMsg); +} + +ColorQuantizerOperation::ColorQuantizerOperation(QUrl* source, qreal depth, qreal rescaleSize) + : source(source) + , maxDepth(depth) + , rescaleSize(rescaleSize) { + setAutoDelete(false); +} + +void ColorQuantizerOperation::quantizeImage(const QAtomicInteger& shouldCancel) { + if (shouldCancel.loadAcquire() || source->isEmpty()) return; + + colors.clear(); + + auto image = QImage(source->toLocalFile()); + if ((image.width() > rescaleSize || image.height() > rescaleSize) && rescaleSize > 0) { + image = image.scaled( + static_cast(rescaleSize), + static_cast(rescaleSize), + Qt::KeepAspectRatio, + Qt::SmoothTransformation + ); + } + + if (image.isNull()) { + qCWarning(logColorQuantizer) << "Failed to load image from" << source->toString(); + return; + } + + QList pixels; + for (int y = 0; y != image.height(); ++y) { + for (int x = 0; x != image.width(); ++x) { + auto pixel = image.pixel(x, y); + if (qAlpha(pixel) == 0) continue; + + pixels.append(QColor::fromRgb(pixel)); + } + } + + auto startTime = QDateTime::currentDateTime(); + + colors = quantization(pixels, 0); + + auto endTime = QDateTime::currentDateTime(); + auto milliseconds = startTime.msecsTo(endTime); + qCDebug(logColorQuantizer) << "Color Quantization took: " << milliseconds << "ms"; +} + +QList ColorQuantizerOperation::quantization( + QList& rgbValues, + qreal depth, + const QAtomicInteger& shouldCancel +) { + if (shouldCancel.loadAcquire()) return QList(); + + if (depth >= maxDepth || rgbValues.isEmpty()) { + if (rgbValues.isEmpty()) return QList(); + + auto totalR = 0; + auto totalG = 0; + auto totalB = 0; + + for (const auto& color: rgbValues) { + if (shouldCancel.loadAcquire()) return QList(); + + totalR += color.red(); + totalG += color.green(); + totalB += color.blue(); + } + + auto avgColor = QColor( + qRound(totalR / static_cast(rgbValues.size())), + qRound(totalG / static_cast(rgbValues.size())), + qRound(totalB / static_cast(rgbValues.size())) + ); + + return QList() << avgColor; + } + + auto dominantChannel = findBiggestColorRange(rgbValues); + std::ranges::sort(rgbValues, [dominantChannel](const auto& a, const auto& b) { + if (dominantChannel == 'r') return a.red() < b.red(); + else if (dominantChannel == 'g') return a.green() < b.green(); + return a.blue() < b.blue(); + }); + + auto mid = rgbValues.size() / 2; + + auto leftHalf = rgbValues.mid(0, mid); + auto rightHalf = rgbValues.mid(mid); + + QList result; + result.append(quantization(leftHalf, depth + 1)); + result.append(quantization(rightHalf, depth + 1)); + + return result; +} + +char ColorQuantizerOperation::findBiggestColorRange(const QList& rgbValues) { + if (rgbValues.isEmpty()) return 'r'; + + auto rMin = 255; + auto gMin = 255; + auto bMin = 255; + auto rMax = 0; + auto gMax = 0; + auto bMax = 0; + + for (const auto& color: rgbValues) { + rMin = qMin(rMin, color.red()); + gMin = qMin(gMin, color.green()); + bMin = qMin(bMin, color.blue()); + + rMax = qMax(rMax, color.red()); + gMax = qMax(gMax, color.green()); + bMax = qMax(bMax, color.blue()); + } + + auto rRange = rMax - rMin; + auto gRange = gMax - gMin; + auto bRange = bMax - bMin; + + auto biggestRange = qMax(rRange, qMax(gRange, bRange)); + if (biggestRange == rRange) { + return 'r'; + } else if (biggestRange == gRange) { + return 'g'; + } else { + return 'b'; + } +} + +void ColorQuantizerOperation::finishRun() { + QMetaObject::invokeMethod(this, &ColorQuantizerOperation::finished, Qt::QueuedConnection); +} + +void ColorQuantizerOperation::finished() { + emit this->done(colors); + delete this; +} + +void ColorQuantizerOperation::run() { + if (!this->shouldCancel) { + this->quantizeImage(); + + if (this->shouldCancel.loadAcquire()) { + qCDebug(logColorQuantizer) << "Color quantization" << this << "cancelled"; + } + } + + this->finishRun(); +} + +void ColorQuantizerOperation::tryCancel() { this->shouldCancel.storeRelease(true); } + +void ColorQuantizer::componentComplete() { + componentCompleted = true; + if (!mSource.isEmpty()) quantizeAsync(); +} + +void ColorQuantizer::setSource(const QUrl& source) { + if (mSource != source) { + mSource = source; + emit this->sourceChanged(); + + if (this->componentCompleted && !mSource.isEmpty()) quantizeAsync(); + } +} + +void ColorQuantizer::setDepth(qreal depth) { + if (mDepth != depth) { + mDepth = depth; + emit this->depthChanged(); + + if (this->componentCompleted) quantizeAsync(); + } +} + +void ColorQuantizer::setRescaleSize(int rescaleSize) { + if (mRescaleSize != rescaleSize) { + mRescaleSize = rescaleSize; + emit this->rescaleSizeChanged(); + + if (this->componentCompleted) quantizeAsync(); + } +} + +void ColorQuantizer::operationFinished(const QList& result) { + bColors = result; + this->liveOperation = nullptr; + emit this->colorsChanged(); +} + +void ColorQuantizer::quantizeAsync() { + if (this->liveOperation) this->cancelAsync(); + + qCDebug(logColorQuantizer) << "Starting color quantization asynchronously"; + this->liveOperation = new ColorQuantizerOperation(&mSource, mDepth, mRescaleSize); + + QObject::connect( + this->liveOperation, + &ColorQuantizerOperation::done, + this, + &ColorQuantizer::operationFinished + ); + + QThreadPool::globalInstance()->start(this->liveOperation); +} + +void ColorQuantizer::cancelAsync() { + if (!this->liveOperation) return; + + this->liveOperation->tryCancel(); + QThreadPool::globalInstance()->waitForDone(); + + QObject::disconnect(this->liveOperation, nullptr, this, nullptr); + this->liveOperation = nullptr; +} diff --git a/src/core/colorquantizer.hpp b/src/core/colorquantizer.hpp new file mode 100644 index 00000000..d35a15ac --- /dev/null +++ b/src/core/colorquantizer.hpp @@ -0,0 +1,128 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class ColorQuantizerOperation + : public QObject + , public QRunnable { + Q_OBJECT; + +public: + explicit ColorQuantizerOperation(QUrl* source, qreal depth, qreal rescaleSize); + + void run() override; + void tryCancel(); + +signals: + void done(QList colors); + +private slots: + void finished(); + +private: + static char findBiggestColorRange(const QList& rgbValues); + + void quantizeImage(const QAtomicInteger& shouldCancel = false); + + QList quantization( + QList& rgbValues, + qreal depth, + const QAtomicInteger& shouldCancel = false + ); + + void finishRun(); + + QAtomicInteger shouldCancel = false; + QList colors; + QUrl* source; + qreal maxDepth; + qreal rescaleSize; +}; + +///! Color Quantization Utility +/// A color quantization utility used for getting prevalent colors in an image, by +/// averaging out the image's color data recursively. +/// +/// #### Example +/// ```qml +/// ColorQuantizer { +/// id: colorQuantizer +/// source: Qt.resolvedUrl("./yourImage.png") +/// depth: 3 // Will produce 8 colors (2³) +/// rescaleSize: 64 // Rescale to 64x64 for faster processing +/// } +/// ``` +class ColorQuantizer + : public QObject + , public QQmlParserStatus { + Q_OBJECT; + QML_ELEMENT; + Q_INTERFACES(QQmlParserStatus); + /// Access the colors resulting from the color quantization performed. + /// > [!NOTE] The amount of colors returned from the quantization is determined by + /// > the property depth, specifically 2ⁿ where n is the depth. + Q_PROPERTY(QList colors READ default NOTIFY colorsChanged BINDABLE bindableColors); + + /// Path to the image you'd like to run the color quantization on. + Q_PROPERTY(QUrl source READ source WRITE setSource NOTIFY sourceChanged); + + /// Max depth for the color quantization. Each level of depth represents another + /// binary split of the color space + Q_PROPERTY(qreal depth READ depth WRITE setDepth NOTIFY depthChanged); + + /// The size to rescale the image to, when rescaleSize is 0 then no scaling will be done. + /// > [!NOTE] Results from color quantization doesn't suffer much when rescaling, it's + /// > reccommended to rescale, otherwise the quantization process will take much longer. + Q_PROPERTY(qreal rescaleSize READ rescaleSize WRITE setRescaleSize NOTIFY rescaleSizeChanged); + +public: + explicit ColorQuantizer(QObject* parent = nullptr): QObject(parent) {} + + void componentComplete() override; + void classBegin() override {} + + [[nodiscard]] QBindable> bindableColors() { return &this->bColors; } + + [[nodiscard]] QUrl source() const { return mSource; } + void setSource(const QUrl& source); + + [[nodiscard]] qreal depth() const { return mDepth; } + void setDepth(qreal depth); + + [[nodiscard]] qreal rescaleSize() const { return mRescaleSize; } + void setRescaleSize(int rescaleSize); + +signals: + void colorsChanged(); + void sourceChanged(); + void depthChanged(); + void rescaleSizeChanged(); + +public slots: + void operationFinished(const QList& result); + +private: + void quantizeAsync(); + void cancelAsync(); + + bool componentCompleted = false; + ColorQuantizerOperation* liveOperation = nullptr; + QUrl mSource; + qreal mDepth = 0; + qreal mRescaleSize = 0; + + Q_OBJECT_BINDABLE_PROPERTY( + ColorQuantizer, + QList, + bColors, + &ColorQuantizer::colorsChanged + ); +}; diff --git a/src/core/common.cpp b/src/core/common.cpp new file mode 100644 index 00000000..080019ab --- /dev/null +++ b/src/core/common.cpp @@ -0,0 +1,9 @@ +#include "common.hpp" + +#include + +namespace qs { + +const QDateTime Common::LAUNCH_TIME = QDateTime::currentDateTime(); + +} // namespace qs diff --git a/src/core/common.hpp b/src/core/common.hpp new file mode 100644 index 00000000..ab8edb80 --- /dev/null +++ b/src/core/common.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +namespace qs { + +struct Common { + static const QDateTime LAUNCH_TIME; + static inline QProcessEnvironment INITIAL_ENVIRONMENT = {}; // NOLINT +}; + +} // namespace qs diff --git a/src/core/desktopentry.cpp b/src/core/desktopentry.cpp new file mode 100644 index 00000000..95fcb89e --- /dev/null +++ b/src/core/desktopentry.cpp @@ -0,0 +1,422 @@ +#include "desktopentry.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../io/processcore.hpp" +#include "logcat.hpp" +#include "model.hpp" +#include "qmlglobal.hpp" + +namespace { +QS_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; +}; + +// NOLINTNEXTLINE(misc-use-internal-linkage) +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 == "StartupWMClass") this->mStartupClass = 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; + this->mCommand = DesktopEntry::parseExecString(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; + action->mCommand = DesktopEntry::parseExecString(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->mCommand, 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'"' || 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'"' || 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 QList& execString, const QString& workingDirectory) { + qs::io::process::ProcessContext ctx; + ctx.setCommand(execString); + ctx.setWorkingDirectory(workingDirectory); + QuickshellGlobal::execDetached(ctx); +} + +void DesktopAction::execute() const { + DesktopEntry::doExec(this->mCommand, this->entry->mWorkingDirectory); +} + +DesktopEntryManager::DesktopEntryManager() { + this->scanDesktopEntries(); + this->populateApplications(); +} + +void DesktopEntryManager::scanDesktopEntries() { + QList dataPaths; + + if (qEnvironmentVariableIsSet("XDG_DATA_HOME")) { + dataPaths.push_back(qEnvironmentVariable("XDG_DATA_HOME")); + } else if (qEnvironmentVariableIsSet("HOME")) { + dataPaths.push_back(qEnvironmentVariable("HOME") + "/.local/share"); + } + + 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.absoluteFilePath(), 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 = 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; + } +} + +DesktopEntry* DesktopEntryManager::heuristicLookup(const QString& name) { + if (auto* entry = this->byId(name)) return entry; + + auto list = this->desktopEntries.values(); + + auto iter = std::ranges::find_if(list, [&](const DesktopEntry* entry) { + return name == entry->mStartupClass; + }); + + if (iter != list.end()) return *iter; + + iter = std::ranges::find_if(list, [&](const DesktopEntry* entry) { + return name.toLower() == entry->mStartupClass.toLower(); + }); + + if (iter != list.end()) return *iter; + return nullptr; +} + +ObjectModel* DesktopEntryManager::applications() { return &this->mApplications; } + +DesktopEntries::DesktopEntries() { DesktopEntryManager::instance(); } + +DesktopEntry* DesktopEntries::byId(const QString& id) { + return DesktopEntryManager::instance()->byId(id); +} + +DesktopEntry* DesktopEntries::heuristicLookup(const QString& name) { + return DesktopEntryManager::instance()->heuristicLookup(name); +} + +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..827a6187 --- /dev/null +++ b/src/core/desktopentry.hpp @@ -0,0 +1,204 @@ +#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); + /// Initial class or app id the app intends to use. May be useful for matching running apps + /// to desktop entries. + Q_PROPERTY(QString startupClass MEMBER mStartupClass 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. + /// + /// > [!WARNING] This cannot be reliably run as a command. See @@command for one you can run. + Q_PROPERTY(QString execString MEMBER mExecString CONSTANT); + /// The parsed `Exec` command in the desktop entry. + /// + /// The entry can be run with @@execute(), or by using this command in + /// @@Quickshell.Quickshell.execDetached() or @@Quickshell.Io.Process. + /// If used in `execDetached` or a `Process`, @@workingDirectory should also be passed to + /// the invoked process. See @@execute() for details. + /// + /// > [!NOTE] The provided command does not invoke a terminal even if @@runInTerminal is true. + Q_PROPERTY(QVector command MEMBER mCommand 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. + /// + /// This is equivalent to calling @@Quickshell.Quickshell.execDetached() with @@command + /// and @@DesktopEntry.workingDirectory as shown below: + /// + /// ```qml + /// Quickshell.execDetached({ + /// command: desktopEntry.command, + /// workingDirectory: desktopEntry.workingDirectory, + /// }); + /// ``` + 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 QList& execString, const QString& workingDirectory); + +public: + QString mId; + QString mName; + QString mGenericName; + QString mStartupClass; + bool mNoDisplay = false; + QString mComment; + QString mIcon; + QString mExecString; + QVector mCommand; + 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 action. + /// + /// > [!WARNING] This cannot be reliably run as a command. See @@command for one you can run. + Q_PROPERTY(QString execString MEMBER mExecString CONSTANT); + /// The parsed `Exec` command in the action. + /// + /// The entry can be run with @@execute(), or by using this command in + /// @@Quickshell.Quickshell.execDetached() or @@Quickshell.Io.Process. + /// If used in `execDetached` or a `Process`, @@DesktopEntry.workingDirectory should also be passed to + /// the invoked process. + /// + /// > [!NOTE] The provided command does not invoke a terminal even if @@runInTerminal is true. + Q_PROPERTY(QVector command MEMBER mCommand 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. + /// + /// This is equivalent to calling @@Quickshell.Quickshell.execDetached() with @@command + /// and @@DesktopEntry.workingDirectory. + Q_INVOKABLE void execute() const; + +private: + DesktopEntry* entry; + QString mId; + QString mName; + QString mIcon; + QString mExecString; + QVector mCommand; + QHash mEntries; + + friend class DesktopEntry; +}; + +class DesktopEntryManager: public QObject { + Q_OBJECT; + +public: + void scanDesktopEntries(); + + [[nodiscard]] DesktopEntry* byId(const QString& id); + [[nodiscard]] DesktopEntry* heuristicLookup(const QString& name); + + [[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. + /// + /// While this function requires an exact match, @@heuristicLookup() will correctly + /// find an entry more often and is generally more useful. + Q_INVOKABLE [[nodiscard]] static DesktopEntry* byId(const QString& id); + /// Look up a desktop entry by name using heuristics. Unlike @@byId(), + /// if no exact matches are found this function will try to guess - potentially incorrectly. + /// May return null. + Q_INVOKABLE [[nodiscard]] static DesktopEntry* heuristicLookup(const QString& name); + + [[nodiscard]] static ObjectModel* applications(); +}; diff --git a/src/core/doc.hpp b/src/core/doc.hpp index e4c907aa..fbb21400 100644 --- a/src/core/doc.hpp +++ b/src/core/doc.hpp @@ -9,3 +9,15 @@ // make the type visible in the docs even if not a QML_ELEMENT #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/easingcurve.cpp b/src/core/easingcurve.cpp new file mode 100644 index 00000000..a758bd3c --- /dev/null +++ b/src/core/easingcurve.cpp @@ -0,0 +1,33 @@ +#include "easingcurve.hpp" +#include + +#include +#include +#include +#include +#include + +qreal EasingCurve::valueAt(qreal x) const { return this->mCurve.valueForProgress(x); } + +qreal EasingCurve::interpolate(qreal x, qreal a, qreal b) const { + return a + (b - a) * this->valueAt(x); +} + +QPointF EasingCurve::interpolate(qreal x, const QPointF& a, const QPointF& b) const { + return QPointF(this->interpolate(x, a.x(), b.x()), this->interpolate(x, a.y(), b.y())); +} + +QRectF EasingCurve::interpolate(qreal x, const QRectF& a, const QRectF& b) const { + return QRectF( + this->interpolate(x, a.topLeft(), b.topLeft()), + this->interpolate(x, a.bottomRight(), b.bottomRight()) + ); +} + +QEasingCurve EasingCurve::curve() const { return this->mCurve; } + +void EasingCurve::setCurve(QEasingCurve curve) { + if (this->mCurve == curve) return; + this->mCurve = std::move(curve); + emit this->curveChanged(); +} diff --git a/src/core/easingcurve.hpp b/src/core/easingcurve.hpp new file mode 100644 index 00000000..ef210383 --- /dev/null +++ b/src/core/easingcurve.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +///! Easing curve. +/// Directly accessible easing curve as used in property animations. +class EasingCurve: public QObject { + Q_OBJECT; + /// Easing curve settings. Works exactly the same as + /// [PropertyAnimation.easing](https://doc.qt.io/qt-6/qml-qtquick-propertyanimation.html#easing-prop). + Q_PROPERTY(QEasingCurve curve READ curve WRITE setCurve NOTIFY curveChanged); + QML_ELEMENT; + +public: + EasingCurve(QObject* parent = nullptr): QObject(parent) {} + + /// Returns the Y value for the given X value on the curve + /// from 0.0 to 1.0. + Q_INVOKABLE [[nodiscard]] qreal valueAt(qreal x) const; + /// Interpolates between two values using the given X coordinate. + Q_INVOKABLE [[nodiscard]] qreal interpolate(qreal x, qreal a, qreal b) const; + /// Interpolates between two points using the given X coordinate. + Q_INVOKABLE [[nodiscard]] QPointF interpolate(qreal x, const QPointF& a, const QPointF& b) const; + /// Interpolates two rects using the given X coordinate. + Q_INVOKABLE [[nodiscard]] QRectF interpolate(qreal x, const QRectF& a, const QRectF& b) const; + + [[nodiscard]] QEasingCurve curve() const; + void setCurve(QEasingCurve curve); + +signals: + void curveChanged(); + +private: + QEasingCurve mCurve; +}; 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/enginecontext.hpp b/src/core/enginecontext.hpp new file mode 100644 index 00000000..6675fc30 --- /dev/null +++ b/src/core/enginecontext.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include "qsintercept.hpp" +#include "scan.hpp" +#include "singleton.hpp" + +class EngineContext { +public: + explicit EngineContext(const QmlScanner& scanner); + +private: + const QmlScanner& scanner; + QQmlEngine engine; + QsInterceptNetworkAccessManagerFactory interceptFactory; + SingletonRegistry singletonRegistry; +}; diff --git a/src/core/floatingwindow.cpp b/src/core/floatingwindow.cpp deleted file mode 100644 index 0f909c23..00000000 --- a/src/core/floatingwindow.cpp +++ /dev/null @@ -1,62 +0,0 @@ -#include "floatingwindow.hpp" - -#include -#include -#include -#include - -#include "proxywindow.hpp" -#include "windowinterface.hpp" - -void ProxyFloatingWindow::setWidth(qint32 width) { - if (this->window == nullptr || !this->window->isVisible()) { - this->ProxyWindowBase::setWidth(width); - } -} - -void ProxyFloatingWindow::setHeight(qint32 height) { - if (this->window == nullptr || !this->window->isVisible()) { - this->ProxyWindowBase::setHeight(height); - } -} - -// FloatingWindowInterface - -FloatingWindowInterface::FloatingWindowInterface(QObject* parent) - : WindowInterface(parent) - , window(new ProxyFloatingWindow(this)) { - // clang-format off - QObject::connect(this->window, &ProxyWindowBase::windowConnected, this, &FloatingWindowInterface::windowConnected); - QObject::connect(this->window, &ProxyWindowBase::visibleChanged, this, &FloatingWindowInterface::visibleChanged); - QObject::connect(this->window, &ProxyWindowBase::heightChanged, this, &FloatingWindowInterface::heightChanged); - QObject::connect(this->window, &ProxyWindowBase::widthChanged, this, &FloatingWindowInterface::widthChanged); - QObject::connect(this->window, &ProxyWindowBase::screenChanged, this, &FloatingWindowInterface::screenChanged); - QObject::connect(this->window, &ProxyWindowBase::colorChanged, this, &FloatingWindowInterface::colorChanged); - QObject::connect(this->window, &ProxyWindowBase::maskChanged, this, &FloatingWindowInterface::maskChanged); - // clang-format on -} - -void FloatingWindowInterface::onReload(QObject* oldInstance) { - auto* old = qobject_cast(oldInstance); - this->window->onReload(old != nullptr ? old->window : nullptr); -} - -QQmlListProperty FloatingWindowInterface::data() { return this->window->data(); } -QQuickItem* FloatingWindowInterface::contentItem() const { return this->window->contentItem(); } - -// NOLINTBEGIN -#define proxyPair(type, get, set) \ - type FloatingWindowInterface::get() const { return this->window->get(); } \ - void FloatingWindowInterface::set(type value) { this->window->set(value); } - -proxyPair(bool, isVisible, setVisible); -proxyPair(qint32, width, setWidth); -proxyPair(qint32, height, setHeight); -proxyPair(QuickshellScreenInfo*, screen, setScreen); -proxyPair(QColor, color, setColor); -proxyPair(PendingRegion*, mask, setMask); - -#undef proxyPair -#undef proxySet -#undef proxyGet -// NOLINTEND diff --git a/src/core/floatingwindow.hpp b/src/core/floatingwindow.hpp deleted file mode 100644 index 408b1e9f..00000000 --- a/src/core/floatingwindow.hpp +++ /dev/null @@ -1,56 +0,0 @@ -#pragma once - -#include -#include - -#include "proxywindow.hpp" - -class ProxyFloatingWindow: public ProxyWindowBase { - Q_OBJECT; - -public: - explicit ProxyFloatingWindow(QObject* parent = nullptr): ProxyWindowBase(parent) {} - - // Setting geometry while the window is visible makes the content item shrinks but not the window - // which is awful so we disable it for floating windows. - void setWidth(qint32 width) override; - void setHeight(qint32 height) override; -}; - -///! Standard floating window. -class FloatingWindowInterface: public WindowInterface { - Q_OBJECT; - QML_NAMED_ELEMENT(FloatingWindow); - -public: - explicit FloatingWindowInterface(QObject* parent = nullptr); - - void onReload(QObject* oldInstance) override; - - [[nodiscard]] QQuickItem* contentItem() const override; - - // NOLINTBEGIN - [[nodiscard]] bool isVisible() const override; - void setVisible(bool visible) override; - - [[nodiscard]] qint32 width() const override; - void setWidth(qint32 width) override; - - [[nodiscard]] qint32 height() const override; - void setHeight(qint32 height) override; - - [[nodiscard]] QuickshellScreenInfo* screen() const override; - void setScreen(QuickshellScreenInfo* screen) override; - - [[nodiscard]] QColor color() const override; - void setColor(QColor color) override; - - [[nodiscard]] PendingRegion* mask() const override; - void setMask(PendingRegion* mask) override; - - [[nodiscard]] QQmlListProperty data() override; - // NOLINTEND - -private: - ProxyFloatingWindow* window; -}; diff --git a/src/core/generation.cpp b/src/core/generation.cpp new file mode 100644 index 00000000..fee94416 --- /dev/null +++ b/src/core/generation.cpp @@ -0,0 +1,413 @@ +#include "generation.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "iconimageprovider.hpp" +#include "imageprovider.hpp" +#include "incubator.hpp" +#include "logcat.hpp" +#include "plugin.hpp" +#include "qsintercept.hpp" +#include "reload.hpp" +#include "scan.hpp" + +namespace { +QS_LOGGING_CATEGORY(logScene, "scene"); +} + +static QHash g_generations; // NOLINT + +EngineGeneration::EngineGeneration(const QDir& rootPath, QmlScanner scanner) + : rootPath(rootPath) + , scanner(std::move(scanner)) + , urlInterceptor(this->rootPath) + , interceptNetFactory(this->rootPath, this->scanner.fileIntercepts) + , engine(new QQmlEngine()) { + g_generations.insert(this->engine, this); + + this->engine->setOutputWarningsToStandardError(false); + QObject::connect(this->engine, &QQmlEngine::warnings, this, &EngineGeneration::onEngineWarnings); + + this->engine->addUrlInterceptor(&this->urlInterceptor); + this->engine->addImportPath("qs:@/"); + + this->engine->setNetworkAccessManagerFactory(&this->interceptNetFactory); + this->engine->setIncubationController(&this->delayedIncubationController); + + this->engine->addImageProvider("icon", new IconImageProvider()); + this->engine->addImageProvider("qsimage", new QsImageProvider()); + this->engine->addImageProvider("qspixmap", new QsPixmapProvider()); + + QsEnginePlugin::runConstructGeneration(*this); +} + +EngineGeneration::EngineGeneration(): EngineGeneration(QDir(), QmlScanner()) {} + +EngineGeneration::~EngineGeneration() { + if (this->engine != nullptr) { + qFatal() << this << "destroyed without calling destroy()"; + } +} + +void EngineGeneration::destroy() { + if (this->destroying) return; + this->destroying = true; + + 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]() { + // prevent further js execution between garbage collection and engine destruction. + this->engine->setInterrupted(true); + + g_generations.remove(this->engine); + + // 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 + qCDebug(logIncubator) << "Locking incubation controllers of old generation" << old; + old->incubationControllersLocked = true; + old->assignIncubationController(); + } + + 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->singletonRegistry.onReload(old == nullptr ? nullptr : &old->singletonRegistry); + this->reloadComplete = true; + emit this->reloadFinished(); + + if (old != nullptr) { + QObject::connect(old, &QObject::destroyed, this, [this]() { this->postReload(); }); + old->destroy(); + } else { + this->postReload(); + } +} + +void EngineGeneration::postReload() { + // This can be called on a generation during its destruction. + if (this->engine == nullptr || this->root == nullptr) return; + + QsEnginePlugin::runOnReload(); + + emit this->firePostReload(); + QObject::disconnect(this, &EngineGeneration::firePostReload, nullptr, nullptr); +} + +void EngineGeneration::setWatchingFiles(bool watching) { + if (watching) { + if (this->watcher == nullptr) { + this->watcher = new QFileSystemWatcher(); + + for (auto& file: this->scanner.scannedFiles) { + this->watcher->addPath(file); + this->watcher->addPath(QFileInfo(file).dir().absolutePath()); + } + + for (auto& file: this->extraWatchedFiles) { + this->watcher->addPath(file); + this->watcher->addPath(QFileInfo(file).dir().absolutePath()); + } + + QObject::connect( + this->watcher, + &QFileSystemWatcher::fileChanged, + this, + &EngineGeneration::onFileChanged + ); + + QObject::connect( + this->watcher, + &QFileSystemWatcher::directoryChanged, + this, + &EngineGeneration::onDirectoryChanged + ); + } + } else { + if (this->watcher != nullptr) { + this->watcher->deleteLater(); + this->watcher = nullptr; + } + } +} + +bool EngineGeneration::setExtraWatchedFiles(const QVector& files) { + this->extraWatchedFiles.clear(); + for (const auto& file: files) { + if (!this->scanner.scannedFiles.contains(file)) { + this->extraWatchedFiles.append(file); + } + } + + if (this->watcher) { + this->setWatchingFiles(false); + this->setWatchingFiles(true); + } + + return !this->extraWatchedFiles.isEmpty(); +} + +void EngineGeneration::onFileChanged(const QString& name) { + if (!this->watcher->files().contains(name)) { + this->deletedWatchedFiles.push_back(name); + } else { + // some editors (e.g vscode) perform file saving in two steps: truncate + write + // ignore the first event (truncate) with size 0 to prevent incorrect live reloading + auto fileInfo = QFileInfo(name); + if (fileInfo.isFile() && fileInfo.size() == 0) return; + + 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. + auto* obj = dynamic_cast(controller); + if (!obj) { + qCWarning(logIncubator) << "Could not register incubation controller as it is not a QObject" + << controller; + + return; + } + + QObject::connect( + obj, + &QObject::destroyed, + this, + &EngineGeneration::incubationControllerDestroyed, + Qt::UniqueConnection + ); + + this->incubationControllers.push_back(obj); + qCDebug(logIncubator) << "Registered incubation controller" << obj << "to generation" << this; + + // This function can run during destruction. + if (this->engine == nullptr) return; + + if (this->engine->incubationController() == &this->delayedIncubationController) { + this->assignIncubationController(); + } +} + +// Multiple controllers may be destroyed at once. Dynamic casts must be performed before working +// with any controllers. The QQmlIncubationController destructor will already have run by the +// point QObject::destroyed is called, so we can't cast to that. +void EngineGeneration::deregisterIncubationController(QQmlIncubationController* controller) { + auto* obj = dynamic_cast(controller); + if (!obj) { + qCCritical(logIncubator) << "Deregistering incubation controller which is not a QObject, " + "however only QObject controllers should be registered."; + } + + QObject::disconnect(obj, nullptr, this, nullptr); + + if (this->incubationControllers.removeOne(obj)) { + qCDebug(logIncubator) << "Deregistered incubation controller" << obj << "from" << this; + } else { + qCCritical(logIncubator) << "Failed to deregister incubation controller" << obj << "from" + << this << "as it was not registered to begin with"; + qCCritical(logIncubator) << "Current registered incuabation controllers" + << this->incubationControllers; + } + + // This function can run during destruction. + if (this->engine == nullptr) return; + + if (this->engine->incubationController() == controller) { + qCDebug(logIncubator + ) << "Destroyed incubation controller was currently active, reassigning from pool"; + this->assignIncubationController(); + } +} + +void EngineGeneration::incubationControllerDestroyed() { + auto* sender = this->sender(); + + if (this->incubationControllers.removeAll(sender) != 0) { + qCDebug(logIncubator) << "Destroyed incubation controller" << sender << "deregistered from" + << this; + } else { + qCCritical(logIncubator) << "Destroyed incubation controller" << sender + << "was not registered, but its destruction was observed by" << this; + + return; + } + + // This function can run during destruction. + if (this->engine == nullptr) return; + + if (dynamic_cast(this->engine->incubationController()) == sender) { + qCDebug(logIncubator + ) << "Destroyed incubation controller was currently active, reassigning from pool"; + this->assignIncubationController(); + } +} + +void EngineGeneration::onEngineWarnings(const QList& warnings) { + for (const auto& error: warnings) { + const auto& url = error.url(); + auto rel = url.scheme() == "qs" && url.path().startsWith("@/qs/") ? "@" % url.path().sliced(5) + : url.toString(); + + QString objectName; + auto desc = error.description(); + if (auto i = desc.indexOf(": "); i != -1 && desc.startsWith("QML ")) { + objectName = desc.first(i) + " at "; + desc = desc.sliced(i + 2); + } + + qCWarning(logScene).noquote().nospace() + << objectName << rel << '[' << error.line() << ':' << error.column() << "]: " << desc; + } +} + +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->incubationControllersLocked || this->incubationControllers.isEmpty()) { + controller = &this->delayedIncubationController; + } else { + controller = dynamic_cast(this->incubationControllers.first()); + } + + qCDebug(logIncubator) << "Assigning incubation controller" << controller << "to generation" + << this + << "fallback:" << (controller == &this->delayedIncubationController); + + this->engine->setIncubationController(controller); +} + +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 = EngineGeneration::findEngineGeneration(context->engine())) { + return generation; + } + } + + object = object->parent(); + } + + return nullptr; +} diff --git a/src/core/generation.hpp b/src/core/generation.hpp new file mode 100644 index 00000000..3c0c4ae5 --- /dev/null +++ b/src/core/generation.hpp @@ -0,0 +1,100 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "incubator.hpp" +#include "qsintercept.hpp" +#include "scan.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(); + explicit EngineGeneration(const QDir& rootPath, QmlScanner scanner); + ~EngineGeneration() override; + Q_DISABLE_COPY_MOVE(EngineGeneration); + + // assumes root has been initialized, consumes old generation + void onReload(EngineGeneration* old); + void setWatchingFiles(bool watching); + bool setExtraWatchedFiles(const QVector& files); + + void registerIncubationController(QQmlIncubationController* controller); + void deregisterIncubationController(QQmlIncubationController* controller); + + // 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; + QObject* root = nullptr; + SingletonRegistry singletonRegistry; + QFileSystemWatcher* watcher = nullptr; + QVector deletedWatchedFiles; + QVector extraWatchedFiles; + DelayedQmlIncubationController delayedIncubationController; + bool reloadComplete = false; + QuickshellGlobal* qsgInstance = nullptr; + + void destroy(); + void shutdown(); + +signals: + void filesChanged(); + void reloadFinished(); + void firePostReload(); + +public slots: + void quit(); + void exit(int code); + +private slots: + void onFileChanged(const QString& name); + void onDirectoryChanged(); + void incubationControllerDestroyed(); + static void onEngineWarnings(const QList& warnings); + +private: + void postReload(); + void assignIncubationController(); + 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 new file mode 100644 index 00000000..43e00fd8 --- /dev/null +++ b/src/core/iconimageprovider.cpp @@ -0,0 +1,84 @@ +#include "iconimageprovider.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include + +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); + path = id.sliced(splitIdx + 6); + qWarning() << "Searching custom icon paths is not yet supported. Icon path will be ignored for" + << id; + } else { + 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); + auto pixmap = icon.pixmap(targetSize.width(), targetSize.height()); + + if (pixmap.isNull()) { + qWarning() << "Could not load icon" << id << "at size" << targetSize << "from request"; + pixmap = IconImageProvider::missingPixmap(targetSize); + } + + if (size != nullptr) *size = pixmap.size(); + return pixmap; +} + +QPixmap IconImageProvider::missingPixmap(const QSize& size) { + auto width = size.width() % 2 == 0 ? size.width() : size.width() + 1; + auto height = size.height() % 2 == 0 ? size.height() : size.height() + 1; + width = std::max(width, 2); + height = std::max(height, 2); + + auto pixmap = QPixmap(width, height); + pixmap.fill(QColorConstants::Black); + auto painter = QPainter(&pixmap); + + auto halfWidth = width / 2; + auto halfHeight = height / 2; + auto purple = QColor(0xd900d8); + painter.fillRect(halfWidth, 0, halfWidth, halfHeight, purple); + painter.fillRect(0, halfHeight, halfWidth, halfHeight, purple); + return pixmap; +} + +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 new file mode 100644 index 00000000..57e26049 --- /dev/null +++ b/src/core/iconimageprovider.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +class IconImageProvider: public QQuickImageProvider { +public: + explicit IconImageProvider(): QQuickImageProvider(QQuickImageProvider::Pixmap) {} + + 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 = 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/imageprovider.cpp b/src/core/imageprovider.cpp new file mode 100644 index 00000000..47f284c7 --- /dev/null +++ b/src/core/imageprovider.cpp @@ -0,0 +1,93 @@ +#include "imageprovider.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +namespace { +QMap liveImages; // NOLINT +quint32 handleIndex = 0; // NOLINT +} // namespace + +void parseReq(const QString& req, QString& target, QString& param) { + auto splitIdx = req.indexOf('/'); + if (splitIdx != -1) { + target = req.sliced(0, splitIdx); + param = req.sliced(splitIdx + 1); + } else { + target = req; + } +} + +} // namespace + +QsImageHandle::QsImageHandle(QQmlImageProviderBase::ImageType type) + : type(type) + , id(QString::number(++handleIndex)) { + liveImages.insert(this->id, this); +} + +QsImageHandle::~QsImageHandle() { liveImages.remove(this->id); } + +QString QsImageHandle::url() const { + QString url = "image://"; + if (this->type == QQmlImageProviderBase::Image) url += "qsimage"; + else if (this->type == QQmlImageProviderBase::Pixmap) url += "qspixmap"; + url += "/" + this->id; + return url; +} + +QImage +QsImageHandle::requestImage(const QString& /*unused*/, QSize* /*unused*/, const QSize& /*unused*/) { + qWarning() << "Image handle" << this << "does not provide QImages"; + return QImage(); +} + +QPixmap QsImageHandle:: + requestPixmap(const QString& /*unused*/, QSize* /*unused*/, const QSize& /*unused*/) { + qWarning() << "Image handle" << this << "does not provide QPixmaps"; + return QPixmap(); +} + +QImage QsImageProvider::requestImage(const QString& id, QSize* size, const QSize& requestedSize) { + QString target; + QString param; + parseReq(id, target, param); + + auto* handle = liveImages.value(target); + if (handle != nullptr) { + return handle->requestImage(param, size, requestedSize); + } else { + qWarning() << "Requested image from unknown handle" << id; + return QImage(); + } +} + +QPixmap +QsPixmapProvider::requestPixmap(const QString& id, QSize* size, const QSize& requestedSize) { + QString target; + QString param; + parseReq(id, target, param); + + auto* handle = liveImages.value(target); + if (handle != nullptr) { + return handle->requestPixmap(param, size, requestedSize); + } else { + qWarning() << "Requested image from unknown handle" << id; + return QPixmap(); + } +} + +QString QsIndexedImageHandle::url() const { + return this->QsImageHandle::url() % '/' % QString::number(this->changeIndex); +} + +void QsIndexedImageHandle::imageChanged() { ++this->changeIndex; } diff --git a/src/core/imageprovider.hpp b/src/core/imageprovider.hpp new file mode 100644 index 00000000..8568d4f7 --- /dev/null +++ b/src/core/imageprovider.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class QsImageProvider: public QQuickImageProvider { +public: + explicit QsImageProvider(): QQuickImageProvider(QQuickImageProvider::Image) {} + QImage requestImage(const QString& id, QSize* size, const QSize& requestedSize) override; +}; + +class QsPixmapProvider: public QQuickImageProvider { +public: + explicit QsPixmapProvider(): QQuickImageProvider(QQuickImageProvider::Pixmap) {} + QPixmap requestPixmap(const QString& id, QSize* size, const QSize& requestedSize) override; +}; + +class QsImageHandle { +public: + explicit QsImageHandle(QQmlImageProviderBase::ImageType type); + virtual ~QsImageHandle(); + Q_DISABLE_COPY_MOVE(QsImageHandle); + + [[nodiscard]] virtual QString url() const; + + virtual QImage requestImage(const QString& id, QSize* size, const QSize& requestedSize); + virtual QPixmap requestPixmap(const QString& id, QSize* size, const QSize& requestedSize); + +private: + QQmlImageProviderBase::ImageType type; + QString id; +}; + +class QsIndexedImageHandle: public QsImageHandle { +public: + explicit QsIndexedImageHandle(QQmlImageProviderBase::ImageType type): QsImageHandle(type) {} + + [[nodiscard]] QString url() const override; + void imageChanged(); + +private: + quint32 changeIndex = 0; +}; diff --git a/src/core/incubator.cpp b/src/core/incubator.cpp new file mode 100644 index 00000000..c9d149a8 --- /dev/null +++ b/src/core/incubator.cpp @@ -0,0 +1,17 @@ +#include "incubator.hpp" + +#include +#include +#include + +#include "logcat.hpp" + +QS_LOGGING_CATEGORY(logIncubator, "quickshell.incubator", QtWarningMsg); + +void QsQmlIncubator::statusChanged(QQmlIncubator::Status status) { + switch (status) { + case QQmlIncubator::Ready: emit this->completed(); break; + case QQmlIncubator::Error: emit this->failed(); break; + default: break; + } +} diff --git a/src/core/incubator.hpp b/src/core/incubator.hpp new file mode 100644 index 00000000..5ebb9a0e --- /dev/null +++ b/src/core/incubator.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +#include "logcat.hpp" + +QS_DECLARE_LOGGING_CATEGORY(logIncubator); + +class QsQmlIncubator + : public QObject + , public QQmlIncubator { + Q_OBJECT; + +public: + explicit QsQmlIncubator(QsQmlIncubator::IncubationMode mode, QObject* parent = nullptr) + : QObject(parent) + , QQmlIncubator(mode) {} + + void statusChanged(QQmlIncubator::Status status) override; + +signals: + void completed(); + void failed(); +}; + +class DelayedQmlIncubationController: public QQmlIncubationController { + // Do nothing. + // This ensures lazy loaders don't start blocking before onReload creates windows. +}; diff --git a/src/core/instanceinfo.cpp b/src/core/instanceinfo.cpp new file mode 100644 index 00000000..7f0132be --- /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 << info.pid; + return stream; +} + +QDataStream& operator>>(QDataStream& stream, InstanceInfo& info) { + stream >> info.instanceId >> info.configPath >> info.shellId >> info.launchTime >> info.pid; + 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..98ce614f --- /dev/null +++ b/src/core/instanceinfo.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include + +struct InstanceInfo { + QString instanceId; + QString configPath; + QString shellId; + QDateTime launchTime; + pid_t pid = -1; + + 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 new file mode 100644 index 00000000..be0eb78b --- /dev/null +++ b/src/core/lazyloader.cpp @@ -0,0 +1,200 @@ +#include "lazyloader.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "incubator.hpp" +#include "reload.hpp" + +void LazyLoader::onReload(QObject* oldInstance) { + auto* old = qobject_cast(oldInstance); + + this->incubateIfReady(true); + + if (old != nullptr && old->mItem != nullptr && this->incubator != nullptr) { + this->incubator->forceCompletion(); + } + + if (this->mItem != nullptr) { + if (auto* reloadable = qobject_cast(this->mItem)) { + reloadable->reload(old == nullptr ? nullptr : old->mItem); + } else { + Reloadable::reloadRecursive(this->mItem, old); + } + } +} + +QObject* LazyLoader::item() { + if (this->isLoading()) this->setActive(true); + return this->mItem; +} + +void LazyLoader::setItem(QObject* item) { + if (item == this->mItem) return; + + if (this->mItem != nullptr) { + this->mItem->deleteLater(); + } + + this->mItem = item; + + if (item != nullptr) { + item->setParent(this); + } + + this->targetActive = this->isActive(); + + emit this->itemChanged(); + emit this->activeChanged(); +} + +bool LazyLoader::isLoading() const { return this->incubator != nullptr; } + +void LazyLoader::setLoading(bool loading) { + if (loading == this->targetLoading || this->isActive()) return; + this->targetLoading = loading; + + if (loading) { + this->incubateIfReady(); + } else if (this->mItem != nullptr) { + this->mItem->deleteLater(); + this->mItem = nullptr; + } else if (this->incubator != nullptr) { + delete this->incubator; + this->incubator = nullptr; + } +} + +bool LazyLoader::isActive() const { return this->mItem != nullptr; } + +void LazyLoader::setActive(bool active) { + if (active == this->targetActive) return; + this->targetActive = active; + + if (active) { + if (this->isLoading()) { + this->incubator->forceCompletion(); + } else if (!this->isActive()) { + this->incubateIfReady(); + } + } else if (this->isActive()) { + this->setItem(nullptr); + } +} + +void LazyLoader::setActiveAsync(bool active) { + if (active == (this->targetActive || this->targetLoading)) return; + if (active) this->setLoading(true); + else this->setActive(false); +} + +QQmlComponent* LazyLoader::component() const { + return this->cleanupComponent ? nullptr : this->mComponent; +} + +void LazyLoader::setComponent(QQmlComponent* component) { + if (this->cleanupComponent) this->setSource(nullptr); + if (component == this->mComponent) return; + this->cleanupComponent = false; + + if (this->mComponent != nullptr) { + QObject::disconnect(this->mComponent, nullptr, this, nullptr); + } + + this->mComponent = component; + + if (component != nullptr) { + QObject::connect( + this->mComponent, + &QObject::destroyed, + this, + &LazyLoader::onComponentDestroyed + ); + } + + emit this->componentChanged(); +} + +void LazyLoader::onComponentDestroyed() { + this->mComponent = nullptr; + // todo: figure out what happens to the incubator +} + +QString LazyLoader::source() const { return this->mSource; } + +void LazyLoader::setSource(QString source) { + if (!this->cleanupComponent) this->setComponent(nullptr); + if (source == this->mSource) return; + this->cleanupComponent = true; + + this->mSource = std::move(source); + delete this->mComponent; + + if (!this->mSource.isEmpty()) { + auto* context = QQmlEngine::contextForObject(this); + this->mComponent = new QQmlComponent( + context == nullptr ? nullptr : context->engine(), + context == nullptr ? this->mSource : context->resolvedUrl(this->mSource) + ); + + if (this->mComponent->isError()) { + qWarning() << this->mComponent->errorString().toStdString().c_str(); + delete this->mComponent; + this->mComponent = nullptr; + } + } else { + this->mComponent = nullptr; + } + + emit this->sourceChanged(); +} + +void LazyLoader::incubateIfReady(bool overrideReloadCheck) { + if (!(this->reloadComplete || overrideReloadCheck) || !(this->targetLoading || this->targetActive) + || this->mComponent == nullptr || this->incubator != nullptr) + { + return; + } + + this->incubator = new QsQmlIncubator( + this->targetActive ? QQmlIncubator::Synchronous : QQmlIncubator::Asynchronous, + this + ); + + // clang-format off + QObject::connect(this->incubator, &QsQmlIncubator::completed, this, &LazyLoader::onIncubationCompleted); + QObject::connect(this->incubator, &QsQmlIncubator::failed, this, &LazyLoader::onIncubationFailed); + // clang-format on + + emit this->loadingChanged(); + + this->mComponent->create(*this->incubator, QQmlEngine::contextForObject(this->mComponent)); +} + +void LazyLoader::onIncubationCompleted() { + this->setItem(this->incubator->object()); + // 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(); +} + +void LazyLoader::onIncubationFailed() { + qWarning() << "Failed to create LazyLoader component"; + + for (auto& error: this->incubator->errors()) { + qWarning() << error; + } + + delete this->incubator; + this->targetLoading = false; + emit this->loadingChanged(); +} diff --git a/src/core/lazyloader.hpp b/src/core/lazyloader.hpp new file mode 100644 index 00000000..dbaad4b5 --- /dev/null +++ b/src/core/lazyloader.hpp @@ -0,0 +1,173 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "incubator.hpp" +#include "reload.hpp" + +///! Asynchronous component loader. +/// The LazyLoader can be used to prepare components that don't need to be +/// created immediately, such as windows that aren't visible until triggered +/// by another action. It works on creating the component in the gaps between +/// frame rendering to prevent blocking the interface thread. +/// It can also be used to preserve memory by loading components only +/// when you need them and unloading them afterward. +/// +/// Note that when reloading the UI due to changes, lazy loaders will always +/// load synchronously so windows can be reused. +/// +/// #### Example +/// The following example creates a PopupWindow asynchronously as the bar loads. +/// This means the bar can be shown onscreen before the popup is ready, however +/// trying to show the popup before it has finished loading in the background +/// will cause the UI thread to block. +/// +/// ```qml +/// import QtQuick +/// import QtQuick.Controls +/// import Quickshell +/// +/// ShellRoot { +/// PanelWindow { +/// id: window +/// height: 50 +/// +/// anchors { +/// bottom: true +/// left: true +/// right: true +/// } +/// +/// LazyLoader { +/// id: popupLoader +/// +/// // start loading immediately +/// loading: true +/// +/// // this window will be loaded in the background during spare +/// // frame time unless active is set to true, where it will be +/// // loaded in the foreground +/// PopupWindow { +/// // position the popup above the button +/// parentWindow: window +/// relativeX: window.width / 2 - width / 2 +/// relativeY: -height +/// +/// // some heavy component here +/// +/// width: 200 +/// height: 200 +/// } +/// } +/// +/// Button { +/// anchors.centerIn: parent +/// text: "show popup" +/// +/// // accessing popupLoader.item will force the loader to +/// // finish loading on the UI thread if it isn't finished yet. +/// onClicked: popupLoader.item.visible = !popupLoader.item.visible +/// } +/// } +/// } +/// ``` +/// +/// > [!WARNING] Components that internally load other components must explicitly +/// > support asynchronous loading to avoid blocking. +/// > +/// > 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. +/// +/// > [!WARNING] LazyLoaders do not start loading before the first window is created, +/// > 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 nor @@active. + /// + /// Note that the item is owned by the LazyLoader, and destroying the LazyLoader + /// will destroy the item. + /// + /// > [!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 @@activeChanged(s) signal to + /// > ensure loading happens asynchronously. + Q_PROPERTY(QObject* item READ item NOTIFY itemChanged); + /// If the loader is actively loading. + /// + /// If the component is not loaded, setting this property to true will start + /// loading it asynchronously. If the component is already loaded, setting + /// this property has no effect. + /// + /// See also: @@activeAsync. + Q_PROPERTY(bool loading READ isLoading WRITE setLoading NOTIFY loadingChanged); + /// If the component is fully loaded. + /// + /// Setting this property to `true` will force the component to load to completion, + /// blocking the UI, and setting it to `false` will destroy the component, requiring + /// it to be loaded again. + /// + /// 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. 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. + Q_PROPERTY(QQmlComponent* component READ component WRITE setComponent NOTIFY componentChanged); + /// 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; + +public: + void onReload(QObject* oldInstance) override; + + [[nodiscard]] bool isActive() const; + void setActive(bool active); + void setActiveAsync(bool active); + + [[nodiscard]] bool isLoading() const; + void setLoading(bool loading); + + [[nodiscard]] QObject* item(); + void setItem(QObject* item); + + [[nodiscard]] QQmlComponent* component() const; + void setComponent(QQmlComponent* component); + + [[nodiscard]] QString source() const; + void setSource(QString source); + +signals: + void activeChanged(); + void loadingChanged(); + void itemChanged(); + void sourceChanged(); + void componentChanged(); + +private slots: + void onIncubationCompleted(); + void onIncubationFailed(); + void onComponentDestroyed(); + +private: + void incubateIfReady(bool overrideReloadCheck = false); + void waitForObjectCreation(); + + bool targetLoading = false; + bool targetActive = false; + QObject* mItem = nullptr; + QString mSource; + QQmlComponent* mComponent = nullptr; + QsQmlIncubator* incubator = nullptr; + bool cleanupComponent = false; +}; diff --git a/src/core/logcat.hpp b/src/core/logcat.hpp new file mode 100644 index 00000000..9650ddbf --- /dev/null +++ b/src/core/logcat.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +namespace qs::log { +void initLogCategoryLevel(const char* name, QtMsgType defaultLevel = QtDebugMsg); +} + +// NOLINTNEXTLINE(cppcoreguidelines-macro-usage) +#define QS_DECLARE_LOGGING_CATEGORY(name) \ + namespace qslogcat { \ + Q_DECLARE_LOGGING_CATEGORY(name); \ + } \ + const QLoggingCategory& name() + +// NOLINTNEXTLINE(cppcoreguidelines-macro-usage) +#define QS_LOGGING_CATEGORY(name, category, ...) \ + namespace qslogcat { \ + Q_LOGGING_CATEGORY(name, category __VA_OPT__(, __VA_ARGS__)); \ + } \ + const QLoggingCategory& name() { \ + static auto* init = []() { \ + qs::log::initLogCategoryLevel(category __VA_OPT__(, __VA_ARGS__)); \ + return &qslogcat::name; \ + }(); \ + return (init) (); \ + } diff --git a/src/core/logging.cpp b/src/core/logging.cpp new file mode 100644 index 00000000..cb3a2142 --- /dev/null +++ b/src/core/logging.cpp @@ -0,0 +1,957 @@ +#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 "logcat.hpp" +#include "logging_p.hpp" +#include "logging_qtprivate.cpp" // NOLINT +#include "paths.hpp" +#include "ringbuf.hpp" + +QS_LOGGING_CATEGORY(logBare, "quickshell.bare"); + +namespace qs::log { +using namespace qt_logging_registry; + +QS_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.")); + + CategoryFilter filter; + + // We don't respect log filters for qs logs because some distros like to ship + // default configs that hide everything. QT_LOGGING_RULES is considered via the filter list. + if (isQs) { + // QtDebugMsg == 0, so default + auto defaultLevel = instance->defaultLevels.value(categoryName); + + filter = CategoryFilter(); + // clang-format off + filter.debug = instance->mDefaultLevel == QtDebugMsg || defaultLevel == QtDebugMsg; + filter.info = filter.debug || instance->mDefaultLevel == QtInfoMsg || defaultLevel == QtInfoMsg; + filter.warn = filter.info || instance->mDefaultLevel == QtWarningMsg || defaultLevel == QtWarningMsg; + filter.critical = filter.warn || instance->mDefaultLevel == QtCriticalMsg || defaultLevel == QtCriticalMsg; + // clang-format on + } else if (instance->lastCategoryFilter) { + instance->lastCategoryFilter(category); + filter = CategoryFilter(category); + } + + 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; + // Load QT_LOGGING_RULES because we ignore the last category filter for QS messages + // due to disk config files. + parser.setContent(qEnvironmentVariable("QT_LOGGING_RULES")); + instance->rules = new QList(parser.rules()); + parser.setContent(rules); + instance->rules->append(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 initLogCategoryLevel(const char* name, QtMsgType defaultLevel) { + LogManager::instance()->defaultLevels.insert(QLatin1StringView(name), defaultLevel); +} + +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" << detailedPath; + } + + // 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 && !this->detailedFile->flush())) { + if (this->detailedFile) { + qCCritical(logLogging) << "Detailed logger failed to write. Ending detailed logs."; + } + + this->detailedWriter.setDevice(nullptr); + this->detailedFile->close(); + this->detailedFile = nullptr; + } +} + +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..bf811333 --- /dev/null +++ b/src/core/logging.hpp @@ -0,0 +1,154 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "logcat.hpp" + +QS_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 defaultLevels; + QHash sparseFilters; + QHash allFilters; + + QTextStream stdoutStream; + LoggingThreadProxy threadProxy; + + friend void initLogCategoryLevel(const char* name, QtMsgType defaultLevel); +}; + +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..48f74dee --- /dev/null +++ b/src/core/logging_qtprivate.cpp @@ -0,0 +1,139 @@ +// 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 "logcat.hpp" +#include "logging_qtprivate.hpp" + +namespace qs::log { +QS_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..61d3a7cf --- /dev/null +++ b/src/core/logging_qtprivate.hpp @@ -0,0 +1,47 @@ +#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 + +#include "logcat.hpp" + +namespace qs::log { +QS_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 7dd7af30..00000000 --- a/src/core/main.cpp +++ /dev/null @@ -1,257 +0,0 @@ -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "plugin.hpp" -#include "rootwrapper.hpp" - -int main(int argc, char** argv) { - const auto app = QGuiApplication(argc, argv); - QGuiApplication::setApplicationName("quickshell"); - QGuiApplication::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); - - QString configFilePath; - { - 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) { - qFatal() << "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) { - qFatal() << "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 - } - } - - qFatal() << "configuration" << configName << "not found in manifest" << manifestPath; - return -1; - } else if (manifestPathLevel < 2) { - qFatal() << "cannot open config manifest at" << manifestPath; - return -1; - } - } - - { - auto basePathInfo = QFileInfo(basePath); - if (!basePathInfo.exists()) { - qFatal() << "base path does not exist:" << basePath; - return -1; - } else if (!QFileInfo(basePathInfo.canonicalFilePath()).isDir()) { - qFatal() << "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 - } - } - - qFatal() << "no directory named " << configName << "found in base path" << basePath; - return -1; - } - haspath:; - } else { - configFilePath = basePath; - } - - auto configFile = QFileInfo(configFilePath); - if (!configFile.exists()) { - qFatal() << "config path does not exist:" << configFilePath; - return -1; - } - - if (configFile.isDir()) { - configFilePath = QDir(configFilePath).filePath("shell.qml"); - } - - configFile = QFileInfo(configFilePath); - if (!configFile.exists()) { - qFatal() << "no shell.qml found in config path:" << configFilePath; - return -1; - } else if (configFile.isDir()) { - qFatal() << "shell.qml is a directory:" << configFilePath; - return -1; - } - - configFilePath = QFileInfo(configFilePath).canonicalFilePath(); - configFile = QFileInfo(configFilePath); - if (!configFile.exists()) { - qFatal() << "config file does not exist:" << configFilePath; - return -1; - } else if (configFile.isDir()) { - qFatal() << "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)) { - QDir::setCurrent(parser.value(workdirOption)); - } - - QuickshellPlugin::initPlugins(); - - // Base window transparency appears to be additive. - // Use a fully transparent window with a colored rect. - QQuickWindow::setDefaultAlphaBuffer(true); - - auto root = RootWrapper(configFilePath); - - return QGuiApplication::exec(); -} diff --git a/src/core/model.cpp b/src/core/model.cpp new file mode 100644 index 00000000..165c6066 --- /dev/null +++ b/src/core/model.cpp @@ -0,0 +1,81 @@ +#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"}}; +} + +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..3c5822a4 --- /dev/null +++ b/src/core/model.hpp @@ -0,0 +1,126 @@ +#pragma once + +#include + +#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(QList 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]] QList values() const { return this->valuesList; } + 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 insertObjectSorted(T* object, const std::function& compare) { + auto& list = this->valueList(); + auto iter = list.begin(); + + while (iter != list.end()) { + if (!compare(object, *iter)) break; + ++iter; + } + + auto idx = iter - list.begin(); + this->UntypedObjectModel::insertObject(object, idx); + } + + 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 70b2c8c8..b9404ea9 100644 --- a/src/core/module.md +++ b/src/core/module.md @@ -7,10 +7,28 @@ headers = [ "shell.hpp", "variants.hpp", "region.hpp", - "proxywindow.hpp", + "../window/proxywindow.hpp", "persistentprops.hpp", - "windowinterface.hpp", - "panelinterface.hpp", - "floatingwindow.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", + "scriptmodel.hpp", + "colorquantizer.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..e17c3bcf --- /dev/null +++ b/src/core/paths.cpp @@ -0,0 +1,425 @@ +#include "paths.hpp" +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "instanceinfo.hpp" +#include "logcat.hpp" + +namespace { +QS_LOGGING_CATEGORY(logPaths, "quickshell.paths", QtWarningMsg); +} + +QsPaths* QsPaths::instance() { + static auto* instance = new QsPaths(); // NOLINT + return instance; +} + +void QsPaths::init(QString shellId, QString pathId, QString dataOverride, QString stateOverride) { + auto* instance = QsPaths::instance(); + instance->shellId = std::move(shellId); + instance->pathId = std::move(pathId); + instance->shellDataOverride = std::move(dataOverride); + instance->shellStateOverride = std::move(stateOverride); +} + +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::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; +} + +QDir* QsPaths::shellVfsDir() { + if (this->shellVfsState == DirState::Unknown) { + if (auto* baseRunDir = this->baseRunDir()) { + this->mShellVfsDir = QDir(baseRunDir->filePath("vfs")); + this->mShellVfsDir = QDir(this->mShellVfsDir.filePath(this->shellId)); + + qCDebug(logPaths) << "Initialized runtime vfs path:" << this->mShellVfsDir.path(); + + if (!this->mShellVfsDir.mkpath(".")) { + qCCritical(logPaths) << "Could not create runtime vfs directory at" + << this->mShellVfsDir.path(); + this->shellVfsState = DirState::Failed; + } else { + this->shellVfsState = DirState::Ready; + } + } else { + qCCritical(logPaths) << "Could not create shell runtime vfs path as it was not possible to " + "create the base runtime path."; + + this->shellVfsState = DirState::Failed; + } + } + + if (this->shellVfsState == DirState::Failed) return nullptr; + else return &this->mShellVfsDir; +} + +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."; + } +} + +QDir QsPaths::shellDataDir() { + if (this->shellDataState == DirState::Unknown) { + QDir dir; + if (this->shellDataOverride.isEmpty()) { + dir = QDir(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)); + dir = QDir(dir.filePath("by-shell")); + dir = QDir(dir.filePath(this->shellId)); + } else { + auto basedir = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation); + dir = QDir(this->shellDataOverride.replace("$BASE", basedir)); + } + + this->mShellDataDir = dir; + + qCDebug(logPaths) << "Initialized data path:" << dir.path(); + + if (!dir.mkpath(".")) { + qCCritical(logPaths) << "Could not create data directory at" << dir.path(); + + this->shellDataState = DirState::Failed; + } else { + this->shellDataState = DirState::Ready; + } + } + + // Returning no path on fail might result in files being written in unintended locations. + return this->mShellDataDir; +} + +QDir QsPaths::shellStateDir() { + if (this->shellStateState == DirState::Unknown) { +#if QT_VERSION < QT_VERSION_CHECK(6, 7, 0) + QDir dir; + if (qEnvironmentVariableIsSet("XDG_STATE_HOME")) { + dir = QDir(qEnvironmentVariable("XDG_STATE_HOME")); + } else { + auto home = QDir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation)); + dir = QDir(home.filePath(".local/state")); + } + + if (this->shellStateOverride.isEmpty()) { + dir = QDir(dir.filePath("quickshell/by-shell")); + dir = QDir(dir.filePath(this->shellId)); + } else { + dir = QDir(this->shellStateOverride.replace("$BASE", dir.path())); + } +#else + QDir dir; + if (this->shellStateOverride.isEmpty()) { + dir = QDir(QStandardPaths::writableLocation(QStandardPaths::StateLocation)); + dir = QDir(dir.filePath("by-shell")); + dir = QDir(dir.filePath(this->shellId)); + } else { + auto basedir = QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation); + dir = QDir(this->shellStateOverride.replace("$BASE", basedir)); + } +#endif + this->mShellStateDir = dir; + + qCDebug(logPaths) << "Initialized state path:" << dir.path(); + + if (!dir.mkpath(".")) { + qCCritical(logPaths) << "Could not create state directory at" << dir.path(); + + this->shellStateState = DirState::Failed; + } else { + this->shellStateState = DirState::Ready; + } + } + + // Returning no path on fail might result in files being written in unintended locations. + return this->mShellStateDir; +} + +QDir QsPaths::shellCacheDir() { + if (this->shellCacheState == DirState::Unknown) { + auto dir = QDir(QStandardPaths::writableLocation(QStandardPaths::CacheLocation)); + dir = QDir(dir.filePath("by-shell")); + dir = QDir(dir.filePath(this->shellId)); + this->mShellCacheDir = dir; + + qCDebug(logPaths) << "Initialized cache path:" << dir.path(); + + if (!dir.mkpath(".")) { + qCCritical(logPaths) << "Could not create cache directory at" << dir.path(); + + this->shellCacheState = DirState::Failed; + } else { + this->shellCacheState = DirState::Ready; + } + } + + // Returning no path on fail might result in files being written in unintended locations. + return this->mShellCacheDir; +} + +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, bool allowDead) { + 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 + auto isLocked = lock.l_type != F_UNLCK; + + if (!isLocked && !allowDead) return false; + + if (info) { + info->pid = isLocked ? lock.l_pid : -1; + + auto stream = QDataStream(&file); + stream >> info->instance; + } + + return true; +} + +QPair, QVector> +QsPaths::collectInstances(const QString& path) { + qCDebug(logPaths) << "Collecting instances from" << path; + auto liveInstances = QVector(); + auto deadInstances = 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, true)) { + qCDebug(logPaths).nospace() << "Found instance " << info.instance.instanceId << " (pid " + << info.pid << ") at " << path; + + if (info.pid == -1) { + deadInstances.push_back(info); + } else { + liveInstances.push_back(info); + } + } else { + qCDebug(logPaths) << "Skipped potential instance at" << path; + } + } + + return qMakePair(liveInstances, deadInstances); +} diff --git a/src/core/paths.hpp b/src/core/paths.hpp new file mode 100644 index 00000000..178bcda1 --- /dev/null +++ b/src/core/paths.hpp @@ -0,0 +1,68 @@ +#pragma once +#include +#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, QString dataOverride, QString stateOverride); + 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, bool allowDead = false); + static QPair, QVector> + collectInstances(const QString& path); + + QDir* baseRunDir(); + QDir* shellRunDir(); + QDir* shellVfsDir(); + QDir* instanceRunDir(); + void linkRunDir(); + void linkPathDir(); + void createLock(); + + QDir shellDataDir(); + QDir shellStateDir(); + QDir shellCacheDir(); + +private: + enum class DirState : quint8 { + Unknown = 0, + Ready = 1, + Failed = 2, + }; + + QString shellId; + QString pathId; + QDir mBaseRunDir; + QDir mShellRunDir; + QDir mShellVfsDir; + QDir mInstanceRunDir; + DirState baseRunState = DirState::Unknown; + DirState shellRunState = DirState::Unknown; + DirState shellVfsState = DirState::Unknown; + DirState instanceRunState = DirState::Unknown; + + QDir mShellDataDir; + QDir mShellStateDir; + QDir mShellCacheDir; + DirState shellDataState = DirState::Unknown; + DirState shellStateState = DirState::Unknown; + DirState shellCacheState = DirState::Unknown; + + QString shellDataOverride; + QString shellStateOverride; +}; 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 21312de1..0eb9a067 100644 --- a/src/core/plugin.cpp +++ b/src/core/plugin.cpp @@ -3,31 +3,36 @@ #include // NOLINT (what??) -static QVector plugins; // NOLINT +#include "generation.hpp" -void QuickshellPlugin::registerPlugin(QuickshellPlugin& plugin) { plugins.push_back(&plugin); } +static QVector plugins; // NOLINT -void QuickshellPlugin::initPlugins() { - plugins.erase( - std::remove_if( - plugins.begin(), - plugins.end(), - [](QuickshellPlugin* plugin) { return !plugin->applies(); } - ), - plugins.end() - ); +void QsEnginePlugin::registerPlugin(QsEnginePlugin& plugin) { plugins.push_back(&plugin); } - for (QuickshellPlugin* plugin: plugins) { +void QsEnginePlugin::initPlugins() { + plugins.removeIf([](QsEnginePlugin* plugin) { return !plugin->applies(); }); + + std::ranges::sort(plugins, [](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::runOnReload() { - for (QuickshellPlugin* plugin: plugins) { +void QsEnginePlugin::runConstructGeneration(EngineGeneration& generation) { + for (QsEnginePlugin* plugin: plugins) { + plugin->constructGeneration(generation); + } +} + +void QsEnginePlugin::runOnReload() { + for (QsEnginePlugin* plugin: plugins) { plugin->onReload(); } } diff --git a/src/core/plugin.hpp b/src/core/plugin.hpp index 8a3719b1..f0c14dce 100644 --- a/src/core/plugin.hpp +++ b/src/core/plugin.hpp @@ -2,23 +2,30 @@ #include #include +#include -class QuickshellPlugin { +class EngineGeneration; + +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(); }; @@ -26,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..bbcc3a5f --- /dev/null +++ b/src/core/popupanchor.cpp @@ -0,0 +1,386 @@ +#include "popupanchor.hpp" +#include + +#include +#include +#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(); } + +QWindow* PopupAnchor::backingWindow() const { + return this->mProxyWindow ? this->mProxyWindow->backingWindow() : nullptr; +} + +void PopupAnchor::setWindowInternal(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::setWindow(QObject* window) { + this->setItem(nullptr); + this->setWindowInternal(window); +} + +void PopupAnchor::setItem(QQuickItem* item) { + if (item == this->mItem) return; + + if (this->mItem) { + QObject::disconnect(this->mItem, nullptr, this, nullptr); + } + + this->mItem = item; + this->onItemWindowChanged(); + + if (item) { + QObject::connect(item, &QObject::destroyed, this, &PopupAnchor::onItemDestroyed); + QObject::connect(item, &QQuickItem::windowChanged, this, &PopupAnchor::onItemWindowChanged); + } +} + +void PopupAnchor::onWindowDestroyed() { + this->mWindow = nullptr; + this->mProxyWindow = nullptr; + emit this->windowChanged(); + emit this->backingWindowVisibilityChanged(); +} + +void PopupAnchor::onItemDestroyed() { + this->mItem = nullptr; + emit this->itemChanged(); + this->setWindowInternal(nullptr); +} + +void PopupAnchor::onItemWindowChanged() { + if (auto* window = qobject_cast(this->mItem->window())) { + this->setWindowInternal(window->proxy()); + } else { + this->setWindowInternal(nullptr); + } +} + +void PopupAnchor::setRect(Box rect) { + if (rect.w <= 0) rect.w = 1; + if (rect.h <= 0) rect.h = 1; + if (rect == this->mUserRect) return; + + this->mUserRect = rect; + emit this->rectChanged(); + + this->setWindowRect(rect.qrect().marginsRemoved(this->mMargins.qmargins())); +} + +void PopupAnchor::setMargins(Margins margins) { + if (margins == this->mMargins) return; + + this->mMargins = margins; + emit this->marginsChanged(); + + this->setWindowRect(this->mUserRect.qrect().marginsRemoved(margins.qmargins())); +} + +void PopupAnchor::setWindowRect(QRect rect) { + if (rect.width() <= 0) rect.setWidth(1); + if (rect.height() <= 0) rect.setHeight(1); + if (rect == this->state.rect) return; + + this->state.rect = rect; + emit this->windowRectChanged(); +} + +void PopupAnchor::resetRect() { this->mUserRect = Box(); } + +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(); +} + +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(); +} + +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; +} + +void PopupAnchor::updateAnchor() { + if (this->mItem && this->mProxyWindow) { + auto baseRect = + this->mUserRect.isEmpty() ? this->mItem->boundingRect() : this->mUserRect.qrect(); + + auto rect = this->mProxyWindow->contentItem()->mapFromItem( + this->mItem, + baseRect.marginsRemoved(this->mMargins.qmargins()) + ); + + if (rect.width() < 1) rect.setWidth(1); + if (rect.height() < 1) rect.setHeight(1); + + this->setWindowRect(rect.toRect()); + } + + emit this->anchoring(); +} + +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(); + + anchor->updateAnchor(); + 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->windowRect().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; + } + + effectiveX = std::max(effectiveX, screenGeometry.left()); + } + + if (adjustment.testFlag(PopupAdjustment::SlideY)) { + if (effectiveY + windowGeometry.height() > screenGeometry.bottom()) { + effectiveY = screenGeometry.bottom() - windowGeometry.height() + 1; + } + + effectiveY = std::max(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..a9b121ed --- /dev/null +++ b/src/core/popupanchor.hpp @@ -0,0 +1,214 @@ +#pragma once + +#include + +#include +#include +#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; + + QRect 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. Setting this property unsets @@item. + Q_PROPERTY(QObject* window READ window WRITE setWindow NOTIFY windowChanged); + /// The item to anchor / attach the popup to. Setting this property unsets @@window. + /// + /// The popup's position relative to its parent window is only calculated when it is + /// initially shown (directly before @@anchoring(s) is emitted), meaning its anchor + /// rectangle will be set relative to the item's position in the window at that time. + /// @@updateAnchor() can be called to update the anchor rectangle if the item's position + /// has changed. + /// + /// > [!NOTE] If a more flexible way to position a popup relative to an item is needed, + /// > set @@window to the item's parent window, and handle the @@anchoring signal to + /// > position the popup relative to the window's contentItem. + Q_PROPERTY(QQuickItem* item READ item WRITE setItem NOTIFY itemChanged); + /// The anchorpoints the popup will attach to, relative to @@item or @@window. + /// Which anchors will be used is determined by the @@edges, @@gravity, and @@adjustment. + /// + /// If using @@item, the default anchor rectangle matches the dimensions of the item. + /// + /// 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. + /// + /// [coordinate mapping functions]: https://doc.qt.io/qt-6/qml-qtquick-item.html#mapFromItem-method + Q_PROPERTY(Box rect READ rect WRITE setRect RESET resetRect NOTIFY rectChanged); + /// A margin applied to the anchor rect. + /// + /// This is most useful when @@item is used and @@rect is left at its default + /// value (matching the Item's dimensions). + Q_PROPERTY(Margins margins READ margins WRITE setMargins NOTIFY marginsChanged); + /// 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) {} + + /// Update the popup's anchor rect relative to its parent window. + /// + /// If anchored to an item, popups anchors will not automatically follow + /// the item if its position changes. This function can be called to + /// recalculate the anchors. + Q_INVOKABLE void updateAnchor(); + + [[nodiscard]] bool isDirty() const; + void markClean(); + void markDirty(); + + [[nodiscard]] QObject* window() const { return this->mWindow; } + [[nodiscard]] ProxyWindowBase* proxyWindow() const { return this->mProxyWindow; } + [[nodiscard]] QWindow* backingWindow() const; + void setWindowInternal(QObject* window); + void setWindow(QObject* window); + + [[nodiscard]] QQuickItem* item() const { return this->mItem; } + void setItem(QQuickItem* item); + + [[nodiscard]] QRect windowRect() const { return this->state.rect; } + void setWindowRect(QRect rect); + + [[nodiscard]] Box rect() const { return this->mUserRect; } + void setRect(Box rect); + void resetRect(); + + [[nodiscard]] Margins margins() const { return this->mMargins; } + void setMargins(Margins margins); + + [[nodiscard]] Edges::Flags edges() const { return this->state.edges; } + void setEdges(Edges::Flags edges); + + [[nodiscard]] Edges::Flags gravity() const { return this->state.gravity; } + void setGravity(Edges::Flags gravity); + + [[nodiscard]] PopupAdjustment::Flags adjustment() const { return this->state.adjustment; } + 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(); + void itemChanged(); + QSDOC_HIDE void backingWindowVisibilityChanged(); + QSDOC_HIDE void windowRectChanged(); + void rectChanged(); + void marginsChanged(); + void edgesChanged(); + void gravityChanged(); + void adjustmentChanged(); + +private slots: + void onWindowDestroyed(); + void onItemDestroyed(); + void onItemWindowChanged(); + +private: + QObject* mWindow = nullptr; + QQuickItem* mItem = nullptr; + ProxyWindowBase* mProxyWindow = nullptr; + PopupAnchorState state; + Box mUserRect; + Margins mMargins; + 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/proxywindow.cpp b/src/core/proxywindow.cpp deleted file mode 100644 index 1c1796ad..00000000 --- a/src/core/proxywindow.cpp +++ /dev/null @@ -1,223 +0,0 @@ -#include "proxywindow.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "qmlscreen.hpp" -#include "region.hpp" -#include "reload.hpp" - -ProxyWindowBase::ProxyWindowBase(QObject* parent) - : Reloadable(parent) - , mContentItem(new QQuickItem()) { - QQmlEngine::setObjectOwnership(this->mContentItem, QQmlEngine::CppOwnership); - this->mContentItem->setParent(this); - - QObject::connect(this, &ProxyWindowBase::widthChanged, this, &ProxyWindowBase::onWidthChanged); - QObject::connect(this, &ProxyWindowBase::heightChanged, this, &ProxyWindowBase::onHeightChanged); -} - -ProxyWindowBase::~ProxyWindowBase() { - if (this->window != nullptr) { - this->window->deleteLater(); - } -} - -void ProxyWindowBase::onReload(QObject* oldInstance) { - this->window = this->createWindow(oldInstance); - this->setupWindow(); - - Reloadable::reloadRecursive(this->mContentItem, oldInstance); - - this->mContentItem->setParentItem(this->window->contentItem()); - this->mContentItem->setWidth(this->width()); - this->mContentItem->setHeight(this->height()); - - // without this the dangling screen pointer wont be updated to a real screen - emit this->screenChanged(); - - emit this->windowConnected(); - this->window->setVisible(this->mVisible); -} - -QQuickWindow* ProxyWindowBase::createWindow(QObject* oldInstance) { - auto* old = qobject_cast(oldInstance); - - if (old == nullptr || old->window == nullptr) { - return new QQuickWindow(); - } else { - return old->disownWindow(); - } -} - -void ProxyWindowBase::setupWindow() { - // clang-format off - QObject::connect(this->window, &QWindow::visibilityChanged, this, &ProxyWindowBase::visibleChanged); - QObject::connect(this->window, &QWindow::widthChanged, this, &ProxyWindowBase::widthChanged); - QObject::connect(this->window, &QWindow::heightChanged, this, &ProxyWindowBase::heightChanged); - QObject::connect(this->window, &QWindow::screenChanged, this, &ProxyWindowBase::screenChanged); - QObject::connect(this->window, &QQuickWindow::colorChanged, this, &ProxyWindowBase::colorChanged); - - QObject::connect(this, &ProxyWindowBase::maskChanged, this, &ProxyWindowBase::onMaskChanged); - QObject::connect(this, &ProxyWindowBase::widthChanged, this, &ProxyWindowBase::onMaskChanged); - QObject::connect(this, &ProxyWindowBase::heightChanged, this, &ProxyWindowBase::onMaskChanged); - // clang-format on - - this->window->setScreen(this->mScreen); - this->setWidth(this->mWidth); - this->setHeight(this->mHeight); - this->setColor(this->mColor); - this->updateMask(); -} - -QQuickWindow* ProxyWindowBase::disownWindow() { - QObject::disconnect(this->window, nullptr, this, nullptr); - - this->mContentItem->setParentItem(nullptr); - - auto* window = this->window; - this->window = nullptr; - return window; -} - -QQuickWindow* ProxyWindowBase::backingWindow() const { return this->window; } -QQuickItem* ProxyWindowBase::contentItem() const { return this->mContentItem; } - -bool ProxyWindowBase::isVisible() const { - if (this->window == nullptr) return this->mVisible; - else return this->window->isVisible(); -} - -void ProxyWindowBase::setVisible(bool visible) { - if (this->window == nullptr) { - this->mVisible = visible; - emit this->visibleChanged(); - } else this->window->setVisible(visible); -} - -qint32 ProxyWindowBase::width() const { - if (this->window == nullptr) return this->mWidth; - else return this->window->width(); -} - -void ProxyWindowBase::setWidth(qint32 width) { - if (this->window == nullptr) { - this->mWidth = width; - emit this->widthChanged(); - } else this->window->setWidth(width); -} - -qint32 ProxyWindowBase::height() const { - if (this->window == nullptr) return this->mHeight; - else return this->window->height(); -} - -void ProxyWindowBase::setHeight(qint32 height) { - if (this->window == nullptr) { - this->mHeight = height; - emit this->heightChanged(); - } else this->window->setHeight(height); -} - -void ProxyWindowBase::setScreen(QuickshellScreenInfo* screen) { - if (this->mScreen != nullptr) { - QObject::disconnect(this->mScreen, nullptr, this, nullptr); - } - - auto* qscreen = screen == nullptr ? nullptr : screen->screen; - if (qscreen != nullptr) { - QObject::connect(qscreen, &QObject::destroyed, this, &ProxyWindowBase::onScreenDestroyed); - } - - if (this->window == nullptr) { - this->mScreen = qscreen; - emit this->screenChanged(); - } else this->window->setScreen(qscreen); -} - -void ProxyWindowBase::onScreenDestroyed() { this->mScreen = nullptr; } - -QuickshellScreenInfo* ProxyWindowBase::screen() const { - QScreen* qscreen = nullptr; - - if (this->window == nullptr) { - if (this->mScreen != nullptr) qscreen = this->mScreen; - } else { - qscreen = this->window->screen(); - } - - return new QuickshellScreenInfo( - const_cast(this), // NOLINT - qscreen - ); -} - -QColor ProxyWindowBase::color() const { - if (this->window == nullptr) return this->mColor; - else return this->window->color(); -} - -void ProxyWindowBase::setColor(QColor color) { - if (this->window == nullptr) { - this->mColor = color; - emit this->colorChanged(); - } else this->window->setColor(color); -} - -PendingRegion* ProxyWindowBase::mask() const { return this->mMask; } - -void ProxyWindowBase::setMask(PendingRegion* mask) { - if (mask == this->mMask) return; - - if (this->mMask != nullptr) { - QObject::disconnect(this->mMask, nullptr, this, nullptr); - } - - this->mMask = mask; - - if (mask != nullptr) { - mask->setParent(this); - QObject::connect(mask, &QObject::destroyed, this, &ProxyWindowBase::onMaskDestroyed); - QObject::connect(mask, &PendingRegion::changed, this, &ProxyWindowBase::maskChanged); - } - - emit this->maskChanged(); -} - -void ProxyWindowBase::onMaskChanged() { - if (this->window != nullptr) this->updateMask(); -} - -void ProxyWindowBase::onMaskDestroyed() { - this->mMask = nullptr; - emit this->maskChanged(); -} - -void ProxyWindowBase::updateMask() { - QRegion mask; - if (this->mMask != nullptr) { - // if left as the default, dont combine it with the whole window area, leave it as is. - if (this->mMask->mIntersection == Intersection::Combine) { - mask = this->mMask->build(); - } else { - auto windowRegion = QRegion(QRect(0, 0, this->width(), this->height())); - mask = this->mMask->applyTo(windowRegion); - } - } - - this->window->setMask(mask); -} - -QQmlListProperty ProxyWindowBase::data() { - return this->mContentItem->property("data").value>(); -} - -void ProxyWindowBase::onWidthChanged() { this->mContentItem->setWidth(this->width()); } -void ProxyWindowBase::onHeightChanged() { this->mContentItem->setHeight(this->height()); } diff --git a/src/core/proxywindow.hpp b/src/core/proxywindow.hpp deleted file mode 100644 index accd434e..00000000 --- a/src/core/proxywindow.hpp +++ /dev/null @@ -1,115 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "qmlscreen.hpp" -#include "region.hpp" -#include "reload.hpp" -#include "windowinterface.hpp" - -// Proxy to an actual window exposing a limited property set with the ability to -// transfer it to a new window. - -///! Base class for reloadable windows -/// -/// [ShellWindow]: ../shellwindow -/// [FloatingWindow]: ../floatingwindow -class ProxyWindowBase: public Reloadable { - Q_OBJECT; - /// The QtQuick window backing this window. - /// - /// > [!WARNING] Do not expect values set via this property to work correctly. - /// > Values set this way will almost certainly misbehave across a reload, possibly - /// > even without one. - /// > - /// > Use **only** if you know what you are doing. - Q_PROPERTY(QQuickWindow* _backingWindow READ backingWindow); - Q_PROPERTY(QQuickItem* contentItem READ contentItem); - Q_PROPERTY(bool visible READ isVisible WRITE setVisible NOTIFY visibleChanged); - Q_PROPERTY(qint32 width READ width WRITE setWidth NOTIFY widthChanged); - Q_PROPERTY(qint32 height READ height WRITE setHeight NOTIFY heightChanged); - Q_PROPERTY(QuickshellScreenInfo* screen READ screen WRITE setScreen NOTIFY screenChanged); - Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY colorChanged); - Q_PROPERTY(PendingRegion* mask READ mask WRITE setMask NOTIFY maskChanged); - Q_PROPERTY(QQmlListProperty data READ data); - Q_CLASSINFO("DefaultProperty", "data"); - -public: - explicit ProxyWindowBase(QObject* parent = nullptr); - ~ProxyWindowBase() override; - - ProxyWindowBase(ProxyWindowBase&) = delete; - ProxyWindowBase(ProxyWindowBase&&) = delete; - void operator=(ProxyWindowBase&) = delete; - void operator=(ProxyWindowBase&&) = delete; - - void onReload(QObject* oldInstance) override; - - virtual QQuickWindow* createWindow(QObject* oldInstance); - virtual void setupWindow(); - - // Disown the backing window and delete all its children. - virtual QQuickWindow* disownWindow(); - - [[nodiscard]] QQuickWindow* backingWindow() const; - [[nodiscard]] QQuickItem* contentItem() const; - - [[nodiscard]] virtual bool isVisible() const; - virtual void setVisible(bool visible); - - [[nodiscard]] virtual qint32 width() const; - virtual void setWidth(qint32 width); - - [[nodiscard]] virtual qint32 height() const; - virtual void setHeight(qint32 height); - - [[nodiscard]] virtual QuickshellScreenInfo* screen() const; - virtual void setScreen(QuickshellScreenInfo* screen); - - [[nodiscard]] QColor color() const; - virtual void setColor(QColor color); - - [[nodiscard]] PendingRegion* mask() const; - virtual void setMask(PendingRegion* mask); - - [[nodiscard]] QQmlListProperty data(); - -signals: - void windowConnected(); - void visibleChanged(); - void widthChanged(); - void heightChanged(); - void screenChanged(); - void colorChanged(); - void maskChanged(); - -protected slots: - virtual void onWidthChanged(); - virtual void onHeightChanged(); - void onMaskChanged(); - void onMaskDestroyed(); - void onScreenDestroyed(); - -protected: - bool mVisible = true; - qint32 mWidth = 100; - qint32 mHeight = 100; - QScreen* mScreen = nullptr; - QColor mColor = Qt::white; - PendingRegion* mMask = nullptr; - QQuickWindow* window = nullptr; - QQuickItem* mContentItem = nullptr; - -private: - void updateMask(); -}; diff --git a/src/core/qmlglobal.cpp b/src/core/qmlglobal.cpp index 99f25631..07238f61 100644 --- a/src/core/qmlglobal.cpp +++ b/src/core/qmlglobal.cpp @@ -1,25 +1,44 @@ #include "qmlglobal.hpp" #include +#include #include #include #include #include +#include #include +#include #include #include +#include #include #include #include +#include #include #include #include #include +#include #include +#include "../io/processcore.hpp" +#include "generation.hpp" +#include "iconimageprovider.hpp" +#include "paths.hpp" #include "qmlscreen.hpp" #include "rootwrapper.hpp" +QuickshellSettings::QuickshellSettings() { + QObject::connect( + static_cast(QGuiApplication::instance()), // NOLINT + &QGuiApplication::lastWindowClosed, + this, + &QuickshellSettings::lastWindowClosed + ); +} + QuickshellSettings* QuickshellSettings::instance() { static QuickshellSettings* instance = nullptr; // NOLINT if (instance == nullptr) { @@ -48,37 +67,96 @@ void QuickshellSettings::setWatchFiles(bool watchFiles) { emit this->watchFilesChanged(); } -QuickshellGlobal::QuickshellGlobal(QObject* parent): QObject(parent) { - // clang-format off - QObject::connect(QuickshellSettings::instance(), &QuickshellSettings::workingDirectoryChanged, this, &QuickshellGlobal::workingDirectoryChanged); - QObject::connect(QuickshellSettings::instance(), &QuickshellSettings::watchFilesChanged, this, &QuickshellGlobal::watchFilesChanged); - // clang-format on - +QuickshellTracked::QuickshellTracked() { auto* app = QCoreApplication::instance(); auto* guiApp = qobject_cast(app); if (guiApp != nullptr) { // clang-format off - QObject::connect(guiApp, &QGuiApplication::primaryScreenChanged, this, &QuickshellGlobal::updateScreens); - QObject::connect(guiApp, &QGuiApplication::screenAdded, this, &QuickshellGlobal::updateScreens); - QObject::connect(guiApp, &QGuiApplication::screenRemoved, this, &QuickshellGlobal::updateScreens); + QObject::connect(guiApp, &QGuiApplication::primaryScreenChanged, this, &QuickshellTracked::updateScreens); + QObject::connect(guiApp, &QGuiApplication::screenAdded, this, &QuickshellTracked::updateScreens); + QObject::connect(guiApp, &QGuiApplication::screenRemoved, this, &QuickshellTracked::updateScreens); // clang-format on this->updateScreens(); } } +QuickshellScreenInfo* QuickshellTracked::screenInfo(QScreen* screen) const { + for (auto* info: this->screens) { + if (info->screen == screen) return info; + } + + return nullptr; +} + +QuickshellTracked* QuickshellTracked::instance() { + static QuickshellTracked* instance = nullptr; // NOLINT + if (instance == nullptr) { + QJSEngine::setObjectOwnership(instance, QJSEngine::CppOwnership); + instance = new QuickshellTracked(); + } + return instance; +} + +void QuickshellTracked::updateScreens() { + auto screens = QGuiApplication::screens(); + auto newScreens = QList(); + + for (auto* newScreen: screens) { + for (auto i = 0; i < this->screens.length(); i++) { + auto* oldScreen = this->screens[i]; + if (newScreen == oldScreen->screen) { + newScreens.push_back(oldScreen); + this->screens.remove(i); + goto next; + } + } + + { + auto* si = new QuickshellScreenInfo(this, newScreen); + QQmlEngine::setObjectOwnership(si, QQmlEngine::CppOwnership); + newScreens.push_back(si); + } + next:; + } + + for (auto* oldScreen: this->screens) { + oldScreen->deleteLater(); + } + + this->screens = newScreens; + emit this->screensChanged(); +} + +QuickshellGlobal::QuickshellGlobal(QObject* parent): QObject(parent) { + // clang-format off + QObject::connect(QuickshellSettings::instance(), &QuickshellSettings::workingDirectoryChanged, this, &QuickshellGlobal::workingDirectoryChanged); + QObject::connect(QuickshellSettings::instance(), &QuickshellSettings::watchFilesChanged, this, &QuickshellGlobal::watchFilesChanged); + QObject::connect(QuickshellSettings::instance(), &QuickshellSettings::lastWindowClosed, this, &QuickshellGlobal::lastWindowClosed); + + QObject::connect(QuickshellTracked::instance(), &QuickshellTracked::screensChanged, this, &QuickshellGlobal::screensChanged); + // clang-format on + + QObject::connect( + static_cast(QGuiApplication::instance())->clipboard(), // NOLINT + &QClipboard::changed, + this, + &QuickshellGlobal::onClipboardChanged + ); +} + qint32 QuickshellGlobal::processId() const { // NOLINT return getpid(); } -qsizetype QuickshellGlobal::screensCount(QQmlListProperty* prop) { - return static_cast(prop->object)->mScreens.size(); // NOLINT +qsizetype QuickshellGlobal::screensCount(QQmlListProperty* /*unused*/) { + return QuickshellTracked::instance()->screens.size(); } QuickshellScreenInfo* -QuickshellGlobal::screenAt(QQmlListProperty* prop, qsizetype i) { - return static_cast(prop->object)->mScreens.at(i); // NOLINT +QuickshellGlobal::screenAt(QQmlListProperty* /*unused*/, qsizetype i) { + return QuickshellTracked::instance()->screens.at(i); } QQmlListProperty QuickshellGlobal::screens() { @@ -91,8 +169,8 @@ QQmlListProperty QuickshellGlobal::screens() { } void QuickshellGlobal::reload(bool hard) { - auto* rootobj = QQmlEngine::contextForObject(this)->engine()->parent(); - auto* root = qobject_cast(rootobj); + auto* generation = EngineGeneration::findObjectGeneration(this); + auto* root = generation == nullptr ? nullptr : generation->wrapper; if (root == nullptr) { qWarning() << "cannot find RootWrapper for reload, ignoring request"; @@ -118,20 +196,68 @@ void QuickshellGlobal::setWatchFiles(bool watchFiles) { // NOLINT QuickshellSettings::instance()->setWatchFiles(watchFiles); } -void QuickshellGlobal::updateScreens() { - auto screens = QGuiApplication::screens(); - this->mScreens.resize(screens.size()); +QString QuickshellGlobal::clipboardText() { + return static_cast(QGuiApplication::instance())->clipboard()->text(); // NOLINT +} - for (auto i = 0; i < screens.size(); i++) { - if (this->mScreens[i] != nullptr) { - this->mScreens[i]->screen = nullptr; - this->mScreens[i]->setParent(nullptr); // delete if not owned by the js engine - } +void QuickshellGlobal::setClipboardText(const QString& text) { + return static_cast(QGuiApplication::instance()) // NOLINT + ->clipboard() + ->setText(text); +} - this->mScreens[i] = new QuickshellScreenInfo(this, screens[i]); - } +void QuickshellGlobal::onClipboardChanged(QClipboard::Mode mode) { + if (mode == QClipboard::Clipboard) emit this->clipboardTextChanged(); +} - emit this->screensChanged(); +QString QuickshellGlobal::shellDir() const { + return EngineGeneration::findObjectGeneration(this)->rootPath.path(); +} + +QString QuickshellGlobal::configDir() const { + qWarning() << "Quickshell.configDir is deprecated and may be removed in a future release. Use " + "Quickshell.shellDir."; + return this->shellDir(); +} + +QString QuickshellGlobal::shellRoot() const { + qWarning() << "Quickshell.shellRoot is deprecated and may be removed in a future release. Use " + "Quickshell.shellDir."; + return this->shellDir(); +} + +QString QuickshellGlobal::dataDir() const { // NOLINT + return QsPaths::instance()->shellDataDir().path(); +} + +QString QuickshellGlobal::stateDir() const { // NOLINT + return QsPaths::instance()->shellStateDir().path(); +} + +QString QuickshellGlobal::cacheDir() const { // NOLINT + return QsPaths::instance()->shellCacheDir().path(); +} + +QString QuickshellGlobal::shellPath(const QString& path) const { + return this->shellDir() % '/' % path; +} + +QString QuickshellGlobal::configPath(const QString& path) const { + qWarning() << "Quickshell.configPath() is deprecated and may be removed in a future release. Use " + "Quickshell.shellPath()."; + return this->shellPath(path); +} + +QString QuickshellGlobal::dataPath(const QString& path) const { + return this->dataDir() % '/' % path; +} + +QString QuickshellGlobal::statePath(const QString& path) const { + return this->stateDir() % '/' % path; +} + +QString QuickshellGlobal::cachePath(const QString& path) const { + return this->cacheDir() % '/' % path; } QVariant QuickshellGlobal::env(const QString& variable) { // NOLINT @@ -140,3 +266,60 @@ QVariant QuickshellGlobal::env(const QString& variable) { // NOLINT return qEnvironmentVariable(vstr.data()); } + +void QuickshellGlobal::execDetached(QList command) { + QuickshellGlobal::execDetached(qs::io::process::ProcessContext(std::move(command))); +} + +void QuickshellGlobal::execDetached(const qs::io::process::ProcessContext& context) { + if (context.command.isEmpty()) { + qWarning() << "Cannot start process as command is empty."; + return; + } + + const auto& cmd = context.command.first(); + auto args = context.command.sliced(1); + + QProcess process; + qs::io::process::setupProcessEnvironment(&process, context.clearEnvironment, context.environment); + + if (!context.workingDirectory.isEmpty()) { + process.setWorkingDirectory(context.workingDirectory); + } + + process.setProgram(cmd); + process.setArguments(args); + + process.setStandardInputFile(QProcess::nullDevice()); + + if (context.unbindStdout) { + process.setStandardOutputFile(QProcess::nullDevice()); + process.setStandardErrorFile(QProcess::nullDevice()); + } + + process.startDetached(); +} + +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 8dceea14..9d88591e 100644 --- a/src/core/qmlglobal.hpp +++ b/src/core/qmlglobal.hpp @@ -1,15 +1,22 @@ #pragma once +#include #include +#include #include +#include #include #include #include #include +#include #include #include #include +#include +#include "../io/processcore.hpp" +#include "doc.hpp" #include "qmlscreen.hpp" ///! Accessor for some options under the Quickshell type. @@ -26,16 +33,26 @@ class QuickshellSettings: public QObject { QML_UNCREATABLE("singleton"); public: + QuickshellSettings(); + [[nodiscard]] QString workingDirectory() const; void setWorkingDirectory(QString workingDirectory); [[nodiscard]] bool watchFiles() const; void setWatchFiles(bool watchFiles); + [[nodiscard]] bool quitOnLastClosed() const; + void setQuitOnLastClosed(bool exitOnLastClosed); + static QuickshellSettings* instance(); static void reset(); signals: + /// Sent when the last window is closed. + /// + /// To make the application exit when the last window is closed run `Qt.quit()`. + void lastWindowClosed(); + void workingDirectoryChanged(); void watchFilesChanged(); @@ -43,6 +60,24 @@ private: bool mWatchFiles = true; }; +class QuickshellTracked: public QObject { + Q_OBJECT; + +public: + QuickshellTracked(); + + QVector screens; + QuickshellScreenInfo* screenInfo(QScreen* screen) const; + + static QuickshellTracked* instance(); + +private slots: + void updateScreens(); + +signals: + void screensChanged(); +}; + class QuickshellGlobal: public QObject { Q_OBJECT; // clang-format off @@ -56,12 +91,12 @@ class QuickshellGlobal: public QObject { /// ```qml /// ShellRoot { /// Variants { - /// ShellWindow { - /// // ... - /// } - /// /// // see Variants for details - /// variants: Quickshell.screens.map(screen => ({ screen })) + /// variants: Quickshell.screens + /// PanelWindow { + /// property var modelData + /// screen: modelData + /// } /// } /// } /// ``` @@ -69,11 +104,42 @@ 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 shellDir READ shellDir CONSTANT); + /// > [!WARNING] Deprecated: Renamed to @@shellDir for clarity. + Q_PROPERTY(QString configDir READ configDir CONSTANT); + /// > [!WARNING] Deprecated: Renamed to @@shellDir for consistency. + 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. /// Defaults to true. Q_PROPERTY(bool watchFiles READ watchFiles WRITE setWatchFiles NOTIFY watchFilesChanged); + /// The system clipboard. + /// + /// > [!WARNING] Under wayland the clipboard will be empty unless a quickshell window is focused. + Q_PROPERTY(QString clipboardText READ clipboardText WRITE setClipboardText NOTIFY clipboardTextChanged); + /// The per-shell data directory. + /// + /// Usually `~/.local/share/quickshell/by-shell/` + /// + /// Can be overridden using `//@ pragma DataDir $BASE/path` in the root qml file, where `$BASE` + /// corrosponds to `$XDG_DATA_HOME` (usually `~/.local/share`). + Q_PROPERTY(QString dataDir READ dataDir CONSTANT); + /// The per-shell state directory. + /// + /// Usually `~/.local/state/quickshell/by-shell/` + /// + /// Can be overridden using `//@ pragma StateDir $BASE/path` in the root qml file, where `$BASE` + /// corrosponds to `$XDG_STATE_HOME` (usually `~/.local/state`). + Q_PROPERTY(QString stateDir READ stateDir CONSTANT); + /// The per-shell cache directory. + /// + /// Usually `~/.cache/quickshell/by-shell/` + Q_PROPERTY(QString cacheDir READ cacheDir CONSTANT); // clang-format on QML_SINGLETON; QML_NAMED_ELEMENT(Quickshell); @@ -81,40 +147,119 @@ 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); + // MUST be before execDetached(ctx) or the other will be called with a default constructed obj. + QSDOC_HIDE Q_INVOKABLE static void execDetached(QList command); + /// Launch a process detached from Quickshell. + /// + /// The context parameter can either be a list of command arguments or a JS object with the following fields: + /// - `command`: A list containing the command and all its arguments. See @@Quickshell.Io.Process.command. + /// - `environment`: Changes to make to the process environment. See @@Quickshell.Io.Process.environment. + /// - `clearEnvironment`: Removes all variables from the environment if true. + /// - `workingDirectory`: The working directory the command should run in. + /// + /// > [!WARNING] This does not run command in a shell. All arguments to the command + /// > must be in separate values in the list, e.g. `["echo", "hello"]` + /// > and not `["echo hello"]`. + /// > + /// > Additionally, shell scripts must be run by your shell, + /// > e.g. `["sh", "script.sh"]` instead of `["script.sh"]` unless the script + /// > has a shebang. + /// + /// > [!INFO] You can use `["sh", "-c", ]` to execute your command with + /// > the system shell. + /// + /// This function is equivalent to @@Quickshell.Io.Process.startDetached(). + Q_INVOKABLE static void execDetached(const qs::io::process::ProcessContext& context); + + /// 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); + /// Equivalent to `${Quickshell.configDir}/${path}` + Q_INVOKABLE [[nodiscard]] QString shellPath(const QString& path) const; + /// > [!WARNING] Deprecated: Renamed to @@shellPath() for clarity. + Q_INVOKABLE [[nodiscard]] QString configPath(const QString& path) const; + /// Equivalent to `${Quickshell.dataDir}/${path}` + Q_INVOKABLE [[nodiscard]] QString dataPath(const QString& path) const; + /// Equivalent to `${Quickshell.stateDir}/${path}` + Q_INVOKABLE [[nodiscard]] QString statePath(const QString& path) const; + /// Equivalent to `${Quickshell.cacheDir}/${path}` + Q_INVOKABLE [[nodiscard]] QString cachePath(const QString& path) const; + /// When called from @@reloadCompleted() or @@reloadFailed(), prevents the + /// default reload popup from displaying. + /// + /// The popup can also be blocked by setting `QS_NO_RELOAD_POPUP=1`. + Q_INVOKABLE void inhibitReloadPopup() { this->mInhibitReloadPopup = true; } + + void clearReloadPopupInhibit() { this->mInhibitReloadPopup = false; } + [[nodiscard]] bool isReloadPopupInhibited() const { return this->mInhibitReloadPopup; } + + [[nodiscard]] QString shellDir() const; + [[nodiscard]] QString configDir() const; + [[nodiscard]] QString shellRoot() const; + [[nodiscard]] QString workingDirectory() const; void setWorkingDirectory(QString workingDirectory); [[nodiscard]] bool watchFiles() const; void setWatchFiles(bool watchFiles); + [[nodiscard]] static QString clipboardText(); + static void setClipboardText(const QString& text); + + [[nodiscard]] QString dataDir() const; + [[nodiscard]] QString stateDir() const; + [[nodiscard]] QString cacheDir() const; + + 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(); + void clipboardTextChanged(); -public slots: - void updateScreens(); +private slots: + void onClipboardChanged(QClipboard::Mode mode); private: + QuickshellGlobal(QObject* parent = nullptr); + + bool mInhibitReloadPopup = false; + static qsizetype screensCount(QQmlListProperty* prop); static QuickshellScreenInfo* screenAt(QQmlListProperty* prop, qsizetype i); - - QVector mScreens; }; diff --git a/src/core/qmlscreen.cpp b/src/core/qmlscreen.cpp index 05e01185..105b4f01 100644 --- a/src/core/qmlscreen.cpp +++ b/src/core/qmlscreen.cpp @@ -42,6 +42,42 @@ QString QuickshellScreenInfo::name() const { return this->screen->name(); } +QString QuickshellScreenInfo::model() const { + if (this->screen == nullptr) { + this->warnDangling(); + return "{ NULL SCREEN }"; + } + + return this->screen->model(); +} + +QString QuickshellScreenInfo::serialNumber() const { + if (this->screen == nullptr) { + this->warnDangling(); + return "{ NULL SCREEN }"; + } + + return this->screen->serialNumber(); +} + +qint32 QuickshellScreenInfo::x() const { + if (this->screen == nullptr) { + this->warnDangling(); + return 0; + } + + return this->screen->geometry().x(); +} + +qint32 QuickshellScreenInfo::y() const { + if (this->screen == nullptr) { + this->warnDangling(); + return 0; + } + + return this->screen->geometry().y(); +} + qint32 QuickshellScreenInfo::width() const { if (this->screen == nullptr) { this->warnDangling(); @@ -109,3 +145,21 @@ void QuickshellScreenInfo::screenDestroyed() { this->screen = nullptr; this->dangling = true; } + +QDebug operator<<(QDebug debug, const QuickshellScreenInfo* screen) { + if (screen == nullptr) { + debug.nospace() << "QuickshellScreenInfo(nullptr)"; + return debug; + } + + debug.nospace() << screen->metaObject()->className() << '(' << static_cast(screen) + << ", screen=" << screen->screen << ')'; + + return debug; +} + +QString QuickshellScreenInfo::toString() const { + QString str; + QDebug(&str) << this; + return str; +} diff --git a/src/core/qmlscreen.hpp b/src/core/qmlscreen.hpp index e499dfae..5e978bc0 100644 --- a/src/core/qmlscreen.hpp +++ b/src/core/qmlscreen.hpp @@ -1,25 +1,25 @@ #pragma once +#include #include #include +#include #include #include +#include #include #include // 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); @@ -29,6 +29,12 @@ class QuickshellScreenInfo: public QObject { /// /// Usually something like `DP-1`, `HDMI-1`, `eDP-1`. Q_PROPERTY(QString name READ name CONSTANT); + /// The model of the screen as seen by the operating system. + Q_PROPERTY(QString model READ model CONSTANT); + /// The serial number of the screen as seen by the operating system. + Q_PROPERTY(QString serialNumber READ serialNumber CONSTANT); + Q_PROPERTY(qint32 x READ x NOTIFY geometryChanged); + Q_PROPERTY(qint32 y READ y NOTIFY geometryChanged); Q_PROPERTY(qint32 width READ width NOTIFY geometryChanged); Q_PROPERTY(qint32 height READ height NOTIFY geometryChanged); /// The number of physical pixels per millimeter. @@ -38,7 +44,7 @@ class QuickshellScreenInfo: public QObject { /// The ratio between physical pixels and device-independent (scaled) pixels. Q_PROPERTY(qreal devicePixelRatio READ devicePixelRatio NOTIFY physicalPixelDensityChanged); Q_PROPERTY(Qt::ScreenOrientation orientation READ orientation NOTIFY orientationChanged); - Q_PROPERTY(Qt::ScreenOrientation primatyOrientation READ primaryOrientation NOTIFY primaryOrientationChanged); + Q_PROPERTY(Qt::ScreenOrientation primaryOrientation READ primaryOrientation NOTIFY primaryOrientationChanged); // clang-format on public: @@ -47,6 +53,10 @@ public: bool operator==(QuickshellScreenInfo& other) const; [[nodiscard]] QString name() const; + [[nodiscard]] QString model() const; + [[nodiscard]] QString serialNumber() const; + [[nodiscard]] qint32 x() const; + [[nodiscard]] qint32 y() const; [[nodiscard]] qint32 width() const; [[nodiscard]] qint32 height() const; [[nodiscard]] qreal physicalPixelDensity() const; @@ -55,6 +65,8 @@ public: [[nodiscard]] Qt::ScreenOrientation orientation() const; [[nodiscard]] Qt::ScreenOrientation primaryOrientation() const; + [[nodiscard]] Q_INVOKABLE QString toString() const; + QScreen* screen; private: @@ -71,3 +83,5 @@ signals: private slots: void screenDestroyed(); }; + +QDebug operator<<(QDebug debug, const QuickshellScreenInfo* screen); diff --git a/src/core/qsintercept.cpp b/src/core/qsintercept.cpp new file mode 100644 index 00000000..6687681b --- /dev/null +++ b/src/core/qsintercept.cpp @@ -0,0 +1,131 @@ +#include "qsintercept.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "logcat.hpp" + +QS_LOGGING_CATEGORY(logQsIntercept, "quickshell.interceptor", QtWarningMsg); + +QUrl QsUrlInterceptor::intercept( + const QUrl& originalUrl, + QQmlAbstractUrlInterceptor::DataType type +) { + auto url = originalUrl; + + if (url.scheme() == "root") { + url.setScheme("qs"); + + auto path = url.path(); + if (path.startsWith('/')) path = path.sliced(1); + url.setPath("@/qs/" % path); + + qCDebug(logQsIntercept) << "Rewrote root intercept" << originalUrl << "to" << url; + } + + if (url.scheme() == "qs") { + auto path = url.path(); + + // Our import path is on "qs:@/". + // We want to blackhole any import resolution outside of the config folder as it breaks Qt + // but NOT file lookups that might be on "qs:/" due to a missing "file:/" prefix. + if (path.startsWith("@/qs/")) { + path = this->configRoot.filePath(path.sliced(5)); + } else if (!path.startsWith("/")) { + qCDebug(logQsIntercept) << "Blackholed import URL" << url; + return QUrl("qrc:/qs-blackhole"); + } + + // Some types such as Image take into account where they are loading from, and force + // asynchronous loading over a network. qs: is considered to be over a network. + // In those cases we want to return a file:// url so asynchronous loading is not forced. + if (type == QQmlAbstractUrlInterceptor::DataType::UrlString) { + // Qt.resolvedUrl and context->resolvedUrl can use this on qml files, in which + // case we want to keep the intercept, otherwise objects created from those paths + // will not be able to use singletons. + if (path.endsWith(".qml")) return url; + + auto newUrl = url; + newUrl.setScheme("file"); + // above check asserts path starts with /qs/ + newUrl.setPath(path); + qCDebug(logQsIntercept) << "Rewrote intercept" << url << "to" << newUrl; + return newUrl; + } + } + + return url; +} + +QsInterceptDataReply::QsInterceptDataReply(const QString& data, QObject* parent) + : QNetworkReply(parent) + , content(data.toUtf8()) { + this->setOpenMode(QIODevice::ReadOnly); + this->setFinished(true); +} + +qint64 QsInterceptDataReply::readData(char* data, qint64 maxSize) { + auto size = qMin(maxSize, this->content.length() - this->offset); + if (size == 0) return -1; + memcpy(data, this->content.constData() + this->offset, size); // NOLINT + this->offset += size; + return size; +} + +QsInterceptNetworkAccessManager::QsInterceptNetworkAccessManager( + const QDir& configRoot, + const QHash& fileIntercepts, + QObject* parent +) + : QNetworkAccessManager(parent) + , configRoot(configRoot) + , fileIntercepts(fileIntercepts) {} + +QNetworkReply* QsInterceptNetworkAccessManager::createRequest( + QNetworkAccessManager::Operation op, + const QNetworkRequest& req, + QIODevice* outgoingData +) { + auto url = req.url(); + + if (url.scheme() == "qs") { + auto path = url.path(); + + if (path.startsWith("@/qs/")) path = this->configRoot.filePath(path.sliced(5)); + // otherwise pass through to fs + + qCDebug(logQsIntercept) << "Got intercept for" << path << "contains" + << this->fileIntercepts.value(path); + + if (auto data = this->fileIntercepts.value(path); !data.isEmpty()) { + return new QsInterceptDataReply(data, this); + } + + auto fileReq = req; + auto fileUrl = req.url(); + fileUrl.setScheme("file"); + fileUrl.setPath(path); + qCDebug(logQsIntercept) << "Passing through intercept" << url << "to" << fileUrl; + + fileReq.setUrl(fileUrl); + return this->QNetworkAccessManager::createRequest(op, fileReq, outgoingData); + } + + return this->QNetworkAccessManager::createRequest(op, req, outgoingData); +} + +QNetworkAccessManager* QsInterceptNetworkAccessManagerFactory::create(QObject* parent) { + return new QsInterceptNetworkAccessManager(this->configRoot, this->fileIntercepts, parent); +} diff --git a/src/core/qsintercept.hpp b/src/core/qsintercept.hpp new file mode 100644 index 00000000..c3d8b552 --- /dev/null +++ b/src/core/qsintercept.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "logcat.hpp" + +QS_DECLARE_LOGGING_CATEGORY(logQsIntercept); + +class QsUrlInterceptor: public QQmlAbstractUrlInterceptor { +public: + explicit QsUrlInterceptor(const QDir& configRoot): configRoot(configRoot) {} + + QUrl intercept(const QUrl& originalUrl, QQmlAbstractUrlInterceptor::DataType type) override; + +private: + QDir configRoot; +}; + +class QsInterceptDataReply: public QNetworkReply { + Q_OBJECT; + +public: + QsInterceptDataReply(const QString& data, QObject* parent = nullptr); + + qint64 readData(char* data, qint64 maxSize) override; + +private slots: + void abort() override {} + +private: + qint64 offset = 0; + QByteArray content; +}; + +class QsInterceptNetworkAccessManager: public QNetworkAccessManager { + Q_OBJECT; + +public: + QsInterceptNetworkAccessManager( + const QDir& configRoot, + const QHash& fileIntercepts, + QObject* parent = nullptr + ); + +protected: + QNetworkReply* createRequest( + QNetworkAccessManager::Operation op, + const QNetworkRequest& req, + QIODevice* outgoingData = nullptr + ) override; + +private: + QDir configRoot; + const QHash& fileIntercepts; +}; + +class QsInterceptNetworkAccessManagerFactory: public QQmlNetworkAccessManagerFactory { +public: + QsInterceptNetworkAccessManagerFactory( + const QDir& configRoot, + const QHash& fileIntercepts + ) + : configRoot(configRoot) + , fileIntercepts(fileIntercepts) {} + QNetworkAccessManager* create(QObject* parent) override; + +private: + QDir configRoot; + const QHash& fileIntercepts; +}; 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..90df8b9a --- /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.cpp b/src/core/region.cpp index 9826dbd5..11892d6d 100644 --- a/src/core/region.cpp +++ b/src/core/region.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include PendingRegion::PendingRegion(QObject* parent): QObject(parent) { QObject::connect(this, &PendingRegion::shapeChanged, this, &PendingRegion::changed); @@ -20,30 +22,39 @@ PendingRegion::PendingRegion(QObject* parent): QObject(parent) { } void PendingRegion::setItem(QQuickItem* item) { + if (item == this->mItem) return; + if (this->mItem != nullptr) { QObject::disconnect(this->mItem, nullptr, this, nullptr); } this->mItem = item; - QObject::connect(this->mItem, &QQuickItem::xChanged, this, &PendingRegion::itemChanged); - QObject::connect(this->mItem, &QQuickItem::yChanged, this, &PendingRegion::itemChanged); - QObject::connect(this->mItem, &QQuickItem::widthChanged, this, &PendingRegion::itemChanged); - QObject::connect(this->mItem, &QQuickItem::heightChanged, this, &PendingRegion::itemChanged); + if (item != nullptr) { + QObject::connect(this->mItem, &QObject::destroyed, this, &PendingRegion::onItemDestroyed); + QObject::connect(this->mItem, &QQuickItem::xChanged, this, &PendingRegion::itemChanged); + QObject::connect(this->mItem, &QQuickItem::yChanged, this, &PendingRegion::itemChanged); + QObject::connect(this->mItem, &QQuickItem::widthChanged, this, &PendingRegion::itemChanged); + QObject::connect(this->mItem, &QQuickItem::heightChanged, this, &PendingRegion::itemChanged); + } + + emit this->itemChanged(); } void PendingRegion::onItemDestroyed() { this->mItem = nullptr; } +void PendingRegion::onChildDestroyed() { this->mRegions.removeAll(this->sender()); } + QQmlListProperty PendingRegion::regions() { return QQmlListProperty( this, nullptr, - PendingRegion::regionsAppend, - nullptr, - nullptr, - nullptr, - nullptr, - nullptr + &PendingRegion::regionsAppend, + &PendingRegion::regionsCount, + &PendingRegion::regionAt, + &PendingRegion::regionsClear, + &PendingRegion::regionsReplace, + &PendingRegion::regionsRemoveLast ); } @@ -97,11 +108,67 @@ QRegion PendingRegion::applyTo(QRegion& region) const { return region; } +QRegion PendingRegion::applyTo(const QRect& rect) const { + // if left as the default, dont combine it with the whole rect area, leave it as is. + if (this->mIntersection == Intersection::Combine) { + return this->build(); + } else { + auto baseRegion = QRegion(rect); + return this->applyTo(baseRegion); + } +} + void PendingRegion::regionsAppend(QQmlListProperty* prop, PendingRegion* region) { auto* self = static_cast(prop->object); // NOLINT - region->setParent(self); + if (!region) return; + + QObject::connect(region, &QObject::destroyed, self, &PendingRegion::onChildDestroyed); + QObject::connect(region, &PendingRegion::changed, self, &PendingRegion::childrenChanged); + self->mRegions.append(region); - QObject::connect(region, &PendingRegion::changed, self, &PendingRegion::childrenChanged); + emit self->childrenChanged(); +} + +PendingRegion* PendingRegion::regionAt(QQmlListProperty* prop, qsizetype i) { + return static_cast(prop->object)->mRegions.at(i); // NOLINT +} + +void PendingRegion::regionsClear(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); // NOLINT + + for (auto* region: self->mRegions) { + QObject::disconnect(region, nullptr, self, nullptr); + } + + self->mRegions.clear(); // NOLINT + emit self->childrenChanged(); +} + +qsizetype PendingRegion::regionsCount(QQmlListProperty* prop) { + return static_cast(prop->object)->mRegions.length(); // NOLINT +} + +void PendingRegion::regionsRemoveLast(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); // NOLINT + + auto* last = self->mRegions.last(); + if (last != nullptr) QObject::disconnect(last, nullptr, self, nullptr); + + self->mRegions.removeLast(); + emit self->childrenChanged(); +} + +void PendingRegion::regionsReplace( + QQmlListProperty* prop, + qsizetype i, + PendingRegion* region +) { + auto* self = static_cast(prop->object); // NOLINT + + auto* old = self->mRegions.at(i); + if (old != nullptr) QObject::disconnect(old, nullptr, self, nullptr); + + self->mRegions.replace(i, region); emit self->childrenChanged(); } diff --git a/src/core/region.hpp b/src/core/region.hpp index 06654ca9..6637d7bd 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. @@ -93,6 +96,7 @@ public: [[nodiscard]] bool empty() const; [[nodiscard]] QRegion build() const; [[nodiscard]] QRegion applyTo(QRegion& region) const; + [[nodiscard]] QRegion applyTo(const QRect& rect) const; RegionShape::Enum mShape = RegionShape::Rect; Intersection::Enum mIntersection = Intersection::Combine; @@ -106,13 +110,25 @@ signals: void widthChanged(); void heightChanged(); void childrenChanged(); + + /// Triggered when the region's geometry changes. + /// + /// In some cases the region does not update automatically. + /// In those cases you can emit this signal manually. void changed(); private slots: void onItemDestroyed(); + void onChildDestroyed(); private: static void regionsAppend(QQmlListProperty* prop, PendingRegion* region); + static PendingRegion* regionAt(QQmlListProperty* prop, qsizetype i); + static void regionsClear(QQmlListProperty* prop); + static qsizetype regionsCount(QQmlListProperty* prop); + static void regionsRemoveLast(QQmlListProperty* prop); + static void + regionsReplace(QQmlListProperty* prop, qsizetype i, PendingRegion* region); QQuickItem* mItem = nullptr; diff --git a/src/core/reload.cpp b/src/core/reload.cpp index 8768dc74..ea2abbf6 100644 --- a/src/core/reload.cpp +++ b/src/core/reload.cpp @@ -3,6 +3,56 @@ #include #include #include +#include + +#include "generation.hpp" + +void Reloadable::componentComplete() { + this->engineGeneration = EngineGeneration::findObjectGeneration(this); + + 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) { + // 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, + this, + &Reloadable::onReloadFinished + ); + } + } +} + +void Reloadable::reload(QObject* oldInstance) { + if (this->reloadComplete) return; + this->onReload(oldInstance); + this->reloadComplete = true; + + if (this->engineGeneration != nullptr) { + QObject::disconnect( + this->engineGeneration, + &EngineGeneration::reloadFinished, + this, + &Reloadable::onReloadFinished + ); + } +} + +void Reloadable::onReloadFinished() { this->reload(nullptr); } +void Reloadable::onGenerationDestroyed() { this->engineGeneration = nullptr; } void ReloadPropagator::onReload(QObject* oldInstance) { auto* old = qobject_cast(oldInstance); @@ -13,7 +63,7 @@ void ReloadPropagator::onReload(QObject* oldInstance) { auto* oldChild = old == nullptr || old->mChildren.length() <= i ? nullptr : qobject_cast(old->mChildren.at(i)); - newChild->onReload(oldChild); + newChild->reload(oldChild); } else { Reloadable::reloadRecursive(newChild, oldInstance); } @@ -49,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); } @@ -76,12 +126,21 @@ QObject* Reloadable::getChildByReloadId(QObject* parent, const QString& reloadId return nullptr; } -void PostReloadHook::postReloadTree(QObject* root) { - for (auto* child: root->children()) { - PostReloadHook::postReloadTree(child); - } - - if (auto* self = dynamic_cast(root)) { - self->onPostReload(); +void PostReloadHook::componentComplete() { + auto* engineGeneration = EngineGeneration::findObjectGeneration(this); + if (!engineGeneration || engineGeneration->reloadComplete) this->postReload(); + else { + // disconnected by EngineGeneration::postReload + QObject::connect( + engineGeneration, + &EngineGeneration::firePostReload, + this, + &PostReloadHook::postReload + ); } } + +void PostReloadHook::postReload() { + this->isPostReload = true; + this->onPostReload(); +} diff --git a/src/core/reload.hpp b/src/core/reload.hpp index 2ae459a3..ae5d7c92 100644 --- a/src/core/reload.hpp +++ b/src/core/reload.hpp @@ -7,12 +7,11 @@ #include #include +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 { @@ -26,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 @@ -56,14 +55,10 @@ class Reloadable public: explicit Reloadable(QObject* parent = nullptr): QObject(parent) {} - // Called unconditionally in the reload phase, with nullptr if no source could be determined. - // If non null the old instance may or may not be of the same type, and should be checked - // by `onReload`. - virtual void onReload(QObject* oldInstance) = 0; + void reload(QObject* oldInstance = nullptr); - // TODO: onReload runs after initialization for reloadable objects created late void classBegin() override {} - void componentComplete() override {} + void componentComplete() override; // Reload objects in the parent->child graph recursively. static void reloadRecursive(QObject* newObj, QObject* oldRoot); @@ -71,16 +66,27 @@ public: static void reloadChildrenRecursive(QObject* newRoot, QObject* oldRoot); QString mReloadableId; + bool reloadComplete = false; + EngineGeneration* engineGeneration = nullptr; + +private slots: + void onReloadFinished(); + void onGenerationDestroyed(); + +protected: + // Called unconditionally in the reload phase, with nullptr if no source could be determined. + // If non null the old instance may or may not be of the same type, and should be checked + // by `onReload`. + virtual void onReload(QObject* oldInstance) = 0; private: static QObject* getChildByReloadId(QObject* parent, const QString& reloadId); }; ///! 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 { @@ -113,16 +119,23 @@ private: }; /// Hook that runs after the old widget tree is dropped during a reload. -class PostReloadHook { +class PostReloadHook + : public QObject + , public QQmlParserStatus { + Q_OBJECT; + QML_ANONYMOUS; + Q_INTERFACES(QQmlParserStatus); + public: - PostReloadHook() = default; - virtual ~PostReloadHook() = default; - PostReloadHook(PostReloadHook&&) = default; - PostReloadHook(const PostReloadHook&) = default; - PostReloadHook& operator=(PostReloadHook&&) = default; - PostReloadHook& operator=(const PostReloadHook&) = default; + PostReloadHook(QObject* parent = nullptr): QObject(parent) {} + void classBegin() override {} + void componentComplete() override; virtual void onPostReload() = 0; - static void postReloadTree(QObject* root); +public slots: + void postReload(); + +protected: + bool isPostReload = false; }; 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 16941f5a..25c46ccd 100644 --- a/src/core/rootwrapper.cpp +++ b/src/core/rootwrapper.cpp @@ -2,114 +2,190 @@ #include #include -#include #include #include +#include #include #include #include #include -#include +#include +#include #include -#include "plugin.hpp" +#include "../ui/reload_popup.hpp" +#include "../window/floatingwindow.hpp" +#include "generation.hpp" +#include "instanceinfo.hpp" #include "qmlglobal.hpp" -#include "reload.hpp" -#include "shell.hpp" -#include "watcher.hpp" +#include "scan.hpp" +#include "toolsupport.hpp" -RootWrapper::RootWrapper(QString rootPath) +RootWrapper::RootWrapper(QString rootPath, QString shellId) : QObject(nullptr) , rootPath(std::move(rootPath)) - , engine(this) + , shellId(std::move(shellId)) , originalWorkingDirectory(QDir::current().absolutePath()) { - auto* app = QCoreApplication::instance(); - QObject::connect(&this->engine, &QQmlEngine::quit, app, &QCoreApplication::quit); - QObject::connect(&this->engine, &QQmlEngine::exit, app, &QCoreApplication::exit); + QObject::connect( + QuickshellSettings::instance(), + &QuickshellSettings::watchFilesChanged, + this, + &RootWrapper::onWatchFilesChanged + ); - // clang-format off - QObject::connect(QuickshellSettings::instance(), &QuickshellSettings::watchFilesChanged, this, &RootWrapper::onWatchFilesChanged); - // clang-format on + QObject::connect( + &this->configDirWatcher, + &QFileSystemWatcher::directoryChanged, + this, + &RootWrapper::updateTooling + ); this->reloadGraph(true); - if (this->root == nullptr) { - qCritical() << "could not create scene graph, exiting"; + if (this->generation == nullptr) { exit(-1); // NOLINT } } RootWrapper::~RootWrapper() { // event loop may no longer be running so deleteLater is not an option - delete this->root; + if (this->generation != nullptr) { + this->generation->shutdown(); + } } void RootWrapper::reloadGraph(bool hard) { - if (this->root != nullptr) { + auto rootFile = QFileInfo(this->rootPath); + auto rootPath = rootFile.dir(); + auto scanner = QmlScanner(rootPath); + scanner.scanQmlRoot(this->rootPath); + + qs::core::QmlToolingSupport::updateTooling(rootPath, scanner); + this->configDirWatcher.addPath(rootPath.path()); + + auto* generation = new EngineGeneration(rootPath, std::move(scanner)); + generation->wrapper = this; + + // todo: move into EngineGeneration + if (this->generation != nullptr) { + qInfo() << "Reloading configuration..."; QuickshellSettings::reset(); - this->engine.clearComponentCache(); } QDir::setCurrent(this->originalWorkingDirectory); - auto component = QQmlComponent(&this->engine, QUrl::fromLocalFile(this->rootPath)); + QUrl url; + url.setScheme("qs"); + url.setPath("@/qs/" % rootFile.fileName()); + auto component = QQmlComponent(generation->engine, url); - auto* obj = component.beginCreate(this->engine.rootContext()); + if (!component.isReady()) { + qCritical() << "Failed to load configuration"; + QString errorString = "Failed to load configuration"; + + auto errors = component.errors(); + for (auto& error: errors) { + const auto& url = error.url(); + auto rel = url.scheme() == "qs" && url.path().startsWith("@/qs/") ? "@" % url.path().sliced(5) + : url.toString(); + auto msg = " caused by " % rel % '[' % QString::number(error.line()) % ':' + % QString::number(error.column()) % "]: " % error.description(); + errorString += '\n' % msg; + qCritical().noquote() << msg; + } + + auto newFiles = generation->scanner.scannedFiles; + generation->destroy(); + + if (this->generation != nullptr) { + if (this->generation->setExtraWatchedFiles(newFiles)) { + qInfo() << "Watching additional files picked up in reload for changes..."; + } + + auto showPopup = true; + if (this->generation->qsgInstance != nullptr) { + this->generation->qsgInstance->clearReloadPopupInhibit(); + emit this->generation->qsgInstance->reloadFailed(errorString); + showPopup = !this->generation->qsgInstance->isReloadPopupInhibited(); + } + + if (showPopup) + qs::ui::ReloadPopup::spawnPopup(InstanceInfo::CURRENT.instanceId, true, errorString); + } + + if (this->generation != nullptr && this->generation->qsgInstance != nullptr) { + emit this->generation->qsgInstance->reloadFailed(errorString); + } - if (obj == nullptr) { - qWarning() << component.errorString().toStdString().c_str(); - qWarning() << "failed to create root component"; return; } - auto* newRoot = qobject_cast(obj); - if (newRoot == nullptr) { - qWarning() << "root component was not a Quickshell.ShellRoot"; - delete obj; - return; + auto* newRoot = component.beginCreate(generation->engine->rootContext()); + + 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(); - auto* oldRoot = this->root; - this->root = newRoot; - - this->root->onReload(hard ? nullptr : oldRoot); - - if (oldRoot != nullptr) { - oldRoot->deleteLater(); - - QTimer::singleShot(0, [this, newRoot]() { - if (this->root == newRoot) { - QuickshellPlugin::runOnReload(); - PostReloadHook::postReloadTree(this->root); - } - }); - } else { - PostReloadHook::postReloadTree(newRoot); - QuickshellPlugin::runOnReload(); + if (this->generation) { + QObject::disconnect(this->generation, nullptr, this, nullptr); } + auto isReload = this->generation != nullptr; + generation->onReload(hard ? nullptr : 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, + this, + &RootWrapper::onWatchedFilesChanged + ); + this->onWatchFilesChanged(); + + if (isReload) { + auto showPopup = true; + + if (this->generation->qsgInstance != nullptr) { + this->generation->qsgInstance->clearReloadPopupInhibit(); + emit this->generation->qsgInstance->reloadCompleted(); + showPopup = !this->generation->qsgInstance->isReloadPopupInhibited(); + } + + if (showPopup) qs::ui::ReloadPopup::spawnPopup(InstanceInfo::CURRENT.instanceId, false, ""); + } } +void RootWrapper::generationDestroyed() { this->generation = nullptr; } + void RootWrapper::onWatchFilesChanged() { auto watchFiles = QuickshellSettings::instance()->watchFiles(); - - if (watchFiles && this->configWatcher == nullptr) { - this->configWatcher = new FiletreeWatcher(); - this->configWatcher->addPath(QFileInfo(this->rootPath).dir().path()); - - QObject::connect( - this->configWatcher, - &FiletreeWatcher::fileChanged, - this, - &RootWrapper::onWatchedFilesChanged - ); - } else if (!watchFiles && this->configWatcher != nullptr) { - this->configWatcher->deleteLater(); - this->configWatcher = nullptr; + if (this->generation != nullptr) { + this->generation->setWatchingFiles(watchFiles); } } void RootWrapper::onWatchedFilesChanged() { this->reloadGraph(false); } + +void RootWrapper::updateTooling() { + if (!this->generation) return; + auto configDir = QFileInfo(this->rootPath).dir(); + qs::core::QmlToolingSupport::updateTooling(configDir, this->generation->scanner); +} diff --git a/src/core/rootwrapper.hpp b/src/core/rootwrapper.hpp index 6174c7b1..1425d177 100644 --- a/src/core/rootwrapper.hpp +++ b/src/core/rootwrapper.hpp @@ -1,32 +1,34 @@ #pragma once +#include #include #include #include #include #include -#include "shell.hpp" -#include "watcher.hpp" +#include "generation.hpp" 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(); + void updateTooling(); private: QString rootPath; - QQmlEngine engine; - ShellRoot* root = nullptr; - FiletreeWatcher* configWatcher = nullptr; + QString shellId; + EngineGeneration* generation = nullptr; QString originalWorkingDirectory; + QFileSystemWatcher configDirWatcher; }; diff --git a/src/core/scan.cpp b/src/core/scan.cpp new file mode 100644 index 00000000..4306de73 --- /dev/null +++ b/src/core/scan.cpp @@ -0,0 +1,287 @@ +#include "scan.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "logcat.hpp" + +QS_LOGGING_CATEGORY(logQmlScanner, "quickshell.qmlscanner", QtWarningMsg); + +void QmlScanner::scanDir(const QString& path) { + if (this->scannedDirs.contains(path)) return; + this->scannedDirs.push_back(path); + + qCDebug(logQmlScanner) << "Scanning directory" << path; + auto dir = QDir(path); + + struct Entry { + QString name; + bool singleton = false; + bool internal = false; + }; + + bool seenQmldir = false; + auto entries = QVector(); + + for (auto& name: dir.entryList(QDir::Files | QDir::NoDotAndDotDot)) { + if (name == "qmldir") { + qCDebug(logQmlScanner + ) << "Found qmldir file, qmldir synthesization will be disabled for directory" + << path; + seenQmldir = true; + } else if (name.at(0).isUpper() && name.endsWith(".qml")) { + auto& entry = entries.emplaceBack(); + + if (this->scanQmlFile(dir.filePath(name), entry.singleton, entry.internal)) { + entry.name = name; + } else { + entries.pop_back(); + } + } else if (name.at(0).isUpper() && name.endsWith(".qml.json")) { + if (this->scanQmlJson(dir.filePath(name))) { + entries.push_back({ + .name = name.first(name.length() - 5), + .singleton = true, + }); + } + } + } + + if (!seenQmldir) { + qCDebug(logQmlScanner) << "Synthesizing qmldir for directory" << path; + + QString qmldir; + auto stream = QTextStream(&qmldir); + + // cant derive a module name if not in shell path + if (path.startsWith(this->rootPath.path())) { + auto end = path.sliced(this->rootPath.path().length()); + + // verify we have a valid module name. + for (auto& c: end) { + if (c == '/') c = '.'; + else if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '_') + { + } else { + qCWarning(logQmlScanner) << "Module path contains invalid characters for a module name: " + << path.sliced(this->rootPath.path().length()); + goto skipadd; + } + } + + stream << "module qs" << end << '\n'; + skipadd:; + } else { + qCWarning(logQmlScanner) << "Module path" << path << "is outside of the config folder."; + } + + for (const auto& entry: entries) { + if (entry.internal) stream << "internal "; + if (entry.singleton) stream << "singleton "; + stream << entry.name.sliced(0, entry.name.length() - 4) << " 1.0 " << entry.name << '\n'; + } + + qCDebug(logQmlScanner) << "Synthesized qmldir for" << path << qPrintable("\n" + qmldir); + this->fileIntercepts.insert(QDir(path).filePath("qmldir"), qmldir); + } +} + +bool QmlScanner::scanQmlFile(const QString& path, bool& singleton, bool& internal) { + if (this->scannedFiles.contains(path)) return false; + this->scannedFiles.push_back(path); + + qCDebug(logQmlScanner) << "Scanning qml file" << path; + + auto file = QFile(path); + if (!file.open(QFile::ReadOnly | QFile::Text)) { + qCWarning(logQmlScanner) << "Failed to open file" << path; + return false; + } + + auto stream = QTextStream(&file); + auto imports = QVector(); + + while (!stream.atEnd()) { + auto line = stream.readLine().trimmed(); + if (!singleton && line == "pragma Singleton") { + singleton = true; + } else if (!internal && line == "//@ pragma Internal") { + internal = true; + } else if (line.startsWith("import")) { + // we dont care about "import qs" as we always load the root folder + if (auto importCursor = line.indexOf(" qs."); importCursor != -1) { + importCursor += 4; + QString path; + + while (importCursor != line.length()) { + auto c = line.at(importCursor); + if (c == '.') c = '/'; + else if (c == ' ') break; + else if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '_') + { + } else { + qCWarning(logQmlScanner) << "Import line contains invalid characters: " << line; + goto next; + } + + path.append(c); + importCursor += 1; + } + + imports.append(this->rootPath.filePath(path)); + } else if (auto startQuot = line.indexOf('"'); + startQuot != -1 && line.length() >= startQuot + 3) + { + auto endQuot = line.indexOf('"', startQuot + 1); + if (endQuot == -1) continue; + + auto name = line.sliced(startQuot + 1, endQuot - startQuot - 1); + imports.push_back(name); + } + } else if (line.contains('{')) break; + + next:; + } + + file.close(); + + if (logQmlScanner().isDebugEnabled() && !imports.isEmpty()) { + qCDebug(logQmlScanner) << "Found imports" << imports; + } + + auto currentdir = QDir(QFileInfo(path).canonicalPath()); + + // the root can never be a singleton so it dosent matter if we skip it + this->scanDir(currentdir.path()); + + for (auto& import: imports) { + 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 pathInfo = QFileInfo(ipath); + auto cpath = pathInfo.canonicalFilePath(); + + if (cpath.isEmpty()) { + qCWarning(logQmlScanner) << "Ignoring unresolvable import" << ipath << "from" << path; + continue; + } + + if (!pathInfo.isDir()) { + qCDebug(logQmlScanner) << "Ignoring non-directory import" << ipath << "from" << path; + continue; + } + + if (import.endsWith(".js")) this->scannedFiles.push_back(cpath); + else this->scanDir(cpath); + } + + return true; +} + +void QmlScanner::scanQmlRoot(const QString& path) { + bool singleton = false; + bool internal = false; + this->scanQmlFile(path, singleton, internal); +} + +bool QmlScanner::scanQmlJson(const QString& path) { + qCDebug(logQmlScanner) << "Scanning qml.json file" << path; + + auto file = QFile(path); + if (!file.open(QFile::ReadOnly | QFile::Text)) { + qCWarning(logQmlScanner) << "Failed to open file" << path; + return false; + } + + auto data = file.readAll(); + + // Importing this makes CI builds fail for some reason. + QJsonParseError error; // NOLINT (misc-include-cleaner) + auto json = QJsonDocument::fromJson(data, &error); + + if (error.error != QJsonParseError::NoError) { + qCCritical(logQmlScanner).nospace() + << "Failed to parse qml.json file at " << path << ": " << error.errorString(); + return false; + } + + const QString body = + "pragma Singleton\nimport QtQuick as Q\n\n" % QmlScanner::jsonToQml(json.object()).second; + + qCDebug(logQmlScanner) << "Synthesized qml file for" << path << qPrintable("\n" + body); + + this->fileIntercepts.insert(path.first(path.length() - 5), body); + this->scannedFiles.push_back(path); + return true; +} + +QPair QmlScanner::jsonToQml(const QJsonValue& value, int indent) { + if (value.isObject()) { + const auto& object = value.toObject(); + + auto valIter = object.constBegin(); + + QString accum = "Q.QtObject {\n"; + for (const auto& key: object.keys()) { + const auto& val = *valIter++; + auto [type, repr] = QmlScanner::jsonToQml(val, indent + 2); + accum += QString(' ').repeated(indent + 2) % "readonly property " % type % ' ' % key % ": " + % repr % ";\n"; + } + + accum += QString(' ').repeated(indent) % '}'; + return qMakePair(QStringLiteral("Q.QtObject"), accum); + } else if (value.isArray()) { + return qMakePair( + QStringLiteral("var"), + QJsonDocument(value.toArray()).toJson(QJsonDocument::Compact) + ); + } else if (value.isString()) { + const auto& str = value.toString(); + + if (str.startsWith('#') && (str.length() == 4 || str.length() == 7 || str.length() == 9)) { + for (auto c: str.sliced(1)) { + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + goto noncolor; + } + } + + return qMakePair(QStringLiteral("Q.color"), '"' % str % '"'); + } + + noncolor: + return qMakePair(QStringLiteral("string"), '"' % QString(str).replace("\"", "\\\"") % '"'); + } else if (value.isDouble()) { + auto num = value.toDouble(); + double whole = 0; + if (std::modf(num, &whole) == 0.0) { + return qMakePair(QStringLiteral("int"), QString::number(static_cast(whole))); + } else { + return qMakePair(QStringLiteral("real"), QString::number(num)); + } + } else if (value.isBool()) { + return qMakePair(QStringLiteral("bool"), value.toBool() ? "true" : "false"); + } else { + return qMakePair(QStringLiteral("var"), "null"); + } +} diff --git a/src/core/scan.hpp b/src/core/scan.hpp new file mode 100644 index 00000000..1d3be850 --- /dev/null +++ b/src/core/scan.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "logcat.hpp" + +QS_DECLARE_LOGGING_CATEGORY(logQmlScanner); + +// expects canonical paths +class QmlScanner { +public: + QmlScanner() = default; + QmlScanner(const QDir& rootPath): rootPath(rootPath) {} + + // path must be canonical + void scanDir(const QString& path); + + void scanQmlRoot(const QString& path); + + QVector scannedDirs; + QVector scannedFiles; + QHash fileIntercepts; + +private: + QDir rootPath; + + bool scanQmlFile(const QString& path, bool& singleton, bool& internal); + bool scanQmlJson(const QString& path); + [[nodiscard]] static QPair jsonToQml(const QJsonValue& value, int indent = 0); +}; diff --git a/src/core/scriptmodel.cpp b/src/core/scriptmodel.cpp new file mode 100644 index 00000000..6837c4ab --- /dev/null +++ b/src/core/scriptmodel.cpp @@ -0,0 +1,182 @@ +#include "scriptmodel.hpp" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +void ScriptModel::updateValuesUnique(const QVariantList& newValues) { + this->hasActiveIterators = true; + this->mValues.reserve(newValues.size()); + + auto iter = this->mValues.begin(); + auto newIter = newValues.begin(); + + // TODO: cache this + auto getCmpKey = [&](const QVariant& v) { + if (v.canConvert()) { + auto vMap = v.value(); + if (vMap.contains(this->cmpKey)) { + return vMap.value(this->cmpKey); + } + } + + return v; + }; + + auto variantCmp = [&](const QVariant& a, const QVariant& b) { + if (!this->cmpKey.isEmpty()) return getCmpKey(a) == getCmpKey(b); + else return a == b; + }; + + auto eqPredicate = [&](const QVariant& b) { + return [&](const QVariant& a) { return variantCmp(a, b); }; + }; + + 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 (!variantCmp(*newIter, *iter)) { + auto oldIter = std::find_if(iter, this->mValues.end(), eqPredicate(*newIter)); + + if (oldIter != this->mValues.end()) { + if (std::find_if(newIter, newValues.end(), eqPredicate(*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_if(newIter, newValues.end(), eqPredicate(*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() + && variantCmp(*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_if(iter, this->mValues.end(), eqPredicate(*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 if (*newIter != *iter) { + auto first = static_cast(std::distance(this->mValues.begin(), iter)); + auto index = first; + + do { + this->mValues.replace(index, *newIter); + ++iter; + ++newIter; + ++index; + } while (iter != this->mValues.end() && newIter != newValues.end() && *newIter != *iter); + + this->dataChanged( + this->index(first, 0, QModelIndex()), + this->index(index - 1, 0, QModelIndex()), + {Qt::UserRole} + ); + } else { + ++iter; + ++newIter; + } + } + + this->hasActiveIterators = false; +} + +void ScriptModel::setValues(const QVariantList& newValues) { + if (newValues == this->mValues) return; + this->updateValuesUnique(newValues); + emit this->valuesChanged(); +} + +void ScriptModel::setObjectProp(const QString& objectProp) { + if (objectProp == this->cmpKey) return; + this->cmpKey = objectProp; + this->updateValuesUnique(this->mValues); + emit this->objectPropChanged(); +} + +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..10916f6c --- /dev/null +++ b/src/core/scriptmodel.hpp @@ -0,0 +1,106 @@ +#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: // ... +/// } +/// ``` +/// [QAbstractItemModel]: https://doc.qt.io/qt-6/qabstractitemmodel.html +/// [Data Model]: https://doc.qt.io/qt-6/qtquick-modelviewsdata-modelview.html#qml-data-models +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 @@ObjectModel.values because it will cause @@ScriptModel.values + /// > to receive an update on change. + /// + /// > [!TIP] Most lists exposed by Quickshell are read-only. Some operations like `sort()` + /// > act on a list in-place and cannot be used directly on a list exposed by Quickshell. + /// > You can copy a list using spread syntax: `[...variable]` instead of `variable`. + /// > + /// > For example: + /// > ```qml + /// > ScriptModel { + /// > values: [...DesktopEntries.applications.values].sort(...) + /// > } + /// > ``` + Q_PROPERTY(QVariantList values READ values WRITE setValues NOTIFY valuesChanged); + /// The property that javascript objects passed into the model will be compared with. + /// + /// For example, if `objectProp` is `"myprop"` then `{ myprop: "a", other: "y" }` and + /// `{ myprop: "a", other: "z" }` will be considered equal. + /// + /// Defaults to `""`, meaning no key. + Q_PROPERTY(QString objectProp READ objectProp WRITE setObjectProp NOTIFY objectPropChanged); + QML_ELEMENT; + +public: + [[nodiscard]] QVariantList values() const { + auto values = this->mValues; + // If not detached, the QML engine will invalidate iterators in updateValuesUnique. + if (this->hasActiveIterators) values.detach(); + return values; + } + + void setValues(const QVariantList& newValues); + + [[nodiscard]] QString objectProp() const { return this->cmpKey; } + void setObjectProp(const QString& objectProp); + + [[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(); + void objectPropChanged(); + +private: + QVariantList mValues; + QString cmpKey; + bool hasActiveIterators = false; + + 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/singleton.cpp b/src/core/singleton.cpp new file mode 100644 index 00000000..15668c98 --- /dev/null +++ b/src/core/singleton.cpp @@ -0,0 +1,53 @@ +#include "singleton.hpp" + +#include +#include +#include +#include +#include + +#include "generation.hpp" +#include "reload.hpp" + +void Singleton::componentComplete() { + auto* context = QQmlEngine::contextForObject(this); + + if (context == nullptr) { + qWarning() << "Not registering singleton not created in the qml context:" << this; + return; + } + + auto url = context->baseUrl(); + + if (this->parent() != nullptr || context->contextObject() != this) { + qWarning() << "Tried to register singleton" << this + << "which is not the root component of its file" << url; + return; + } + + auto* generation = EngineGeneration::findObjectGeneration(this); + + if (generation == nullptr) { + qWarning() << "Tried to register singleton" << this + << "which has no associated engine generation" << url; + return; + } + + generation->singletonRegistry.registerSingleton(url, this); + this->ReloadPropagator::componentComplete(); +} + +void SingletonRegistry::registerSingleton(const QUrl& url, Singleton* singleton) { + if (this->registry.contains(url)) { + qWarning() << "Tried to register singleton twice for the same file" << url; + return; + } + + this->registry.insert(url, singleton); +} + +void SingletonRegistry::onReload(SingletonRegistry* old) { + for (auto [url, singleton]: this->registry.asKeyValueRange()) { + singleton->reload(old == nullptr ? nullptr : old->registry.value(url)); + } +} diff --git a/src/core/singleton.hpp b/src/core/singleton.hpp new file mode 100644 index 00000000..200c97f1 --- /dev/null +++ b/src/core/singleton.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "reload.hpp" + +///! The root component for reloadable singletons. +/// All singletons should inherit from this type. +class Singleton: public ReloadPropagator { + Q_OBJECT; + QML_ELEMENT; + +public: + void componentComplete() override; +}; + +class SingletonRegistry { +public: + SingletonRegistry() = default; + + void registerSingleton(const QUrl& url, Singleton* singleton); + void onReload(SingletonRegistry* old); + +private: + QHash registry; +}; diff --git a/src/core/stacklist.hpp b/src/core/stacklist.hpp new file mode 100644 index 00000000..41dc58ee --- /dev/null +++ b/src/core/stacklist.hpp @@ -0,0 +1,173 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +template +class StackList { +public: + T& operator[](size_t i) { + if (i < N) { + return this->array[i]; + } else { + return this->vec[i - N]; + } + } + + const T& operator[](size_t i) const { + return const_cast*>(this)->operator[](i); // NOLINT + } + + void push(const T& value) { + if (this->size < N) { + this->array[this->size] = value; + } else { + this->vec.push_back(value); + } + + ++this->size; + } + + [[nodiscard]] size_t length() const { return this->size; } + [[nodiscard]] bool isEmpty() const { return this->size == 0; } + + [[nodiscard]] bool operator==(const StackList& other) const { + if (other.size != this->size) return false; + + for (size_t i = 0; i != this->size; ++i) { + if (this->operator[](i) != other[i]) return false; + } + + return true; + } + + [[nodiscard]] QList toList() const { + QList list; + list.reserve(this->size); + + for (const auto& entry: *this) { + list.push_back(entry); + } + + return list; + } + + template + struct BaseIterator { + using iterator_category = std::bidirectional_iterator_tag; + using difference_type = int64_t; + using value_type = IT; + using pointer = IT*; + using reference = IT&; + + BaseIterator() = default; + explicit BaseIterator(ListPtr list, size_t i): list(list), i(i) {} + + reference operator*() const { return this->list->operator[](this->i); } + pointer operator->() const { return &**this; } + + Self& operator++() { + ++this->i; + return *static_cast(this); + } + + Self& operator--() { + --this->i; + return *static_cast(this); + } + + Self operator++(int) { + auto v = *this; + this->operator++(); + return v; + } + + Self operator--(int) { + auto v = *this; + this->operator--(); + return v; + } + + difference_type operator-(const Self& other) { + return static_cast(this->i) - static_cast(other.i); + } + + Self& operator+(difference_type offset) { + return Self(this->list, static_cast(this->i) + offset); + } + + [[nodiscard]] bool operator==(const Self& other) const { + return this->list == other.list && this->i == other.i; + } + + [[nodiscard]] bool operator!=(const Self& other) const { return !(*this == other); } + + private: + ListPtr list = nullptr; + size_t i = 0; + }; + + struct Iterator: public BaseIterator*, T> { + Iterator() = default; + Iterator(StackList* list, size_t i) + : BaseIterator*, T>(list, i) {} + }; + + struct ConstIterator: public BaseIterator*, const T> { + ConstIterator() = default; + ConstIterator(const StackList* list, size_t i) + : BaseIterator*, const T>(list, i) {} + }; + + [[nodiscard]] Iterator begin() { return Iterator(this, 0); } + [[nodiscard]] Iterator end() { return Iterator(this, this->size); } + + [[nodiscard]] ConstIterator begin() const { return ConstIterator(this, 0); } + [[nodiscard]] ConstIterator end() const { return ConstIterator(this, this->size); } + + [[nodiscard]] bool isContiguous() const { return this->vec.empty(); } + [[nodiscard]] const T* pArray() const { return this->array.data(); } + [[nodiscard]] size_t dataLength() const { return this->size * sizeof(T); } + + const T* populateAlloc(void* alloc) const { + auto arraylen = std::min(this->size, N) * sizeof(T); + memcpy(alloc, this->array.data(), arraylen); + + if (!this->vec.empty()) { + memcpy( + static_cast(alloc) + arraylen, // NOLINT + this->vec.data(), + this->vec.size() * sizeof(T) + ); + } + + return static_cast(alloc); + } + +private: + std::array array {}; + std::vector vec; + size_t size = 0; +}; + +// might be incorrectly aligned depending on type +// #define STACKLIST_ALLOCA_VIEW(list) ((list).isContiguous() ? (list).pArray() : (list).populateAlloc(alloca((list).dataLength()))) + +// NOLINTBEGIN +#define STACKLIST_VLA_VIEW(type, list, var) \ + const type* var; \ + type var##Data[(list).length()]; \ + if ((list).isContiguous()) { \ + (var) = (list).pArray(); \ + } else { \ + (list).populateAlloc(var##Data); \ + (var) = var##Data; \ + } +// NOLINTEND diff --git a/src/core/test/CMakeLists.txt b/src/core/test/CMakeLists.txt new file mode 100644 index 00000000..4e66c627 --- /dev/null +++ b/src/core/test/CMakeLists.txt @@ -0,0 +1,10 @@ +function (qs_test name) + add_executable(${name} ${ARGN}) + target_link_libraries(${name} PRIVATE Qt::Quick Qt::Test quickshell-core quickshell-window quickshell-ui quickshell-io) + add_test(NAME ${name} WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" COMMAND $) +endfunction() + +qs_test(transformwatcher transformwatcher.cpp) +qs_test(ringbuffer ringbuf.cpp) +qs_test(scriptmodel scriptmodel.cpp) +qs_test(stacklist stacklist.cpp) diff --git a/src/core/test/popupwindow.hpp b/src/core/test/popupwindow.hpp new file mode 100644 index 00000000..e69de29b 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..0abfdbf3 --- /dev/null +++ b/src/core/test/scriptmodel.cpp @@ -0,0 +1,181 @@ +#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; +} + +// NOLINTNEXTLINE(misc-use-internal-linkage) +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; +} + +// NOLINTNEXTLINE(misc-use-internal-linkage) +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(const 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/test/stacklist.cpp b/src/core/test/stacklist.cpp new file mode 100644 index 00000000..9b981729 --- /dev/null +++ b/src/core/test/stacklist.cpp @@ -0,0 +1,92 @@ +#include "stacklist.hpp" +#include + +#include +#include +#include + +#include "../stacklist.hpp" + +void TestStackList::push() { + StackList list; + + list.push(1); + list.push(2); + + QCOMPARE_EQ(list.toList(), QList({1, 2})); + QCOMPARE_EQ(list.length(), 2); +} + +void TestStackList::pushAndGrow() { + StackList list; + + list.push(1); + list.push(2); + list.push(3); + list.push(4); + + QCOMPARE_EQ(list.toList(), QList({1, 2, 3, 4})); + QCOMPARE_EQ(list.length(), 4); +} + +void TestStackList::copy() { + StackList list; + + list.push(1); + list.push(2); + list.push(3); + list.push(4); + + QCOMPARE_EQ(list.toList(), QList({1, 2, 3, 4})); + QCOMPARE_EQ(list.length(), 4); + + auto list2 = list; + + QCOMPARE_EQ(list2.toList(), QList({1, 2, 3, 4})); + QCOMPARE_EQ(list2.length(), 4); + QCOMPARE_EQ(list2, list); +} + +void TestStackList::viewVla() { + StackList list; + + list.push(1); + list.push(2); + + QCOMPARE_EQ(list.toList(), QList({1, 2})); + QCOMPARE_EQ(list.length(), 2); + + STACKLIST_VLA_VIEW(int, list, listView); + + QList ql; + + for (size_t i = 0; i != list.length(); ++i) { + ql.push_back(listView[i]); // NOLINT + } + + QCOMPARE_EQ(ql, list.toList()); +} + +void TestStackList::viewVlaGrown() { + StackList list; + + list.push(1); + list.push(2); + list.push(3); + list.push(4); + + QCOMPARE_EQ(list.toList(), QList({1, 2, 3, 4})); + QCOMPARE_EQ(list.length(), 4); + + STACKLIST_VLA_VIEW(int, list, listView); + + QList ql; + + for (size_t i = 0; i != list.length(); ++i) { + ql.push_back(listView[i]); // NOLINT + } + + QCOMPARE_EQ(ql, list.toList()); +} + +QTEST_MAIN(TestStackList); diff --git a/src/core/test/stacklist.hpp b/src/core/test/stacklist.hpp new file mode 100644 index 00000000..f582761d --- /dev/null +++ b/src/core/test/stacklist.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +class TestStackList: public QObject { + Q_OBJECT; + +private slots: + static void push(); + static void pushAndGrow(); + static void copy(); + static void viewVla(); + static void viewVlaGrown(); +}; diff --git a/src/core/test/transformwatcher.cpp b/src/core/test/transformwatcher.cpp new file mode 100644 index 00000000..ac10cfb0 --- /dev/null +++ b/src/core/test/transformwatcher.cpp @@ -0,0 +1,105 @@ +#include "transformwatcher.hpp" + +#include +#include +#include +#include +#include + +#include "../transformwatcher.hpp" + +void TestTransformWatcher::aParentOfB() { // NOLINT + auto a = QQuickItem(); + a.setObjectName("a"); + auto b = QQuickItem(); + b.setObjectName("b"); + b.setParentItem(&a); + + auto watcher = TransformWatcher(); + watcher.setA(&a); + watcher.setB(&b); + + QCOMPARE(watcher.parentChain, {&a}); + QCOMPARE(watcher.childChain, {&b}); +} + +void TestTransformWatcher::bParentOfA() { // NOLINT + auto a = QQuickItem(); + a.setObjectName("a"); + auto b = QQuickItem(); + b.setObjectName("b"); + a.setParentItem(&b); + + auto watcher = TransformWatcher(); + watcher.setA(&a); + watcher.setB(&b); + + QCOMPARE(watcher.parentChain, (QList {&a, &b})); + QCOMPARE(watcher.childChain, {}); +} + +// a +// p1 b +// p2 c1 +// p3 +void TestTransformWatcher::aParentChainB() { // NOLINT + auto a = QQuickItem(); + a.setObjectName("a"); + auto b = QQuickItem(); + b.setObjectName("b"); + + auto p1 = QQuickItem(); + p1.setObjectName("p1"); + auto p2 = QQuickItem(); + p2.setObjectName("p2"); + auto p3 = QQuickItem(); + p3.setObjectName("p3"); + auto c1 = QQuickItem(); + c1.setObjectName("c1"); + + a.setParentItem(&p1); + p1.setParentItem(&p2); + p2.setParentItem(&p3); + + b.setParentItem(&c1); + c1.setParentItem(&p3); + + auto watcher = TransformWatcher(); + watcher.setA(&a); + watcher.setB(&b); + + QCOMPARE(watcher.parentChain, (QList {&a, &p1, &p2, &p3})); + QCOMPARE(watcher.childChain, (QList {&b, &c1})); +} + +void TestTransformWatcher::multiWindow() { // NOLINT + auto a = QQuickItem(); + a.setObjectName("a"); + auto b = QQuickItem(); + b.setObjectName("b"); + + auto p = QQuickItem(); + p.setObjectName("p"); + auto c = QQuickItem(); + c.setObjectName("c"); + + a.setParentItem(&p); + b.setParentItem(&c); + + auto aW = QQuickWindow(); + auto bW = QQuickWindow(); + + p.setParentItem(aW.contentItem()); + c.setParentItem(bW.contentItem()); + + auto watcher = TransformWatcher(); + watcher.setA(&a); + watcher.setB(&b); + + QCOMPARE(watcher.parentChain, (QList {&a, &p, aW.contentItem()})); + QCOMPARE(watcher.childChain, (QList {&b, &c, bW.contentItem()})); + QCOMPARE(watcher.parentWindow, &aW); + QCOMPARE(watcher.childWindow, &bW); +} + +QTEST_MAIN(TestTransformWatcher); diff --git a/src/core/test/transformwatcher.hpp b/src/core/test/transformwatcher.hpp new file mode 100644 index 00000000..e2bdfda8 --- /dev/null +++ b/src/core/test/transformwatcher.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +class TestTransformWatcher: public QObject { + Q_OBJECT; + +private slots: + void aParentOfB(); + void bParentOfA(); + void aParentChainB(); + void multiWindow(); +}; diff --git a/src/core/toolsupport.cpp b/src/core/toolsupport.cpp new file mode 100644 index 00000000..afce008b --- /dev/null +++ b/src/core/toolsupport.cpp @@ -0,0 +1,241 @@ +#include "toolsupport.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "logcat.hpp" +#include "paths.hpp" +#include "scan.hpp" + +namespace qs::core { + +namespace { +QS_LOGGING_CATEGORY(logTooling, "quickshell.tooling", QtWarningMsg); +} + +bool QmlToolingSupport::updateTooling(const QDir& configRoot, QmlScanner& scanner) { + auto* vfs = QsPaths::instance()->shellVfsDir(); + + if (!vfs) { + qCCritical(logTooling) << "Tooling dir could not be created"; + return false; + } + + if (!QmlToolingSupport::lockTooling()) { + return false; + } + + if (!QmlToolingSupport::updateQmllsConfig(configRoot, false)) { + QDir(vfs->filePath("qs")).removeRecursively(); + return false; + } + + QmlToolingSupport::updateToolingFs(scanner, configRoot, vfs->filePath("qs")); + return true; +} + +bool QmlToolingSupport::lockTooling() { + if (QmlToolingSupport::toolingLock) return true; + + auto lockPath = QsPaths::instance()->shellVfsDir()->filePath("tooling.lock"); + auto* file = new QFile(lockPath); + + if (!file->open(QFile::WriteOnly)) { + qCCritical(logTooling) << "Could not open tooling lock for write"; + return false; + } + + auto lock = flock { + .l_type = F_WRLCK, + .l_whence = SEEK_SET, // NOLINT (fcntl.h??) + .l_start = 0, + .l_len = 0, + .l_pid = 0, + }; + + if (fcntl(file->handle(), F_SETLK, &lock) == 0) { + qCInfo(logTooling) << "Acquired tooling support lock"; + QmlToolingSupport::toolingLock = file; + return true; + } else if (errno == EACCES || errno == EAGAIN) { + qCInfo(logTooling) << "Tooling support locked by another instance"; + return false; + } else { + qCCritical(logTooling).nospace() << "Could not create tooling lock at " << lockPath + << " with error code " << errno << ": " << qt_error_string(); + return false; + } +} + +QString QmlToolingSupport::getQmllsConfig() { + static auto config = []() { + // We can't replicate the algorithm used to create the import path list as it can have distro + // specific patches, e.g. nixos. + auto importPaths = QQmlEngine().importPathList(); + importPaths.removeIf([](const QString& path) { return path.startsWith("qrc:"); }); + + auto vfsPath = QsPaths::instance()->shellVfsDir()->path(); + auto importPathsStr = importPaths.join(u':'); + + QString qmllsConfig; + auto print = QDebug(&qmllsConfig).nospace(); + print << "[General]\nno-cmake-calls=true\nbuildDir=" << vfsPath + << "\nimportPaths=" << importPathsStr << '\n'; + + return qmllsConfig; + }(); + + return config; +} + +bool QmlToolingSupport::updateQmllsConfig(const QDir& configRoot, bool create) { + auto shellConfigPath = configRoot.filePath(".qmlls.ini"); + auto vfsConfigPath = QsPaths::instance()->shellVfsDir()->filePath(".qmlls.ini"); + + auto shellFileInfo = QFileInfo(shellConfigPath); + if (!create && !shellFileInfo.exists() && !shellFileInfo.isSymLink()) { + if (QmlToolingSupport::toolingEnabled) { + qInfo() << "QML tooling support disabled"; + QmlToolingSupport::toolingEnabled = false; + } else { + qCInfo(logTooling) << "Not enabling QML tooling support, qmlls.ini is missing at path" + << shellConfigPath; + } + + QFile::remove(vfsConfigPath); + return false; + } + + auto vfsFile = QFile(vfsConfigPath); + + if (!vfsFile.open(QFile::ReadWrite | QFile::Text)) { + qCCritical(logTooling) << "Failed to create qmlls config in vfs"; + return false; + } + + auto config = QmlToolingSupport::getQmllsConfig(); + + if (vfsFile.readAll() != config) { + if (!vfsFile.resize(0) || !vfsFile.write(config.toUtf8())) { + qCCritical(logTooling) << "Failed to write qmlls config in vfs"; + return false; + } + + qCDebug(logTooling) << "Wrote qmlls config in vfs"; + } + + if (!shellFileInfo.isSymLink() || shellFileInfo.symLinkTarget() != vfsConfigPath) { + QFile::remove(shellConfigPath); + + if (!QFile::link(vfsConfigPath, shellConfigPath)) { + qCCritical(logTooling) << "Failed to create qmlls config symlink"; + return false; + } + + qCDebug(logTooling) << "Created qmlls config symlink"; + } + + if (!QmlToolingSupport::toolingEnabled) { + qInfo() << "QML tooling support enabled"; + QmlToolingSupport::toolingEnabled = true; + } + + return true; +} + +void QmlToolingSupport::updateToolingFs( + QmlScanner& scanner, + const QDir& scanDir, + const QDir& linkDir +) { + QList files; + QSet subdirs; + + auto scanPath = scanDir.path(); + + linkDir.mkpath("."); + + for (auto& path: scanner.scannedFiles) { + if (path.length() < scanPath.length() + 1 || !path.startsWith(scanPath)) continue; + auto name = path.sliced(scanPath.length() + 1); + + if (name.contains('/')) { + auto dirname = name.first(name.indexOf('/')); + subdirs.insert(dirname); + continue; + } + + auto fileInfo = QFileInfo(path); + if (!fileInfo.isFile()) continue; + + auto spath = linkDir.filePath(name); + auto sFileInfo = QFileInfo(spath); + + if (!sFileInfo.isSymLink() || sFileInfo.symLinkTarget() != path) { + QFile::remove(spath); + + if (QFile::link(path, spath)) { + qCDebug(logTooling) << "Created symlink to" << path << "at" << spath; + files.append(spath); + } else { + qCCritical(logTooling) << "Could not create symlink to" << path << "at" << spath; + } + } else { + files.append(spath); + } + } + + for (auto [path, text]: scanner.fileIntercepts.asKeyValueRange()) { + if (path.length() < scanPath.length() + 1 || !path.startsWith(scanPath)) continue; + auto name = path.sliced(scanPath.length() + 1); + + if (name.contains('/')) { + auto dirname = name.first(name.indexOf('/')); + subdirs.insert(dirname); + continue; + } + + auto spath = linkDir.filePath(name); + auto file = QFile(spath); + if (!file.open(QFile::ReadWrite | QFile::Text)) { + qCCritical(logTooling) << "Failed to open injected file" << spath; + continue; + } + + if (file.readAll() == text) { + files.append(spath); + continue; + } + + if (file.resize(0) && file.write(text.toUtf8())) { + files.append(spath); + qCDebug(logTooling) << "Wrote injected file" << spath; + } else { + qCCritical(logTooling) << "Failed to write injected file" << spath; + } + } + + for (auto& name: linkDir.entryList(QDir::Files | QDir::System)) { // System = broken symlinks + auto path = linkDir.filePath(name); + + if (!files.contains(path)) { + if (QFile::remove(path)) qCDebug(logTooling) << "Removed old file at" << path; + else qCWarning(logTooling) << "Failed to remove old file at" << path; + } + } + + for (const auto& subdir: subdirs) { + QmlToolingSupport::updateToolingFs(scanner, scanDir.filePath(subdir), linkDir.filePath(subdir)); + } +} + +} // namespace qs::core diff --git a/src/core/toolsupport.hpp b/src/core/toolsupport.hpp new file mode 100644 index 00000000..9fb79216 --- /dev/null +++ b/src/core/toolsupport.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include "scan.hpp" + +namespace qs::core { + +class QmlToolingSupport { +public: + static bool updateTooling(const QDir& configRoot, QmlScanner& scanner); + +private: + static QString getQmllsConfig(); + static bool lockTooling(); + static bool updateQmllsConfig(const QDir& configRoot, bool create); + static void updateToolingFs(QmlScanner& scanner, const QDir& scanDir, const QDir& linkDir); + static inline bool toolingEnabled = false; + static inline QFile* toolingLock = nullptr; +}; + +} // namespace qs::core diff --git a/src/core/transformwatcher.cpp b/src/core/transformwatcher.cpp new file mode 100644 index 00000000..6fc7c34a --- /dev/null +++ b/src/core/transformwatcher.cpp @@ -0,0 +1,183 @@ +#include "transformwatcher.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +void TransformWatcher::resolveChains(QQuickItem* a, QQuickItem* b, QQuickItem* commonParent) { + if (a == nullptr || b == nullptr) return; + + auto aChain = QVector(); + auto bChain = QVector(); + + auto* aParent = a; + auto* bParent = b; + + // resolve the parent chain of b. if a is in the chain break early + while (bParent != nullptr) { + bChain.push_back(bParent); + + if (bParent == a) { + aChain.push_back(a); + goto chainResolved; + } + + if (bParent == commonParent) break; + bParent = bParent->parentItem(); + } + + // resolve the parent chain of a, breaking as soon as b is found + while (aParent != nullptr) { + aChain.push_back(aParent); + + for (auto bParent = bChain.begin(); bParent != bChain.end(); bParent++) { + if (*bParent == aParent) { + bParent++; + bChain.erase(bParent, bChain.end()); + goto chainResolved; + } + } + + if (aParent == commonParent) break; + aParent = aParent->parentItem(); + } + + if (commonParent != nullptr && aParent == commonParent) { + qWarning() << this << "failed to find a common parent between" << a << "and" << b + << "due to incorrectly set commonParent" << commonParent; + + return; + } + +chainResolved: + + this->parentChain = aChain; + if (bChain.last() == aChain.last()) bChain.removeLast(); + this->childChain = bChain; + + if (a->window() != b->window()) { + this->parentWindow = a->window(); + this->childWindow = b->window(); + } else { + this->parentWindow = nullptr; + this->childWindow = nullptr; + } +} + +void TransformWatcher::resolveChains() { + this->resolveChains(this->mA, this->mB, this->mCommonParent); +} + +void TransformWatcher::linkItem(QQuickItem* item) const { + QObject::connect(item, &QQuickItem::xChanged, this, &TransformWatcher::transformChanged); + QObject::connect(item, &QQuickItem::yChanged, this, &TransformWatcher::transformChanged); + QObject::connect(item, &QQuickItem::widthChanged, this, &TransformWatcher::transformChanged); + QObject::connect(item, &QQuickItem::heightChanged, this, &TransformWatcher::transformChanged); + QObject::connect(item, &QQuickItem::scaleChanged, this, &TransformWatcher::transformChanged); + QObject::connect(item, &QQuickItem::rotationChanged, this, &TransformWatcher::transformChanged); + + QObject::connect(item, &QQuickItem::parentChanged, this, &TransformWatcher::recalcChains); + QObject::connect(item, &QQuickItem::windowChanged, this, &TransformWatcher::recalcChains); + + if (item != this->mA && item != this->mB) { + QObject::connect(item, &QObject::destroyed, this, &TransformWatcher::itemDestroyed); + } +} + +void TransformWatcher::linkChains() { + for (auto* item: this->parentChain) { + this->linkItem(item); + } + + for (auto* item: this->childChain) { + this->linkItem(item); + } +} + +void TransformWatcher::unlinkChains() { + for (auto* item: this->parentChain) { + QObject::disconnect(item, nullptr, this, nullptr); + } + + 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() { + this->unlinkChains(); + this->resolveChains(); + 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->recalcChains(); +} diff --git a/src/core/transformwatcher.hpp b/src/core/transformwatcher.hpp new file mode 100644 index 00000000..8efa9399 --- /dev/null +++ b/src/core/transformwatcher.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#ifdef QS_TEST +class TestTransformWatcher; +#endif + +///! Monitor of all geometry changes between two objects. +/// The TransformWatcher monitors all properties that affect the geometry +/// 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`, +/// > or `a` being closer to the common parent of `a` and `b` than `b`. +class TransformWatcher: public QObject { + Q_OBJECT; + // clang-format off + Q_PROPERTY(QQuickItem* a READ a WRITE setA NOTIFY aChanged); + Q_PROPERTY(QQuickItem* b READ b WRITE setB NOTIFY bChanged); + /// Known common parent of both `a` and `b`. Defaults to `null`. + /// + /// This property can be used to optimize the algorithm that figures out + /// the relationship between `a` and `b`. Setting it to something that is not + /// a common parent of both `a` and `b` will prevent the path from being determined + /// correctly, and setting it to `null` will disable the optimization. + Q_PROPERTY(QQuickItem* commonParent READ commonParent WRITE setCommonParent NOTIFY commonParentChanged); + /// This property is updated whenever the geometry of any item in the path from `a` to `b` changes. + /// + /// Its value is undefined, and is intended to trigger an expression update. + Q_PROPERTY(QObject* transform READ transform NOTIFY transformChanged); + // clang-format on + QML_ELEMENT; + +public: + explicit TransformWatcher(QObject* parent = nullptr): QObject(parent) {} + + [[nodiscard]] QQuickItem* a() const; + void setA(QQuickItem* a); + + [[nodiscard]] QQuickItem* b() const; + void setB(QQuickItem* b); + + [[nodiscard]] QQuickItem* commonParent() const; + void setCommonParent(QQuickItem* commonParent); + + [[nodiscard]] QObject* transform() const { return nullptr; } // NOLINT + +signals: + void transformChanged(); + + void aChanged(); + void bChanged(); + void commonParentChanged(); + +private slots: + void recalcChains(); + void itemDestroyed(); + void aDestroyed(); + void bDestroyed(); + +private: + void resolveChains(QQuickItem* a, QQuickItem* b, QQuickItem* commonParent); + void resolveChains(); + void linkItem(QQuickItem* item) const; + void linkChains(); + void unlinkChains(); + + QQuickItem* mA = nullptr; + QQuickItem* mB = nullptr; + QQuickItem* mCommonParent = nullptr; + + // a -> traverse parent chain -> parent window -> global scope -> child window -> traverse child chain -> b + QList parentChain; + QList childChain; + QQuickWindow* parentWindow = nullptr; + QQuickWindow* childWindow = nullptr; + +#ifdef QS_TEST + friend class TestTransformWatcher; +#endif +}; diff --git a/src/core/types.cpp b/src/core/types.cpp new file mode 100644 index 00000000..81c1d010 --- /dev/null +++ b/src/core/types.cpp @@ -0,0 +1,26 @@ +#include "types.hpp" + +#include +#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); +} + +QMargins Margins::qmargins() const { return {this->left, this->top, this->right, this->bottom}; } diff --git a/src/core/types.hpp b/src/core/types.hpp new file mode 100644 index 00000000..b6cb2598 --- /dev/null +++ b/src/core/types.hpp @@ -0,0 +1,101 @@ +#pragma once + +#include +#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; + [[nodiscard]] bool isEmpty() const { return this->w == 0 && this->h == 0; } +}; + +QDebug operator<<(QDebug debug, const Box& box); + +class Margins { + Q_GADGET; + Q_PROPERTY(qint32 left MEMBER left); + Q_PROPERTY(qint32 right MEMBER right); + Q_PROPERTY(qint32 top MEMBER top); + Q_PROPERTY(qint32 bottom MEMBER bottom); + QML_CONSTRUCTIBLE_VALUE; + QML_VALUE_TYPE(margins); + +public: + [[nodiscard]] bool operator==(const Margins& other) const noexcept { + // clang-format off + return this->left == other.left + && this->right == other.right + && this->top == other.top + && this->bottom == other.bottom; + // clang-format on + } + + qint32 left = 0; + qint32 right = 0; + qint32 top = 0; + qint32 bottom = 0; + + [[nodiscard]] QMargins qmargins() const; +}; + +///! 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..88583d0c --- /dev/null +++ b/src/core/util.hpp @@ -0,0 +1,304 @@ +#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); +} + +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.cpp b/src/core/variants.cpp index e564e1bf..a190e36d 100644 --- a/src/core/variants.cpp +++ b/src/core/variants.cpp @@ -6,51 +6,110 @@ #include #include #include +#include +#include +#include +#include #include "reload.hpp" void Variants::onReload(QObject* oldInstance) { auto* old = qobject_cast(oldInstance); - for (auto& [variant, instanceObj]: this->instances.values) { + for (auto& [variant, instanceObj]: this->mInstances.values) { QObject* oldInstance = nullptr; if (old != nullptr) { - auto& values = old->instances.values; + auto& values = old->mInstances.values; - int matchcount = 0; - int matchi = 0; - int i = 0; - for (auto& [valueSet, _]: values) { - int count = 0; - for (auto& [k, v]: variant.toStdMap()) { - if (valueSet.contains(k) && valueSet.value(k) == v) { - count++; + if (variant.canConvert()) { + auto variantMap = variant.value(); + + int matchcount = 0; + int matchi = 0; + int i = 0; + for (auto& [value, _]: values) { + if (!value.canConvert()) continue; + auto valueSet = value.value(); + + int count = 0; + for (auto [k, v]: variantMap.asKeyValueRange()) { + if (valueSet.contains(k) && valueSet.value(k) == v) { + count++; + } } + + if (count > matchcount) { + matchcount = count; + matchi = i; + } + + i++; } - if (count > matchcount) { - matchcount = count; - matchi = i; + if (matchcount > 0) { + oldInstance = values.takeAt(matchi).second; } + } else { + int i = 0; + for (auto& [value, _]: values) { + if (variant == value) { + oldInstance = values.takeAt(i).second; + break; + } - i++; - } - - if (matchcount > 0) { - oldInstance = values.takeAt(matchi).second; + i++; + } } } auto* instance = qobject_cast(instanceObj); - if (instance != nullptr) instance->onReload(oldInstance); + if (instance != nullptr) instance->reload(oldInstance); else Reloadable::reloadChildrenRecursive(instanceObj, oldInstance); } + + this->loaded = true; } -void Variants::setVariants(QVariantList variants) { - this->mVariants = std::move(variants); +QVariant Variants::model() const { return QVariant::fromValue(this->mModel); } + +void Variants::setModel(const QVariant& model) { + if (model.canConvert()) { + this->mModel = model.value(); + } else if (model.canConvert()) { + auto list = model.value(); + if (!list.isReadable()) { + qWarning() << "Non readable list" << model << "assigned to Variants.model, Ignoring."; + return; + } + + QVariantList model; + auto size = list.count(); + for (auto i = 0; i < size; i++) { + model.push_back(QVariant::fromValue(list.at(i))); + } + + this->mModel = std::move(model); + } else { + qWarning() << "Non list data" << model << "assigned to Variants.model, Ignoring."; + return; + } + this->updateVariants(); + emit this->modelChanged(); + emit this->instancesChanged(); +} + +QQmlListProperty Variants::instances() { + return QQmlListProperty(this, nullptr, &Variants::instanceCount, &Variants::instanceAt); +} + +qsizetype Variants::instanceCount(QQmlListProperty* prop) { + return static_cast(prop->object)->mInstances.values.length(); // NOLINT +} + +QObject* Variants::instanceAt(QQmlListProperty* prop, qsizetype i) { + return static_cast(prop->object)->mInstances.values.at(i).second; // NOLINT } void Variants::componentComplete() { @@ -59,47 +118,46 @@ void Variants::componentComplete() { } void Variants::updateVariants() { - if (this->mComponent == nullptr) { + if (this->mDelegate == nullptr) { qWarning() << "Variants instance does not have a component specified"; return; } // clean up removed entries - for (auto iter = this->instances.values.begin(); iter < this->instances.values.end();) { - if (this->mVariants.contains(iter->first)) { + for (auto iter = this->mInstances.values.begin(); iter < this->mInstances.values.end();) { + if (this->mModel.contains(iter->first)) { iter++; } else { iter->second->deleteLater(); - iter = this->instances.values.erase(iter); + iter = this->mInstances.values.erase(iter); } } - for (auto iter = this->mVariants.begin(); iter < this->mVariants.end(); iter++) { - auto& variantObj = *iter; - if (!variantObj.canConvert()) { - qWarning() << "value passed to Variants is not an object and will be ignored:" << variantObj; - } else { - auto variant = variantObj.value(); - - for (auto iter2 = this->mVariants.begin(); iter2 < iter; iter2++) { - if (*iter2 == variantObj) { - qWarning() << "same value specified twice in Variants, duplicates will be ignored:" - << variantObj; - goto outer; - } + for (auto iter = this->mModel.begin(); iter < this->mModel.end(); iter++) { + auto& variant = *iter; + for (auto iter2 = this->mModel.begin(); iter2 < iter; iter2++) { + if (*iter2 == variant) { + qWarning() << "same value specified twice in Variants, duplicates will be ignored:" + << variant; + goto outer; } + } - if (this->instances.contains(variant)) { + { + if (this->mInstances.contains(variant)) { continue; // we dont need to recreate this one } - auto* instance = this->mComponent->createWithInitialProperties( - variant, - QQmlEngine::contextForObject(this->mComponent) + auto variantMap = QVariantMap(); + variantMap.insert("modelData", variant); + + auto* instance = this->mDelegate->createWithInitialProperties( + variantMap, + QQmlEngine::contextForObject(this->mDelegate) ); if (instance == nullptr) { - qWarning() << this->mComponent->errorString().toStdString().c_str(); + qWarning() << this->mDelegate->errorString().toStdString().c_str(); qWarning() << "failed to create variant with object" << variant; continue; } @@ -107,7 +165,12 @@ void Variants::updateVariants() { QQmlEngine::setObjectOwnership(instance, QQmlEngine::CppOwnership); instance->setParent(this); - this->instances.insert(variant, instance); + this->mInstances.insert(variant, instance); + + if (this->loaded) { + if (auto* reloadable = qobject_cast(instance)) reloadable->reload(nullptr); + else Reloadable::reloadChildrenRecursive(instance, nullptr); + } } outer:; @@ -133,7 +196,7 @@ V* AwfulMap::get(const K& key) { } template -void AwfulMap::insert(K key, V value) { +void AwfulMap::insert(const K& key, V value) { this->values.push_back(QPair(key, value)); } diff --git a/src/core/variants.hpp b/src/core/variants.hpp index 79224e24..fa0333d3 100644 --- a/src/core/variants.hpp +++ b/src/core/variants.hpp @@ -6,9 +6,12 @@ #include #include #include +#include #include #include +#include +#include "doc.hpp" #include "reload.hpp" // extremely inefficient map @@ -17,40 +20,64 @@ class AwfulMap { public: [[nodiscard]] bool contains(const K& key) const; [[nodiscard]] V* get(const K& key); - void insert(K key, V value); // assumes no duplicates - bool remove(const K& key); // returns true if anything was removed + void insert(const K& key, V value); // assumes no duplicates + bool remove(const K& key); // returns true if anything was removed QList> values; }; -///! Creates instances of a component based on a given set of variants. +///! Creates instances of a component based on a given model. /// Creates and destroys instances of the given component when the given property changes. /// -/// See [Quickshell.screens] for an example of using `Variants` to create copies of a window per +/// `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 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 /// screen. /// -/// [Quickshell.screens]: ../quickshell#prop.screens +/// > [!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) class Variants: public Reloadable { Q_OBJECT; - /// The component to create instances of - Q_PROPERTY(QQmlComponent* component MEMBER mComponent); + /// The component to create instances of. + /// + /// The delegate should define a `modelData` property that will be popuplated with a value + /// 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. - Q_PROPERTY(QList variants MEMBER mVariants WRITE setVariants); - Q_CLASSINFO("DefaultProperty", "component"); + QSDOC_PROPERTY_OVERRIDE(QList model READ model WRITE setModel NOTIFY modelChanged); + QSDOC_HIDE Q_PROPERTY(QVariant model READ model WRITE setModel NOTIFY modelChanged); + /// Current instances of the delegate. + Q_PROPERTY(QQmlListProperty instances READ instances NOTIFY instancesChanged); + Q_CLASSINFO("DefaultProperty", "delegate"); QML_ELEMENT; public: explicit Variants(QObject* parent = nullptr): Reloadable(parent) {} void onReload(QObject* oldInstance) override; - void componentComplete() override; + [[nodiscard]] QVariant model() const; + void setModel(const QVariant& model); + + QQmlListProperty instances(); + +signals: + void modelChanged(); + void instancesChanged(); + private: - void setVariants(QVariantList variants); + static qsizetype instanceCount(QQmlListProperty* prop); + static QObject* instanceAt(QQmlListProperty* prop, qsizetype i); + void updateVariants(); - QQmlComponent* mComponent = nullptr; - QVariantList mVariants; - AwfulMap instances; + QQmlComponent* mDelegate = nullptr; + QVariantList mModel; + AwfulMap mInstances; + bool loaded = false; }; diff --git a/src/core/watcher.cpp b/src/core/watcher.cpp deleted file mode 100644 index 6b06d584..00000000 --- a/src/core/watcher.cpp +++ /dev/null @@ -1,38 +0,0 @@ -#include "watcher.hpp" - -#include -#include -#include -#include -#include - -FiletreeWatcher::FiletreeWatcher(QObject* parent): QObject(parent) { - QObject::connect( - &this->watcher, - &QFileSystemWatcher::fileChanged, - this, - &FiletreeWatcher::onFileChanged - ); - - QObject::connect( - &this->watcher, - &QFileSystemWatcher::directoryChanged, - this, - &FiletreeWatcher::onDirectoryChanged - ); -} -void FiletreeWatcher::addPath(const QString& path) { - this->watcher.addPath(path); - - if (QFileInfo(path).isDir()) { - auto dir = QDir(path); - - for (auto& entry: dir.entryList(QDir::AllEntries | QDir::NoDotAndDotDot)) { - this->addPath(dir.filePath(entry)); - } - } -} - -void FiletreeWatcher::onDirectoryChanged(const QString& path) { this->addPath(path); } - -void FiletreeWatcher::onFileChanged(const QString& path) { emit this->fileChanged(path); } diff --git a/src/core/watcher.hpp b/src/core/watcher.hpp deleted file mode 100644 index a729f03c..00000000 --- a/src/core/watcher.hpp +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include -#include -#include - -class FiletreeWatcher: public QObject { - Q_OBJECT; - -public: - explicit FiletreeWatcher(QObject* parent = nullptr); - - void addPath(const QString& path); - -signals: - void fileChanged(const QString& path); - -private slots: - void onDirectoryChanged(const QString& path); - void onFileChanged(const QString& path); - -private: - QFileSystemWatcher watcher; -}; 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/core/windowinterface.hpp b/src/core/windowinterface.hpp deleted file mode 100644 index 4f20d9c0..00000000 --- a/src/core/windowinterface.hpp +++ /dev/null @@ -1,125 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -#include "qmlscreen.hpp" -#include "region.hpp" -#include "reload.hpp" - -class WindowInterface: public Reloadable { - Q_OBJECT; - // clang-format off - Q_PROPERTY(QQuickItem* contentItem READ contentItem); - /// If the window is shown or hidden. Defaults to true. - Q_PROPERTY(bool visible READ isVisible WRITE setVisible NOTIFY visibleChanged); - Q_PROPERTY(qint32 width READ width WRITE setWidth NOTIFY widthChanged); - Q_PROPERTY(qint32 height READ height WRITE setHeight NOTIFY heightChanged); - /// The screen that the window currently occupies. - /// - /// > [!INFO] This cannot be changed after windowConnected. - Q_PROPERTY(QuickshellScreenInfo* screen READ screen WRITE setScreen NOTIFY screenChanged); - /// The background color of the window. Defaults to white. - /// - /// > [!WARNING] This seems to behave weirdly when using transparent colors on some systems. - /// > Using a colored content item over a transparent window is the recommended way to work around this: - /// > ```qml - /// > ProxyWindow { - /// > color: "transparent" - /// > Rectangle { - /// > anchors.fill: parent - /// > color: "#20ffffff" - /// > - /// > // your content here - /// > } - /// > } - /// > ``` - Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY colorChanged); - /// The clickthrough mask. Defaults to null. - /// - /// If non null then the clickable areas of the window will be determined by the provided region. - /// - /// ```qml - /// ShellWindow { - /// // The mask region is set to `rect`, meaning only `rect` is clickable. - /// // All other clicks pass through the window to ones behind it. - /// mask: Region { item: rect } - /// - /// Rectangle { - /// id: rect - /// - /// anchors.centerIn: parent - /// width: 100 - /// height: 100 - /// } - /// } - /// ``` - /// - /// If the provided region's intersection mode is `Combine` (the default), - /// then the region will be used as is. Otherwise it will be applied on top of the window region. - /// - /// For example, setting the intersection mode to `Xor` will invert the mask and make everything in - /// the mask region not clickable and pass through clicks inside it through the window. - /// - /// ```qml - /// ShellWindow { - /// // The mask region is set to `rect`, but the intersection mode is set to `Xor`. - /// // This inverts the mask causing all clicks inside `rect` to be passed to the window - /// // behind this one. - /// mask: Region { item: rect; intersection: Intersection.Xor } - /// - /// Rectangle { - /// id: rect - /// - /// anchors.centerIn: parent - /// width: 100 - /// height: 100 - /// } - /// } - /// ``` - Q_PROPERTY(PendingRegion* mask READ mask WRITE setMask NOTIFY maskChanged); - Q_PROPERTY(QQmlListProperty data READ data); - // clang-format on - Q_CLASSINFO("DefaultProperty", "data"); - QML_NAMED_ELEMENT(QSWindow); - QML_UNCREATABLE("uncreatable base class"); - -public: - explicit WindowInterface(QObject* parent = nullptr): Reloadable(parent) {} - - [[nodiscard]] virtual QQuickItem* contentItem() const = 0; - - [[nodiscard]] virtual bool isVisible() const = 0; - virtual void setVisible(bool visible) = 0; - - [[nodiscard]] virtual qint32 width() const = 0; - virtual void setWidth(qint32 width) = 0; - - [[nodiscard]] virtual qint32 height() const = 0; - virtual void setHeight(qint32 height) = 0; - - [[nodiscard]] virtual QuickshellScreenInfo* screen() const = 0; - virtual void setScreen(QuickshellScreenInfo* screen) = 0; - - [[nodiscard]] virtual QColor color() const = 0; - virtual void setColor(QColor color) = 0; - - [[nodiscard]] virtual PendingRegion* mask() const = 0; - virtual void setMask(PendingRegion* mask) = 0; - - [[nodiscard]] virtual QQmlListProperty data() = 0; - -signals: - void windowConnected(); - void visibleChanged(); - void widthChanged(); - void heightChanged(); - void screenChanged(); - void colorChanged(); - void maskChanged(); -}; 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..1433a879 --- /dev/null +++ b/src/crash/handler.cpp @@ -0,0 +1,185 @@ +#include "handler.hpp" +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/instanceinfo.hpp" +#include "../core/logcat.hpp" + +extern char** environ; // NOLINT + +using namespace google_breakpad; + +namespace qs::crash { + +namespace { +QS_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..b9f0eabe --- /dev/null +++ b/src/crash/main.cpp @@ -0,0 +1,191 @@ +#include "main.hpp" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/instanceinfo.hpp" +#include "../core/logcat.hpp" +#include "../core/logging.hpp" +#include "../core/paths.hpp" +#include "build.hpp" +#include "interface.hpp" + +namespace { + +QS_LOGGING_CATEGORY(logCrashReporter, "quickshell.crashreporter", QtWarningMsg); + +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."; +} + +} // namespace + +void qsCheckCrash(int argc, char** argv) { + auto fd = qEnvironmentVariable("__QUICKSHELL_CRASH_DUMP_FD"); + if (fd.isEmpty()) return; + + 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 app = QApplication(argc, argv); + QApplication::setDesktopFileName("org.quickshell"); + + 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 +} 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 new file mode 100644 index 00000000..fc004f3d --- /dev/null +++ b/src/dbus/CMakeLists.txt @@ -0,0 +1,44 @@ +set_source_files_properties(org.freedesktop.DBus.Properties.xml PROPERTIES + CLASSNAME DBusPropertiesInterface +) + +set_source_files_properties(org.freedesktop.DBus.ObjectManager.xml PROPERTIES + CLASSNAME DBusObjectManagerInterface + INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/dbus_objectmanager_types.hpp +) + +qt_add_dbus_interface(DBUS_INTERFACES + org.freedesktop.DBus.Properties.xml + dbus_properties +) + +qt_add_dbus_interface(DBUS_INTERFACES + org.freedesktop.DBus.ObjectManager.xml + dbus_objectmanager +) + +qt_add_library(quickshell-dbus STATIC + properties.cpp + objectmanager.cpp + bus.cpp + ${DBUS_INTERFACES} +) + +# dbus headers +target_include_directories(quickshell-dbus PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) + +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_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..d53c4c6b --- /dev/null +++ b/src/dbus/bus.cpp @@ -0,0 +1,61 @@ +#include "bus.hpp" // NOLINT +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/logcat.hpp" + +namespace qs::dbus { + +namespace { +QS_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/dbus_objectmanager_types.hpp b/src/dbus/dbus_objectmanager_types.hpp new file mode 100644 index 00000000..5e0869c1 --- /dev/null +++ b/src/dbus/dbus_objectmanager_types.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include +#include +#include +#include +#include + +using DBusObjectManagerInterfaces = QHash; +using DBusObjectManagerObjects = QHash; diff --git a/src/dbus/dbusmenu/CMakeLists.txt b/src/dbus/dbusmenu/CMakeLists.txt new file mode 100644 index 00000000..61cee42c --- /dev/null +++ b/src/dbus/dbusmenu/CMakeLists.txt @@ -0,0 +1,35 @@ +set_source_files_properties(com.canonical.dbusmenu.xml PROPERTIES + CLASSNAME DBusMenuInterface + INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/dbus_menu_types.hpp +) + +qt_add_dbus_interface(DBUS_INTERFACES + com.canonical.dbusmenu.xml + dbus_menu +) + +qt_add_library(quickshell-dbusmenu STATIC + dbus_menu_types.cpp + dbusmenu.cpp + ${DBUS_INTERFACES} +) + +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::Quick Qt::DBus) +qs_add_link_dependencies(quickshell-dbusmenu quickshell-dbus) + +qs_module_pch(quickshell-dbusmenu SET dbus) + +target_link_libraries(quickshell PRIVATE quickshell-dbusmenuplugin) diff --git a/src/dbus/dbusmenu/com.canonical.dbusmenu.xml b/src/dbus/dbusmenu/com.canonical.dbusmenu.xml new file mode 100644 index 00000000..12f021bc --- /dev/null +++ b/src/dbus/dbusmenu/com.canonical.dbusmenu.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/dbus/dbusmenu/dbus_menu_types.cpp b/src/dbus/dbusmenu/dbus_menu_types.cpp new file mode 100644 index 00000000..36ae41fe --- /dev/null +++ b/src/dbus/dbusmenu/dbus_menu_types.cpp @@ -0,0 +1,92 @@ +#include "dbus_menu_types.hpp" + +#include +#include +#include +#include +#include + +const QDBusArgument& operator>>(const QDBusArgument& argument, DBusMenuLayout& layout) { + layout.children.clear(); + + argument.beginStructure(); + argument >> layout.id; + argument >> layout.properties; + + argument.beginArray(); + while (!argument.atEnd()) { + auto childArgument = qdbus_cast(argument).variant().value(); + auto child = qdbus_cast(childArgument); + layout.children.append(child); + } + argument.endArray(); + + argument.endStructure(); + return argument; +} + +const QDBusArgument& operator<<(QDBusArgument& argument, const DBusMenuLayout& layout) { + argument.beginStructure(); + argument << layout.id; + argument << layout.properties; + + argument.beginArray(qMetaTypeId()); + for (const auto& child: layout.children) { + argument << QDBusVariant(QVariant::fromValue(child)); + } + argument.endArray(); + + argument.endStructure(); + return argument; +} + +const QDBusArgument& operator>>(const QDBusArgument& argument, DBusMenuItemProperties& item) { + argument.beginStructure(); + argument >> item.id; + argument >> item.properties; + argument.endStructure(); + return argument; +} + +const QDBusArgument& operator<<(QDBusArgument& argument, const DBusMenuItemProperties& item) { + argument.beginStructure(); + argument << item.id; + argument << item.properties; + argument.endStructure(); + return argument; +} + +const QDBusArgument& operator>>(const QDBusArgument& argument, DBusMenuItemPropertyNames& names) { + argument.beginStructure(); + argument >> names.id; + argument >> names.properties; + argument.endStructure(); + return argument; +} + +const QDBusArgument& operator<<(QDBusArgument& argument, const DBusMenuItemPropertyNames& names) { + argument.beginStructure(); + argument << names.id; + argument << names.properties; + argument.endStructure(); + return argument; +} + +QDebug operator<<(QDebug debug, const DBusMenuLayout& layout) { + debug.nospace() << "DBusMenuLayout(id=" << layout.id << ", properties=" << layout.properties + << ", children=" << layout.children << ")"; + + return debug; +} + +QDebug operator<<(QDebug debug, const DBusMenuItemProperties& item) { + debug.nospace() << "DBusMenuItemProperties(id=" << item.id << ", properties=" << item.properties + << ")"; + return debug; +} + +QDebug operator<<(QDebug debug, const DBusMenuItemPropertyNames& names) { + debug.nospace() << "DBusMenuItemPropertyNames(id=" << names.id + << ", properties=" << names.properties << ")"; + return debug; +} diff --git a/src/dbus/dbusmenu/dbus_menu_types.hpp b/src/dbus/dbusmenu/dbus_menu_types.hpp new file mode 100644 index 00000000..29659497 --- /dev/null +++ b/src/dbus/dbusmenu/dbus_menu_types.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include +#include + +struct DBusMenuLayout { + qint32 id = 0; + QVariantMap properties; + QList children; +}; + +using DBusMenuIdList = QList; + +struct DBusMenuItemProperties { + qint32 id = 0; + QVariantMap properties; +}; + +using DBusMenuItemPropertiesList = QList; + +struct DBusMenuItemPropertyNames { + qint32 id = 0; + QStringList properties; +}; + +using DBusMenuItemPropertyNamesList = QList; + +const QDBusArgument& operator>>(const QDBusArgument& argument, DBusMenuLayout& layout); +const QDBusArgument& operator<<(QDBusArgument& argument, const DBusMenuLayout& layout); +const QDBusArgument& operator>>(const QDBusArgument& argument, DBusMenuItemProperties& item); +const QDBusArgument& operator<<(QDBusArgument& argument, const DBusMenuItemProperties& item); +const QDBusArgument& operator>>(const QDBusArgument& argument, DBusMenuItemPropertyNames& names); +const QDBusArgument& operator<<(QDBusArgument& argument, const DBusMenuItemPropertyNames& names); + +QDebug operator<<(QDebug debug, const DBusMenuLayout& layout); +QDebug operator<<(QDebug debug, const DBusMenuItemProperties& item); +QDebug operator<<(QDebug debug, const DBusMenuItemPropertyNames& names); + +Q_DECLARE_METATYPE(DBusMenuLayout); +Q_DECLARE_METATYPE(DBusMenuIdList); +Q_DECLARE_METATYPE(DBusMenuItemProperties); +Q_DECLARE_METATYPE(DBusMenuItemPropertiesList); +Q_DECLARE_METATYPE(DBusMenuItemPropertyNames); +Q_DECLARE_METATYPE(DBusMenuItemPropertyNamesList); diff --git a/src/dbus/dbusmenu/dbusmenu.cpp b/src/dbus/dbusmenu/dbusmenu.cpp new file mode 100644 index 00000000..186b1330 --- /dev/null +++ b/src/dbus/dbusmenu/dbusmenu.cpp @@ -0,0 +1,573 @@ +#include "dbusmenu.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/iconimageprovider.hpp" +#include "../../core/logcat.hpp" +#include "../../core/model.hpp" +#include "../../core/qsmenu.hpp" +#include "../../dbus/properties.hpp" +#include "dbus_menu.h" +#include "dbus_menu_types.hpp" + +QS_LOGGING_CATEGORY(logDbusMenu, "quickshell.dbus.dbusmenu", QtWarningMsg); + +using namespace qs::menu; + +namespace qs::dbus::dbusmenu { + +DBusMenuItem::DBusMenuItem(qint32 id, DBusMenu* menu, DBusMenuItem* parentMenu) + : QsMenuEntry(menu) + , id(id) + , menu(menu) + , parentMenu(parentMenu) { + 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::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; } +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.value().join(':') + ); + } else if (this->image.hasData()) { + return this->image.url(); + } else return nullptr; +} + +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::setShowChildrenRecursive(bool showChildren) { + if (showChildren == this->mShowChildren) return; + this->mShowChildren = showChildren; + this->childrenLoaded = false; + + if (showChildren) { + this->menu->prepareToShow(this->id, -1); + } else { + if (!this->mChildren.isEmpty()) { + for (auto child: this->mChildren) { + this->menu->removeRecursive(child); + } + + this->mChildren.clear(); + this->onChildrenUpdated(); + } + } +} + +void DBusMenuItem::updateLayout() const { + if (!this->isShowingChildren()) return; + this->menu->updateLayout(this->id, -1); +} + +bool DBusMenuItem::hasChildren() const { return this->displayChildren || this->id == 0; } + +ObjectModel* DBusMenuItem::children() { + return reinterpret_cast*>(&this->enabledChildren); +} + +void DBusMenuItem::updateProperties(const QVariantMap& properties, const QStringList& removed) { + // Some programs appear to think sending an empty map does not mean "reset everything" + // and instead means "do nothing". oh well... + if (properties.isEmpty() && removed.isEmpty()) { + qCDebug(logDbusMenu) << "Ignoring empty property update for" << this; + return; + } + + auto originalText = this->mText; + //auto originalMnemonic = this->mnemonic; + auto originalEnabled = this->mEnabled; + auto originalVisible = this->visible; + auto originalIconName = this->iconName; + auto imageChanged = false; + auto originalIsSeparator = this->mSeparator; + 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->mText = text; + this->mCleanLabel = text; + //this->mnemonic = QChar(); + + 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->mText.remove(i, 1); + this->mText.insert(i + 1, ""); + this->mText.insert(i, ""); + i += 8; + } else { + i++; + } + } + + for (auto i = 0; i < this->mCleanLabel.length() - 1; i++) { + if (this->mCleanLabel.at(i) == '_') { + this->mCleanLabel.remove(i, 1); + } + } + } else if (removed.isEmpty() || removed.contains("label")) { + this->mText = ""; + //this->mnemonic = QChar(); + } + + auto enabled = properties.value("enabled"); + if (enabled.canConvert()) { + this->mEnabled = enabled.value(); + } else if (removed.isEmpty() || removed.contains("enabled")) { + this->mEnabled = true; + } + + auto visible = properties.value("visible"); + if (visible.canConvert()) { + this->visible = visible.value(); + } else if (removed.isEmpty() || removed.contains("visible")) { + this->visible = true; + } + + auto iconName = properties.value("icon-name"); + if (iconName.canConvert()) { + this->iconName = iconName.value(); + } else if (removed.isEmpty() || removed.contains("icon-name")) { + this->iconName = ""; + } + + auto iconData = properties.value("icon-data"); + if (iconData.canConvert()) { + auto data = iconData.value(); + if (data.isEmpty()) { + imageChanged = this->image.hasData(); + this->image.data.clear(); + } else if (!this->image.hasData() || this->image.data != data) { + imageChanged = true; + this->image.data = data; + this->image.imageChanged(); + } + } else if (removed.isEmpty() || removed.contains("icon-data")) { + imageChanged = this->image.hasData(); + image.data.clear(); + } + + auto type = properties.value("type"); + if (type.canConvert()) { + this->mSeparator = type.value() == "separator"; + } else if (removed.isEmpty() || removed.contains("type")) { + this->mSeparator = false; + } + + auto toggleType = properties.value("toggle-type"); + if (toggleType.canConvert()) { + auto toggleTypeStr = toggleType.value(); + + 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->mButtonType = QsMenuButtonType::None; + } + } else if (removed.isEmpty() || removed.contains("toggle-type")) { + this->mButtonType = QsMenuButtonType::None; + } + + auto toggleState = properties.value("toggle-state"); + if (toggleState.canConvert()) { + auto toggleStateInt = toggleState.value(); + + if (toggleStateInt == 0) this->mCheckState = Qt::Unchecked; + else if (toggleStateInt == 1) this->mCheckState = Qt::Checked; + else this->mCheckState = Qt::PartiallyChecked; + } else if (removed.isEmpty() || removed.contains("toggle-state")) { + this->mCheckState = Qt::Unchecked; + } + + auto childrenDisplay = properties.value("children-display"); + if (childrenDisplay.canConvert()) { + auto childrenDisplayStr = childrenDisplay.value(); + + if (childrenDisplayStr == "") this->displayChildren = false; + else if (childrenDisplayStr == "submenu") this->displayChildren = true; + else { + qCWarning(logDbusMenu) << "Unrecognized children-display mode" << childrenDisplayStr << "for" + << this; + this->displayChildren = false; + } + } else if (removed.isEmpty() || removed.contains("children-display")) { + this->displayChildren = false; + } + + 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->mButtonType != originalButtonType) emit this->buttonTypeChanged(); + if (this->mCheckState != originalToggleState) emit this->checkStateChanged(); + if (this->mSeparator != originalIsSeparator) emit this->isSeparatorChanged(); + if (this->displayChildren != originalDisplayChildren) emit this->hasChildrenChanged(); + + if (this->iconName != originalIconName || imageChanged) { + emit this->iconChanged(); + } + + 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->mButtonType + << ", toggleState=" << this->mCheckState + << ", displayChildren=" << this->displayChildren << " }"; +} + +void DBusMenuItem::onChildrenUpdated() { + QVector children; + for (auto child: this->mChildren) { + auto* item = this->menu->items.value(child); + if (item->visible) children.append(item); + } + + this->enabledChildren.diffUpdate(children); +} + +QDebug operator<<(QDebug debug, DBusMenuItem* item) { + if (item == nullptr) { + debug << "DBusMenuItem(nullptr)"; + return debug; + } + + auto saver = QDebugStateSaver(debug); + debug.nospace() << "DBusMenuItem(" << static_cast(item) << ", id=" << item->id + << ", label=" << item->mText << ", menu=" << item->menu << ")"; + return debug; +} + +DBusMenu::DBusMenu(const QString& service, const QString& path, QObject* parent): QObject(parent) { + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + + this->interface = new DBusMenuInterface(service, path, QDBusConnection::sessionBus(), this); + + if (!this->interface->isValid()) { + qCWarning(logDbusMenu).noquote() << "Cannot create DBusMenu for" << service << "at" << path; + return; + } + + QObject::connect( + this->interface, + &DBusMenuInterface::LayoutUpdated, + this, + &DBusMenu::onLayoutUpdated + ); + + this->properties.setInterface(this->interface); + this->properties.updateAllViaGetAll(); +} + +void DBusMenu::prepareToShow(qint32 item, qint32 depth) { + auto pending = this->interface->AboutToShow(item); + auto* call = new QDBusPendingCallWatcher(pending, this); + + auto responseCallback = [this, item, depth](QDBusPendingCallWatcher* call) { + const QDBusPendingReply reply = *call; + if (reply.isError()) { + qCDebug(logDbusMenu) << "Error in AboutToShow, but showing anyway for menu" << item << "of" + << this << reply.error(); + } + + this->updateLayout(item, depth); + + delete call; + }; + + QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); +} + +void DBusMenu::updateLayout(qint32 parent, qint32 depth) { + auto pending = this->interface->GetLayout(parent, depth, QStringList()); + auto* call = new QDBusPendingCallWatcher(pending, this); + + auto responseCallback = [this, parent, depth](QDBusPendingCallWatcher* call) { + const QDBusPendingReply reply = *call; + + if (reply.isError()) { + qCWarning(logDbusMenu) << "Error updating layout for menu" << parent << "of" << this + << reply.error(); + } else { + auto layout = reply.argumentAt<1>(); + this->updateLayoutRecursive(layout, this->items.value(parent), depth); + } + + delete call; + }; + + QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); +} + +void DBusMenu::updateLayoutRecursive( + const DBusMenuLayout& layout, + DBusMenuItem* parent, + qint32 depth +) { + auto* item = this->items.value(layout.id); + if (item == nullptr) { + // 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); + } + } + + if (item == nullptr) return; + + qCDebug(logDbusMenu) << "Updating layout recursively for" << this << "menu" << layout.id; + item->updateProperties(layout.properties); + + if (depth != 0) { + auto childrenChanged = false; + auto iter = item->mChildren.begin(); + while (iter != item->mChildren.end()) { + auto existing = std::ranges::find_if(layout.children, [&](const DBusMenuLayout& layout) { + return layout.id == *iter; + }); + + if (!item->mShowChildren || existing == layout.children.end()) { + qCDebug(logDbusMenu) << "Removing missing layout item" << this->items.value(*iter) << "from" + << item; + this->removeRecursive(*iter); + iter = item->mChildren.erase(iter); + childrenChanged = true; + } else { + iter++; + } + } + + 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); + this->items.insert(child.id, nullptr); + childrenChanged = true; + } + + this->updateLayoutRecursive(child, item, depth - 1); + } + + 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->layoutUpdated(); +} + +void DBusMenu::removeRecursive(qint32 id) { + auto* item = this->items.value(id); + + if (item != nullptr) { + for (auto child: item->mChildren) { + this->removeRecursive(child); + } + } + + this->items.remove(id); + + if (item != nullptr) { + item->deleteLater(); + } +} + +void DBusMenu::sendEvent(qint32 item, const QString& event) { + qCDebug(logDbusMenu) << "Sending event" << event << "to menu" << item << "of" << this; + + auto pending = + this->interface->Event(item, event, QDBusVariant(0), QDateTime::currentSecsSinceEpoch()); + auto* call = new QDBusPendingCallWatcher(pending, this); + + auto responseCallback = [this, item, event](QDBusPendingCallWatcher* call) { + const QDBusPendingReply<> reply = *call; + + if (reply.isError()) { + qCWarning(logDbusMenu) << "Error sending event" << event << "to" << item << "of" << this + << reply.error(); + } + + delete call; + }; + + QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); +} + +DBusMenuItem* DBusMenu::menu() { return &this->rootItem; } + +void DBusMenu::onLayoutUpdated(quint32 /*unused*/, qint32 parent) { + // note: spec says this is recursive + this->updateLayout(parent, -1); +} + +void DBusMenu::onItemPropertiesUpdated( // NOLINT + const DBusMenuItemPropertiesList& updatedProps, + const DBusMenuItemPropertyNamesList& removedProps +) { + for (const auto& propset: updatedProps) { + auto* item = this->items.value(propset.id); + if (item != nullptr) { + item->updateProperties(propset.properties); + } + } + + for (const auto& propset: removedProps) { + auto* item = this->items.value(propset.id); + if (item != nullptr) { + item->updateProperties({}, propset.properties); + } + } +} + +QDebug operator<<(QDebug debug, DBusMenu* menu) { + if (menu == nullptr) { + debug << "DBusMenu(nullptr)"; + return debug; + } + + auto saver = QDebugStateSaver(debug); + debug.nospace() << "DBusMenu(" << static_cast(menu) << ", " << menu->properties.toString() + << ")"; + return debug; +} + +QImage +DBusMenuPngImage::requestImage(const QString& /*unused*/, QSize* size, const QSize& /*unused*/) { + auto image = QImage(); + + if (!image.loadFromData(this->data, "PNG")) { + qCWarning(logDbusMenu) << "Failed to load dbusmenu item png"; + } + + if (size != nullptr) *size = image.size(); + 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 new file mode 100644 index 00000000..1192baaa --- /dev/null +++ b/src/dbus/dbusmenu/dbusmenu.hpp @@ -0,0 +1,196 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#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" + +QS_DECLARE_LOGGING_CATEGORY(logDbusMenu); + +class DBusMenuInterface; + +namespace qs::dbus::dbusmenu { + +// hack because docgen can't take namespaces in superclasses +using menu::QsMenuEntry; + +class DBusMenu; +class DBusMenuItem; + +class DBusMenuPngImage: public QsIndexedImageHandle { +public: + explicit DBusMenuPngImage(): QsIndexedImageHandle(QQuickImageProvider::Image) {} + + [[nodiscard]] bool hasData() const { return !data.isEmpty(); } + QImage requestImage(const QString& id, QSize* size, const QSize& requestedSize) override; + + QByteArray data; +}; + +///! 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 QsMenuEntry { + Q_OBJECT; + /// Handle to the root of this menu. + 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); + + /// Refreshes the menu contents. + /// + /// 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]] 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 setShowChildrenRecursive(bool showChildren); + + [[nodiscard]] ObjectModel* children() override; + + void updateProperties(const QVariantMap& properties, const QStringList& removed = {}); + void onChildrenUpdated(); + + qint32 id = 0; + QString mText; + QVector mChildren; + bool mShowChildren = false; + bool childrenLoaded = false; + DBusMenu* menu = nullptr; + +signals: + void layoutUpdated(); + +private slots: + void sendOpened() const; + void sendClosed() const; + void sendTriggered() const; + +private: + QString mCleanLabel; + //QChar mnemonic; + bool mEnabled = true; + bool visible = true; + bool mSeparator = false; + QString iconName; + DBusMenuPngImage image; + menu::QsMenuButtonType::Enum mButtonType = menu::QsMenuButtonType::None; + Qt::CheckState mCheckState = Qt::Unchecked; + bool displayChildren = false; + ObjectModel enabledChildren {this}; + DBusMenuItem* parentMenu = nullptr; +}; + +QDebug operator<<(QDebug debug, DBusMenuItem* item); + +///! Handle to a DBusMenu tree. +/// Handle to a menu tree provided by a remote process. +class DBusMenu: public QObject { + Q_OBJECT; + 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); + + QS_DBUS_BINDABLE_PROPERTY_GROUP(DBusMenu, properties); + +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); + + DBusMenuItem rootItem {0, this, nullptr}; + QHash items {std::make_pair(0, &this->rootItem)}; + + [[nodiscard]] DBusMenuItem* menu(); + +private slots: + void onLayoutUpdated(quint32 revision, qint32 parent); + void onItemPropertiesUpdated( + const DBusMenuItemPropertiesList& updatedProps, + const DBusMenuItemPropertyNamesList& removedProps + ); + +private: + void updateLayoutRecursive(const DBusMenuLayout& layout, DBusMenuItem* parent, qint32 depth); + + QS_DBUS_PROPERTY_BINDING( + DBusMenu, + pIconThemePath, + iconThemePath, + properties, + "IconThemePath", + false + ); + + DBusMenuInterface* interface = nullptr; +}; + +QDebug operator<<(QDebug debug, DBusMenu* menu); + +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/dbusmenu/module.md b/src/dbus/dbusmenu/module.md new file mode 100644 index 00000000..810393e4 --- /dev/null +++ b/src/dbus/dbusmenu/module.md @@ -0,0 +1,4 @@ +name = "Quickshell.DBusMenu" +description = "Types related to DBusMenu (used in system tray)" +headers = [ "dbusmenu.hpp" ] +----- diff --git a/src/dbus/objectmanager.cpp b/src/dbus/objectmanager.cpp new file mode 100644 index 00000000..258f6fe8 --- /dev/null +++ b/src/dbus/objectmanager.cpp @@ -0,0 +1,87 @@ +#include "objectmanager.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/logcat.hpp" +#include "dbus_objectmanager.h" +#include "dbus_objectmanager_types.hpp" + +namespace { +QS_LOGGING_CATEGORY(logDbusObjectManager, "quickshell.dbus.objectmanager", QtWarningMsg); +} + +namespace qs::dbus { + +DBusObjectManager::DBusObjectManager(QObject* parent): QObject(parent) { + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); +} + +bool DBusObjectManager::setInterface( + const QString& service, + const QString& path, + const QDBusConnection& connection +) { + delete this->mInterface; + this->mInterface = new DBusObjectManagerInterface(service, path, connection, this); + + if (!this->mInterface->isValid()) { + qCWarning(logDbusObjectManager) << "Failed to create DBusObjectManagerInterface for" << service + << path << ":" << this->mInterface->lastError(); + delete this->mInterface; + this->mInterface = nullptr; + return false; + } + + QObject::connect( + this->mInterface, + &DBusObjectManagerInterface::InterfacesAdded, + this, + &DBusObjectManager::interfacesAdded + ); + + QObject::connect( + this->mInterface, + &DBusObjectManagerInterface::InterfacesRemoved, + this, + &DBusObjectManager::interfacesRemoved + ); + + this->fetchInitialObjects(); + return true; +} + +void DBusObjectManager::fetchInitialObjects() { + if (!this->mInterface) return; + + auto reply = this->mInterface->GetManagedObjects(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply reply = *watcher; + watcher->deleteLater(); + + if (reply.isError()) { + qCWarning(logDbusObjectManager) << "Failed to get managed objects:" << reply.error(); + return; + } + + for (const auto& [path, interfaces]: reply.value().asKeyValueRange()) { + emit this->interfacesAdded(path, interfaces); + } + } + ); +} + +} // namespace qs::dbus diff --git a/src/dbus/objectmanager.hpp b/src/dbus/objectmanager.hpp new file mode 100644 index 00000000..4246ea28 --- /dev/null +++ b/src/dbus/objectmanager.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include + +#include "dbus_objectmanager_types.hpp" + +class DBusObjectManagerInterface; + +namespace qs::dbus { + +class DBusObjectManager: public QObject { + Q_OBJECT; + +public: + explicit DBusObjectManager(QObject* parent = nullptr); + + bool setInterface( + const QString& service, + const QString& path, + const QDBusConnection& connection = QDBusConnection::sessionBus() + ); + +signals: + void + interfacesAdded(const QDBusObjectPath& objectPath, const DBusObjectManagerInterfaces& interfaces); + void interfacesRemoved(const QDBusObjectPath& objectPath, const QStringList& interfaces); + +private: + void fetchInitialObjects(); + + DBusObjectManagerInterface* mInterface = nullptr; +}; + +} // namespace qs::dbus \ No newline at end of file diff --git a/src/dbus/org.freedesktop.DBus.ObjectManager.xml b/src/dbus/org.freedesktop.DBus.ObjectManager.xml new file mode 100644 index 00000000..24749f22 --- /dev/null +++ b/src/dbus/org.freedesktop.DBus.ObjectManager.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/dbus/org.freedesktop.DBus.Properties.xml b/src/dbus/org.freedesktop.DBus.Properties.xml new file mode 100644 index 00000000..021123ab --- /dev/null +++ b/src/dbus/org.freedesktop.DBus.Properties.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/dbus/properties.cpp b/src/dbus/properties.cpp new file mode 100644 index 00000000..d0f65d99 --- /dev/null +++ b/src/dbus/properties.cpp @@ -0,0 +1,342 @@ +#include "properties.hpp" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/logcat.hpp" +#include "dbus_properties.h" + +QS_LOGGING_CATEGORY(logDbusProperties, "quickshell.dbus.properties", QtWarningMsg); + +namespace qs::dbus { + +QDBusError demarshallVariant(const QVariant& variant, const QMetaType& type, void* slot) { + const char* expectedSignature = "v"; + + if (type.id() != QMetaType::QVariant) { + expectedSignature = QDBusMetaType::typeToSignature(type); + if (expectedSignature == nullptr) { + qFatal() << "failed to demarshall unregistered dbus meta-type" << type << "with" << variant; + } + } + + if (variant.metaType() == type) { + if (type.id() == QMetaType::QVariant) { + *reinterpret_cast(slot) = variant; + } else { + type.destruct(slot); + type.construct(slot, variant.constData()); + } + } else if (variant.metaType() == QMetaType::fromType()) { + auto arg = qvariant_cast(variant); + auto signature = arg.currentSignature(); + + if (signature == expectedSignature) { + if (!QDBusMetaType::demarshall(arg, type, slot)) { + QString error; + QDebug(&error) << "failed to deserialize dbus value" << variant << "into" << type; + return QDBusError(QDBusError::InvalidArgs, error); + } + } else { + QString error; + QDebug(&error) << "mismatched signature while trying to demarshall" << variant << "into" + << type << "expected" << expectedSignature << "got" << signature; + return QDBusError(QDBusError::InvalidArgs, error); + } + } else { + QString error; + QDebug(&error) << "failed to deserialize variant" << variant << "into" << type; + return QDBusError(QDBusError::InvalidArgs, error); + } + + return QDBusError(); +} + +void asyncReadPropertyInternal( + const QMetaType& type, + QDBusAbstractInterface& interface, + const QString& property, + std::function)> callback // NOLINT +) { + if (type.id() != QMetaType::QVariant) { + const char* expectedSignature = QDBusMetaType::typeToSignature(type); + if (expectedSignature == nullptr) { + qFatal() << "qs::dbus::asyncReadPropertyInternal called with unregistered dbus meta-type" + << type; + } + } + + auto callMessage = QDBusMessage::createMethodCall( + interface.service(), + interface.path(), + "org.freedesktop.DBus.Properties", + "Get" + ); + + callMessage << interface.interface() << property; + auto pendingCall = interface.connection().asyncCall(callMessage); + + auto* call = new QDBusPendingCallWatcher(pendingCall, &interface); + + auto responseCallback = [type, callback](QDBusPendingCallWatcher* call) { + QDBusPendingReply reply = *call; + + callback([&](void* slot) { + if (reply.isError()) { + return reply.error(); + } else { + return demarshallVariant(reply.value().variant(), type, slot); + } + }); + + delete call; + }; + + QObject::connect(call, &QDBusPendingCallWatcher::finished, &interface, responseCallback); +} + +DBusPropertyGroup::DBusPropertyGroup(QVector properties, QObject* parent) + : QObject(parent) + , properties(std::move(properties)) {} + +void DBusPropertyGroup::setInterface(QDBusAbstractInterface* interface) { + if (this->interface != nullptr) { + delete this->propertyInterface; + this->propertyInterface = nullptr; + } + + if (interface != nullptr) { + this->interface = interface; + + this->propertyInterface = new DBusPropertiesInterface( + interface->service(), + interface->path(), + interface->connection(), + this + ); + + QObject::connect( + this->propertyInterface, + &DBusPropertiesInterface::PropertiesChanged, + this, + &DBusPropertyGroup::onPropertiesChanged + ); + } +} + +void DBusPropertyGroup::attachProperty(DBusPropertyCore* property) { + this->properties.append(property); +} + +void DBusPropertyGroup::updateAllDirect() { + qCDebug(logDbusProperties).noquote() + << "Updating all properties of" << this->toString() << "via individual queries"; + + if (this->interface == nullptr) { + qFatal() << "Attempted to update properties of disconnected property group"; + } + + for (auto* property: this->properties) { + this->requestPropertyUpdate(property); + } +} + +void DBusPropertyGroup::updateAllViaGetAll() { + qCDebug(logDbusProperties).noquote() + << "Updating all properties of" << this->toString() << "via GetAll"; + + if (this->interface == nullptr) { + qFatal() << "Attempted to update properties of disconnected property group"; + } + + auto pendingCall = this->propertyInterface->GetAll(this->interface->interface()); + auto* call = new QDBusPendingCallWatcher(pendingCall, this); + + auto responseCallback = [this](QDBusPendingCallWatcher* call) { + const QDBusPendingReply reply = *call; + + if (reply.isError()) { + 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(), true); + emit this->getAllFinished(); + } + + delete call; + }; + + QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); +} + +void DBusPropertyGroup::updatePropertySet(const QVariantMap& properties, bool complainMissing) { + for (const auto [name, value]: properties.asKeyValueRange()) { + auto prop = std::ranges::find_if(this->properties, [&name](DBusPropertyCore* prop) { + return prop->nameRef() == name; + }); + + if (prop == this->properties.end()) { + qCDebug(logDbusProperties) << "Ignoring untracked property update" << name << "for" + << this->toString(); + } else { + 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()) { + if (!property->isRequired() && reply.error().type() == QDBusError::InvalidArgs) { + qCDebug(logDbusProperties) << "Error updating non-required property" << propStr; + qCDebug(logDbusProperties) << reply.error(); + } else { + 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 { + if (this->interface == nullptr) { + return "{ DISCONNECTED }"; + } else { + return this->interface->service() + this->interface->path() + "/" + + this->interface->interface(); + } +} + +QString DBusPropertyGroup::propertyString(const DBusPropertyCore* property) const { + return this->toString() % ':' % property->nameRef(); +} + +void DBusPropertyGroup::onPropertiesChanged( + const QString& interfaceName, + const QVariantMap& changedProperties, + const QStringList& invalidatedProperties +) { + if (interfaceName != this->interface->interface()) return; + qCDebug(logDbusProperties).noquote() + << "Received property change set and invalidations for" << this->toString(); + + for (const auto& name: invalidatedProperties) { + auto prop = std::ranges::find_if(this->properties, [&name](DBusPropertyCore* prop) { + return prop->nameRef() == name; + }); + + if (prop == this->properties.end()) { + qCDebug(logDbusProperties) << "Ignoring untracked property invalidation" << name << "for" + << this; + } else { + this->requestPropertyUpdate(*prop); + } + } + + this->updatePropertySet(changedProperties, false); +} + +} // namespace qs::dbus + +#if QT_VERSION < QT_VERSION_CHECK(6, 8, 0) +QDebug operator<<(QDebug debug, const QDBusObjectPath& path) { + debug.nospace() << "QDBusObjectPath(" << path.path() << ")"; + return debug; +} +#endif diff --git a/src/dbus/properties.hpp b/src/dbus/properties.hpp new file mode 100644 index 00000000..f6a63300 --- /dev/null +++ b/src/dbus/properties.hpp @@ -0,0 +1,337 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/logcat.hpp" +#include "../core/util.hpp" + +class DBusPropertiesInterface; + +QS_DECLARE_LOGGING_CATEGORY(logDbusProperties); + +namespace qs::dbus { + +QDBusError demarshallVariant(const QVariant& variant, const QMetaType& type, void* slot); + +template +class DBusResult { +public: + explicit DBusResult() = default; + DBusResult(T value): value(std::move(value)) {} + DBusResult(QDBusError error): error(std::move(error)) {} + explicit DBusResult(T value, QDBusError error) + : value(std::move(value)) + , error(std::move(error)) {} + + bool isValid() { return !this->error.isValid(); } + + T value {}; + QDBusError error; +}; + +template +DBusResult demarshallVariant(const QVariant& variant) { + T value; + auto error = demarshallVariant(variant, QMetaType::fromType(), &value); + return DBusResult(value, error); +} + +void asyncReadPropertyInternal( + const QMetaType& type, + QDBusAbstractInterface& interface, + const QString& property, + std::function)> callback +); + +template +void asyncReadProperty( + QDBusAbstractInterface& interface, + const QString& property, + const std::function& callback +) { + asyncReadPropertyInternal( + QMetaType::fromType(), + interface, + property, + [callback](std::function internalCallback) { // NOLINT + T slot; + auto error = internalCallback(static_cast(&slot)); + callback(slot, error); + } + ); +} + +class DBusPropertyGroup; + +class DBusPropertyCore { +public: + DBusPropertyCore() = default; + virtual ~DBusPropertyCore() = default; + Q_DISABLE_COPY_MOVE(DBusPropertyCore); + + [[nodiscard]] virtual QString name() const = 0; + [[nodiscard]] virtual QStringView nameRef() const = 0; + [[nodiscard]] virtual QString valueString() = 0; + [[nodiscard]] virtual bool isRequired() const = 0; + [[nodiscard]] bool exists() const { return this->mExists; } + +protected: + virtual QDBusError store(const QVariant& variant) = 0; + [[nodiscard]] virtual QVariant serialize() = 0; + +private: + 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