From da57debd66fe92b181ad8b05593feb3edd5b2e58 Mon Sep 17 00:00:00 2001 From: Toast Date: Mon, 3 Mar 2025 20:34:33 +0100 Subject: [PATCH 1/4] Kde: load patches automatically --- roles/kde/default.nix | 1 + roles/kde/patches/default.nix | 30 ++++++++++++++++++++++++++++++ roles/kde/programs/konsole.nix | 13 ------------- 3 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 roles/kde/patches/default.nix diff --git a/roles/kde/default.nix b/roles/kde/default.nix index 363920a..f80328a 100755 --- a/roles/kde/default.nix +++ b/roles/kde/default.nix @@ -3,5 +3,6 @@ ./plasma.nix ./sddm.nix ./programs + ./patches ]; } diff --git a/roles/kde/patches/default.nix b/roles/kde/patches/default.nix new file mode 100644 index 0000000..1e14271 --- /dev/null +++ b/roles/kde/patches/default.nix @@ -0,0 +1,30 @@ +{lib, ...}: let + rootDirs = builtins.readDir ./.; + removeFiles = lib.attrsets.filterAttrs (n: v: v == "directory") rootDirs; + + getPatches = name: + builtins.map (value: ./${name}/${value}) (builtins.attrNames ( + lib.attrsets.filterAttrs ( + n: v: + v == "regular" && lib.strings.hasSuffix ".patch" n + ) (builtins.readDir ./${name}) + )); + + bigOverlay = final: prev: + builtins.mapAttrs ( + name: _value: + prev."${name}".overrideAttrs { + version = prev."${name}".version + "-patched"; + patches = prev."${name}".patches ++ getPatches name; + } + ) + removeFiles; +in { + nixpkgs.overlays = [ + ( + final: prev: { + kdePackages = prev.kdePackages.overrideScope bigOverlay; + } + ) + ]; +} diff --git a/roles/kde/programs/konsole.nix b/roles/kde/programs/konsole.nix index 25040e8..ef3b429 100644 --- a/roles/kde/programs/konsole.nix +++ b/roles/kde/programs/konsole.nix @@ -16,17 +16,4 @@ in { }; }; }; - nixpkgs.overlays = [ - ( - final: prev: { - kdePackages = prev.kdePackages.overrideScope ( - kFinal: kPrev: { - konsole = kPrev.konsole.overrideAttrs { - patches = []; - }; - } - ); - } - ) - ]; } From f3ee33177f38cfb2d76ec5fcc81b220b32625325 Mon Sep 17 00:00:00 2001 From: Toast Date: Tue, 4 Mar 2025 14:27:20 +0100 Subject: [PATCH 2/4] Kde: fix qt wayland --- roles/kde/plasma.nix | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/roles/kde/plasma.nix b/roles/kde/plasma.nix index 00c4099..728b560 100644 --- a/roles/kde/plasma.nix +++ b/roles/kde/plasma.nix @@ -37,6 +37,34 @@ in { # Enable the Plasma 6 Desktop Environment services.desktopManager.plasma6.enable = true; + # Same as https://github.com/NixOS/nixpkgs/pull/386932 + nixpkgs.overlays = [ + ( + final: prev: { + kdePackages = prev.kdePackages.overrideScope ( + kFinal: kPrev: { + qtbase-vulkan = kPrev.qtbase.overrideAttrs { + postFixup = '' + moveToOutput "mkspecs/modules" "$dev" + fixQtModulePaths "$dev/mkspecs/modules" + fixQtBuiltinPaths "$out" '*.pr?' + patchelf --add-rpath "${final.libmysqlclient}/lib/mariadb" $out/lib/qt-6/plugins/sqldrivers/libqsqlmysql.so + patchelf --add-rpath "${final.vulkan-loader}/lib" --add-needed "libvulkan.so" $out/lib/libQt6Gui.so + ''; + }; + } + ); + } + ) + ]; + + system.replaceDependencies.replacements = with pkgs.kdePackages; [ + { + oldDependency = qtbase; + newDependency = qtbase-vulkan; + } + ]; + qt.enable = true; # GTK apps need dconf to grab the correct theme on Wayland From 92a6c20c18ed1419741d3eff4faa350fbcefca97 Mon Sep 17 00:00:00 2001 From: Toast Date: Tue, 4 Mar 2025 14:31:15 +0100 Subject: [PATCH 3/4] Kde: add kinfocenter patches --- roles/kde/patches/kinfocenter/00-pr238.patch | 72 ++++ roles/kde/patches/kinfocenter/pr234.patch | 426 +++++++++++++++++++ 2 files changed, 498 insertions(+) create mode 100644 roles/kde/patches/kinfocenter/00-pr238.patch create mode 100644 roles/kde/patches/kinfocenter/pr234.patch diff --git a/roles/kde/patches/kinfocenter/00-pr238.patch b/roles/kde/patches/kinfocenter/00-pr238.patch new file mode 100644 index 0000000..a199e2b --- /dev/null +++ b/roles/kde/patches/kinfocenter/00-pr238.patch @@ -0,0 +1,72 @@ +From 7cc7fe9783a68e086369bd0b96b280082097d60a Mon Sep 17 00:00:00 2001 +From: Oliver Beard +Date: Thu, 20 Feb 2025 22:49:25 +0000 +Subject: [PATCH] kcms/about-distro: Fix hint expanding height of parent layout + Instead, use an label with padding to match the height of normal text. A + background is used to fill this padded area. + +If smallFont is larger than normal text, the text will draw outside the label which would be restricted to the same height as normal text by negative padding. I don't see this as worth fixing - if the small font is larger than normal text, that would be the problem, not this... + +When valueLabel spans multiple lines, it will be vertically centered. + +CCBUG: 500355 +--- + kcms/about-distro/src/ui/main.qml | 35 +++++++++++++++++++------------ + 1 file changed, 22 insertions(+), 13 deletions(-) + +diff --git a/kcms/about-distro/src/ui/main.qml b/kcms/about-distro/src/ui/main.qml +index 7a3f72bfb..80fbc2c13 100644 +--- a/kcms/about-distro/src/ui/main.qml ++++ b/kcms/about-distro/src/ui/main.qml +@@ -136,26 +136,35 @@ KCMUtils.SimpleKCM { + } + } + +- QQC2.Control { ++ QQC2.Label { ++ Kirigami.Theme.colorSet: Kirigami.Theme.Window + visible: hint !== "" +- topPadding: Kirigami.Units.smallSpacing +- rightPadding: Kirigami.Units.smallSpacing +- bottomPadding: Kirigami.Units.smallSpacing +- leftPadding: Kirigami.Units.smallSpacing + +- Kirigami.Theme.colorSet: Kirigami.Theme.Window ++ // Vertical padding accounts for the difference in normal label height and the content height of this small label ++ readonly property real verticalPadding: (hintMetrics.height - contentHeight) / 2 ++ // Horizontal padding also accounts for the difference in content height and the font's pixelSize to better balance the text ++ readonly property real horizontalPadding: ((hintMetrics.height - contentHeight) + (contentHeight - font.pixelSize)) / 2 ++ ++ TextMetrics { ++ // Necessary as valueLabel could be multiple lines ++ id: hintMetrics ++ text: " " ++ } ++ ++ topPadding: verticalPadding ++ bottomPadding: verticalPadding ++ leftPadding: horizontalPadding ++ rightPadding: horizontalPadding ++ ++ text: hint ++ color: hintColorForeground ++ font.bold: true ++ font.pixelSize: Kirigami.Theme.smallFont.pixelSize + + background: Rectangle { + color: hintColorBackground + radius: Kirigami.Units.cornerRadius + } +- +- contentItem: QQC2.Label { +- text: hint +- color: hintColorForeground +- font.bold: true +- font.pixelSize: Kirigami.Theme.smallFont.pixelSize +- } + } + + QQC2.Button { +-- +GitLab + diff --git a/roles/kde/patches/kinfocenter/pr234.patch b/roles/kde/patches/kinfocenter/pr234.patch new file mode 100644 index 0000000..e683a76 --- /dev/null +++ b/roles/kde/patches/kinfocenter/pr234.patch @@ -0,0 +1,426 @@ +From f44af69b07ed19d076819fe4cc84e5777747d957 Mon Sep 17 00:00:00 2001 +From: Oliver Beard +Date: Thu, 20 Feb 2025 22:43:47 +0000 +Subject: [PATCH 1/2] kcms/about-distro: Add help property to Entry & show + total amount of installed memory in MemoryEntry This provides additional + information to the user, with a new help tooltip that clarifies the displayed + values as is contextually appropriate. For example, if the shown message is + "32 GB of RAM (31.3 GB usable)", the tooltip will elucidate that some memory + is reserved for use by system hardware. BUG: 500412 + +--- + CMakeLists.txt | 4 + + kcms/about-distro/src/CMakeLists.txt | 5 + + kcms/about-distro/src/Entry.cpp | 5 + + kcms/about-distro/src/Entry.h | 3 + + kcms/about-distro/src/MemoryEntry.cpp | 144 +++++++++++++++++++++++--- + kcms/about-distro/src/MemoryEntry.h | 9 +- + kcms/about-distro/src/ui/main.qml | 5 + + 7 files changed, 157 insertions(+), 18 deletions(-) + +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 51f940789..e3005878a 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -41,6 +41,10 @@ find_package(KF6 ${KF6_MIN_VERSION} REQUIRED COMPONENTS + find_package(PkgConfig) + pkg_check_modules(libdrm REQUIRED IMPORTED_TARGET libdrm) + ++if(CMAKE_SYSTEM_NAME MATCHES "Linux") ++ find_package(UDev REQUIRED COMPONENTS UDev) ++endif() ++ + ecm_find_qmlmodule(org.kde.kirigami 2.5) + + macro(kinfocenter_add_kcm target) +diff --git a/kcms/about-distro/src/CMakeLists.txt b/kcms/about-distro/src/CMakeLists.txt +index 13ad8d0af..d731d81a1 100644 +--- a/kcms/about-distro/src/CMakeLists.txt ++++ b/kcms/about-distro/src/CMakeLists.txt +@@ -43,6 +43,11 @@ target_link_libraries(kcm_about-distro PRIVATE + PkgConfig::libdrm + ) + ++if(UDev_FOUND) ++ target_link_libraries(kcm_about-distro PRIVATE UDev::UDev) ++ target_compile_definitions(kcm_about-distro PRIVATE UDEV_FOUND) ++endif() ++ + cmake_path(RELATIVE_PATH KDE_INSTALL_FULL_LIBEXECDIR BASE_DIRECTORY "${KDE_INSTALL_FULL_PLUGINDIR}/plasma/kcms/" OUTPUT_VARIABLE LIBEXECDIR_FROM_KCM) + + target_compile_options( +diff --git a/kcms/about-distro/src/Entry.cpp b/kcms/about-distro/src/Entry.cpp +index a4077efda..63dc71fbb 100644 +--- a/kcms/about-distro/src/Entry.cpp ++++ b/kcms/about-distro/src/Entry.cpp +@@ -82,4 +82,9 @@ Hint Entry::localizedHint(Language) const + return {}; + } + ++QString Entry::localizedHelp(Language) const ++{ ++ return {}; ++} ++ + #include "moc_Entry.cpp" +diff --git a/kcms/about-distro/src/Entry.h b/kcms/about-distro/src/Entry.h +index e5c3f6f17..bc053a4f4 100644 +--- a/kcms/about-distro/src/Entry.h ++++ b/kcms/about-distro/src/Entry.h +@@ -78,6 +78,9 @@ public: + // Returns a hint for the user to consider when interpreting the value. + Q_INVOKABLE [[nodiscard]] virtual Hint localizedHint(Language language = Language::System) const; + ++ // Returns a help string for the entry, shown with a ContextualHelpButton ++ Q_SCRIPTABLE [[nodiscard]] virtual QString localizedHelp(Language language = Language::System) const; ++ + protected: + // Returns localized QString for the given language. + QString localize(const KLocalizedString &string, Language language) const; +diff --git a/kcms/about-distro/src/MemoryEntry.cpp b/kcms/about-distro/src/MemoryEntry.cpp +index 1baaea2ac..b58b55237 100644 +--- a/kcms/about-distro/src/MemoryEntry.cpp ++++ b/kcms/about-distro/src/MemoryEntry.cpp +@@ -9,6 +9,9 @@ + + #ifdef Q_OS_LINUX + #include ++#ifdef UDEV_FOUND ++#include ++#endif + #elif defined(Q_OS_FREEBSD) + // clang-format off + #include +@@ -21,34 +24,141 @@ MemoryEntry::MemoryEntry() + { + } + +-qlonglong MemoryEntry::calculateTotalRam() ++std::optional MemoryEntry::calculateTotalRam() ++{ ++#if defined(Q_OS_LINUX) && defined(UDEV_FOUND) ++ std::unique_ptr udev(udev_new(), &udev_unref); ++ if (!udev) { ++ return {}; ++ } ++ ++ std::unique_ptr dmi(udev_device_new_from_syspath(udev.get(), "/sys/class/dmi/id/"), &udev_device_unref); ++ if (!dmi) { ++ return {}; ++ } ++ ++ const char *numMemoryDevicesCStr = udev_device_get_property_value(dmi.get(), "MEMORY_ARRAY_NUM_DEVICES"); ++ if (!numMemoryDevicesCStr) { ++ return {}; ++ } ++ ++ bool ok; ++ int numMemoryDevices = QByteArray(numMemoryDevicesCStr).toInt(&ok); ++ if (!ok) { ++ return {}; ++ } ++ ++ qlonglong totalBytes = 0; ++ for (int i = 0; i < numMemoryDevices; ++i) { ++ const char *memoryBytesCStr = udev_device_get_property_value(dmi.get(), QStringLiteral("MEMORY_DEVICE_%1_SIZE").arg(i).toLatin1()); ++ qlonglong memoryBytes = QByteArray(memoryBytesCStr).toLongLong(&ok); ++ if (ok) { ++ totalBytes += memoryBytes; ++ } ++ } ++ ++ return totalBytes; ++#endif ++ ++ /* ++ * TODO: A FreeBSD impl is likely possible, but it appears that ++ * sysctlbyname() cannot get what we want with either "hw.physmem", ++ * "hw.usermem" or "hw.realmem". ++ * On a system with 2 x 4 GiB memory modules installed, we would need ++ * to return a value of 8 GiB in bytes. ++ */ ++ ++ return {}; ++} ++ ++std::optional MemoryEntry::calculateAvailableRam() + { +- qlonglong ret = -1; + #ifdef Q_OS_LINUX + struct sysinfo info; +- if (sysinfo(&info) == 0) +- // manpage "sizes are given as multiples of mem_unit bytes" +- ret = qlonglong(info.totalram) * info.mem_unit; ++ if (sysinfo(&info) == 0) { ++ // manpage: "sizes are given as multiples of mem_unit bytes" ++ return qlonglong(info.totalram) * info.mem_unit; ++ } + #elif defined(Q_OS_FREEBSD) + /* Stuff for sysctl */ +- size_t len; +- + unsigned long memory; +- len = sizeof(memory); +- sysctlbyname("hw.physmem", &memory, &len, NULL, 0); +- +- ret = memory; ++ size_t len = sizeof(memory); ++ if (sysctlbyname("hw.physmem", &memory, &len, NULL, 0) == 0) { ++ return memory; ++ } + #endif +- return ret; ++ ++ return {}; + } + + QString MemoryEntry::localizedValue(Language language) const + { +- const qlonglong totalRam = calculateTotalRam(); +- if (totalRam > 0) { +- const auto string = ki18nc("@label %1 is the formatted amount of system memory (e.g. 7,7 GiB)", "%1 of RAM") +- .subs(KFormat(localeForLanguage(language)).formatByteSize(totalRam)); ++ auto precisionForGiB = [](std::optional bytes) -> int { ++ if (!bytes.has_value()) { ++ return 0; ++ } ++ ++ constexpr qlonglong GiB = 1024 * 1024 * 1024; ++ return (bytes.value() % GiB == 0) ? 0 : 1; ++ }; ++ ++ const int totalRamPrecision = precisionForGiB(m_totalRam); ++ const int availableRamPrecision = precisionForGiB(m_availableRam); ++ ++ if (m_totalRam.has_value() && m_availableRam.has_value()) { ++ // Both known ++ const auto string = ki18nc("@label, %1 is the total amount of installed system memory, %2 is the amount of which is usable, both expressed as 7.7 GiB", ++ "%1 of RAM (%2 usable)") ++ .subs(KFormat(localeForLanguage(language)).formatByteSize(m_totalRam.value(), totalRamPrecision)) ++ .subs(KFormat(localeForLanguage(language)).formatByteSize(m_availableRam.value(), availableRamPrecision)); ++ return localize(string, language); ++ } ++ ++ if (m_totalRam.has_value() && !m_availableRam.has_value()) { ++ // Known total, unknown available ++ const auto string = ki18nc("@label, %1 is the amount of installed system memory expressed as 7.7 GiB", "%1 of RAM") ++ .subs(KFormat(localeForLanguage(language)).formatByteSize(m_totalRam.value(), totalRamPrecision)); ++ return localize(string, language); ++ } ++ ++ if (!m_totalRam.has_value() && m_availableRam.has_value()) { ++ // Unknown total, known available ++ const auto string = ki18nc("@label, %1 is the amount of usable system memory expressed as 7.7 GiB", "%1 of usable RAM") ++ .subs(KFormat(localeForLanguage(language)).formatByteSize(m_availableRam.value(), availableRamPrecision)); + return localize(string, language); + } +- return localize(ki18nc("Unknown amount of RAM", "Unknown"), language); ++ ++ // Both unknown ++ return localize(ki18nc("@label, Unknown amount of system memory", "Unknown"), language); ++} ++ ++QString MemoryEntry::localizedHelp(Language language) const ++{ ++ if (m_totalRam.has_value() && m_availableRam.has_value()) { ++ // Both known ++ return localize(ki18nc("@info:tooltip, referring to system memory or RAM", ++ "Some memory is reserved for use by the kernel or system hardware such as integrated graphics memory."), ++ language); ++ } ++ ++ if (m_totalRam.has_value() && !m_availableRam.has_value()) { ++ // Known total, unknown available ++ return localize( ++ ki18nc("@info:tooltip, referring to system memory or RAM", ++ "The amount of usable memory may be lower than the displayed amount because some memory is reserved for use by the kernel or system " ++ "hardware, such as integrated graphics memory."), ++ language); ++ } ++ ++ if (!m_totalRam.has_value() && m_availableRam.has_value()) { ++ // Unknown total, known available ++ return localize( ++ ki18nc("@info:tooltip, referring to system memory or RAM", ++ "The amount of memory displayed may be lower than the installed amount because some memory is reserved for use by the kernel or system " ++ "hardware, such as integrated graphics memory."), ++ language); ++ } ++ ++ // Both unknown ++ return QString(); + } +diff --git a/kcms/about-distro/src/MemoryEntry.h b/kcms/about-distro/src/MemoryEntry.h +index 43beb2e87..d0757651f 100644 +--- a/kcms/about-distro/src/MemoryEntry.h ++++ b/kcms/about-distro/src/MemoryEntry.h +@@ -12,10 +12,17 @@ class MemoryEntry : public Entry + { + public: + MemoryEntry(); +- static qlonglong calculateTotalRam(); + + // Overwrite to get correct localization for the value. + QString localizedValue(Language language = Language::System) const final; ++ QString localizedHelp(Language language = Language::System) const final; ++ ++private: ++ static std::optional calculateTotalRam(); ++ static std::optional calculateAvailableRam(); ++ ++ std::optional m_totalRam = calculateTotalRam(); ++ std::optional m_availableRam = calculateAvailableRam(); + }; + + #endif // MEMORYENTRY_H +diff --git a/kcms/about-distro/src/ui/main.qml b/kcms/about-distro/src/ui/main.qml +index 80fbc2c13..e80b7fe93 100644 +--- a/kcms/about-distro/src/ui/main.qml ++++ b/kcms/about-distro/src/ui/main.qml +@@ -167,6 +167,11 @@ KCMUtils.SimpleKCM { + } + } + ++ Kirigami.ContextualHelpButton { ++ visible: toolTipText.length > 0 ++ toolTipText: entry.localizedHelp() ++ } ++ + QQC2.Button { + visible: hidden + property var dialog: null +-- +GitLab + + +From fc2e540dc6f4784c2602a520f4b3285355213f5a Mon Sep 17 00:00:00 2001 +From: Oliver Beard +Date: Mon, 24 Feb 2025 23:17:48 +0000 +Subject: [PATCH 2/2] kcms/about-distro: Clean-up and refactoring - Spacing + specified on RowLayout - Instead of specifying properties up-front for each + entry, entries use them as needed. - Use .length > 0 instead of !== "" - + [[nodiscard]] and Q_INVOKABLE specified on virtual Entry methods + +--- + kcms/about-distro/src/Entry.h | 8 +++--- + kcms/about-distro/src/ui/main.qml | 42 +++++++++++++++---------------- + 2 files changed, 25 insertions(+), 25 deletions(-) + +diff --git a/kcms/about-distro/src/Entry.h b/kcms/about-distro/src/Entry.h +index bc053a4f4..3c7dd8491 100644 +--- a/kcms/about-distro/src/Entry.h ++++ b/kcms/about-distro/src/Entry.h +@@ -66,20 +66,20 @@ public: + // Returns textual representation of entry. + QString diagnosticLine(Language language = Language::System) const; + +- Q_SCRIPTABLE virtual QString localizedLabel(Language language = Language::System) const; ++ Q_INVOKABLE [[nodiscard]] virtual QString localizedLabel(Language language = Language::System) const; + + // Returns the value by default. Needs to be overridden in subclasses if localization + // is needed for the value. +- Q_SCRIPTABLE virtual QString localizedValue(Language language = Language::System) const; ++ Q_INVOKABLE [[nodiscard]] virtual QString localizedValue(Language language = Language::System) const; + + // Returns whether this Entry should be hidden by default (i.e. only shown upon user request) +- Q_INVOKABLE virtual bool isHidden() const; ++ Q_INVOKABLE [[nodiscard]] virtual bool isHidden() const; + + // Returns a hint for the user to consider when interpreting the value. + Q_INVOKABLE [[nodiscard]] virtual Hint localizedHint(Language language = Language::System) const; + + // Returns a help string for the entry, shown with a ContextualHelpButton +- Q_SCRIPTABLE [[nodiscard]] virtual QString localizedHelp(Language language = Language::System) const; ++ Q_INVOKABLE [[nodiscard]] virtual QString localizedHelp(Language language = Language::System) const; + + protected: + // Returns localized QString for the given language. +diff --git a/kcms/about-distro/src/ui/main.qml b/kcms/about-distro/src/ui/main.qml +index e80b7fe93..547f4b665 100644 +--- a/kcms/about-distro/src/ui/main.qml ++++ b/kcms/about-distro/src/ui/main.qml +@@ -54,14 +54,14 @@ KCMUtils.SimpleKCM { + } + + Kirigami.Heading { +- visible: kcm.distroVariant !== "" ++ visible: kcm.distroVariant.length > 0 + text: kcm.distroVariant + level: 2 + type: Kirigami.Heading.Type.Secondary + } + + QQC2.Label { +- visible: kcm.distroUrl !== "" ++ visible: kcm.distroUrl.length > 0 + text: "%1".arg(kcm.distroUrl) + textFormat: Text.RichText + onLinkActivated: link => Qt.openUrlExternally(link) +@@ -82,23 +82,11 @@ KCMUtils.SimpleKCM { + Kirigami.FormData.label: entry.localizedLabel() + Kirigami.FormData.labelAlignment: idealAlignment + Layout.alignment: idealAlignment ++ + readonly property int idealAlignment: valueLabel.lineCount > 1 ? Qt.AlignTop : Qt.AlignVCenter // looks tidier this way + readonly property bool hidden: entry.isHidden() +- readonly property string hint: entry.localizedHint().text +- readonly property color hintColorForeground: { +- switch (entry.localizedHint().color) { +- case Private.Hint.Color.One: return Kirigami.Theme.linkColor +- case Private.Hint.Color.Two: return Kirigami.Theme.positiveTextColor +- case Private.Hint.Color.Three: return Kirigami.Theme.alternateTextColor +- } +- } +- readonly property color hintColorBackground: { +- switch (entry.localizedHint().color) { +- case Private.Hint.Color.One: return Kirigami.Theme.linkBackgroundColor +- case Private.Hint.Color.Two: return Kirigami.Theme.positiveBackgroundColor +- case Private.Hint.Color.Three: return Kirigami.Theme.alternateBackgroundColor +- } +- } ++ ++ spacing: Kirigami.Units.smallSpacing + + Component { + id: unhideDialog +@@ -138,7 +126,7 @@ KCMUtils.SimpleKCM { + + QQC2.Label { + Kirigami.Theme.colorSet: Kirigami.Theme.Window +- visible: hint !== "" ++ visible: text.length > 0 + + // Vertical padding accounts for the difference in normal label height and the content height of this small label + readonly property real verticalPadding: (hintMetrics.height - contentHeight) / 2 +@@ -156,13 +144,25 @@ KCMUtils.SimpleKCM { + leftPadding: horizontalPadding + rightPadding: horizontalPadding + +- text: hint +- color: hintColorForeground ++ text: entry.localizedHint().text ++ color: { ++ switch (entry.localizedHint().color) { ++ case Private.Hint.Color.One: return Kirigami.Theme.linkColor ++ case Private.Hint.Color.Two: return Kirigami.Theme.positiveTextColor ++ case Private.Hint.Color.Three: return Kirigami.Theme.alternateTextColor ++ } ++ } + font.bold: true + font.pixelSize: Kirigami.Theme.smallFont.pixelSize + + background: Rectangle { +- color: hintColorBackground ++ color: { ++ switch (entry.localizedHint().color) { ++ case Private.Hint.Color.One: return Kirigami.Theme.linkBackgroundColor ++ case Private.Hint.Color.Two: return Kirigami.Theme.positiveBackgroundColor ++ case Private.Hint.Color.Three: return Kirigami.Theme.alternateBackgroundColor ++ } ++ } + radius: Kirigami.Units.cornerRadius + } + } +-- +GitLab + From a7545faa4542502d09ea69550635c60b112603a0 Mon Sep 17 00:00:00 2001 From: Toast Date: Tue, 4 Mar 2025 21:07:55 +0100 Subject: [PATCH 4/4] Kde: add spectacle patch --- roles/kde/patches/spectacle/pr431.patch | 3393 +++++++++++++++++++++++ 1 file changed, 3393 insertions(+) create mode 100644 roles/kde/patches/spectacle/pr431.patch diff --git a/roles/kde/patches/spectacle/pr431.patch b/roles/kde/patches/spectacle/pr431.patch new file mode 100644 index 0000000..aef0059 --- /dev/null +++ b/roles/kde/patches/spectacle/pr431.patch @@ -0,0 +1,3393 @@ +diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt +index c95d17e9..8a6994f8 100644 +--- a/src/CMakeLists.txt ++++ b/src/CMakeLists.txt +@@ -37,6 +37,8 @@ target_sources(spectacle PRIVATE + Gui/ExportMenu.cpp + Gui/HelpMenu.cpp + Gui/OptionsMenu.cpp ++ Gui/RecordingModeMenu.cpp ++ Gui/ScreenshotModeMenu.cpp + Gui/SmartSpinBox.cpp + Gui/Selection.cpp + Gui/SelectionEditor.cpp +@@ -147,6 +149,7 @@ set_source_files_properties(Gui/QmlUtils.qml PROPERTIES + + qt_target_qml_sources(spectacle + QML_FILES ++ Gui/AcceptAction.qml + Gui/AnimatedLoader.qml + Gui/AnnotationOptionsToolBarContents.qml + Gui/Annotations/AnnotationEditor.qml +@@ -160,38 +163,49 @@ qt_target_qml_sources(spectacle + Gui/CaptureOptions.qml + Gui/CaptureSettingsColumn.qml + Gui/CopiedMessage.qml ++ Gui/CopyImageAction.qml ++ Gui/CopyLocationAction.qml + Gui/DashedOutline.qml + Gui/DelaySpinBox.qml + Gui/DialogPage.qml ++ Gui/EditAction.qml + Gui/EmptyPage.qml ++ Gui/ExportMenuButton.qml + Gui/FloatingBackground.qml + Gui/FloatingToolBar.qml + Gui/Handle.qml ++ Gui/HelpMenuButton.qml + Gui/ImageCaptureOverlay.qml + Gui/ImageView.qml + Gui/InlineMessage.qml + Gui/LocationCopiedMessage.qml + Gui/Magnifier.qml +- Gui/MainToolBarContents.qml ++ Gui/NewScreenshotToolButton.qml ++ Gui/OptionsMenuButton.qml + Gui/Outline.qml + Gui/QRCodeScannedMessage.qml + Gui/QmlUtils.qml ++ Gui/RecordAction.qml + Gui/RecordOptions.qml + Gui/RecordingFailedMessage.qml + Gui/RecordingModeButtonsColumn.qml ++ Gui/RecordingModeMenuButton.qml + Gui/RecordingSettingsColumn.qml + Gui/RecordingView.qml ++ Gui/SaveAction.qml ++ Gui/SaveAsAction.qml + Gui/SavedAndCopiedMessage.qml + Gui/SavedAndLocationCopied.qml + Gui/SavedMessage.qml + Gui/ScreenshotFailedMessage.qml ++ Gui/ScreenshotModeMenuButton.qml + Gui/ScreenshotView.qml + Gui/ShareErrorMessage.qml + Gui/SharedMessage.qml + Gui/ShortcutsTextBox.qml + Gui/SizeLabel.qml ++ Gui/TtToolButton.qml + Gui/UndoRedoGroup.qml +- Gui/VideoCaptureOverlay.qml + ) + + install(TARGETS spectacle ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) +diff --git a/src/CaptureModeModel.cpp b/src/CaptureModeModel.cpp +index 1b7720dc..2037b97f 100644 +--- a/src/CaptureModeModel.cpp ++++ b/src/CaptureModeModel.cpp +@@ -15,6 +15,16 @@ + + using namespace Qt::StringLiterals; + ++static std::unique_ptr s_instance; ++ ++CaptureModeModel *CaptureModeModel::instance() ++{ ++ if (!s_instance) { ++ s_instance = std::make_unique(); ++ } ++ return s_instance.get(); ++} ++ + static QString actionShortcutsToString(QAction *action) + { + QString value; +diff --git a/src/CaptureModeModel.h b/src/CaptureModeModel.h +index 574936cb..ef78756a 100644 +--- a/src/CaptureModeModel.h ++++ b/src/CaptureModeModel.h +@@ -7,6 +7,7 @@ + #include "Platforms/ImagePlatform.h" + + #include ++#include + + /** + * This is a model containing the current supported capture modes and their labels and shortcuts. +@@ -15,11 +16,23 @@ class CaptureModeModel : public QAbstractListModel + { + Q_OBJECT + QML_ELEMENT ++ QML_SINGLETON + Q_PROPERTY(int count READ rowCount NOTIFY countChanged FINAL) + + public: + CaptureModeModel(QObject *parent = nullptr); + ++ static CaptureModeModel *instance(); ++ ++ static CaptureModeModel *create(QQmlEngine *engine, QJSEngine *) ++ { ++ auto inst = instance(); ++ Q_ASSERT(inst); ++ Q_ASSERT(inst->thread() == engine->thread()); ++ QJSEngine::setObjectOwnership(inst, QJSEngine::CppOwnership); ++ return inst; ++ } ++ + enum CaptureMode { + RectangularRegion, + AllScreens, +diff --git a/src/Gui/AcceptAction.qml b/src/Gui/AcceptAction.qml +new file mode 100644 +index 00000000..436066bc +--- /dev/null ++++ b/src/Gui/AcceptAction.qml +@@ -0,0 +1,11 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++import QtQuick.Templates as T ++ ++T.Action { ++ icon.name: "dialog-ok" ++ text: i18nc("@action accept selection", "Accept") ++ onTriggered: contextWindow.accept() ++} +diff --git a/src/Gui/AnnotationOptionsToolBarContents.qml b/src/Gui/AnnotationOptionsToolBarContents.qml +index 89198ac1..e7428127 100644 +--- a/src/Gui/AnnotationOptionsToolBarContents.qml ++++ b/src/Gui/AnnotationOptionsToolBarContents.qml +@@ -38,14 +38,9 @@ Row { + } + } + +- component ToolButton: QQC.ToolButton { +- implicitHeight: QmlUtils.iconTextButtonHeight +- width: display === QQC.ToolButton.IconOnly ? height : implicitWidth ++ component ToolButton: TtToolButton { + focusPolicy: root.focusPolicy + display: root.displayMode +- QQC.ToolTip.text: text +- QQC.ToolTip.visible: (hovered || pressed) && display === QQC.ToolButton.IconOnly +- QQC.ToolTip.delay: Kirigami.Units.toolTipDelay + } + + Loader { // stroke +diff --git a/src/Gui/Annotations/CropTool.qml b/src/Gui/Annotations/CropTool.qml +index c17557da..80b3588c 100644 +--- a/src/Gui/Annotations/CropTool.qml ++++ b/src/Gui/Annotations/CropTool.qml +@@ -153,6 +153,7 @@ Loader { + } + + Outline { ++ pathHints: ShapePath.PathLinear + x: selectionRect.x - strokeWidth + y: selectionRect.y - strokeWidth + width: selectionRect.width + strokeWidth * 2 +diff --git a/src/Gui/Annotations/TextTool.qml b/src/Gui/Annotations/TextTool.qml +index a2def4a8..b687b7fb 100644 +--- a/src/Gui/Annotations/TextTool.qml ++++ b/src/Gui/Annotations/TextTool.qml +@@ -183,6 +183,7 @@ AnimatedLoader { + topInset: -background.strokeWidth + bottomInset: -background.strokeWidth + background: DashedOutline { ++ pathHints: ShapePath.PathLinear + strokeWidth: QmlUtils.clampPx(dprRound(1) / root.viewport.scale) + } + +diff --git a/src/Gui/ButtonGrid.qml b/src/Gui/ButtonGrid.qml +index bb158446..a3e7b834 100644 +--- a/src/Gui/ButtonGrid.qml ++++ b/src/Gui/ButtonGrid.qml +@@ -12,14 +12,23 @@ Grid { + property int displayMode: QQC.AbstractButton.TextBesideIcon + property int focusPolicy: Qt.StrongFocus + readonly property bool mirrored: effectiveLayoutDirection === Qt.RightToLeft +- property bool animationsEnabled: true ++ property bool animationsEnabled: false + + clip: childrenRect.width > width || childrenRect.height > height + horizontalItemAlignment: Grid.AlignHCenter + verticalItemAlignment: Grid.AlignVCenter + spacing: Kirigami.Units.mediumSpacing +- columns: flow === Grid.LeftToRight ? visibleChildren.length : 1 +- rows: flow === Grid.TopToBottom ? visibleChildren.length : 1 ++ /* Using -1 for either rows or columns sets the amount to unlimited, ++ * but not if you set both to -1. Using `visibleChildren.length` to set ++ * unlimited rows or columns can generate errors about not having enough ++ * rows/columns when a child item's `visible` property is toggled. ++ * Internally, rows and columns are set to defaults like this: ++ * if (rows <= 0 && columns <= 0) { columns = 4; rows = (numVisible+3)/4; } ++ * else if (rows <= 0) { rows = (numVisible+(columns-1))/columns; } ++ * else if (columns <= 0) { columns = (numVisible+(rows-1))/rows; } ++ */ ++ columns: flow === Grid.LeftToRight ? -1 : 1 ++ rows: flow === Grid.TopToBottom ? -1 : 1 + move: Transition { + enabled: root.animationsEnabled + NumberAnimation { properties: "x,y"; duration: Kirigami.Units.longDuration; easing.type: Easing.OutCubic } +diff --git a/src/Gui/CaptureModeButtonsColumn.qml b/src/Gui/CaptureModeButtonsColumn.qml +index 08b78be1..9071a5e6 100644 +--- a/src/Gui/CaptureModeButtonsColumn.qml ++++ b/src/Gui/CaptureModeButtonsColumn.qml +@@ -11,7 +11,7 @@ import org.kde.spectacle.private + ColumnLayout { + spacing: Kirigami.Units.mediumSpacing + Repeater { +- model: CaptureModeModel { } ++ model: CaptureModeModel + delegate: QQC.DelayButton { + id: button + readonly property bool showCancel: Settings.captureMode === model.captureMode && SpectacleCore.captureTimeRemaining > 0 +diff --git a/src/Gui/CaptureWindow.cpp b/src/Gui/CaptureWindow.cpp +index 462a1344..7568b8c4 100644 +--- a/src/Gui/CaptureWindow.cpp ++++ b/src/Gui/CaptureWindow.cpp +@@ -107,25 +107,14 @@ QScreen *CaptureWindow::screenToFollow() const + + void CaptureWindow::setMode(CaptureWindow::Mode mode) + { +- if (mode == Image) { +- syncGeometryWithScreen(); +- QVariantMap initialProperties = { +- // Set the parent in initialProperties to avoid having +- // the parent and window be null in Component.onCompleted +- {u"parent"_s, QVariant::fromValue(contentItem())} +- }; +- setSource(QUrl("%1/Gui/ImageCaptureOverlay.qml"_L1.arg(SPECTACLE_QML_PATH)), +- initialProperties); +- } else if (mode == Video) { +- syncGeometryWithScreen(); +- QVariantMap initialProperties = { +- // Set the parent in initialProperties to avoid having +- // the parent and window be null in Component.onCompleted +- {u"parent"_s, QVariant::fromValue(contentItem())} +- }; +- setSource(QUrl("%1/Gui/VideoCaptureOverlay.qml"_L1.arg(SPECTACLE_QML_PATH)), +- initialProperties); +- } ++ syncGeometryWithScreen(); ++ QVariantMap initialProperties = { ++ // Set the parent in initialProperties to avoid having ++ // the parent and window be null in Component.onCompleted ++ {u"parent"_s, QVariant::fromValue(contentItem())} ++ }; ++ setSource(QUrl("%1/Gui/ImageCaptureOverlay.qml"_L1.arg(SPECTACLE_QML_PATH)), ++ initialProperties); + } + + bool CaptureWindow::accept() +diff --git a/src/Gui/CopyImageAction.qml b/src/Gui/CopyImageAction.qml +new file mode 100644 +index 00000000..8a4718a9 +--- /dev/null ++++ b/src/Gui/CopyImageAction.qml +@@ -0,0 +1,15 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++import QtQuick.Templates as T ++import org.kde.spectacle.private ++ ++T.Action { ++ // We don't use this in video mode because you can't copy raw video to the ++ // clipboard, or at least not elegantly. ++ enabled: !SpectacleCore.videoMode ++ icon.name: "edit-copy" ++ text: i18nc("@action", "Copy") ++ onTriggered: contextWindow.copyImage() ++} +diff --git a/src/Gui/CopyLocationAction.qml b/src/Gui/CopyLocationAction.qml +new file mode 100644 +index 00000000..d52255f0 +--- /dev/null ++++ b/src/Gui/CopyLocationAction.qml +@@ -0,0 +1,11 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++import QtQuick.Templates as T ++ ++T.Action { ++ icon.name: "edit-copy-path" ++ text: i18nc("@action", "Copy Location") ++ onTriggered: contextWindow.copyLocation() ++} +diff --git a/src/Gui/DashedOutline.qml b/src/Gui/DashedOutline.qml +index d3d7d40d..a1067305 100644 +--- a/src/Gui/DashedOutline.qml ++++ b/src/Gui/DashedOutline.qml +@@ -19,6 +19,7 @@ Outline { + property alias dashOffset: dashPath.dashOffset + property alias dashSvgPath: dashPathSvg.path + property alias dashPathScale: dashPath.scale ++ property alias dashPathHints: dashPath.pathHints + + // A regular alternative pattern with a spacing in logical pixels + function regularDashPattern(spacing, strokeWidth = root.strokeWidth) { +@@ -38,6 +39,7 @@ Outline { + capStyle: ShapePath.FlatCap + joinStyle: root.joinStyle + scale: root.pathScale ++ pathHints: root.pathHints + PathSvg { + id: dashPathSvg + path: root.svgPath +diff --git a/src/Gui/EditAction.qml b/src/Gui/EditAction.qml +new file mode 100644 +index 00000000..988af740 +--- /dev/null ++++ b/src/Gui/EditAction.qml +@@ -0,0 +1,15 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++import QtQuick.Templates as T ++import org.kde.spectacle.private ++ ++T.Action { ++ enabled: !SpectacleCore.videoMode ++ icon.name: "edit-image" ++ text: i18nc("@action edit screenshot", "Edit…") ++ checkable: true ++ checked: contextWindow.annotating ++ onToggled: contextWindow.annotating = checked ++} +diff --git a/src/Gui/ExportMenu.cpp b/src/Gui/ExportMenu.cpp +index d994179c..1a1a041c 100644 +--- a/src/Gui/ExportMenu.cpp ++++ b/src/Gui/ExportMenu.cpp +@@ -47,6 +47,8 @@ ExportMenu::ExportMenu(QWidget *parent) + , mPurposeMenu(new Purpose::Menu) + #endif + { ++ setTitle(i18nc("@title:menu", "Export")); ++ setIcon(QIcon::fromTheme(u"document-share"_s)); + addAction(QIcon::fromTheme(u"document-open-folder"_s), + i18n("Open Default Screenshots Folder"), + this, &ExportMenu::openScreenshotsFolder); +diff --git a/src/Gui/ExportMenuButton.qml b/src/Gui/ExportMenuButton.qml +new file mode 100644 +index 00000000..769f8053 +--- /dev/null ++++ b/src/Gui/ExportMenuButton.qml +@@ -0,0 +1,17 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++import QtQuick ++import org.kde.kirigami as Kirigami ++import org.kde.spectacle.private ++ ++TtToolButton { ++ // FIXME: make export menu actually work with videos ++ visible: !SpectacleCore.videoMode ++ icon.name: "document-share" ++ text: i18nc("@action", "Export") ++ down: pressed || ExportMenu.visible ++ Accessible.role: Accessible.ButtonMenu ++ onPressed: ExportMenu.popup(this) ++} +diff --git a/src/Gui/FloatingToolBar.qml b/src/Gui/FloatingToolBar.qml +index a42fc3dc..3fe9d1d8 100644 +--- a/src/Gui/FloatingToolBar.qml ++++ b/src/Gui/FloatingToolBar.qml +@@ -14,6 +14,7 @@ T.Pane { + property real topRightRadius: radius + property real bottomLeftRadius: radius + property real bottomRightRadius: radius ++ property real backgroundColorOpacity: 0.95 + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + contentWidth + leftPadding + rightPadding) +@@ -26,7 +27,7 @@ T.Pane { + background: FloatingBackground { + color: Qt.rgba(root.palette.window.r, + root.palette.window.g, +- root.palette.window.b, 0.85) ++ root.palette.window.b, root.backgroundColorOpacity) + border.color: Qt.rgba(root.palette.windowText.r, + root.palette.windowText.g, + root.palette.windowText.b, 0.2) +diff --git a/src/Gui/Handle.qml b/src/Gui/Handle.qml +index fbb386d0..a6c241ba 100644 +--- a/src/Gui/Handle.qml ++++ b/src/Gui/Handle.qml +@@ -153,6 +153,9 @@ Shape { + strokeStyle: root.strokeStyle + joinStyle: root.joinStyle + capStyle: root.capStyle ++ pathHints: (Math.abs(root.sweepAngle) === 360 || Math.abs(root.sweepAngle) <= 180 ++ ? ShapePath.PathConvex : ShapePath.PathSolid) ++ | ShapePath.PathNonOverlappingControlPointTriangles + PathAngleArc { + moveToStart: true // this path should not be affected by startX/startY + radiusX: root.radiusX +diff --git a/src/Gui/HelpMenu.cpp b/src/Gui/HelpMenu.cpp +index cadeb22c..ccc66adc 100644 +--- a/src/Gui/HelpMenu.cpp ++++ b/src/Gui/HelpMenu.cpp +@@ -6,6 +6,7 @@ + #include "WidgetWindowUtils.h" + + #include ++#include + + #include + #include +@@ -13,6 +14,8 @@ + + #include + ++using namespace Qt::StringLiterals; ++ + class HelpMenuSingleton + { + public: +@@ -39,6 +42,8 @@ HelpMenu::HelpMenu(QWidget* parent) + : SpectacleMenu(parent) + , kHelpMenu(new KHelpMenu(parent, KAboutData::applicationData(), true)) + { ++ setTitle(i18nc("@title:menu", "Help")); ++ setIcon(QIcon::fromTheme(u"help-contents"_s)); + addActions(kHelpMenu->menu()->actions()); + connect(this, &QMenu::triggered, this, &HelpMenu::onTriggered); + } +diff --git a/src/Gui/HelpMenuButton.qml b/src/Gui/HelpMenuButton.qml +new file mode 100644 +index 00000000..be30b32f +--- /dev/null ++++ b/src/Gui/HelpMenuButton.qml +@@ -0,0 +1,14 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++import QtQuick ++import org.kde.spectacle.private ++ ++TtToolButton { ++ icon.name: "help-contents" ++ text: i18nc("@action", "Help") ++ down: pressed || HelpMenu.visible ++ Accessible.role: Accessible.ButtonMenu ++ onPressed: HelpMenu.popup(this) ++} +diff --git a/src/Gui/ImageCaptureOverlay.qml b/src/Gui/ImageCaptureOverlay.qml +index d94a649c..da4cf33d 100644 +--- a/src/Gui/ImageCaptureOverlay.qml ++++ b/src/Gui/ImageCaptureOverlay.qml +@@ -6,6 +6,7 @@ + import QtQuick + import QtQuick.Window + import QtQuick.Layouts ++import QtQuick.Shapes + import QtQuick.Controls as QQC + import org.kde.kirigami as Kirigami + import org.kde.spectacle.private +@@ -25,42 +26,58 @@ import "Annotations" + MouseArea { + // This needs to be a mousearea in orcer for the proper mouse events to be correctly filtered + id: root ++ readonly property rect viewportRect: Geometry.mapFromPlatformRect(screenToFollow.geometry, screenToFollow.devicePixelRatio) ++ readonly property AnnotationDocument document: annotationsLoader.item?.document ?? null + focus: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: true + LayoutMirroring.enabled: Qt.application.layoutDirection === Qt.RightToLeft + LayoutMirroring.childrenInherit: true + anchors.fill: parent ++ enabled: !SpectacleCore.videoPlatform.isRecording + +- readonly property Selection selection: SelectionEditor.selection +- +- AnnotationEditor { +- id: annotations ++ AnimatedLoader { ++ id: annotationsLoader + anchors.fill: parent +- visible: true +- enabled: contextWindow.annotating +- viewportRect: Geometry.mapFromPlatformRect(screenToFollow.geometry, screenToFollow.devicePixelRatio) ++ state: !SpectacleCore.videoMode ? "active" : "inactive" ++ animationDuration: Kirigami.Units.veryLongDuration ++ sourceComponent: AnnotationEditor { ++ enabled: contextWindow.annotating ++ viewportRect: root.viewportRect ++ } ++ } ++ ++ component ToolButton : TtToolButton { ++ focusPolicy: Qt.NoFocus ++ } ++ ++ component ToolBarSizeLabel: SizeLabel { ++ height: QmlUtils.iconTextButtonHeight ++ size: { ++ const sz = SelectionEditor.selection.empty ++ ? Qt.size(SelectionEditor.screensRect.width, ++ SelectionEditor.screensRect.height) ++ : SelectionEditor.selection.size ++ return Geometry.rawSize(sz, SelectionEditor.devicePixelRatio) ++ } ++ leftPadding: Kirigami.Units.mediumSpacing + QmlUtils.fontMetrics.descent ++ rightPadding: leftPadding + } + + component Overlay: Rectangle { + color: Settings.useLightMaskColor ? "white" : "black" +- opacity: 0.5 +- LayoutMirroring.enabled: false +- } +- Overlay { // top / full overlay when nothing selected +- id: topOverlay +- anchors.top: parent.top +- anchors.left: parent.left +- anchors.right: parent.right +- anchors.bottom: selectionRectangle.visible ? selectionRectangle.top : parent.bottom +- opacity: if (root.selection.empty +- && (!annotations.document.tool.isNoTool || annotations.document.undoStackDepth > 0)) { ++ opacity: if (SpectacleCore.videoPlatform.isRecording ++ || (annotationsLoader.visible ++ && SelectionEditor.selection.empty ++ && root.document && (!root.document.tool.isNoTool ++ || root.document.undoStackDepth > 0))) { + return 0 +- } else if (root.selection.empty) { ++ } else if (SelectionEditor.selection.empty) { + return 0.25 + } else { + return 0.5 + } ++ LayoutMirroring.enabled: false + Behavior on opacity { + NumberAnimation { + duration: Kirigami.Units.longDuration +@@ -68,6 +85,13 @@ MouseArea { + } + } + } ++ Overlay { // top / full overlay when nothing selected ++ id: topOverlay ++ anchors.top: parent.top ++ anchors.left: parent.left ++ anchors.right: parent.right ++ anchors.bottom: selectionRectangle.visible ? selectionRectangle.top : parent.bottom ++ } + Overlay { // bottom + id: bottomOverlay + anchors.left: parent.left +@@ -95,37 +119,69 @@ MouseArea { + visible: selectionRectangle.visible && height > 0 && width > 0 + } + +- Rectangle { ++ AnimatedLoader { ++ anchors.centerIn: parent ++ visible: opacity > 0 && !SpectacleCore.videoPlatform.isRecording ++ state: topOverlay.opacity === 0.25 ? "active" : "inactive" ++ sourceComponent: Kirigami.Heading { ++ id: cropToolHelp ++ horizontalAlignment: Text.AlignHCenter ++ verticalAlignment: Text.AlignVCenter ++ text: i18nc("@info basic crop tool explanation", "Click and drag to make a selection.") ++ padding: cropToolHelpMetrics.height - cropToolHelpMetrics.descent ++ leftPadding: cropToolHelpMetrics.height ++ rightPadding: cropToolHelpMetrics.height ++ background: FloatingBackground { ++ radius: cropToolHelpMetrics.height ++ color: Qt.alpha(palette.window, 0.9) ++ } ++ FontMetrics { ++ id: cropToolHelpMetrics ++ font: cropToolHelp.font ++ } ++ } ++ } ++ ++ ++ DashedOutline { + id: selectionRectangle +- enabled: annotations.document.tool.isNoTool +- color: "transparent" +- border.color: if (enabled) { ++ // We need to be a bit careful about staying out of the recorded area ++ function roundPos(value: real) : real { ++ return SpectacleCore.videoPlatform.isRecording ? dprFloor(value - (strokeWidth + 1 / Screen.devicePixelRatio)) : dprRound(value - strokeWidth) ++ } ++ function roundSize(value: real) : real { ++ return SpectacleCore.videoPlatform.isRecording ? dprCeil(value + (strokeWidth + 1 / Screen.devicePixelRatio)) : dprRound(value + strokeWidth) ++ } ++ pathHints: ShapePath.PathLinear ++ dashSvgPath: SpectacleCore.videoPlatform.isRecording ? svgPath : "" ++ visible: !SelectionEditor.selection.empty ++ && Geometry.rectIntersects(Qt.rect(x,y,width,height), Qt.rect(0,0,parent.width, parent.height)) ++ enabled: root.document?.tool.isNoTool || SpectacleCore.videoMode ++ strokeWidth: dprRound(1) ++ strokeColor: if (enabled) { + return palette.active.highlight + } else if (Settings.useLightMaskColor) { + return "black" + } else { + return "white" + } +- border.width: contextWindow.dprRound(1) +- visible: !root.selection.empty && Geometry.rectIntersects(Qt.rect(x,y,width,height), Qt.rect(0,0,parent.width, parent.height)) +- x: root.selection.x - border.width - annotations.viewportRect.x +- y: root.selection.y - border.width - annotations.viewportRect.y +- width: root.selection.width + border.width * 2 +- height: root.selection.height + border.width * 2 +- +- LayoutMirroring.enabled: false +- LayoutMirroring.childrenInherit: true ++ dashColor: SpectacleCore.videoPlatform.isRecording ? palette.active.base : strokeColor ++ // We need to be a bit careful about staying out of the recorded area ++ x: roundPos(SelectionEditor.selection.x - root.viewportRect.x) ++ y: roundPos(SelectionEditor.selection.y - root.viewportRect.y) ++ width: roundSize(SelectionEditor.selection.right - root.viewportRect.x) - x ++ height: roundSize(SelectionEditor.selection.bottom - root.viewportRect.y) - y + } + + Item { +- x: -annotations.viewportRect.x +- y: -annotations.viewportRect.y ++ x: -root.viewportRect.x ++ y: -root.viewportRect.y + enabled: selectionRectangle.enabled + component SelectionHandle: Handle { + visible: enabled && selectionRectangle.visible + && SelectionEditor.dragLocation === SelectionEditor.None +- && Geometry.rectIntersects(Qt.rect(x,y,width,height), annotations.viewportRect) +- fillColor: selectionRectangle.border.color ++ && Geometry.rectIntersects(Qt.rect(x,y,width,height), root.viewportRect) ++ fillColor: selectionRectangle.strokeColor + } + + SelectionHandle { +@@ -170,34 +226,23 @@ MouseArea { + } + } + +- ShortcutsTextBox { +- anchors { +- horizontalCenter: parent.horizontalCenter +- bottom: parent.bottom +- } +- visible: opacity > 0 && Settings.showCaptureInstructions +- // Assume SelectionEditor covers all screens. +- // Use parent's coordinate system. +- opacity: root.containsMouse +- && !contains(mapFromItem(root, root.mouseX, root.mouseY)) +- && !root.pressed +- && annotations.document.tool.isNoTool +- && !mtbDragHandler.active +- && !atbDragHandler.active +- && !Geometry.rectIntersects(SelectionEditor.handlesRect, Qt.rect(x, y, width, height)) +- Behavior on opacity { +- NumberAnimation { +- duration: Kirigami.Units.longDuration +- easing.type: Easing.OutCubic +- } +- } +- } +- + Item { // separate item because it needs to be above the stuff defined above ++ id: screensRectItem ++ readonly property bool allowToolbars: { ++ let emptyHovered = (root.containsMouse || annotationsLoader.item?.hovered) && SelectionEditor.selection.empty ++ let menuVisible = ExportMenu.visible ++ menuVisible |= OptionsMenu.visible ++ menuVisible |= HelpMenu.visible ++ menuVisible |= ScreenshotModeMenu.visible ++ menuVisible |= RecordingModeMenu.visible ++ let pressed = SelectionEditor.dragLocation || annotationsLoader.item?.anyPressed ++ return !SpectacleCore.videoPlatform.isRecording && !pressed ++ && (emptyHovered || !SelectionEditor.selection.empty || menuVisible) ++ } + width: SelectionEditor.screensRect.width + height: SelectionEditor.screensRect.height +- x: -annotations.viewportRect.x +- y: -annotations.viewportRect.y ++ x: -root.viewportRect.x ++ y: -root.viewportRect.y + + // Magnifier + Loader { +@@ -211,33 +256,33 @@ MouseArea { + if (SelectionEditor.magnifierLocation === SelectionEditor.TopLeft + || SelectionEditor.magnifierLocation === SelectionEditor.Left + || SelectionEditor.magnifierLocation === SelectionEditor.BottomLeft) { +- x = root.selection.left ++ x = SelectionEditor.selection.left + } else if (SelectionEditor.magnifierLocation === SelectionEditor.TopRight + || SelectionEditor.magnifierLocation === SelectionEditor.Right + || SelectionEditor.magnifierLocation === SelectionEditor.BottomRight) { +- x = root.selection.right ++ x = SelectionEditor.selection.right + } else if (SelectionEditor.magnifierLocation === SelectionEditor.Top + || SelectionEditor.magnifierLocation === SelectionEditor.Bottom) { + if (SelectionEditor.dragLocation !== SelectionEditor.None) { + x = SelectionEditor.mousePosition.x + } else { +- x = root.selection.horizontalCenter ++ x = SelectionEditor.selection.horizontalCenter + } + } + if (SelectionEditor.magnifierLocation === SelectionEditor.TopLeft + || SelectionEditor.magnifierLocation === SelectionEditor.Top + || SelectionEditor.magnifierLocation === SelectionEditor.TopRight) { +- y = root.selection.top ++ y = SelectionEditor.selection.top + } else if (SelectionEditor.magnifierLocation === SelectionEditor.BottomLeft + || SelectionEditor.magnifierLocation === SelectionEditor.Bottom + || SelectionEditor.magnifierLocation === SelectionEditor.BottomRight) { +- y = root.selection.bottom ++ y = SelectionEditor.selection.bottom + } else if (SelectionEditor.magnifierLocation === SelectionEditor.Left + || SelectionEditor.magnifierLocation === SelectionEditor.Right) { + if (SelectionEditor.dragLocation !== SelectionEditor.None) { + y = SelectionEditor.mousePosition.y + } else { +- y = root.selection.verticalCenter ++ y = SelectionEditor.selection.verticalCenter + } + } + return Qt.point(x, y) +@@ -281,91 +326,30 @@ MouseArea { + z: 100 + visible: SelectionEditor.showMagnifier + && SelectionEditor.magnifierLocation !== SelectionEditor.None +- && Geometry.rectIntersects(rect, annotations.viewportRect) +- active: Settings.showMagnifier !== Settings.ShowMagnifierNever ++ && Geometry.rectIntersects(rect, root.viewportRect) ++ active: Settings.showMagnifier !== Settings.ShowMagnifierNever && annotationsLoader.item !== null + sourceComponent: Magnifier { +- viewport: annotations ++ viewport: annotationsLoader.item + targetPoint: magnifierLoader.targetPoint + } + } + +- // Size ToolTip +- SizeLabel { +- id: ssToolTip +- readonly property int valignment: { +- if (root.selection.empty) { +- return Qt.AlignVCenter +- } +- const margin = Kirigami.Units.mediumSpacing * 2 +- const w = width + margin +- const h = height + margin +- if (SelectionEditor.handlesRect.top >= h) { +- return Qt.AlignTop +- } else if (SelectionEditor.screensRect.height - SelectionEditor.handlesRect.bottom >= h) { +- return Qt.AlignBottom +- } else { +- // At the bottom of the inside of the selection rect. +- return Qt.AlignBaseline +- } +- } +- readonly property bool normallyVisible: !root.selection.empty && !(mainToolBar.visible && mainToolBar.valignment === ssToolTip.valignment) +- Binding on x { +- value: contextWindow.dprRound(root.selection.horizontalCenter - ssToolTip.width / 2) +- when: ssToolTip.normallyVisible +- restoreMode: Binding.RestoreNone +- } +- Binding on y { +- value: { +- let v = 0 +- if (ssToolTip.valignment & Qt.AlignBaseline) { +- v = Math.min(root.selection.bottom, SelectionEditor.handlesRect.bottom - Kirigami.Units.gridUnit) +- - ssToolTip.height - Kirigami.Units.mediumSpacing * 2 +- } else if (ssToolTip.valignment & Qt.AlignTop) { +- v = SelectionEditor.handlesRect.top +- - ssToolTip.height - Kirigami.Units.mediumSpacing * 2 +- } else if (ssToolTip.valignment & Qt.AlignBottom) { +- v = SelectionEditor.handlesRect.bottom + Kirigami.Units.mediumSpacing * 2 +- } else { +- v = (root.height - ssToolTip.height) / 2 - parent.y +- } +- return contextWindow.dprRound(v) +- } +- when: ssToolTip.normallyVisible +- restoreMode: Binding.RestoreNone +- } +- visible: opacity > 0 +- opacity: ssToolTip.normallyVisible +- && Geometry.rectIntersects(Qt.rect(x,y,width,height), annotations.viewportRect) +- Behavior on opacity { +- NumberAnimation { +- duration: Kirigami.Units.longDuration +- easing.type: Easing.OutCubic +- } +- } +- size: Geometry.rawSize(root.selection.size, SelectionEditor.devicePixelRatio) +- padding: Kirigami.Units.mediumSpacing * 2 +- topPadding: padding - QmlUtils.fontMetrics.descent +- bottomPadding: topPadding +- background: FloatingBackground { +- implicitWidth: Math.ceil(parent.contentWidth) + parent.leftPadding + parent.rightPadding +- implicitHeight: Math.ceil(parent.contentHeight) + parent.topPadding + parent.bottomPadding +- color: Qt.rgba(parent.palette.window.r, +- parent.palette.window.g, +- parent.palette.window.b, 0.85) +- border.color: Qt.rgba(parent.palette.windowText.r, +- parent.palette.windowText.g, +- parent.palette.windowText.b, 0.2) +- border.width: contextWindow.dprRound(1) +- } +- } +- + Connections { +- target: root.selection ++ target: SelectionEditor.selection + function onEmptyChanged() { +- if (!root.selection.empty +- && (mainToolBar.rememberPosition || atbLoader.rememberPosition)) { +- mainToolBar.rememberPosition = false +- atbLoader.rememberPosition = false ++ if (!SelectionEditor.selection.empty) { ++ if (mainToolBar.rememberPosition) { ++ mainToolBar.z = 0 ++ mainToolBar.rememberPosition = false ++ } ++ if (ftbLoader.item?.rememberPosition) { ++ ftbLoader.z = 0 ++ ftbLoader.item.rememberPosition = false ++ } ++ if (atbLoader.item?.rememberPosition) { ++ atbLoader.z = 0 ++ atbLoader.item.rememberPosition = false ++ } + } + } + } +@@ -373,65 +357,27 @@ MouseArea { + // Main ToolBar + FloatingToolBar { + id: mainToolBar ++ readonly property rect rect: Qt.rect(x, y, width, height) + property bool rememberPosition: false +- readonly property int valignment: { +- if (root.selection.empty) { +- return 0 +- } +- if (3 * height + topPadding + Kirigami.Units.mediumSpacing +- <= SelectionEditor.screensRect.height - SelectionEditor.handlesRect.bottom +- ) { +- return Qt.AlignBottom +- } else if (3 * height + bottomPadding + Kirigami.Units.mediumSpacing +- <= SelectionEditor.handlesRect.top +- ) { +- return Qt.AlignTop +- } else { +- // At the bottom of the inside of the selection rect. +- return Qt.AlignBaseline +- } +- } +- readonly property bool normallyVisible: { +- let emptyHovered = (root.containsMouse || annotations.hovered) && root.selection.empty +- let menuVisible = ExportMenu.visible +- menuVisible |= OptionsMenu.visible +- menuVisible |= HelpMenu.visible +- let pressed = SelectionEditor.dragLocation || annotations.anyPressed +- return (emptyHovered || !root.selection.empty || menuVisible) && !pressed +- } ++ property alias dragging: mtbDragHandler.active + Binding on x { +- value: { +- const v = root.selection.empty ? (root.width - mainToolBar.width) / 2 + annotations.viewportRect.x +- : root.selection.horizontalCenter - mainToolBar.width / 2 +- return Math.max(mainToolBar.leftPadding, // min value +- Math.min(contextWindow.dprRound(v), +- SelectionEditor.screensRect.width - mainToolBar.width - mainToolBar.rightPadding)) // max value +- } +- when: mainToolBar.normallyVisible && !mainToolBar.rememberPosition ++ value: Math.max(root.viewportRect.x + mainToolBar.leftPadding, // min value ++ Math.min(dprRound(root.viewportRect.x + (root.width - mainToolBar.width) / 2), ++ root.viewportRect.x + root.width - mainToolBar.width - mainToolBar.rightPadding)) // max value ++ when: screensRectItem.allowToolbars && !mainToolBar.rememberPosition + restoreMode: Binding.RestoreNone + } + Binding on y { +- value: { +- let v = 0 +- // put above selection if not enough room below selection +- if (mainToolBar.valignment & Qt.AlignTop) { +- v = SelectionEditor.handlesRect.top +- - mainToolBar.height - mainToolBar.bottomPadding +- } else if (mainToolBar.valignment & Qt.AlignBottom) { +- v = SelectionEditor.handlesRect.bottom + mainToolBar.topPadding +- } else if (mainToolBar.valignment & Qt.AlignBaseline) { +- v = Math.min(root.selection.bottom, SelectionEditor.handlesRect.bottom - Kirigami.Units.gridUnit) +- - mainToolBar.height - mainToolBar.bottomPadding +- } else { +- v = (mainToolBar.height / 2) - mainToolBar.parent.y +- } +- return contextWindow.dprRound(v) +- } +- when: mainToolBar.normallyVisible && !mainToolBar.rememberPosition ++ value: dprRound(root.viewportRect.y + root.height - mainToolBar.height - mainToolBar.bottomPadding) ++ when: screensRectItem.allowToolbars && !mainToolBar.rememberPosition + restoreMode: Binding.RestoreNone + } +- visible: opacity > 0 +- opacity: normallyVisible && Geometry.rectIntersects(Qt.rect(x,y,width,height), annotations.viewportRect) ++ visible: opacity > 0 && !SpectacleCore.videoPlatform.isRecording ++ opacity: screensRectItem.allowToolbars ++ && (mainToolBar.rememberPosition ++ || SelectionEditor.selection.empty ++ || (!Geometry.rectIntersects(mainToolBar.rect, ftbLoader.rect) ++ && !Geometry.rectIntersects(mainToolBar.rect, SelectionEditor.handlesRect))) + Behavior on opacity { + NumberAnimation { + duration: Kirigami.Units.longDuration +@@ -440,120 +386,261 @@ MouseArea { + } + layer.enabled: true // improves the visuals of the opacity animation + focusPolicy: Qt.NoFocus +- contentItem: MainToolBarContents { +- id: mainToolBarContents +- focusPolicy: Qt.NoFocus +- displayMode: QQC.AbstractButton.TextBesideIcon +- showSizeLabel: mainToolBar.valignment === ssToolTip.valignment +- imageSize: Geometry.rawSize(root.selection.size, SelectionEditor.devicePixelRatio) ++ contentItem: ButtonGrid { ++ spacing: parent.spacing ++ Loader { ++ id: mtbImageVideoContentLoader ++ visible: SelectionEditor.selection.empty ++ active: visible ++ sourceComponent: SpectacleCore.videoMode ? videoToolBarComponent : imageMainToolBarComponent ++ } ++ QQC.ToolSeparator { ++ visible: mtbImageVideoContentLoader.visible ++ height: QmlUtils.iconTextButtonHeight ++ } ++ ScreenshotModeMenuButton { ++ focusPolicy: Qt.NoFocus ++ } ++ RecordingModeMenuButton { ++ focusPolicy: Qt.NoFocus ++ } ++ OptionsMenuButton { ++ focusPolicy: Qt.NoFocus ++ } + } + ++ HoverHandler { ++ target: mainToolBar ++ margin: mainToolBar.padding ++ cursorShape: enabled ? ++ (mtbDragHandler.active ? Qt.ClosedHandCursor : Qt.OpenHandCursor) ++ : undefined ++ } + DragHandler { // parent is contentItem and parent is a read-only property + id: mtbDragHandler +- enabled: root.selection.empty + target: mainToolBar + acceptedButtons: Qt.LeftButton + margin: mainToolBar.padding +- xAxis.minimum: annotations.viewportRect.x +- xAxis.maximum: annotations.viewportRect.x + root.width - mainToolBar.width +- yAxis.minimum: annotations.viewportRect.y +- yAxis.maximum: annotations.viewportRect.y + root.height - mainToolBar.height +- cursorShape: enabled ? +- (active ? Qt.ClosedHandCursor : Qt.OpenHandCursor) +- : undefined +- onActiveChanged: if (active && !mainToolBar.rememberPosition) { +- mainToolBar.rememberPosition = true ++ xAxis.minimum: root.viewportRect.x ++ xAxis.maximum: root.viewportRect.x + root.width - mainToolBar.width ++ yAxis.minimum: root.viewportRect.y ++ yAxis.maximum: root.viewportRect.y + root.height - mainToolBar.height ++ onActiveChanged: if (active) { ++ mainToolBar.z = 2 ++ atbLoader.z = atbLoader.z > ftbLoader.z ? 1 : 0 ++ ftbLoader.z = atbLoader.z < ftbLoader.z ? 1 : 0 ++ if (!mainToolBar.rememberPosition) { ++ mainToolBar.rememberPosition = true ++ } + } + } + } + +- AnimatedLoader { +- id: atbLoader +- property bool rememberPosition: false +- readonly property int valignment: mainToolBar.valignment & (Qt.AlignTop | Qt.AlignBaseline) ? +- Qt.AlignTop : Qt.AlignBottom +- active: visible && mainToolBar.visible +- onActiveChanged: if (!active && rememberPosition +- && !contextWindow.annotating) { +- rememberPosition = false +- } +- state: mainToolBar.normallyVisible +- && contextWindow.annotating ? "active" : "inactive" +- +- Binding on x { +- value: { +- const min = mainToolBar.x +- const target = contextWindow.dprRound(mainToolBar.x + (mainToolBar.width - atbLoader.width) / 2) +- const max = mainToolBar.x + mainToolBar.width - atbLoader.width +- return Math.max(min, Math.min(target, max)) ++ Component { ++ id: imageMainToolBarComponent ++ ButtonGrid { ++ spacing: parent.parent.spacing ++ ToolBarSizeLabel {} ++ ToolButton { ++ display: TtToolButton.TextBesideIcon ++ visible: action.enabled ++ action: AcceptAction {} ++ } ++ ToolButton { ++ display: TtToolButton.IconOnly ++ visible: action.enabled ++ action: SaveAction {} ++ } ++ ToolButton { ++ display: TtToolButton.IconOnly ++ visible: action.enabled ++ action: SaveAsAction {} ++ } ++ ToolButton { ++ display: TtToolButton.IconOnly ++ visible: action.enabled ++ action: CopyImageAction {} ++ } ++ ExportMenuButton { ++ focusPolicy: Qt.NoFocus + } +- when: !atbLoader.rememberPosition +- restoreMode: Binding.RestoreNone + } +- Binding on y { +- value: contextWindow.dprRound(atbLoader.valignment & Qt.AlignTop ? +- mainToolBar.y - atbLoader.height - Kirigami.Units.mediumSpacing +- : mainToolBar.y + mainToolBar.height + Kirigami.Units.mediumSpacing) +- when: !atbLoader.rememberPosition +- restoreMode: Binding.RestoreNone ++ } ++ Component { ++ id: imageFinalizerToolBarComponent ++ ButtonGrid { ++ spacing: parent.parent.spacing ++ ToolBarSizeLabel {} ++ ToolButton { ++ visible: action.enabled ++ action: AcceptAction {} ++ } ++ ToolButton { ++ visible: action.enabled ++ action: SaveAction {} ++ } ++ ToolButton { ++ visible: action.enabled ++ action: SaveAsAction {} ++ } ++ ToolButton { ++ visible: action.enabled ++ action: CopyImageAction {} ++ } ++ ExportMenuButton { ++ focusPolicy: Qt.NoFocus ++ } + } +- +- DragHandler { // parented to contentItem +- id: atbDragHandler +- enabled: root.selection.empty +- acceptedButtons: Qt.LeftButton +- xAxis.minimum: annotations.viewportRect.x +- xAxis.maximum: annotations.viewportRect.x + root.width - atbLoader.width +- yAxis.minimum: annotations.viewportRect.y +- yAxis.maximum: annotations.viewportRect.y + root.height - atbLoader.height +- cursorShape: enabled ? +- (active ? Qt.ClosedHandCursor : Qt.OpenHandCursor) +- : undefined +- onActiveChanged: if (active && !atbLoader.rememberPosition) { +- atbLoader.rememberPosition = true ++ } ++ Component { ++ id: videoToolBarComponent ++ ButtonGrid { ++ spacing: parent.parent.spacing ++ ToolBarSizeLabel {} ++ ToolButton { ++ display: TtToolButton.TextBesideIcon ++ visible: action.enabled ++ action: RecordAction {} + } + } ++ } + ++ Loader { ++ id: atbLoader ++ readonly property rect rect: Qt.rect(x, y, width, height) ++ visible: annotationsLoader.visible ++ active: visible ++ z: 2 + sourceComponent: FloatingToolBar { + id: annotationsToolBar ++ property bool rememberPosition: false ++ readonly property int valignment: { ++ if (SelectionEditor.handlesRect.top >= height + annotationsToolBar.bottomPadding || SelectionEditor.selection.empty) { ++ // the top of the top side of the selection ++ // or the top of the screen ++ return Qt.AlignTop ++ } else { ++ // the bottom of the top side of the selection ++ return Qt.AlignBottom ++ } ++ } ++ property alias dragging: dragHandler.active ++ visible: opacity > 0 ++ opacity: screensRectItem.allowToolbars && Geometry.rectIntersects(atbLoader.rect, root.viewportRect) ++ Behavior on opacity { ++ NumberAnimation { ++ duration: Kirigami.Units.longDuration ++ easing.type: Easing.OutCubic ++ } ++ } ++ // Can't use layer.enabled to improve opacity animation because ++ // that makes the options toolbar invisible + focusPolicy: Qt.NoFocus + contentItem: AnnotationsToolBarContents { + id: annotationsContents ++ spacing: parent.spacing + displayMode: QQC.AbstractButton.IconOnly + focusPolicy: Qt.NoFocus + } + +- topLeftRadius: optionsToolBar.visible +- && optionsToolBar.x === 0 +- && atbLoader.valignment & Qt.AlignTop ? 0 : radius +- topRightRadius: optionsToolBar.visible +- && optionsToolBar.x === width - optionsToolBar.width +- && atbLoader.valignment & Qt.AlignTop ? 0 : radius +- bottomLeftRadius: optionsToolBar.visible +- && optionsToolBar.x === 0 +- && atbLoader.valignment & Qt.AlignBottom ? 0 : radius +- bottomRightRadius: optionsToolBar.visible +- && optionsToolBar.x === width - optionsToolBar.width +- && atbLoader.valignment & Qt.AlignBottom ? 0 : radius ++ topLeftRadius: otbLoader.visible ++ && otbLoader.x === 0 ++ && (annotationsToolBar.valignment & Qt.AlignTop) ++ && !SelectionEditor.selection.empty ? 0 : radius ++ topRightRadius: otbLoader.visible ++ && otbLoader.x === width - otbLoader.width ++ && (annotationsToolBar.valignment & Qt.AlignTop) ++ && !SelectionEditor.selection.empty? 0 : radius ++ bottomLeftRadius: otbLoader.visible ++ && otbLoader.x === 0 ++ && ((annotationsToolBar.valignment & Qt.AlignBottom) ++ || SelectionEditor.selection.empty) ? 0 : radius ++ bottomRightRadius: otbLoader.visible ++ && otbLoader.x === width - otbLoader.width ++ && ((annotationsToolBar.valignment & Qt.AlignBottom) ++ || SelectionEditor.selection.empty) ? 0 : radius ++ ++ Binding { ++ property: "x" ++ target: atbLoader ++ value: { ++ const v = SelectionEditor.selection.empty ? (root.width - annotationsToolBar.width) / 2 + root.viewportRect.x ++ : SelectionEditor.selection.horizontalCenter - annotationsToolBar.width / 2 ++ return Math.max(annotationsToolBar.leftPadding, // min value ++ Math.min(dprRound(v), ++ SelectionEditor.screensRect.width - annotationsToolBar.width - annotationsToolBar.rightPadding)) // max value ++ } ++ when: screensRectItem.allowToolbars && !annotationsToolBar.rememberPosition ++ restoreMode: Binding.RestoreNone ++ } ++ Binding { ++ property: "y" ++ target: atbLoader ++ value: { ++ let v = 0 ++ if (SelectionEditor.selection.empty) { ++ v = root.viewportRect.y + annotationsToolBar.topPadding ++ } else if (annotationsToolBar.valignment & Qt.AlignTop) { ++ v = SelectionEditor.handlesRect.top - annotationsToolBar.height - annotationsToolBar.bottomPadding ++ } else if (annotationsToolBar.valignment & Qt.AlignBottom) { ++ v = Math.max(SelectionEditor.selection.top, SelectionEditor.handlesRect.top + Kirigami.Units.gridUnit) ++ + annotationsToolBar.topPadding ++ } else { ++ v = (root.height - annotationsToolBar.height) / 2 - parent.y ++ } ++ return dprRound(v) ++ } ++ when: screensRectItem.allowToolbars && !annotationsToolBar.rememberPosition ++ restoreMode: Binding.RestoreNone ++ } + + // Exists purely for cosmetic reasons to make the border of + // optionsToolBar that meets annotationsToolBar look better + Rectangle { + id: borderBg + z: -1 +- visible: optionsToolBar.visible +- opacity: optionsToolBar.opacity ++ visible: otbLoader.visible ++ opacity: otbLoader.opacity + parent: annotationsToolBar +- x: optionsToolBar.x + annotationsToolBar.background.border.width +- y: atbLoader.valignment & Qt.AlignTop ? +- optionsToolBar.y + optionsToolBar.height : optionsToolBar.y +- width: optionsToolBar.width - annotationsToolBar.background.border.width * 2 +- height: contextWindow.dprRound(1) ++ x: otbLoader.x + annotationsToolBar.background.border.width ++ y: (annotationsToolBar.valignment & Qt.AlignTop) ++ && !SelectionEditor.selection.empty ++ ? otbLoader.y + otbLoader.height : otbLoader.y ++ width: otbLoader.width - annotationsToolBar.background.border.width * 2 ++ height: dprRound(1) + color: annotationsToolBar.background.color + } + ++ HoverHandler { ++ enabled: dragHandler.enabled ++ target: annotationsToolBar ++ margin: annotationsToolBar.padding ++ cursorShape: enabled ? ++ (dragHandler.active ? Qt.ClosedHandCursor : Qt.OpenHandCursor) ++ : undefined ++ } ++ DragHandler { // parented to contentItem ++ id: dragHandler ++ enabled: SelectionEditor.selection.empty || !selectionRectangle.enabled ++ target: atbLoader ++ acceptedButtons: Qt.LeftButton ++ margin: annotationsToolBar.padding ++ xAxis.minimum: root.viewportRect.x ++ xAxis.maximum: root.viewportRect.x + root.width - annotationsToolBar.width ++ yAxis.minimum: root.viewportRect.y ++ yAxis.maximum: root.viewportRect.y + root.height - annotationsToolBar.height ++ onActiveChanged: if (active) { ++ mainToolBar.z = mainToolBar.z > ftbLoader.z ? 1 : 0 ++ atbLoader.z = 2 ++ ftbLoader.z = mainToolBar.z < ftbLoader.z ? 1 : 0 ++ if (!annotationsToolBar.rememberPosition) { ++ annotationsToolBar.rememberPosition = true ++ } ++ } ++ } ++ + AnimatedLoader { +- id: optionsToolBar ++ id: otbLoader + parent: annotationsToolBar + x: { + let targetX = annotationsContents.x +@@ -562,15 +649,16 @@ MouseArea { + targetX += checkedButton.x + (checkedButton.width - width) / 2 + } + return Math.max(0, // min value +- Math.min(contextWindow.dprRound(targetX), ++ Math.min(contextWindow.dprRound(targetX), + parent.width - width)) // max value + } +- y: atbLoader.valignment & Qt.AlignTop ? +- -optionsToolBar.height + borderBg.height +- : optionsToolBar.height - borderBg.height +- state: if (SpectacleCore.annotationDocument.tool.options !== AnnotationTool.NoOptions +- || (SpectacleCore.annotationDocument.tool.type === AnnotationTool.SelectTool +- && SpectacleCore.annotationDocument.selectedItem.options !== AnnotationTool.NoOptions) ++ y: (annotationsToolBar.valignment & Qt.AlignTop) ++ && !SelectionEditor.selection.empty ++ ? -otbLoader.height + borderBg.height ++ : otbLoader.height - borderBg.height ++ state: if (root.document?.tool.options !== AnnotationTool.NoOptions ++ || (root.document?.tool.type === AnnotationTool.SelectTool ++ && root.document?.selectedItem.options !== AnnotationTool.NoOptions) + ) { + return "active" + } else { +@@ -579,13 +667,111 @@ MouseArea { + sourceComponent: FloatingToolBar { + focusPolicy: Qt.NoFocus + contentItem: AnnotationOptionsToolBarContents { ++ spacing: parent.spacing + displayMode: QQC.AbstractButton.IconOnly + focusPolicy: Qt.NoFocus + } +- topLeftRadius: atbLoader.valignment & Qt.AlignBottom && x >= 0 ? 0 : radius +- topRightRadius: atbLoader.valignment & Qt.AlignBottom && x + width <= annotationsToolBar.width ? 0 : radius +- bottomLeftRadius: atbLoader.valignment & Qt.AlignTop && x >= 0 ? 0 : radius +- bottomRightRadius: atbLoader.valignment & Qt.AlignTop && x + width <= annotationsToolBar.width ? 0 : radius ++ topLeftRadius: ((annotationsToolBar.valignment & Qt.AlignBottom) || SelectionEditor.selection.empty) && otbLoader.x >= 0 ? 0 : radius ++ topRightRadius: ((annotationsToolBar.valignment & Qt.AlignBottom) || SelectionEditor.selection.empty) && otbLoader.x + width <= annotationsToolBar.width ? 0 : radius ++ bottomLeftRadius: (annotationsToolBar.valignment & Qt.AlignTop) && !SelectionEditor.selection.empty && otbLoader.x >= 0 ? 0 : radius ++ bottomRightRadius: (annotationsToolBar.valignment & Qt.AlignTop) && !SelectionEditor.selection.empty && otbLoader.x + width <= annotationsToolBar.width ? 0 : radius ++ } ++ } ++ } ++ } ++ ++ // Finalizer ToolBar ++ Loader { ++ id: ftbLoader ++ readonly property rect rect: Qt.rect(x, y, width, height) ++ visible: !SpectacleCore.videoPlatform.isRecording && !SelectionEditor.selection.empty ++ active: visible ++ sourceComponent: FloatingToolBar { ++ id: toolBar ++ property bool rememberPosition: false ++ property alias dragging: dragHandler.active ++ readonly property int valignment: { ++ if (SelectionEditor.screensRect.height - SelectionEditor.handlesRect.bottom >= height + toolBar.topPadding || SelectionEditor.selection.empty) { ++ // the bottom of the bottom side of the selection ++ // or the bottom of the screen ++ return Qt.AlignBottom ++ } else { ++ // the top of the bottom side of the selection ++ return Qt.AlignTop ++ } ++ } ++ Binding { ++ property: "x" ++ target: ftbLoader ++ value: { ++ const v = SelectionEditor.selection.empty ? (root.width - toolBar.width) / 2 + root.viewportRect.x ++ : SelectionEditor.selection.horizontalCenter - toolBar.width / 2 ++ return Math.max(toolBar.leftPadding, // min value ++ Math.min(dprRound(v), ++ SelectionEditor.screensRect.width - toolBar.width - toolBar.rightPadding)) // max value ++ } ++ when: screensRectItem.allowToolbars && !toolBar.rememberPosition ++ restoreMode: Binding.RestoreNone ++ } ++ Binding { ++ property: "y" ++ target: ftbLoader ++ value: { ++ let v = 0 ++ if (SelectionEditor.selection.empty) { ++ v = root.viewportRect.y + root.height - toolBar.height - toolBar.bottomPadding ++ } else if (toolBar.valignment & Qt.AlignBottom) { ++ v = SelectionEditor.handlesRect.bottom + toolBar.topPadding ++ } else if (toolBar.valignment & Qt.AlignTop) { ++ v = Math.min(SelectionEditor.selection.bottom, SelectionEditor.handlesRect.bottom - Kirigami.Units.gridUnit) ++ - toolBar.height - toolBar.bottomPadding ++ } else { ++ v = (toolBar.height / 2) - toolBar.parent.y ++ } ++ return dprRound(v) ++ } ++ when: screensRectItem.allowToolbars && !toolBar.rememberPosition ++ restoreMode: Binding.RestoreNone ++ } ++ visible: opacity > 0 ++ opacity: screensRectItem.allowToolbars && Geometry.rectIntersects(ftbLoader.rect, root.viewportRect) ++ Behavior on opacity { ++ NumberAnimation { ++ duration: Kirigami.Units.longDuration ++ easing.type: Easing.OutCubic ++ } ++ } ++ layer.enabled: true // improves the visuals of the opacity animation ++ focusPolicy: Qt.NoFocus ++ contentItem: Loader { ++ sourceComponent: SpectacleCore.videoMode ? videoToolBarComponent : imageFinalizerToolBarComponent ++ } ++ ++ HoverHandler { ++ enabled: dragHandler.enabled ++ target: toolBar ++ margin: toolBar.padding ++ cursorShape: enabled ? ++ (dragHandler.active ? Qt.ClosedHandCursor : Qt.OpenHandCursor) ++ : undefined ++ } ++ DragHandler { // parent is contentItem and parent is a read-only property ++ id: dragHandler ++ enabled: SelectionEditor.selection.empty || !selectionRectangle.enabled ++ target: ftbLoader ++ acceptedButtons: Qt.LeftButton ++ margin: toolBar.padding ++ xAxis.minimum: root.viewportRect.x ++ xAxis.maximum: root.viewportRect.x + root.width - toolBar.width ++ yAxis.minimum: root.viewportRect.y ++ yAxis.maximum: root.viewportRect.y + root.height - toolBar.height ++ onActiveChanged: if (active) { ++ mainToolBar.z = mainToolBar.z > atbLoader.z ? 1 : 0 ++ atbLoader.z = mainToolBar.z < atbLoader.z ? 1 : 0 ++ ftbLoader.z = 2 ++ if (!toolBar.rememberPosition) { ++ toolBar.rememberPosition = true ++ } + } + } + } +diff --git a/src/Gui/ImageView.qml b/src/Gui/ImageView.qml +index ba68e4ca..2be177ec 100644 +--- a/src/Gui/ImageView.qml ++++ b/src/Gui/ImageView.qml +@@ -26,12 +26,11 @@ EmptyPage { + readonly property real minimumWidth: Math.max( + header.implicitWidth, + annotationsToolBar.implicitWidth + separator.implicitWidth + footerLoader.implicitWidth, +- captureOptionsLoader.implicitWidth + 480 // leave some room for content if necessary ++ 480 // leave some room for content if necessary + ) + readonly property real minimumHeight: header.implicitHeight + + Math.max(annotationsToolBar.implicitHeight, +- footerLoader.implicitHeight, +- captureOptionsLoader.implicitHeight) ++ footerLoader.implicitHeight) + + property var inlineMessageData: {} + property string inlineMessageSource: "" +@@ -47,12 +46,46 @@ EmptyPage { + + header: QQC.ToolBar { + id: header +- contentItem: MainToolBarContents { ++ contentItem: ButtonGrid { + id: mainToolBarContents +- showNewScreenshotButton: false +- showOptionsMenu: false +- showUndoRedo: contextWindow.annotating +- displayMode: QQC.AbstractButton.TextBesideIcon ++ animationsEnabled: true ++ AnimatedLoader { ++ state: contextWindow.annotating ? "active" : "inactive" ++ sourceComponent: UndoRedoGroup { ++ buttonHeight: QmlUtils.iconTextButtonHeight ++ spacing: mainToolBarContents.spacing ++ } ++ } ++ TtToolButton { ++ display: TtToolButton.IconOnly ++ visible: action.enabled ++ action: SaveAction {} ++ } ++ TtToolButton { ++ display: SpectacleCore.videoMode ? TtToolButton.TextBesideIcon : TtToolButton.IconOnly ++ action: SaveAsAction {} ++ } ++ TtToolButton { ++ display: TtToolButton.IconOnly ++ visible: action.enabled ++ action: CopyImageAction {} ++ } ++ // We only show this in video mode to save space in screenshot mode ++ TtToolButton { ++ visible: SpectacleCore.videoMode ++ action: CopyLocationAction {} ++ } ++ ExportMenuButton {} ++ TtToolButton { ++ visible: action.enabled ++ action: EditAction {} ++ } ++ QQC.ToolSeparator { ++ height: QmlUtils.iconTextButtonHeight ++ } ++ ScreenshotModeMenuButton {} ++ RecordingModeMenuButton {} ++ OptionsMenuButton {} + } + } + +@@ -64,7 +97,7 @@ EmptyPage { + AnimatedLoader { // parent is contentItem + id: inlineMessageLoader + anchors.left: annotationsToolBar.right +- anchors.right: captureOptionsLoader.left ++ anchors.right: parent.right + anchors.top: parent.top + state: "inactive" + height: visible ? implicitHeight : 0 +@@ -111,7 +144,7 @@ EmptyPage { + id: contentLoader + anchors { + left: footerLoader.left +- right: captureOptionsLoader.left ++ right: parent.right + top: inlineMessageLoader.bottom + bottom: footerLoader.top + } +@@ -126,78 +159,10 @@ EmptyPage { + } + } + +- Loader { // parent is contentItem +- id: captureOptionsLoader +- visible: true +- active: visible +- anchors { +- top: parent.top +- bottom: parent.bottom +- right: parent.right +- } +- width: Math.max(implicitWidth, Kirigami.Units.gridUnit * 15) +- sourceComponent: QQC.Page { +- +- leftPadding: Kirigami.Units.mediumSpacing * 2 +- + (!mirrored ? sideBarSeparator.implicitWidth : 0) +- rightPadding: Kirigami.Units.mediumSpacing * 2 +- + (mirrored ? sideBarSeparator.implicitWidth : 0) +- topPadding: Kirigami.Units.mediumSpacing * 2 +- bottomPadding: Kirigami.Units.mediumSpacing * 2 +- +- header: RowLayout { +- spacing: 0 +- Kirigami.Separator { +- Layout.fillHeight: true +- } +- Kirigami.NavigationTabBar { +- id: tabBar +- Layout.fillWidth: true +- visible: SpectacleCore.videoPlatform.supportedRecordingModes +- currentIndex: 0 +- Kirigami.Theme.colorSet: Kirigami.Theme.Window +- +- actions: [ +- Kirigami.Action { +- text: i18n("Screenshot") +- icon.name: "camera-photo" +- checked: tabBar.currentIndex === 0 +- }, +- Kirigami.Action { +- text: i18n("Recording") +- icon.name: "camera-video" +- checked: tabBar.currentIndex === 1 +- } +- ] +- } +- } +- +- contentItem: Loader { +- source: switch (tabBar.currentIndex) { +- case 0: return "CaptureOptions.qml" +- case 1: return "RecordOptions.qml" +- default: return "" +- } +- } +- +- background: Rectangle { +- color: Kirigami.Theme.backgroundColor +- Kirigami.Separator { +- id: sideBarSeparator +- anchors { +- left: parent.left +- top: parent.top +- bottom: parent.bottom +- } +- } +- } +- } +- } +- + Loader { + id: footerLoader + anchors.left: separator.right +- anchors.right: captureOptionsLoader.left ++ anchors.right: parent.right + anchors.top: parent.bottom + visible: false + active: visible +@@ -294,11 +259,6 @@ EmptyPage { + anchors.bottom: parent.bottom + anchors.top: undefined + } +- AnchorChanges { +- target: captureOptionsLoader +- anchors.left: parent.right +- anchors.right: undefined +- } + }, + State { + name: "normal" +@@ -315,11 +275,6 @@ EmptyPage { + anchors.bottom: undefined + anchors.top: parent.bottom + } +- AnchorChanges { +- target: captureOptionsLoader +- anchors.left: undefined +- anchors.right: parent.right +- } + } + ] + transitions: [ +@@ -335,21 +290,11 @@ EmptyPage { + duration: Kirigami.Units.longDuration + easing.type: Easing.OutCubic + } +- PropertyAction { +- targets: captureOptionsLoader +- property: "visible" +- value: false +- } + } + }, + Transition { + to: "normal" + SequentialAnimation { +- PropertyAction { +- targets: captureOptionsLoader +- property: "visible" +- value: true +- } + AnchorAnimation { + duration: Kirigami.Units.longDuration + easing.type: Easing.OutCubic +diff --git a/src/Gui/MainToolBarContents.qml b/src/Gui/MainToolBarContents.qml +deleted file mode 100644 +index eaf2ad01..00000000 +--- a/src/Gui/MainToolBarContents.qml ++++ /dev/null +@@ -1,153 +0,0 @@ +-/* SPDX-FileCopyrightText: 2022 Noah Davis +- * SPDX-License-Identifier: LGPL-2.0-or-later +- */ +- +-import QtQuick +-import QtQuick.Controls as QQC +-import org.kde.kirigami as Kirigami +-import org.kde.spectacle.private +- +-ButtonGrid { +- id: root +- property size imageSize: Qt.size(0, 0) +- property bool showSizeLabel: false +- property bool showUndoRedo: false +- property bool showNewScreenshotButton: true +- property bool showOptionsMenu: true +- +- component ToolButton: QQC.ToolButton { +- implicitHeight: QmlUtils.iconTextButtonHeight +- width: display === QQC.ToolButton.IconOnly ? height : implicitWidth +- focusPolicy: root.focusPolicy +- display: root.displayMode +- QQC.ToolTip.text: text +- QQC.ToolTip.visible: (hovered || pressed) && display === QQC.ToolButton.IconOnly +- QQC.ToolTip.delay: Kirigami.Units.toolTipDelay +- } +- +- AnimatedLoader { +- id: sizeLabelLoader +- state: root.showSizeLabel && root.imageSize.width > 0 && root.imageSize.height > 0 ? +- "active" : "inactive" +- sourceComponent: SizeLabel { +- height: QmlUtils.iconTextButtonHeight +- size: root.imageSize +- leftPadding: Kirigami.Units.mediumSpacing + QmlUtils.fontMetrics.descent +- rightPadding: leftPadding +- } +- } +- +- AnimatedLoader { +- state: root.showUndoRedo ? "active" : "inactive" +- sourceComponent: UndoRedoGroup { +- animationsEnabled: root.animationsEnabled +- buttonHeight: QmlUtils.iconTextButtonHeight +- focusPolicy: root.focusPolicy +- flow: root.flow +- spacing: root.spacing +- } +- } +- +- // We don't show this in video mode because the video is already automatically saved. +- // and you can't edit the video. +- ToolButton { +- visible: !SpectacleCore.videoMode +- icon.name: "document-save" +- text: i18n("Save") +- onClicked: contextWindow.save() +- } +- +- ToolButton { +- icon.name: "document-save-as" +- text: i18n("Save As...") +- onClicked: contextWindow.saveAs() +- } +- +- // We don't show this in video mode because you can't copy raw video to the clipboard, +- // or at least not elegantly. +- ToolButton { +- visible: !SpectacleCore.videoMode +- icon.name: "edit-copy" +- text: i18n("Copy") +- onClicked: contextWindow.copyImage() +- } +- +- // We only show this in video mode to save space in screenshot mode +- ToolButton { +- visible: SpectacleCore.videoMode +- icon.name: "edit-copy-path" +- text: i18n("Copy Location") +- onClicked: contextWindow.copyLocation() +- } +- +- ToolButton { +- // FIXME: make export menu actually work with videos +- visible: !SpectacleCore.videoMode +- icon.name: "document-share" +- text: i18n("Export") +- down: pressed || ExportMenu.visible +- Accessible.role: Accessible.ButtonMenu +- onPressed: ExportMenu.popup(this) +- } +- +- ToolButton { +- id: annotationsButton +- icon.name: "edit-image" +- text: i18nc("@action:button edit screenshot", "Edit…") +- visible: !SpectacleCore.videoMode +- checkable: true +- checked: contextWindow.annotating +- onToggled: contextWindow.annotating = checked +- } +- +- ToolButton { +- // Can't rely on checked since clicking also toggles checked +- readonly property bool showCancel: SpectacleCore.captureTimeRemaining > 0 +- readonly property real cancelWidth: QmlUtils.getButtonSize(display, cancelText(Settings.captureDelay), icon.name).width +- +- function cancelText(seconds) { +- return i18np("Cancel (%1 second)", "Cancel (%1 seconds)", Math.ceil(seconds)) +- } +- +- visible: root.showNewScreenshotButton +- checked: showCancel +- width: if (showCancel) { +- return cancelWidth +- } else { +- return display === QQC.ToolButton.IconOnly ? height : implicitWidth +- } +- icon.name: showCancel ? "dialog-cancel" : "list-add" +- text: showCancel ? +- cancelText(SpectacleCore.captureTimeRemaining / 1000) +- : i18n("New Screenshot") +- onClicked: if (showCancel) { +- SpectacleCore.cancelScreenshot() +- } else { +- SpectacleCore.takeNewScreenshot() +- } +- } +- +- ToolButton { +- visible: root.showOptionsMenu +- icon.name: "configure" +- text: i18n("Options") +- down: pressed || OptionsMenu.visible +- Accessible.role: Accessible.ButtonMenu +- onPressed: OptionsMenu.popup(this) +- } +- ToolButton { +- visible: !root.showOptionsMenu +- icon.name: "configure" +- text: i18n("Configure...") +- onClicked: OptionsMenu.showPreferencesDialog(); +- } +- +- ToolButton { +- id: helpButton +- icon.name: "help-contents" +- text: i18n("Help") +- down: pressed || HelpMenu.visible +- Accessible.role: Accessible.ButtonMenu +- onPressed: HelpMenu.popup(this) +- } +-} +diff --git a/src/Gui/NewScreenshotToolButton.qml b/src/Gui/NewScreenshotToolButton.qml +new file mode 100644 +index 00000000..4c7aa91a +--- /dev/null ++++ b/src/Gui/NewScreenshotToolButton.qml +@@ -0,0 +1,33 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++import QtQuick ++import org.kde.kirigami as Kirigami ++import org.kde.spectacle.private ++ ++TtToolButton { ++ // Can't rely on checked since clicking also toggles checked ++ readonly property bool showCancel: SpectacleCore.captureTimeRemaining > 0 ++ readonly property real cancelWidth: QmlUtils.getButtonSize(display, cancelText(Settings.captureDelay), icon.name).width ++ ++ function cancelText(seconds) { ++ return i18np("Cancel (%1 second)", "Cancel (%1 seconds)", Math.ceil(seconds)) ++ } ++ ++ checked: showCancel ++ width: if (showCancel) { ++ return cancelWidth ++ } else { ++ return display === TtToolButton.IconOnly ? height : implicitWidth ++ } ++ icon.name: showCancel ? "dialog-cancel" : "list-add" ++ text: showCancel ? ++ cancelText(SpectacleCore.captureTimeRemaining / 1000) ++ : i18n("New Screenshot") ++ onClicked: if (showCancel) { ++ SpectacleCore.cancelScreenshot() ++ } else { ++ SpectacleCore.takeNewScreenshot() ++ } ++} +diff --git a/src/Gui/OptionsMenu.cpp b/src/Gui/OptionsMenu.cpp +index e031f8f1..d9ac01da 100644 +--- a/src/Gui/OptionsMenu.cpp ++++ b/src/Gui/OptionsMenu.cpp +@@ -5,15 +5,21 @@ + #include "OptionsMenu.h" + + #include "CaptureModeModel.h" ++#include "Gui/SmartSpinBox.h" + #include "Gui/SettingsDialog/SettingsDialog.h" + #include "SpectacleCore.h" + #include "WidgetWindowUtils.h" ++#include "HelpMenu.h" + #include "settings.h" + + #include + #include + ++#include ++#include ++#include + #include ++#include + + using namespace Qt::StringLiterals; + +@@ -21,158 +27,129 @@ static QPointer s_instance = nullptr; + + OptionsMenu::OptionsMenu(QWidget *parent) + : SpectacleMenu(parent) +- , captureModeSection(new QAction(this)) +- , captureModeGroup(new QActionGroup(this)) // exclusive by default) +- , captureSettingsSection(new QAction(this)) +- , includeMousePointerAction(new QAction(this)) +- , includeWindowDecorationsAction(new QAction(this)) +- , includeWindowShadowAction(new QAction(this)) +- , onlyCapturePopupAction(new QAction(this)) +- , quitAfterSaveAction(new QAction(this)) +- , captureOnClickAction(new QAction(this)) +- , delayAction(new QWidgetAction(this)) +- , delayWidget(new QWidget(this)) +- , delayLayout(new QHBoxLayout(delayWidget.get())) +- , delayLabel(new QLabel(delayWidget.get())) +- , delaySpinBox(new SmartSpinBox(delayWidget.get())) ++ , m_delayAction(new QWidgetAction(this)) ++ , m_delayWidget(new QWidget(this)) ++ , m_delayLayout(new QHBoxLayout(m_delayWidget.get())) ++ , m_delayLabel(new QLabel(m_delayWidget.get())) ++ , m_delaySpinBox(new SmartSpinBox(m_delayWidget.get())) + { +- addAction(KStandardActions::preferences(this, &OptionsMenu::showPreferencesDialog, this)); +- ++ setToolTipsVisible(true); + // QMenu::addSection just adds an action with text and separator mode enabled +- captureModeSection->setText(i18n("Capture Mode")); +- captureModeSection->setSeparator(true); +- addAction(captureModeSection.get()); ++ addSection(i18nc("@title:menu", "Screenshot Settings")); + +- // Add capture mode actions. +- // This cannot be done in the constructor because captureModeModel will be null at this time. +- connect(this, &OptionsMenu::aboutToShow, +- this, &OptionsMenu::updateCaptureModes); +- +- // make capture mode actions do things +- connect(captureModeGroup.get(), &QActionGroup::triggered, this, [](QAction *action){ +- int mode = action->data().toInt(); +- Settings::setCaptureMode(mode); +- }); +- connect(Settings::self(), &Settings::captureModeChanged, this, [this](){ +- int mode = Settings::captureMode(); +- if (captureModeGroup->checkedAction() && mode == captureModeGroup->checkedAction()->data().toInt()) { +- return; +- } +- for (auto action : std::as_const(captureModeActions)) { +- if (mode == action->data().toInt()) { +- action->setChecked(true); +- } +- } +- }); +- +- captureSettingsSection->setText(i18n("Capture Settings")); +- captureSettingsSection->setSeparator(true); +- addAction(captureSettingsSection.get()); +- +- includeMousePointerAction->setText(i18n("Include mouse pointer")); +- includeMousePointerAction->setToolTip(i18n("Show the mouse cursor in the screenshot image")); ++ auto includeMousePointerAction = addAction(i18nc("@option:check for screenshots", "Include mouse pointer")); ++ includeMousePointerAction->setToolTip(i18nc("@info:tooltip", "Show the mouse cursor in the screenshot image")); + includeMousePointerAction->setCheckable(true); + includeMousePointerAction->setChecked(Settings::includePointer()); +- connect(includeMousePointerAction.get(), &QAction::toggled, this, [](bool checked){ +- Settings::setIncludePointer(checked); +- }); +- connect(Settings::self(), &Settings::includePointerChanged, this, [this](){ ++ QObject::connect(includeMousePointerAction, &QAction::toggled, Settings::self(), &Settings::setIncludePointer); ++ QObject::connect(Settings::self(), &Settings::includePointerChanged, includeMousePointerAction, [includeMousePointerAction](){ + includeMousePointerAction->setChecked(Settings::includePointer()); + }); +- addAction(includeMousePointerAction.get()); + +- includeWindowDecorationsAction->setText(i18n("Include window titlebar and borders")); +- includeWindowDecorationsAction->setToolTip(i18n("Show the window title bar, the minimize/maximize/close buttons, and the window border")); ++ auto includeWindowDecorationsAction = addAction(i18nc("@option:check", "Include window titlebar and borders")); ++ includeWindowDecorationsAction->setToolTip(i18nc("@info:tooltip", "Show the window title bar, the minimize/maximize/close buttons, and the window border")); + includeWindowDecorationsAction->setCheckable(true); + includeWindowDecorationsAction->setChecked(Settings::includeDecorations()); +- connect(includeWindowDecorationsAction.get(), &QAction::toggled, this, [](bool checked){ +- Settings::setIncludeDecorations(checked); +- }); +- connect(Settings::self(), &Settings::includeDecorationsChanged, this, [this](){ ++ QObject::connect(includeWindowDecorationsAction, &QAction::toggled, Settings::self(), Settings::setIncludeDecorations); ++ QObject::connect(Settings::self(), &Settings::includeDecorationsChanged, includeWindowDecorationsAction, [includeWindowDecorationsAction](){ + includeWindowDecorationsAction->setChecked(Settings::includeDecorations()); + }); +- addAction(includeWindowDecorationsAction.get()); + +- includeWindowShadowAction->setText(i18n("Include window shadow")); +- includeWindowShadowAction->setToolTip(i18n("Show the window shadow")); ++ auto includeWindowShadowAction = addAction(i18nc("@option:check", "Include window shadow")); ++ includeWindowShadowAction->setToolTip(i18nc("@info:tooltip", "Show the window shadow")); + includeWindowShadowAction->setCheckable(true); + includeWindowShadowAction->setChecked(Settings::includeShadow()); +- connect(includeWindowShadowAction.get(), &QAction::toggled, this, [](bool checked) { +- Settings::setIncludeShadow(checked); +- }); +- connect(Settings::self(), &Settings::includeShadowChanged, this, [this]() { ++ QObject::connect(includeWindowShadowAction, &QAction::toggled, Settings::self(), &Settings::setIncludeShadow); ++ QObject::connect(Settings::self(), &Settings::includeShadowChanged, includeWindowShadowAction, [includeWindowShadowAction]() { + includeWindowShadowAction->setChecked(Settings::includeShadow()); + }); +- addAction(includeWindowShadowAction.get()); + +- onlyCapturePopupAction->setText(i18n("Capture the current pop-up only")); +- onlyCapturePopupAction->setToolTip( +- i18n("Capture only the current pop-up window (like a menu, tooltip etc).\n" +- "If disabled, the pop-up is captured along with the parent window")); +- onlyCapturePopupAction->setCheckable(true); +- onlyCapturePopupAction->setChecked(Settings::transientOnly()); +- connect(onlyCapturePopupAction.get(), &QAction::toggled, this, [](bool checked){ +- Settings::setTransientOnly(checked); +- }); +- connect(Settings::self(), &Settings::transientOnlyChanged, this, [this](){ ++ const bool hasTransientWithParent = SpectacleCore::instance()->imagePlatform()->supportedGrabModes().testFlag(ImagePlatform::TransientWithParent); ++ if (hasTransientWithParent) { ++ auto onlyCapturePopupAction = addAction(i18nc("@option:check", "Capture the current pop-up only")); ++ onlyCapturePopupAction->setToolTip( ++ i18nc("@info:tooltip", "Capture only the current pop-up window (like a menu, tooltip etc).\n" ++ "If disabled, the pop-up is captured along with the parent window")); ++ onlyCapturePopupAction->setCheckable(true); + onlyCapturePopupAction->setChecked(Settings::transientOnly()); +- }); +- addAction(onlyCapturePopupAction.get()); ++ QObject::connect(onlyCapturePopupAction, &QAction::toggled, Settings::self(), &Settings::setTransientOnly); ++ QObject::connect(Settings::self(), &Settings::transientOnlyChanged, onlyCapturePopupAction, [onlyCapturePopupAction](){ ++ onlyCapturePopupAction->setChecked(Settings::transientOnly()); ++ }); ++ } + +- quitAfterSaveAction->setText(i18n("Quit after manual Save or Copy")); +- quitAfterSaveAction->setToolTip(i18n("Quit Spectacle after manually saving or copying the image")); ++ auto quitAfterSaveAction = addAction(i18nc("@option:check", "Quit after manual Save or Copy")); ++ quitAfterSaveAction->setToolTip(i18nc("@info:tooltip", "Quit Spectacle after manually saving or copying the image")); + quitAfterSaveAction->setCheckable(true); + quitAfterSaveAction->setChecked(Settings::quitAfterSaveCopyExport()); +- connect(quitAfterSaveAction.get(), &QAction::toggled, this, [](bool checked){ +- Settings::setQuitAfterSaveCopyExport(checked); +- }); +- connect(Settings::self(), &Settings::quitAfterSaveCopyExportChanged, this, [this](){ ++ QObject::connect(quitAfterSaveAction, &QAction::toggled, Settings::self(), &Settings::setQuitAfterSaveCopyExport); ++ QObject::connect(Settings::self(), &Settings::quitAfterSaveCopyExportChanged, quitAfterSaveAction, [quitAfterSaveAction](){ + quitAfterSaveAction->setChecked(Settings::quitAfterSaveCopyExport()); + }); +- addAction(quitAfterSaveAction.get()); + + // add capture on click + const bool hasOnClick = SpectacleCore::instance()->imagePlatform()->supportedShutterModes().testFlag(ImagePlatform::OnClick); +- addSeparator()->setVisible(hasOnClick); +- captureOnClickAction->setText(i18n("Capture On Click")); +- captureOnClickAction->setCheckable(true); +- captureOnClickAction->setChecked(Settings::captureOnClick() && hasOnClick); +- captureOnClickAction->setVisible(hasOnClick); +- connect(captureOnClickAction.get(), &QAction::toggled, this, [this](bool checked){ +- Settings::setCaptureOnClick(checked); +- delayAction->setEnabled(!checked); +- }); +- connect(Settings::self(), &Settings::captureOnClickChanged, this, [this](){ ++ if (hasOnClick) { ++ addSeparator(); ++ auto captureOnClickAction = addAction(i18nc("@option:check", "Capture On Click")); ++ captureOnClickAction->setCheckable(true); + captureOnClickAction->setChecked(Settings::captureOnClick()); +- }); +- addAction(captureOnClickAction.get()); ++ QObject::connect(captureOnClickAction, &QAction::toggled, this, [this](bool checked){ ++ Settings::setCaptureOnClick(checked); ++ m_delayAction->setEnabled(!checked); ++ }); ++ QObject::connect(Settings::self(), &Settings::captureOnClickChanged, captureOnClickAction, [captureOnClickAction](){ ++ captureOnClickAction->setChecked(Settings::captureOnClick()); ++ }); ++ } + + // set up delay widget +- auto spinbox = delaySpinBox.get(); +- auto label = delayLabel.get(); +- label->setText(i18n("Delay:")); ++ auto spinbox = m_delaySpinBox.get(); ++ auto label = m_delayLabel.get(); ++ label->setText(i18nc("@label:spinbox", "Delay:")); + spinbox->setDecimals(1); + spinbox->setSingleStep(1.0); + spinbox->setMinimum(0.0); + spinbox->setMaximum(999); +- spinbox->setSpecialValueText(i18n("No Delay")); ++ spinbox->setSpecialValueText(i18nc("@item 0 delay special value", "No Delay")); + delayActionLayoutUpdate(); +- connect(spinbox, qOverload(&SmartSpinBox::valueChanged), this, [this](){ +- if (updatingDelayActionLayout) { ++ QObject::connect(spinbox, qOverload(&SmartSpinBox::valueChanged), this, [this](){ ++ if (m_updatingDelayActionLayout) { + return; + } +- Settings::setCaptureDelay(delaySpinBox->value()); ++ Settings::setCaptureDelay(m_delaySpinBox->value()); ++ }); ++ QObject::connect(Settings::self(), &Settings::captureDelayChanged, spinbox, [this](){ ++ m_delaySpinBox->setValue(Settings::captureDelay()); + }); +- connect(Settings::self(), &Settings::captureDelayChanged, this, [this](){ +- delaySpinBox->setValue(Settings::captureDelay()); ++ m_delayWidget->setLayout(m_delayLayout.get()); ++ m_delayLayout->addWidget(label); ++ m_delayLayout->addWidget(spinbox); ++ m_delayLayout->setAlignment(Qt::AlignLeft); ++ m_delayAction->setDefaultWidget(m_delayWidget.get()); ++ m_delayAction->setEnabled(!hasOnClick || !Settings::captureOnClick()); ++ addAction(m_delayAction.get()); ++ ++ addSection(i18nc("@title:menu", "Recording Settings")); ++ ++ auto videoIncludeMousePointerAction = addAction(i18nc("@option:check for recordings", "Include mouse pointer")); ++ videoIncludeMousePointerAction->setToolTip(i18nc("@info:tooltip", "Show the mouse cursor in the recording")); ++ videoIncludeMousePointerAction->setCheckable(true); ++ videoIncludeMousePointerAction->setChecked(Settings::videoIncludePointer()); ++ QObject::connect(videoIncludeMousePointerAction, &QAction::toggled, Settings::self(), &Settings::setVideoIncludePointer); ++ QObject::connect(Settings::self(), &Settings::videoIncludePointerChanged, videoIncludeMousePointerAction, [videoIncludeMousePointerAction](){ ++ videoIncludeMousePointerAction->setChecked(Settings::videoIncludePointer()); + }); +- delayWidget->setLayout(delayLayout.get()); +- delayLayout->addWidget(label); +- delayLayout->addWidget(spinbox); +- delayLayout->setAlignment(Qt::AlignLeft); +- delayAction->setDefaultWidget(delayWidget.get()); +- delayAction->setEnabled(!captureOnClickAction->isChecked()); +- addAction(delayAction.get()); ++ ++ addSeparator(); ++ ++ addAction(KStandardActions::preferences(this, &OptionsMenu::showPreferencesDialog, this)); ++ ++ addMenu(HelpMenu::instance()); ++ connect(this, &OptionsMenu::aboutToShow, ++ this, [this] { ++ setWidgetTransientParentToWidget(HelpMenu::instance(), this); ++ }); + } + + OptionsMenu *OptionsMenu::instance() +@@ -201,9 +178,26 @@ void OptionsMenu::showPreferencesDialog() + dialog->show(); + } + +-void OptionsMenu::setCaptureModeOptionsEnabled(bool enabled) ++void OptionsMenu::delayActionLayoutUpdate() + { +- captureModeOptionsEnabled = enabled; ++ // We can't block signals while doing this to prevent unnecessary ++ // processing because the spinbox has internal connections that need ++ // to work in order to get the correct size. ++ // We use our own guarding variable instead. ++ m_updatingDelayActionLayout = true; ++ m_delaySpinBox->setValue(m_delaySpinBox->maximum()); ++ m_delaySpinBox->setMinimumWidth(m_delaySpinBox->sizeHint().width()); ++ m_delaySpinBox->setValue(Settings::captureDelay()); ++ m_updatingDelayActionLayout = false; ++ ++ int menuHMargin = style()->pixelMetric(QStyle::PM_MenuHMargin); ++ int menuVMargin = style()->pixelMetric(QStyle::PM_MenuVMargin); ++ if (layoutDirection() == Qt::RightToLeft) { ++ m_delayLabel->setContentsMargins(0, 0, menuHMargin + m_delayLabel->fontMetrics().descent(), 0); ++ } else { ++ m_delayLabel->setContentsMargins(menuHMargin + m_delayLabel->fontMetrics().descent(), 0, 0, 0); ++ } ++ m_delayLayout->setContentsMargins(0, menuVMargin, 0, 0); + } + + void OptionsMenu::changeEvent(QEvent *event) +@@ -219,80 +213,30 @@ void OptionsMenu::changeEvent(QEvent *event) + QWidget::changeEvent(event); + } + +-void OptionsMenu::delayActionLayoutUpdate() ++void OptionsMenu::keyPressEvent(QKeyEvent *event) + { +- // We can't block signals while doing this to prevent unnecessary +- // processing because the spinbox has internal connections that need +- // to work in order to get the correct size. +- // We use our own guarding variable instead. +- updatingDelayActionLayout = true; +- delaySpinBox->setValue(delaySpinBox->maximum()); +- delaySpinBox->setMinimumWidth(delaySpinBox->sizeHint().width()); +- delaySpinBox->setValue(Settings::captureDelay()); +- updatingDelayActionLayout = false; +- +- int menuHMargin = style()->pixelMetric(QStyle::PM_MenuHMargin); +- int menuVMargin = style()->pixelMetric(QStyle::PM_MenuVMargin); +- if (layoutDirection() == Qt::RightToLeft) { +- delayLabel->setContentsMargins(0, 0, menuHMargin + delayLabel->fontMetrics().descent(), 0); +- } else { +- delayLabel->setContentsMargins(menuHMargin + delayLabel->fontMetrics().descent(), 0, 0, 0); ++ // Try to keep menu open when triggering checkable actions ++ const auto key = event->key(); ++ const auto action = activeAction(); ++ if (action && action->isEnabled() && action->isCheckable() // ++ && (key == Qt::Key_Return || key == Qt::Key_Enter // ++ || (key == Qt::Key_Space && style()->styleHint(QStyle::SH_Menu_SpaceActivatesItem, nullptr, this)))) { ++ action->trigger(); ++ event->accept(); ++ return; + } +- delayLayout->setContentsMargins(0, menuVMargin, 0, 0); ++ SpectacleMenu::keyPressEvent(event); + } + +-void OptionsMenu::updateCaptureModes() ++void OptionsMenu::mouseReleaseEvent(QMouseEvent *event) + { +- captureModeSection->setVisible(captureModeOptionsEnabled); +- if (!captureModeOptionsEnabled) { +- for (auto action : std::as_const(captureModeActions)) { +- captureModeGroup->removeAction(action); +- removeAction(action); +- action->deleteLater(); +- } +- captureModeActions.clear(); +- return; +- } +- +- if (!captureModeModel) { +- captureModeModel = std::make_unique(); +- } +- +- // Only make this conneciton once. +- // Can't be done in the constructor because captureModeModel is null at that time. +- if (!captureModesInitialized) { +- connect(captureModeModel.get(), &CaptureModeModel::captureModesChanged, this, [this]() { +- shouldUpdateCaptureModes = true; +- }); +- captureModesInitialized = true; +- } +- // avoid unnecessarily resetting actions +- if (!shouldUpdateCaptureModes) { ++ // Try to keep menu open when triggering checkable actions ++ const auto action = activeAction() == actionAt(event->position().toPoint()) ? activeAction() : nullptr; ++ if (action && action->isEnabled() && action->isCheckable()) { ++ action->trigger(); + return; + } +- shouldUpdateCaptureModes = false; +- for (auto action : std::as_const(captureModeActions)) { +- captureModeGroup->removeAction(action); +- removeAction(action); +- action->deleteLater(); +- } +- captureModeActions.clear(); +- for (int i = 0; i < captureModeModel->rowCount(); ++i) { +- auto index = captureModeModel->index(i); +- auto action = new QAction(this); +- captureModeActions.append(action); +- action->setText(captureModeModel->data(index, Qt::DisplayRole).toString()); +- const auto mode = captureModeModel->data(index, CaptureModeModel::CaptureModeRole).toInt(); +- action->setData(mode); +- action->setCheckable(true); +- if (!CaptureWindow::instances().empty() && !SpectacleCore::instance()->videoMode()) { +- action->setChecked(mode == CaptureModeModel::RectangularRegion); +- } else if (mode == Settings::captureMode()) { +- action->setChecked(true); +- } +- captureModeGroup->addAction(action); +- insertAction(captureSettingsSection.get(), action); +- } ++ SpectacleMenu::mouseReleaseEvent(event); + } + + #include "moc_OptionsMenu.cpp" +diff --git a/src/Gui/OptionsMenu.h b/src/Gui/OptionsMenu.h +index 5f87d097..b600f3f4 100644 +--- a/src/Gui/OptionsMenu.h ++++ b/src/Gui/OptionsMenu.h +@@ -6,20 +6,13 @@ + #define OPTIONSMENU_H + + #include "SpectacleMenu.h" +- + #include "Gui/SmartSpinBox.h" + +-#include + #include + #include +-#include + #include + #include + +-#include +- +-class CaptureModeModel; +- + /** + * A menu that allows choosing capture modes and related options. + */ +@@ -34,8 +27,6 @@ public: + + Q_SLOT void showPreferencesDialog(); + +- void setCaptureModeOptionsEnabled(bool enabled); +- + static OptionsMenu *create(QQmlEngine *engine, QJSEngine *) + { + auto inst = instance(); +@@ -47,37 +38,18 @@ public: + + protected: + void changeEvent(QEvent *event) override; ++ void keyPressEvent(QKeyEvent *event) override; ++ void mouseReleaseEvent(QMouseEvent *event) override; + +-private: + explicit OptionsMenu(QWidget *parent = nullptr); + + void delayActionLayoutUpdate(); +- Q_SLOT void updateCaptureModes(); +- +- QList captureModeActions; +- const std::unique_ptr captureModeSection; +- const std::unique_ptr captureModeGroup; +- const std::unique_ptr captureSettingsSection; +- const std::unique_ptr includeMousePointerAction; +- const std::unique_ptr includeWindowDecorationsAction; +- const std::unique_ptr includeWindowShadowAction; +- const std::unique_ptr onlyCapturePopupAction; +- const std::unique_ptr quitAfterSaveAction; +- const std::unique_ptr captureOnClickAction; +- const std::unique_ptr delayAction; +- const std::unique_ptr delayWidget; +- const std::unique_ptr delayLayout; +- const std::unique_ptr delayLabel; +- const std::unique_ptr delaySpinBox; +- +- std::unique_ptr captureModeModel; +- +- bool captureModesInitialized = false; +- bool shouldUpdateCaptureModes = true; +- bool updatingDelayActionLayout = false; +- bool captureModeOptionsEnabled = true; +- +- friend class OptionsMenuSingleton; ++ const std::unique_ptr m_delayAction; ++ const std::unique_ptr m_delayWidget; ++ const std::unique_ptr m_delayLayout; ++ const std::unique_ptr m_delayLabel; ++ const std::unique_ptr m_delaySpinBox; ++ bool m_updatingDelayActionLayout = false; + }; + + #endif // OPTIONSMENU_H +diff --git a/src/Gui/OptionsMenuButton.qml b/src/Gui/OptionsMenuButton.qml +new file mode 100644 +index 00000000..5a86ff96 +--- /dev/null ++++ b/src/Gui/OptionsMenuButton.qml +@@ -0,0 +1,14 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++import QtQuick ++import org.kde.spectacle.private ++ ++TtToolButton { ++ icon.name: "configure" ++ text: i18nc("@action", "Options") ++ down: pressed || OptionsMenu.visible ++ Accessible.role: Accessible.ButtonMenu ++ onPressed: OptionsMenu.popup(this) ++} +diff --git a/src/Gui/Outline.qml b/src/Gui/Outline.qml +index 158e356f..85d9f6ca 100644 +--- a/src/Gui/Outline.qml ++++ b/src/Gui/Outline.qml +@@ -17,6 +17,7 @@ Shape { + property alias joinStyle: shapePath.joinStyle + property alias svgPath: pathSvg.path + property alias pathScale: shapePath.scale ++ property alias pathHints: shapePath.pathHints + + // Get a rectangular SVG path + function rectanglePath(x, y, w, h) { +diff --git a/src/Gui/RecordAction.qml b/src/Gui/RecordAction.qml +new file mode 100644 +index 00000000..7c7a83df +--- /dev/null ++++ b/src/Gui/RecordAction.qml +@@ -0,0 +1,13 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++import QtQuick.Templates as T ++import org.kde.spectacle.private ++ ++T.Action { ++ enabled: SpectacleCore.videoMode ++ icon.name: "media-record" ++ text: i18nc("@action start recording", "Record") ++ onTriggered: contextWindow.accept() ++} +diff --git a/src/Gui/RecordingModeButtonsColumn.qml b/src/Gui/RecordingModeButtonsColumn.qml +index 58644a97..2737f620 100644 +--- a/src/Gui/RecordingModeButtonsColumn.qml ++++ b/src/Gui/RecordingModeButtonsColumn.qml +@@ -12,7 +12,7 @@ import org.kde.spectacle.private + ColumnLayout { + spacing: Kirigami.Units.mediumSpacing + Repeater { +- model: RecordingModeModel { } ++ model: RecordingModeModel + delegate: QQC.Button { + id: button + Layout.fillWidth: true +diff --git a/src/Gui/RecordingModeMenu.cpp b/src/Gui/RecordingModeMenu.cpp +new file mode 100644 +index 00000000..d7451fa7 +--- /dev/null ++++ b/src/Gui/RecordingModeMenu.cpp +@@ -0,0 +1,65 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++#include "RecordingModeMenu.h" ++#include "RecordingModeModel.h" ++#include "SpectacleCore.h" ++#include "ShortcutActions.h" ++#include ++ ++using namespace Qt::StringLiterals; ++ ++static QPointer s_instance = nullptr; ++ ++RecordingModeMenu::RecordingModeMenu(QWidget *parent) ++ : SpectacleMenu(i18nc("@title:menu", "Recording Modes"), parent) ++{ ++ auto addModes = [this] { ++ clear(); ++ auto model = RecordingModeModel::instance(); ++ for (auto idx = model->index(0); idx.isValid(); idx = idx.siblingAtRow(idx.row() + 1)) { ++ const auto action = addAction(idx.data(Qt::DisplayRole).toString()); ++ const auto mode = idx.data(RecordingModeModel::RecordingModeRole).value(); ++ QAction *globalAction = nullptr; ++ auto globalShortcuts = [](QAction *globalAction) { ++ if (!globalAction) { ++ return QList{}; ++ } ++ auto component = ShortcutActions::self()->componentName(); ++ auto id = globalAction->objectName(); ++ return KGlobalAccel::self()->globalShortcut(component, id); ++ }; ++ switch (mode) { ++ case VideoPlatform::Region: ++ globalAction = ShortcutActions::self()->recordRegionAction(); ++ break; ++ case VideoPlatform::Screen: ++ globalAction = ShortcutActions::self()->recordScreenAction(); ++ break; ++ case VideoPlatform::Window: ++ globalAction = ShortcutActions::self()->recordWindowAction(); ++ break; ++ default: ++ break; ++ } ++ action->setShortcuts(globalShortcuts(globalAction)); ++ auto onTriggered = [mode] { ++ SpectacleCore::instance()->startRecording(mode); ++ }; ++ connect(action, &QAction::triggered, action, onTriggered); ++ } ++ }; ++ addModes(); ++ connect(RecordingModeModel::instance(), &RecordingModeModel::recordingModesChanged, this, addModes); ++} ++ ++RecordingModeMenu *RecordingModeMenu::instance() ++{ ++ if (!s_instance) { ++ s_instance = new RecordingModeMenu; ++ } ++ return s_instance; ++} ++ ++#include "moc_RecordingModeMenu.cpp" +diff --git a/src/Gui/RecordingModeMenu.h b/src/Gui/RecordingModeMenu.h +new file mode 100644 +index 00000000..fe8f0a71 +--- /dev/null ++++ b/src/Gui/RecordingModeMenu.h +@@ -0,0 +1,30 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++#pragma once ++ ++#include "SpectacleMenu.h" ++#include ++ ++class RecordingModeMenu : public SpectacleMenu ++{ ++ Q_OBJECT ++ QML_ELEMENT ++ QML_SINGLETON ++ ++public: ++ static RecordingModeMenu *instance(); ++ ++ static RecordingModeMenu *create(QQmlEngine *engine, QJSEngine *) ++ { ++ auto inst = instance(); ++ Q_ASSERT(inst); ++ Q_ASSERT(inst->thread() == engine->thread()); ++ QJSEngine::setObjectOwnership(inst, QJSEngine::CppOwnership); ++ return inst; ++ } ++ ++private: ++ explicit RecordingModeMenu(QWidget *parent = nullptr); ++}; +diff --git a/src/Gui/RecordingModeMenuButton.qml b/src/Gui/RecordingModeMenuButton.qml +new file mode 100644 +index 00000000..54d40666 +--- /dev/null ++++ b/src/Gui/RecordingModeMenuButton.qml +@@ -0,0 +1,15 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++import QtQuick ++import QtQuick.Controls as QQC ++import org.kde.spectacle.private ++ ++TtToolButton { ++ icon.name: "camera-video" ++ text: i18nc("@action select new recording mode", "New Recording") ++ down: pressed || RecordingModeMenu.visible ++ Accessible.role: Accessible.ButtonMenu ++ onPressed: RecordingModeMenu.popup(this) ++} +diff --git a/src/Gui/RecordingView.qml b/src/Gui/RecordingView.qml +index fc7b6040..a4e747ae 100644 +--- a/src/Gui/RecordingView.qml ++++ b/src/Gui/RecordingView.qml +@@ -87,13 +87,6 @@ FocusScope { + id: tbHoverHandler + } + +- component ToolButton: QQC.ToolButton { +- display: QQC.ToolButton.IconOnly +- QQC.ToolTip.text: text +- QQC.ToolTip.visible: (hovered || pressed) && display === QQC.ToolButton.IconOnly +- QQC.ToolTip.delay: Kirigami.Units.toolTipDelay +- } +- + FloatingToolBar { + id: toolBar + anchors.left: parent.left +@@ -105,7 +98,7 @@ FocusScope { + enabled: root.hasContent + contentItem: RowLayout { + spacing: parent.spacing +- ToolButton { ++ TtToolButton { + id: playPauseButton + containmentMask: Item { + parent: playPauseButton +diff --git a/src/Gui/SaveAction.qml b/src/Gui/SaveAction.qml +new file mode 100644 +index 00000000..084bda25 +--- /dev/null ++++ b/src/Gui/SaveAction.qml +@@ -0,0 +1,15 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++import QtQuick.Templates as T ++import org.kde.spectacle.private ++ ++T.Action { ++ // We don't use this in video mode because the video is already ++ // automatically saved and you can't edit the video. ++ enabled: !SpectacleCore.videoMode ++ icon.name: "document-save" ++ text: i18nc("@action", "Save") ++ onTriggered: contextWindow.save() ++} +diff --git a/src/Gui/SaveAsAction.qml b/src/Gui/SaveAsAction.qml +new file mode 100644 +index 00000000..371dfeca +--- /dev/null ++++ b/src/Gui/SaveAsAction.qml +@@ -0,0 +1,11 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++import QtQuick.Templates as T ++ ++T.Action { ++ icon.name: "document-save-as" ++ text: i18nc("@action", "Save As…") ++ onTriggered: contextWindow.saveAs() ++} +diff --git a/src/Gui/ScreenshotModeMenu.cpp b/src/Gui/ScreenshotModeMenu.cpp +new file mode 100644 +index 00000000..341ae7ec +--- /dev/null ++++ b/src/Gui/ScreenshotModeMenu.cpp +@@ -0,0 +1,74 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++#include "ScreenshotModeMenu.h" ++#include "CaptureModeModel.h" ++#include "SpectacleCore.h" ++#include "ShortcutActions.h" ++#include ++ ++using namespace Qt::StringLiterals; ++ ++static QPointer s_instance = nullptr; ++ ++ScreenshotModeMenu::ScreenshotModeMenu(QWidget *parent) ++ : SpectacleMenu(i18nc("@title:menu", "Screenshot Modes"), parent) ++{ ++ auto addModes = [this] { ++ clear(); ++ auto model = CaptureModeModel::instance(); ++ for (auto idx = model->index(0); idx.isValid(); idx = idx.siblingAtRow(idx.row() + 1)) { ++ const auto action = addAction(idx.data(Qt::DisplayRole).toString()); ++ const auto mode = idx.data(CaptureModeModel::CaptureModeRole).value(); ++ QAction *globalAction = nullptr; ++ auto globalShortcuts = [](QAction *globalAction) { ++ if (!globalAction) { ++ return QList{}; ++ } ++ auto component = ShortcutActions::self()->componentName(); ++ auto id = globalAction->objectName(); ++ return KGlobalAccel::self()->globalShortcut(component, id); ++ }; ++ switch (mode) { ++ case CaptureModeModel::RectangularRegion: ++ globalAction = ShortcutActions::self()->regionAction(); ++ break; ++ case CaptureModeModel::AllScreens: ++ globalAction = ShortcutActions::self()->fullScreenAction(); ++ break; ++ case CaptureModeModel::CurrentScreen: ++ globalAction = ShortcutActions::self()->currentScreenAction(); ++ break; ++ case CaptureModeModel::ActiveWindow: ++ globalAction = ShortcutActions::self()->activeWindowAction(); ++ break; ++ case CaptureModeModel::WindowUnderCursor: ++ globalAction = ShortcutActions::self()->windowUnderCursorAction(); ++ break; ++ case CaptureModeModel::FullScreen: ++ globalAction = ShortcutActions::self()->fullScreenAction(); ++ break; ++ default: ++ break; ++ } ++ action->setShortcuts(globalShortcuts(globalAction)); ++ auto onTriggered = [mode] { ++ SpectacleCore::instance()->takeNewScreenshot(mode); ++ }; ++ connect(action, &QAction::triggered, action, onTriggered); ++ } ++ }; ++ addModes(); ++ connect(CaptureModeModel::instance(), &CaptureModeModel::captureModesChanged, this, addModes); ++} ++ ++ScreenshotModeMenu *ScreenshotModeMenu::instance() ++{ ++ if (!s_instance) { ++ s_instance = new ScreenshotModeMenu; ++ } ++ return s_instance; ++} ++ ++#include "moc_ScreenshotModeMenu.cpp" +diff --git a/src/Gui/ScreenshotModeMenu.h b/src/Gui/ScreenshotModeMenu.h +new file mode 100644 +index 00000000..387a05db +--- /dev/null ++++ b/src/Gui/ScreenshotModeMenu.h +@@ -0,0 +1,30 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++#pragma once ++ ++#include "SpectacleMenu.h" ++#include ++ ++class ScreenshotModeMenu : public SpectacleMenu ++{ ++ Q_OBJECT ++ QML_ELEMENT ++ QML_SINGLETON ++ ++public: ++ static ScreenshotModeMenu *instance(); ++ ++ static ScreenshotModeMenu *create(QQmlEngine *engine, QJSEngine *) ++ { ++ auto inst = instance(); ++ Q_ASSERT(inst); ++ Q_ASSERT(inst->thread() == engine->thread()); ++ QJSEngine::setObjectOwnership(inst, QJSEngine::CppOwnership); ++ return inst; ++ } ++ ++private: ++ explicit ScreenshotModeMenu(QWidget *parent = nullptr); ++}; +diff --git a/src/Gui/ScreenshotModeMenuButton.qml b/src/Gui/ScreenshotModeMenuButton.qml +new file mode 100644 +index 00000000..5440f17d +--- /dev/null ++++ b/src/Gui/ScreenshotModeMenuButton.qml +@@ -0,0 +1,15 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++import QtQuick ++import QtQuick.Controls as QQC ++import org.kde.spectacle.private ++ ++TtToolButton { ++ icon.name: "camera-photo" ++ text: i18nc("@action select new screenshot mode", "New Screenshot") ++ down: pressed || ScreenshotModeMenu.visible ++ Accessible.role: Accessible.ButtonMenu ++ onPressed: ScreenshotModeMenu.popup(this) ++} +diff --git a/src/Gui/SettingsDialog/GeneralOptions.ui b/src/Gui/SettingsDialog/GeneralOptions.ui +index 474ae28d..b7d6ddb5 100644 +--- a/src/Gui/SettingsDialog/GeneralOptions.ui ++++ b/src/Gui/SettingsDialog/GeneralOptions.ui +@@ -20,6 +20,11 @@ + + + ++ ++ ++ Take rectangular screenshot ++ ++ + + + Take full screen screenshot +diff --git a/src/Gui/SettingsDialog/spectacle.kcfg b/src/Gui/SettingsDialog/spectacle.kcfg +index 3d916aee..f58f28fc 100644 +--- a/src/Gui/SettingsDialog/spectacle.kcfg ++++ b/src/Gui/SettingsDialog/spectacle.kcfg +@@ -15,11 +15,12 @@ + + + ++ + + + + +- TakeFullscreenScreenshot ++ TakeRectangularScreenshot + + + +diff --git a/src/Gui/TtToolButton.qml b/src/Gui/TtToolButton.qml +new file mode 100644 +index 00000000..31de1309 +--- /dev/null ++++ b/src/Gui/TtToolButton.qml +@@ -0,0 +1,16 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++import QtQuick ++import QtQuick.Controls as QQC ++import org.kde.kirigami as Kirigami ++import org.kde.spectacle.private ++ ++QQC.ToolButton { ++ implicitHeight: QmlUtils.iconTextButtonHeight ++ width: display === QQC.ToolButton.IconOnly ? height : implicitWidth ++ QQC.ToolTip.text: text ++ QQC.ToolTip.visible: (hovered || pressed) && display === QQC.ToolButton.IconOnly ++ QQC.ToolTip.delay: Kirigami.Units.toolTipDelay ++} +diff --git a/src/Gui/UndoRedoGroup.qml b/src/Gui/UndoRedoGroup.qml +index 83fa44fa..19deb798 100644 +--- a/src/Gui/UndoRedoGroup.qml ++++ b/src/Gui/UndoRedoGroup.qml +@@ -22,7 +22,7 @@ Grid { + NumberAnimation { properties: "x,y"; duration: Kirigami.Units.longDuration; easing.type: Easing.OutCubic } + } + +- QQC.ToolButton { ++ TtToolButton { + id: undoButton + enabled: SpectacleCore.annotationDocument.undoStackDepth > 0 + height: root.buttonHeight +@@ -31,13 +31,10 @@ Grid { + text: i18n("Undo") + icon.name: "edit-undo" + autoRepeat: true +- QQC.ToolTip.text: text +- QQC.ToolTip.visible: hovered || pressed +- QQC.ToolTip.delay: Kirigami.Units.toolTipDelay + onClicked: SpectacleCore.annotationDocument.undo() + } + +- QQC.ToolButton { ++ TtToolButton { + enabled: SpectacleCore.annotationDocument.redoStackDepth > 0 + height: root.buttonHeight + focusPolicy: root.focusPolicy +@@ -45,9 +42,6 @@ Grid { + text: i18n("Redo") + icon.name: "edit-redo" + autoRepeat: true +- QQC.ToolTip.text: text +- QQC.ToolTip.visible: hovered || pressed +- QQC.ToolTip.delay: Kirigami.Units.toolTipDelay + onClicked: SpectacleCore.annotationDocument.redo() + } + +diff --git a/src/Gui/VideoCaptureOverlay.qml b/src/Gui/VideoCaptureOverlay.qml +deleted file mode 100644 +index 09ad4d61..00000000 +--- a/src/Gui/VideoCaptureOverlay.qml ++++ /dev/null +@@ -1,243 +0,0 @@ +-/* SPDX-FileCopyrightText: 2023 Noah Davis +- * SPDX-License-Identifier: LGPL-2.0-or-later +- */ +- +-import QtQuick +-import QtQuick.Shapes +-import QtQuick.Window +-import QtQuick.Layouts +-import QtQuick.Controls as QQC +-import org.kde.kirigami as Kirigami +-import org.kde.spectacle.private +- +-MouseArea { +- id: root +- readonly property rect viewportRect: Geometry.mapFromPlatformRect(screenToFollow.geometry, +- screenToFollow.devicePixelRatio) +- focus: true +- acceptedButtons: Qt.LeftButton | Qt.RightButton +- hoverEnabled: true +- LayoutMirroring.enabled: Qt.application.layoutDirection === Qt.RightToLeft +- LayoutMirroring.childrenInherit: true +- anchors.fill: parent +- enabled: !SpectacleCore.videoPlatform.isRecording +- +- component Overlay: Rectangle { +- color: Settings.useLightMaskColor ? "white" : "black" +- opacity: if (SpectacleCore.videoPlatform.isRecording) { +- return 0 +- } else if (SelectionEditor.selection.empty) { +- return 0.25 +- } else { +- return 0.5 +- } +- LayoutMirroring.enabled: false +- Behavior on opacity { +- NumberAnimation { +- duration: Kirigami.Units.longDuration +- easing.type: Easing.OutCubic +- } +- } +- } +- Overlay { // top / full overlay when nothing selected +- id: topOverlay +- anchors.top: parent.top +- anchors.left: parent.left +- anchors.right: parent.right +- anchors.bottom: selectionRectangle.visible ? selectionRectangle.top : parent.bottom +- } +- Overlay { // bottom +- id: bottomOverlay +- anchors.left: parent.left +- anchors.top: selectionRectangle.visible ? selectionRectangle.bottom : undefined +- anchors.right: parent.right +- anchors.bottom: parent.bottom +- visible: selectionRectangle.visible && height > 0 +- } +- Overlay { // left +- anchors { +- left: topOverlay.left +- top: topOverlay.bottom +- right: selectionRectangle.visible ? selectionRectangle.left : undefined +- bottom: bottomOverlay.top +- } +- visible: selectionRectangle.visible && height > 0 && width > 0 +- } +- Overlay { // right +- anchors { +- left: selectionRectangle.visible ? selectionRectangle.right : undefined +- top: topOverlay.bottom +- right: topOverlay.right +- bottom: bottomOverlay.top +- } +- visible: selectionRectangle.visible && height > 0 && width > 0 +- } +- +- DashedOutline { +- id: selectionRectangle +- readonly property real margin: strokeWidth + 1 / Screen.devicePixelRatio +- dashSvgPath: SpectacleCore.videoPlatform.isRecording ? svgPath : "" +- visible: !SelectionEditor.selection.empty +- && Geometry.rectIntersects(Qt.rect(x,y,width,height), Qt.rect(0,0,parent.width, parent.height)) +- strokeWidth: dprRound(1) +- strokeColor: palette.active.highlight +- dashColor: SpectacleCore.videoPlatform.isRecording ? palette.active.base : strokeColor +- // We need to be a bit careful about staying out of the recorded area +- x: dprFloor(SelectionEditor.selection.x - margin - root.viewportRect.x) +- y: dprFloor(SelectionEditor.selection.y - margin - root.viewportRect.y) +- width: dprCeil(SelectionEditor.selection.right + margin - root.viewportRect.x) - x +- height: dprCeil(SelectionEditor.selection.bottom + margin - root.viewportRect.y) - y +- } +- +- Item { +- x: -root.viewportRect.x +- y: -root.viewportRect.y +- enabled: selectionRectangle.enabled +- visible: !SpectacleCore.videoPlatform.isRecording +- +- component SelectionHandle: Handle { +- id: handle +- visible: enabled && selectionRectangle.visible +- && SelectionEditor.dragLocation === SelectionEditor.None +- && Geometry.rectIntersects(Qt.rect(x,y,width,height), root.viewportRect) +- fillColor: selectionRectangle.strokeColor +- width: Kirigami.Units.gridUnit +- height: width +- transform: Translate { +- x: handle.xOffsetForEdges(selectionRectangle.strokeWidth) +- y: handle.yOffsetForEdges(selectionRectangle.strokeWidth) +- } +- } +- +- SelectionHandle { +- edges: Qt.TopEdge | Qt.LeftEdge +- x: dprFloor(SelectionEditor.handlesRect.x) +- y: dprFloor(SelectionEditor.handlesRect.y) +- } +- SelectionHandle { +- edges: Qt.LeftEdge +- x: dprFloor(SelectionEditor.handlesRect.x) +- y: dprRound(SelectionEditor.handlesRect.y + SelectionEditor.handlesRect.height/2 - height/2) +- } +- SelectionHandle { +- edges: Qt.LeftEdge | Qt.BottomEdge +- x: dprFloor(SelectionEditor.handlesRect.x) +- y: dprCeil(SelectionEditor.handlesRect.y + SelectionEditor.handlesRect.height - height) +- } +- SelectionHandle { +- edges: Qt.TopEdge +- x: dprRound(SelectionEditor.handlesRect.x + SelectionEditor.handlesRect.width/2 - width/2) +- y: dprFloor(SelectionEditor.handlesRect.y) +- } +- SelectionHandle { +- edges: Qt.BottomEdge +- x: dprRound(SelectionEditor.handlesRect.x + SelectionEditor.handlesRect.width/2 - width/2) +- y: dprCeil(SelectionEditor.handlesRect.y + SelectionEditor.handlesRect.height - height) +- } +- SelectionHandle { +- edges: Qt.RightEdge +- x: dprCeil(SelectionEditor.handlesRect.x + SelectionEditor.handlesRect.width - width) +- y: dprRound(SelectionEditor.handlesRect.y + SelectionEditor.handlesRect.height/2 - height/2) +- } +- SelectionHandle { +- edges: Qt.TopEdge | Qt.RightEdge +- x: dprCeil(SelectionEditor.handlesRect.x + SelectionEditor.handlesRect.width - width) +- y: dprFloor(SelectionEditor.handlesRect.y) +- } +- SelectionHandle { +- edges: Qt.RightEdge | Qt.BottomEdge +- x: dprCeil(SelectionEditor.handlesRect.x + SelectionEditor.handlesRect.width - width) +- y: dprCeil(SelectionEditor.handlesRect.y + SelectionEditor.handlesRect.height - height) +- } +- } +- +- Item { // separate item because it needs to be above the stuff defined above +- visible: !SpectacleCore.videoPlatform.isRecording +- width: SelectionEditor.screensRect.width +- height: SelectionEditor.screensRect.height +- x: -root.viewportRect.x +- y: -root.viewportRect.y +- +- // Size ToolTip +- SizeLabel { +- id: ssToolTip +- readonly property int valignment: { +- if (SelectionEditor.selection.empty) { +- return Qt.AlignVCenter +- } +- const margin = Kirigami.Units.mediumSpacing * 2 +- const w = width + margin +- const h = height + margin +- if (SelectionEditor.handlesRect.top >= h) { +- return Qt.AlignTop +- } else if (SelectionEditor.screensRect.height - SelectionEditor.handlesRect.bottom >= h) { +- return Qt.AlignBottom +- } else { +- // At the bottom of the inside of the selection rect. +- return Qt.AlignBaseline +- } +- } +- readonly property bool normallyVisible: !SelectionEditor.selection.empty +- Binding on x { +- value: contextWindow.dprRound(SelectionEditor.selection.horizontalCenter - ssToolTip.width / 2) +- when: ssToolTip.normallyVisible +- restoreMode: Binding.RestoreNone +- } +- Binding on y { +- value: { +- let v = 0 +- if (ssToolTip.valignment & Qt.AlignBaseline) { +- v = Math.min(SelectionEditor.selection.bottom, SelectionEditor.handlesRect.bottom - Kirigami.Units.gridUnit) +- - ssToolTip.height - Kirigami.Units.mediumSpacing * 2 +- } else if (ssToolTip.valignment & Qt.AlignTop) { +- v = SelectionEditor.handlesRect.top +- - ssToolTip.height - Kirigami.Units.mediumSpacing * 2 +- } else if (ssToolTip.valignment & Qt.AlignBottom) { +- v = SelectionEditor.handlesRect.bottom + Kirigami.Units.mediumSpacing * 2 +- } else { +- v = (root.height - ssToolTip.height) / 2 - parent.y +- } +- return contextWindow.dprRound(v) +- } +- when: ssToolTip.normallyVisible +- restoreMode: Binding.RestoreNone +- } +- visible: opacity > 0 +- opacity: ssToolTip.normallyVisible +- && Geometry.rectIntersects(Qt.rect(x,y,width,height), root.viewportRect) +- Behavior on opacity { +- NumberAnimation { +- duration: Kirigami.Units.longDuration +- easing.type: Easing.OutCubic +- } +- } +- size: Geometry.rawSize(SelectionEditor.selection.size, SelectionEditor.devicePixelRatio) // TODO: real pixel size on wayland +- padding: Kirigami.Units.mediumSpacing * 2 +- topPadding: padding - QmlUtils.fontMetrics.descent +- bottomPadding: topPadding +- background: FloatingBackground { +- implicitWidth: Math.ceil(parent.contentWidth) + parent.leftPadding + parent.rightPadding +- implicitHeight: Math.ceil(parent.contentHeight) + parent.topPadding + parent.bottomPadding +- color: Qt.rgba(parent.palette.window.r, +- parent.palette.window.g, +- parent.palette.window.b, 0.85) +- border.color: Qt.rgba(parent.palette.windowText.r, +- parent.palette.windowText.g, +- parent.palette.windowText.b, 0.2) +- border.width: contextWindow.dprRound(1) +- } +- } +- } +- +- Connections { +- target: contextWindow +- function onVisibilityChanged(visibility) { +- if (visibility !== Window.Hidden && visibility !== Window.Minimized) { +- contextWindow.raise() +- if (root.containsMouse) { +- contextWindow.requestActivate() +- } +- } +- } +- } +-} +diff --git a/src/Platforms/ImagePlatform.h b/src/Platforms/ImagePlatform.h +index 7e51db95..de766f52 100644 +--- a/src/Platforms/ImagePlatform.h ++++ b/src/Platforms/ImagePlatform.h +@@ -57,6 +57,7 @@ Q_SIGNALS: + void newCroppableScreenshotTaken(const QImage &image); + + void newScreenshotFailed(const QString &message = {}); ++ void newScreenshotCanceled(); + }; + + Q_DECLARE_OPERATORS_FOR_FLAGS(ImagePlatform::GrabModes) +diff --git a/src/Platforms/ImagePlatformKWin.cpp b/src/Platforms/ImagePlatformKWin.cpp +index 237130c9..67fc7375 100644 +--- a/src/Platforms/ImagePlatformKWin.cpp ++++ b/src/Platforms/ImagePlatformKWin.cpp +@@ -468,6 +468,8 @@ void ImagePlatformKWin::trackSource(ScreenShotSource2 *source) + Q_EMIT newScreenshotTaken(std::get(result)); + } else if (index == ResultVariant::ErrorString) { + Q_EMIT newScreenshotFailed(std::get(result)); ++ } else if (index == ResultVariant::CanceledState) { ++ Q_EMIT newScreenshotCanceled(); + } + }); + } +diff --git a/src/RecordingModeModel.cpp b/src/RecordingModeModel.cpp +index 4388daa8..220dcd7e 100644 +--- a/src/RecordingModeModel.cpp ++++ b/src/RecordingModeModel.cpp +@@ -18,6 +18,16 @@ + + using namespace Qt::StringLiterals; + ++static std::unique_ptr s_instance; ++ ++RecordingModeModel *RecordingModeModel::instance() ++{ ++ if (!s_instance) { ++ s_instance = std::make_unique(); ++ } ++ return s_instance.get(); ++} ++ + RecordingModeModel::RecordingModeModel(QObject *parent) + : QAbstractListModel(parent) + { +@@ -71,6 +81,7 @@ int RecordingModeModel::indexOfRecordingMode(VideoPlatform::RecordingMode mode) + + void RecordingModeModel::setRecordingModes(VideoPlatform::RecordingModes modes) + { ++ auto count = m_data.size(); + m_data.clear(); + if (modes & VideoPlatform::Region) { + m_data.append({VideoPlatform::Region, recordingModeLabel(VideoPlatform::Region)}); +@@ -81,7 +92,10 @@ void RecordingModeModel::setRecordingModes(VideoPlatform::RecordingModes modes) + if (modes & VideoPlatform::Window) { + m_data.append({VideoPlatform::Window, recordingModeLabel(VideoPlatform::Window)}); + } +- Q_EMIT countChanged(); ++ Q_EMIT recordingModesChanged(); ++ if (count != m_data.size()) { ++ Q_EMIT countChanged(); ++ } + } + + QString RecordingModeModel::recordingModeLabel(VideoPlatform::RecordingMode mode) +diff --git a/src/RecordingModeModel.h b/src/RecordingModeModel.h +index 383f98f4..e56492ae 100644 +--- a/src/RecordingModeModel.h ++++ b/src/RecordingModeModel.h +@@ -7,15 +7,28 @@ + #include "Platforms/VideoPlatform.h" + + #include ++#include + + class RecordingModeModel : public QAbstractListModel + { + Q_OBJECT + QML_ELEMENT ++ QML_SINGLETON + Q_PROPERTY(int count READ rowCount NOTIFY countChanged FINAL) + public: + explicit RecordingModeModel(QObject *parent = nullptr); + ++ static RecordingModeModel *instance(); ++ ++ static RecordingModeModel *create(QQmlEngine *engine, QJSEngine *) ++ { ++ auto inst = instance(); ++ Q_ASSERT(inst); ++ Q_ASSERT(inst->thread() == engine->thread()); ++ QJSEngine::setObjectOwnership(inst, QJSEngine::CppOwnership); ++ return inst; ++ } ++ + enum { + RecordingModeRole = Qt::UserRole + 1, + }; +@@ -32,6 +45,7 @@ public: + + Q_SIGNALS: + void countChanged(); ++ void recordingModesChanged(); + + private: + struct Item { +diff --git a/src/SpectacleCore.cpp b/src/SpectacleCore.cpp +index bfbb3085..820852df 100644 +--- a/src/SpectacleCore.cpp ++++ b/src/SpectacleCore.cpp +@@ -128,6 +128,7 @@ SpectacleCore::SpectacleCore(QObject *parent) + connect(SelectionEditor::instance(), &SelectionEditor::accepted, + this, [this](const QRectF &rect, const ExportManager::Actions &actions){ + ExportManager::instance()->updateTimestamp(); ++ m_returnToViewer = m_startMode == StartMode::Gui; + if (m_videoMode) { + const auto captureWindows = CaptureWindow::instances(); + SpectacleWindow::setVisibilityForAll(QWindow::Hidden); +@@ -160,10 +161,14 @@ SpectacleCore::SpectacleCore(QObject *parent) + deleteWindows(); + m_annotationDocument->cropCanvas(rect); + syncExportImage(); ++ const auto &exportActions = actions & ExportManager::AnyAction ? actions : autoExportActions(); ++ const bool willQuit = exportActions.testFlag(ExportManager::AnyAction) // ++ && exportActions.testFlag(ExportManager::UserAction) // ++ && Settings::quitAfterSaveCopyExport(); ++ m_returnToViewer &= !willQuit; + showViewerIfGuiMode(); + SpectacleWindow::setTitleForAll(SpectacleWindow::Unsaved); + ExportManager::instance()->scanQRCode(); +- const auto &exportActions = actions & ExportManager::AnyAction ? actions : autoExportActions(); + ExportManager::instance()->exportImage(exportActions, outputUrl()); + } + }); +@@ -173,6 +178,7 @@ SpectacleCore::SpectacleCore(QObject *parent) + m_annotationDocument->setBaseImage(image); + setExportImage(image); + ExportManager::instance()->updateTimestamp(); ++ m_returnToViewer = true; + showViewerIfGuiMode(); + SpectacleWindow::setTitleForAll(SpectacleWindow::Unsaved); + ExportManager::instance()->scanQRCode(); +@@ -237,6 +243,21 @@ SpectacleCore::SpectacleCore(QObject *parent) + auto uiMessage = i18nc("@info", "An error occurred while taking a screenshot."); + onScreenshotOrRecordingFailed(message, uiMessage, &SpectacleCore::dbusScreenshotFailed, &ViewerWindow::showScreenshotFailedMessage); + }); ++ connect(imagePlatform, &ImagePlatform::newScreenshotCanceled, this, [this]() { ++ if (m_startMode != StartMode::Gui || !m_returnToViewer || isGuiNull()) { ++ Q_EMIT allDone(); ++ return; ++ } ++ SpectacleWindow::setTitleForAll(SpectacleWindow::Previous); ++ const auto windows = SpectacleWindow::instances(); ++ if (windows.empty()) { ++ initViewerWindow(ViewerWindow::Image); ++ return; ++ } ++ for (auto w : windows) { ++ w->setVisible(true); ++ } ++ }); + + auto videoPlatform = m_videoPlatform.get(); + connect(videoPlatform, &VideoPlatform::recordingChanged, this, [this](bool isRecording) { +@@ -314,11 +335,19 @@ SpectacleCore::SpectacleCore(QObject *parent) + ExportManager::instance()->exportVideo(autoExportActions() | ExportManager::Save, fileUrl, videoOutputUrl()); + }); + connect(videoPlatform, &VideoPlatform::recordingCanceled, this, [this] { +- if (m_startMode != StartMode::Gui || isGuiNull()) { ++ if (m_startMode != StartMode::Gui || !m_returnToViewer || isGuiNull()) { + Q_EMIT allDone(); + return; + } + SpectacleWindow::setTitleForAll(SpectacleWindow::Previous); ++ const auto windows = SpectacleWindow::instances(); ++ if (windows.empty()) { ++ initViewerWindow(ViewerWindow::Image); ++ return; ++ } ++ for (auto w : windows) { ++ w->setVisible(true); ++ } + }); + connect(videoPlatform, &VideoPlatform::recordingFailed, this, [onScreenshotOrRecordingFailed](const QString &message){ + auto uiMessage = i18nc("@info", "An error occurred while attempting to record the screen."); +@@ -578,7 +607,7 @@ void SpectacleCore::activate(const QStringList &arguments, const QString &workin + } + } + +- if (parser.optionNames().size() > 0 || m_startMode != StartMode::Gui) { ++ if (parser.optionNames().size() > 0 || m_startMode != StartMode::Gui || !m_returnToViewer) { + // Delete windows if we have CLI options or not in GUI mode. + // We don't want to delete them otherwise because that will mess with the + // settings for PrintScreen key behavior. +@@ -672,20 +701,33 @@ void SpectacleCore::activate(const QStringList &arguments, const QString &workin + // Determine grab mode + using CaptureMode = CaptureModeModel::CaptureMode; + using GrabMode = ImagePlatform::GrabMode; +- GrabMode grabMode = GrabMode::AllScreens; // Default to all screens +- if (m_cliOptions[Option::Fullscreen]) { +- grabMode = GrabMode::AllScreens; +- } else if (m_cliOptions[Option::Current]) { +- grabMode = GrabMode::CurrentScreen; +- } else if (m_cliOptions[Option::ActiveWindow]) { +- grabMode = GrabMode::ActiveWindow; +- } else if (m_cliOptions[Option::Region]) { +- grabMode = GrabMode::PerScreenImageNative; +- } else if (m_cliOptions[Option::WindowUnderCursor]) { +- grabMode = GrabMode::WindowUnderCursor; +- } else if (Settings::launchAction() == Settings::UseLastUsedCapturemode) { +- grabMode = toGrabMode(CaptureMode(Settings::captureMode()), transientOnly); +- } ++ auto cliGrabMode = [&]() -> std::optional { ++ if (m_cliOptions[Option::Fullscreen]) { ++ return GrabMode::AllScreens; ++ } else if (m_cliOptions[Option::Current]) { ++ return GrabMode::CurrentScreen; ++ } else if (m_cliOptions[Option::ActiveWindow]) { ++ return GrabMode::ActiveWindow; ++ } else if (m_cliOptions[Option::Region]) { ++ return GrabMode::PerScreenImageNative; ++ } else if (m_cliOptions[Option::WindowUnderCursor]) { ++ return GrabMode::WindowUnderCursor; ++ } ++ return std::nullopt; ++ }; ++ auto launchActionGrabMode = [&] { ++ switch (Settings::launchAction()) { ++ case Settings::TakeRectangularScreenshot: ++ return GrabMode::PerScreenImageNative; ++ case Settings::TakeFullscreenScreenshot: ++ return GrabMode::AllScreens; ++ case Settings::UseLastUsedCapturemode: ++ return toGrabMode(CaptureMode(Settings::captureMode()), transientOnly); ++ default: ++ return GrabMode::NoGrabModes; ++ } ++ }; ++ auto grabMode = cliGrabMode().value_or(m_startMode == StartMode::Background ? GrabMode::AllScreens : launchActionGrabMode()); + + using RecordingMode = VideoPlatform::RecordingMode; + RecordingMode recordingMode = RecordingMode::NoRecordingModes; +@@ -855,7 +897,7 @@ void SpectacleCore::takeNewScreenshot(int captureMode, int timeout, bool include + + void SpectacleCore::cancelScreenshot() + { +- if (m_startMode != StartMode::Gui) { ++ if (m_startMode != StartMode::Gui || !m_returnToViewer) { + Q_EMIT allDone(); + return; + } +@@ -883,7 +925,7 @@ void SpectacleCore::showErrorMessage(const QString &message) + + void SpectacleCore::showViewerIfGuiMode(bool minimized) + { +- if (m_startMode != StartMode::Gui) { ++ if (m_startMode != StartMode::Gui || !m_returnToViewer) { + return; + } + initViewerWindow(ViewerWindow::Image); +@@ -1140,6 +1182,7 @@ void SpectacleCore::initViewerWindow(ViewerWindow::Mode mode) + { + // always switch to gui mode when a viewer window is used. + m_startMode = SpectacleCore::StartMode::Gui; ++ m_returnToViewer = true; + deleteWindows(); + + // Transparency isn't needed for this window. +@@ -1168,6 +1211,12 @@ void SpectacleCore::startRecording(VideoPlatform::RecordingMode mode, bool withP + if (m_videoPlatform->isRecording() || mode == VideoPlatform::NoRecordingModes) { + return; + } ++ if (!CaptureWindow::instances().empty()) { ++ SpectacleWindow::setVisibilityForAll(QWindow::Hidden); ++ if (mode != VideoPlatform::Region) { ++ m_returnToViewer = true; ++ } ++ } + m_lastRecordingMode = mode; + setVideoMode(true); + const auto &output = m_outputUrl.isLocalFile() ? videoOutputUrl() : QUrl(); +@@ -1191,6 +1240,10 @@ void SpectacleCore::setVideoMode(bool videoMode) + return; + } + m_videoMode = videoMode; ++ if (!videoMode && m_annotationDocument->baseImage().isNull()) { ++ // Change this if there ends up being a way to toggle video mode outside of rectangle capture mode. ++ takeNewScreenshot(ImagePlatform::PerScreenImageNative, 0, Settings::includePointer(), Settings::includeDecorations(), Settings::includeShadow()); ++ } + Q_EMIT videoModeChanged(videoMode); + } + +diff --git a/src/SpectacleCore.h b/src/SpectacleCore.h +index 246e8944..23d65ead 100644 +--- a/src/SpectacleCore.h ++++ b/src/SpectacleCore.h +@@ -37,7 +37,7 @@ class SpectacleCore : public QObject + Q_PROPERTY(int captureTimeRemaining READ captureTimeRemaining NOTIFY captureTimeRemainingChanged FINAL) + Q_PROPERTY(qreal captureProgress READ captureProgress NOTIFY captureProgressChanged FINAL) + Q_PROPERTY(QString recordedTime READ recordedTime NOTIFY recordedTimeChanged) +- Q_PROPERTY(bool videoMode READ videoMode NOTIFY videoModeChanged) ++ Q_PROPERTY(bool videoMode READ videoMode WRITE setVideoMode NOTIFY videoModeChanged) + Q_PROPERTY(QUrl currentVideo READ currentVideo NOTIFY currentVideoChanged) + Q_PROPERTY(AnnotationDocument *annotationDocument READ annotationDocument CONSTANT FINAL) + +@@ -67,15 +67,21 @@ public: + int captureTimeRemaining() const; + qreal captureProgress() const; + ++ QString recordedTime() const; ++ ++ bool videoMode() const; ++ void setVideoMode(bool enabled); ++ ++ QUrl currentVideo() const; ++ ++ + void initGuiNoScreenshot(); + + void syncExportImage(); + + Q_INVOKABLE void startRecording(VideoPlatform::RecordingMode mode, bool withPointer = Settings::videoIncludePointer()); + Q_INVOKABLE void finishRecording(); +- bool videoMode() const; +- QUrl currentVideo() const; +- QString recordedTime() const; ++ + Q_INVOKABLE QString timeFromMilliseconds(qint64 milliseconds) const; + + ExportManager::Actions autoExportActions() const; +@@ -140,13 +146,13 @@ private: + void initViewerWindow(ViewerWindow::Mode mode); + void deleteWindows(); + void unityLauncherUpdate(const QVariantMap &properties) const; +- void setVideoMode(bool enabled); + void setCurrentVideo(const QUrl ¤tVideo); + QUrl videoOutputUrl() const; + + static SpectacleCore *s_self; + std::unique_ptr m_annotationDocument = nullptr; + StartMode m_startMode = StartMode::Gui; ++ bool m_returnToViewer = false; + QUrl m_screenCaptureUrl; + std::unique_ptr m_imagePlatform; + std::unique_ptr m_videoPlatform;