diff --git a/CMakeLists.txt b/CMakeLists.txt index 9e20e14..ce22903 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,6 +31,13 @@ if("shell" IN_LIST ENABLE_MODULES) foreach(dir assets scripts Components Config Modules Daemons Drawers Effects Helpers Paths) install(DIRECTORY ${dir} DESTINATION "${INSTALL_QSCONFDIR}") endforeach() - install(FILES shell.qml DESTINATION "${INSTALL_QSCONFDIR}") + + # Disable watching for changes + file(READ shell.qml SHELL_QML) + string(REPLACE "settings.watchFiles: true" "settings.watchFiles: false" SHELL_QML "${SHELL_QML}") + file(WRITE "${CMAKE_BINARY_DIR}/qml/shell.qml" "${SHELL_QML}") + install(FILES "${CMAKE_BINARY_DIR}/qml/shell.qml" DESTINATION "${INSTALL_QSCONFDIR}") + + # Greeter install(DIRECTORY Greeter/ DESTINATION "${INSTALL_GREETERCONFDIR}") endif() diff --git a/Components/CustomText.qml b/Components/CustomText.qml index cff8b2d..f4c1600 100644 --- a/Components/CustomText.qml +++ b/Components/CustomText.qml @@ -15,6 +15,7 @@ Text { color: DynamicColors.palette.m3onSurface font.family: Appearance.font.family.sans font.pointSize: Appearance.font.size.normal + linkColor: DynamicColors.palette.m3onPrimaryFixedVariant renderType: Text.NativeRendering textFormat: Text.PlainText diff --git a/Config/Config.qml b/Config/Config.qml index 66fbe6d..e92a6f9 100644 --- a/Config/Config.qml +++ b/Config/Config.qml @@ -214,6 +214,10 @@ Singleton { }, idle: { timeouts: general.idle.timeouts + }, + battery: { + popupThresholds: general.battery.popupThresholds, + critPerc: general.battery.critPerc } }; } diff --git a/Config/General.qml b/Config/General.qml index a0a2a2c..7948941 100644 --- a/Config/General.qml +++ b/Config/General.qml @@ -4,6 +4,8 @@ import Quickshell JsonObject { property Apps apps: Apps { } + property Battery battery: Battery { + } property Color color: Color { } property string dateFormat: "ddd d MMM - hh:mm:ss" @@ -19,6 +21,17 @@ JsonObject { property list playback: ["mpv"] property list terminal: ["kitty"] } + component Battery: JsonObject { + property int critPerc: 5 + property list popupThresholds: [ + { + perc: 20, + name: qsTr("Low battery"), + message: qsTr("Battery at %1%").arg(Battery.currentPerc * 100), + icon: "battery_android_frame_2" + }, + ] + } component Color: JsonObject { property int hyprsunsetTemp: 5000 property string mode: "dark" diff --git a/Daemons/Battery.qml b/Daemons/Battery.qml new file mode 100644 index 0000000..ba346bd --- /dev/null +++ b/Daemons/Battery.qml @@ -0,0 +1,46 @@ +import Quickshell +import Quickshell.Services.UPower +import QtQuick +import ZShell +import qs.Config +import qs.Components.Toast + +Scope { + id: root + + readonly property real currentPerc: UPower.displayDevice.percentage + readonly property list popupThresholds: [...Config.general.battery.popupThresholds].sort((a, b) => b.perc - a.perc) + + Connections { + function onOnBatteryChanged(): void { + if (UPower.onBattery) { + if (Config.utilities.toasts.chargingChanged) + Toaster.toast(qsTr("Charger unplugged"), qsTr("Battery is discharging"), "power_off"); + } else { + if (Config.utilities.toasts.chargingChanged) + Toaster.toast(qsTr("Charger plugged in"), qsTr("Battery is charging"), "power"); + for (const level of root.popupThresholds) + level.warned = false; + } + } + + target: UPower + } + + Connections { + function onPercentageChanged(): void { + if (!UPower.onBattery) + return; + + const p = UPower.displayDevice.percentage * 100; + for (const perc of root.popupThresholds) { + if (p <= perc.perc && !perc.warned) { + perc.warned = true; + Toaster.toast(perc.title ?? qsTr("Battery warning"), perc.message ?? qsTr("Battery perc is low"), perc.icon ?? "battery_android_alert", perc.critical ? Toast.Error : Toast.Warning); + } + } + } + + target: UPower.displayDevice + } +} diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index 37bd73c..ed91fda 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -35,7 +35,7 @@ Variants { property var root: Quickshell.shellDir WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.keyboardFocus: visibilities.dock || visibilities.launcher || visibilities.sidebar || visibilities.dashboard || visibilities.settings || visibilities.resources ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + // WlrLayershell.keyboardFocus: visibilities.dock || visibilities.launcher || visibilities.sidebar || visibilities.dashboard || visibilities.settings || visibilities.resources ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None color: "transparent" contentItem.focus: true mask: visibilities.isDrawing ? null : region diff --git a/Helpers/Recorder.qml b/Helpers/Recorder.qml index 4c7b330..128c65b 100644 --- a/Helpers/Recorder.qml +++ b/Helpers/Recorder.qml @@ -1,41 +1,82 @@ -// pragma Singleton -// -// import Quickshell -// import QtQuick -// -// Singleton { -// id: root -// -// function start(extraArgs = []): void { -// needsStart = true; -// startArgs = extraArgs; -// checkProc.running = true; -// } -// -// PersistentProperties { -// id: props -// -// property real elapsed: 0 -// property bool paused: false -// property bool running: false -// -// reloadableId: "recorder" -// } -// -// Process { -// id: checkProc -// -// command: ["pidof", "gpu-screen-recorder"] -// running: true -// -// onExited: code => { -// props.running = code === 0; -// -// if (code === 0) { -// if (root.needsStop) { -// Quickshell.execDetached(["zshell-cli"]); -// } -// } -// } -// } -// } +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property alias elapsed: props.elapsed + property bool needsPause + property bool needsStart + property bool needsStop + readonly property alias paused: props.paused + readonly property alias running: props.running + property list startArgs + + function start(extraArgs = []): void { + needsStart = true; + startArgs = extraArgs; + checkProc.running = true; + } + + function stop(): void { + needsStop = true; + checkProc.running = true; + } + + function togglePause(): void { + needsPause = true; + checkProc.running = true; + } + + PersistentProperties { + id: props + + property real elapsed: 0 + property bool paused: false + property bool running: false + + reloadableId: "recorder" + } + + Process { + id: checkProc + + command: ["pidof", "gpu-screen-recorder"] + running: true + + onExited: code => { + props.running = code === 0; + + if (code === 0) { + if (root.needsStop) { + Quickshell.execDetached(["zshell-cli", "record", "record"]); + props.running = false; + props.paused = false; + } else if (root.needsPause) { + Quickshell.execDetached(["zshell-cli", "record", "record", "-p"]); + props.paused = !props.paused; + } + } else if (root.needsStart) { + Quickshell.execDetached(["zshell-cli", "record", "record", ...root.startArgs]); + props.running = true; + props.paused = false; + props.elapsed = 0; + } + + root.needsStart = false; + root.needsStop = false; + root.needsPause = false; + } + } + + Connections { + function onSecondsChanged(): void { + props.elapsed++; + } + + target: Time // qmllint disable incompatible-type + } +} diff --git a/Helpers/Searcher.qml b/Helpers/Searcher.qml index 5bfdef5..23f15e5 100644 --- a/Helpers/Searcher.qml +++ b/Helpers/Searcher.qml @@ -1,7 +1,7 @@ -import Quickshell import "../scripts/fzf.js" as Fzf import "../scripts/fuzzysort.js" as Fuzzy import QtQuick +import Quickshell Singleton { property var extraOpts: ({}) diff --git a/Modules/Launcher/Wrapper.qml b/Modules/Launcher/Wrapper.qml index a7ababd..1e8b6d6 100644 --- a/Modules/Launcher/Wrapper.qml +++ b/Modules/Launcher/Wrapper.qml @@ -4,6 +4,7 @@ import Quickshell import QtQuick import qs.Components import qs.Config +import qs.Modules.Launcher.Services Item { id: root @@ -19,26 +20,17 @@ Item { max -= panels.popouts.nonAnimHeight; return max; } + property real offsetScale: shouldBeActive ? 0 : 1 required property var panels required property ShellScreen screen - required property PersistentProperties visibilities readonly property bool shouldBeActive: visibilities.launcher - property real offsetScale: shouldBeActive ? 0 : 1 + required property PersistentProperties visibilities - onShouldBeActiveChanged: { - if (shouldBeActive) { - implicitHeight = Qt.binding(() => content.implicitHeight); - timer.stop(); - } else { - implicitHeight = implicitHeight; - } - } - - visible: offsetScale < 1 anchors.bottomMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth || 400 opacity: 1 - offsetScale + visible: offsetScale < 1 Behavior on offsetScale { Anim { @@ -47,61 +39,26 @@ Item { } } - onMaxHeightChanged: timer.start() - - Connections { - function onEnabledChanged(): void { - timer.start(); - } - - function onMaxShownChanged(): void { - timer.start(); - } - - target: Config.launcher - } - - Connections { - function onValuesChanged(): void { - if (DesktopEntries.applications.values.length < Config.launcher.maxAppsShown) - timer.start(); - } - - target: DesktopEntries.applications - } - - Timer { - id: timer - - interval: Appearance.anim.durations.small - - onRunningChanged: { - if (running && !root.shouldBeActive) { - content.visible = false; - content.active = true; - } else { - root.contentHeight = Math.min(root.maxHeight, content.implicitHeight); - content.active = Qt.binding(() => root.shouldBeActive || root.visible); - content.visible = true; - } - } + Component.onCompleted: Qt.callLater(() => Apps) + onShouldBeActiveChanged: { + if (shouldBeActive) + implicitHeight = Qt.binding(() => content.implicitHeight); + else + implicitHeight = implicitHeight; } Loader { id: content - active: false + active: root.shouldBeActive || root.visible anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top + asynchronous: true sourceComponent: Content { maxHeight: root.maxHeight panels: root.panels visibilities: root.visibilities - - Component.onCompleted: root.contentHeight = implicitHeight } - - Component.onCompleted: timer.start() } } diff --git a/Modules/Notifications/Sidebar/Notif.qml b/Modules/Notifications/Sidebar/Notif.qml index 1d99414..53445e9 100644 --- a/Modules/Notifications/Sidebar/Notif.qml +++ b/Modules/Notifications/Sidebar/Notif.qml @@ -136,7 +136,10 @@ CustomRect { wrapMode: Text.WordWrap onLinkActivated: link => { - Quickshell.execDetached(["app2unit", "-O", "--", link]); + if (Config.launcher.uwsm) + Quickshell.execDetached(["app2unit", "-O", "--", link]); + else + Quickshell.execDetached(["xdg-open", link]); root.visibilities.sidebar = false; } } diff --git a/Modules/Notifications/Sidebar/Utils/Cards/Record.qml b/Modules/Notifications/Sidebar/Utils/Cards/Record.qml new file mode 100644 index 0000000..7d7c832 --- /dev/null +++ b/Modules/Notifications/Sidebar/Utils/Cards/Record.qml @@ -0,0 +1,290 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Config +import qs.Helpers + +CustomRect { + id: root + + required property var props + required property PersistentProperties visibilities + + Layout.fillWidth: true + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 + radius: Appearance.rounding.smallest + + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.normal + + RowLayout { + spacing: Appearance.spacing.normal + z: 1 + + CustomRect { + color: Recorder.running ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3secondaryContainer + implicitHeight: { + const h = icon.implicitHeight + Appearance.padding.smaller * 2; + return h - (h % 2); + } + implicitWidth: implicitHeight + radius: Appearance.rounding.full + + MaterialIcon { + id: icon + + anchors.centerIn: parent + anchors.horizontalCenterOffset: -0.5 + anchors.verticalCenterOffset: 1.5 + color: Recorder.running ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3onSecondaryContainer + font.pointSize: Appearance.font.size.large + text: "screen_record" + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + CustomText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pointSize: Appearance.font.size.normal + text: qsTr("Screen Recorder") + } + + CustomText { + Layout.fillWidth: true + color: DynamicColors.palette.m3onSurfaceVariant + elide: Text.ElideRight + font.pointSize: Appearance.font.size.small + text: Recorder.paused ? qsTr("Recording paused") : Recorder.running ? qsTr("Recording running") : qsTr("Recording off") + } + } + + CustomSplitButton { + active: menuItems.find(m => root.props.recordingMode === m.icon + m.text) ?? menuItems[0] + disabled: Recorder.running + + menuItems: [ + MenuItem { + activeText: qsTr("Fullscreen") + icon: "fullscreen" + text: qsTr("Record fullscreen") + + onClicked: Recorder.start() + }, + MenuItem { + activeText: qsTr("Region") + icon: "screenshot_region" + text: qsTr("Record region") + + onClicked: Recorder.start(["-r"]) + }, + MenuItem { + activeText: qsTr("Fullscreen") + icon: "select_to_speak" + text: qsTr("Record fullscreen with sound") + + onClicked: Recorder.start(["-s"]) + }, + MenuItem { + activeText: qsTr("Region") + icon: "volume_up" + text: qsTr("Record region with sound") + + onClicked: Recorder.start(["-s", "-r"]) + } + ] + + menu.onItemSelected: item => root.props.recordingMode = item.icon + item.text + } + } + + Loader { + id: listOrControls + + property bool running: Recorder.running + + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + asynchronous: true + sourceComponent: running ? recordingControls : recordingList + + Behavior on Layout.preferredHeight { + id: locHeightAnim + + enabled: false + + Anim { + } + } + Behavior on running { + SequentialAnimation { + ParallelAnimation { + Anim { + duration: Appearance.anim.durations.small + easing: Appearance.anim.curves.standardAccel + property: "scale" + target: listOrControls + to: 0.7 + } + + Anim { + duration: Appearance.anim.durations.small + easing: Appearance.anim.curves.standardAccel + property: "opacity" + target: listOrControls + to: 0 + } + } + + PropertyAction { + property: "enabled" + target: locHeightAnim + value: true + } + + PropertyAction { + } + + PropertyAction { + property: "enabled" + target: locHeightAnim + value: false + } + + ParallelAnimation { + Anim { + duration: Appearance.anim.durations.small + easing: Appearance.anim.curves.standardDecel + property: "scale" + target: listOrControls + to: 1 + } + + Anim { + duration: Appearance.anim.durations.small + easing: Appearance.anim.curves.standardDecel + property: "opacity" + target: listOrControls + to: 1 + } + } + } + } + } + } + + Component { + id: recordingList + + RecordingList { + props: root.props + visibilities: root.visibilities + } + } + + Component { + id: recordingControls + + RowLayout { + spacing: Appearance.spacing.normal + + CustomRect { + color: Recorder.paused ? DynamicColors.palette.m3tertiary : DynamicColors.palette.m3error + implicitHeight: recText.implicitHeight + Appearance.padding.smaller * 2 + implicitWidth: recText.implicitWidth + Appearance.padding.normal * 2 + radius: Appearance.rounding.full + + Behavior on implicitWidth { + Anim { + } + } + SequentialAnimation on opacity { + alwaysRunToEnd: true + loops: Animation.Infinite + running: !Recorder.paused + + Anim { + duration: Appearance.anim.durations.large + easing: Appearance.anim.curves.emphasizedAccel + from: 1 + to: 0 + } + + Anim { + duration: Appearance.anim.durations.extraLarge + easing: Appearance.anim.curves.emphasizedDecel + from: 0 + to: 1 + } + } + + CustomText { + id: recText + + anchors.centerIn: parent + animate: true + color: Recorder.paused ? DynamicColors.palette.m3onTertiary : DynamicColors.palette.m3onError + font.family: Appearance.font.family.mono + text: Recorder.paused ? "PAUSED" : "REC" + } + } + + CustomText { + font.pointSize: Appearance.font.size.normal + 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); + } + } + + Item { + Layout.fillWidth: true + } + + IconButton { + checked: Recorder.paused + font.pointSize: Appearance.font.size.large + icon: Recorder.paused ? "play_arrow" : "pause" + label.animate: true + toggle: true + type: IconButton.Tonal + + onClicked: { + Recorder.togglePause(); + internalChecked = Recorder.paused; + } + } + + IconButton { + font.pointSize: Appearance.font.size.large + icon: "stop" + inactiveColour: DynamicColors.palette.m3error + inactiveOnColour: DynamicColors.palette.m3onError + + onClicked: Recorder.stop() + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml new file mode 100644 index 0000000..bb94fc9 --- /dev/null +++ b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml @@ -0,0 +1,226 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import ZShell.Models +import qs.Components +import qs.Helpers +import qs.Paths +import qs.Config + +ColumnLayout { + id: root + + required property var props + required property PersistentProperties 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 + font.pointSize: Appearance.font.size.large + text: "list" + } + + CustomText { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + font.pointSize: Appearance.font.size.normal + text: qsTr("Recordings") + } + + IconButton { + icon: root.props.recordingListExpanded ? "unfold_less" : "unfold_more" + label.animate: true + type: IconButton.Text + + onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded + } + } + } + + CustomListView { + id: list + + Layout.fillWidth: true + Layout.rightMargin: -Appearance.spacing.small + clip: true + implicitHeight: (Appearance.font.size.larger + Appearance.padding.small) * (root.props.recordingListExpanded ? 10 : 3) + + CustomScrollBar.vertical: CustomScrollBar { + flickable: list + } + add: Transition { + Anim { + from: 0 + property: "opacity" + to: 1 + } + + Anim { + from: 0.5 + property: "scale" + to: 1 + } + } + delegate: RowLayout { + id: recording + + property string baseName + required property FileSystemEntry modelData + + 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 + + CustomText { + Layout.fillWidth: true + Layout.rightMargin: Appearance.spacing.small / 2 + color: DynamicColors.palette.m3onSurfaceVariant + elide: Text.ElideRight + 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)); + date.setMonth(date.getMonth() - 1); + return qsTr("Recording at %1").arg(Qt.formatDateTime(date, Qt.locale())); + } + } + + IconButton { + icon: "play_arrow" + type: IconButton.Text + + onClicked: { + root.visibilities.sidebar = false; + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); + } + } + + IconButton { + icon: "folder" + type: IconButton.Text + + onClicked: { + root.visibilities.sidebar = false; + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); + } + } + } + displaced: Transition { + Anim { + properties: "opacity,scale" + to: 1 + } + + Anim { + property: "y" + } + } + Behavior on implicitHeight { + Anim { + } + } + model: FileSystemModel { + nameFilters: ["recording_*.mp4"] + path: Paths.recsdir + sortReverse: true + } + remove: Transition { + Anim { + property: "opacity" + to: 0 + } + + Anim { + property: "scale" + to: 0.5 + } + } + + Loader { + active: opacity > 0 + anchors.centerIn: parent + asynchronous: true + opacity: list.count === 0 ? 1 : 0 + + Behavior on opacity { + Anim { + } + } + sourceComponent: ColumnLayout { + spacing: Appearance.spacing.small + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + Layout.preferredHeight: root.props.recordingListExpanded ? implicitHeight : 0 + color: DynamicColors.palette.m3outline + font.pointSize: Appearance.font.size.extraLarge + opacity: root.props.recordingListExpanded ? 1 : 0 + scale: root.props.recordingListExpanded ? 1 : 0 + text: "scan_delete" + + Behavior on Layout.preferredHeight { + Anim { + } + } + Behavior on opacity { + Anim { + } + } + Behavior on scale { + Anim { + } + } + } + + RowLayout { + spacing: Appearance.spacing.smaller + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: !root.props.recordingListExpanded ? implicitWidth : 0 + color: DynamicColors.palette.m3outline + opacity: !root.props.recordingListExpanded ? 1 : 0 + scale: !root.props.recordingListExpanded ? 1 : 0 + text: "scan_delete" + + Behavior on Layout.preferredWidth { + Anim { + } + } + Behavior on opacity { + Anim { + } + } + Behavior on scale { + Anim { + } + } + } + + CustomText { + color: DynamicColors.palette.m3outline + text: qsTr("No recordings found") + } + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Utils/Content.qml b/Modules/Notifications/Sidebar/Utils/Content.qml index d9404ea..348340a 100644 --- a/Modules/Notifications/Sidebar/Utils/Content.qml +++ b/Modules/Notifications/Sidebar/Utils/Content.qml @@ -1,13 +1,14 @@ -import qs.Modules.Notifications.Sidebar.Utils.Cards -import qs.Config +import Quickshell import QtQuick import QtQuick.Layouts +import qs.Modules.Notifications.Sidebar.Utils.Cards +import qs.Config Item { id: root required property Item popouts - required property var props + required property PersistentProperties props required property var visibilities implicitHeight: layout.implicitHeight @@ -22,6 +23,12 @@ Item { IdleInhibit { } + Record { + props: root.props + visibilities: root.visibilities + z: 1 + } + Toggles { popouts: root.popouts visibilities: root.visibilities diff --git a/Modules/Osd/Content.qml b/Modules/Osd/Content.qml index 04d5b1b..ef5ce62 100644 --- a/Modules/Osd/Content.qml +++ b/Modules/Osd/Content.qml @@ -100,12 +100,14 @@ Item { icon: `brightness_${(Math.round(value * 6) + 1)}` value: root.brightness - onMoved: { - if (Config.osd.allMonBrightness) { - root.monitor?.setBrightness(value); - } else { - for (const mon of Brightness.monitors) { - mon.setBrightness(value); + onPressedChanged: { + if (!pressed) { + if (Config.osd.allMonBrightness) { + for (const mon of Brightness.monitors) { + mon.setBrightness(value); + } + } else { + root.monitor?.setBrightness(value); } } } diff --git a/Modules/Settings/Categories/Dashboard.qml b/Modules/Settings/Categories/Dashboard.qml index dabb37e..1e9ad84 100644 --- a/Modules/Settings/Categories/Dashboard.qml +++ b/Modules/Settings/Categories/Dashboard.qml @@ -19,8 +19,8 @@ SettingsPage { } SettingSpinBox { - name: "Media update interval" min: 0 + name: "Media update interval" object: Config.dashboard setting: "mediaUpdateInterval" step: 50 @@ -30,8 +30,8 @@ SettingsPage { } SettingSpinBox { - name: "Resource update interval" min: 0 + name: "Resource update interval" object: Config.dashboard setting: "resourceUpdateInterval" step: 50 @@ -41,8 +41,8 @@ SettingsPage { } SettingSpinBox { - name: "Drag threshold" min: 0 + name: "Drag threshold" object: Config.dashboard setting: "dragThreshold" } @@ -107,112 +107,112 @@ SettingsPage { } } - SettingsSection { - sectionId: "Layout Sizes" - - SettingsHeader { - name: "Layout Sizes" - } - - SettingReadOnly { - name: "Tab indicator height" - value: String(Config.dashboard.sizes.tabIndicatorHeight) - } - - Separator { - } - - SettingReadOnly { - name: "Tab indicator spacing" - value: String(Config.dashboard.sizes.tabIndicatorSpacing) - } - - Separator { - } - - SettingReadOnly { - name: "Info width" - value: String(Config.dashboard.sizes.infoWidth) - } - - Separator { - } - - SettingReadOnly { - name: "Info icon size" - value: String(Config.dashboard.sizes.infoIconSize) - } - - Separator { - } - - SettingReadOnly { - name: "Date time width" - value: String(Config.dashboard.sizes.dateTimeWidth) - } - - Separator { - } - - SettingReadOnly { - name: "Media width" - value: String(Config.dashboard.sizes.mediaWidth) - } - - Separator { - } - - SettingReadOnly { - name: "Media progress sweep" - value: String(Config.dashboard.sizes.mediaProgressSweep) - } - - Separator { - } - - SettingReadOnly { - name: "Media progress thickness" - value: String(Config.dashboard.sizes.mediaProgressThickness) - } - - Separator { - } - - SettingReadOnly { - name: "Resource progress thickness" - value: String(Config.dashboard.sizes.resourceProgessThickness) - } - - Separator { - } - - SettingReadOnly { - name: "Weather width" - value: String(Config.dashboard.sizes.weatherWidth) - } - - Separator { - } - - SettingReadOnly { - name: "Media cover art size" - value: String(Config.dashboard.sizes.mediaCoverArtSize) - } - - Separator { - } - - SettingReadOnly { - name: "Media visualiser size" - value: String(Config.dashboard.sizes.mediaVisualiserSize) - } - - Separator { - } - - SettingReadOnly { - name: "Resource size" - value: String(Config.dashboard.sizes.resourceSize) - } - } + // SettingsSection { + // sectionId: "Layout Sizes" + // + // SettingsHeader { + // name: "Layout Sizes" + // } + // + // SettingReadOnly { + // name: "Tab indicator height" + // value: String(Config.dashboard.sizes.tabIndicatorHeight) + // } + // + // Separator { + // } + // + // SettingReadOnly { + // name: "Tab indicator spacing" + // value: String(Config.dashboard.sizes.tabIndicatorSpacing) + // } + // + // Separator { + // } + // + // SettingReadOnly { + // name: "Info width" + // value: String(Config.dashboard.sizes.infoWidth) + // } + // + // Separator { + // } + // + // SettingReadOnly { + // name: "Info icon size" + // value: String(Config.dashboard.sizes.infoIconSize) + // } + // + // Separator { + // } + // + // SettingReadOnly { + // name: "Date time width" + // value: String(Config.dashboard.sizes.dateTimeWidth) + // } + // + // Separator { + // } + // + // SettingReadOnly { + // name: "Media width" + // value: String(Config.dashboard.sizes.mediaWidth) + // } + // + // Separator { + // } + // + // SettingReadOnly { + // name: "Media progress sweep" + // value: String(Config.dashboard.sizes.mediaProgressSweep) + // } + // + // Separator { + // } + // + // SettingReadOnly { + // name: "Media progress thickness" + // value: String(Config.dashboard.sizes.mediaProgressThickness) + // } + // + // Separator { + // } + // + // SettingReadOnly { + // name: "Resource progress thickness" + // value: String(Config.dashboard.sizes.resourceProgessThickness) + // } + // + // Separator { + // } + // + // SettingReadOnly { + // name: "Weather width" + // value: String(Config.dashboard.sizes.weatherWidth) + // } + // + // Separator { + // } + // + // SettingReadOnly { + // name: "Media cover art size" + // value: String(Config.dashboard.sizes.mediaCoverArtSize) + // } + // + // Separator { + // } + // + // SettingReadOnly { + // name: "Media visualiser size" + // value: String(Config.dashboard.sizes.mediaVisualiserSize) + // } + // + // Separator { + // } + // + // SettingReadOnly { + // name: "Resource size" + // value: String(Config.dashboard.sizes.resourceSize) + // } + // } } diff --git a/Modules/Settings/Categories/Lockscreen.qml b/Modules/Settings/Categories/Lockscreen.qml index 50c93db..d08b8a1 100644 --- a/Modules/Settings/Categories/Lockscreen.qml +++ b/Modules/Settings/Categories/Lockscreen.qml @@ -103,6 +103,18 @@ SettingsPage { } } + SettingsSection { + sectionId: "Greeter" + + SettingsHeader { + name: "Greeter" + } + + SettingsIconButton { + name: "Install wallpaper and color scheme to greeter" + } + } + SettingsSection { sectionId: "Idle" diff --git a/Modules/Settings/Categories/Lockscreen/Idle.qml b/Modules/Settings/Categories/Lockscreen/Idle.qml index c431fa0..43ea412 100644 --- a/Modules/Settings/Categories/Lockscreen/Idle.qml +++ b/Modules/Settings/Categories/Lockscreen/Idle.qml @@ -9,6 +9,8 @@ import qs.Modules.Settings.Controls ColumnLayout { id: root + property bool shouldBeActive: true + function addTimeoutEntry() { let list = [...Config.general.idle.timeouts]; @@ -40,8 +42,26 @@ ColumnLayout { Config.save(); } - Layout.fillWidth: true + anchors.left: parent.left + anchors.right: parent.right + height: shouldBeActive ? implicitHeight : 0 + opacity: shouldBeActive ? 1 : 0 + scale: shouldBeActive ? 1 : 0.8 spacing: Appearance.spacing.smaller + visible: opacity > 0 + + Behavior on opacity { + Anim { + } + } + Behavior on scale { + Anim { + } + } + Behavior on y { + Anim { + } + } Settings { name: "Idle Monitors" @@ -52,6 +72,8 @@ ColumnLayout { SettingList { Layout.fillWidth: true + anchors.left: undefined + anchors.right: undefined onAddActiveActionRequested: { root.updateTimeoutEntry(index, "activeAction", ""); diff --git a/Modules/Settings/Categories/Utilities.qml b/Modules/Settings/Categories/Utilities.qml index 0fb6bcd..a40af60 100644 --- a/Modules/Settings/Categories/Utilities.qml +++ b/Modules/Settings/Categories/Utilities.qml @@ -19,8 +19,8 @@ SettingsPage { } SettingSpinBox { - name: "Max toasts" min: 1 + name: "Max toasts" object: Config.utilities setting: "maxToasts" } @@ -29,8 +29,8 @@ SettingsPage { } SettingSpinBox { - name: "Panel width" min: 1 + name: "Panel width" object: Config.utilities.sizes setting: "width" } @@ -39,8 +39,8 @@ SettingsPage { } SettingSpinBox { - name: "Toast width" min: 1 + name: "Toast width" object: Config.utilities.sizes setting: "toastWidth" } @@ -77,100 +77,100 @@ SettingsPage { setting: "gameModeChanged" } - Separator { - } - - SettingSwitch { - name: "Do not disturb changed" - object: Config.utilities.toasts - setting: "dndChanged" - } - - Separator { - } - - SettingSwitch { - name: "Audio output changed" - object: Config.utilities.toasts - setting: "audioOutputChanged" - } - - Separator { - } - - SettingSwitch { - name: "Audio input changed" - object: Config.utilities.toasts - setting: "audioInputChanged" - } - - Separator { - } - - SettingSwitch { - name: "Caps lock changed" - object: Config.utilities.toasts - setting: "capsLockChanged" - } - - Separator { - } - - SettingSwitch { - name: "Num lock changed" - object: Config.utilities.toasts - setting: "numLockChanged" - } - - Separator { - } - - SettingSwitch { - name: "Keyboard layout changed" - object: Config.utilities.toasts - setting: "kbLayoutChanged" - } - - Separator { - } - - SettingSwitch { - name: "VPN changed" - object: Config.utilities.toasts - setting: "vpnChanged" - } - - Separator { - } - - SettingSwitch { - name: "Now playing" - object: Config.utilities.toasts - setting: "nowPlaying" - } + // Separator { + // } + // + // SettingSwitch { + // name: "Do not disturb changed" + // object: Config.utilities.toasts + // setting: "dndChanged" + // } + // + // Separator { + // } + // + // SettingSwitch { + // name: "Audio output changed" + // object: Config.utilities.toasts + // setting: "audioOutputChanged" + // } + // + // Separator { + // } + // + // SettingSwitch { + // name: "Audio input changed" + // object: Config.utilities.toasts + // setting: "audioInputChanged" + // } + // + // Separator { + // } + // + // SettingSwitch { + // name: "Caps lock changed" + // object: Config.utilities.toasts + // setting: "capsLockChanged" + // } + // + // Separator { + // } + // + // SettingSwitch { + // name: "Num lock changed" + // object: Config.utilities.toasts + // setting: "numLockChanged" + // } + // + // Separator { + // } + // + // SettingSwitch { + // name: "Keyboard layout changed" + // object: Config.utilities.toasts + // setting: "kbLayoutChanged" + // } + // + // Separator { + // } + // + // SettingSwitch { + // name: "VPN changed" + // object: Config.utilities.toasts + // setting: "vpnChanged" + // } + // + // Separator { + // } + // + // SettingSwitch { + // name: "Now playing" + // object: Config.utilities.toasts + // setting: "nowPlaying" + // } } - SettingsSection { - sectionId: "VPN" - - SettingsHeader { - name: "VPN" - } - - SettingSwitch { - name: "Enable VPN integration" - object: Config.utilities.vpn - setting: "enabled" - } - - Separator { - } - - SettingStringList { - name: "Provider" - addLabel: qsTr("Add VPN provider") - object: Config.utilities.vpn - setting: "provider" - } - } + // SettingsSection { + // sectionId: "VPN" + // + // SettingsHeader { + // name: "VPN" + // } + // + // SettingSwitch { + // name: "Enable VPN integration" + // object: Config.utilities.vpn + // setting: "enabled" + // } + // + // Separator { + // } + // + // SettingStringList { + // name: "Provider" + // addLabel: qsTr("Add VPN provider") + // object: Config.utilities.vpn + // setting: "provider" + // } + // } } diff --git a/Modules/Settings/Controls/SettingActionList.qml b/Modules/Settings/Controls/SettingActionList.qml index af32bb1..373e021 100644 --- a/Modules/Settings/Controls/SettingActionList.qml +++ b/Modules/Settings/Controls/SettingActionList.qml @@ -127,6 +127,9 @@ ColumnLayout { } Separator { + Layout.fillWidth: true + anchors.left: undefined + anchors.right: undefined } RowLayout { @@ -207,6 +210,8 @@ ColumnLayout { StringListEditor { Layout.fillWidth: true addLabel: qsTr("Add command argument") + anchors.left: undefined + anchors.right: undefined values: [...(modelData.command ?? [])] onListEdited: function (values) { @@ -215,6 +220,9 @@ ColumnLayout { } Separator { + Layout.fillWidth: true + anchors.left: undefined + anchors.right: undefined } RowLayout { @@ -233,6 +241,9 @@ ColumnLayout { } Separator { + Layout.fillWidth: true + anchors.left: undefined + anchors.right: undefined } RowLayout { diff --git a/Modules/Settings/Controls/SettingAliasList.qml b/Modules/Settings/Controls/SettingAliasList.qml index bfdb215..908dc29 100644 --- a/Modules/Settings/Controls/SettingAliasList.qml +++ b/Modules/Settings/Controls/SettingAliasList.qml @@ -6,7 +6,7 @@ import qs.Components import qs.Config import qs.Helpers -ColumnLayout { +CustomRect { id: root readonly property bool highlighted: SettingsHighlight.highlightedSetting === name @@ -43,10 +43,9 @@ ColumnLayout { anchors.left: parent.left anchors.right: parent.right - height: shouldBeActive ? implicitHeight : 0 + height: shouldBeActive ? layout.implicitHeight : 0 opacity: shouldBeActive ? 1 : 0 scale: shouldBeActive ? 1 : 0.8 - spacing: Appearance.spacing.smaller visible: opacity > 0 Behavior on opacity { @@ -77,115 +76,128 @@ ColumnLayout { } } - CustomText { - Layout.fillWidth: true - font.pointSize: Appearance.font.size.larger - text: root.name - } + ColumnLayout { + id: layout - Repeater { - model: [...root.object[root.setting]] - - Item { - required property int index - required property var modelData - - Layout.fillWidth: true - Layout.preferredHeight: layout.implicitHeight + Appearance.padding.smaller * 2 - - CustomRect { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.topMargin: -(Appearance.spacing.smaller / 2) - color: DynamicColors.tPalette.m3outlineVariant - implicitHeight: 1 - visible: index !== 0 - } - - ColumnLayout { - id: layout - - anchors.left: parent.left - anchors.margins: Appearance.padding.small - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: Appearance.spacing.small - - RowLayout { - Layout.fillWidth: true - - CustomText { - Layout.fillWidth: true - text: qsTr("From") - } - - CustomRect { - Layout.fillWidth: true - Layout.preferredHeight: 33 - color: DynamicColors.tPalette.m3surfaceContainerHigh - radius: Appearance.rounding.full - - CustomTextField { - anchors.fill: parent - anchors.leftMargin: Appearance.padding.normal - anchors.rightMargin: Appearance.padding.normal - text: modelData.from ?? "" - - onEditingFinished: root.updateAlias(index, "from", text) - } - } - - IconButton { - font.pointSize: Appearance.font.size.large - icon: "delete" - type: IconButton.Tonal - - onClicked: root.removeAlias(index) - } - } - - RowLayout { - Layout.fillWidth: true - - CustomText { - Layout.fillWidth: true - text: qsTr("To") - } - - CustomRect { - Layout.fillWidth: true - Layout.preferredHeight: 33 - color: DynamicColors.tPalette.m3surface - radius: Appearance.rounding.small - - CustomTextField { - anchors.fill: parent - anchors.leftMargin: Appearance.padding.normal - anchors.rightMargin: Appearance.padding.normal - text: modelData.to ?? "" - - onEditingFinished: root.updateAlias(index, "to", text) - } - } - } - } - } - } - - RowLayout { - Layout.fillWidth: true - - IconButton { - font.pointSize: Appearance.font.size.large - icon: "add" - - onClicked: root.addAlias() - } + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Appearance.spacing.smaller CustomText { Layout.fillWidth: true - text: qsTr("Add alias") + font.pointSize: Appearance.font.size.larger + text: root.name + } + + Repeater { + model: [...root.object[root.setting]] + + Item { + required property int index + required property var modelData + + Layout.fillWidth: true + Layout.preferredHeight: layout.implicitHeight + Appearance.padding.smaller * 2 + + CustomRect { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: -(Appearance.spacing.smaller / 2) + color: DynamicColors.tPalette.m3outlineVariant + implicitHeight: 1 + visible: index !== 0 + } + + ColumnLayout { + id: layout + + anchors.left: parent.left + anchors.margins: Appearance.padding.small + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + + CustomText { + Layout.fillWidth: true + text: qsTr("From") + } + + CustomRect { + Layout.preferredHeight: 33 + Layout.preferredWidth: Math.max(Math.min(fromTextField.contentWidth + Appearance.padding.large * 2, 550), 50) + color: DynamicColors.tPalette.m3surfaceContainerHigh + radius: Appearance.rounding.full + + CustomTextField { + id: fromTextField + + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + implicitWidth: Math.min(contentWidth + Appearance.padding.normal * 2, 550) + text: modelData.from ?? "" + + onEditingFinished: root.updateAlias(index, "from", text) + } + } + + IconButton { + font.pointSize: Appearance.font.size.large + icon: "delete" + type: IconButton.Tonal + + onClicked: root.removeAlias(index) + } + } + + RowLayout { + Layout.fillWidth: true + + CustomText { + Layout.fillWidth: true + text: qsTr("To") + } + + CustomRect { + Layout.preferredHeight: 33 + Layout.preferredWidth: Math.max(Math.min(toTextField.contentWidth + Appearance.padding.large * 2, 550), 50) + color: DynamicColors.tPalette.m3surfaceContainerHigh + radius: Appearance.rounding.full + + CustomTextField { + id: toTextField + + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + implicitWidth: Math.min(contentWidth + Appearance.padding.normal * 2, 550) + text: modelData.to ?? "" + + onEditingFinished: root.updateAlias(index, "to", text) + } + } + } + } + } + } + + RowLayout { + Layout.fillWidth: true + + IconButton { + font.pointSize: Appearance.font.size.large + icon: "add" + + onClicked: root.addAlias() + } + + CustomText { + Layout.fillWidth: true + text: qsTr("Add alias") + } } } } diff --git a/Modules/Settings/Controls/SettingList.qml b/Modules/Settings/Controls/SettingList.qml index 6351c91..fad061f 100644 --- a/Modules/Settings/Controls/SettingList.qml +++ b/Modules/Settings/Controls/SettingList.qml @@ -194,6 +194,9 @@ Item { } Separator { + Layout.fillWidth: true + anchors.left: undefined + anchors.right: undefined } RowLayout { @@ -225,6 +228,9 @@ Item { } Separator { + Layout.fillWidth: true + anchors.left: undefined + anchors.right: undefined } Item { diff --git a/Modules/Settings/Controls/SettingsIconButton.qml b/Modules/Settings/Controls/SettingsIconButton.qml new file mode 100644 index 0000000..5f6673d --- /dev/null +++ b/Modules/Settings/Controls/SettingsIconButton.qml @@ -0,0 +1,83 @@ +import Quickshell +import QtQuick +import QtQuick.Layouts +import qs.Paths +import qs.Components +import qs.Config +import qs.Helpers + +Item { + id: root + + property alias button: iButton + readonly property bool highlighted: SettingsHighlight.highlightedSetting === name + required property string name + property bool shouldBeActive: true + + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: shouldBeActive ? row.implicitHeight + Appearance.padding.smaller * 2 : 0 + opacity: shouldBeActive ? 1 : 0 + scale: shouldBeActive ? 1 : 0.8 + visible: opacity > 0 + + Behavior on opacity { + Anim { + } + } + Behavior on scale { + Anim { + } + } + Behavior on y { + Anim { + } + } + + Rectangle { + anchors.fill: parent + anchors.margins: -Appearance.padding.smaller + color: DynamicColors.palette.m3primaryContainer + opacity: root.highlighted ? 0.5 : 0 + radius: Appearance.rounding.small + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.normal + } + } + } + + RowLayout { + id: row + + anchors.left: parent.left + anchors.margins: Appearance.padding.small + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + CustomText { + id: text + + Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter + Layout.fillWidth: true + font.pointSize: Appearance.font.size.larger + text: root.name + } + + IconButton { + id: iButton + + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + icon: "download" + + onClicked: { + const lockBg = `${Paths.state}/lockscreen_bg.png`; + const scheme = `${Paths.state}/scheme.json`; + const face = `${Paths.home}/.face`; + const destination = "/etc/zshell-greeter/images"; + Quickshell.execDetached(["pkexec", "sh", "-c", `mkdir -p ${destination}; cp ${lockBg} ${destination}; cp ${scheme} /etc/zshell-greeter; cp ${face} ${destination}`]); + } + } + } +} diff --git a/Modules/Wallpaper/Wallpaper.qml b/Modules/Wallpaper/Wallpaper.qml index 9fac581..476fd29 100644 --- a/Modules/Wallpaper/Wallpaper.qml +++ b/Modules/Wallpaper/Wallpaper.qml @@ -6,7 +6,7 @@ import qs.Modules.DesktopIcons Loader { active: Config.background.enabled - asynchronous: true + asynchronous: false sourceComponent: Variants { model: Quickshell.screens diff --git a/cli/pyproject.toml b/cli/pyproject.toml index 55204fb..ae332cc 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -9,6 +9,7 @@ version = "0.1.0" dependencies = [ "typer", "pillow", + "jinja2", "materialyoucolor" ] diff --git a/cli/src/zshell/__init__.py b/cli/src/zshell/__init__.py index 1b062a4..57ba1c6 100644 --- a/cli/src/zshell/__init__.py +++ b/cli/src/zshell/__init__.py @@ -1,15 +1,53 @@ from __future__ import annotations -import typer -from zshell.subcommands import shell, scheme, screenshot, wallpaper +import sys +from pathlib import Path -app = typer.Typer() +import click +import typer +from typer._completion_shared import install, _get_shell_name +from zshell.subcommands import shell, scheme, screenshot, wallpaper, record + +app = typer.Typer(name="zshell-cli", add_completion=False) app.add_typer(shell.app, name="shell") app.add_typer(scheme.app, name="scheme") app.add_typer(screenshot.app, name="screenshot") app.add_typer(wallpaper.app, name="wallpaper") -# app.add_typer(preset.app, name="preset") +app.add_typer(record.app, name="record") + + +def _completion_installed() -> bool: + shell = _get_shell_name() + match shell: + case "zsh": + return (Path.home() / ".zfunc" / "_zshell-cli").exists() + case "bash": + return (Path.home() / ".bash_completions" / "zshell-cli.sh").exists() + case "fish": + return (Path.home() / ".config" / "fish" / "completions" / "zshell-cli.fish").exists() + return False + + +def _install_completion() -> None: + if _completion_installed(): + click.echo("zshell-cli: Shell completion already installed.") + raise typer.Exit() + shell = _get_shell_name() + if shell is None: + click.echo("zshell-cli: Unable to detect shell type.", err=True) + raise typer.Exit(code=1) + try: + _, path = install(prog_name="zshell-cli") + click.secho(f"zshell-cli: Shell completion installed ({shell}: {path})", fg="green") + click.echo("zshell-cli: Restart your shell or source the file to enable tab-completion.") + except Exception: + pass def main() -> None: + if "--install-autocomplete" in sys.argv: + _install_completion() + return + if sys.stdout.isatty() and not _completion_installed(): + click.echo("zshell-cli: Tip: run with --install-autocomplete for tab completion.", err=True) app() diff --git a/cli/src/zshell/subcommands/__pycache__/scheme.cpython-313.pyc b/cli/src/zshell/subcommands/__pycache__/scheme.cpython-313.pyc deleted file mode 100644 index 71fa4f8..0000000 Binary files a/cli/src/zshell/subcommands/__pycache__/scheme.cpython-313.pyc and /dev/null differ diff --git a/cli/src/zshell/subcommands/__pycache__/scheme.cpython-314.pyc b/cli/src/zshell/subcommands/__pycache__/scheme.cpython-314.pyc deleted file mode 100644 index 010ddf9..0000000 Binary files a/cli/src/zshell/subcommands/__pycache__/scheme.cpython-314.pyc and /dev/null differ diff --git a/cli/src/zshell/subcommands/__pycache__/screenshot.cpython-313.pyc b/cli/src/zshell/subcommands/__pycache__/screenshot.cpython-313.pyc deleted file mode 100644 index 043fc1d..0000000 Binary files a/cli/src/zshell/subcommands/__pycache__/screenshot.cpython-313.pyc and /dev/null differ diff --git a/cli/src/zshell/subcommands/__pycache__/screenshot.cpython-314.pyc b/cli/src/zshell/subcommands/__pycache__/screenshot.cpython-314.pyc deleted file mode 100644 index ecd3450..0000000 Binary files a/cli/src/zshell/subcommands/__pycache__/screenshot.cpython-314.pyc and /dev/null differ diff --git a/cli/src/zshell/subcommands/__pycache__/shell.cpython-313.pyc b/cli/src/zshell/subcommands/__pycache__/shell.cpython-313.pyc deleted file mode 100644 index af12c92..0000000 Binary files a/cli/src/zshell/subcommands/__pycache__/shell.cpython-313.pyc and /dev/null differ diff --git a/cli/src/zshell/subcommands/__pycache__/shell.cpython-314.pyc b/cli/src/zshell/subcommands/__pycache__/shell.cpython-314.pyc deleted file mode 100644 index a47f91f..0000000 Binary files a/cli/src/zshell/subcommands/__pycache__/shell.cpython-314.pyc and /dev/null differ diff --git a/cli/src/zshell/subcommands/__pycache__/wallpaper.cpython-313.pyc b/cli/src/zshell/subcommands/__pycache__/wallpaper.cpython-313.pyc deleted file mode 100644 index e70c525..0000000 Binary files a/cli/src/zshell/subcommands/__pycache__/wallpaper.cpython-313.pyc and /dev/null differ diff --git a/cli/src/zshell/subcommands/__pycache__/wallpaper.cpython-314.pyc b/cli/src/zshell/subcommands/__pycache__/wallpaper.cpython-314.pyc deleted file mode 100644 index c888649..0000000 Binary files a/cli/src/zshell/subcommands/__pycache__/wallpaper.cpython-314.pyc and /dev/null differ diff --git a/cli/src/zshell/subcommands/record.py b/cli/src/zshell/subcommands/record.py new file mode 100644 index 0000000..00a9c07 --- /dev/null +++ b/cli/src/zshell/subcommands/record.py @@ -0,0 +1,210 @@ +import os +import json +import subprocess +import time +from pathlib import Path +from typing import Optional + +import typer + +app = typer.Typer() + +RECORDER = "gpu-screen-recorder" +HOME = str(os.getenv("HOME", str(Path.home()))) +CONFIG = Path(HOME) / ".config/zshell/config.json" + +STATE_DIR = Path(HOME) / ".local/state/zshell/record" +TEMP_RECORDING = STATE_DIR / "recording.mp4" +REPLAY_RECORDING = STATE_DIR / "replay.mp4" +NOTIF_ID_FILE = STATE_DIR / "notifid.txt" + +RECORDINGS_DIR = os.getenv("ZSHELL_RECORDINGS_DIR", str(Path(HOME) / "Videos/Recordings")) + + +def _read_extra_args() -> list[str]: + try: + if CONFIG.is_file(): + data = json.loads(CONFIG.read_text()) + return data.get("record", {}).get("extraArgs", []) + except Exception: + pass + return [] + + +def _is_recording() -> bool: + return subprocess.run(["pidof", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0 + + +def _notify(summary: str, body: str = "", actions: list | None = None, timeout: int = 5000) -> Optional[int]: + args = ["notify-send", summary, body, "-t", str(timeout), "-p"] + if actions: + for action in actions: + args.extend(["-A", action]) + try: + proc = subprocess.run(args, capture_output=True, text=True) + return int(proc.stdout.strip()) if proc.stdout.strip().isdigit() else None + except Exception: + return None + + +def _close_notification(notif_id: int): + subprocess.run(["notify-send", "--close", str(notif_id)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def _get_monitors() -> list[dict]: + try: + res = subprocess.run(["hyprctl", "monitors", "-j"], capture_output=True, text=True) + return json.loads(res.stdout) + except Exception: + return [] + + +def _focused_monitor_name() -> Optional[str]: + for m in _get_monitors(): + if m.get("focused"): + return m["name"] + return None + + +def _monitors_intersecting_region(x: int, y: int, w: int, h: int) -> list[dict]: + region = (x, y, x + w, y + h) + intersecting = [] + for m in _get_monitors(): + mx, my, mw, mh = m["x"], m["y"], m["width"], m["height"] + if not (region[2] <= mx or region[0] >= mx + mw or region[3] <= my or region[1] >= my + mh): + intersecting.append(m) + return intersecting + + +def _highest_refresh(monitors: list[dict]) -> float: + return max((m["refreshRate"] for m in monitors), default=60.0) + + +def _slurp_region() -> Optional[str]: + try: + return subprocess.check_output(["slurp", "-f", "%wx%h+%x+%y"], text=True).strip() + except subprocess.CalledProcessError: + return None + + +def _parse_geometry(geometry: str) -> Optional[tuple[int, int, int, int]]: + import re + + match = re.match(r"(\d+)x(\d+)\+(\d+)\+(\d+)", geometry) + if match: + return int(match.group(3)), int(match.group(4)), int(match.group(1)), int(match.group(2)) + return None + + +def start_recording(region: Optional[str], sound: bool): + STATE_DIR.mkdir(parents=True, exist_ok=True) + cmd = [RECORDER] + extra_args = _read_extra_args() + + if region: + if region.lower() == "slurp" or not region: + geometry = _slurp_region() + if not geometry: + typer.echo("Region selection cancelled.") + raise typer.Abort() + else: + geometry = region + + parsed = _parse_geometry(geometry) + if not parsed: + typer.echo("Invalid geometry format.") + raise typer.Abort() + x, y, w, h = parsed + + monitors = _monitors_intersecting_region(x, y, w, h) + framerate = _highest_refresh(monitors) + cmd.extend(["-w", "region", "-region", geometry, "-f", str(int(framerate))]) + + else: + monitor_name = _focused_monitor_name() + if not monitor_name: + typer.echo("No focused monitor found.") + raise typer.Abort() + + monitors = _get_monitors() + mon = next((m for m in monitors if m["name"] == monitor_name), None) + rate = int(mon["refreshRate"]) if mon else 60 + cmd.extend(["-w", monitor_name, "-f", str(rate)]) + + if sound: + cmd.extend(["-a", "default_output"]) + + cmd.extend(extra_args) + cmd.extend(["-o", str(TEMP_RECORDING)]) + + subprocess.Popen(cmd, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + notif_id = _notify("Recording started", f"Saving to {TEMP_RECORDING}") + if notif_id is not None: + NOTIF_ID_FILE.write_text(str(notif_id)) + + time.sleep(1) + if not _is_recording(): + _notify("Recording failed", "Check gpu-screen-recorder output.", timeout=5000) + raise typer.Exit(code=1) + + +def stop_recording(clipboard: bool): + subprocess.run(["pkill", "-f", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + for _ in range(50): + if not _is_recording(): + break + time.sleep(0.1) + + dest_dir = Path(RECORDINGS_DIR) + dest_dir.mkdir(parents=True, exist_ok=True) + timestamp = time.strftime("%Y-%m-%d_%H-%M-%S") + final_path = dest_dir / f"recording_{timestamp}.mp4" + + if TEMP_RECORDING.exists(): + TEMP_RECORDING.rename(final_path) + + if NOTIF_ID_FILE.is_file(): + try: + _close_notification(int(NOTIF_ID_FILE.read_text().strip())) + except Exception: + pass + NOTIF_ID_FILE.unlink() + + if clipboard: + subprocess.run( + ["wl-copy", "--type", "text/uri-list", f"file://{final_path}"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + _notify("Recording stopped", f"Saved to {final_path}", timeout=5000) + + +def toggle_pause(): + subprocess.run(["pkill", "-USR2", "-f", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + typer.echo("Toggled pause.") + + +@app.command() +def record( + region: Optional[str] = typer.Option( + None, + "--region", + "-r", + help="Record a region. Use 'slurp' (or omit value) to select interactively, or give 'WxH+X+Y'.", + ), + sound: bool = typer.Option(False, "--sound", "-s", help="Record audio from default output."), + pause: bool = typer.Option(False, "--pause", "-p", help="Toggle pause/resume."), + clipboard: bool = typer.Option(False, "--clipboard", "-c", help="Copy the final recording path to clipboard."), +): + """Start or stop a screen recording with gpu-screen-recorder.""" + if pause: + toggle_pause() + raise typer.Exit() + + if _is_recording(): + stop_recording(clipboard) + else: + start_recording(region, sound) diff --git a/cli/src/zshell/subcommands/scheme.py b/cli/src/zshell/subcommands/scheme.py index ef2a303..8cb1feb 100644 --- a/cli/src/zshell/subcommands/scheme.py +++ b/cli/src/zshell/subcommands/scheme.py @@ -2,6 +2,7 @@ import typer import json import shutil import os +import sys import re import subprocess @@ -15,11 +16,61 @@ from materialyoucolor.score.score import Score from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors from materialyoucolor.hct.hct import Hct from materialyoucolor.utils.color_utils import argb_from_rgb -from materialyoucolor.utils.math_utils import difference_degrees, rotation_direction, sanitize_degrees_double +from materialyoucolor.utils.math_utils import ( + difference_degrees, + rotation_direction, + sanitize_degrees_double, +) app = typer.Typer() +def _complete_scheme_name(incomplete): + schemes = [ + "fruit-salad", + "expressive", + "monochrome", + "rainbow", + "tonal-spot", + "neutral", + "fidelity", + "content", + "vibrant", + ] + return [s for s in schemes if incomplete in s] + + +def _complete_preset(incomplete): + results = [] + for sid, meta in list_schemes().items(): + for v in meta.variants: + preset = f"{sid}:{v.id}" + if incomplete in preset: + results.append((preset, f"{meta.name} - {v.name}")) + return results + + +def _complete_mode(incomplete): + return [m for m in ("dark", "light") if incomplete in m] + + +def _complete_accent(ctx, incomplete): + preset_val = ctx.params.get("preset") + if preset_val: + try: + p_scheme, p_variant = resolve_preset(preset_val) + for v in list_schemes()[p_scheme].variants: + if v.id == p_variant: + return [a for a in v.accents if incomplete in a] + except (ValueError, KeyError): + pass + all_accents = set() + for meta in list_schemes().values(): + for v in meta.variants: + all_accents.update(v.accents) + return [a for a in sorted(all_accents) if incomplete in a] + + @app.command() def list_presets( json_format: bool = typer.Option(False, "--json", help="Output in JSON format"), @@ -30,7 +81,7 @@ def list_presets( for sid, meta in sorted(schemes.items()): variants = {} for v in meta.variants: - entry = {"modes": sorted(v.modes)} + entry: dict[str, Any] = {"modes": sorted(v.modes)} if v.accents: entry["accents"] = sorted(v.accents) entry["default_accent"] = sorted(v.accents)[0] @@ -55,14 +106,35 @@ def list_presets( @app.command() def generate( - image_path: Optional[Path] = typer.Option(None, help="Path to source image. Required for image mode."), - scheme: Optional[str] = typer.Option( - None, help="Color scheme algorithm to use for image mode. Ignored in preset mode." + image_path: Optional[Path] = typer.Option( + None, help="Path to source image. Required for image mode." + ), + scheme: Optional[str] = typer.Option( + None, + help="Color scheme algorithm to use for image mode. Ignored in preset mode.", + autocompletion=_complete_scheme_name, + ), + preset: Optional[str] = typer.Option( + None, + help="Name of a premade scheme in this format: :", + autocompletion=_complete_preset, + ), + mode: Optional[str] = typer.Option( + None, + help="Mode of the preset scheme (dark or light).", + autocompletion=_complete_mode, + ), + accent: Optional[str] = typer.Option( + None, + help="Accent for schemes that support it (e.g. mauve).", + autocompletion=_complete_accent, ), - preset: Optional[str] = typer.Option(None, help="Name of a premade scheme in this format: :"), - mode: Optional[str] = typer.Option(None, help="Mode of the preset scheme (dark or light)."), - accent: Optional[str] = typer.Option(None, help="Accent for schemes that support it (e.g. mauve)."), ): + if not any([image_path, scheme, preset, mode, accent]): + print( + "Hint: use --preset : or --image-path ", + file=sys.stderr, + ) HOME = str(os.getenv("HOME")) OUTPUT = Path(HOME + "/.local/state/zshell/scheme.json") @@ -200,11 +272,15 @@ def generate( def harmonize(from_hct: Hct, to_hct: Hct, tone_boost: float) -> Hct: diff = difference_degrees(from_hct.hue, to_hct.hue) rotation = min(diff * 0.8, 100) - output_hue = sanitize_degrees_double(from_hct.hue + rotation * rotation_direction(from_hct.hue, to_hct.hue)) + output_hue = sanitize_degrees_double( + from_hct.hue + rotation * rotation_direction(from_hct.hue, to_hct.hue) + ) tone = max(0.0, min(100.0, from_hct.tone * (1 + tone_boost))) return Hct.from_hct(output_hue, from_hct.chroma, tone) - def terminal_palette(colors: dict[str, str], mode: str, variant: str) -> dict[str, str]: + def terminal_palette( + colors: dict[str, str], mode: str, variant: str + ) -> dict[str, str]: light = mode.lower() == "light" key_hex = ( @@ -236,7 +312,7 @@ def generate( image = Image.open(image_path) image = image.convert("RGB") - image.thumbnail(size, Image.NEAREST) + image.thumbnail(size, Image.Resampling.NEAREST) thumbnail_file.parent.mkdir(parents=True, exist_ok=True) image.save(thumbnail_path, "JPEG") @@ -268,8 +344,15 @@ def generate( is_dark = "" with Image.open(image_path) as img: - img.thumbnail((1, 1), Image.LANCZOS) - hct = Hct.from_int(argb_from_rgb(*img.getpixel((0, 0)))) + img.thumbnail((1, 1), Image.Resampling.LANCZOS) + px = img.getpixel((0, 0)) + if isinstance(px, (int, float)): + r = g = b = int(px) + elif px is not None: + r, g, b = int(px[0]), int(px[1]), int(px[2]) + else: + r = g = b = 0 + hct = Hct.from_int(argb_from_rgb(r, g, b)) is_dark = "light" if hct.tone > 50 else "dark" return is_dark @@ -431,6 +514,8 @@ def generate( raw = tpl_path.read_text(encoding="utf-8") out_path, body = split_directive_and_body(raw) + if out_path is None: + continue out_path.parent.mkdir(parents=True, exist_ok=True) @@ -484,23 +569,30 @@ def generate( with CONFIG.open() as f: config = json.load(f) - scheme = scheme or config["colors"]["schemeType"] + scheme_type = config["colors"].get("schemeType", "fruit-salad") + scheme = scheme or scheme_type + assert isinstance(scheme, str) config_mode = config["general"]["color"]["mode"] smart = bool(config["general"]["color"].get("smart", False)) scheme_class = get_scheme_class(scheme) + p_variant = "default" if preset: p_scheme, p_variant = resolve_preset(preset) schemes = list_schemes() if accent and p_scheme in schemes: meta = schemes[p_scheme] - var_accents = next((v.accents for v in meta.variants if v.id == p_variant), ()) + var_accents = next( + (v.accents for v in meta.variants if v.id == p_variant), () + ) if accent not in var_accents: available = ", ".join(var_accents) if var_accents else "none" raise typer.BadParameter( f"Accent '{accent}' not available for '{p_scheme}:{p_variant}'. Available accents: {available}" ) - palette_obj = get_palette(p_scheme, p_variant, mode or config_mode, accent=accent) + palette_obj = get_palette( + p_scheme, p_variant, mode or config_mode, accent=accent + ) colors = palette_obj.colors effective_mode = palette_obj.mode name = palette_obj.scheme diff --git a/cli/src/zshell/subcommands/shell.py b/cli/src/zshell/subcommands/shell.py index c6a8587..4b30da7 100644 --- a/cli/src/zshell/subcommands/shell.py +++ b/cli/src/zshell/subcommands/shell.py @@ -1,4 +1,8 @@ import subprocess +import sys +import time + +import click import typer args = ["qs", "-c", "zshell"] @@ -8,35 +12,68 @@ app = typer.Typer() @app.command() def kill(): - subprocess.run(args + ["kill"], check=True) + result = subprocess.run(args + ["kill"], capture_output=True) + if result.returncode != 0: + raise click.ClickException("No running instance to kill.") + sys.stderr.write(result.stderr.decode()) + + +def start_instance(no_daemon: bool = False) -> None: + result = subprocess.run(args + ["-n"] + ([] if no_daemon else ["-d"]), capture_output=True) + stdout = result.stdout.decode().strip() + if stdout: + if "already running" in stdout.lower(): + raise click.ClickException(stdout) + if result.returncode != 0: + stderr = result.stderr.decode().strip() + raise click.ClickException(stderr) @app.command() def start(no_daemon: bool = False): - subprocess.run(args + ["-n"] + ([] if no_daemon else ["-d"]), check=True) + start_instance(no_daemon) @app.command() def restart(no_daemon: bool = False): - subprocess.run(args + ["kill"], check=False) - subprocess.run(args + ["-n"] + ([] if no_daemon else ["-d"]), check=True) + subprocess.run(args + ["kill"], capture_output=True) + deadline = time.monotonic() + 2.5 + while time.monotonic() < deadline: + result = subprocess.run(args + ["kill"], capture_output=True) + if result.returncode == 255: + break + time.sleep(0.25) + start_instance(no_daemon=no_daemon) @app.command() def show(): - subprocess.run(args + ["ipc"] + ["show"], check=True) + result = subprocess.run(args + ["ipc"] + ["show"], capture_output=True) + if result.returncode != 0: + raise click.ClickException(result.stderr.decode().strip()) + sys.stderr.write(result.stderr.decode()) @app.command() def log(): - subprocess.run(args + ["log"], check=True) + result = subprocess.run(args + ["log"], capture_output=True) + if result.returncode != 0: + raise click.ClickException(result.stderr.decode().strip()) + sys.stdout.write(result.stdout.decode()) + sys.stderr.write(result.stderr.decode()) @app.command() def lock(): - subprocess.run(args + ["ipc"] + ["call"] + ["lock"] + ["lock"], check=True) + result = subprocess.run(args + ["ipc"] + ["call"] + ["lock"] + ["lock"], capture_output=True) + if result.returncode != 0: + raise click.ClickException(result.stderr.decode().strip()) + sys.stderr.write(result.stderr.decode()) @app.command() def call(target: str, method: str, method_args: list[str] = typer.Argument(None)): - subprocess.run(args + ["ipc"] + ["call"] + [target] + [method] + (method_args or []), check=True) + result = subprocess.run(args + ["ipc"] + ["call"] + [target] + [method] + (method_args or []), capture_output=True) + if result.returncode != 0: + raise click.ClickException(result.stderr.decode().strip()) + sys.stderr.write(result.stderr.decode()) diff --git a/cli/src/zshell/subcommands/wallpaper.py b/cli/src/zshell/subcommands/wallpaper.py index b803b54..58f6c85 100644 --- a/cli/src/zshell/subcommands/wallpaper.py +++ b/cli/src/zshell/subcommands/wallpaper.py @@ -34,9 +34,9 @@ def lockscreen( return if size[0] < 3840 or size[1] < 2160: - img = img.resize((size[0] // 2, size[1] // 2), Image.NEAREST) + img = img.resize((size[0] // 2, size[1] // 2), Image.Resampling.NEAREST) else: - img = img.resize((size[0] // 4, size[1] // 4), Image.NEAREST) + img = img.resize((size[0] // 4, size[1] // 4), Image.Resampling.NEAREST) img = img.filter(ImageFilter.GaussianBlur(blur_amount)) diff --git a/cli/src/zshell/utils/__pycache__/schemepalettes.cpython-313.pyc b/cli/src/zshell/utils/__pycache__/schemepalettes.cpython-313.pyc deleted file mode 100644 index 4f3c04b..0000000 Binary files a/cli/src/zshell/utils/__pycache__/schemepalettes.cpython-313.pyc and /dev/null differ diff --git a/cli/tests/test_shell.py b/cli/tests/test_shell.py index feb292c..1763247 100644 --- a/cli/tests/test_shell.py +++ b/cli/tests/test_shell.py @@ -1,13 +1,15 @@ from __future__ import annotations +from subprocess import CompletedProcess from unittest.mock import patch, call + +from typer.testing import CliRunner from zshell.subcommands.shell import app +runner = CliRunner() -def invoke(*args: str) -> None: - from typer.testing import CliRunner - runner = CliRunner() +def invoke(*args: str): result = runner.invoke(app, args) if result.exit_code != 0: raise RuntimeError(result.output) @@ -16,72 +18,113 @@ def invoke(*args: str) -> None: class TestKill: @patch("zshell.subcommands.shell.subprocess.run") - def test_kill_runs_qs_kill(self, mock_run): + def test_kill_runs_qs_kill_success(self, mock_run): + mock_run.return_value = CompletedProcess([], 0, b"", b"Killed abc\n") invoke("kill") - mock_run.assert_called_once_with(["qs", "-c", "zshell", "kill"], check=True) + mock_run.assert_called_once_with(["qs", "-c", "zshell", "kill"], capture_output=True) + + @patch("zshell.subcommands.shell.subprocess.run") + def test_kill_no_instance_errors(self, mock_run): + mock_run.return_value = CompletedProcess([], 255, b"", b"No running instances\n") + result = runner.invoke(app, ["kill"]) + assert result.exit_code != 0 + assert "No running instance to kill" in result.output class TestStart: @patch("zshell.subcommands.shell.subprocess.run") def test_start_default_daemon(self, mock_run): + mock_run.return_value = CompletedProcess([], 0, b"", b"Launching config\n") invoke("start") - mock_run.assert_called_once_with(["qs", "-c", "zshell", "-n", "-d"], check=True) + mock_run.assert_called_once_with(["qs", "-c", "zshell", "-n", "-d"], capture_output=True) @patch("zshell.subcommands.shell.subprocess.run") def test_start_no_daemon(self, mock_run): + mock_run.return_value = CompletedProcess([], 0, b"", b"Launching config\n") invoke("start", "--no-daemon") - mock_run.assert_called_once_with(["qs", "-c", "zshell", "-n"], check=True) + mock_run.assert_called_once_with(["qs", "-c", "zshell", "-n"], capture_output=True) + + @patch("zshell.subcommands.shell.subprocess.run") + def test_start_already_running_errors(self, mock_run): + mock_run.return_value = CompletedProcess([], 0, b"An instance of this configuration is already running.\n", b"") + result = runner.invoke(app, ["start"]) + assert result.exit_code != 0 + assert "already running" in result.output + + @patch("zshell.subcommands.shell.subprocess.run") + def test_start_other_failure_errors(self, mock_run): + mock_run.return_value = CompletedProcess([], 1, b"", b"Config error\n") + result = runner.invoke(app, ["start"]) + assert result.exit_code != 0 + assert "Config error" in result.output class TestShow: @patch("zshell.subcommands.shell.subprocess.run") def test_show_runs_ipc_show(self, mock_run): + mock_run.return_value = CompletedProcess([], 0, b"", b"target visibilities\n") invoke("show") - mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "show"], check=True) + mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "show"], capture_output=True) class TestLog: @patch("zshell.subcommands.shell.subprocess.run") def test_log_runs_qs_log(self, mock_run): + mock_run.return_value = CompletedProcess([], 0, b"log output\n", b"") invoke("log") - mock_run.assert_called_once_with(["qs", "-c", "zshell", "log"], check=True) + mock_run.assert_called_once_with(["qs", "-c", "zshell", "log"], capture_output=True) class TestLock: @patch("zshell.subcommands.shell.subprocess.run") def test_lock_runs_ipc_call_lock(self, mock_run): + mock_run.return_value = CompletedProcess([], 0, b"", b"") invoke("lock") - mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "call", "lock", "lock"], check=True) + mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "call", "lock", "lock"], capture_output=True) class TestCall: @patch("zshell.subcommands.shell.subprocess.run") def test_call_no_args(self, mock_run): + mock_run.return_value = CompletedProcess([], 0, b"", b"") invoke("call", "target", "method") - mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "call", "target", "method"], check=True) + mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "call", "target", "method"], capture_output=True) @patch("zshell.subcommands.shell.subprocess.run") def test_call_with_args(self, mock_run): + mock_run.return_value = CompletedProcess([], 0, b"", b"") invoke("call", "target", "method", "arg1", "arg2") mock_run.assert_called_once_with( ["qs", "-c", "zshell", "ipc", "call", "target", "method", "arg1", "arg2"], - check=True, + capture_output=True, ) class TestRestart: + @patch("zshell.subcommands.shell.start_instance") @patch("zshell.subcommands.shell.subprocess.run") - def test_restart_kills_then_starts_daemon(self, mock_run): + def test_restart_kills_then_starts(self, mock_run, mock_start): + mock_run.side_effect = [ + CompletedProcess([], 0, b"", b"Killed abc\n"), # first kill (captured) + CompletedProcess([], 255, b"", b""), # poll → no instance + ] invoke("restart") assert mock_run.call_args_list == [ - call(["qs", "-c", "zshell", "kill"], check=False), - call(["qs", "-c", "zshell", "-n", "-d"], check=True), + call(["qs", "-c", "zshell", "kill"], capture_output=True), + call(["qs", "-c", "zshell", "kill"], capture_output=True), ] + mock_start.assert_called_once_with(no_daemon=False) + @patch("zshell.subcommands.shell.start_instance") @patch("zshell.subcommands.shell.subprocess.run") - def test_restart_no_daemon(self, mock_run): + def test_restart_no_daemon(self, mock_run, mock_start): + mock_run.side_effect = [ + CompletedProcess([], 0, b"", b"Killed abc\n"), + CompletedProcess([], 255, b"", b""), + ] invoke("restart", "--no-daemon") assert mock_run.call_args_list == [ - call(["qs", "-c", "zshell", "kill"], check=False), - call(["qs", "-c", "zshell", "-n"], check=True), + call(["qs", "-c", "zshell", "kill"], capture_output=True), + call(["qs", "-c", "zshell", "kill"], capture_output=True), ] + mock_start.assert_called_once_with(no_daemon=True) diff --git a/shell.qml b/shell.qml index 892321c..599b41b 100644 --- a/shell.qml +++ b/shell.qml @@ -1,19 +1,27 @@ //@ pragma UseQApplication //@ pragma Env QSG_RENDER_LOOP=threaded -// @ pragma Env QSG_RHI_BACKEND=vulkan +//@ pragma Env QSG_RHI_BACKEND=vulkan //@ pragma Env QSG_NO_VSYNC=1 //@ pragma Env QS_NO_RELOAD_POPUP=1 //@ pragma Env QT_SCALE_FACTOR_ROUNDING_POLICY=Round //@ pragma DropExpensiveFonts import Quickshell +import Quickshell.Services.UPower import qs.Modules import qs.Modules.Wallpaper import qs.Modules.Lock import qs.Drawers import qs.Helpers import qs.Modules.Polkit +import qs.Daemons ShellRoot { + id: root + + readonly property bool laptop: UPower.displayDevice.isLaptopBattery + + settings.watchFiles: true + Windows { } @@ -36,4 +44,11 @@ ShellRoot { Polkit { } + + LazyLoader { + activeAsync: root.laptop + + component: Battery { + } + } }