diff --git a/.envrc b/.envrc index c90b500c9..84ba4f7eb 100644 --- a/.envrc +++ b/.envrc @@ -12,4 +12,4 @@ 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:-}" +export QML2_IMPORT_PATH="$PWD/build/qml:${QML2_IMPORT_PATH:-}" \ No newline at end of file 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 new file mode 100644 index 000000000..63e5737aa --- /dev/null +++ b/components/containers/WrapperMouseArea.qml @@ -0,0 +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 + + // expose a cursor shape like MouseArea + property alias cursorShape: hover.cursorShape + + signal 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/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/config/GeneralConfig.qml b/config/GeneralConfig.qml index eecca01db..c2de47e7a 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: ["swappy", "-f"] } 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/install-system.sh b/install-system.sh new file mode 100755 index 000000000..eef991db3 --- /dev/null +++ b/install-system.sh @@ -0,0 +1,25 @@ +#!/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 \ + -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 + +# 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/areapicker/AreaPicker.qml b/modules/areapicker/AreaPicker.qml index 7ff051fc8..d6e136c12 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 @@ -12,6 +13,10 @@ Scope { property bool freeze property bool closing + property bool recording: false + property bool recordWithSound: false + + // no-op Variants { model: Quickshell.screens @@ -40,6 +45,8 @@ Scope { Picker { loader: root screen: win.modelData + recording: root.recording + recordWithSound: root.recordWithSound } } } @@ -51,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 1625ccf1b..bd3871d24 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 @@ -14,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 @@ -72,8 +77,36 @@ 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])); + // Compute logical coordinates (same for both screenshots and recordings) + // 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; + const globalY = screenRelY + screen.y; + const region = `${screenRelW}x${screenRelH}+${globalX}+${globalY}`; + + 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"); + } + // Start fast polling for instant recording state detection + Recorder.startFastPolling(); + 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); + } + closeAnim.start(); } @@ -195,12 +228,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/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); } } diff --git a/modules/utilities/Content.qml b/modules/utilities/Content.qml index d5be82423..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,15 +18,16 @@ Item { anchors.fill: parent spacing: Appearance.spacing.normal - IdleInhibit {} + UtilCards.IdleInhibit {} - Record { + // Combined media card: Screenshots + Recordings in tabs + UtilCards.Media { props: root.props visibilities: root.visibilities z: 1 } - Toggles { + UtilCards.Toggles { visibilities: root.visibilities } } @@ -34,4 +35,8 @@ Item { RecordingDeleteModal { props: root.props } + + ScreenshotDeleteModal { + props: root.props + } } 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 new file mode 100644 index 000000000..d371000f6 --- /dev/null +++ b/modules/utilities/ScreenshotDeleteModal.qml @@ -0,0 +1,144 @@ +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 + z: 1000 + + 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..4737f2e85 100644 --- a/modules/utilities/Wrapper.qml +++ b/modules/utilities/Wrapper.qml @@ -12,9 +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 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..54f0a96b2 --- /dev/null +++ b/modules/utilities/cards/Media.qml @@ -0,0 +1,388 @@ +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("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"]) + ] + + // 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), + mkRecRegion("screenshot_region", "Record region", "Region", false), + mkRec("select_to_speak", "Record fullscreen with sound", "Fullscreen", ["-s"]), + mkRecRegion("volume_up", "Record region with sound", "Region", true) + ] + } + + 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 { + enabled: false // let inner list drive height; avoid damping overshoot + 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; + + // 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/recordings need delay for UI to close + 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 + + // 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: { + shotsBody.visible = root.tabIndex === 0; + recsBody.visible = root.tabIndex === 1; + } + } +} diff --git a/modules/utilities/cards/MediaList.qml b/modules/utilities/cards/MediaList.qml new file mode 100644 index 000000000..368279dd2 --- /dev/null +++ b/modules/utilities/cards/MediaList.qml @@ -0,0 +1,349 @@ +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 +// ============================ +// 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 + + 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 + // 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] + + 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] + } + } + } + + // ---------- VIEWPORT (the only resizable, clipping parent) ---------- + Item { + id: viewport + Layout.fillWidth: true + Layout.rightMargin: -Appearance.spacing.small + clip: true + // Enforce a separate layer to guarantee clipping across z-stacking + layer.enabled: true + layer.smooth: true + + 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) + // 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] + + Layout.preferredHeight: animatedHeight + height: animatedHeight + + // 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 ---------- + StyledListView { + id: list + anchors.fill: parent + clip: true + + 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); + } + } + + // 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 var 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: 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" } } + } + + // ---------- PSEUDO CONTAINER (TARGET CENTER REFERENCE) ---------- + Item { + id: pseudoContainer + width: parent.width + height: viewport.targetHeight // center reference independent of current height + } + + // ---------- 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); + } + + // ---- 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 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) + + // 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 + spacing: Appearance.spacing.small + anchors.horizontalCenter: parent.horizontalCenter + + MaterialIcon { + anchors.horizontalCenter: parent.horizontalCenter + text: root.textPrefix === "Screenshot" ? "image" : "videocam" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.extraLarge + } + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("No %1 found").arg(root.title.toLowerCase()) + color: Colours.palette.m3outline + } + } + } + + // ---- 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, (viewport.animatedHeight - height) / 2) + y: phWrap.clampedYFor(small, viewport.expandedTarget ? small.desiredCenter : small.desiredCenter) + + // 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 } } + + + Row { + id: smallRow + spacing: Appearance.spacing.smaller + 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 } + } + } + } + } +} 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/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/run.sh b/run.sh new file mode 100755 index 000000000..b777f6504 --- /dev/null +++ b/run.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -e + +# Make sure .local exists +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 +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 + export QML2_IMPORT_PATH="$PWD/.local/lib/qt6/qml:$QML2_IMPORT_PATH" + QS_CONFIG_NAME=caelestia \ + XDG_CONFIG_DIRS="$PWD/.local/etc/xdg" \ + quickshell & +} + +export -f run_quickshell + +# Find all QML, CPP, and header files, then watch them +find \ + "$PWD" \ + -name '*.qml' -o -name '*.cpp' -o -name '*.hpp' \ +| grep -v '^./build' \ +| entr -r bash -c run_quickshell diff --git a/services/Recorder.qml b/services/Recorder.qml index e4ce6a8bd..b9285985e 100644 --- a/services/Recorder.qml +++ b/services/Recorder.qml @@ -11,23 +11,43 @@ 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 + + // 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; + 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; + } + + // 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; } @@ -47,6 +67,7 @@ Singleton { running: true command: ["pidof", "gpu-screen-recorder"] onExited: code => { + const wasRunning = props.running; props.running = code === 0; if (code === 0) { @@ -54,15 +75,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., region recording via area picker CLI) + 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 (e.g., recording finished externally) + props.paused = false; + props.elapsed = 0; } root.needsStart = false; @@ -71,9 +101,36 @@ Singleton { } } + // Smart polling: fast when actions are pending or during fast poll burst, slower when idle + Timer { + 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 + 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 { target: Time - // enabled: props.running && !props.paused + enabled: props.running && !props.paused function onSecondsChanged(): void { props.elapsed++; 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 {