From 23f77cf54cddc24d0a5f890d8ed343ce44ef47f3 Mon Sep 17 00:00:00 2001 From: Nikita Friesen Date: Sat, 27 Sep 2025 02:41:37 +0200 Subject: [PATCH 01/14] feat: add screenshot functionality and UI components --- components/containers/WrapperMouseArea.qml | 29 +++ config/GeneralConfig.qml | 1 + config/UtilitiesConfig.qml | 2 +- modules/areapicker/AreaPicker.qml | 3 + modules/areapicker/Picker.qml | 27 ++- modules/utilities/Content.qml | 10 + modules/utilities/ScreenshotDeleteModal.qml | 143 ++++++++++++ modules/utilities/Wrapper.qml | 3 + modules/utilities/cards/Screenshot.qml | 154 +++++++++++++ modules/utilities/cards/ScreenshotList.qml | 239 ++++++++++++++++++++ utils/Paths.qml | 1 + 11 files changed, 603 insertions(+), 9 deletions(-) create mode 100644 components/containers/WrapperMouseArea.qml create mode 100644 modules/utilities/ScreenshotDeleteModal.qml create mode 100644 modules/utilities/cards/Screenshot.qml create mode 100644 modules/utilities/cards/ScreenshotList.qml diff --git a/components/containers/WrapperMouseArea.qml b/components/containers/WrapperMouseArea.qml new file mode 100644 index 000000000..116bfdf79 --- /dev/null +++ b/components/containers/WrapperMouseArea.qml @@ -0,0 +1,29 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +Item { + id: root + + Layout.fillWidth: true + + property alias cursorShape: mouseArea.cursorShape + + signal clicked() + + MouseArea { + id: mouseArea + + anchors.fill: parent + + onClicked: root.clicked() + } + + default property alias data: contentItem.data + + Item { + id: contentItem + + anchors.fill: parent + } +} diff --git a/config/GeneralConfig.qml b/config/GeneralConfig.qml index eecca01db..a9f152d77 100644 --- a/config/GeneralConfig.qml +++ b/config/GeneralConfig.qml @@ -10,6 +10,7 @@ JsonObject { property list audio: ["pavucontrol"] property list playback: ["mpv"] property list explorer: ["thunar"] + property list image: ["imv"] } component Idle: JsonObject { diff --git a/config/UtilitiesConfig.qml b/config/UtilitiesConfig.qml index 227130718..3b6e625a0 100644 --- a/config/UtilitiesConfig.qml +++ b/config/UtilitiesConfig.qml @@ -22,4 +22,4 @@ JsonObject { property bool capsLockChanged: true property bool numLockChanged: true } -} +} \ No newline at end of file diff --git a/modules/areapicker/AreaPicker.qml b/modules/areapicker/AreaPicker.qml index 7ff051fc8..814549d6a 100644 --- a/modules/areapicker/AreaPicker.qml +++ b/modules/areapicker/AreaPicker.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import qs.components.containers import qs.components.misc +import QtQuick import Quickshell import Quickshell.Wayland import Quickshell.Io @@ -13,6 +14,8 @@ Scope { property bool freeze property bool closing + // no-op + Variants { model: Quickshell.screens diff --git a/modules/areapicker/Picker.qml b/modules/areapicker/Picker.qml index 1625ccf1b..41dd81aae 100644 --- a/modules/areapicker/Picker.qml +++ b/modules/areapicker/Picker.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import qs.components import qs.services import qs.config +import qs.utils import Caelestia import Quickshell import Quickshell.Wayland @@ -72,8 +73,23 @@ MouseArea { } function save(): void { - const tmpfile = Qt.resolvedUrl(`/tmp/caelestia-picker-${Quickshell.processId}-${Date.now()}.png`); - CUtils.saveItem(screencopy, tmpfile, Qt.rect(Math.ceil(rsx), Math.ceil(rsy), Math.floor(sw), Math.floor(sh)), path => Quickshell.execDetached(["swappy", "-f", path])); + // Ensure screenshots directory exists + Quickshell.execDetached(["mkdir", "-p", Paths.shotsdir]); + + // Build timestamped filename in the screenshots directory + const now = new Date(); + const pad = n => n.toString().padStart(2, "0"); + const fname = `Screenshot_${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}.png`; + const destUrl = Qt.resolvedUrl(`${Paths.shotsdir}/${fname}`); + + // Save the selected region to the screenshots folder + + CUtils.saveItem( + screencopy, + destUrl, + Qt.rect(Math.ceil(rsx), Math.ceil(rsy), Math.floor(sw), Math.floor(sh)), + path => Quickshell.execDetached(["swappy", "-f", Paths.toLocalFile(destUrl)]) + ); closeAnim.start(); } @@ -195,12 +211,7 @@ MouseArea { sourceComponent: ScreencopyView { captureSource: root.screen - onHasContentChanged: { - if (hasContent && !root.loader.freeze) { - overlay.visible = border.visible = true; - root.save(); - } - } + onHasContentChanged: hasContent && !root.loader.freeze && (overlay.visible = border.visible = true, root.save()) } } diff --git a/modules/utilities/Content.qml b/modules/utilities/Content.qml index d5be82423..d39566a6f 100644 --- a/modules/utilities/Content.qml +++ b/modules/utilities/Content.qml @@ -20,6 +20,12 @@ Item { IdleInhibit {} + Screenshot { + props: root.props + visibilities: root.visibilities + z: 1 + } + Record { props: root.props visibilities: root.visibilities @@ -34,4 +40,8 @@ Item { RecordingDeleteModal { props: root.props } + + ScreenshotDeleteModal { + props: root.props + } } diff --git a/modules/utilities/ScreenshotDeleteModal.qml b/modules/utilities/ScreenshotDeleteModal.qml new file mode 100644 index 000000000..5700e8255 --- /dev/null +++ b/modules/utilities/ScreenshotDeleteModal.qml @@ -0,0 +1,143 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.components.effects +import qs.services +import qs.config +import Caelestia +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes + +Loader { + id: root + + required property var props + + anchors.fill: parent + + opacity: root.props.screenshotConfirmDelete ? 1 : 0 + active: opacity > 0 + asynchronous: true + + sourceComponent: MouseArea { + id: deleteConfirmation + + property string path + + Component.onCompleted: path = root.props.screenshotConfirmDelete + + hoverEnabled: true + onClicked: root.props.screenshotConfirmDelete = "" + + Item { + anchors.fill: parent + anchors.margins: -Appearance.padding.large + anchors.rightMargin: -Appearance.padding.large - Config.border.thickness + anchors.bottomMargin: -Appearance.padding.large - Config.border.thickness + opacity: 0.5 + + StyledRect { + anchors.fill: parent + topLeftRadius: Config.border.rounding + color: Colours.palette.m3scrim + } + + Shape { + id: shape + + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + asynchronous: true + + ShapePath { + startX: -Config.border.rounding * 2 + startY: shape.height - Config.border.thickness + strokeWidth: 0 + fillGradient: LinearGradient { + orientation: LinearGradient.Horizontal + x1: -Config.border.rounding * 2 + + GradientStop { position: 0; color: Qt.alpha(Colours.palette.m3scrim, 0) } + GradientStop { position: 1; color: Colours.palette.m3scrim } + } + + PathLine { relativeX: Config.border.rounding; relativeY: 0 } + PathArc { relativeY: -Config.border.rounding; radiusX: Config.border.rounding; radiusY: Config.border.rounding; direction: PathArc.Counterclockwise } + PathLine { relativeX: 0; relativeY: Config.border.rounding + Config.border.thickness } + PathLine { relativeX: -Config.border.rounding * 2; relativeY: 0 } + } + + ShapePath { + startX: shape.width - Config.border.rounding - Config.border.thickness + strokeWidth: 0 + fillGradient: LinearGradient { + orientation: LinearGradient.Vertical + y1: -Config.border.rounding * 2 + + GradientStop { position: 0; color: Qt.alpha(Colours.palette.m3scrim, 0) } + GradientStop { position: 1; color: Colours.palette.m3scrim } + } + + PathArc { relativeX: Config.border.rounding; relativeY: -Config.border.rounding; radiusX: Config.border.rounding; radiusY: Config.border.rounding; direction: PathArc.Counterclockwise } + PathLine { relativeX: 0; relativeY: -Config.border.rounding } + PathLine { relativeX: Config.border.thickness; relativeY: 0 } + PathLine { relativeX: 0 } + } + } + } + + StyledRect { + anchors.centerIn: parent + radius: Appearance.rounding.large + color: Colours.palette.m3surfaceContainerHigh + + scale: 0 + Component.onCompleted: scale = Qt.binding(() => root.props.screenshotConfirmDelete ? 1 : 0) + + width: Math.min(parent.width - Appearance.padding.large * 2, implicitWidth) + implicitWidth: deleteConfirmationLayout.implicitWidth + Appearance.padding.large * 3 + implicitHeight: deleteConfirmationLayout.implicitHeight + Appearance.padding.large * 3 + + MouseArea { anchors.fill: parent } + + Elevation { anchors.fill: parent; radius: parent.radius; z: -1; level: 3 } + + ColumnLayout { + id: deleteConfirmationLayout + + anchors.fill: parent + anchors.margins: Appearance.padding.large * 1.5 + spacing: Appearance.spacing.normal + + StyledText { text: qsTr("Delete screenshot?"); font.pointSize: Appearance.font.size.large } + + StyledText { + Layout.fillWidth: true + text: qsTr("Screenshot '%1' will be permanently deleted.").arg(deleteConfirmation.path) + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + } + + RowLayout { + Layout.topMargin: Appearance.spacing.normal + Layout.alignment: Qt.AlignRight + spacing: Appearance.spacing.normal + + TextButton { text: qsTr("Cancel"); type: TextButton.Text; onClicked: root.props.screenshotConfirmDelete = "" } + + TextButton { + text: qsTr("Delete"); type: TextButton.Text + onClicked: { CUtils.deleteFile(Qt.resolvedUrl(root.props.screenshotConfirmDelete)); root.props.screenshotConfirmDelete = "" } + } + } + } + + Behavior on scale { Anim { duration: Appearance.anim.durations.expressiveDefaultSpatial; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } + } + } + + Behavior on opacity { Anim {} } +} diff --git a/modules/utilities/Wrapper.qml b/modules/utilities/Wrapper.qml index dd784bce8..10ef667a7 100644 --- a/modules/utilities/Wrapper.qml +++ b/modules/utilities/Wrapper.qml @@ -15,6 +15,9 @@ Item { property bool recordingListExpanded: false property string recordingConfirmDelete property string recordingMode + property bool screenshotListExpanded: false + property string screenshotConfirmDelete + property string screenshotMode reloadableId: "utilities" } diff --git a/modules/utilities/cards/Screenshot.qml b/modules/utilities/cards/Screenshot.qml new file mode 100644 index 000000000..bbef6d31d --- /dev/null +++ b/modules/utilities/cards/Screenshot.qml @@ -0,0 +1,154 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.services +import qs.config +import qs.utils +import Quickshell +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property var props + required property var visibilities + + Layout.fillWidth: true + implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 + + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surfaceContainer + + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.normal + + RowLayout { + spacing: Appearance.spacing.normal + z: 1 + + StyledRect { + implicitWidth: implicitHeight + implicitHeight: { + const h = icon.implicitHeight + Appearance.padding.smaller * 2; + return h - (h % 2); + } + + radius: Appearance.rounding.full + color: Colours.palette.m3secondaryContainer + + MaterialIcon { + id: icon + + anchors.centerIn: parent + anchors.horizontalCenterOffset: -0.5 + anchors.verticalCenterOffset: 1.5 + text: "screenshot_monitor" + color: Colours.palette.m3onSecondaryContainer + font.pointSize: Appearance.font.size.large + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: qsTr("Screenshots") + font.pointSize: Appearance.font.size.normal + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + text: qsTr("Capture an area or the screen and edit in Swappy") + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + elide: Text.ElideRight + } + } + + SplitButton { + id: screenshotSplit + // Ensure the main action area stays interactive and above potential siblings + z: 2 + disabled: false + active: menuItems.find(m => root.props.screenshotMode === m.icon + m.text) ?? menuItems[0] + menu.onItemSelected: item => root.props.screenshotMode = item.icon + item.text + // Be explicit: the primary click target is enabled + stateLayer.disabled: false + + menuItems: [ + MenuItem { + icon: "screenshot" + text: qsTr("Area (edit)") + activeText: qsTr("Area") + onClicked: { + root.visibilities.utilities = false; + root.visibilities.sidebar = false; + pendingCommand = ["caelestia", "shell", "picker", "openFreeze"]; + delayTimer.start(); + } + }, + MenuItem { + icon: "screenshot_region" + text: qsTr("Active window (edit)") + activeText: qsTr("Window") + onClicked: { + root.visibilities.utilities = false; + root.visibilities.sidebar = false; + pendingCommand = ["caelestia", "shell", "picker", "open"]; + delayTimer.start(); + } + } + ] + } + } + + Loader { + id: screenshotLoader + + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + sourceComponent: screenshotList + + Behavior on Layout.preferredHeight { + id: screenshotHeightAnim + + enabled: false + + Anim {} + } + } + } + + Component { + id: screenshotList + + ScreenshotList { + props: root.props + visibilities: root.visibilities + } + } + + property var pendingCommand: [] + + Timer { + id: delayTimer + + interval: 300 + + onTriggered: { + if (pendingCommand.length > 0) { + Quickshell.execDetached(pendingCommand); + pendingCommand = []; + } + } + } +} diff --git a/modules/utilities/cards/ScreenshotList.qml b/modules/utilities/cards/ScreenshotList.qml new file mode 100644 index 000000000..90bb68a65 --- /dev/null +++ b/modules/utilities/cards/ScreenshotList.qml @@ -0,0 +1,239 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.components.containers +import qs.services +import qs.config +import qs.utils +import Caelestia +import Caelestia.Models +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property var props + required property var visibilities + + spacing: 0 + + WrapperMouseArea { + Layout.fillWidth: true + + cursorShape: Qt.PointingHandCursor + onClicked: root.props.screenshotListExpanded = !root.props.screenshotListExpanded + + RowLayout { + spacing: Appearance.spacing.smaller + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + text: "list" + font.pointSize: Appearance.font.size.large + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + text: qsTr("Screenshots") + font.pointSize: Appearance.font.size.normal + } + + IconButton { + icon: root.props.screenshotListExpanded ? "unfold_less" : "unfold_more" + type: IconButton.Text + label.animate: true + onClicked: root.props.screenshotListExpanded = !root.props.screenshotListExpanded + } + } + } + + StyledListView { + id: list + + model: FileSystemModel { + path: Paths.shotsdir + nameFilters: ["screenshot_*.png"] + sortReverse: true + } + + Layout.fillWidth: true + Layout.rightMargin: -Appearance.spacing.small + implicitHeight: (Appearance.font.size.larger + Appearance.padding.small) * (root.props.screenshotListExpanded ? 10 : 3) + clip: true + + StyledScrollBar.vertical: StyledScrollBar { + flickable: list + } + + delegate: RowLayout { + id: screenshot + + required property FileSystemEntry modelData + property string baseName + + anchors.left: list.contentItem.left + anchors.right: list.contentItem.right + anchors.rightMargin: Appearance.spacing.small + spacing: Appearance.spacing.small / 2 + + Component.onCompleted: baseName = modelData.baseName + + StyledText { + Layout.fillWidth: true + Layout.rightMargin: Appearance.spacing.small / 2 + text: { + const time = screenshot.baseName; + const matches = time.match(/^screenshot_(\d{4})(\d{2})(\d{2})_(\d{2})-(\d{2})-(\d{2})/); + if (!matches) + return time; + const date = new Date(...matches.slice(1)); + return qsTr("Screenshot at %1").arg(Qt.formatDateTime(date, Qt.locale())); + } + color: Colours.palette.m3onSurfaceVariant + elide: Text.ElideRight + } + + IconButton { + icon: "photo_library" + type: IconButton.Text + onClicked: { + root.visibilities.utilities = false; + root.visibilities.sidebar = false; + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.image, screenshot.modelData.path]); + } + } + + IconButton { + icon: "folder" + type: IconButton.Text + onClicked: { + root.visibilities.utilities = false; + root.visibilities.sidebar = false; + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, screenshot.modelData.path]); + } + } + + IconButton { + icon: "delete_forever" + type: IconButton.Text + label.color: Colours.palette.m3error + stateLayer.color: Colours.palette.m3error + onClicked: root.props.screenshotConfirmDelete = screenshot.modelData.path + } + } + + add: Transition { + Anim { + property: "opacity" + from: 0 + to: 1 + } + Anim { + property: "scale" + from: 0.5 + to: 1 + } + } + + remove: Transition { + Anim { + property: "opacity" + to: 0 + } + Anim { + property: "scale" + to: 0.5 + } + } + + displaced: Transition { + Anim { + properties: "opacity,scale" + to: 1 + } + Anim { + property: "y" + } + } + + Loader { + anchors.centerIn: parent + + opacity: list.count === 0 ? 1 : 0 + active: opacity > 0 + asynchronous: true + + sourceComponent: ColumnLayout { + spacing: Appearance.spacing.small + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "scan_delete" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.extraLarge + + opacity: root.props.screenshotListExpanded ? 1 : 0 + scale: root.props.screenshotListExpanded ? 1 : 0 + Layout.preferredHeight: root.props.screenshotListExpanded ? implicitHeight : 0 + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + Behavior on Layout.preferredHeight { + Anim {} + } + } + + RowLayout { + spacing: Appearance.spacing.smaller + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "scan_delete" + color: Colours.palette.m3outline + + opacity: !root.props.screenshotListExpanded ? 1 : 0 + scale: !root.props.screenshotListExpanded ? 1 : 0 + Layout.preferredWidth: !root.props.screenshotListExpanded ? implicitWidth : 0 + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + Behavior on Layout.preferredWidth { + Anim {} + } + } + + StyledText { + text: qsTr("No screenshots found") + color: Colours.palette.m3outline + } + } + } + + Behavior on opacity { Anim {} } + } + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } +} diff --git a/utils/Paths.qml b/utils/Paths.qml index f95134f49..a8a8c175e 100644 --- a/utils/Paths.qml +++ b/utils/Paths.qml @@ -20,6 +20,7 @@ Singleton { readonly property string notifimagecache: `${imagecache}/notifs` readonly property string wallsdir: Quickshell.env("CAELESTIA_WALLPAPERS_DIR") || absolutePath(Config.paths.wallpaperDir) readonly property string recsdir: Quickshell.env("CAELESTIA_RECORDINGS_DIR") || `${videos}/Recordings` + readonly property string shotsdir: Quickshell.env("CAELESTIA_SCREENSHOTS_DIR") || `${pictures}/Screenshots` readonly property string libdir: Quickshell.env("CAELESTIA_LIB_DIR") || "/usr/lib/caelestia" function toLocalFile(path: url): string { From 7c1c3a799a0b4a8d9be39944680d3acab1c8cf68 Mon Sep 17 00:00:00 2001 From: Nikita Friesen Date: Sun, 28 Sep 2025 03:11:09 +0200 Subject: [PATCH 02/14] feat: enhance media handling with new components and installation script --- .envrc | 29 +- .gitignore | 1 + components/containers/WrapperMouseArea.qml | 19 +- install-system.sh | 22 ++ modules/utilities/Content.qml | 15 +- modules/utilities/RecordingDeleteModal.qml | 1 + modules/utilities/ScreenshotDeleteModal.qml | 1 + modules/utilities/Wrapper.qml | 4 +- modules/utilities/cards/Media.qml | 361 ++++++++++++++++++++ modules/utilities/cards/MediaCard.qml | 310 +++++++++++++++++ modules/utilities/cards/MediaList.qml | 216 ++++++++++++ run.sh | 32 ++ shell | 1 + 13 files changed, 987 insertions(+), 25 deletions(-) create mode 100644 install-system.sh create mode 100644 modules/utilities/cards/Media.qml create mode 100644 modules/utilities/cards/MediaCard.qml create mode 100644 modules/utilities/cards/MediaList.qml create mode 100755 run.sh create mode 120000 shell diff --git a/.envrc b/.envrc index c90b500c9..8c73c5305 100644 --- a/.envrc +++ b/.envrc @@ -1,15 +1,34 @@ +# If nix is available, enable flakes if has nix; then use flake fi +# Where we "install" locally +export CAELESTIA_PREFIX="$PWD/.local/usr" +export CAELESTIA_LIB_DIR="$CAELESTIA_PREFIX/lib" +export QML2_IMPORT_PATH="$CAELESTIA_PREFIX/lib/qt6/qml:${QML2_IMPORT_PATH:-}" +export PATH="$CAELESTIA_PREFIX/bin:$PATH" + +# Configure build directory once +if [ ! -f build/Makefile ] && [ ! -f build/build.ninja ]; then + cmake -S . -B build \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_CXX_COMPILER=clazy \ + -DCMAKE_INSTALL_PREFIX=$CAELESTIA_PREFIX \ + -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +fi + +# Rebuild automatically when C++ sources or CMake files change shopt -s globstar -watch_file assets/cpp/**/*.cpp -watch_file assets/cpp/**/*.hpp watch_file plugin/**/*.cpp watch_file plugin/**/*.hpp watch_file **/CMakeLists.txt -cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_CXX_COMPILER=clazy -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DDISTRIBUTOR=direnv cmake --build build -export CAELESTIA_LIB_DIR="$PWD/build/lib" -export QML2_IMPORT_PATH="$PWD/build/qml:${QML2_IMPORT_PATH:-}" +cmake --install build + +# Symlink config dir so QuickShell loads Caelestia from source +mkdir -p ~/.config/quickshell +ln -sf "$PWD" ~/.config/quickshell/caelestia diff --git a/.gitignore b/.gitignore index a114b1bcd..271176f2b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /.qmlls.ini build/ .cache/ +.local/ \ No newline at end of file diff --git a/components/containers/WrapperMouseArea.qml b/components/containers/WrapperMouseArea.qml index 116bfdf79..63e5737aa 100644 --- a/components/containers/WrapperMouseArea.qml +++ b/components/containers/WrapperMouseArea.qml @@ -1,29 +1,32 @@ pragma ComponentBehavior: Bound import QtQuick +import QtQuick.Layouts +import QtQuick.Handlers Item { id: root + // allow using Layout attached props at call sites Layout.fillWidth: true - property alias cursorShape: mouseArea.cursorShape + // expose a cursor shape like MouseArea + property alias cursorShape: hover.cursorShape signal clicked() - MouseArea { - id: mouseArea - - anchors.fill: parent - - onClicked: root.clicked() + // Use pointer handlers so child controls remain interactive + HoverHandler { id: hover; cursorShape: Qt.ArrowCursor } + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: root.clicked() } + // forward arbitrary children into this content item default property alias data: contentItem.data Item { id: contentItem - anchors.fill: parent } } diff --git a/install-system.sh b/install-system.sh new file mode 100644 index 000000000..66937e776 --- /dev/null +++ b/install-system.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -e + +# Configure with system prefix +rm -rf build +cmake -S . -B build \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_CXX_COMPILER=clazy \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON + +# Build +cmake --build build + +# Install system-wide (requires sudo) +sudo cmake --install build + +echo "✅ Caelestia installed system-wide into /usr" +echo "👉 After reboot, run QuickShell with:" +echo " QS_CONFIG_NAME=caelestia quickshell" diff --git a/modules/utilities/Content.qml b/modules/utilities/Content.qml index d39566a6f..83ba732cc 100644 --- a/modules/utilities/Content.qml +++ b/modules/utilities/Content.qml @@ -1,4 +1,4 @@ -import "cards" +import "cards" as UtilCards import qs.config import QtQuick import QtQuick.Layouts @@ -18,21 +18,16 @@ Item { anchors.fill: parent spacing: Appearance.spacing.normal - IdleInhibit {} + UtilCards.IdleInhibit {} - Screenshot { + // Combined media card: Screenshots + Recordings in tabs + UtilCards.Media { props: root.props visibilities: root.visibilities z: 1 } - Record { - props: root.props - visibilities: root.visibilities - z: 1 - } - - Toggles { + UtilCards.Toggles { visibilities: root.visibilities } } diff --git a/modules/utilities/RecordingDeleteModal.qml b/modules/utilities/RecordingDeleteModal.qml index e832c27c6..c7d43c737 100644 --- a/modules/utilities/RecordingDeleteModal.qml +++ b/modules/utilities/RecordingDeleteModal.qml @@ -16,6 +16,7 @@ Loader { required property var props anchors.fill: parent + z: 1000 opacity: root.props.recordingConfirmDelete ? 1 : 0 active: opacity > 0 diff --git a/modules/utilities/ScreenshotDeleteModal.qml b/modules/utilities/ScreenshotDeleteModal.qml index 5700e8255..d371000f6 100644 --- a/modules/utilities/ScreenshotDeleteModal.qml +++ b/modules/utilities/ScreenshotDeleteModal.qml @@ -16,6 +16,7 @@ Loader { required property var props anchors.fill: parent + z: 1000 opacity: root.props.screenshotConfirmDelete ? 1 : 0 active: opacity > 0 diff --git a/modules/utilities/Wrapper.qml b/modules/utilities/Wrapper.qml index 10ef667a7..4737f2e85 100644 --- a/modules/utilities/Wrapper.qml +++ b/modules/utilities/Wrapper.qml @@ -12,12 +12,12 @@ Item { required property Item sidebar readonly property PersistentProperties props: PersistentProperties { - property bool recordingListExpanded: false + property bool mediaListExpanded: false property string recordingConfirmDelete property string recordingMode - property bool screenshotListExpanded: false property string screenshotConfirmDelete property string screenshotMode + property int utilitiesMediaTab: 0 reloadableId: "utilities" } diff --git a/modules/utilities/cards/Media.qml b/modules/utilities/cards/Media.qml new file mode 100644 index 000000000..f53acdf22 --- /dev/null +++ b/modules/utilities/cards/Media.qml @@ -0,0 +1,361 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.services +import qs.config +import qs.utils +import Quickshell +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property var props + required property var visibilities + + Layout.fillWidth: true + implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 + + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surfaceContainer + + // 0 = Screenshots, 1 = Recordings + property int tabIndex: props.utilitiesMediaTab + onTabIndexChanged: tabSwitch.restart() + + property var screenshotMenuItems + property var recordingMenuItems + + Component.onCompleted: { + // Helper to create screenshot items that hold a stable reference to this card + const mkShot = (icon, text, activeText, cmdArray) => { + const qml = 'import qs.components.controls; MenuItem { property var mediaRoot; icon: "' + icon + '"; text: qsTr("' + text + '"); activeText: qsTr("' + activeText + '"); onClicked: { if (mediaRoot && mediaRoot.triggerShot) mediaRoot.triggerShot(' + JSON.stringify(cmdArray) + ') } }'; + const item = Qt.createQmlObject(qml, root); + item.mediaRoot = root; + return item; + } + screenshotMenuItems = [ + mkShot("crop_free", "Area (edit)", "Area", ["caelestia", "shell", "picker", "openFreeze"]), + mkShot("web_asset", "Active window (edit)", "Window", ["caelestia", "shell", "picker", "open"]) + ] + + // Helper to create recording items that call Recorder directly + const mkRec = (icon, text, activeText, args) => { + const call = args ? 'Recorder.start(' + JSON.stringify(args) + ')' : 'Recorder.start()'; + const qml = 'import qs.components.controls; import qs.services; MenuItem { icon: "' + icon + '"; text: qsTr("' + text + '"); activeText: qsTr("' + activeText + '"); onClicked: { ' + call + ' } }'; + return Qt.createQmlObject(qml, root); + } + recordingMenuItems = [ + mkRec("fullscreen", "Record fullscreen", "Fullscreen", null), + mkRec("screenshot_region", "Record region", "Region", ["-r"]), + mkRec("select_to_speak", "Record fullscreen with sound", "Fullscreen", ["-s"]), + mkRec("volume_up", "Record region with sound", "Region", ["-sr"]) + ] + } + + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.normal + + // Header: two icon chips (tabs) + contextual actions on the right + RowLayout { + spacing: Appearance.spacing.normal + z: 1 + + // Screenshot chip (acts as tab toggle) + StyledRect { + implicitWidth: implicitHeight + implicitHeight: { + const h = shotIcon.implicitHeight + Appearance.padding.smaller * 2; + return h - (h % 2); + } + radius: Appearance.rounding.full + color: root.tabIndex === 0 ? Colours.palette.m3secondaryContainer : Colours.layer(Colours.palette.m3surfaceContainerHigh, 1) + + MaterialIcon { + id: shotIcon + anchors.centerIn: parent + anchors.horizontalCenterOffset: -0.5 + anchors.verticalCenterOffset: 1.5 + text: "screenshot_monitor" + color: root.tabIndex === 0 ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.large + } + + CustomMouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { root.tabIndex = 0; root.props.utilitiesMediaTab = 0 } + } + } + + // Recording chip (acts as tab toggle) + StyledRect { + implicitWidth: implicitHeight + implicitHeight: { + const h = recIcon.implicitHeight + Appearance.padding.smaller * 2; + return h - (h % 2); + } + radius: Appearance.rounding.full + color: root.tabIndex === 1 + ? (Recorder.running ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer) + : Colours.layer(Colours.palette.m3surfaceContainerHigh, 1) + + MaterialIcon { + id: recIcon + anchors.centerIn: parent + anchors.horizontalCenterOffset: -0.5 + anchors.verticalCenterOffset: 1.5 + text: "screen_record" + color: root.tabIndex === 1 + ? (Recorder.running ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer) + : Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.large + } + + CustomMouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { root.tabIndex = 1; root.props.utilitiesMediaTab = 1 } + } + } + + Item { Layout.fillWidth: true } + + // Contextual action area (SplitButton) + SplitButton { + Layout.leftMargin: Appearance.spacing.normal + z: 2 + disabled: root.tabIndex === 1 && Recorder.running + active: root.tabIndex === 0 ? (screenshotMenuItems.find(m => root.props.screenshotMode === m.icon + m.text) ?? screenshotMenuItems[0]) : (recordingMenuItems.find(m => root.props.recordingMode === m.icon + m.text) ?? recordingMenuItems[0]) + menuItems: root.tabIndex === 0 ? screenshotMenuItems : recordingMenuItems + menu.onItemSelected: item => root.tabIndex === 0 ? root.props.screenshotMode = item.icon + item.text : root.props.recordingMode = item.icon + item.text + stateLayer.disabled: false + } + } + + // Body: cross-fade between bodies and animate height smoothly + Item { + id: body + Layout.fillWidth: true + // Start at the active body's height; animation sequence will adjust when switching + Layout.preferredHeight: root.tabIndex === 0 ? shotsBody.implicitHeight : recsBody.implicitHeight + + // Screenshots + Loader { + id: shotsBody + anchors.fill: parent + active: true + visible: root.tabIndex === 0 + opacity: visible ? 1 : 0 + scale: visible ? 1 : 0.9 + transformOrigin: Item.Top + sourceComponent: screenshotList + + Behavior on opacity { Anim { duration: 250; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } + Behavior on scale { Anim { duration: 250; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } + } + + // Recordings container: keep both sub-bodies alive and cross-fade + Item { + id: recsBody + anchors.fill: parent + visible: root.tabIndex === 1 + opacity: visible ? 1 : 0 + scale: visible ? 1 : 0.9 + transformOrigin: Item.Top + implicitHeight: Math.max(recListLoader.implicitHeight || 0, recCtlLoader.implicitHeight || 0) + + // Recording list + Loader { + id: recListLoader + anchors.fill: parent + active: true + visible: !Recorder.running + opacity: visible ? 1 : 0 + scale: visible ? 1 : 0.9 + transformOrigin: Item.Top + sourceComponent: recordingList + + Behavior on opacity { Anim { duration: 250; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } + Behavior on scale { Anim { duration: 250; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } + } + + // Recording controls + Loader { + id: recCtlLoader + anchors.fill: parent + active: true + visible: Recorder.running + opacity: visible ? 1 : 0 + scale: visible ? 1 : 0.9 + transformOrigin: Item.Top + sourceComponent: recordingControls + + Behavior on opacity { Anim { duration: 250; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } + Behavior on scale { Anim { duration: 250; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } + } + + Behavior on opacity { Anim { duration: 250; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } + Behavior on scale { Anim { duration: 250; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } + } + Behavior on Layout.preferredHeight { Anim {} } + } + } + + // --- Bodies --- + Component { + id: screenshotList + + MediaList { + props: root.props + visibilities: root.visibilities + title: "Screenshots" + path: Paths.shotsdir + nameFilters: ["screenshot_*.png"] + firstIcon: "photo_library" + firstApp: Config.general.apps.image + textPrefix: "Screenshot" + expandedProp: "mediaListExpanded" + } + } + + Component { + id: recordingList + + MediaList { + props: root.props + visibilities: root.visibilities + title: "Recordings" + path: Paths.recsdir + nameFilters: ["recording_*.mp4"] + firstIcon: "play_arrow" + firstApp: Config.general.apps.playback + textPrefix: "Recording" + expandedProp: "mediaListExpanded" + } + } + + Component { + id: recordingControls + + RowLayout { + spacing: Appearance.spacing.normal + + StyledRect { + radius: Appearance.rounding.full + color: Recorder.paused ? Colours.palette.m3tertiary : Colours.palette.m3error + + implicitWidth: recText.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: recText.implicitHeight + Appearance.padding.smaller * 2 + + StyledText { + id: recText + + anchors.centerIn: parent + animate: true + text: Recorder.paused ? "PAUSED" : "REC" + color: Recorder.paused ? Colours.palette.m3onTertiary : Colours.palette.m3onError + font.family: Appearance.font.family.mono + } + + Behavior on implicitWidth { Anim {} } + + SequentialAnimation on opacity { + running: !Recorder.paused + alwaysRunToEnd: true + loops: Animation.Infinite + + Anim { from: 1; to: 0 } + Anim { from: 0; to: 1 } + } + } + + StyledText { + text: { + const elapsed = Recorder.elapsed; + + const hours = Math.floor(elapsed / 3600); + const mins = Math.floor((elapsed % 3600) / 60); + const secs = Math.floor(elapsed % 60).toString().padStart(2, "0"); + + let time; + if (hours > 0) + time = `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; + else + time = `${mins}:${secs}`; + + return qsTr("Recording for %1").arg(time); + } + font.pointSize: Appearance.font.size.normal + } + + Item { Layout.fillWidth: true } + + IconButton { + label.animate: true + icon: Recorder.paused ? "play_arrow" : "pause" + toggle: true + checked: Recorder.paused + type: IconButton.Tonal + font.pointSize: Appearance.font.size.large + onClicked: { + Recorder.togglePause(); + internalChecked = Recorder.paused; + } + } + + IconButton { + icon: "stop" + inactiveColour: Colours.palette.m3error + inactiveOnColour: Colours.palette.m3onError + font.pointSize: Appearance.font.size.large + onClicked: Recorder.stop() + } + } + } + + // Delayed launch for screenshot tools + property var pendingCommand: [] + + // Helper so dynamically-created MenuItems can trigger with a delay + function triggerShot(cmd) { + root.visibilities.utilities = false; + root.visibilities.sidebar = false; + root.pendingCommand = cmd; + delayTimer.restart(); + } + + Timer { + id: delayTimer + interval: 300 + running: false + repeat: false + onTriggered: { + if (root.pendingCommand.length > 0) { + Quickshell.execDetached(root.pendingCommand); + root.pendingCommand = []; + } + } + } + + // Smooth switch: animate height, then change visibility to trigger opacity/scale animations. + SequentialAnimation { + id: tabSwitch + running: false + alwaysRunToEnd: true + + // Animate container height to the active body's height + Anim { target: body; property: "Layout.preferredHeight"; to: root.tabIndex === 0 ? shotsBody.implicitHeight : recsBody.implicitHeight; duration: 200; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } + + onStopped: { + shotsBody.visible = root.tabIndex === 0; + recsBody.visible = root.tabIndex === 1; + } + } +} diff --git a/modules/utilities/cards/MediaCard.qml b/modules/utilities/cards/MediaCard.qml new file mode 100644 index 000000000..75e8362a8 --- /dev/null +++ b/modules/utilities/cards/MediaCard.qml @@ -0,0 +1,310 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.services +import qs.config +import qs.utils +import Quickshell +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property var props + required property var visibilities + + Layout.fillWidth: true + implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 + + radius: Appearance.rounding.normal + color: Colours.tPalette.m3surfaceContainer + + // 0 = Screenshots, 1 = Recordings + property int tabIndex: props.utilitiesMediaTab + + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.normal + + // Header + RowLayout { + spacing: Appearance.spacing.normal + z: 1 + + // Leading icon chip + StyledRect { + implicitWidth: implicitHeight + implicitHeight: { + const h = icon.implicitHeight + Appearance.padding.smaller * 2; + return h - (h % 2); + } + + radius: Appearance.rounding.full + color: tabIndex === 1 + ? (Recorder.running ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer) + : Colours.palette.m3secondaryContainer + + MaterialIcon { + id: icon + + anchors.centerIn: parent + anchors.horizontalCenterOffset: -0.5 + anchors.verticalCenterOffset: 1.5 + text: tabIndex === 0 ? "screenshot_monitor" : "screen_record" + color: tabIndex === 1 + ? (Recorder.running ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer) + : Colours.palette.m3onSecondaryContainer + font.pointSize: Appearance.font.size.large + } + } + + // Titles + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: tabIndex === 0 ? qsTr("Screenshots") : qsTr("Screen Recorder") + font.pointSize: Appearance.font.size.normal + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + text: tabIndex === 0 + ? qsTr("Capture an area or the screen and edit in Swappy") + : (Recorder.paused ? qsTr("Recording paused") : Recorder.running ? qsTr("Recording running") : qsTr("Recording off")) + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + elide: Text.ElideRight + } + } + + // Tab selector + contextual action on the right + RowLayout { + spacing: Appearance.spacing.small + + // Mini tab selector + RowLayout { + spacing: Appearance.spacing.smaller + + IconButton { + id: tabShots + icon: "photo" + type: IconButton.Text + toggle: true + checked: root.tabIndex === 0 + onClicked: { root.tabIndex = 0; root.props.utilitiesMediaTab = 0 } + label.text: qsTr("Shots") + } + + IconButton { + id: tabRecs + icon: "screen_record" + type: IconButton.Text + toggle: true + checked: root.tabIndex === 1 + onClicked: { root.tabIndex = 1; root.props.utilitiesMediaTab = 1 } + label.text: qsTr("Recs") + } + } + + // Contextual action area + Loader { + Layout.leftMargin: Appearance.spacing.normal + sourceComponent: root.tabIndex === 0 ? screenshotActions : recordingActions + } + } + } + + // Body switches between lists/controls + Loader { + id: bodyLoader + + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + sourceComponent: root.tabIndex === 0 ? screenshotList : (Recorder.running ? recordingControls : recordingList) + + Behavior on Layout.preferredHeight { + Anim {} + } + } + } + + // --- Screenshots actions --- + Component { + id: screenshotActions + + SplitButton { + id: screenshotSplit + z: 2 + disabled: false + active: menuItems.find(m => root.props.screenshotMode === m.icon + m.text) ?? menuItems[0] + menu.onItemSelected: item => root.props.screenshotMode = item.icon + item.text + stateLayer.disabled: false + + menuItems: [ + MenuItem { + icon: "screenshot" + text: qsTr("Area (edit)") + activeText: qsTr("Area") + onClicked: { + root.visibilities.utilities = false; + root.visibilities.sidebar = false; + pendingCommand = ["caelestia", "shell", "picker", "openFreeze"]; + delayTimer.restart(); + } + }, + MenuItem { + icon: "screenshot_region" + text: qsTr("Active window (edit)") + activeText: qsTr("Window") + onClicked: { + root.visibilities.utilities = false; + root.visibilities.sidebar = false; + pendingCommand = ["caelestia", "shell", "picker", "open"]; + delayTimer.restart(); + } + } + ] + } + } + + // --- Recording actions (when not running) --- + Component { + id: recordingActions + + SplitButton { + disabled: Recorder.running + active: menuItems.find(m => root.props.recordingMode === m.icon + m.text) ?? menuItems[0] + menu.onItemSelected: item => root.props.recordingMode = item.icon + item.text + + menuItems: [ + MenuItem { icon: "fullscreen"; text: qsTr("Record fullscreen"); activeText: qsTr("Fullscreen"); onClicked: Recorder.start() }, + MenuItem { icon: "screenshot_region"; text: qsTr("Record region"); activeText: qsTr("Region"); onClicked: Recorder.start(["-r"]) }, + MenuItem { icon: "select_to_speak"; text: qsTr("Record fullscreen with sound"); activeText: qsTr("Fullscreen"); onClicked: Recorder.start(["-s"]) }, + MenuItem { icon: "volume_up"; text: qsTr("Record region with sound"); activeText: qsTr("Region"); onClicked: Recorder.start(["-sr"]) } + ] + } + } + + // --- Bodies --- + Component { + id: screenshotList + + ScreenshotList { + props: root.props + visibilities: root.visibilities + } + } + + Component { + id: recordingList + + RecordingList { + props: root.props + visibilities: root.visibilities + } + } + + Component { + id: recordingControls + + RowLayout { + spacing: Appearance.spacing.normal + + StyledRect { + radius: Appearance.rounding.full + color: Recorder.paused ? Colours.palette.m3tertiary : Colours.palette.m3error + + implicitWidth: recText.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: recText.implicitHeight + Appearance.padding.smaller * 2 + + StyledText { + id: recText + + anchors.centerIn: parent + animate: true + text: Recorder.paused ? "PAUSED" : "REC" + color: Recorder.paused ? Colours.palette.m3onTertiary : Colours.palette.m3onError + font.family: Appearance.font.family.mono + } + + Behavior on implicitWidth { Anim {} } + + SequentialAnimation on opacity { + running: !Recorder.paused + alwaysRunToEnd: true + loops: Animation.Infinite + + Anim { from: 1; to: 0 } + Anim { from: 0; to: 1 } + } + } + + StyledText { + text: { + const elapsed = Recorder.elapsed; + + const hours = Math.floor(elapsed / 3600); + const mins = Math.floor((elapsed % 3600) / 60); + const secs = Math.floor(elapsed % 60).toString().padStart(2, "0"); + + let time; + if (hours > 0) + time = `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; + else + time = `${mins}:${secs}`; + + return qsTr("Recording for %1").arg(time); + } + font.pointSize: Appearance.font.size.normal + } + + Item { Layout.fillWidth: true } + + IconButton { + label.animate: true + icon: Recorder.paused ? "play_arrow" : "pause" + toggle: true + checked: Recorder.paused + type: IconButton.Tonal + font.pointSize: Appearance.font.size.large + onClicked: { + Recorder.togglePause(); + internalChecked = Recorder.paused; + } + } + + IconButton { + icon: "stop" + inactiveColour: Colours.palette.m3error + inactiveOnColour: Colours.palette.m3onError + font.pointSize: Appearance.font.size.large + onClicked: Recorder.stop() + } + } + } + + // Delayed launch for screenshot tools + property var pendingCommand: [] + + Timer { + id: delayTimer + interval: 300 + running: false + repeat: false + onTriggered: { + if (root.pendingCommand.length > 0) { + Quickshell.execDetached(root.pendingCommand); + root.pendingCommand = []; + } + } + } +} diff --git a/modules/utilities/cards/MediaList.qml b/modules/utilities/cards/MediaList.qml new file mode 100644 index 000000000..54fe02433 --- /dev/null +++ b/modules/utilities/cards/MediaList.qml @@ -0,0 +1,216 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.controls +import qs.components.containers +import qs.services +import qs.config +import qs.utils +import Caelestia +import Caelestia.Models +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property var props + required property var visibilities + required property string title + required property string path + required property var nameFilters + required property string firstIcon + required property var firstApp + required property string textPrefix + required property string expandedProp + + spacing: 0 + + WrapperMouseArea { + Layout.fillWidth: true + + cursorShape: Qt.PointingHandCursor + onClicked: root.props[root.expandedProp] = !root.props[root.expandedProp] + + RowLayout { + spacing: Appearance.spacing.smaller + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + text: "list" + font.pointSize: Appearance.font.size.large + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + text: qsTr(root.title) + font.pointSize: Appearance.font.size.normal + } + + IconButton { + icon: root.props[root.expandedProp] ? "unfold_less" : "unfold_more" + type: IconButton.Text + label.animate: true + onClicked: root.props[root.expandedProp] = !root.props[root.expandedProp] + } + } + } + + StyledListView { + id: list + + model: FileSystemModel { + path: root.path + nameFilters: root.nameFilters + sortReverse: true + } + + Layout.fillWidth: true + Layout.rightMargin: -Appearance.spacing.small + implicitHeight: (Appearance.font.size.larger + Appearance.padding.small) * (root.props[root.expandedProp] ? 10 : 3) + clip: true + + StyledScrollBar.vertical: StyledScrollBar { + flickable: list + } + + delegate: RowLayout { + id: item + + required property FileSystemEntry modelData + property string baseName + + anchors.left: list.contentItem.left + anchors.right: list.contentItem.right + anchors.rightMargin: Appearance.spacing.small + spacing: Appearance.spacing.small / 2 + + Component.onCompleted: baseName = modelData.baseName + + StyledText { + Layout.fillWidth: true + Layout.rightMargin: Appearance.spacing.small / 2 + text: { + const time = item.baseName; + const matches = time.match(new RegExp(`^${root.textPrefix}_(\\d{4})(\\d{2})(\\d{2})_(\\d{2})-(\\d{2})-(\\d{2})`)); + if (!matches) + return time; + const date = new Date(...matches.slice(1)); + return qsTr(`${root.textPrefix} at %1`).arg(Qt.formatDateTime(date, Qt.locale())); + } + color: Colours.palette.m3onSurfaceVariant + elide: Text.ElideRight + } + + IconButton { + icon: root.firstIcon + type: IconButton.Text + onClicked: { + root.visibilities.utilities = false; + root.visibilities.sidebar = false; + Quickshell.execDetached(["app2unit", "--", ...root.firstApp, item.modelData.path]); + } + } + + IconButton { + icon: "folder" + type: IconButton.Text + onClicked: { + root.visibilities.utilities = false; + root.visibilities.sidebar = false; + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, item.modelData.path]); + } + } + + IconButton { + icon: "delete_forever" + type: IconButton.Text + label.color: Colours.palette.m3error + stateLayer.color: Colours.palette.m3error + enabled: true + onClicked: root.props[root.textPrefix.toLowerCase() + "ConfirmDelete"] = item.modelData.path + } + } + + add: Transition { + Anim { + property: "opacity" + from: 0 + to: 1 + } + Anim { + property: "scale" + from: 0.5 + to: 1 + } + } + + remove: Transition { + Anim { + property: "opacity" + to: 0 + } + Anim { + property: "scale" + to: 0.5 + } + } + + displaced: Transition { + Anim { + properties: "opacity,scale" + to: 1 + } + Anim { + property: "y" + } + } + + Loader { + anchors.centerIn: parent + + opacity: list.count === 0 ? 1 : 0 + active: opacity > 0 + asynchronous: true + + sourceComponent: ColumnLayout { + spacing: Appearance.spacing.small + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: root.textPrefix === "Screenshot" ? "image" : "videocam" + font.pointSize: Appearance.font.size.larger * 2 + color: Colours.palette.m3onSurfaceVariant + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("No %1 found").arg(root.title.toLowerCase()) + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Take a %1 to see it here").arg(root.title.toLowerCase().slice(0, -1)) + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + } + } + + Behavior on opacity { + Anim {} + } + } + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } +} \ No newline at end of file diff --git a/run.sh b/run.sh new file mode 100755 index 000000000..928e24a2e --- /dev/null +++ b/run.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -e + +# Make sure .local exists +mkdir -p "$PWD/.local/usr" + +# Full build + install once before entering the loop +cmake --build build +cmake --install build + +# Function to rebuild + restart quickshell +run_quickshell() { + cmake --build build + cmake --install build + + # Kill old quickshell if running + pkill -x quickshell || true + + # Relaunch + QS_CONFIG_NAME=caelestia \ + XDG_CONFIG_DIRS="$PWD/.local/usr/etc/xdg" \ + quickshell & +} + +export -f run_quickshell + +# Find all QML, CPP, and header files, then watch them +find \ + "$PWD/caelestia" \ + "$PWD/plugin" \ + -name '*.qml' -o -name '*.cpp' -o -name '*.hpp' \ +| entr -r bash -c run_quickshell diff --git a/shell b/shell new file mode 120000 index 000000000..c45663c5c --- /dev/null +++ b/shell @@ -0,0 +1 @@ +/home/nikita/Projects/Private/shell \ No newline at end of file From f60ae78069db09393365d1534cfb6ba7efe7708c Mon Sep 17 00:00:00 2001 From: Nikita Friesen Date: Sun, 28 Sep 2025 03:49:50 +0200 Subject: [PATCH 03/14] feat: remove unused media components and optimize media list placeholders --- modules/utilities/cards/MediaCard.qml | 310 --------------------- modules/utilities/cards/MediaList.qml | 69 +++-- modules/utilities/cards/Record.qml | 277 ------------------ modules/utilities/cards/RecordingList.qml | 241 ---------------- modules/utilities/cards/Screenshot.qml | 154 ---------- modules/utilities/cards/ScreenshotList.qml | 239 ---------------- 6 files changed, 48 insertions(+), 1242 deletions(-) delete mode 100644 modules/utilities/cards/MediaCard.qml delete mode 100644 modules/utilities/cards/Record.qml delete mode 100644 modules/utilities/cards/RecordingList.qml delete mode 100644 modules/utilities/cards/Screenshot.qml delete mode 100644 modules/utilities/cards/ScreenshotList.qml diff --git a/modules/utilities/cards/MediaCard.qml b/modules/utilities/cards/MediaCard.qml deleted file mode 100644 index 75e8362a8..000000000 --- a/modules/utilities/cards/MediaCard.qml +++ /dev/null @@ -1,310 +0,0 @@ -pragma ComponentBehavior: Bound - -import qs.components -import qs.components.controls -import qs.services -import qs.config -import qs.utils -import Quickshell -import QtQuick -import QtQuick.Layouts - -StyledRect { - id: root - - required property var props - required property var visibilities - - Layout.fillWidth: true - implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 - - radius: Appearance.rounding.normal - color: Colours.tPalette.m3surfaceContainer - - // 0 = Screenshots, 1 = Recordings - property int tabIndex: props.utilitiesMediaTab - - ColumnLayout { - id: layout - - anchors.fill: parent - anchors.margins: Appearance.padding.large - spacing: Appearance.spacing.normal - - // Header - RowLayout { - spacing: Appearance.spacing.normal - z: 1 - - // Leading icon chip - StyledRect { - implicitWidth: implicitHeight - implicitHeight: { - const h = icon.implicitHeight + Appearance.padding.smaller * 2; - return h - (h % 2); - } - - radius: Appearance.rounding.full - color: tabIndex === 1 - ? (Recorder.running ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer) - : Colours.palette.m3secondaryContainer - - MaterialIcon { - id: icon - - anchors.centerIn: parent - anchors.horizontalCenterOffset: -0.5 - anchors.verticalCenterOffset: 1.5 - text: tabIndex === 0 ? "screenshot_monitor" : "screen_record" - color: tabIndex === 1 - ? (Recorder.running ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer) - : Colours.palette.m3onSecondaryContainer - font.pointSize: Appearance.font.size.large - } - } - - // Titles - ColumnLayout { - Layout.fillWidth: true - spacing: 0 - - StyledText { - Layout.fillWidth: true - text: tabIndex === 0 ? qsTr("Screenshots") : qsTr("Screen Recorder") - font.pointSize: Appearance.font.size.normal - elide: Text.ElideRight - } - - StyledText { - Layout.fillWidth: true - text: tabIndex === 0 - ? qsTr("Capture an area or the screen and edit in Swappy") - : (Recorder.paused ? qsTr("Recording paused") : Recorder.running ? qsTr("Recording running") : qsTr("Recording off")) - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small - elide: Text.ElideRight - } - } - - // Tab selector + contextual action on the right - RowLayout { - spacing: Appearance.spacing.small - - // Mini tab selector - RowLayout { - spacing: Appearance.spacing.smaller - - IconButton { - id: tabShots - icon: "photo" - type: IconButton.Text - toggle: true - checked: root.tabIndex === 0 - onClicked: { root.tabIndex = 0; root.props.utilitiesMediaTab = 0 } - label.text: qsTr("Shots") - } - - IconButton { - id: tabRecs - icon: "screen_record" - type: IconButton.Text - toggle: true - checked: root.tabIndex === 1 - onClicked: { root.tabIndex = 1; root.props.utilitiesMediaTab = 1 } - label.text: qsTr("Recs") - } - } - - // Contextual action area - Loader { - Layout.leftMargin: Appearance.spacing.normal - sourceComponent: root.tabIndex === 0 ? screenshotActions : recordingActions - } - } - } - - // Body switches between lists/controls - Loader { - id: bodyLoader - - Layout.fillWidth: true - Layout.preferredHeight: implicitHeight - sourceComponent: root.tabIndex === 0 ? screenshotList : (Recorder.running ? recordingControls : recordingList) - - Behavior on Layout.preferredHeight { - Anim {} - } - } - } - - // --- Screenshots actions --- - Component { - id: screenshotActions - - SplitButton { - id: screenshotSplit - z: 2 - disabled: false - active: menuItems.find(m => root.props.screenshotMode === m.icon + m.text) ?? menuItems[0] - menu.onItemSelected: item => root.props.screenshotMode = item.icon + item.text - stateLayer.disabled: false - - menuItems: [ - MenuItem { - icon: "screenshot" - text: qsTr("Area (edit)") - activeText: qsTr("Area") - onClicked: { - root.visibilities.utilities = false; - root.visibilities.sidebar = false; - pendingCommand = ["caelestia", "shell", "picker", "openFreeze"]; - delayTimer.restart(); - } - }, - MenuItem { - icon: "screenshot_region" - text: qsTr("Active window (edit)") - activeText: qsTr("Window") - onClicked: { - root.visibilities.utilities = false; - root.visibilities.sidebar = false; - pendingCommand = ["caelestia", "shell", "picker", "open"]; - delayTimer.restart(); - } - } - ] - } - } - - // --- Recording actions (when not running) --- - Component { - id: recordingActions - - SplitButton { - disabled: Recorder.running - active: menuItems.find(m => root.props.recordingMode === m.icon + m.text) ?? menuItems[0] - menu.onItemSelected: item => root.props.recordingMode = item.icon + item.text - - menuItems: [ - MenuItem { icon: "fullscreen"; text: qsTr("Record fullscreen"); activeText: qsTr("Fullscreen"); onClicked: Recorder.start() }, - MenuItem { icon: "screenshot_region"; text: qsTr("Record region"); activeText: qsTr("Region"); onClicked: Recorder.start(["-r"]) }, - MenuItem { icon: "select_to_speak"; text: qsTr("Record fullscreen with sound"); activeText: qsTr("Fullscreen"); onClicked: Recorder.start(["-s"]) }, - MenuItem { icon: "volume_up"; text: qsTr("Record region with sound"); activeText: qsTr("Region"); onClicked: Recorder.start(["-sr"]) } - ] - } - } - - // --- Bodies --- - Component { - id: screenshotList - - ScreenshotList { - props: root.props - visibilities: root.visibilities - } - } - - Component { - id: recordingList - - RecordingList { - props: root.props - visibilities: root.visibilities - } - } - - Component { - id: recordingControls - - RowLayout { - spacing: Appearance.spacing.normal - - StyledRect { - radius: Appearance.rounding.full - color: Recorder.paused ? Colours.palette.m3tertiary : Colours.palette.m3error - - implicitWidth: recText.implicitWidth + Appearance.padding.normal * 2 - implicitHeight: recText.implicitHeight + Appearance.padding.smaller * 2 - - StyledText { - id: recText - - anchors.centerIn: parent - animate: true - text: Recorder.paused ? "PAUSED" : "REC" - color: Recorder.paused ? Colours.palette.m3onTertiary : Colours.palette.m3onError - font.family: Appearance.font.family.mono - } - - Behavior on implicitWidth { Anim {} } - - SequentialAnimation on opacity { - running: !Recorder.paused - alwaysRunToEnd: true - loops: Animation.Infinite - - Anim { from: 1; to: 0 } - Anim { from: 0; to: 1 } - } - } - - StyledText { - text: { - const elapsed = Recorder.elapsed; - - const hours = Math.floor(elapsed / 3600); - const mins = Math.floor((elapsed % 3600) / 60); - const secs = Math.floor(elapsed % 60).toString().padStart(2, "0"); - - let time; - if (hours > 0) - time = `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; - else - time = `${mins}:${secs}`; - - return qsTr("Recording for %1").arg(time); - } - font.pointSize: Appearance.font.size.normal - } - - Item { Layout.fillWidth: true } - - IconButton { - label.animate: true - icon: Recorder.paused ? "play_arrow" : "pause" - toggle: true - checked: Recorder.paused - type: IconButton.Tonal - font.pointSize: Appearance.font.size.large - onClicked: { - Recorder.togglePause(); - internalChecked = Recorder.paused; - } - } - - IconButton { - icon: "stop" - inactiveColour: Colours.palette.m3error - inactiveOnColour: Colours.palette.m3onError - font.pointSize: Appearance.font.size.large - onClicked: Recorder.stop() - } - } - } - - // Delayed launch for screenshot tools - property var pendingCommand: [] - - Timer { - id: delayTimer - interval: 300 - running: false - repeat: false - onTriggered: { - if (root.pendingCommand.length > 0) { - Quickshell.execDetached(root.pendingCommand); - root.pendingCommand = []; - } - } - } -} diff --git a/modules/utilities/cards/MediaList.qml b/modules/utilities/cards/MediaList.qml index 54fe02433..94f18758e 100644 --- a/modules/utilities/cards/MediaList.qml +++ b/modules/utilities/cards/MediaList.qml @@ -176,28 +176,55 @@ ColumnLayout { active: opacity > 0 asynchronous: true - sourceComponent: ColumnLayout { - spacing: Appearance.spacing.small - - MaterialIcon { - Layout.alignment: Qt.AlignHCenter - text: root.textPrefix === "Screenshot" ? "image" : "videocam" - font.pointSize: Appearance.font.size.larger * 2 - color: Colours.palette.m3onSurfaceVariant + sourceComponent: list.implicitHeight > 150 ? expandedPlaceholder : collapsedPlaceholder + + Component { + id: expandedPlaceholder + + ColumnLayout { + spacing: Appearance.spacing.small + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: root.textPrefix === "Screenshot" ? "image" : "videocam" + font.pointSize: Appearance.font.size.larger * 2 + color: Colours.palette.m3onSurfaceVariant + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("No %1 found").arg(root.title.toLowerCase()) + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Take a %1 to see it here").arg(root.title.toLowerCase().slice(0, -1)) + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + } } + } - StyledText { - Layout.alignment: Qt.AlignHCenter - text: qsTr("No %1 found").arg(root.title.toLowerCase()) - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.normal - } + Component { + id: collapsedPlaceholder + + RowLayout { + spacing: Appearance.spacing.smaller + + MaterialIcon { + text: root.textPrefix === "Screenshot" ? "image" : "videocam" + font.pointSize: Appearance.font.size.normal + color: Colours.palette.m3onSurfaceVariant + } - StyledText { - Layout.alignment: Qt.AlignHCenter - text: qsTr("Take a %1 to see it here").arg(root.title.toLowerCase().slice(0, -1)) - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + StyledText { + text: qsTr("No %1").arg(root.title.toLowerCase()) + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + elide: Text.ElideRight + } } } @@ -208,8 +235,8 @@ ColumnLayout { Behavior on implicitHeight { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + duration: 80 + easing.bezierCurve: [0, 0, 1, 1] // Linear easing for speed } } } diff --git a/modules/utilities/cards/Record.qml b/modules/utilities/cards/Record.qml deleted file mode 100644 index 273c64002..000000000 --- a/modules/utilities/cards/Record.qml +++ /dev/null @@ -1,277 +0,0 @@ -pragma ComponentBehavior: Bound - -import qs.components -import qs.components.controls -import qs.services -import qs.config -import QtQuick -import QtQuick.Layouts - -StyledRect { - id: root - - required property var props - required property var visibilities - - Layout.fillWidth: true - implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 - - radius: Appearance.rounding.normal - color: Colours.tPalette.m3surfaceContainer - - ColumnLayout { - id: layout - - anchors.fill: parent - anchors.margins: Appearance.padding.large - spacing: Appearance.spacing.normal - - RowLayout { - spacing: Appearance.spacing.normal - z: 1 - - StyledRect { - implicitWidth: implicitHeight - implicitHeight: { - const h = icon.implicitHeight + Appearance.padding.smaller * 2; - return h - (h % 2); - } - - radius: Appearance.rounding.full - color: Recorder.running ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer - - MaterialIcon { - id: icon - - anchors.centerIn: parent - anchors.horizontalCenterOffset: -0.5 - anchors.verticalCenterOffset: 1.5 - text: "screen_record" - color: Recorder.running ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer - font.pointSize: Appearance.font.size.large - } - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 0 - - StyledText { - Layout.fillWidth: true - text: qsTr("Screen Recorder") - font.pointSize: Appearance.font.size.normal - elide: Text.ElideRight - } - - StyledText { - Layout.fillWidth: true - text: Recorder.paused ? qsTr("Recording paused") : Recorder.running ? qsTr("Recording running") : qsTr("Recording off") - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small - elide: Text.ElideRight - } - } - - SplitButton { - disabled: Recorder.running - active: menuItems.find(m => root.props.recordingMode === m.icon + m.text) ?? menuItems[0] - menu.onItemSelected: item => root.props.recordingMode = item.icon + item.text - - menuItems: [ - MenuItem { - icon: "fullscreen" - text: qsTr("Record fullscreen") - activeText: qsTr("Fullscreen") - onClicked: Recorder.start() - }, - MenuItem { - icon: "screenshot_region" - text: qsTr("Record region") - activeText: qsTr("Region") - onClicked: Recorder.start(["-r"]) - }, - MenuItem { - icon: "select_to_speak" - text: qsTr("Record fullscreen with sound") - activeText: qsTr("Fullscreen") - onClicked: Recorder.start(["-s"]) - }, - MenuItem { - icon: "volume_up" - text: qsTr("Record region with sound") - activeText: qsTr("Region") - onClicked: Recorder.start(["-sr"]) - } - ] - } - } - - Loader { - id: listOrControls - - property bool running: Recorder.running - - Layout.fillWidth: true - Layout.preferredHeight: implicitHeight - sourceComponent: running ? recordingControls : recordingList - - Behavior on Layout.preferredHeight { - id: locHeightAnim - - enabled: false - - Anim {} - } - - Behavior on running { - SequentialAnimation { - ParallelAnimation { - Anim { - target: listOrControls - property: "scale" - to: 0.7 - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.standardAccel - } - Anim { - target: listOrControls - property: "opacity" - to: 0 - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.standardAccel - } - } - PropertyAction { - target: locHeightAnim - property: "enabled" - value: true - } - PropertyAction {} - PropertyAction { - target: locHeightAnim - property: "enabled" - value: false - } - ParallelAnimation { - Anim { - target: listOrControls - property: "scale" - to: 1 - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.standardDecel - } - Anim { - target: listOrControls - property: "opacity" - to: 1 - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.standardDecel - } - } - } - } - } - } - - Component { - id: recordingList - - RecordingList { - props: root.props - visibilities: root.visibilities - } - } - - Component { - id: recordingControls - - RowLayout { - spacing: Appearance.spacing.normal - - StyledRect { - radius: Appearance.rounding.full - color: Recorder.paused ? Colours.palette.m3tertiary : Colours.palette.m3error - - implicitWidth: recText.implicitWidth + Appearance.padding.normal * 2 - implicitHeight: recText.implicitHeight + Appearance.padding.smaller * 2 - - StyledText { - id: recText - - anchors.centerIn: parent - animate: true - text: Recorder.paused ? "PAUSED" : "REC" - color: Recorder.paused ? Colours.palette.m3onTertiary : Colours.palette.m3onError - font.family: Appearance.font.family.mono - } - - Behavior on implicitWidth { - Anim {} - } - - SequentialAnimation on opacity { - running: !Recorder.paused - alwaysRunToEnd: true - loops: Animation.Infinite - - Anim { - from: 1 - to: 0 - duration: Appearance.anim.durations.large - easing.bezierCurve: Appearance.anim.curves.emphasizedAccel - } - Anim { - from: 0 - to: 1 - duration: Appearance.anim.durations.extraLarge - easing.bezierCurve: Appearance.anim.curves.emphasizedDecel - } - } - } - - StyledText { - text: { - const elapsed = Recorder.elapsed; - - const hours = Math.floor(elapsed / 3600); - const mins = Math.floor((elapsed % 3600) / 60); - const secs = Math.floor(elapsed % 60).toString().padStart(2, "0"); - - let time; - if (hours > 0) - time = `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; - else - time = `${mins}:${secs}`; - - return qsTr("Recording for %1").arg(time); - } - font.pointSize: Appearance.font.size.normal - } - - Item { - Layout.fillWidth: true - } - - IconButton { - label.animate: true - icon: Recorder.paused ? "play_arrow" : "pause" - toggle: true - checked: Recorder.paused - type: IconButton.Tonal - font.pointSize: Appearance.font.size.large - onClicked: { - Recorder.togglePause(); - internalChecked = Recorder.paused; - } - } - - IconButton { - icon: "stop" - inactiveColour: Colours.palette.m3error - inactiveOnColour: Colours.palette.m3onError - font.pointSize: Appearance.font.size.large - onClicked: Recorder.stop() - } - } - } -} diff --git a/modules/utilities/cards/RecordingList.qml b/modules/utilities/cards/RecordingList.qml deleted file mode 100644 index ab7537ea2..000000000 --- a/modules/utilities/cards/RecordingList.qml +++ /dev/null @@ -1,241 +0,0 @@ -pragma ComponentBehavior: Bound - -import qs.components -import qs.components.controls -import qs.components.containers -import qs.services -import qs.config -import qs.utils -import Caelestia -import Caelestia.Models -import Quickshell -import Quickshell.Widgets -import QtQuick -import QtQuick.Layouts - -ColumnLayout { - id: root - - required property var props - required property var visibilities - - spacing: 0 - - WrapperMouseArea { - Layout.fillWidth: true - - cursorShape: Qt.PointingHandCursor - onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded - - RowLayout { - spacing: Appearance.spacing.smaller - - MaterialIcon { - Layout.alignment: Qt.AlignVCenter - text: "list" - font.pointSize: Appearance.font.size.large - } - - StyledText { - Layout.alignment: Qt.AlignVCenter - Layout.fillWidth: true - text: qsTr("Recordings") - font.pointSize: Appearance.font.size.normal - } - - IconButton { - icon: root.props.recordingListExpanded ? "unfold_less" : "unfold_more" - type: IconButton.Text - label.animate: true - onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded - } - } - } - - StyledListView { - id: list - - model: FileSystemModel { - path: Paths.recsdir - nameFilters: ["recording_*.mp4"] - sortReverse: true - } - - Layout.fillWidth: true - Layout.rightMargin: -Appearance.spacing.small - implicitHeight: (Appearance.font.size.larger + Appearance.padding.small) * (root.props.recordingListExpanded ? 10 : 3) - clip: true - - StyledScrollBar.vertical: StyledScrollBar { - flickable: list - } - - delegate: RowLayout { - id: recording - - required property FileSystemEntry modelData - property string baseName - - anchors.left: list.contentItem.left - anchors.right: list.contentItem.right - anchors.rightMargin: Appearance.spacing.small - spacing: Appearance.spacing.small / 2 - - Component.onCompleted: baseName = modelData.baseName - - StyledText { - Layout.fillWidth: true - Layout.rightMargin: Appearance.spacing.small / 2 - text: { - const time = recording.baseName; - const matches = time.match(/^recording_(\d{4})(\d{2})(\d{2})_(\d{2})-(\d{2})-(\d{2})/); - if (!matches) - return time; - const date = new Date(...matches.slice(1)); - return qsTr("Recording at %1").arg(Qt.formatDateTime(date, Qt.locale())); - } - color: Colours.palette.m3onSurfaceVariant - elide: Text.ElideRight - } - - IconButton { - icon: "play_arrow" - type: IconButton.Text - onClicked: { - root.visibilities.utilities = false; - root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); - } - } - - IconButton { - icon: "folder" - type: IconButton.Text - onClicked: { - root.visibilities.utilities = false; - root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); - } - } - - IconButton { - icon: "delete_forever" - type: IconButton.Text - label.color: Colours.palette.m3error - stateLayer.color: Colours.palette.m3error - onClicked: root.props.recordingConfirmDelete = recording.modelData.path - } - } - - add: Transition { - Anim { - property: "opacity" - from: 0 - to: 1 - } - Anim { - property: "scale" - from: 0.5 - to: 1 - } - } - - remove: Transition { - Anim { - property: "opacity" - to: 0 - } - Anim { - property: "scale" - to: 0.5 - } - } - - displaced: Transition { - Anim { - properties: "opacity,scale" - to: 1 - } - Anim { - property: "y" - } - } - - Loader { - anchors.centerIn: parent - - opacity: list.count === 0 ? 1 : 0 - active: opacity > 0 - asynchronous: true - - sourceComponent: ColumnLayout { - spacing: Appearance.spacing.small - - MaterialIcon { - Layout.alignment: Qt.AlignHCenter - text: "scan_delete" - color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.extraLarge - - opacity: root.props.recordingListExpanded ? 1 : 0 - scale: root.props.recordingListExpanded ? 1 : 0 - Layout.preferredHeight: root.props.recordingListExpanded ? implicitHeight : 0 - - Behavior on opacity { - Anim {} - } - - Behavior on scale { - Anim {} - } - - Behavior on Layout.preferredHeight { - Anim {} - } - } - - RowLayout { - spacing: Appearance.spacing.smaller - - MaterialIcon { - Layout.alignment: Qt.AlignHCenter - text: "scan_delete" - color: Colours.palette.m3outline - - opacity: !root.props.recordingListExpanded ? 1 : 0 - scale: !root.props.recordingListExpanded ? 1 : 0 - Layout.preferredWidth: !root.props.recordingListExpanded ? implicitWidth : 0 - - Behavior on opacity { - Anim {} - } - - Behavior on scale { - Anim {} - } - - Behavior on Layout.preferredWidth { - Anim {} - } - } - - StyledText { - text: qsTr("No recordings found") - color: Colours.palette.m3outline - } - } - } - - Behavior on opacity { - Anim {} - } - } - - Behavior on implicitHeight { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - } - } -} diff --git a/modules/utilities/cards/Screenshot.qml b/modules/utilities/cards/Screenshot.qml deleted file mode 100644 index bbef6d31d..000000000 --- a/modules/utilities/cards/Screenshot.qml +++ /dev/null @@ -1,154 +0,0 @@ -pragma ComponentBehavior: Bound - -import qs.components -import qs.components.controls -import qs.services -import qs.config -import qs.utils -import Quickshell -import QtQuick -import QtQuick.Layouts - -StyledRect { - id: root - - required property var props - required property var visibilities - - Layout.fillWidth: true - implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 - - radius: Appearance.rounding.normal - color: Colours.tPalette.m3surfaceContainer - - ColumnLayout { - id: layout - - anchors.fill: parent - anchors.margins: Appearance.padding.large - spacing: Appearance.spacing.normal - - RowLayout { - spacing: Appearance.spacing.normal - z: 1 - - StyledRect { - implicitWidth: implicitHeight - implicitHeight: { - const h = icon.implicitHeight + Appearance.padding.smaller * 2; - return h - (h % 2); - } - - radius: Appearance.rounding.full - color: Colours.palette.m3secondaryContainer - - MaterialIcon { - id: icon - - anchors.centerIn: parent - anchors.horizontalCenterOffset: -0.5 - anchors.verticalCenterOffset: 1.5 - text: "screenshot_monitor" - color: Colours.palette.m3onSecondaryContainer - font.pointSize: Appearance.font.size.large - } - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 0 - - StyledText { - Layout.fillWidth: true - text: qsTr("Screenshots") - font.pointSize: Appearance.font.size.normal - elide: Text.ElideRight - } - - StyledText { - Layout.fillWidth: true - text: qsTr("Capture an area or the screen and edit in Swappy") - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small - elide: Text.ElideRight - } - } - - SplitButton { - id: screenshotSplit - // Ensure the main action area stays interactive and above potential siblings - z: 2 - disabled: false - active: menuItems.find(m => root.props.screenshotMode === m.icon + m.text) ?? menuItems[0] - menu.onItemSelected: item => root.props.screenshotMode = item.icon + item.text - // Be explicit: the primary click target is enabled - stateLayer.disabled: false - - menuItems: [ - MenuItem { - icon: "screenshot" - text: qsTr("Area (edit)") - activeText: qsTr("Area") - onClicked: { - root.visibilities.utilities = false; - root.visibilities.sidebar = false; - pendingCommand = ["caelestia", "shell", "picker", "openFreeze"]; - delayTimer.start(); - } - }, - MenuItem { - icon: "screenshot_region" - text: qsTr("Active window (edit)") - activeText: qsTr("Window") - onClicked: { - root.visibilities.utilities = false; - root.visibilities.sidebar = false; - pendingCommand = ["caelestia", "shell", "picker", "open"]; - delayTimer.start(); - } - } - ] - } - } - - Loader { - id: screenshotLoader - - Layout.fillWidth: true - Layout.preferredHeight: implicitHeight - sourceComponent: screenshotList - - Behavior on Layout.preferredHeight { - id: screenshotHeightAnim - - enabled: false - - Anim {} - } - } - } - - Component { - id: screenshotList - - ScreenshotList { - props: root.props - visibilities: root.visibilities - } - } - - property var pendingCommand: [] - - Timer { - id: delayTimer - - interval: 300 - - onTriggered: { - if (pendingCommand.length > 0) { - Quickshell.execDetached(pendingCommand); - pendingCommand = []; - } - } - } -} diff --git a/modules/utilities/cards/ScreenshotList.qml b/modules/utilities/cards/ScreenshotList.qml deleted file mode 100644 index 90bb68a65..000000000 --- a/modules/utilities/cards/ScreenshotList.qml +++ /dev/null @@ -1,239 +0,0 @@ -pragma ComponentBehavior: Bound - -import qs.components -import qs.components.controls -import qs.components.containers -import qs.services -import qs.config -import qs.utils -import Caelestia -import Caelestia.Models -import Quickshell -import Quickshell.Widgets -import QtQuick -import QtQuick.Layouts - -ColumnLayout { - id: root - - required property var props - required property var visibilities - - spacing: 0 - - WrapperMouseArea { - Layout.fillWidth: true - - cursorShape: Qt.PointingHandCursor - onClicked: root.props.screenshotListExpanded = !root.props.screenshotListExpanded - - RowLayout { - spacing: Appearance.spacing.smaller - - MaterialIcon { - Layout.alignment: Qt.AlignVCenter - text: "list" - font.pointSize: Appearance.font.size.large - } - - StyledText { - Layout.alignment: Qt.AlignVCenter - Layout.fillWidth: true - text: qsTr("Screenshots") - font.pointSize: Appearance.font.size.normal - } - - IconButton { - icon: root.props.screenshotListExpanded ? "unfold_less" : "unfold_more" - type: IconButton.Text - label.animate: true - onClicked: root.props.screenshotListExpanded = !root.props.screenshotListExpanded - } - } - } - - StyledListView { - id: list - - model: FileSystemModel { - path: Paths.shotsdir - nameFilters: ["screenshot_*.png"] - sortReverse: true - } - - Layout.fillWidth: true - Layout.rightMargin: -Appearance.spacing.small - implicitHeight: (Appearance.font.size.larger + Appearance.padding.small) * (root.props.screenshotListExpanded ? 10 : 3) - clip: true - - StyledScrollBar.vertical: StyledScrollBar { - flickable: list - } - - delegate: RowLayout { - id: screenshot - - required property FileSystemEntry modelData - property string baseName - - anchors.left: list.contentItem.left - anchors.right: list.contentItem.right - anchors.rightMargin: Appearance.spacing.small - spacing: Appearance.spacing.small / 2 - - Component.onCompleted: baseName = modelData.baseName - - StyledText { - Layout.fillWidth: true - Layout.rightMargin: Appearance.spacing.small / 2 - text: { - const time = screenshot.baseName; - const matches = time.match(/^screenshot_(\d{4})(\d{2})(\d{2})_(\d{2})-(\d{2})-(\d{2})/); - if (!matches) - return time; - const date = new Date(...matches.slice(1)); - return qsTr("Screenshot at %1").arg(Qt.formatDateTime(date, Qt.locale())); - } - color: Colours.palette.m3onSurfaceVariant - elide: Text.ElideRight - } - - IconButton { - icon: "photo_library" - type: IconButton.Text - onClicked: { - root.visibilities.utilities = false; - root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.image, screenshot.modelData.path]); - } - } - - IconButton { - icon: "folder" - type: IconButton.Text - onClicked: { - root.visibilities.utilities = false; - root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, screenshot.modelData.path]); - } - } - - IconButton { - icon: "delete_forever" - type: IconButton.Text - label.color: Colours.palette.m3error - stateLayer.color: Colours.palette.m3error - onClicked: root.props.screenshotConfirmDelete = screenshot.modelData.path - } - } - - add: Transition { - Anim { - property: "opacity" - from: 0 - to: 1 - } - Anim { - property: "scale" - from: 0.5 - to: 1 - } - } - - remove: Transition { - Anim { - property: "opacity" - to: 0 - } - Anim { - property: "scale" - to: 0.5 - } - } - - displaced: Transition { - Anim { - properties: "opacity,scale" - to: 1 - } - Anim { - property: "y" - } - } - - Loader { - anchors.centerIn: parent - - opacity: list.count === 0 ? 1 : 0 - active: opacity > 0 - asynchronous: true - - sourceComponent: ColumnLayout { - spacing: Appearance.spacing.small - - MaterialIcon { - Layout.alignment: Qt.AlignHCenter - text: "scan_delete" - color: Colours.palette.m3outline - font.pointSize: Appearance.font.size.extraLarge - - opacity: root.props.screenshotListExpanded ? 1 : 0 - scale: root.props.screenshotListExpanded ? 1 : 0 - Layout.preferredHeight: root.props.screenshotListExpanded ? implicitHeight : 0 - - Behavior on opacity { - Anim {} - } - - Behavior on scale { - Anim {} - } - - Behavior on Layout.preferredHeight { - Anim {} - } - } - - RowLayout { - spacing: Appearance.spacing.smaller - - MaterialIcon { - Layout.alignment: Qt.AlignHCenter - text: "scan_delete" - color: Colours.palette.m3outline - - opacity: !root.props.screenshotListExpanded ? 1 : 0 - scale: !root.props.screenshotListExpanded ? 1 : 0 - Layout.preferredWidth: !root.props.screenshotListExpanded ? implicitWidth : 0 - - Behavior on opacity { - Anim {} - } - - Behavior on scale { - Anim {} - } - - Behavior on Layout.preferredWidth { - Anim {} - } - } - - StyledText { - text: qsTr("No screenshots found") - color: Colours.palette.m3outline - } - } - } - - Behavior on opacity { Anim {} } - } - - Behavior on implicitHeight { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - } - } -} From 1c1286fc7653b842d8448d292522bd966dc7665d Mon Sep 17 00:00:00 2001 From: Nikita Friesen Date: Sun, 28 Sep 2025 04:23:27 +0200 Subject: [PATCH 04/14] feat: add invert scroll direction option for enhanced user control --- config/BarConfig.qml | 1 + modules/bar/Bar.qml | 11 ++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/config/BarConfig.qml b/config/BarConfig.qml index 0067bced9..3ea78b699 100644 --- a/config/BarConfig.qml +++ b/config/BarConfig.qml @@ -54,6 +54,7 @@ JsonObject { property bool workspaces: true property bool volume: true property bool brightness: true + property bool invertScrollDirection: false } component Workspaces: JsonObject { diff --git a/modules/bar/Bar.qml b/modules/bar/Bar.qml index 64b1d8672..5a98245c9 100644 --- a/modules/bar/Bar.qml +++ b/modules/bar/Bar.qml @@ -76,6 +76,7 @@ ColumnLayout { } function handleWheel(y: real, angleDelta: point): void { + const invert = Config.bar.scrollActions.invertScrollDirection; const ch = childAt(width / 2, y) as WrappedLoader; if (ch?.id === "workspaces" && Config.bar.scrollActions.workspaces) { // Workspace scroll @@ -84,19 +85,19 @@ ColumnLayout { if (specialWs?.length > 0) Hypr.dispatch(`togglespecialworkspace ${specialWs.slice(8)}`); else if (angleDelta.y < 0 || (Config.bar.workspaces.perMonitorWorkspaces ? mon.activeWorkspace?.id : Hypr.activeWsId) > 1) - Hypr.dispatch(`workspace r${angleDelta.y > 0 ? "-" : "+"}1`); + Hypr.dispatch(`workspace r${angleDelta.y > 0 ? (invert ? "+" : "-") : (invert ? "-" : "+")}1`); } else if (y < screen.height / 2 && Config.bar.scrollActions.volume) { // Volume scroll on top half - if (angleDelta.y > 0) + if ((angleDelta.y > 0) !== invert) Audio.incrementVolume(); - else if (angleDelta.y < 0) + else if ((angleDelta.y < 0) !== invert) Audio.decrementVolume(); } else if (Config.bar.scrollActions.brightness) { // Brightness scroll on bottom half const monitor = Brightness.getMonitorForScreen(screen); - if (angleDelta.y > 0) + if ((angleDelta.y > 0) !== invert) monitor.setBrightness(monitor.brightness + 0.1); - else if (angleDelta.y < 0) + else if ((angleDelta.y < 0) !== invert) monitor.setBrightness(monitor.brightness - 0.1); } } From 9af0cfc78c72da1b8e02ce25e64f9e96ee0ee8d5 Mon Sep 17 00:00:00 2001 From: Nikita Friesen Date: Sun, 28 Sep 2025 06:07:44 +0200 Subject: [PATCH 05/14] feat: update installation paths and enhance build configuration for local development --- .envrc | 5 +- install-system.sh | 3 + modules/utilities/cards/MediaList.qml | 323 ++++++++++++++------------ run.sh | 23 +- 4 files changed, 205 insertions(+), 149 deletions(-) diff --git a/.envrc b/.envrc index 8c73c5305..c8b550bac 100644 --- a/.envrc +++ b/.envrc @@ -4,7 +4,7 @@ if has nix; then fi # Where we "install" locally -export CAELESTIA_PREFIX="$PWD/.local/usr" +export CAELESTIA_PREFIX="$PWD/.local" export CAELESTIA_LIB_DIR="$CAELESTIA_PREFIX/lib" export QML2_IMPORT_PATH="$CAELESTIA_PREFIX/lib/qt6/qml:${QML2_IMPORT_PATH:-}" export PATH="$CAELESTIA_PREFIX/bin:$PATH" @@ -16,6 +16,9 @@ if [ ! -f build/Makefile ] && [ ! -f build/build.ninja ]; then -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DCMAKE_CXX_COMPILER=clazy \ -DCMAKE_INSTALL_PREFIX=$CAELESTIA_PREFIX \ + -DINSTALL_LIBDIR=lib/caelestia \ + -DINSTALL_QMLDIR=lib/qt6/qml \ + -DINSTALL_QSCONFDIR=etc/xdg/quickshell/caelestia \ -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON fi diff --git a/install-system.sh b/install-system.sh index 66937e776..eef991db3 100644 --- a/install-system.sh +++ b/install-system.sh @@ -8,6 +8,9 @@ cmake -S . -B build \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DCMAKE_CXX_COMPILER=clazy \ -DCMAKE_INSTALL_PREFIX=/usr \ + -DINSTALL_LIBDIR=lib/caelestia \ + -DINSTALL_QMLDIR=lib/qt6/qml \ + -DINSTALL_QSCONFDIR=etc/xdg/quickshell/caelestia \ -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON diff --git a/modules/utilities/cards/MediaList.qml b/modules/utilities/cards/MediaList.qml index 94f18758e..b1c1b1f65 100644 --- a/modules/utilities/cards/MediaList.qml +++ b/modules/utilities/cards/MediaList.qml @@ -13,6 +13,18 @@ import Quickshell.Widgets import QtQuick import QtQuick.Layouts +// ============================ +// OVERFLOW-PROOF EXPANDABLE LIST +// Key ideas: +// 1) Only the VIEWPORT animates height (Layout.preferredHeight) and owns clipping. +// 2) Placeholder is drawn in a dedicated CLIPPED wrapper with height bound to the +// viewport's CURRENT animatedHeight (not target), so it can never paint past the bottom. +// 3) The placeholder slides from TOP -> CENTER immediately, but its y is CLAMPED each frame +// against (viewport.animatedHeight - effectiveHeight) so scale/slide can never exceed bounds. +// 4) We account for SCALE when clamping by using transformOrigin: Item.Top and multiplying height * scale. +// 5) We avoid anchor churn; positions are computed numerically (x/y), per Qt docs. +// ============================ + ColumnLayout { id: root @@ -28,9 +40,9 @@ ColumnLayout { spacing: 0 + // ---------- HEADER ---------- WrapperMouseArea { Layout.fillWidth: true - cursorShape: Qt.PointingHandCursor onClicked: root.props[root.expandedProp] = !root.props[root.expandedProp] @@ -59,185 +71,208 @@ ColumnLayout { } } - StyledListView { - id: list - - model: FileSystemModel { - path: root.path - nameFilters: root.nameFilters - sortReverse: true - } - + // ---------- VIEWPORT (the only resizable, clipping parent) ---------- + Item { + id: viewport Layout.fillWidth: true Layout.rightMargin: -Appearance.spacing.small - implicitHeight: (Appearance.font.size.larger + Appearance.padding.small) * (root.props[root.expandedProp] ? 10 : 3) clip: true + // Enforce a separate layer to guarantee clipping across z-stacking + layer.enabled: true + layer.smooth: true - StyledScrollBar.vertical: StyledScrollBar { - flickable: list - } + readonly property real rowHeight: Appearance.font.size.larger + Appearance.padding.small + readonly property int collapsedRows: 3 + readonly property int expandedRows: 10 + readonly property real targetHeight: rowHeight * (root.props[root.expandedProp] ? expandedRows : collapsedRows) + property real animatedHeight: targetHeight - delegate: RowLayout { - id: item + // Live target toggle (drives immediate slide/scale) + property bool expandedTarget: root.props[root.expandedProp] - required property FileSystemEntry modelData - property string baseName + Layout.preferredHeight: animatedHeight + height: animatedHeight - anchors.left: list.contentItem.left - anchors.right: list.contentItem.right - anchors.rightMargin: Appearance.spacing.small - spacing: Appearance.spacing.small / 2 + Behavior on animatedHeight { + Anim { + id: heightAnim + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } - Component.onCompleted: baseName = modelData.baseName + // ---------- LIST ---------- + StyledListView { + id: list + anchors.fill: parent + clip: true - StyledText { - Layout.fillWidth: true - Layout.rightMargin: Appearance.spacing.small / 2 - text: { - const time = item.baseName; - const matches = time.match(new RegExp(`^${root.textPrefix}_(\\d{4})(\\d{2})(\\d{2})_(\\d{2})-(\\d{2})-(\\d{2})`)); - if (!matches) - return time; - const date = new Date(...matches.slice(1)); - return qsTr(`${root.textPrefix} at %1`).arg(Qt.formatDateTime(date, Qt.locale())); - } - color: Colours.palette.m3onSurfaceVariant - elide: Text.ElideRight + model: FileSystemModel { + path: root.path + nameFilters: root.nameFilters + sortReverse: true } - IconButton { - icon: root.firstIcon - type: IconButton.Text - onClicked: { - root.visibilities.utilities = false; - root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...root.firstApp, item.modelData.path]); + StyledScrollBar.vertical: StyledScrollBar { flickable: list } + + delegate: RowLayout { + id: item + required property FileSystemEntry modelData + property string baseName + + anchors.left: list.contentItem.left + anchors.right: list.contentItem.right + anchors.rightMargin: Appearance.spacing.small + spacing: Appearance.spacing.small / 2 + + Component.onCompleted: baseName = modelData.baseName + + StyledText { + Layout.fillWidth: true + Layout.rightMargin: Appearance.spacing.small / 2 + text: { + const time = item.baseName; + const rx = new RegExp(`^${root.textPrefix}_(\\d{4})(\\d{2})(\\d{2})_(\\d{2})-(\\d{2})-(\\d{2})`); + const m = time.match(rx); + if (!m) return time; + const y = +m[1], mo = (+m[2]) - 1, d = +m[3], hh = +m[4], mm = +m[5], ss = +m[6]; + const date = new Date(y, mo, d, hh, mm, ss); + return qsTr(`${root.textPrefix} at %1`).arg(Qt.formatDateTime(date, Qt.locale())); + } + color: Colours.palette.m3onSurfaceVariant + elide: Text.ElideRight } - } - IconButton { - icon: "folder" - type: IconButton.Text - onClicked: { - root.visibilities.utilities = false; - root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, item.modelData.path]); + IconButton { + icon: root.firstIcon + type: IconButton.Text + onClicked: { + root.visibilities.utilities = false; + root.visibilities.sidebar = false; + Quickshell.execDetached(["app2unit", "--", ...root.firstApp, item.modelData.path]); + } } - } - IconButton { - icon: "delete_forever" - type: IconButton.Text - label.color: Colours.palette.m3error - stateLayer.color: Colours.palette.m3error - enabled: true - onClicked: root.props[root.textPrefix.toLowerCase() + "ConfirmDelete"] = item.modelData.path - } - } + IconButton { + icon: "folder" + type: IconButton.Text + onClicked: { + root.visibilities.utilities = false; + root.visibilities.sidebar = false; + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, item.modelData.path]); + } + } - add: Transition { - Anim { - property: "opacity" - from: 0 - to: 1 - } - Anim { - property: "scale" - from: 0.5 - to: 1 + IconButton { + icon: "delete_forever" + type: IconButton.Text + label.color: Colours.palette.m3error + stateLayer.color: Colours.palette.m3error + enabled: true + onClicked: root.props[root.textPrefix.toLowerCase() + "ConfirmDelete"] = item.modelData.path + } } - } - remove: Transition { - Anim { - property: "opacity" - to: 0 - } - Anim { - property: "scale" - to: 0.5 - } + add: Transition { Anim { property: "opacity"; from: 0; to: 1 } Anim { property: "scale"; from: 0.5; to: 1 } } + remove: Transition { Anim { property: "opacity"; to: 0 } Anim { property: "scale"; to: 0.5 } } + displaced: Transition { Anim { properties: "opacity,scale"; to: 1 } Anim { property: "y" } } } - displaced: Transition { - Anim { - properties: "opacity,scale" - to: 1 - } - Anim { - property: "y" - } + // ---------- PSEUDO CONTAINER (TARGET CENTER REFERENCE) ---------- + Item { + id: pseudoContainer + width: parent.width + height: viewport.targetHeight // center reference independent of current height } - Loader { - anchors.centerIn: parent - - opacity: list.count === 0 ? 1 : 0 - active: opacity > 0 - asynchronous: true - - sourceComponent: list.implicitHeight > 150 ? expandedPlaceholder : collapsedPlaceholder - - Component { - id: expandedPlaceholder + // ---------- PLACEHOLDER (NO DATA) ---------- + // Fully isolated wrapper clipped to current viewport height. + Item { + id: phWrap + width: parent.width + height: viewport.animatedHeight // HARD bound to current animated height + clip: true + anchors.top: parent.top + z: 10 + visible: list.count === 0 + // separate layer to ensure clipping is preserved with z ordering + layer.enabled: true + layer.smooth: true + + // Which visual style to show + readonly property bool showBig: viewport.expandedTarget + readonly property bool showSmall: !viewport.expandedTarget + + // Utility to clamp Y using *effective* height (height * scale) + function clampedYFor(node, desired) { + const effH = node.height * node.scale; + const maxY = Math.max(0, phWrap.height - effH); + return Math.min(Math.max(0, desired), maxY); + } - ColumnLayout { + // ---- BIG variant ---- + Item { + id: big + visible: phWrap.showBig + opacity: visible ? 1 : 0 + // To make scaling grow downward (so bottom clamp is reliable), scale around TOP + transformOrigin: Item.Top + scale: visible ? 1 : 0.95 + width: bigCol.implicitWidth + height: bigCol.implicitHeight + x: Math.max(0, (phWrap.width - width) / 2) + // Desired center based on TARGET height; then clamped by CURRENT viewport height + property real desiredCenter: Math.max(0, (pseudoContainer.height - height) / 2) + y: phWrap.clampedYFor(big, viewport.expandedTarget ? big.desiredCenter : 0) + + Behavior on opacity { Anim { duration: heightAnim.duration } } + Behavior on scale { Anim { duration: heightAnim.duration } } + Behavior on y { Anim { duration: heightAnim.duration; easing: heightAnim.easing } } + + Column { + id: bigCol spacing: Appearance.spacing.small + anchors.horizontalCenter: parent.horizontalCenter MaterialIcon { - Layout.alignment: Qt.AlignHCenter + anchors.horizontalCenter: parent.horizontalCenter text: root.textPrefix === "Screenshot" ? "image" : "videocam" - font.pointSize: Appearance.font.size.larger * 2 - color: Colours.palette.m3onSurfaceVariant + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.extraLarge } - StyledText { - Layout.alignment: Qt.AlignHCenter + anchors.horizontalCenter: parent.horizontalCenter text: qsTr("No %1 found").arg(root.title.toLowerCase()) - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.normal - } - - StyledText { - Layout.alignment: Qt.AlignHCenter - text: qsTr("Take a %1 to see it here").arg(root.title.toLowerCase().slice(0, -1)) - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + color: Colours.palette.m3outline } } } - Component { - id: collapsedPlaceholder - - RowLayout { + // ---- SMALL variant ---- + Item { + id: small + visible: phWrap.showSmall + opacity: visible ? 1 : 0 + transformOrigin: Item.Top + scale: visible ? 1 : 0.95 + width: smallRow.implicitWidth + height: smallRow.implicitHeight + x: Math.max(0, (phWrap.width - width) / 2) + property real desiredCenter: Math.max(0, (pseudoContainer.height - height) / 2) + y: phWrap.clampedYFor(small, viewport.expandedTarget ? 0 : small.desiredCenter) + + Behavior on opacity { Anim { duration: heightAnim.duration } } + Behavior on scale { Anim { duration: heightAnim.duration } } + Behavior on y { Anim { duration: heightAnim.duration; easing: heightAnim.easing } } + + Row { + id: smallRow spacing: Appearance.spacing.smaller - - MaterialIcon { - text: root.textPrefix === "Screenshot" ? "image" : "videocam" - font.pointSize: Appearance.font.size.normal - color: Colours.palette.m3onSurfaceVariant - } - - StyledText { - text: qsTr("No %1").arg(root.title.toLowerCase()) - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small - elide: Text.ElideRight - } + anchors.horizontalCenter: parent.horizontalCenter + MaterialIcon { text: root.textPrefix === "Screenshot" ? "image" : "videocam"; color: Colours.palette.m3outline } + StyledText { text: qsTr("No %1").arg(root.title.toLowerCase()); color: Colours.palette.m3outline } } } - - Behavior on opacity { - Anim {} - } - } - - Behavior on implicitHeight { - Anim { - duration: 80 - easing.bezierCurve: [0, 0, 1, 1] // Linear easing for speed - } } } -} \ No newline at end of file +} diff --git a/run.sh b/run.sh index 928e24a2e..b777f6504 100755 --- a/run.sh +++ b/run.sh @@ -2,7 +2,21 @@ set -e # Make sure .local exists -mkdir -p "$PWD/.local/usr" +mkdir -p "$PWD/.local" + +# Configure build if not already done +if [ ! -d build ]; then + cmake -S . -B build \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_CXX_COMPILER=clazy \ + -DCMAKE_INSTALL_PREFIX=$PWD/.local \ + -DINSTALL_LIBDIR=lib/caelestia \ + -DINSTALL_QMLDIR=lib/qt6/qml \ + -DINSTALL_QSCONFDIR=etc/xdg/quickshell/caelestia \ + -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +fi # Full build + install once before entering the loop cmake --build build @@ -17,8 +31,9 @@ run_quickshell() { pkill -x quickshell || true # Relaunch + export QML2_IMPORT_PATH="$PWD/.local/lib/qt6/qml:$QML2_IMPORT_PATH" QS_CONFIG_NAME=caelestia \ - XDG_CONFIG_DIRS="$PWD/.local/usr/etc/xdg" \ + XDG_CONFIG_DIRS="$PWD/.local/etc/xdg" \ quickshell & } @@ -26,7 +41,7 @@ export -f run_quickshell # Find all QML, CPP, and header files, then watch them find \ - "$PWD/caelestia" \ - "$PWD/plugin" \ + "$PWD" \ -name '*.qml' -o -name '*.cpp' -o -name '*.hpp' \ +| grep -v '^./build' \ | entr -r bash -c run_quickshell From 2df871606cbdda7a4b4584cded4e16164c9fe71a Mon Sep 17 00:00:00 2001 From: Nikita Friesen Date: Sun, 28 Sep 2025 06:07:44 +0200 Subject: [PATCH 06/14] feat: unified media capture Based on gitkubikon's Media card implementation: - Tab-based interface with screenshot/recording mode switching - Compact header design without verbose descriptions - Contextual SplitButton that adapts to active tab - Unified MediaList component for both screenshots and recordings - Space-efficient design with better information density --- .envrc | 5 +- install-system.sh | 3 + modules/utilities/cards/MediaList.qml | 323 ++++++++++++++------------ run.sh | 23 +- 4 files changed, 205 insertions(+), 149 deletions(-) diff --git a/.envrc b/.envrc index 8c73c5305..c8b550bac 100644 --- a/.envrc +++ b/.envrc @@ -4,7 +4,7 @@ if has nix; then fi # Where we "install" locally -export CAELESTIA_PREFIX="$PWD/.local/usr" +export CAELESTIA_PREFIX="$PWD/.local" export CAELESTIA_LIB_DIR="$CAELESTIA_PREFIX/lib" export QML2_IMPORT_PATH="$CAELESTIA_PREFIX/lib/qt6/qml:${QML2_IMPORT_PATH:-}" export PATH="$CAELESTIA_PREFIX/bin:$PATH" @@ -16,6 +16,9 @@ if [ ! -f build/Makefile ] && [ ! -f build/build.ninja ]; then -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DCMAKE_CXX_COMPILER=clazy \ -DCMAKE_INSTALL_PREFIX=$CAELESTIA_PREFIX \ + -DINSTALL_LIBDIR=lib/caelestia \ + -DINSTALL_QMLDIR=lib/qt6/qml \ + -DINSTALL_QSCONFDIR=etc/xdg/quickshell/caelestia \ -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON fi diff --git a/install-system.sh b/install-system.sh index 66937e776..eef991db3 100644 --- a/install-system.sh +++ b/install-system.sh @@ -8,6 +8,9 @@ cmake -S . -B build \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DCMAKE_CXX_COMPILER=clazy \ -DCMAKE_INSTALL_PREFIX=/usr \ + -DINSTALL_LIBDIR=lib/caelestia \ + -DINSTALL_QMLDIR=lib/qt6/qml \ + -DINSTALL_QSCONFDIR=etc/xdg/quickshell/caelestia \ -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON diff --git a/modules/utilities/cards/MediaList.qml b/modules/utilities/cards/MediaList.qml index 94f18758e..b1c1b1f65 100644 --- a/modules/utilities/cards/MediaList.qml +++ b/modules/utilities/cards/MediaList.qml @@ -13,6 +13,18 @@ import Quickshell.Widgets import QtQuick import QtQuick.Layouts +// ============================ +// OVERFLOW-PROOF EXPANDABLE LIST +// Key ideas: +// 1) Only the VIEWPORT animates height (Layout.preferredHeight) and owns clipping. +// 2) Placeholder is drawn in a dedicated CLIPPED wrapper with height bound to the +// viewport's CURRENT animatedHeight (not target), so it can never paint past the bottom. +// 3) The placeholder slides from TOP -> CENTER immediately, but its y is CLAMPED each frame +// against (viewport.animatedHeight - effectiveHeight) so scale/slide can never exceed bounds. +// 4) We account for SCALE when clamping by using transformOrigin: Item.Top and multiplying height * scale. +// 5) We avoid anchor churn; positions are computed numerically (x/y), per Qt docs. +// ============================ + ColumnLayout { id: root @@ -28,9 +40,9 @@ ColumnLayout { spacing: 0 + // ---------- HEADER ---------- WrapperMouseArea { Layout.fillWidth: true - cursorShape: Qt.PointingHandCursor onClicked: root.props[root.expandedProp] = !root.props[root.expandedProp] @@ -59,185 +71,208 @@ ColumnLayout { } } - StyledListView { - id: list - - model: FileSystemModel { - path: root.path - nameFilters: root.nameFilters - sortReverse: true - } - + // ---------- VIEWPORT (the only resizable, clipping parent) ---------- + Item { + id: viewport Layout.fillWidth: true Layout.rightMargin: -Appearance.spacing.small - implicitHeight: (Appearance.font.size.larger + Appearance.padding.small) * (root.props[root.expandedProp] ? 10 : 3) clip: true + // Enforce a separate layer to guarantee clipping across z-stacking + layer.enabled: true + layer.smooth: true - StyledScrollBar.vertical: StyledScrollBar { - flickable: list - } + readonly property real rowHeight: Appearance.font.size.larger + Appearance.padding.small + readonly property int collapsedRows: 3 + readonly property int expandedRows: 10 + readonly property real targetHeight: rowHeight * (root.props[root.expandedProp] ? expandedRows : collapsedRows) + property real animatedHeight: targetHeight - delegate: RowLayout { - id: item + // Live target toggle (drives immediate slide/scale) + property bool expandedTarget: root.props[root.expandedProp] - required property FileSystemEntry modelData - property string baseName + Layout.preferredHeight: animatedHeight + height: animatedHeight - anchors.left: list.contentItem.left - anchors.right: list.contentItem.right - anchors.rightMargin: Appearance.spacing.small - spacing: Appearance.spacing.small / 2 + Behavior on animatedHeight { + Anim { + id: heightAnim + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } - Component.onCompleted: baseName = modelData.baseName + // ---------- LIST ---------- + StyledListView { + id: list + anchors.fill: parent + clip: true - StyledText { - Layout.fillWidth: true - Layout.rightMargin: Appearance.spacing.small / 2 - text: { - const time = item.baseName; - const matches = time.match(new RegExp(`^${root.textPrefix}_(\\d{4})(\\d{2})(\\d{2})_(\\d{2})-(\\d{2})-(\\d{2})`)); - if (!matches) - return time; - const date = new Date(...matches.slice(1)); - return qsTr(`${root.textPrefix} at %1`).arg(Qt.formatDateTime(date, Qt.locale())); - } - color: Colours.palette.m3onSurfaceVariant - elide: Text.ElideRight + model: FileSystemModel { + path: root.path + nameFilters: root.nameFilters + sortReverse: true } - IconButton { - icon: root.firstIcon - type: IconButton.Text - onClicked: { - root.visibilities.utilities = false; - root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...root.firstApp, item.modelData.path]); + StyledScrollBar.vertical: StyledScrollBar { flickable: list } + + delegate: RowLayout { + id: item + required property FileSystemEntry modelData + property string baseName + + anchors.left: list.contentItem.left + anchors.right: list.contentItem.right + anchors.rightMargin: Appearance.spacing.small + spacing: Appearance.spacing.small / 2 + + Component.onCompleted: baseName = modelData.baseName + + StyledText { + Layout.fillWidth: true + Layout.rightMargin: Appearance.spacing.small / 2 + text: { + const time = item.baseName; + const rx = new RegExp(`^${root.textPrefix}_(\\d{4})(\\d{2})(\\d{2})_(\\d{2})-(\\d{2})-(\\d{2})`); + const m = time.match(rx); + if (!m) return time; + const y = +m[1], mo = (+m[2]) - 1, d = +m[3], hh = +m[4], mm = +m[5], ss = +m[6]; + const date = new Date(y, mo, d, hh, mm, ss); + return qsTr(`${root.textPrefix} at %1`).arg(Qt.formatDateTime(date, Qt.locale())); + } + color: Colours.palette.m3onSurfaceVariant + elide: Text.ElideRight } - } - IconButton { - icon: "folder" - type: IconButton.Text - onClicked: { - root.visibilities.utilities = false; - root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, item.modelData.path]); + IconButton { + icon: root.firstIcon + type: IconButton.Text + onClicked: { + root.visibilities.utilities = false; + root.visibilities.sidebar = false; + Quickshell.execDetached(["app2unit", "--", ...root.firstApp, item.modelData.path]); + } } - } - IconButton { - icon: "delete_forever" - type: IconButton.Text - label.color: Colours.palette.m3error - stateLayer.color: Colours.palette.m3error - enabled: true - onClicked: root.props[root.textPrefix.toLowerCase() + "ConfirmDelete"] = item.modelData.path - } - } + IconButton { + icon: "folder" + type: IconButton.Text + onClicked: { + root.visibilities.utilities = false; + root.visibilities.sidebar = false; + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, item.modelData.path]); + } + } - add: Transition { - Anim { - property: "opacity" - from: 0 - to: 1 - } - Anim { - property: "scale" - from: 0.5 - to: 1 + IconButton { + icon: "delete_forever" + type: IconButton.Text + label.color: Colours.palette.m3error + stateLayer.color: Colours.palette.m3error + enabled: true + onClicked: root.props[root.textPrefix.toLowerCase() + "ConfirmDelete"] = item.modelData.path + } } - } - remove: Transition { - Anim { - property: "opacity" - to: 0 - } - Anim { - property: "scale" - to: 0.5 - } + add: Transition { Anim { property: "opacity"; from: 0; to: 1 } Anim { property: "scale"; from: 0.5; to: 1 } } + remove: Transition { Anim { property: "opacity"; to: 0 } Anim { property: "scale"; to: 0.5 } } + displaced: Transition { Anim { properties: "opacity,scale"; to: 1 } Anim { property: "y" } } } - displaced: Transition { - Anim { - properties: "opacity,scale" - to: 1 - } - Anim { - property: "y" - } + // ---------- PSEUDO CONTAINER (TARGET CENTER REFERENCE) ---------- + Item { + id: pseudoContainer + width: parent.width + height: viewport.targetHeight // center reference independent of current height } - Loader { - anchors.centerIn: parent - - opacity: list.count === 0 ? 1 : 0 - active: opacity > 0 - asynchronous: true - - sourceComponent: list.implicitHeight > 150 ? expandedPlaceholder : collapsedPlaceholder - - Component { - id: expandedPlaceholder + // ---------- PLACEHOLDER (NO DATA) ---------- + // Fully isolated wrapper clipped to current viewport height. + Item { + id: phWrap + width: parent.width + height: viewport.animatedHeight // HARD bound to current animated height + clip: true + anchors.top: parent.top + z: 10 + visible: list.count === 0 + // separate layer to ensure clipping is preserved with z ordering + layer.enabled: true + layer.smooth: true + + // Which visual style to show + readonly property bool showBig: viewport.expandedTarget + readonly property bool showSmall: !viewport.expandedTarget + + // Utility to clamp Y using *effective* height (height * scale) + function clampedYFor(node, desired) { + const effH = node.height * node.scale; + const maxY = Math.max(0, phWrap.height - effH); + return Math.min(Math.max(0, desired), maxY); + } - ColumnLayout { + // ---- BIG variant ---- + Item { + id: big + visible: phWrap.showBig + opacity: visible ? 1 : 0 + // To make scaling grow downward (so bottom clamp is reliable), scale around TOP + transformOrigin: Item.Top + scale: visible ? 1 : 0.95 + width: bigCol.implicitWidth + height: bigCol.implicitHeight + x: Math.max(0, (phWrap.width - width) / 2) + // Desired center based on TARGET height; then clamped by CURRENT viewport height + property real desiredCenter: Math.max(0, (pseudoContainer.height - height) / 2) + y: phWrap.clampedYFor(big, viewport.expandedTarget ? big.desiredCenter : 0) + + Behavior on opacity { Anim { duration: heightAnim.duration } } + Behavior on scale { Anim { duration: heightAnim.duration } } + Behavior on y { Anim { duration: heightAnim.duration; easing: heightAnim.easing } } + + Column { + id: bigCol spacing: Appearance.spacing.small + anchors.horizontalCenter: parent.horizontalCenter MaterialIcon { - Layout.alignment: Qt.AlignHCenter + anchors.horizontalCenter: parent.horizontalCenter text: root.textPrefix === "Screenshot" ? "image" : "videocam" - font.pointSize: Appearance.font.size.larger * 2 - color: Colours.palette.m3onSurfaceVariant + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.extraLarge } - StyledText { - Layout.alignment: Qt.AlignHCenter + anchors.horizontalCenter: parent.horizontalCenter text: qsTr("No %1 found").arg(root.title.toLowerCase()) - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.normal - } - - StyledText { - Layout.alignment: Qt.AlignHCenter - text: qsTr("Take a %1 to see it here").arg(root.title.toLowerCase().slice(0, -1)) - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small + color: Colours.palette.m3outline } } } - Component { - id: collapsedPlaceholder - - RowLayout { + // ---- SMALL variant ---- + Item { + id: small + visible: phWrap.showSmall + opacity: visible ? 1 : 0 + transformOrigin: Item.Top + scale: visible ? 1 : 0.95 + width: smallRow.implicitWidth + height: smallRow.implicitHeight + x: Math.max(0, (phWrap.width - width) / 2) + property real desiredCenter: Math.max(0, (pseudoContainer.height - height) / 2) + y: phWrap.clampedYFor(small, viewport.expandedTarget ? 0 : small.desiredCenter) + + Behavior on opacity { Anim { duration: heightAnim.duration } } + Behavior on scale { Anim { duration: heightAnim.duration } } + Behavior on y { Anim { duration: heightAnim.duration; easing: heightAnim.easing } } + + Row { + id: smallRow spacing: Appearance.spacing.smaller - - MaterialIcon { - text: root.textPrefix === "Screenshot" ? "image" : "videocam" - font.pointSize: Appearance.font.size.normal - color: Colours.palette.m3onSurfaceVariant - } - - StyledText { - text: qsTr("No %1").arg(root.title.toLowerCase()) - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small - elide: Text.ElideRight - } + anchors.horizontalCenter: parent.horizontalCenter + MaterialIcon { text: root.textPrefix === "Screenshot" ? "image" : "videocam"; color: Colours.palette.m3outline } + StyledText { text: qsTr("No %1").arg(root.title.toLowerCase()); color: Colours.palette.m3outline } } } - - Behavior on opacity { - Anim {} - } - } - - Behavior on implicitHeight { - Anim { - duration: 80 - easing.bezierCurve: [0, 0, 1, 1] // Linear easing for speed - } } } -} \ No newline at end of file +} diff --git a/run.sh b/run.sh index 928e24a2e..b777f6504 100755 --- a/run.sh +++ b/run.sh @@ -2,7 +2,21 @@ set -e # Make sure .local exists -mkdir -p "$PWD/.local/usr" +mkdir -p "$PWD/.local" + +# Configure build if not already done +if [ ! -d build ]; then + cmake -S . -B build \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_CXX_COMPILER=clazy \ + -DCMAKE_INSTALL_PREFIX=$PWD/.local \ + -DINSTALL_LIBDIR=lib/caelestia \ + -DINSTALL_QMLDIR=lib/qt6/qml \ + -DINSTALL_QSCONFDIR=etc/xdg/quickshell/caelestia \ + -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +fi # Full build + install once before entering the loop cmake --build build @@ -17,8 +31,9 @@ run_quickshell() { pkill -x quickshell || true # Relaunch + export QML2_IMPORT_PATH="$PWD/.local/lib/qt6/qml:$QML2_IMPORT_PATH" QS_CONFIG_NAME=caelestia \ - XDG_CONFIG_DIRS="$PWD/.local/usr/etc/xdg" \ + XDG_CONFIG_DIRS="$PWD/.local/etc/xdg" \ quickshell & } @@ -26,7 +41,7 @@ export -f run_quickshell # Find all QML, CPP, and header files, then watch them find \ - "$PWD/caelestia" \ - "$PWD/plugin" \ + "$PWD" \ -name '*.qml' -o -name '*.cpp' -o -name '*.hpp' \ +| grep -v '^./build' \ | entr -r bash -c run_quickshell From 3f5d8ec4e5086ad4008f50a0c9d170079df6c8f4 Mon Sep 17 00:00:00 2001 From: Robin Seger Date: Sun, 28 Sep 2025 15:38:02 +0200 Subject: [PATCH 07/14] fix: placeholder overflow and animation stagger in MediaList - Fix placeholder overflow by using viewport.animatedHeight instead of pseudoContainer.height - Reduce Y animation duration for big variant to 60% for smoother tracking - Remove Y animation for small variant to eliminate stagger during collapse --- modules/utilities/cards/MediaList.qml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/modules/utilities/cards/MediaList.qml b/modules/utilities/cards/MediaList.qml index b1c1b1f65..d5aa0a63a 100644 --- a/modules/utilities/cards/MediaList.qml +++ b/modules/utilities/cards/MediaList.qml @@ -221,13 +221,13 @@ ColumnLayout { width: bigCol.implicitWidth height: bigCol.implicitHeight x: Math.max(0, (phWrap.width - width) / 2) - // Desired center based on TARGET height; then clamped by CURRENT viewport height - property real desiredCenter: Math.max(0, (pseudoContainer.height - height) / 2) + // Desired center based on CURRENT animated height to prevent overflow during animation + property real desiredCenter: Math.max(0, (viewport.animatedHeight - height) / 2) y: phWrap.clampedYFor(big, viewport.expandedTarget ? big.desiredCenter : 0) Behavior on opacity { Anim { duration: heightAnim.duration } } Behavior on scale { Anim { duration: heightAnim.duration } } - Behavior on y { Anim { duration: heightAnim.duration; easing: heightAnim.easing } } + Behavior on y { Anim { duration: heightAnim.duration * 0.6; easing: heightAnim.easing } } Column { id: bigCol @@ -258,12 +258,13 @@ ColumnLayout { width: smallRow.implicitWidth height: smallRow.implicitHeight x: Math.max(0, (phWrap.width - width) / 2) - property real desiredCenter: Math.max(0, (pseudoContainer.height - height) / 2) - y: phWrap.clampedYFor(small, viewport.expandedTarget ? 0 : small.desiredCenter) + property real desiredCenter: Math.max(0, (viewport.animatedHeight - height) / 2) + y: phWrap.clampedYFor(small, viewport.expandedTarget ? small.desiredCenter : small.desiredCenter) Behavior on opacity { Anim { duration: heightAnim.duration } } Behavior on scale { Anim { duration: heightAnim.duration } } - Behavior on y { Anim { duration: heightAnim.duration; easing: heightAnim.easing } } + // Remove Y animation for small variant to eliminate stagger during collapse + // Behavior on y { Anim { duration: heightAnim.duration * 0.6; easing: heightAnim.easing } } Row { id: smallRow From d54d43c3db83860fa866a6eebed764acebe6e35d Mon Sep 17 00:00:00 2001 From: Nikita Friesen Date: Mon, 29 Sep 2025 13:02:02 +0200 Subject: [PATCH 08/14] fix: improve height animations and overshoot handling in MediaList and Media components --- modules/utilities/cards/Media.qml | 7 +++-- modules/utilities/cards/MediaList.qml | 39 +++++++++++++++++++-------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/modules/utilities/cards/Media.qml b/modules/utilities/cards/Media.qml index f53acdf22..ab990bc37 100644 --- a/modules/utilities/cards/Media.qml +++ b/modules/utilities/cards/Media.qml @@ -204,7 +204,10 @@ StyledRect { Behavior on opacity { Anim { duration: 250; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } Behavior on scale { Anim { duration: 250; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } } - Behavior on Layout.preferredHeight { Anim {} } + Behavior on Layout.preferredHeight { + enabled: false // let inner list drive height; avoid damping overshoot + Anim {} + } } } @@ -350,7 +353,7 @@ StyledRect { running: false alwaysRunToEnd: true - // Animate container height to the active body's height + // Simple height animation for tab switch (no overshoot, let inner list handle its own bounce) Anim { target: body; property: "Layout.preferredHeight"; to: root.tabIndex === 0 ? shotsBody.implicitHeight : recsBody.implicitHeight; duration: 200; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } onStopped: { diff --git a/modules/utilities/cards/MediaList.qml b/modules/utilities/cards/MediaList.qml index b536addc4..425c200f6 100644 --- a/modules/utilities/cards/MediaList.qml +++ b/modules/utilities/cards/MediaList.qml @@ -39,9 +39,12 @@ ColumnLayout { required property string expandedProp spacing: 0 + // Make parent containers (e.g., Loader) see our live height so overshoot is visible + implicitHeight: header.implicitHeight + viewport.animatedHeight // ---------- HEADER ---------- WrapperMouseArea { + id: header Layout.fillWidth: true cursorShape: Qt.PointingHandCursor onClicked: root.props[root.expandedProp] = !root.props[root.expandedProp] @@ -85,7 +88,17 @@ ColumnLayout { readonly property int collapsedRows: 3 readonly property int expandedRows: 10 readonly property real targetHeight: rowHeight * (root.props[root.expandedProp] ? expandedRows : collapsedRows) + // Keep a separate, animatable height; initialize to target and break binding at startup property real animatedHeight: targetHeight + Component.onCompleted: animatedHeight = targetHeight + + onTargetHeightChanged: { + // Explicit two-step bounce: hop past the target, then settle back + const expanding = targetHeight > animatedHeight; + bounceAnim.stop(); + bounceAnim.overshoot = expanding ? targetHeight * 1.10 : targetHeight * 0.90; + bounceAnim.start(); + } // Live target toggle (drives immediate slide/scale) property bool expandedTarget: root.props[root.expandedProp] @@ -93,12 +106,14 @@ ColumnLayout { Layout.preferredHeight: animatedHeight height: animatedHeight - Behavior on animatedHeight { - Anim { - id: heightAnim - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } + // Explicit overshoot sequence (works for both expand and collapse) + SequentialAnimation { + id: bounceAnim + property real overshoot: 0 + running: false + alwaysRunToEnd: true + NumberAnimation { target: viewport; property: "animatedHeight"; to: bounceAnim.overshoot; duration: 110; easing.type: Easing.OutCubic } + NumberAnimation { target: viewport; property: "animatedHeight"; to: viewport.targetHeight; duration: 130; easing.type: Easing.InCubic } } // ---------- LIST ---------- @@ -227,9 +242,10 @@ ColumnLayout { y: phWrap.clampedYFor(big, viewport.expandedTarget ? big.desiredCenter : 0) - Behavior on opacity { Anim { duration: heightAnim.duration } } - Behavior on scale { Anim { duration: heightAnim.duration } } - Behavior on y { Anim { duration: heightAnim.duration * 0.6; easing: heightAnim.easing } } + // Keep placeholder animations unchanged (decoupled from heightAnim) + Behavior on opacity { Anim { duration: Appearance.anim.durations.expressiveDefaultSpatial; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } + Behavior on scale { Anim { duration: Appearance.anim.durations.expressiveDefaultSpatial; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } + // Behavior on y { Anim { duration: Appearance.anim.durations.expressiveDefaultSpatial * 0.2; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } Column { id: bigCol @@ -264,8 +280,9 @@ ColumnLayout { property real desiredCenter: Math.max(0, (viewport.animatedHeight - height) / 2) y: phWrap.clampedYFor(small, viewport.expandedTarget ? small.desiredCenter : small.desiredCenter) - Behavior on opacity { Anim { duration: heightAnim.duration } } - Behavior on scale { Anim { duration: heightAnim.duration } } + // Keep placeholder animations unchanged (decoupled from heightAnim) + Behavior on opacity { Anim { duration: Appearance.anim.durations.expressiveDefaultSpatial; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } + Behavior on scale { Anim { duration: Appearance.anim.durations.expressiveDefaultSpatial; easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } // Remove Y animation for small variant to eliminate stagger during collapse // Behavior on y { Anim { duration: heightAnim.duration * 0.6; easing: heightAnim.easing } } From f9631126ac66bcc7430b35ac91e140c10504aaef Mon Sep 17 00:00:00 2001 From: Nikita Friesen Date: Mon, 29 Sep 2025 14:41:42 +0200 Subject: [PATCH 09/14] feat: add recording functionality with region selection and sound options in AreaPicker and Picker components fix: update saveItem function to handle scaling correctly in CUtils refactor: adjust Recorder to support external start/stop detection and maintain state chore: change default image viewer in GeneralConfig --- config/GeneralConfig.qml | 2 +- install-system.sh | 0 modules/areapicker/AreaPicker.qml | 25 ++++++++++ modules/areapicker/Picker.qml | 76 +++++++++++++++++++++++++++++-- modules/utilities/cards/Media.qml | 15 ++++-- plugin/src/Caelestia/cutils.cpp | 31 +++++++++---- services/Recorder.qml | 30 ++++++++++-- 7 files changed, 157 insertions(+), 22 deletions(-) mode change 100644 => 100755 install-system.sh diff --git a/config/GeneralConfig.qml b/config/GeneralConfig.qml index a9f152d77..c2de47e7a 100644 --- a/config/GeneralConfig.qml +++ b/config/GeneralConfig.qml @@ -10,7 +10,7 @@ JsonObject { property list audio: ["pavucontrol"] property list playback: ["mpv"] property list explorer: ["thunar"] - property list image: ["imv"] + property list image: ["swappy", "-f"] } component Idle: JsonObject { diff --git a/install-system.sh b/install-system.sh old mode 100644 new mode 100755 diff --git a/modules/areapicker/AreaPicker.qml b/modules/areapicker/AreaPicker.qml index 814549d6a..d6e136c12 100644 --- a/modules/areapicker/AreaPicker.qml +++ b/modules/areapicker/AreaPicker.qml @@ -13,6 +13,8 @@ Scope { property bool freeze property bool closing + property bool recording: false + property bool recordWithSound: false // no-op @@ -43,6 +45,8 @@ Scope { Picker { loader: root screen: win.modelData + recording: root.recording + recordWithSound: root.recordWithSound } } } @@ -54,12 +58,33 @@ Scope { function open(): void { root.freeze = false; root.closing = false; + root.recording = false; + root.recordWithSound = false; root.activeAsync = true; } function openFreeze(): void { root.freeze = true; root.closing = false; + root.recording = false; + root.recordWithSound = false; + root.activeAsync = true; + } + + // Recording variants reuse the same area picker to select geometry, then start Recorder + function openRecord(): void { + root.freeze = false; + root.closing = false; + root.recording = true; + root.recordWithSound = false; + root.activeAsync = true; + } + + function openRecordSound(): void { + root.freeze = false; + root.closing = false; + root.recording = true; + root.recordWithSound = true; root.activeAsync = true; } } diff --git a/modules/areapicker/Picker.qml b/modules/areapicker/Picker.qml index 41dd81aae..1fb591a91 100644 --- a/modules/areapicker/Picker.qml +++ b/modules/areapicker/Picker.qml @@ -15,6 +15,10 @@ MouseArea { required property LazyLoader loader required property ShellScreen screen + // When true, we will start a recording of the selected region instead of taking a screenshot + property bool recording: false + // Only used when recording=true. If true, include default output+input audio. + property bool recordWithSound: false property bool onClient @@ -73,17 +77,79 @@ MouseArea { } function save(): void { - // Ensure screenshots directory exists - Quickshell.execDetached(["mkdir", "-p", Paths.shotsdir]); + // If we're in recording mode, start gpu-screen-recorder with region geometry + if (root.loader.recording) { + // Compute geometry in PHYSICAL pixels, normalized to the global physical origin (top-left across all monitors). + // This handles fractional scales correctly. + let minPhysX = 0; + let minPhysY = 0; + let haveAny = false; + try { + for (const m of Hypr.monitors) { + const mi = m.lastIpcObject; + if (!mi) + continue; + const mx = mi.x * (mi.scale || 1); + const my = mi.y * (mi.scale || 1); + if (!haveAny) { + minPhysX = mx; + minPhysY = my; + haveAny = true; + } else { + if (mx < minPhysX) + minPhysX = mx; + if (my < minPhysY) + minPhysY = my; + } + } + } catch (e) { + // Fallback to zeros if Hypr monitor list is unavailable + minPhysX = 0; + minPhysY = 0; + } + + const mon = Hypr.monitorFor(screen); + const mi = mon?.lastIpcObject; + const s = mi?.scale || 1; + + const gx = Math.max(0, Math.round((rsx + screen.x) * s - minPhysX)); + const gy = Math.max(0, Math.round((rsy + screen.y) * s - minPhysY)); + const gw = Math.max(1, Math.round(sw * s)); + const gh = Math.max(1, Math.round(sh * s)); + + // Ensure recordings directory exists + Quickshell.execDetached(["mkdir", "-p", Paths.recsdir]); + + // Output file + const now = new Date(); + const pad = n => n.toString().padStart(2, "0"); + const ofile = `${Paths.recsdir}/Recording_${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}.mp4`; + + const region = `${gw}x${gh}+${gx}+${gy}`; + const cmd = [ + "gpu-screen-recorder", + "-w", "region", + "-region", region, + "-o", ofile + ]; + if (root.loader.recordWithSound) { + // Merge default output and input into one track; users can tweak later via settings if needed + cmd.push("-a", "default_output|default_input"); + } + Quickshell.execDetached(cmd); + // Update UI state to reflect that a recording likely started + Recorder.refresh(); + closeAnim.start(); + return; + } - // Build timestamped filename in the screenshots directory + // Screenshot flow (unchanged) + Quickshell.execDetached(["mkdir", "-p", Paths.shotsdir]); const now = new Date(); const pad = n => n.toString().padStart(2, "0"); const fname = `Screenshot_${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}.png`; const destUrl = Qt.resolvedUrl(`${Paths.shotsdir}/${fname}`); - // Save the selected region to the screenshots folder - CUtils.saveItem( screencopy, destUrl, diff --git a/modules/utilities/cards/Media.qml b/modules/utilities/cards/Media.qml index ab990bc37..c2d5259b5 100644 --- a/modules/utilities/cards/Media.qml +++ b/modules/utilities/cards/Media.qml @@ -41,17 +41,26 @@ StyledRect { mkShot("web_asset", "Active window (edit)", "Window", ["caelestia", "shell", "picker", "open"]) ] - // Helper to create recording items that call Recorder directly + // Helper to create recording items that call Recorder directly (fullscreen variants) const mkRec = (icon, text, activeText, args) => { const call = args ? 'Recorder.start(' + JSON.stringify(args) + ')' : 'Recorder.start()'; const qml = 'import qs.components.controls; import qs.services; MenuItem { icon: "' + icon + '"; text: qsTr("' + text + '"); activeText: qsTr("' + activeText + '"); onClicked: { ' + call + ' } }'; return Qt.createQmlObject(qml, root); } + // Helper to create region recording items that go through AreaPicker (scale-correct geometry) + const mkRecRegion = (icon, text, activeText, withSound) => { + const cmd = withSound ? ['caelestia', 'shell', 'picker', 'openRecordSound'] + : ['caelestia', 'shell', 'picker', 'openRecord']; + const qml = 'import qs.components.controls; MenuItem { property var mediaRoot; icon: "' + icon + '"; text: qsTr("' + text + '"); activeText: qsTr("' + activeText + '"); onClicked: { if (mediaRoot && mediaRoot.triggerShot) mediaRoot.triggerShot(' + JSON.stringify(cmd) + ') } }'; + const item = Qt.createQmlObject(qml, root); + item.mediaRoot = root; + return item; + } recordingMenuItems = [ mkRec("fullscreen", "Record fullscreen", "Fullscreen", null), - mkRec("screenshot_region", "Record region", "Region", ["-r"]), + mkRecRegion("screenshot_region", "Record region", "Region", false), mkRec("select_to_speak", "Record fullscreen with sound", "Fullscreen", ["-s"]), - mkRec("volume_up", "Record region with sound", "Region", ["-sr"]) + mkRecRegion("volume_up", "Record region with sound", "Region", true) ] } diff --git a/plugin/src/Caelestia/cutils.cpp b/plugin/src/Caelestia/cutils.cpp index 27074ee88..0171bd05f 100644 --- a/plugin/src/Caelestia/cutils.cpp +++ b/plugin/src/Caelestia/cutils.cpp @@ -46,22 +46,33 @@ void CUtils::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, Q return; } - auto scaledRect = rect; - const qreal scale = target->window()->devicePixelRatio(); - if (rect.isValid() && !qFuzzyCompare(scale + 1.0, 2.0)) { - scaledRect = - QRectF(rect.left() * scale, rect.top() * scale, rect.width() * scale, rect.height() * scale).toRect(); - } - const QSharedPointer grabResult = target->grabToImage(); QObject::connect(grabResult.data(), &QQuickItemGrabResult::ready, this, - [grabResult, scaledRect, path, onSaved, onFailed, this]() { + [grabResult, rect, path, onSaved, onFailed, target, this]() { const auto future = QtConcurrent::run([=]() { QImage image = grabResult->image(); - if (scaledRect.isValid()) { - image = image.copy(scaledRect); + if (rect.isValid()) { + // Compute actual pixel scaling based on grabbed image vs item size. + // This is robust across fractional monitor scales and Wayland backends. + const qreal itemW = qMax(1.0, target->width()); + const qreal itemH = qMax(1.0, target->height()); + const qreal scaleX = static_cast(image.width()) / itemW; + const qreal scaleY = static_cast(image.height()) / itemH; + + QRectF rf(rect.left() * scaleX, + rect.top() * scaleY, + rect.width() * scaleX, + rect.height() * scaleY); + + // Convert to an aligned integer rect and clamp to image bounds + QRect crop = rf.toAlignedRect().intersected(image.rect()); + if (!crop.isEmpty()) { + image = image.copy(crop); + } else { + qWarning() << "CUtils::saveItem: computed crop rect is empty after scaling"; + } } const QString file = path.toLocalFile(); diff --git a/services/Recorder.qml b/services/Recorder.qml index e4ce6a8bd..d6ef2fbe0 100644 --- a/services/Recorder.qml +++ b/services/Recorder.qml @@ -11,13 +11,13 @@ Singleton { readonly property alias paused: props.paused readonly property alias elapsed: props.elapsed property bool needsStart - property list startArgs + property list startArgs: [] property bool needsStop property bool needsPause function start(extraArgs: list): void { needsStart = true; - startArgs = extraArgs; + startArgs = extraArgs || []; checkProc.running = true; } @@ -31,6 +31,12 @@ Singleton { checkProc.running = true; } + // Re-run the pid check to update running/paused state when the recorder + // was started or stopped outside of Recorder.start/stop. + function refresh(): void { + checkProc.running = true; + } + PersistentProperties { id: props @@ -47,6 +53,7 @@ Singleton { running: true command: ["pidof", "gpu-screen-recorder"] onExited: code => { + const wasRunning = props.running; props.running = code === 0; if (code === 0) { @@ -54,15 +61,24 @@ Singleton { Quickshell.execDetached(["caelestia", "record"]); props.running = false; props.paused = false; + props.elapsed = 0; } else if (root.needsPause) { Quickshell.execDetached(["caelestia", "record", "-p"]); props.paused = !props.paused; + } else if (!wasRunning && props.running) { + // External start detected (e.g., via AreaPicker path) + props.paused = false; + props.elapsed = 0; } } else if (root.needsStart) { Quickshell.execDetached(["caelestia", "record", ...root.startArgs]); props.running = true; props.paused = false; props.elapsed = 0; + } else if (wasRunning && !props.running) { + // External stop detected + props.paused = false; + props.elapsed = 0; } root.needsStart = false; @@ -71,9 +87,17 @@ Singleton { } } + // Poll for recorder state while running or when actions are pending + Timer { + interval: 1000 + repeat: true + running: props.running || root.needsStart || root.needsStop || root.needsPause + onTriggered: checkProc.running = true + } + Connections { target: Time - // enabled: props.running && !props.paused + enabled: props.running && !props.paused function onSecondsChanged(): void { props.elapsed++; From 2815a453556b842e23131af5f90373d5fde60a51 Mon Sep 17 00:00:00 2001 From: Nikita Friesen Date: Tue, 30 Sep 2025 12:54:41 +0200 Subject: [PATCH 10/14] feat: update MediaList to use SortFilterProxyModel for improved sorting and add timestamp pattern matching --- .envrc | 32 ++++------------------ modules/utilities/cards/MediaList.qml | 38 ++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 31 deletions(-) diff --git a/.envrc b/.envrc index c8b550bac..84ba4f7eb 100644 --- a/.envrc +++ b/.envrc @@ -1,37 +1,15 @@ -# If nix is available, enable flakes if has nix; then use flake fi -# Where we "install" locally -export CAELESTIA_PREFIX="$PWD/.local" -export CAELESTIA_LIB_DIR="$CAELESTIA_PREFIX/lib" -export QML2_IMPORT_PATH="$CAELESTIA_PREFIX/lib/qt6/qml:${QML2_IMPORT_PATH:-}" -export PATH="$CAELESTIA_PREFIX/bin:$PATH" - -# Configure build directory once -if [ ! -f build/Makefile ] && [ ! -f build/build.ninja ]; then - cmake -S . -B build \ - -G Ninja \ - -DCMAKE_BUILD_TYPE=RelWithDebInfo \ - -DCMAKE_CXX_COMPILER=clazy \ - -DCMAKE_INSTALL_PREFIX=$CAELESTIA_PREFIX \ - -DINSTALL_LIBDIR=lib/caelestia \ - -DINSTALL_QMLDIR=lib/qt6/qml \ - -DINSTALL_QSCONFDIR=etc/xdg/quickshell/caelestia \ - -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \ - -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -fi - -# Rebuild automatically when C++ sources or CMake files change shopt -s globstar +watch_file assets/cpp/**/*.cpp +watch_file assets/cpp/**/*.hpp watch_file plugin/**/*.cpp watch_file plugin/**/*.hpp watch_file **/CMakeLists.txt +cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_CXX_COMPILER=clazy -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DDISTRIBUTOR=direnv cmake --build build -cmake --install build - -# Symlink config dir so QuickShell loads Caelestia from source -mkdir -p ~/.config/quickshell -ln -sf "$PWD" ~/.config/quickshell/caelestia +export CAELESTIA_LIB_DIR="$PWD/build/lib" +export QML2_IMPORT_PATH="$PWD/build/qml:${QML2_IMPORT_PATH:-}" \ No newline at end of file diff --git a/modules/utilities/cards/MediaList.qml b/modules/utilities/cards/MediaList.qml index 425c200f6..f58f99d1d 100644 --- a/modules/utilities/cards/MediaList.qml +++ b/modules/utilities/cards/MediaList.qml @@ -12,6 +12,7 @@ import Quickshell import Quickshell.Widgets import QtQuick import QtQuick.Layouts +import QtQml.Models // ============================ // OVERFLOW-PROOF EXPANDABLE LIST @@ -122,10 +123,39 @@ ColumnLayout { anchors.fill: parent clip: true - model: FileSystemModel { - path: root.path - nameFilters: root.nameFilters - sortReverse: true + model: SortFilterProxyModel { + id: sortedMediaModel + + sourceModel: FileSystemModel { + path: root.path + nameFilters: root.nameFilters + } + + property var timestampPattern: new RegExp(`^${root.textPrefix}_(\\d{4})(\\d{2})(\\d{2})_(\\d{2})-(\\d{2})-(\\d{2})`, "i") + + sorters: [ + ExpressionSorter { + expression: { + const entry = model.modelData; + if (!entry) + return 0; + + const match = entry.baseName.match(sortedMediaModel.timestampPattern); + if (!match) + return 0; + + return Number(`${match[1]}${match[2]}${match[3]}${match[4]}${match[5]}${match[6]}`); + } + ascendingOrder: false + }, + ExpressionSorter { + expression: { + const entry = model.modelData; + return entry ? entry.baseName : ""; + } + ascendingOrder: false + } + ] } StyledScrollBar.vertical: StyledScrollBar { flickable: list } From f12fdf7f6f9b9bbf52c42b4b95886a97cd65c214 Mon Sep 17 00:00:00 2001 From: Nikita Friesen Date: Tue, 30 Sep 2025 13:14:50 +0200 Subject: [PATCH 11/14] feat: enhance recording functionality with external start tracking and notifications --- modules/areapicker/Picker.qml | 53 +++++++++++++++++++++++------------ services/Recorder.qml | 15 ++++++++++ shell | 1 - 3 files changed, 50 insertions(+), 19 deletions(-) delete mode 120000 shell diff --git a/modules/areapicker/Picker.qml b/modules/areapicker/Picker.qml index 1fb591a91..3e5f3f7a3 100644 --- a/modules/areapicker/Picker.qml +++ b/modules/areapicker/Picker.qml @@ -77,7 +77,7 @@ MouseArea { } function save(): void { - // If we're in recording mode, start gpu-screen-recorder with region geometry + // If we're in recording mode, start gpu-screen-recorder with region geometry and show notifications if (root.loader.recording) { // Compute geometry in PHYSICAL pixels, normalized to the global physical origin (top-left across all monitors). // This handles fractional scales correctly. @@ -120,10 +120,10 @@ MouseArea { // Ensure recordings directory exists Quickshell.execDetached(["mkdir", "-p", Paths.recsdir]); - // Output file + // Output file with timestamp const now = new Date(); const pad = n => n.toString().padStart(2, "0"); - const ofile = `${Paths.recsdir}/Recording_${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}.mp4`; + const ofile = `${Paths.recsdir}/recording_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}.mp4`; const region = `${gw}x${gh}+${gx}+${gy}`; const cmd = [ @@ -133,29 +133,46 @@ MouseArea { "-o", ofile ]; if (root.loader.recordWithSound) { - // Merge default output and input into one track; users can tweak later via settings if needed - cmd.push("-a", "default_output|default_input"); + cmd.push("-a", "default_output"); } + + // Show start notification + const notifCmd = [ + "notify-send", + "-a", "caelestia-cli", + "-p", + "Recording started", + "Recording..." + ]; + Quickshell.execDetached(notifCmd); + + // Start recording Quickshell.execDetached(cmd); + // Update UI state to reflect that a recording likely started Recorder.refresh(); closeAnim.start(); return; } - // Screenshot flow (unchanged) - Quickshell.execDetached(["mkdir", "-p", Paths.shotsdir]); - const now = new Date(); - const pad = n => n.toString().padStart(2, "0"); - const fname = `Screenshot_${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}.png`; - const destUrl = Qt.resolvedUrl(`${Paths.shotsdir}/${fname}`); - - CUtils.saveItem( - screencopy, - destUrl, - Qt.rect(Math.ceil(rsx), Math.ceil(rsy), Math.floor(sw), Math.floor(sh)), - path => Quickshell.execDetached(["swappy", "-f", Paths.toLocalFile(destUrl)]) - ); + // Screenshot flow - use CLI for proper notifications and action handling + // Compute logical coordinates for grim (handles scaling internally) + const screenRelX = Math.ceil(rsx); + const screenRelY = Math.ceil(rsy); + const screenRelW = Math.floor(sw); + const screenRelH = Math.floor(sh); + + // Convert to global logical coordinates + const globalX = screenRelX + screen.x; + const globalY = screenRelY + screen.y; + const region = `${screenRelW}x${screenRelH}+${globalX}+${globalY}`; + + // Use CLI for screenshots to get proper notifications with actions + const cmd = ["caelestia", "screenshot", "-r", region]; + if (root.loader.freeze) { + cmd.push("-f"); + } + Quickshell.execDetached(cmd); closeAnim.start(); } diff --git a/services/Recorder.qml b/services/Recorder.qml index d6ef2fbe0..46ef64aaf 100644 --- a/services/Recorder.qml +++ b/services/Recorder.qml @@ -14,6 +14,7 @@ Singleton { property list startArgs: [] property bool needsStop property bool needsPause + property bool externalStart: false // Track if recording was started externally (e.g., via area picker) function start(extraArgs: list): void { needsStart = true; @@ -69,14 +70,28 @@ Singleton { // External start detected (e.g., via AreaPicker path) props.paused = false; props.elapsed = 0; + root.externalStart = true; } } else if (root.needsStart) { Quickshell.execDetached(["caelestia", "record", ...root.startArgs]); props.running = true; props.paused = false; props.elapsed = 0; + root.externalStart = false; // CLI start, not external } else if (wasRunning && !props.running) { // External stop detected + if (root.externalStart) { + // Show stop notification for external recordings + const notifCmd = [ + "notify-send", + "-a", "caelestia-cli", + "--action=open=Open folder", + "Recording stopped", + "Recording saved to recordings folder" + ]; + Quickshell.execDetached(notifCmd); + root.externalStart = false; + } props.paused = false; props.elapsed = 0; } diff --git a/shell b/shell deleted file mode 120000 index c45663c5c..000000000 --- a/shell +++ /dev/null @@ -1 +0,0 @@ -/home/nikita/Projects/Private/shell \ No newline at end of file From a66e33b9d3e4114aa4975cf90fb0edb51f274ed9 Mon Sep 17 00:00:00 2001 From: Nikita Friesen Date: Tue, 30 Sep 2025 14:43:54 +0200 Subject: [PATCH 12/14] feat: refactor recording and screenshot handling in Picker and Media components for improved clarity and functionality --- modules/areapicker/Picker.qml | 101 ++++---------------------- modules/utilities/cards/Media.qml | 1 + modules/utilities/cards/MediaList.qml | 89 ++++++++++++++--------- services/Recorder.qml | 19 +---- 4 files changed, 73 insertions(+), 137 deletions(-) diff --git a/modules/areapicker/Picker.qml b/modules/areapicker/Picker.qml index 3e5f3f7a3..b326ff0c1 100644 --- a/modules/areapicker/Picker.qml +++ b/modules/areapicker/Picker.qml @@ -77,86 +77,7 @@ MouseArea { } function save(): void { - // If we're in recording mode, start gpu-screen-recorder with region geometry and show notifications - if (root.loader.recording) { - // Compute geometry in PHYSICAL pixels, normalized to the global physical origin (top-left across all monitors). - // This handles fractional scales correctly. - let minPhysX = 0; - let minPhysY = 0; - let haveAny = false; - try { - for (const m of Hypr.monitors) { - const mi = m.lastIpcObject; - if (!mi) - continue; - const mx = mi.x * (mi.scale || 1); - const my = mi.y * (mi.scale || 1); - if (!haveAny) { - minPhysX = mx; - minPhysY = my; - haveAny = true; - } else { - if (mx < minPhysX) - minPhysX = mx; - if (my < minPhysY) - minPhysY = my; - } - } - } catch (e) { - // Fallback to zeros if Hypr monitor list is unavailable - minPhysX = 0; - minPhysY = 0; - } - - const mon = Hypr.monitorFor(screen); - const mi = mon?.lastIpcObject; - const s = mi?.scale || 1; - - const gx = Math.max(0, Math.round((rsx + screen.x) * s - minPhysX)); - const gy = Math.max(0, Math.round((rsy + screen.y) * s - minPhysY)); - const gw = Math.max(1, Math.round(sw * s)); - const gh = Math.max(1, Math.round(sh * s)); - - // Ensure recordings directory exists - Quickshell.execDetached(["mkdir", "-p", Paths.recsdir]); - - // Output file with timestamp - const now = new Date(); - const pad = n => n.toString().padStart(2, "0"); - const ofile = `${Paths.recsdir}/recording_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}.mp4`; - - const region = `${gw}x${gh}+${gx}+${gy}`; - const cmd = [ - "gpu-screen-recorder", - "-w", "region", - "-region", region, - "-o", ofile - ]; - if (root.loader.recordWithSound) { - cmd.push("-a", "default_output"); - } - - // Show start notification - const notifCmd = [ - "notify-send", - "-a", "caelestia-cli", - "-p", - "Recording started", - "Recording..." - ]; - Quickshell.execDetached(notifCmd); - - // Start recording - Quickshell.execDetached(cmd); - - // Update UI state to reflect that a recording likely started - Recorder.refresh(); - closeAnim.start(); - return; - } - - // Screenshot flow - use CLI for proper notifications and action handling - // Compute logical coordinates for grim (handles scaling internally) + // Compute logical coordinates (same for both screenshots and recordings) const screenRelX = Math.ceil(rsx); const screenRelY = Math.ceil(rsy); const screenRelW = Math.floor(sw); @@ -167,12 +88,22 @@ MouseArea { const globalY = screenRelY + screen.y; const region = `${screenRelW}x${screenRelH}+${globalX}+${globalY}`; - // Use CLI for screenshots to get proper notifications with actions - const cmd = ["caelestia", "screenshot", "-r", region]; - if (root.loader.freeze) { - cmd.push("-f"); + if (root.loader.recording) { + // Use CLI for recording - it handles fractional scaling conversion to physical pixels + const cmd = ["caelestia", "record", "-r", region]; + if (root.loader.recordWithSound) { + cmd.push("-s"); + } + Quickshell.execDetached(cmd); + } else { + // Use CLI for screenshot - it handles notifications and actions + const cmd = ["caelestia", "screenshot", "-r", region]; + if (root.loader.freeze) { + cmd.push("-f"); + } + Quickshell.execDetached(cmd); } - Quickshell.execDetached(cmd); + closeAnim.start(); } diff --git a/modules/utilities/cards/Media.qml b/modules/utilities/cards/Media.qml index c2d5259b5..3d220d592 100644 --- a/modules/utilities/cards/Media.qml +++ b/modules/utilities/cards/Media.qml @@ -37,6 +37,7 @@ StyledRect { return item; } screenshotMenuItems = [ + mkShot("fullscreen", "Fullscreen", "Fullscreen", ["caelestia", "screenshot"]), mkShot("crop_free", "Area (edit)", "Area", ["caelestia", "shell", "picker", "openFreeze"]), mkShot("web_asset", "Active window (edit)", "Window", ["caelestia", "shell", "picker", "open"]) ] diff --git a/modules/utilities/cards/MediaList.qml b/modules/utilities/cards/MediaList.qml index f58f99d1d..368279dd2 100644 --- a/modules/utilities/cards/MediaList.qml +++ b/modules/utilities/cards/MediaList.qml @@ -12,8 +12,6 @@ import Quickshell import Quickshell.Widgets import QtQuick import QtQuick.Layouts -import QtQml.Models - // ============================ // OVERFLOW-PROOF EXPANDABLE LIST // Key ideas: @@ -123,46 +121,67 @@ ColumnLayout { anchors.fill: parent clip: true - model: SortFilterProxyModel { - id: sortedMediaModel - - sourceModel: FileSystemModel { - path: root.path - nameFilters: root.nameFilters + property var sourceModel: FileSystemModel { + path: root.path + nameFilters: root.nameFilters + } + + property var sortedFiles: [] + + Component.onCompleted: updateSortedFiles() + + Connections { + target: list.sourceModel + function onModelAboutToBeReset() { list.updateSortedFiles() } + function onModelReset() { list.updateSortedFiles() } + function onRowsInserted() { list.updateSortedFiles() } + function onRowsRemoved() { list.updateSortedFiles() } + } + + function updateSortedFiles() { + if (!sourceModel) return; + + let files = []; + for (let i = 0; i < sourceModel.rowCount(); i++) { + let index = sourceModel.index(i, 0); + let entry = sourceModel.data(index, Qt.UserRole); + if (entry) { + files.push(entry); + } } - - property var timestampPattern: new RegExp(`^${root.textPrefix}_(\\d{4})(\\d{2})(\\d{2})_(\\d{2})-(\\d{2})-(\\d{2})`, "i") - - sorters: [ - ExpressionSorter { - expression: { - const entry = model.modelData; - if (!entry) - return 0; - - const match = entry.baseName.match(sortedMediaModel.timestampPattern); - if (!match) - return 0; - - return Number(`${match[1]}${match[2]}${match[3]}${match[4]}${match[5]}${match[6]}`); - } - ascendingOrder: false - }, - ExpressionSorter { - expression: { - const entry = model.modelData; - return entry ? entry.baseName : ""; - } - ascendingOrder: false + + // Sort by timestamp first, then by name + let timestampPattern = new RegExp(`^${root.textPrefix}_(\\d{4})(\\d{2})(\\d{2})_(\\d{2})-(\\d{2})-(\\d{2})`, "i"); + + files.sort(function(a, b) { + let matchA = timestampPattern.exec(a.baseName || ""); + let matchB = timestampPattern.exec(b.baseName || ""); + + if (matchA && matchB) { + let timeA = Number(`${matchA[1]}${matchA[2]}${matchA[3]}${matchA[4]}${matchA[5]}${matchA[6]}`); + let timeB = Number(`${matchB[1]}${matchB[2]}${matchB[3]}${matchB[4]}${matchB[5]}${matchB[6]}`); + return timeB - timeA; // Descending order (newest first) } - ] + + if (matchA && !matchB) return -1; + if (!matchA && matchB) return 1; + + // Fall back to name comparison + let nameA = (a.baseName || "").toLowerCase(); + let nameB = (b.baseName || "").toLowerCase(); + return nameB.localeCompare(nameA); // Descending order + }); + + sortedFiles = files; } + model: sortedFiles + StyledScrollBar.vertical: StyledScrollBar { flickable: list } delegate: RowLayout { id: item - required property FileSystemEntry modelData + required property var modelData property string baseName anchors.left: list.contentItem.left @@ -170,7 +189,7 @@ ColumnLayout { anchors.rightMargin: Appearance.spacing.small spacing: Appearance.spacing.small / 2 - Component.onCompleted: baseName = modelData.baseName + Component.onCompleted: baseName = modelData.baseName || "" StyledText { Layout.fillWidth: true diff --git a/services/Recorder.qml b/services/Recorder.qml index 46ef64aaf..cf254d4eb 100644 --- a/services/Recorder.qml +++ b/services/Recorder.qml @@ -14,7 +14,6 @@ Singleton { property list startArgs: [] property bool needsStop property bool needsPause - property bool externalStart: false // Track if recording was started externally (e.g., via area picker) function start(extraArgs: list): void { needsStart = true; @@ -67,31 +66,17 @@ Singleton { Quickshell.execDetached(["caelestia", "record", "-p"]); props.paused = !props.paused; } else if (!wasRunning && props.running) { - // External start detected (e.g., via AreaPicker path) + // External start detected (should not happen with CLI-only approach) props.paused = false; props.elapsed = 0; - root.externalStart = true; } } else if (root.needsStart) { Quickshell.execDetached(["caelestia", "record", ...root.startArgs]); props.running = true; props.paused = false; props.elapsed = 0; - root.externalStart = false; // CLI start, not external } else if (wasRunning && !props.running) { - // External stop detected - if (root.externalStart) { - // Show stop notification for external recordings - const notifCmd = [ - "notify-send", - "-a", "caelestia-cli", - "--action=open=Open folder", - "Recording stopped", - "Recording saved to recordings folder" - ]; - Quickshell.execDetached(notifCmd); - root.externalStart = false; - } + // External stop detected (should not happen with CLI-only approach) props.paused = false; props.elapsed = 0; } From 98d001055d60ec4264f31244bece9558038e1153 Mon Sep 17 00:00:00 2001 From: Nikita Friesen Date: Tue, 30 Sep 2025 15:55:22 +0200 Subject: [PATCH 13/14] feat: enhance area selection and recording functionality with improved coordinate handling and external command detection --- modules/areapicker/Picker.qml | 9 +++++---- modules/utilities/cards/Media.qml | 11 +++++++++-- services/Recorder.qml | 9 +++++---- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/modules/areapicker/Picker.qml b/modules/areapicker/Picker.qml index b326ff0c1..92f1822cb 100644 --- a/modules/areapicker/Picker.qml +++ b/modules/areapicker/Picker.qml @@ -78,10 +78,11 @@ MouseArea { function save(): void { // Compute logical coordinates (same for both screenshots and recordings) - const screenRelX = Math.ceil(rsx); - const screenRelY = Math.ceil(rsy); - const screenRelW = Math.floor(sw); - const screenRelH = Math.floor(sh); + // Use consistent rounding to avoid invalid regions + const screenRelX = Math.floor(rsx); + const screenRelY = Math.floor(rsy); + const screenRelW = Math.max(1, Math.ceil(sw)); // Ensure minimum 1px width + const screenRelH = Math.max(1, Math.ceil(sh)); // Ensure minimum 1px height // Convert to global logical coordinates const globalX = screenRelX + screen.x; diff --git a/modules/utilities/cards/Media.qml b/modules/utilities/cards/Media.qml index 3d220d592..4867fb609 100644 --- a/modules/utilities/cards/Media.qml +++ b/modules/utilities/cards/Media.qml @@ -340,8 +340,15 @@ StyledRect { function triggerShot(cmd) { root.visibilities.utilities = false; root.visibilities.sidebar = false; - root.pendingCommand = cmd; - delayTimer.restart(); + + // Fullscreen screenshots don't need UI close delay - execute immediately + if (cmd.length === 2 && cmd[0] === "caelestia" && cmd[1] === "screenshot") { + Quickshell.execDetached(cmd); + } else { + // Area-based screenshots need delay for UI to close + root.pendingCommand = cmd; + delayTimer.restart(); + } } Timer { diff --git a/services/Recorder.qml b/services/Recorder.qml index cf254d4eb..87828fb5d 100644 --- a/services/Recorder.qml +++ b/services/Recorder.qml @@ -66,7 +66,7 @@ Singleton { Quickshell.execDetached(["caelestia", "record", "-p"]); props.paused = !props.paused; } else if (!wasRunning && props.running) { - // External start detected (should not happen with CLI-only approach) + // External start detected (e.g., region recording via area picker CLI) props.paused = false; props.elapsed = 0; } @@ -76,7 +76,7 @@ Singleton { props.paused = false; props.elapsed = 0; } else if (wasRunning && !props.running) { - // External stop detected (should not happen with CLI-only approach) + // External stop detected (e.g., recording finished externally) props.paused = false; props.elapsed = 0; } @@ -88,10 +88,11 @@ Singleton { } // Poll for recorder state while running or when actions are pending + // Also poll periodically when not running to detect external starts (e.g., region recording via CLI) Timer { - interval: 1000 + interval: props.running || root.needsStart || root.needsStop || root.needsPause ? 1000 : 3000 repeat: true - running: props.running || root.needsStart || root.needsStop || root.needsPause + running: true // Always poll, but less frequently when idle onTriggered: checkProc.running = true } From 13ebebdbe4c69526af550feecaf26f590b26c695 Mon Sep 17 00:00:00 2001 From: Nikita Friesen Date: Tue, 30 Sep 2025 16:01:06 +0200 Subject: [PATCH 14/14] feat: implement fast polling for instant recording state detection in Recorder and Picker components --- modules/areapicker/Picker.qml | 2 ++ modules/utilities/cards/Media.qml | 9 ++++++- services/Recorder.qml | 42 +++++++++++++++++++++++++++---- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/modules/areapicker/Picker.qml b/modules/areapicker/Picker.qml index 92f1822cb..bd3871d24 100644 --- a/modules/areapicker/Picker.qml +++ b/modules/areapicker/Picker.qml @@ -95,6 +95,8 @@ MouseArea { if (root.loader.recordWithSound) { cmd.push("-s"); } + // Start fast polling for instant recording state detection + Recorder.startFastPolling(); Quickshell.execDetached(cmd); } else { // Use CLI for screenshot - it handles notifications and actions diff --git a/modules/utilities/cards/Media.qml b/modules/utilities/cards/Media.qml index 4867fb609..54f0a96b2 100644 --- a/modules/utilities/cards/Media.qml +++ b/modules/utilities/cards/Media.qml @@ -341,11 +341,18 @@ StyledRect { root.visibilities.utilities = false; root.visibilities.sidebar = false; + // Check if this is a recording command and start fast polling for instant feedback + const isRecordingCmd = cmd.length >= 3 && cmd[0] === "caelestia" && cmd[1] === "shell" && cmd[2] === "picker" && + (cmd[3] === "openRecord" || cmd[3] === "openRecordSound"); + if (isRecordingCmd) { + Recorder.startFastPolling(); + } + // Fullscreen screenshots don't need UI close delay - execute immediately if (cmd.length === 2 && cmd[0] === "caelestia" && cmd[1] === "screenshot") { Quickshell.execDetached(cmd); } else { - // Area-based screenshots need delay for UI to close + // Area-based screenshots/recordings need delay for UI to close root.pendingCommand = cmd; delayTimer.restart(); } diff --git a/services/Recorder.qml b/services/Recorder.qml index 87828fb5d..b9285985e 100644 --- a/services/Recorder.qml +++ b/services/Recorder.qml @@ -14,20 +14,34 @@ Singleton { property list startArgs: [] property bool needsStop property bool needsPause + + // Fast polling for instant feedback on recording actions + property bool fastPolling: false + property int fastPollCount: 0 function start(extraArgs: list): void { needsStart = true; startArgs = extraArgs || []; + startFastPolling(); checkProc.running = true; } function stop(): void { needsStop = true; + startFastPolling(); checkProc.running = true; } function togglePause(): void { needsPause = true; + startFastPolling(); + checkProc.running = true; + } + + // Start fast polling for instant feedback (called externally for area recordings) + function startFastPolling(): void { + fastPolling = true; + fastPollCount = 0; checkProc.running = true; } @@ -87,13 +101,31 @@ Singleton { } } - // Poll for recorder state while running or when actions are pending - // Also poll periodically when not running to detect external starts (e.g., region recording via CLI) + // Smart polling: fast when actions are pending or during fast poll burst, slower when idle Timer { - interval: props.running || root.needsStart || root.needsStop || root.needsPause ? 1000 : 3000 + interval: { + if (root.fastPolling || root.needsStart || root.needsStop || root.needsPause) { + return 200; // Very fast polling for instant feedback + } else if (props.running) { + return 1000; // Normal polling when recording + } else { + return 3000; // Slow polling when idle + } + } repeat: true - running: true // Always poll, but less frequently when idle - onTriggered: checkProc.running = true + running: true + onTriggered: { + checkProc.running = true; + + // Manage fast polling burst - stop after 10 fast polls (2 seconds) + if (root.fastPolling) { + root.fastPollCount++; + if (root.fastPollCount >= 10) { + root.fastPolling = false; + root.fastPollCount = 0; + } + } + } } Connections {