diff options
43 files changed, 4251 insertions, 29 deletions
@@ -27,6 +27,9 @@ firefox-addons.url = "gitlab:rycee/nur-expressions?dir=pkgs/firefox-addons"; firefox-addons.inputs.nixpkgs.follows = "nixpkgs"; + + quickshell.url = "git+https://git.outfoxxed.me/quickshell/quickshell"; + quickshell.inputs.nixpkgs.follows = "nixpkgs"; }; outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } (inputs.import-tree ./modules); diff --git a/modules/system/quickshell.nix b/modules/system/quickshell.nix new file mode 100644 index 0000000..766c250 --- /dev/null +++ b/modules/system/quickshell.nix @@ -0,0 +1,39 @@ +{ inputs, ... }: +{ + flake.modules.homeManager.quickshell = + { pkgs, config, ... }: + { + home.packages = [ + pkgs.inter + pkgs.brightnessctl + pkgs.adwaita-icon-theme + pkgs.hicolor-icon-theme + ]; + + programs.quickshell = { + enable = true; + package = inputs.quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default; + activeConfig = "bar"; + configs.bar = ./quickshell; + systemd.enable = true; + }; + + systemd.user.services.quickshell = { + Service.Environment = [ + "PATH=${pkgs.sway}/bin:${pkgs.pipewire}/bin:${pkgs.wireplumber}/bin:${pkgs.brightnessctl}/bin:${config.home.profileDirectory}/bin:/run/current-system/sw/bin" + "XDG_DATA_DIRS=${pkgs.adwaita-icon-theme}/share:${pkgs.hicolor-icon-theme}/share:${config.home.profileDirectory}/share:/run/current-system/sw/share" + ]; + }; + }; + + flake.modules.nixos.quickshell = + { pkgs, ... }: + { + security.pam.services.quickshell = { + text = '' + auth required pam_unix.so + account required pam_unix.so + ''; + }; + }; +} diff --git a/modules/system/quickshell/Background.qml b/modules/system/quickshell/Background.qml new file mode 100644 index 0000000..2bf9ae6 --- /dev/null +++ b/modules/system/quickshell/Background.qml @@ -0,0 +1,21 @@ +import Quickshell +import Quickshell.Wayland +import QtQuick + +PanelWindow { + WlrLayershell.layer: WlrLayer.Background + WlrLayershell.exclusiveZone: -1 + + anchors { + top: true + bottom: true + left: true + right: true + } + + Image { + anchors.fill: parent + source: "./wallpaper.jpg" + fillMode: Image.PreserveAspectCrop + } +} diff --git a/modules/system/quickshell/Bar.qml b/modules/system/quickshell/Bar.qml new file mode 100644 index 0000000..77cfd1e --- /dev/null +++ b/modules/system/quickshell/Bar.qml @@ -0,0 +1,108 @@ +import Quickshell +import Quickshell.Wayland +import Quickshell.Services.SystemTray +import QtQuick + +PanelWindow { + id: barWindow + + WlrLayershell.layer: WlrLayer.Top + + anchors { + top: true + left: true + right: true + } + + implicitHeight: Theme.barHeight + color: Theme.barBg + + Item { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + + Workspaces { + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + } + } + + Row { + anchors { + right: parent.right + verticalCenter: parent.verticalCenter + } + spacing: 14 + + Repeater { + model: SystemTray.items + + delegate: Image { + id: trayIcon + required property SystemTrayItem modelData + + width: 16 + height: 16 + anchors.verticalCenter: parent.verticalCenter + source: modelData.icon + sourceSize: Qt.size(width, height) + smooth: true + mipmap: true + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: mouse => { + if (mouse.button === Qt.RightButton) { + if (modelData.hasMenu && modelData.menu) { + trayMenu.menuItem = modelData.menu; + trayMenu.open(trayIcon); + } + } else { + modelData.activate() + } + } + } + } + } + + TrayMenu { + id: trayMenu + parentWindow: barWindow + } + + Bluetooth { anchors.verticalCenter: parent.verticalCenter } + + Wifi { anchors.verticalCenter: parent.verticalCenter } + + Volume { anchors.verticalCenter: parent.verticalCenter } + + Media { anchors.verticalCenter: parent.verticalCenter } + + ControlCenter { anchors.verticalCenter: parent.verticalCenter } + + Text { + id: clock + anchors.verticalCenter: parent.verticalCenter + color: Theme.text + font { + family: Theme.mainFont + pixelSize: 13 + weight: Font.Medium + } + + Timer { + interval: 1000 + running: true + repeat: true + onTriggered: parent.text = Qt.formatDateTime(new Date(), "ddd d MMM HH:mm:ss") + } + + Component.onCompleted: text = Qt.formatDateTime(new Date(), "ddd d MMM HH:mm:ss") + } + } + } +} + diff --git a/modules/system/quickshell/Bluetooth.qml b/modules/system/quickshell/Bluetooth.qml new file mode 100644 index 0000000..17091ff --- /dev/null +++ b/modules/system/quickshell/Bluetooth.qml @@ -0,0 +1,352 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Widgets +import QtQuick.Effects // Required for icon tinting + +Item { + id: root + + Scope { + id: internal + readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter + + readonly property var allDevices: adapter ? adapter.devices.values : [] + + property bool hasPaired: false + property bool hasNewVisible: false + + function updateState() { + let paired = false + let newVisible = false + + for (const d of internal.allDevices) { + if (d?.paired) paired = true + if (d && !d.paired && d.name) newVisible = true + } + + internal.hasPaired = paired + internal.hasNewVisible = newVisible + } + } + + width: childrenRect.width + height: parent.height + + Row { + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + Image { + width: 20 + height: 20 + source: Quickshell.iconPath(internal.adapter?.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic") + sourceSize: Qt.size(width, height) + smooth: true + mipmap: true + opacity: internal.adapter?.enabled ? 1.0 : 0.5 + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + GlobalState.toggle("Bluetooth") + } else if (mouse.button === Qt.RightButton && internal.adapter) { + internal.adapter.enabled = !internal.adapter.enabled + } + } + } + + PopupWindow { + id: popup + visible: GlobalState.activePopup === "Bluetooth" + grabFocus: true + implicitWidth: card.width + implicitHeight: card.height + + anchor { + window: barWindow + item: root + edges: Edges.Bottom + gravity: Edges.Bottom + margins.top: Theme.popupGap + } + + color: Theme.transparent + + onVisibleChanged: { + if (visible) { + anchor.updateAnchor() + if (internal.adapter) internal.adapter.discovering = true + } else if (internal.adapter?.discovering) { + internal.adapter.discovering = false + } + } + + Connections { + target: internal.adapter ? internal.adapter.devices : null + function onValuesChanged() { internal.updateState() } + } + + PopupCard { + id: card + + // Main Toggle Header + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: "Bluetooth" + color: Theme.text + font { + family: Theme.mainFont + pixelSize: 14 + weight: Font.DemiBold + } + } + + Item { Layout.fillWidth: true } + + Toggle { + checked: internal.adapter?.enabled ?? false + enabled: internal.adapter !== null + onToggled: internal.adapter.enabled = !internal.adapter.enabled + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.border + } + + // Paired Devices Header + Text { + visible: internal.hasPaired + text: "My Devices" // macOS typically labels this "My Devices" + color: Theme.textMuted + font { + family: Theme.mainFont + pixelSize: 12 + weight: Font.DemiBold + } + } + + // Paired Devices List + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + visible: internal.hasPaired + + Repeater { + model: internal.adapter ? internal.adapter.devices : null + + delegate: Squircle { + id: pairedItem + required property BluetoothDevice modelData + readonly property bool hovered: pairedArea.containsMouse + visible: pairedItem.modelData?.paired ?? false + Layout.fillWidth: true + Layout.preferredHeight: visible ? 36 : 0 + fillColor: hovered ? Theme.surface : Theme.transparent + cornerRadius: 6 + + Connections { + target: pairedItem.modelData + function onPairedChanged() { internal.updateState() } + function onConnectedChanged() { internal.updateState() } + function onNameChanged() { internal.updateState() } + } + Component.onCompleted: internal.updateState() + Component.onDestruction: internal.updateState() + + MouseArea { + id: pairedArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + const d = pairedItem.modelData + if (d.connected) d.disconnect() + else d.connect() + } + } + + RowLayout { + anchors { + fill: parent + margins: 8 + } + spacing: 10 + + // macOS uses bare icons in the list, tinted accent color when connected + Item { + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + + Image { + id: deviceIcon + anchors.fill: parent + source: Quickshell.iconPath("bluetooth-active-symbolic") + sourceSize: Qt.size(20, 20) + visible: false + } + + MultiEffect { + anchors.fill: deviceIcon + source: deviceIcon + colorizationColor: (pairedItem.modelData?.connected ?? false) ? Theme.accent : Theme.text + colorization: 1.0 + } + } + + Text { + Layout.fillWidth: true + text: pairedItem.modelData?.name || pairedItem.modelData?.deviceName || pairedItem.modelData?.address || "" + color: Theme.text + font { + family: Theme.mainFont + pixelSize: 13 + } + elide: Text.ElideRight + } + + // macOS displays connection status text instead of a toggle/button + Text { + text: (pairedItem.modelData?.connected ?? false) ? "Connected" : "Not Connected" + color: Theme.textMuted + font { + family: Theme.mainFont + pixelSize: 11 + } + } + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.border + visible: internal.hasPaired + } + + // Other Devices Header + RowLayout { + Layout.fillWidth: true + + Text { + text: "Other Devices" + color: Theme.textMuted + font { + family: Theme.mainFont + pixelSize: 12 + weight: Font.DemiBold + } + Layout.fillWidth: true + } + + // Passive scanning indicator instead of interactive refresh button + Image { + width: 14 + height: 14 + source: Quickshell.iconPath("process-working-symbolic") + sourceSize: Qt.size(width, height) + smooth: true + mipmap: true + visible: internal.adapter?.discovering ?? false + opacity: 0.6 + + RotationAnimation on rotation { + running: parent.visible + from: 0 + to: 360 + duration: 1000 + loops: Animation.Infinite + } + } + } + + // Unpaired Devices List + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + visible: internal.hasNewVisible + + Repeater { + model: internal.adapter ? internal.adapter.devices : null + + delegate: Squircle { + id: newItem + required property BluetoothDevice modelData + visible: !(newItem.modelData?.paired ?? true) && (newItem.modelData?.name ?? "") !== "" + Layout.fillWidth: true + Layout.preferredHeight: visible ? 36 : 0 + fillColor: newArea.containsMouse ? Theme.surface : Theme.transparent + cornerRadius: 6 + + Connections { + target: newItem.modelData + function onPairedChanged() { internal.updateState() } + function onNameChanged() { internal.updateState() } + } + Component.onCompleted: internal.updateState() + Component.onDestruction: internal.updateState() + + MouseArea { + id: newArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !(newItem.modelData?.pairing ?? false) + onClicked: newItem.modelData.pair() + } + + RowLayout { + anchors { + fill: parent + margins: 8 + } + spacing: 10 + + Image { + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + source: Quickshell.iconPath("bluetooth-active-symbolic") + sourceSize: Qt.size(20, 20) + opacity: 0.6 + } + + Text { + Layout.fillWidth: true + text: newItem.modelData?.name || newItem.modelData?.address || "" + color: Theme.text + font { + family: Theme.mainFont + pixelSize: 13 + } + elide: Text.ElideRight + } + + // Replace + icon with "Connecting..." text when pairing + Text { + visible: newItem.modelData?.pairing ?? false + text: "Connecting..." + color: Theme.textMuted + font { + family: Theme.mainFont + pixelSize: 11 + } + } + } + } + } + } + } + } +} diff --git a/modules/system/quickshell/BrightnessService.qml b/modules/system/quickshell/BrightnessService.qml new file mode 100644 index 0000000..991a6ec --- /dev/null +++ b/modules/system/quickshell/BrightnessService.qml @@ -0,0 +1,36 @@ +import QtQuick +import Quickshell +import Quickshell.Io + +// Simplified brightness service using brightnessctl. +QtObject { + id: root + property real brightness: 0 + property int maxBrightness: 1 + + function update() { + updateProc.running = true + } + + function setBrightness(value) { + const raw = Math.round(value * maxBrightness) + Quickshell.execDetached(["brightnessctl", "s", raw.toString()]) + brightness = value + } + + readonly property Process updateProc: Process { + command: ["sh", "-c", "brightnessctl g && brightnessctl m"] + stdout: SplitParser { + onRead: data => { + const lines = data.trim().split("\n") + if (lines.length >= 2) { + const current = parseInt(lines[0]) + root.maxBrightness = parseInt(lines[1]) + root.brightness = current / root.maxBrightness + } + } + } + } + + Component.onCompleted: update() +} diff --git a/modules/system/quickshell/ConnectivityBox.qml b/modules/system/quickshell/ConnectivityBox.qml new file mode 100644 index 0000000..e1fb03f --- /dev/null +++ b/modules/system/quickshell/ConnectivityBox.qml @@ -0,0 +1,121 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Networking +import Quickshell.Bluetooth + +Squircle { + id: root + cornerRadius: 16 + fillColor: Theme.surface + Layout.fillWidth: true + Layout.preferredHeight: 140 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 0 // Using spacers for better control + + // WiFi Row + Item { + Layout.fillWidth: true + height: 50 + scale: wifiArea.pressed ? 0.96 : 1.0 + Behavior on scale { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } + + RowLayout { + anchors.fill: parent + spacing: 12 + + IconCircle { + source: "network-wireless" + active: Networking.wifiEnabled + size: 32 + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { + spacing: 0 + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + Text { + text: "Wi-Fi" + color: Theme.text + font.pixelSize: 13 + font.weight: Font.DemiBold + } + Text { + text: { + const vs = Networking.devices?.values || [] + for (const device of vs) { + if (device.scannerEnabled !== undefined) { + const nets = device.networks?.values || [] + for (const n of nets) if (n.connected) return n.name + } + } + return Networking.wifiEnabled ? "On" : "Off" + } + color: Theme.textMuted + font.pixelSize: 12 + font.weight: Font.Medium + elide: Text.ElideRight + Layout.maximumWidth: 80 + } + } + } + + MouseArea { + id: wifiArea + anchors.fill: parent + onClicked: Qt.callLater(() => GlobalState.open("Wifi")) + } + } + + Item { Layout.fillHeight: true } // Spacer + + // Bluetooth Row + Item { + Layout.fillWidth: true + height: 50 + scale: btArea.pressed ? 0.96 : 1.0 + Behavior on scale { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } + + RowLayout { + anchors.fill: parent + spacing: 12 + + IconCircle { + source: "bluetooth-active" + active: Bluetooth.defaultAdapter?.enabled ?? false + size: 32 + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { + spacing: 0 + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + Text { + text: "Bluetooth" + color: Theme.text + font.pixelSize: 13 + font.weight: Font.DemiBold + } + Text { + text: Bluetooth.defaultAdapter?.enabled ? "On" : "Off" + color: Theme.textMuted + font.pixelSize: 12 + font.weight: Font.Medium + Layout.fillWidth: true + } + } + } + + MouseArea { + id: btArea + anchors.fill: parent + onClicked: Qt.callLater(() => GlobalState.open("Bluetooth")) + } + } + } +} diff --git a/modules/system/quickshell/ControlCenter.qml b/modules/system/quickshell/ControlCenter.qml new file mode 100644 index 0000000..25aae5e --- /dev/null +++ b/modules/system/quickshell/ControlCenter.qml @@ -0,0 +1,141 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Networking +import Quickshell.Bluetooth +import Quickshell.Services.Pipewire +import Quickshell.Services.Mpris + +Item { + id: root + width: childrenRect.width + height: parent.height + + BrightnessService { id: brightnessService } + + MouseArea { + anchors.fill: parent + onClicked: GlobalState.toggle("ControlCenter") + } + + // Indicator in bar + Row { + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + Image { + width: 20; height: 20 + source: Quickshell.iconPath("emblem-system-symbolic") + sourceSize: Qt.size(width, height) + smooth: true + mipmap: true + } + } + + PopupWindow { + id: popup + visible: GlobalState.activePopup === "ControlCenter" + grabFocus: true + implicitWidth: card.width + implicitHeight: card.height + + anchor { + window: barWindow + item: root + edges: Edges.Bottom + gravity: Edges.Bottom + margins.top: Theme.popupGap + } + + color: "transparent" + + onVisibleChanged: { + if (visible) anchor.updateAnchor() + } + + PopupCard { + id: card + width: 320 + margins: 16 + + // TOP SECTION: Connectivity & Quick Actions + RowLayout { + Layout.fillWidth: true + spacing: 12 + + ConnectivityBox { + Layout.fillWidth: true + } + + ColumnLayout { + spacing: 12 + ControlTile { + label: "Do Not Disturb" + icon: "notifications" + } + ControlTile { + icon: "network-wireless" + } + } + } + + // MIDDLE SECTION: Sliders + SliderBox { + label: "Display" + icon: "display-brightness" + value: brightnessService.brightness + onMoved: val => brightnessService.setBrightness(val) + } + + SliderBox { + label: "Sound" + icon: "audio-volume-high" + value: Pipewire.defaultAudioSink?.audio?.volume ?? 0 + onMoved: val => { + const sink = Pipewire.defaultAudioSink + if (sink?.audio) { + sink.audio.muted = false + sink.audio.volume = val + } + } + } + + // NOW PLAYING BOX + Squircle { + Layout.fillWidth: true + height: 64 + cornerRadius: 16 + fillColor: hoverArea.containsMouse ? Theme.surfaceHover : Theme.surface + visible: (Mpris.players?.values?.length ?? 0) > 0 + + MouseArea { + id: hoverArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + MediaCard { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 12 + anchors.rightMargin: 12 + + isExpanded: false + + readonly property var activePlayer: { + const ps = Mpris.players?.values || [] + for (const p of ps) if (p.playbackState === MprisPlaybackState.Playing) return p + return ps[0] + } + + player: activePlayer + + onClicked: Qt.callLater(() => GlobalState.open("Media")) + } + } + } + } +} + + diff --git a/modules/system/quickshell/ControlTile.qml b/modules/system/quickshell/ControlTile.qml new file mode 100644 index 0000000..2c8e0a6 --- /dev/null +++ b/modules/system/quickshell/ControlTile.qml @@ -0,0 +1,50 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell + +Item { + id: root + property string icon: "" + property string label: "" + property bool active: false + property var clickHandler: null + + Layout.fillWidth: true + Layout.preferredHeight: 64 + + Squircle { + anchors.fill: parent + cornerRadius: 16 + fillColor: root.active ? Theme.accent : Theme.surface + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: { + if (root.clickHandler) root.clickHandler() + } + } + + RowLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 12 + + IconCircle { + source: root.icon + active: root.active + } + + Text { + text: root.label + color: root.active ? Theme.bg : Theme.text + font.family: Theme.mainFont + font.pixelSize: 11 + font.weight: Font.Medium + elide: Text.ElideRight + Layout.maximumWidth: 80 + visible: root.label !== "" + } + } + } +}
\ No newline at end of file diff --git a/modules/system/quickshell/CustomCheckBox.qml b/modules/system/quickshell/CustomCheckBox.qml new file mode 100644 index 0000000..9b7014d --- /dev/null +++ b/modules/system/quickshell/CustomCheckBox.qml @@ -0,0 +1,44 @@ +import QtQuick +import QtQuick.Controls + +CheckBox { + id: control + + contentItem: Text { + text: control.text + color: Theme.text + font.family: Theme.mainFont + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + leftPadding: control.indicator.width + control.spacing + } + + indicator: Rectangle { + implicitWidth: 14 + implicitHeight: 14 + x: control.leftPadding + y: Math.round((control.height - height) / 2) + radius: 3.5 + color: control.checked ? Theme.accent : Theme.surface + border.color: control.checked ? Theme.accent : Theme.border + border.width: 1 + + Canvas { + anchors.fill: parent + visible: control.checked + onPaint: { + var ctx = getContext("2d"); + ctx.reset(); + ctx.lineWidth = 1.5; + ctx.strokeStyle = "white"; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.beginPath(); + ctx.moveTo(3, 7); + ctx.lineTo(6, 10); + ctx.lineTo(11, 4); + ctx.stroke(); + } + } + } +} diff --git a/modules/system/quickshell/GlobalState.qml b/modules/system/quickshell/GlobalState.qml new file mode 100644 index 0000000..c212967 --- /dev/null +++ b/modules/system/quickshell/GlobalState.qml @@ -0,0 +1,23 @@ +import QtQuick + +pragma Singleton + +// Shared state to coordinate which popup is currently open. +// Ensures only one menu is visible at a time. +QtObject { + property string activePopup: "" + + function open(name) { + activePopup = "" + activePopup = name + } + + function close() { + activePopup = "" + } + + function toggle(name) { + if (activePopup === name) activePopup = "" + else activePopup = name + } +} diff --git a/modules/system/quickshell/IconCircle.qml b/modules/system/quickshell/IconCircle.qml new file mode 100644 index 0000000..50d5dd6 --- /dev/null +++ b/modules/system/quickshell/IconCircle.qml @@ -0,0 +1,38 @@ +import QtQuick +import QtQuick.Effects +import Quickshell + +Rectangle { + id: root + + property string source: "" + property bool active: false + property real size: 24 + + width: size + height: size + radius: size / 2 + color: active ? Theme.accent : Theme.surfaceLighter + + Image { + id: iconImage + anchors.centerIn: parent + width: parent.width * 0.6 + height: parent.height * 0.6 + source: Quickshell.iconPath(root.source.endsWith("-symbolic") ? root.source : root.source + "-symbolic") + sourceSize: Qt.size(width, height) + smooth: true + mipmap: true + + visible: false + } + + MultiEffect { + anchors.fill: iconImage + source: iconImage + colorizationColor: "#FFFFFF" + colorization: 1.0 + + opacity: root.active ? 1.0 : 0.6 + } +} diff --git a/modules/system/quickshell/Launcher.qml b/modules/system/quickshell/Launcher.qml new file mode 100644 index 0000000..fc0dc3d --- /dev/null +++ b/modules/system/quickshell/Launcher.qml @@ -0,0 +1,228 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland + +PanelWindow { + id: root + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "launcher" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + WlrLayershell.exclusiveZone: -1 + + exclusionMode: ExclusionMode.Ignore + + // Centering logic + anchors { + top: true + } + margins.top: 200 + + implicitWidth: 600 + implicitHeight: 400 + + color: "transparent" + visible: GlobalState.activePopup === "Launcher" + + onVisibleChanged: { + if (visible) { + searchInput.forceActiveFocus() + searchInput.text = "" + } + } + + Scope { + id: internal + + function filterApps(searchText) { + const search = searchText.toLowerCase() + const apps = DesktopEntries.applications.values + + return apps.filter(app => { + if (app.noDisplay) return false + if (!search) return true + + return app.name.toLowerCase().includes(search) || + (app.comment && app.comment.toLowerCase().includes(search)) + }) + } + } + + FocusScope { + anchors.fill: parent + focus: true + + Squircle { + id: launcherContent + anchors.fill: parent + cornerRadius: 12 + fillColor: Theme.barBg + strokeColor: Theme.border + strokeWidth: 1 + + // Capture Escape to close + Keys.onEscapePressed: GlobalState.close() + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + + // Search Header + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Image { + width: 24; height: 24 + source: Quickshell.iconPath("system-search-symbolic") + sourceSize: Qt.size(24, 24) + smooth: true + mipmap: true + opacity: 0.7 + } + + TextInput { + id: searchInput + Layout.fillWidth: true + font { + family: Theme.mainFont + pixelSize: 20 + } + color: Theme.text + clip: true + + focus: true + cursorVisible: true + selectByMouse: true + inputMethodHints: Qt.ImhNoPredictiveText + + Text { + visible: searchInput.text === "" + text: "Search Applications..." + color: Theme.text + opacity: 0.3 + font: searchInput.font + } + + onTextChanged: resultsList.currentIndex = 0 + + Keys.onPressed: event => { + if (event.key === Qt.Key_Down) { + resultsList.incrementCurrentIndex() + event.accepted = true + } else if (event.key === Qt.Key_Up) { + resultsList.decrementCurrentIndex() + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + if (resultsList.count > 0 && resultsList.currentIndex >= 0) { + if (resultsList.currentItem) { + resultsList.currentItem.launch() + } + } + event.accepted = true + } + } + } + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Theme.border + } + + ListView { + id: resultsList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + boundsBehavior: Flickable.StopAtBounds + highlightFollowsCurrentItem: true + highlightMoveDuration: 150 + highlightResizeDuration: 0 + + highlight: Squircle { + width: resultsList.width + height: 44 + cornerRadius: 8 + fillColor: Theme.surface + z: 1 + } + + model: internal.filterApps(searchInput.text) + + delegate: Squircle { + id: delegateRoot + required property var modelData + required property int index + + height: 44 + width: resultsList.width + + cornerRadius: 8 + fillColor: "transparent" + z: 5 + + function launch() { + if (modelData && modelData.execute) { + modelData.execute() + GlobalState.close() + } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 12 + + Image { + width: 24; height: 24 + source: Quickshell.iconPath(modelData.icon) + sourceSize: Qt.size(32, 32) + smooth: true + mipmap: true + } + + Column { + Layout.fillWidth: true + Text { + text: delegateRoot.modelData.name + color: Theme.text + font { + family: Theme.mainFont + pixelSize: 14 + weight: Font.Medium + } + } + Text { + visible: delegateRoot.modelData.comment !== "" + text: delegateRoot.modelData.comment + color: Theme.textMuted + font { + family: Theme.mainFont + pixelSize: 11 + } + elide: Text.ElideRight + width: parent.width + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: resultsList.currentIndex = index + onClicked: delegateRoot.launch() + } + } + + ScrollBar.vertical: ScrollBar {} + } + } + } +} +} diff --git a/modules/system/quickshell/LockContext.qml b/modules/system/quickshell/LockContext.qml new file mode 100644 index 0000000..05fd1de --- /dev/null +++ b/modules/system/quickshell/LockContext.qml @@ -0,0 +1,58 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Pam + +Scope { + id: root + signal unlocked() + signal failed() + + property string currentText: "" + property bool unlockInProgress: false + property string pamMessage: "" + property bool pamError: false + + onCurrentTextChanged: { + pamError = false; + pamMessage = ""; + } + + function tryUnlock() { + if (currentText === "" || unlockInProgress) return; + unlockInProgress = true; + pam.start(); + } + + function reset() { + currentText = ""; + pamError = false; + pamMessage = ""; + unlockInProgress = false; + } + + PamContext { + id: pam + configDirectory: "pam" + config: "password.conf" + + onPamMessage: { + root.pamMessage = pam.message; + root.pamError = pam.messageIsError; + if (this.responseRequired) { + this.respond(root.currentText); + } + } + + onCompleted: result => { + if (result == PamResult.Success) { + root.currentText = ""; + root.unlocked(); + } else { + root.currentText = ""; + root.pamError = true; + root.failed(); + } + root.unlockInProgress = false; + } + } +} diff --git a/modules/system/quickshell/LockSurface.qml b/modules/system/quickshell/LockSurface.qml new file mode 100644 index 0000000..eacd98f --- /dev/null +++ b/modules/system/quickshell/LockSurface.qml @@ -0,0 +1,197 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland + +Item { + id: root + required property LockContext context + focus: true + + property bool showInput: false + property string realName: "" + + Process { + id: nameProc + command: ["sh", "-c", "NAME=$(getent passwd $USER | cut -d: -f5 | cut -d, -f1); if [ -n \"$NAME\" ]; then echo \"$NAME\"; else whoami; fi"] + running: true + stdout: SplitParser { + onRead: line => { + root.realName = line.trim() + } + } + } + + // Capture keyboard input to reveal the text field + Keys.onPressed: (event) => { + if (!showInput && event.key !== Qt.Key_Escape) { + showInput = true + passwordInput.forceActiveFocus() + if (event.text !== "") { + passwordInput.text = event.text + root.context.currentText = event.text + } + } + } + + MouseArea { + anchors.fill: parent + onClicked: { + root.showInput = true + passwordInput.forceActiveFocus() + } + } + + Image { + anchors.fill: parent + source: "./wallpaper.jpg" + fillMode: Image.PreserveAspectCrop + } + + // Main Content (Clock) + Column { + anchors { + top: parent.top + topMargin: 120 + horizontalCenter: parent.horizontalCenter + } + spacing: 0 + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: Qt.formatDateTime(new Date(), "dddd, MMMM d") + color: "white" + opacity: 0.9 + font { + family: Theme.mainFont + pixelSize: 24 + weight: Font.Medium + } + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: Qt.formatDateTime(new Date(), "HH:mm") + color: "white" + font { + family: Theme.mainFont + pixelSize: 150 + weight: Font.DemiBold + letterSpacing: -8 + } + } } + + // Profile and Password Area (Bottom Center) + ColumnLayout { + id: passwordContainer + anchors { + bottom: parent.bottom + horizontalCenter: parent.horizontalCenter + bottomMargin: 100 + } + spacing: 15 + + SequentialAnimation { + id: shakeAnimation + NumberAnimation { target: passwordContainer; property: "anchors.horizontalCenterOffset"; to: -6; duration: 30; easing.type: Easing.OutQuad } + NumberAnimation { target: passwordContainer; property: "anchors.horizontalCenterOffset"; to: 6; duration: 60; easing.type: Easing.InOutQuad } + NumberAnimation { target: passwordContainer; property: "anchors.horizontalCenterOffset"; to: -6; duration: 60; easing.type: Easing.InOutQuad } + NumberAnimation { target: passwordContainer; property: "anchors.horizontalCenterOffset"; to: 6; duration: 60; easing.type: Easing.InOutQuad } + NumberAnimation { target: passwordContainer; property: "anchors.horizontalCenterOffset"; to: -6; duration: 60; easing.type: Easing.InOutQuad } + NumberAnimation { target: passwordContainer; property: "anchors.horizontalCenterOffset"; to: 6; duration: 60; easing.type: Easing.InOutQuad } + NumberAnimation { target: passwordContainer; property: "anchors.horizontalCenterOffset"; to: 0; duration: 30; easing.type: Easing.InQuad } + } + + Connections { + target: root.context + function onFailed() { + shakeAnimation.start(); + } + } + + // Avatar Placeholder + Rectangle { + Layout.alignment: Qt.AlignHCenter + width: 60 + height: 60 + radius: 30 + color: "#b0b0b0" + } + + // User Name + Text { + Layout.alignment: Qt.AlignHCenter + text: root.realName || "User" + color: "white" + font { + family: Theme.mainFont + pixelSize: 16 + weight: Font.DemiBold + } + visible: !root.showInput + } + + // Prompt Text + Text { + Layout.alignment: Qt.AlignHCenter + text: "Enter Password" + color: "white" + opacity: 0.7 + font { + family: Theme.mainFont + pixelSize: 13 + } + visible: !root.showInput + } + + // Password Input Field + TextField { + id: passwordInput + Layout.preferredWidth: 220 + Layout.preferredHeight: 32 + Layout.alignment: Qt.AlignHCenter + visible: root.showInput + + placeholderText: root.context.pamMessage || "Enter Password" + placeholderTextColor: Theme.textPlaceholder + echoMode: TextInput.Password + inputMethodHints: Qt.ImhSensitiveData + enabled: !root.context.unlockInProgress + + // Update context when text is changed directly in this field + onTextChanged: root.context.currentText = this.text + + // Sync text from context to support multi-monitor mirroring securely + Connections { + target: root.context + function onCurrentTextChanged() { + if (passwordInput.text !== root.context.currentText) { + passwordInput.text = root.context.currentText; + } + } + } + + background: Rectangle { + radius: height / 2 + color: Theme.surface + border.color: parent.activeFocus ? Theme.accent : Theme.border + border.width: 1 + } + + color: Theme.text + font.pixelSize: 13 + horizontalAlignment: TextInput.AlignHCenter + verticalAlignment: TextInput.AlignVCenter + + onAccepted: { + root.context.tryUnlock() + } + + Keys.onEscapePressed: { + root.showInput = false + root.context.currentText = "" + root.forceActiveFocus() + } + } + } +} diff --git a/modules/system/quickshell/Media.qml b/modules/system/quickshell/Media.qml new file mode 100644 index 0000000..ee5bf42 --- /dev/null +++ b/modules/system/quickshell/Media.qml @@ -0,0 +1,119 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Mpris + +Item { + id: root + + property QtObject manualExpandedPlayer: null + + readonly property var activePlayers: Mpris.players?.values || [] + + readonly property var expandedPlayer: { + if (root.manualExpandedPlayer !== null && root.activePlayers.includes(root.manualExpandedPlayer)) { + return root.manualExpandedPlayer; + } + for (const player of root.activePlayers) { + if (player.playbackState === MprisPlaybackState.Playing) { + return player; + } + } + if (root.activePlayers.length > 0) { + return root.activePlayers[0]; + } + return null; + } + + width: childrenRect.width + height: parent.height + + Row { + anchors.verticalCenter: parent.verticalCenter + + MusicVisualizer { + active: root.expandedPlayer?.playbackState === MprisPlaybackState.Playing + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + anchors.fill: parent + onClicked: GlobalState.toggle("Media") + } + + PopupWindow { + id: popup + visible: GlobalState.activePopup === "Media" + grabFocus: true + implicitWidth: card.width + implicitHeight: card.height + + anchor { + window: barWindow + item: root + edges: Edges.Bottom + gravity: Edges.Bottom + margins.top: Theme.popupGap + } + + color: Theme.transparent + + PopupCard { + id: card + width: 320 + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Layout.margins: 8 + + Repeater { + model: root.activePlayers.length > 0 ? root.activePlayers : [null] + + delegate: Item { + id: playerDelegate + Layout.fillWidth: true + implicitHeight: mediaCard.implicitHeight + (separator.visible ? separator.height + 16 : 0) + + required property var modelData + required property int index + + readonly property bool isEmpty: modelData === null + readonly property bool isExpanded: root.expandedPlayer === modelData && !isEmpty + + ColumnLayout { + anchors.fill: parent + spacing: 16 + + MediaCard { + id: mediaCard + Layout.fillWidth: true + player: playerDelegate.modelData + isExpanded: playerDelegate.isExpanded + + onClicked: { + if (!playerDelegate.isEmpty) { + root.manualExpandedPlayer = playerDelegate.modelData; + } else { + Quickshell.execDetached(["spotify"]); + } + } + } + + Rectangle { + id: separator + Layout.fillWidth: true + height: 1 + color: Theme.border + opacity: 0.3 + visible: !playerDelegate.isEmpty && playerDelegate.index < root.activePlayers.length - 1 + Layout.topMargin: 4 + } + } + } + } + } + } + } +} diff --git a/modules/system/quickshell/MediaCard.qml b/modules/system/quickshell/MediaCard.qml new file mode 100644 index 0000000..5aba34c --- /dev/null +++ b/modules/system/quickshell/MediaCard.qml @@ -0,0 +1,244 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Mpris + +Item { + id: root + + property QtObject player: null + property bool isExpanded: false + signal clicked() + + Layout.fillWidth: true + implicitHeight: layout.implicitHeight + + Behavior on implicitHeight { + NumberAnimation { duration: 200; easing.type: Easing.OutCubic } + } + + MouseArea { + anchors.fill: parent + enabled: !root.isExpanded + onClicked: root.clicked() + } + + function formatTime(s) { + const mins = Math.floor(s / 60) + const secs = Math.floor(s % 60) + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + ColumnLayout { + id: layout + anchors.left: parent.left + anchors.right: parent.right + spacing: 16 + + // Top Row: Minimal view and header for expanded view + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Squircle { + width: root.isExpanded ? 56 : 40 + height: root.isExpanded ? 56 : 40 + cornerRadius: root.isExpanded ? 10 : 8 + fillColor: root.player ? Theme.transparent : Theme.surface + clip: true + + Behavior on width { NumberAnimation { duration: 200 } } + Behavior on height { NumberAnimation { duration: 200 } } + Behavior on cornerRadius { NumberAnimation { duration: 200 } } + + Image { + anchors.fill: parent + source: root.player?.trackArtUrl || "" + fillMode: Image.PreserveAspectCrop + visible: status === Image.Ready && root.player !== null + } + + IconCircle { + anchors.centerIn: parent + size: root.isExpanded ? 28 : 20 + source: "audio-x-generic-symbolic" + visible: !root.player?.trackArtUrl || parent.children[0].status !== Image.Ready || root.player === null + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 2 + + Text { + Layout.fillWidth: true + text: root.player?.trackTitle || "Music" + color: root.player ? Theme.text : Theme.textDisabled + font.pixelSize: 13 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + text: root.player?.trackArtist || "" + visible: text !== "" && root.player !== null + color: Theme.textDim + font.pixelSize: 12 + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + text: root.player?.trackAlbum || "" + visible: root.isExpanded && text !== "" && root.player !== null + color: Theme.textDisabled + font.pixelSize: 11 + font.weight: Font.Normal + elide: Text.ElideRight + } + } + + // Minimal Controls: Only visible when not expanded + Row { + visible: !root.isExpanded + spacing: 12 + Layout.alignment: Qt.AlignVCenter + + Item { + width: 24; height: 24 + Image { + anchors.centerIn: parent + source: Quickshell.iconPath(root.player?.playbackState === MprisPlaybackState.Playing ? "media-playback-pause-symbolic" : "media-playback-start-symbolic") + sourceSize: Qt.size(24, 24) + opacity: root.player?.canTogglePlaying ? (minPlayMouse.pressed ? 0.7 : 1.0) : 0.3 + } + MouseArea { + id: minPlayMouse + anchors.fill: parent + enabled: root.player?.canTogglePlaying ?? false + onClicked: if (root.player) root.player.togglePlaying() + } + } + + Item { + width: 24; height: 24 + Image { + anchors.centerIn: parent + source: Quickshell.iconPath("media-skip-forward-symbolic") + sourceSize: Qt.size(20, 20) + opacity: root.player?.canGoNext ? (minNextMouse.pressed ? 0.5 : 0.8) : 0.3 + } + MouseArea { + id: minNextMouse + anchors.fill: parent + enabled: root.player?.canGoNext ?? false + onClicked: if (root.player) root.player.next() + } + } + } + } + + // Expanded Controls: Only visible when expanded and a player exists + ColumnLayout { + visible: root.isExpanded && root.player !== null + Layout.fillWidth: true + spacing: 8 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + ThinSlider { + id: progressSlider + Layout.fillWidth: true + from: 0 + to: root.player?.length || 1 + value: root.player?.position || 0 + enabled: root.player?.canSeek ?? false + + onMoved: if (root.player) root.player.position = value + + Timer { + running: root.player?.playbackState === MprisPlaybackState.Playing && root.isExpanded + interval: 500 + repeat: true + onTriggered: progressSlider.value = root.player.position + } + } + + RowLayout { + Layout.fillWidth: true + Text { + text: root.formatTime(root.player?.position || 0) + color: Theme.textPlaceholder + font.pixelSize: 9 + font.weight: Font.Medium + } + Item { Layout.fillWidth: true } + Text { + text: root.formatTime(root.player?.length || 0) + color: Theme.textPlaceholder + font.pixelSize: 9 + font.weight: Font.Medium + } + } + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 32 + + Item { + width: 24; height: 24 + Image { + anchors.centerIn: parent + source: Quickshell.iconPath("media-skip-backward-symbolic") + sourceSize: Qt.size(20, 20) + opacity: root.player?.canGoPrevious ? (prevMouse.pressed ? 0.5 : 0.8) : 0.3 + } + MouseArea { + id: prevMouse + anchors.fill: parent + enabled: root.player?.canGoPrevious ?? false + onClicked: if (root.player) root.player.previous() + } + } + + Item { + width: 32; height: 32 + Image { + anchors.centerIn: parent + source: Quickshell.iconPath(root.player?.playbackState === MprisPlaybackState.Playing ? "media-playback-pause-symbolic" : "media-playback-start-symbolic") + sourceSize: Qt.size(28, 28) + opacity: root.player?.canTogglePlaying ? (maxPlayMouse.pressed ? 0.7 : 1.0) : 0.3 + } + MouseArea { + id: maxPlayMouse + anchors.fill: parent + enabled: root.player?.canTogglePlaying ?? false + onClicked: if (root.player) root.player.togglePlaying() + } + } + + Item { + width: 24; height: 24 + Image { + anchors.centerIn: parent + source: Quickshell.iconPath("media-skip-forward-symbolic") + sourceSize: Qt.size(20, 20) + opacity: root.player?.canGoNext ? (maxNextMouse.pressed ? 0.5 : 0.8) : 0.3 + } + MouseArea { + id: maxNextMouse + anchors.fill: parent + enabled: root.player?.canGoNext ?? false + onClicked: if (root.player) root.player.next() + } + } + } + } + } +} diff --git a/modules/system/quickshell/MusicVisualizer.qml b/modules/system/quickshell/MusicVisualizer.qml new file mode 100644 index 0000000..e136304 --- /dev/null +++ b/modules/system/quickshell/MusicVisualizer.qml @@ -0,0 +1,35 @@ +import QtQuick + +// iOS-style animated music visualizer bars. +// Simulates audio reactivity by randomly changing bar heights when active. +Row { + id: root + property bool active: false + property color color: Theme.accent + spacing: 2 + height: 16 + + Repeater { + model: 4 + delegate: Rectangle { + id: bar + width: 3 + height: root.active ? 4 + Math.random() * (root.height - 4) : 4 + radius: 1.5 + color: root.color + + Timer { + running: root.active + interval: 100 + Math.random() * 200 + repeat: true + onTriggered: { + bar.height = 4 + Math.random() * (root.height - 4) + } + } + + Behavior on height { + NumberAnimation { duration: 150; easing.type: Easing.OutCubic } + } + } + } +} diff --git a/modules/system/quickshell/NotificationCard.qml b/modules/system/quickshell/NotificationCard.qml new file mode 100644 index 0000000..8b6478a --- /dev/null +++ b/modules/system/quickshell/NotificationCard.qml @@ -0,0 +1,405 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects +import Quickshell + +Item { + id: card + + required property int nId + required property string nSummary + required property string nBody + required property string nAppName + required property string nAppIcon + required property string nImage + required property var nTimestamp + required property int nTimeout + required property var nActions + required property bool nClickable + required property bool nHasInlineReply + required property string nReplyPlaceholder + required property bool nResident + + property bool hovered: cardHover.containsMouse || closeHover.containsMouse || (replyInput && replyInput.activeFocus) + + readonly property bool hasBottom: (nActions ? nActions.count > 0 : false) || nHasInlineReply + + signal actionInvoked(int id, string identifier) + signal replySent(int id, string text) + signal dismissed(int id) + signal activated(int id) + signal hideRequested(int id) + + function formatTimeAgo(timestamp) { + const now = new Date(); + const diffMs = now - timestamp; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return diffMins + "m ago"; + if (diffHours < 24) return diffHours + "h ago"; + return Qt.formatTime(timestamp, "h:mm p"); + } + + width: ListView.view ? ListView.view.width : 400 + height: contentColumn.implicitHeight + 32 + Behavior on height { NumberAnimation { duration: 200; easing.type: Easing.OutSine } } + + Squircle { + anchors.fill: parent + cornerRadius: 16 + fillColor: Theme.surface + strokeColor: Theme.border + strokeWidth: 1 + } + + MouseArea { + id: cardHover + anchors.fill: parent + hoverEnabled: true + cursorShape: nClickable ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + if (nClickable) card.activated(nId) + } + } + + ColumnLayout { + id: contentColumn + anchors { + top: parent.top + left: parent.left + right: parent.right + margins: 16 + } + spacing: 12 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + Layout.alignment: Qt.AlignTop + + // LEFT: Icon + Item { + width: 44 + height: 44 + Layout.alignment: Qt.AlignVCenter + + Squircle { + anchors.fill: parent + cornerRadius: 10 + fillColor: Theme.surfaceLighter + } + + Image { + visible: nAppIcon !== "" + anchors.centerIn: parent + width: 30; height: 30 + source: nAppIcon + fillMode: Image.PreserveAspectFit + smooth: true + } + } + + // MIDDLE: Text + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + spacing: 2 + + RowLayout { + Layout.fillWidth: true + Text { + text: nAppName + color: Theme.text + font { + family: Theme.mainFont + pixelSize: 12 + weight: Font.Medium + } + opacity: 0.8 + elide: Text.ElideRight + Layout.fillWidth: true + } + Text { + text: card.formatTimeAgo(nTimestamp) + color: Theme.text + opacity: 0.5 + font { + family: Theme.mainFont + pixelSize: 11 + } + + Timer { + interval: 60000 + running: true + repeat: true + onTriggered: parent.text = card.formatTimeAgo(nTimestamp) + } + } + } + + Text { + visible: nSummary !== "" + Layout.fillWidth: true + text: nSummary + color: Theme.text + font { + family: Theme.mainFont + pixelSize: 14 + weight: Font.DemiBold + } + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + } + + Text { + visible: nBody !== "" + Layout.fillWidth: true + text: nBody + color: Theme.text + opacity: 0.85 + font { + family: Theme.mainFont + pixelSize: 13 + } + wrapMode: Text.WordWrap + maximumLineCount: 3 + elide: Text.ElideRight + textFormat: Text.StyledText + } + } + + // RIGHT: Image + Item { + visible: nImage !== "" + width: visible ? 44 : 0 + height: 44 + Layout.alignment: Qt.AlignTop + + Squircle { + id: mediaMask + anchors.fill: parent + cornerRadius: 8 + fillColor: Theme.text + visible: false + layer.enabled: true + layer.smooth: true + } + + Image { + anchors.fill: parent + source: nImage + fillMode: Image.PreserveAspectCrop + smooth: true + asynchronous: true + layer.enabled: true + layer.effect: MultiEffect { + maskEnabled: true + maskSource: mediaMask + maskThresholdMin: 0.5 + } + } + } + } + + ColumnLayout { + id: bottomStack + Layout.fillWidth: true + visible: card.hasBottom + spacing: 8 + Layout.leftMargin: 56 // Align with text + + RowLayout { + visible: nActions && nActions.count > 0 + Layout.fillWidth: true + spacing: 8 + + Repeater { + model: nActions + delegate: Item { + id: actionBtn + required property int index + required property string identifier + required property string text + + Layout.fillWidth: true + Layout.preferredHeight: 28 + + Squircle { + anchors.fill: parent + cornerRadius: 6 + fillColor: actionHover.containsMouse ? Theme.surfaceHover : Theme.surfaceLighter + scale: actionHover.pressed ? 0.95 : 1.0 + Behavior on scale { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } + } + + Text { + anchors.centerIn: parent + text: actionBtn.text + color: Theme.text + font { + family: Theme.mainFont + pixelSize: 12 + weight: Font.Medium + } + } + + MouseArea { + id: actionHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: card.actionInvoked(card.nId, actionBtn.identifier) + } + } + } + } + + Item { + visible: nHasInlineReply + Layout.fillWidth: true + Layout.preferredHeight: 30 + + Squircle { + anchors.fill: parent + cornerRadius: 6 + fillColor: Theme.surfaceLighter + } + + TextInput { + id: replyInput + anchors { + left: parent.left; leftMargin: 10 + right: sendBtn.left; rightMargin: 6 + top: parent.top; bottom: parent.bottom + } + verticalAlignment: TextInput.AlignVCenter + color: Theme.text + font { + family: Theme.mainFont + pixelSize: 12 + } + clip: true + activeFocusOnTab: true + + Text { + visible: replyInput.text === "" && !replyInput.activeFocus + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + text: nReplyPlaceholder + color: Theme.text + opacity: 0.5 + font: replyInput.font + } + + Keys.onReturnPressed: if (text.length > 0) { card.replySent(card.nId, text); text = "" } + Keys.onEnterPressed: if (text.length > 0) { card.replySent(card.nId, text); text = "" } + } + + Item { + id: sendBtn + anchors { + right: parent.right; rightMargin: 4 + verticalCenter: parent.verticalCenter + } + width: 22; height: 22 + opacity: replyInput.text.length > 0 ? 1.0 : 0.4 + + Rectangle { + anchors.fill: parent + radius: 4 + color: Theme.text + } + + Text { + anchors.centerIn: parent + text: "↑" + color: Theme.bg + font.pixelSize: 14 + font.weight: Font.DemiBold + } + + MouseArea { + anchors.fill: parent + enabled: replyInput.text.length > 0 + cursorShape: Qt.PointingHandCursor + onClicked: { + card.replySent(card.nId, replyInput.text) + replyInput.text = "" + } + } + } + } + } + } + + // Close button — top-left, visible on hover + Rectangle { + width: 20 + height: 20 + radius: 10 + anchors { + top: parent.top + left: parent.left + topMargin: -6 + leftMargin: -6 + } + color: Theme.surfaceLighter + border.color: Theme.border + border.width: 1 + opacity: card.hovered ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + + Item { + anchors.centerIn: parent + width: 8 + height: 8 + + Rectangle { + anchors.centerIn: parent + width: parent.width * Math.SQRT2 + height: 1.2 + radius: 0.6 + color: Theme.text + rotation: 45 + antialiasing: true + } + Rectangle { + anchors.centerIn: parent + width: parent.width * Math.SQRT2 + height: 1.2 + radius: 0.6 + color: Theme.text + rotation: -45 + antialiasing: true + } + } + + MouseArea { + id: closeHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: card.dismissed(card.nId) + } + } + + Timer { + id: hideTimer + interval: nTimeout + running: !nResident + onTriggered: card.hideRequested(nId) + } + + Connections { + target: card + function onHoveredChanged() { + if (!nResident) { + if (card.hovered) hideTimer.stop() + else hideTimer.restart() + } + } + } +}
\ No newline at end of file diff --git a/modules/system/quickshell/NotificationPopupList.qml b/modules/system/quickshell/NotificationPopupList.qml new file mode 100644 index 0000000..6b9280b --- /dev/null +++ b/modules/system/quickshell/NotificationPopupList.qml @@ -0,0 +1,75 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland + +PanelWindow { + id: root + property var popupModel + + signal actionInvoked(int id, string identifier) + signal replySent(int id, string text) + signal dismissed(int id) + signal activated(int id) + signal hideRequested(int id) + + visible: popupList.count > 0 + + anchors { top: true; right: true } + WlrLayershell.margins.top: Theme.barHeight + exclusionMode: ExclusionMode.Ignore + color: "transparent" + + readonly property int edgeMargin: 12 + readonly property int animationSafeMargin: 80 + readonly property int popupWidth: 400 + + implicitWidth: popupWidth + edgeMargin * 2 + animationSafeMargin + implicitHeight: popupList.contentHeight + edgeMargin * 2 + + WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + + ListView { + id: popupList + anchors { + top: parent.top + right: parent.right + topMargin: edgeMargin + rightMargin: edgeMargin + } + + width: popupWidth + height: contentHeight + spacing: 8 + model: root.popupModel + interactive: false + clip: false + + delegate: NotificationCard { + onActionInvoked: (id, identifier) => root.actionInvoked(id, identifier) + onReplySent: (id, text) => root.replySent(id, text) + onDismissed: id => root.dismissed(id) + onActivated: id => root.activated(id) + onHideRequested: id => root.hideRequested(id) + } + + add: Transition { + ParallelAnimation { + NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 250; easing.type: Easing.OutSine } + NumberAnimation { property: "x"; from: popupWidth + edgeMargin; duration: 350; easing.type: Easing.OutBack } + } + } + + remove: Transition { + ParallelAnimation { + NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 200; easing.type: Easing.InSine } + NumberAnimation { property: "x"; to: popupWidth + edgeMargin; duration: 200; easing.type: Easing.InSine } + } + } + + displaced: Transition { + NumberAnimation { properties: "x,y"; duration: 250; easing.type: Easing.OutSine } + } + } +} + + diff --git a/modules/system/quickshell/Notifications.qml b/modules/system/quickshell/Notifications.qml new file mode 100644 index 0000000..d17e903 --- /dev/null +++ b/modules/system/quickshell/Notifications.qml @@ -0,0 +1,149 @@ +import Quickshell +import Quickshell.Wayland +import Quickshell.Services.Notifications +import QtQuick + +Scope { + id: scope + + readonly property int defaultTimeout: 5000 + + property int nextId: 0 + property var liveNotifs: ({}) + + ListModel { id: popupModel } + + function removeNotificationData(id) { + for (let i = 0; i < popupModel.count; i++) { + if (popupModel.get(i).nId === id) { + popupModel.remove(i) + break + } + } + delete liveNotifs[id] + } + + function dismissExplicitly(id) { + const n = liveNotifs[id] + if (n) n.dismiss() + removeNotificationData(id) + } + + function hidePopup(id) { + for (let i = 0; i < popupModel.count; i++) { + if (popupModel.get(i).nId === id) { + popupModel.remove(i) + break + } + } + } + + function activateById(id) { + const n = liveNotifs[id] + if (!n) return + + let invoked = false + for (const action of n.actions) { + if (action.identifier === "default") { + action.invoke() + invoked = true + break + } + } + if (!invoked && n.desktopEntry) { + Quickshell.execDetached(["gtk-launch", n.desktopEntry]) + } + dismissExplicitly(id) + } + + function invokeAction(id, identifier) { + const n = liveNotifs[id] + if (n) { + for (const action of n.actions) { + if (action.identifier === identifier) { + action.invoke() + break + } + } + } + dismissExplicitly(id) + } + + function sendReply(id, text) { + const n = liveNotifs[id] + if (n && n.hasInlineReply) n.sendInlineReply(text) + dismissExplicitly(id) + } + + NotificationServer { + keepOnReload: false + actionsSupported: true + actionIconsSupported: true + bodySupported: true + bodyMarkupSupported: true + bodyImagesSupported: true + imageSupported: true + inlineReplySupported: true + persistenceSupported: true + + onNotification: notif => { + notif.tracked = true + + const id = nextId + nextId = nextId + 1 + liveNotifs[id] = notif + + const acts = [] + let hasDefault = false + for (const a of notif.actions) { + if (a.identifier === "default") hasDefault = true + else acts.push({ identifier: a.identifier, text: a.text }) + } + + notif.closed.connect(() => removeNotificationData(id)) + + let formattedAppIcon = notif.appIcon || "" + if (formattedAppIcon !== "") { + if (formattedAppIcon.startsWith("file://")) { + } else if (formattedAppIcon.startsWith("/")) { + formattedAppIcon = "file://" + formattedAppIcon + } else { + formattedAppIcon = `image://icon/${formattedAppIcon}` + } + } + + let formattedImage = notif.image || "" + if (formattedImage !== "" && formattedImage.startsWith("/")) { + formattedImage = "file://" + formattedImage + } + + const data = { + nId: id, + nSummary: notif.summary, + nBody: notif.body, + nAppName: notif.appName || "Notification", + nAppIcon: formattedAppIcon, + nImage: formattedImage, + nTimestamp: new Date(), + nTimeout: notif.expireTimeout > 0 ? notif.expireTimeout : defaultTimeout, + nActions: acts, + nClickable: hasDefault || (notif.desktopEntry || "") !== "", + nHasInlineReply: notif.hasInlineReply || false, + nReplyPlaceholder: notif.inlineReplyPlaceholder || "Reply", + nResident: notif.resident || false + } + + popupModel.insert(0, data) + } + } + + NotificationPopupList { + popupModel: scope.popupModel + onActionInvoked: (id, identifier) => scope.invokeAction(id, identifier) + onReplySent: (id, text) => scope.sendReply(id, text) + onDismissed: id => scope.dismissExplicitly(id) + onActivated: id => scope.activateById(id) + onHideRequested: id => scope.hidePopup(id) + } +} + diff --git a/modules/system/quickshell/PillSlider.qml b/modules/system/quickshell/PillSlider.qml new file mode 100644 index 0000000..5330ef8 --- /dev/null +++ b/modules/system/quickshell/PillSlider.qml @@ -0,0 +1,77 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import Quickshell + +Slider { + id: root + + property string icon: "" + property color colorTrack: Theme.sliderTrack + property color colorProgress: Theme.accent + property color colorHandle: "#FFFFFF" + property color iconColor: Theme.iconDefault + + implicitHeight: 24 + padding: 0 + + background: Rectangle { + width: root.width + height: root.height + radius: height / 2 + color: root.colorTrack + + clip: true + + Rectangle { + width: root.handle.x + (root.handle.width / 2) + height: parent.height + color: root.colorProgress + } + + Image { + id: iconImage + visible: root.icon !== "" + anchors.left: parent.left + anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + source: Quickshell.iconPath(root.icon) + sourceSize: Qt.size(14, 14) + width: 14 + height: 14 + } + + MultiEffect { + source: iconImage + anchors.fill: iconImage + colorizationColor: root.iconColor + colorization: 1.0 + } + } + + handle: Item { + x: root.visualPosition * (root.availableWidth - width) + y: root.topPadding + root.availableHeight / 2 - height / 2 + + width: root.height + height: root.height + + Rectangle { + id: handleCircle + anchors.fill: parent + radius: width / 2 + color: root.colorHandle + border.width: 1 + border.color: "#1A000000" + } + + MultiEffect { + source: handleCircle + anchors.fill: handleCircle + shadowEnabled: true + shadowBlur: 1.0 + shadowColor: "#4D000000" + shadowVerticalOffset: 1 + } + } +} diff --git a/modules/system/quickshell/Polkit.qml b/modules/system/quickshell/Polkit.qml new file mode 100644 index 0000000..4ac8bd1 --- /dev/null +++ b/modules/system/quickshell/Polkit.qml @@ -0,0 +1,222 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Wayland +import Quickshell.Services.Polkit + +PanelWindow { + id: root + + PolkitAgent { + id: agent + + Component.onCompleted: console.log("PolkitAgent status: " + (isRegistered ? "Registered" : "Not Registered")) + onIsRegisteredChanged: console.log("PolkitAgent registration changed: " + isRegistered) + + onIsActiveChanged: { + console.log("PolkitAgent active state: " + isActive) + if (isActive) { + root.visible = true; + passwordInput.text = ""; + Qt.callLater(() => passwordInput.forceActiveFocus()); + } else { + root.visible = false; + } + } + } + + visible: false + color: Theme.transparent + anchors { + top: true + bottom: true + left: true + right: true + } + exclusiveZone: 0 + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: visible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + + Rectangle { + anchors.fill: parent + color: Theme.scrim + MouseArea { + anchors.fill: parent + onClicked: { + if (agent.flow) { + agent.flow.cancelAuthenticationRequest(); + } + } + } + } + + Squircle { + id: dialog + anchors.centerIn: parent + width: 480 + height: Math.max(220, layout.implicitHeight + 40) + fillColor: Theme.bg + strokeColor: Theme.border + strokeWidth: 1 + cornerRadius: 12 + + MouseArea { + anchors.fill: parent + onClicked: (mouse) => mouse.accepted = true + } + + RowLayout { + id: layout + anchors.fill: parent + anchors.margins: 20 + spacing: 16 + + Item { + Layout.alignment: Qt.AlignTop + Layout.preferredWidth: 64 + Layout.preferredHeight: 64 + + Image { + anchors.fill: parent + source: agent.flow ? Quickshell.iconPath(agent.flow.iconName || "dialog-password-symbolic") : "" + fillMode: Image.PreserveAspectFit + smooth: true + mipmap: true + sourceSize: Qt.size(64, 64) + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 12 + + Text { + Layout.fillWidth: true + text: agent.flow ? agent.flow.message : "" + color: Theme.text + font.family: Theme.mainFont + font.pixelSize: 13 + font.weight: Font.DemiBold + wrapMode: Text.WordWrap + } + + Text { + visible: agent.flow && agent.flow.supplementaryMessage !== "" + Layout.fillWidth: true + text: agent.flow ? agent.flow.supplementaryMessage : "" + color: (agent.flow && agent.flow.supplementaryIsError) ? Theme.destructive : Theme.textDim + font.family: Theme.mainFont + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + GridLayout { + Layout.fillWidth: true + columns: 2 + rowSpacing: 8 + columnSpacing: 8 + visible: agent.flow && agent.flow.isResponseRequired + + Text { + text: agent.flow ? agent.flow.inputPrompt : "Password:" + color: Theme.text + font.family: Theme.mainFont + font.pixelSize: 12 + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + } + + TextField { + id: passwordInput + Layout.fillWidth: true + Layout.preferredHeight: 28 + font.family: Theme.mainFont + font.pixelSize: 12 + color: Theme.text + echoMode: (agent.flow && agent.flow.responseVisible) ? TextInput.Normal : TextInput.Password + + background: Rectangle { + color: Theme.surface + radius: 4 + border.color: passwordInput.activeFocus ? Theme.accent : Theme.border + border.width: passwordInput.activeFocus ? 2 : 1 + } + + onAccepted: { + if (agent.flow) { + agent.flow.submit(passwordInput.text); + passwordInput.text = ""; + } + } + } + } + + Item { Layout.preferredHeight: 4 } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Item { Layout.fillWidth: true } + + Button { + text: "Cancel" + + contentItem: Text { + text: parent.text + color: Theme.text + font.family: Theme.mainFont + font.pixelSize: 13 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + implicitWidth: 80 + implicitHeight: 28 + color: parent.hovered ? Theme.surfaceHover : Theme.surface + radius: 6 + border.color: Theme.border + border.width: 1 + } + + onClicked: { + if (agent.flow) { + agent.flow.cancelAuthenticationRequest(); + } + } + } + + Button { + enabled: agent.flow && (!agent.flow.isResponseRequired || passwordInput.text !== "") + text: "Authenticate" + + contentItem: Text { + text: parent.text + color: enabled ? Theme.text : Theme.textDisabled + font.pixelSize: 13 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + implicitWidth: 100 + implicitHeight: 28 + color: parent.enabled ? (parent.hovered ? Theme.accentHover : Theme.accent) : Theme.surface + radius: 6 + } + + onClicked: { + if (agent.flow) { + agent.flow.submit(passwordInput.text); + passwordInput.text = ""; + } + } + } + } + } + } + } +} diff --git a/modules/system/quickshell/PopupCard.qml b/modules/system/quickshell/PopupCard.qml new file mode 100644 index 0000000..6563877 --- /dev/null +++ b/modules/system/quickshell/PopupCard.qml @@ -0,0 +1,42 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Squircle { + id: root + + readonly property int maxHeight: 440 + property int margins: 16 + default property alias content: contentCol.data + + width: 320 + height: Math.min(Math.max(contentCol.implicitHeight + 2 * margins, 80), maxHeight) + + Behavior on height { + SpringAnimation { spring: 3; damping: 0.25 } + } + + fillColor: Theme.bg + strokeColor: Theme.border + strokeWidth: 1 + cornerRadius: 20 + clip: true + + ScrollView { + anchors { + fill: parent + margins: root.margins + } + clip: true + contentWidth: availableWidth + ScrollBar.vertical.policy: contentCol.implicitHeight > height + ? ScrollBar.AsNeeded + : ScrollBar.AlwaysOff + + ColumnLayout { + id: contentCol + width: parent.width + spacing: 12 + } + } +} diff --git a/modules/system/quickshell/SliderBox.qml b/modules/system/quickshell/SliderBox.qml new file mode 100644 index 0000000..59b994d --- /dev/null +++ b/modules/system/quickshell/SliderBox.qml @@ -0,0 +1,52 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell + +Squircle { + id: root + property string label: "" + property string icon: "" + property real value: 0 + signal moved(real val) + + Layout.fillWidth: true + height: 64 + cornerRadius: 16 + fillColor: hoverArea.containsMouse ? Theme.surfaceHover : Theme.surface + + MouseArea { + id: hoverArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + RowLayout { + Layout.fillWidth: true + Text { + text: root.label + color: Theme.textMuted + font { + family: Theme.mainFont + pixelSize: 12 + weight: Font.DemiBold + } + Layout.leftMargin: 2 + } + Item { Layout.fillWidth: true } + } + + PillSlider { + id: slider + Layout.fillWidth: true + icon: root.icon + value: root.value + onMoved: root.moved(value) + } + } +} diff --git a/modules/system/quickshell/Squircle.qml b/modules/system/quickshell/Squircle.qml new file mode 100644 index 0000000..1d505c9 --- /dev/null +++ b/modules/system/quickshell/Squircle.qml @@ -0,0 +1,12 @@ +import QtQuick + +ShaderEffect { + id: root + + property color fillColor: "transparent" + property color strokeColor: "transparent" + property real strokeWidth: 0 + property real cornerRadius: 12 + + fragmentShader: "squircle.qsb" +} diff --git a/modules/system/quickshell/Theme.qml b/modules/system/quickshell/Theme.qml new file mode 100644 index 0000000..f409f98 --- /dev/null +++ b/modules/system/quickshell/Theme.qml @@ -0,0 +1,48 @@ +import QtQuick + +pragma Singleton + +QtObject { + // Basics + readonly property color bg: "#33000000" // More translucent for macOS 18 style + readonly property color barBg: "#66000000" + readonly property color surface: "#4DFFFFFF" // Semi-transparent white/gray + readonly property color surfaceHover: "#66FFFFFF" + readonly property color surfaceLighter: "#80FFFFFF" + readonly property color border: "#1AFFFFFF" + + // Accents + readonly property color accent: "#007AFF" // macOS Blue + readonly property color accentHover: "#0066CC" + readonly property color destructive: "#FF3B30" + readonly property color focus: "#007AFF" + + // Text + readonly property color text: "#FFFFFF" + readonly property color textDim: "#EBEBEB" + readonly property color textMuted: "#C6C6C6" + readonly property color textDisabled: "#999999" + readonly property color textPlaceholder: "#808080" + readonly property color textLight: "#FFFFFF" + + // Icons + readonly property color iconDefault: "#FFFFFF" + readonly property color iconActive: "#007AFF" + + // Components + readonly property color sliderTrack: "#33FFFFFF" + readonly property color sliderHandle: "#FFFFFF" + readonly property color sliderOutline: "#1A000000" + + // Transparency Helpers + readonly property color scrim: "#80000000" + readonly property color transparent: "transparent" + + // Layout + readonly property int barHeight: 32 // Slightly taller for macOS look + readonly property int popupGap: 8 + + // Fonts + readonly property string mainFont: "Inter" + readonly property string monoFont: "JetBrains Mono" +} diff --git a/modules/system/quickshell/ThinSlider.qml b/modules/system/quickshell/ThinSlider.qml new file mode 100644 index 0000000..61a9ce8 --- /dev/null +++ b/modules/system/quickshell/ThinSlider.qml @@ -0,0 +1,40 @@ +import QtQuick +import QtQuick.Controls + +Slider { + id: root + + readonly property color colorTrack: Theme.sliderTrack + readonly property color colorProgress: Theme.accent + readonly property color colorHandle: Theme.sliderHandle + + implicitHeight: 14 + padding: 0 + + background: Rectangle { + x: root.leftPadding + y: root.topPadding + root.availableHeight / 2 - height / 2 + implicitWidth: 200 + implicitHeight: 2 + width: root.availableWidth + height: implicitHeight + radius: height / 2 + color: root.colorTrack + + Rectangle { + width: root.handle.x + (root.handle.width / 2) + height: parent.height + color: root.colorProgress + radius: height / 2 + } + } + + handle: Rectangle { + x: root.leftPadding + root.visualPosition * (root.availableWidth - width) + y: root.topPadding + root.availableHeight / 2 - height / 2 + implicitWidth: 2 + implicitHeight: 10 + radius: width / 2 + color: root.colorHandle + } +} diff --git a/modules/system/quickshell/Toggle.qml b/modules/system/quickshell/Toggle.qml new file mode 100644 index 0000000..76eb36c --- /dev/null +++ b/modules/system/quickshell/Toggle.qml @@ -0,0 +1,31 @@ +import QtQuick + +// iOS-style on/off switch. `checked` is the visual state; the parent +// owns truth and handles `toggled` by flipping it. Uses Item.enabled +// for the disabled visual + input gating. +Rectangle { + id: root + property bool checked: false + signal toggled() + + width: 40 + height: 22 + radius: 11 + color: checked ? Theme.accent : Theme.sliderTrack + opacity: enabled ? 1.0 : 0.4 + + Rectangle { + width: 18 + height: 18 + radius: 9 + color: Theme.text + anchors { verticalCenter: parent.verticalCenter } + x: root.checked ? parent.width - width - 2 : 2 + Behavior on x { NumberAnimation { duration: 150 } } + } + + MouseArea { + anchors { fill: parent } + onClicked: root.toggled() + } +} diff --git a/modules/system/quickshell/TrayMenu.qml b/modules/system/quickshell/TrayMenu.qml new file mode 100644 index 0000000..9e2e9f2 --- /dev/null +++ b/modules/system/quickshell/TrayMenu.qml @@ -0,0 +1,39 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell + +Scope { + id: root + property var menuItem: null + property var parentWindow + property var anchorItem + property bool active: false + + function open(item) { + anchorItem = item + active = true + menuAnchor.open() + } + + function close() { + active = false + menuAnchor.close() + } + + QsMenuAnchor { + id: menuAnchor + menu: root.menuItem + + anchor.window: root.parentWindow + anchor.item: root.anchorItem + anchor.edges: Edges.Bottom + anchor.gravity: Edges.Bottom + anchor.margins.top: Theme.popupGap + + onVisibleChanged: { + if (!visible && root.active) { + root.active = false + } + } + } +} diff --git a/modules/system/quickshell/Volume.qml b/modules/system/quickshell/Volume.qml new file mode 100644 index 0000000..c4d1625 --- /dev/null +++ b/modules/system/quickshell/Volume.qml @@ -0,0 +1,206 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Services.Pipewire + +Item { + id: root + width: childrenRect.width + height: parent.height + + // Properties bound directly to the Pipewire service + readonly property PwNode sink: Pipewire.defaultAudioSink + readonly property bool muted: sink && sink.audio ? sink.audio.muted : true + readonly property real volume: sink && sink.audio ? sink.audio.volume : 0 + + // Pipewire nodes only emit property updates while tracked. + PwObjectTracker { + objects: root.sink ? [root.sink] : [] + } + + function setVolume(value) { + if (sink?.ready && sink?.audio) { + sink.audio.muted = false; + sink.audio.volume = value; + } else { + // Fallback for unbound nodes + Quickshell.execDetached(["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", value.toFixed(2)]); + } + } + + function toggleMute() { + if (sink?.ready && sink?.audio) { + sink.audio.muted = !sink.audio.muted; + } else { + Quickshell.execDetached(["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"]); + } + } + + function getDeviceIcon(node) { + return node?.properties?.["device.icon-name"] ?? "audio-card" + } + + Row { + id: triggerRow + anchors { + verticalCenter: parent.verticalCenter + } + spacing: 4 + + Image { + width: 20 + height: 20 + source: Quickshell.iconPath(root.getDeviceIcon(root.sink) + "-symbolic") + sourceSize: Qt.size(width, height) + smooth: true + mipmap: true + opacity: root.muted ? 0.5 : 1.0 + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + GlobalState.toggle("Volume") + } else if (mouse.button === Qt.RightButton) { + root.toggleMute() + } + } + onWheel: wheel => { + const step = 0.05 + const next = wheel.angleDelta.y > 0 ? root.volume + step : root.volume - step + root.setVolume(Math.max(0.0, Math.min(1.0, next))) + } + } + + PopupWindow { + id: popup + visible: GlobalState.activePopup === "Volume" + grabFocus: true + implicitWidth: bgRect.width + implicitHeight: bgRect.height + + anchor { + window: barWindow + item: root + edges: Edges.Bottom + gravity: Edges.Bottom + margins.top: Theme.popupGap + } + + color: "transparent" + + onVisibleChanged: { + if (visible) anchor.updateAnchor() + } + + Squircle { + id: bgRect + width: 260 + height: contentCol.height + 24 + fillColor: Theme.bg + strokeColor: Theme.border + strokeWidth: 1 + cornerRadius: 8 + + Column { + id: contentCol + anchors { + top: parent.top + left: parent.left + right: parent.right + margins: 12 + } + spacing: 12 + + Text { + text: "Sound" + color: Theme.text + font { + family: Theme.mainFont + pixelSize: 14 + weight: Font.DemiBold + } + } + + PillSlider { + id: volumeSlider + width: parent.width + value: root.volume + onMoved: root.setVolume(value) + + Binding { + target: volumeSlider + property: "value" + value: root.volume + when: !volumeSlider.pressed + } + } + + Rectangle { width: parent.width; height: 1; color: Theme.border } + + Text { + text: "Output Devices" + color: Theme.textMuted + font { + family: Theme.mainFont + pixelSize: 12 + weight: Font.DemiBold + } + padding: 4 + } + + Repeater { + model: Pipewire.nodes + delegate: Item { + width: parent.width + property bool isOutput: modelData && modelData.isSink && !modelData.isStream && modelData.name !== "Dummy-Driver" + visible: isOutput + height: isOutput ? 40 : 0 + + Squircle { + anchors.fill: parent + fillColor: mouseArea.containsMouse ? Theme.surfaceLighter : Theme.transparent + cornerRadius: 6 + + RowLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 12 + + IconCircle { + size: 24 + source: root.getDeviceIcon(modelData) + active: root.sink && root.sink.id === modelData.id + } + + Text { + Layout.fillWidth: true + text: modelData.description || modelData.nickname || modelData.name + color: (root.sink && root.sink.id === modelData.id) ? Theme.text : Theme.textDim + font { + family: Theme.mainFont + pixelSize: 13 + } + elide: Text.ElideRight + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + Quickshell.execDetached(["wpctl", "set-default", modelData.id.toString()]) + } + } + } + } + } + } + } + } +} diff --git a/modules/system/quickshell/VolumeOSD.qml b/modules/system/quickshell/VolumeOSD.qml new file mode 100644 index 0000000..8efe305 --- /dev/null +++ b/modules/system/quickshell/VolumeOSD.qml @@ -0,0 +1,90 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire +import Quickshell.Wayland + +// Volume OSD that appears on volume change, styled to match the system menus. +Scope { + id: root + + readonly property PwNode sink: Pipewire.defaultAudioSink + property bool visible: false + + PwObjectTracker { + objects: root.sink ? [root.sink] : [] + } + + Connections { + target: root.sink && root.sink.audio ? root.sink.audio : null + ignoreUnknownSignals: true + + function onVolumeChanged() { + root.visible = true + hideTimer.restart() + } + function onMutedChanged() { + root.visible = true + hideTimer.restart() + } + } + + Timer { + id: hideTimer + interval: 2000 + onTriggered: root.visible = false + } + + PanelWindow { + visible: root.visible + + // Compositor centers horizontally if only bottom anchor is set + anchors.bottom: true + margins.bottom: 100 + exclusiveZone: 0 + + implicitWidth: 240 + implicitHeight: 64 + + WlrLayershell.layer: WlrLayer.Overlay + exclusionMode: ExclusionMode.Ignore + color: Theme.transparent + mask: Region {} // Pass-through clicks + + Squircle { + id: card + anchors.fill: parent + fillColor: Theme.bg + strokeColor: Theme.border + strokeWidth: 1 + cornerRadius: 16 + + RowLayout { + anchors { + fill: parent + margins: 16 + } + spacing: 12 + + IconCircle { + size: 32 + source: { + if (root.sink?.audio?.muted) return "audio-volume-muted" + const vol = root.sink?.audio?.volume ?? 0 + if (vol <= 0) return "audio-volume-low" + if (vol <= 0.33) return "audio-volume-low" + if (vol <= 0.66) return "audio-volume-medium" + return "audio-volume-high" + } + active: !(root.sink?.audio?.muted ?? true) + } + + PillSlider { + Layout.fillWidth: true + value: root.sink?.audio?.volume ?? 0 + enabled: false // OSD is for display only + } + } + } + } +} diff --git a/modules/system/quickshell/Wifi.qml b/modules/system/quickshell/Wifi.qml new file mode 100644 index 0000000..0ce57a0 --- /dev/null +++ b/modules/system/quickshell/Wifi.qml @@ -0,0 +1,397 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Networking +import Quickshell.Widgets + +Item { + id: root + + Scope { + id: internal + + readonly property var device: { + if (!Networking.devices) return null + for (const d of Networking.devices.values || []) { + if (d && d.scannerEnabled !== undefined) return d + } + return null + } + + readonly property var allNetworks: device?.networks ? device.networks.values : [] + + property bool hasKnown: false + property bool hasOther: false + property var activeNetwork: null + + function updateState() { + let known = false + let other = false + let active = null + + for (const n of internal.allNetworks) { + if (n?.connected) active = n + if (n?.known) known = true + if (n && !n.known && n.name) other = true + } + + internal.hasKnown = known + internal.hasOther = other + internal.activeNetwork = active + } + + Connections { + target: internal.device ? internal.device.networks : null + function onValuesChanged() { internal.updateState() } + } + } + + function _getWifiIcon(strength) { + if (!Networking.wifiEnabled) return "network-wireless-offline-symbolic" + if (!internal.activeNetwork && !internal.device?.enabled) return "network-wireless-offline-symbolic" + + const s = strength ?? 0 + if (s >= 0.75) return "network-wireless-signal-excellent-symbolic" + if (s >= 0.50) return "network-wireless-signal-good-symbolic" + if (s >= 0.25) return "network-wireless-signal-ok-symbolic" + if (s > 0) return "network-wireless-signal-weak-symbolic" + return "network-wireless-signal-none-symbolic" + } + + function _onNetworkClick(net) { + if (!net) return + if (net.connected) { net.disconnect(); return } + if (net.stateChanging) return + if (net.known) { + net.connect() + } else if ((net.security ?? 0) === 0) { + net.connect() + } else { + pskPrompt.network = net + pskPrompt.open(net.name ?? "") + } + } + + width: childrenRect.width + height: parent.height + + Component.onCompleted: internal.updateState() + + Row { + anchors { + verticalCenter: parent.verticalCenter + } + spacing: 4 + Image { + width: 20 + height: 20 + source: Quickshell.iconPath(root._getWifiIcon(internal.activeNetwork?.signalStrength ?? 0)) + sourceSize: Qt.size(width, height) + smooth: true + mipmap: true + opacity: !Networking.wifiEnabled ? 0.35 : 1.0 + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + GlobalState.toggle("Wifi") + } else if (mouse.button === Qt.RightButton) { + Networking.wifiEnabled = !Networking.wifiEnabled + } + } + } + + PopupWindow { + id: popup + visible: GlobalState.activePopup === "Wifi" + grabFocus: true + implicitWidth: card.width + implicitHeight: card.height + + anchor { + window: barWindow + item: root + edges: Edges.Bottom + gravity: Edges.Bottom + margins.top: Theme.popupGap + } + + color: Theme.transparent + + onVisibleChanged: { + if (visible) { + anchor.updateAnchor() + if (internal.device) internal.device.scannerEnabled = true + } else if (internal.device?.scannerEnabled) { + internal.device.scannerEnabled = false + } + } + + PopupCard { + id: card + margins: 16 + + // Wi-Fi { Toggle } + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 4 + Layout.bottomMargin: 4 + Layout.leftMargin: 8 + Layout.rightMargin: 8 + spacing: 8 + + Text { + text: "Wi-Fi" + color: Theme.text + font { + family: Theme.mainFont + pixelSize: 14 + weight: Font.DemiBold + } + } + + Item { Layout.fillWidth: true } + + Toggle { + checked: Networking.wifiEnabled + enabled: Networking.wifiHardwareEnabled + onToggled: Networking.wifiEnabled = !Networking.wifiEnabled + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.border + Layout.topMargin: 4 + Layout.bottomMargin: 4 + } + + // Known Network Header + Text { + visible: internal.hasKnown + text: "Known Network" + color: Theme.textMuted + font { + family: Theme.mainFont + pixelSize: 12 + weight: Font.DemiBold + } + Layout.leftMargin: 8 + Layout.topMargin: 2 + Layout.bottomMargin: 2 + } + + // Known networks + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + visible: internal.hasKnown + + Repeater { + model: internal.device?.networks ?? null + + delegate: Squircle { + id: knownItem + required property var modelData + readonly property bool isConnected: knownItem.modelData?.connected ?? false + visible: knownItem.modelData?.known ?? false + Layout.fillWidth: true + Layout.preferredHeight: visible ? 36 : 0 + fillColor: knownArea.containsMouse ? Theme.surface : Theme.transparent + cornerRadius: 6 + + Connections { + target: knownItem.modelData + function onKnownChanged() { internal.updateState() } + function onConnectedChanged() { internal.updateState() } + function onNameChanged() { internal.updateState() } + function onSignalStrengthChanged() { internal.updateState() } + } + + MouseArea { + id: knownArea + anchors.fill: parent + hoverEnabled: true + enabled: !(knownItem.modelData?.stateChanging ?? false) + onClicked: root._onNetworkClick(knownItem.modelData) + } + + RowLayout { + anchors { + fill: parent + leftMargin: 8 + rightMargin: 8 + } + spacing: 12 + + IconCircle { + size: 24 + source: root._getWifiIcon(knownItem.modelData?.signalStrength ?? 0) + active: knownItem.isConnected + } + + Text { + Layout.fillWidth: true + text: knownItem.modelData?.name ?? "" + color: Theme.text + font { + family: Theme.mainFont + pixelSize: 13 + } + elide: Text.ElideRight + } + + IconImage { + visible: (knownItem.modelData?.security ?? 0) !== 0 + Layout.preferredWidth: 14 + Layout.preferredHeight: 14 + source: Quickshell.iconPath("changes-prevent-symbolic") + } + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.border + Layout.topMargin: 4 + Layout.bottomMargin: 4 + } + + // Other Networks Header + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 8 + Layout.rightMargin: 8 + Layout.topMargin: 2 + Layout.bottomMargin: 2 + + Text { + text: "Other Networks" + color: Theme.textMuted + font { + family: Theme.mainFont + pixelSize: 12 + weight: Font.DemiBold + } + Layout.fillWidth: true + } + + Image { + id: refreshIcon + width: 14 + height: 14 + source: Quickshell.iconPath("view-refresh-symbolic") + sourceSize: Qt.size(width, height) + opacity: refreshMouse.containsMouse ? 1.0 : 0.6 + + RotationAnimation on rotation { + running: internal.device?.scannerEnabled ?? false + from: 0 + to: 360 + duration: 1000 + loops: Animation.Infinite + } + + MouseArea { + id: refreshMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (internal.device) { + internal.device.scannerEnabled = !internal.device.scannerEnabled + } + } + } + } + } + + // Other networks list + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + visible: internal.hasOther + + Repeater { + model: internal.device?.networks ?? null + + delegate: Squircle { + id: otherItem + required property var modelData + visible: !(otherItem.modelData?.known ?? true) && (otherItem.modelData?.name ?? "") !== "" + Layout.fillWidth: true + Layout.preferredHeight: visible ? 36 : 0 + fillColor: otherArea.containsMouse ? Theme.surface : Theme.transparent + cornerRadius: 6 + + Connections { + target: otherItem.modelData + function onKnownChanged() { internal.updateState() } + function onNameChanged() { internal.updateState() } + function onSignalStrengthChanged() { internal.updateState() } + } + + MouseArea { + id: otherArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !(otherItem.modelData?.stateChanging ?? false) + onClicked: root._onNetworkClick(otherItem.modelData) + } + + RowLayout { + anchors { + fill: parent + leftMargin: 8 + rightMargin: 8 + } + spacing: 12 + + IconCircle { + size: 24 + source: root._getWifiIcon(otherItem.modelData?.signalStrength ?? 0) + active: false + } + + Text { + Layout.fillWidth: true + text: otherItem.modelData?.name ?? "" + color: Theme.text + opacity: 0.8 + font { + family: Theme.mainFont + pixelSize: 13 + } + elide: Text.ElideRight + } + + IconImage { + visible: (otherItem.modelData?.security ?? 0) !== 0 + Layout.preferredWidth: 14 + Layout.preferredHeight: 14 + source: Quickshell.iconPath("changes-prevent-symbolic") + } + } + } + } + } + } + } + + WifiPasswordPrompt { + id: pskPrompt + property var network: null + onSubmitted: (text, remember) => { + if (network) network.connectWithPsk(text) + } + } +} diff --git a/modules/system/quickshell/WifiPasswordPrompt.qml b/modules/system/quickshell/WifiPasswordPrompt.qml new file mode 100644 index 0000000..c77c351 --- /dev/null +++ b/modules/system/quickshell/WifiPasswordPrompt.qml @@ -0,0 +1,226 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +PanelWindow { + id: root + + // Properties + property string networkName: "" + property string title: "The Wi-Fi network \"" + networkName + "\" requires a password." + property string submitLabel: "Join" + property string iconSource: "network-wireless-symbolic" + + signal submitted(string text, bool remember) + signal cancelled() + + // Methods + function open(name) { + networkName = name + passwordInput.text = "" + showPasswordCheck.checked = false + rememberNetworkCheck.checked = true + visible = true + Qt.callLater(() => passwordInput.forceActiveFocus()) + } + + // Window Configuration + visible: false + color: Theme.transparent + exclusiveZone: 0 + + anchors { + top: true + bottom: true + left: true + right: true + } + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + HyprlandFocusGrab { + id: focusGrab + windows: [root] + active: root.visible + } + + // UI Layout + Rectangle { + anchors.fill: parent + color: Theme.scrim + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.AllButtons + hoverEnabled: true + onWheel: wheel => wheel.accepted = true + } + } + + Squircle { + id: dialog + anchors.centerIn: parent + width: 480 + height: Math.max(180, layout.implicitHeight + 40) + fillColor: Theme.bg + strokeColor: Theme.border + strokeWidth: 1 + cornerRadius: 12 + + Keys.onEscapePressed: { + root.visible = false + root.cancelled() + } + + RowLayout { + id: layout + anchors.fill: parent + anchors.margins: 20 + spacing: 16 + + Item { + Layout.alignment: Qt.AlignTop + Layout.preferredWidth: 64 + Layout.preferredHeight: 64 + + Image { + anchors.fill: parent + source: Quickshell.iconPath(root.iconSource) + fillMode: Image.PreserveAspectFit + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 8 + + Text { + Layout.fillWidth: true + text: root.title + color: Theme.text + font.pixelSize: 13 + font.weight: Font.DemiBold + wrapMode: Text.WordWrap + } + + GridLayout { + Layout.fillWidth: true + columns: 2 + rowSpacing: 8 + columnSpacing: 8 + + Text { + text: "Password:" + color: Theme.text + font.pixelSize: 12 + Layout.alignment: Qt.AlignRight | Qt.AlignTop + Layout.topMargin: 6 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 8 + + TextField { + id: passwordInput + focus: true + Layout.fillWidth: true + Layout.preferredHeight: 28 + font.pixelSize: 12 + color: Theme.text + echoMode: showPasswordCheck.checked ? TextInput.Normal : TextInput.Password + + background: Rectangle { + color: Theme.surface + radius: 4 + border.color: passwordInput.activeFocus ? Theme.accent : Theme.border + border.width: passwordInput.activeFocus ? 2 : 1 + } + + onAccepted: { + root.submitted(passwordInput.text, rememberNetworkCheck.checked) + root.visible = false + } + } + + CustomCheckBox { + id: showPasswordCheck + text: "Show password" + Layout.fillWidth: true + } + + CustomCheckBox { + id: rememberNetworkCheck + text: "Remember this network" + checked: true + Layout.fillWidth: true + } + } + } + + Item { Layout.preferredHeight: 4 } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Item { Layout.fillWidth: true } + + Button { + text: "Cancel" + + contentItem: Text { + text: parent.text + color: Theme.text + font.pixelSize: 13 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + implicitWidth: 80 + implicitHeight: 28 + color: parent.hovered ? Theme.surfaceHover : Theme.surface + radius: 6 + border.color: Theme.border + border.width: 1 + } + + onClicked: { + root.visible = false + root.cancelled() + } + } + + Button { + text: root.submitLabel + + contentItem: Text { + text: parent.text + color: Theme.text + font.pixelSize: 13 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + implicitWidth: 80 + implicitHeight: 28 + color: parent.hovered ? Theme.accentHover : Theme.accent + radius: 6 + } + + onClicked: { + root.submitted(passwordInput.text, rememberNetworkCheck.checked) + root.visible = false + } + } + } + } + } + } +} diff --git a/modules/system/quickshell/Workspaces.qml b/modules/system/quickshell/Workspaces.qml new file mode 100644 index 0000000..b63826d --- /dev/null +++ b/modules/system/quickshell/Workspaces.qml @@ -0,0 +1,99 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +Item { + id: root + + implicitWidth: row.implicitWidth + implicitHeight: 32 + + ListModel { id: workspaceModel } + + function updateWorkspaces(json) { + try { + const ws = JSON.parse(json) + workspaceModel.clear() + ws.forEach(w => workspaceModel.append({ + wsNum: w.num, + wsName: w.name, + wsFocused: w.focused + })) + } catch (_) {} + } + + Process { + id: watcher + command: ["swaymsg", "-t", "subscribe", "-m", "[\"workspace\"]"] + running: true + + stdout: SplitParser { + onRead: _ => { + if (!refresher.running) refresher.running = true + } + } + } + + Process { + id: refresher + command: ["swaymsg", "-t", "get_workspaces", "-r"] + + property string buf: "" + + stdout: SplitParser { + onRead: line => refresher.buf += line + } + + onRunningChanged: { + if (!running && buf !== "") { + root.updateWorkspaces(buf) + buf = "" + } + } + + Component.onCompleted: running = true + } + + Row { + id: row + anchors { + verticalCenter: parent.verticalCenter + } + spacing: 2 + + Repeater { + model: workspaceModel + + delegate: Rectangle { + required property int wsNum + required property string wsName + required property bool wsFocused + + width: 26 + height: 26 + color: wsFocused ? Theme.focus : Theme.transparent + radius: 3 + + Text { + anchors.centerIn: parent + text: wsNum + color: Theme.text + font.pixelSize: 12 + } + + Process { + id: switcher + command: ["swaymsg", "workspace", "number", wsNum.toString()] + } + + MouseArea { + anchors.fill: parent + onClicked: { + switcher.running = false + switcher.running = true + } + } + } + } + } +} diff --git a/modules/system/quickshell/pam/password.conf b/modules/system/quickshell/pam/password.conf new file mode 100644 index 0000000..7e5d75a --- /dev/null +++ b/modules/system/quickshell/pam/password.conf @@ -0,0 +1 @@ +auth required pam_unix.so diff --git a/modules/system/quickshell/qmldir b/modules/system/quickshell/qmldir new file mode 100644 index 0000000..1c0341b --- /dev/null +++ b/modules/system/quickshell/qmldir @@ -0,0 +1,33 @@ +Bar Bar.qml +Background Background.qml +Bluetooth Bluetooth.qml +BrightnessService BrightnessService.qml +ControlCenter ControlCenter.qml +singleton GlobalState GlobalState.qml +IconCircle IconCircle.qml +Media Media.qml +MusicVisualizer MusicVisualizer.qml +Notifications Notifications.qml +NotificationCard NotificationCard.qml +NotificationPopupList NotificationPopupList.qml +Polkit Polkit.qml +PillSlider PillSlider.qml +PopupCard PopupCard.qml +Squircle Squircle.qml +singleton Theme Theme.qml +ThinSlider ThinSlider.qml +Toggle Toggle.qml +TrayMenu TrayMenu.qml +VolumeOSD VolumeOSD.qml +Volume Volume.qml +Wifi Wifi.qml +WifiPasswordPrompt WifiPasswordPrompt.qml +Workspaces Workspaces.qml +CustomCheckBox CustomCheckBox.qml +MediaCard MediaCard.qml +ConnectivityBox ConnectivityBox.qml +ControlTile ControlTile.qml +SliderBox SliderBox.qml +Launcher Launcher.qml +LockContext LockContext.qml +LockSurface LockSurface.qml diff --git a/modules/system/quickshell/shell.qml b/modules/system/quickshell/shell.qml new file mode 100644 index 0000000..a61edad --- /dev/null +++ b/modules/system/quickshell/shell.qml @@ -0,0 +1,68 @@ +//@ pragma UseQApplication +import Quickshell +import QtQuick +import Quickshell.Io +import Quickshell.Wayland + +ShellRoot { + Component.onCompleted: { + Qt.application.font.family = "Inter" + Qt.application.font.hintingPreference = Font.PreferNoHinting + Qt.application.font.styleStrategy = Font.NoSubpixelAntialias + } + + Variants { + model: Quickshell.screens + + Bar { + required property var modelData + screen: modelData + } + } + + Variants { + model: Quickshell.screens + + Background { + required property var modelData + screen: modelData + } + } + + Notifications {} + + VolumeOSD {} + Polkit {} + Launcher {} + + LockContext { + id: lockContext + onUnlocked: { + sessionLock.locked = false; + } + } + + WlSessionLock { + id: sessionLock + + WlSessionLockSurface { + LockSurface { + anchors.fill: parent + context: lockContext + } + } + } + + IpcHandler { + target: "bar" + function toggleLauncher() { + GlobalState.toggle("Launcher") + } + + function lock() { + lockContext.reset(); + sessionLock.locked = true + } + } +} + diff --git a/modules/system/quickshell/squircle.frag b/modules/system/quickshell/squircle.frag new file mode 100644 index 0000000..df2477f --- /dev/null +++ b/modules/system/quickshell/squircle.frag @@ -0,0 +1,47 @@ +#version 440 +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float width; + float height; + float cornerRadius; + float strokeWidth; + vec4 fillColor; + vec4 strokeColor; +} ubuf; + +float squircleSDF(vec2 p, vec2 size, float r) { + vec2 q = abs(p) - size + vec2(r); + vec2 cornerSpace = max(q, 0.0); + + float p_norm = pow(cornerSpace.x, 4.5) + pow(cornerSpace.y, 4.5); + float cornerDist = pow(p_norm, 1.0 / 4.5); + + return cornerDist + min(max(q.x, q.y), 0.0) - r; +} + +void main() { + vec2 halfSize = vec2(ubuf.width, ubuf.height) * 0.5; + vec2 p = (qt_TexCoord0 * vec2(ubuf.width, ubuf.height)) - halfSize; + + // Applied the scaling factor mentioned in the original comment + float r = ubuf.cornerRadius * 1.5286; + + float dist = squircleSDF(p, halfSize, r); + float fwidth_dist = fwidth(dist); + + // Corrected edge parameter order to avoid undefined behavior + float alpha = 1.0 - smoothstep(-fwidth_dist, fwidth_dist, dist); + + if (ubuf.strokeWidth > 0.0) { + float innerDist = dist + ubuf.strokeWidth; + float innerAlpha = 1.0 - smoothstep(-fwidth_dist, fwidth_dist, innerDist); + vec4 color = mix(ubuf.strokeColor, ubuf.fillColor, innerAlpha); + fragColor = color * alpha * ubuf.qt_Opacity; + } else { + fragColor = ubuf.fillColor * alpha * ubuf.qt_Opacity; + } +} diff --git a/modules/system/quickshell/squircle.qsb b/modules/system/quickshell/squircle.qsb Binary files differnew file mode 100644 index 0000000..b69fb4e --- /dev/null +++ b/modules/system/quickshell/squircle.qsb diff --git a/modules/system/quickshell/wallpaper.jpg b/modules/system/quickshell/wallpaper.jpg Binary files differnew file mode 100644 index 0000000..5cd2332 --- /dev/null +++ b/modules/system/quickshell/wallpaper.jpg diff --git a/modules/system/sway.nix b/modules/system/sway.nix index ddbb66c..17d954c 100644 --- a/modules/system/sway.nix +++ b/modules/system/sway.nix @@ -16,12 +16,15 @@ enable = true; wlr.enable = true; extraPortals = [ pkgs.kdePackages.xdg-desktop-portal-kde ]; - config.common.default = [ "wlr" "kde" ]; + config.common.default = [ + "wlr" + "kde" + ]; }; services.greetd = { enable = true; - settings.default_session.command = "${pkgs.greetd.tuigreet}/bin/tuigreet --time --cmd sway"; + settings.default_session.command = "${pkgs.tuigreet}/bin/tuigreet --time --cmd sway"; }; environment.sessionVariables = { @@ -31,12 +34,6 @@ flake.modules.homeManager.sway = { lib, pkgs, ... }: - let - wallpaper = pkgs.fetchurl { - url = "https://cloud.schererleander.de/s/BgqELb7xBXna4iX/download"; - sha256 = "0r9jcsn188yygnp6i8x03h75hqwd5g79f07lym165xd33xhgls5x"; - }; - in { gtk = { enable = true; @@ -67,6 +64,12 @@ size = 24; }; + home.packages = with pkgs; [ + kdePackages.dolphin + kdePackages.kdegraphics-thumbnailers + kdePackages.ffmpegthumbs + ]; + wayland.windowManager.sway = { enable = true; wrapperFeatures.gtk = true; @@ -87,7 +90,7 @@ # disabled as mo27q28g implementation sucks, a lot of brightness flicker #adaptive_sync = "true"; hdr = "on"; - bg = "${wallpaper} fill"; + bg = "#000000 solid_color"; }; }; @@ -98,6 +101,20 @@ window = { titlebar = false; border = 0; + commands = [ + { + command = "floating enable, resize set width 400px height 500px, move position cursor, move down 32"; + criteria = { app_id = "com.nextcloud.desktopclient.nextcloud"; title = "Nextcloud"; }; + } + { + command = "floating enable, resize set width 400px height 500px, move position cursor, move down 32"; + criteria = { app_id = "nextcloud"; title = "Nextcloud"; }; + } + { + command = "floating enable, resize set width 400px height 500px, move position cursor, move down 32"; + criteria = { class = "Nextcloud"; title = "Nextcloud"; }; + } + ]; }; bars = [ ]; @@ -113,38 +130,26 @@ "exec ${pkgs.grim}/bin/grim -g \"$(${pkgs.slurp}/bin/slurp)\" - | ${pkgs.ffmpeg}/bin/ffmpeg -i - -vf \"zscale=primariesin=bt2020:transferin=smpte2084:primaries=bt709:transfer=bt709\" -f image2pipe -c:v png - | tee ~/Pictures/screenshot-$(date +%Y%m%d_%H%M%S).png | ${pkgs.wl-clipboard}/bin/wl-copy"; "${modifier}+v" = "exec ${pkgs.cliphist}/bin/cliphist list | ${pkgs.wmenu}/bin/wmenu -nb #000000 -nf #ffffff -sb #285577 -sf #ffffff | ${pkgs.cliphist}/bin/cliphist decode | ${pkgs.wl-clipboard}/bin/wl-copy"; - "${modifier}+l" = "exec ${pkgs.swaylock}/bin/swaylock -f -c 000000"; + "${modifier}+l" = "exec quickshell -c bar ipc call bar lock"; + "${modifier}+space" = "exec quickshell -c bar ipc call bar toggleLauncher"; }; startup = [ { command = "${pkgs.wl-clipboard}/bin/wl-paste --watch ${pkgs.cliphist}/bin/cliphist store"; } - { command = "${pkgs.kdePackages.polkit-kde-agent-1}/libexec/polkit-kde-authentication-agent-1"; } { - command = "${pkgs.swayidle}/bin/swayidle -w" + command = + "${pkgs.swayidle}/bin/swayidle -w" + " timeout 600 'swaymsg \"output * dpms off\"'" + " resume 'swaymsg \"output * dpms on\"'" - + " timeout 1800 '${pkgs.swaylock}/bin/swaylock -f -c 000000'" - + " before-sleep '${pkgs.swaylock}/bin/swaylock -f -c 000000'"; + + " timeout 800 'quickshell -c bar ipc call bar lock'" + + " before-sleep 'quickshell -c bar ipc call bar lock'"; } ]; - menu = "${pkgs.wmenu}/bin/wmenu-run -b -nb #000000 -nf #ffffff -sb #285577 -sf #ffffff"; + menu = "quickshell -c bar ipc call bar toggleLauncher"; defaultWorkspace = "workspace number 1"; }; }; - services.mako = { - enable = true; - settings = { - background-color = "#000000FF"; - text-color = "#FFFFFFFF"; - border-color = "#285577FF"; - border-size = 2; - border-radius = 0; - margin = "15"; - default-timeout = 5000; - }; - }; - }; } diff --git a/modules/users/schererleander/hm-linux.nix b/modules/users/schererleander/hm-linux.nix index ab26450..bc3022c 100644 --- a/modules/users/schererleander/hm-linux.nix +++ b/modules/users/schererleander/hm-linux.nix @@ -1,10 +1,11 @@ { flake.modules.homeManager.schererleander-linux = - { inputs, pkgs, ... }: + { inputs, ... }: { imports = with inputs.self.modules.homeManager; [ schererleander-base sway + quickshell firefox anki nextcloud-client |
