From 83a0ac8899ce6d9ad53e7736a56f0d1d3e81bb13 Mon Sep 17 00:00:00 2001 From: kossLAN Date: Fri, 4 Jul 2025 02:16:35 -0400 Subject: [PATCH] progress maybe, maybe not --- shell/ShellSettings.qml | 11 +- shell/bar/volume/VolumeControl.qml | 51 +--- shell/experimental-bar/ActiveWindow.qml | 12 + shell/experimental-bar/Border.qml | 117 +++++++++ shell/experimental-bar/Clock.qml | 21 ++ shell/experimental-bar/Controller.qml | 102 ++++++++ shell/experimental-bar/Workspaces.qml | 73 ++++++ .../notifications/NotificationButton.qml | 70 ++++++ shell/experimental-bar/popups/MenuWindow.qml | 233 ++++++++++++++++++ .../power/BatteryIndicator.qml | 102 ++++++++ shell/experimental-bar/systray/SysTray.qml | 96 ++++++++ .../systray/TrayMenuEntry.qml | 170 +++++++++++++ .../experimental-bar/systray/TrayMenuItem.qml | 29 +++ .../volume/ApplicationMixer.qml | 70 ++++++ shell/experimental-bar/volume/DeviceMixer.qml | 64 +++++ shell/experimental-bar/volume/VolumeCard.qml | 50 ++++ .../experimental-bar/volume/VolumeControl.qml | 34 +++ .../volume/VolumeIndicator.qml | 27 ++ shell/shell.qml | 3 +- shell/widgets/FontIcon.qml | 34 +++ shell/widgets/TabBar.qml | 92 +++++++ 21 files changed, 1412 insertions(+), 49 deletions(-) create mode 100644 shell/experimental-bar/ActiveWindow.qml create mode 100644 shell/experimental-bar/Border.qml create mode 100644 shell/experimental-bar/Clock.qml create mode 100644 shell/experimental-bar/Controller.qml create mode 100644 shell/experimental-bar/Workspaces.qml create mode 100644 shell/experimental-bar/notifications/NotificationButton.qml create mode 100644 shell/experimental-bar/popups/MenuWindow.qml create mode 100644 shell/experimental-bar/power/BatteryIndicator.qml create mode 100644 shell/experimental-bar/systray/SysTray.qml create mode 100644 shell/experimental-bar/systray/TrayMenuEntry.qml create mode 100644 shell/experimental-bar/systray/TrayMenuItem.qml create mode 100644 shell/experimental-bar/volume/ApplicationMixer.qml create mode 100644 shell/experimental-bar/volume/DeviceMixer.qml create mode 100644 shell/experimental-bar/volume/VolumeCard.qml create mode 100644 shell/experimental-bar/volume/VolumeControl.qml create mode 100644 shell/experimental-bar/volume/VolumeIndicator.qml create mode 100644 shell/widgets/FontIcon.qml create mode 100644 shell/widgets/TabBar.qml diff --git a/shell/ShellSettings.qml b/shell/ShellSettings.qml index 75882cc..7d27c72 100644 --- a/shell/ShellSettings.qml +++ b/shell/ShellSettings.qml @@ -6,12 +6,13 @@ import Quickshell.Io Singleton { property alias settings: jsonAdapter.settings + property alias sizing: jsonAdapter.sizing property alias colors: jsonAdapter.colors FileView { path: `${Quickshell.env("XDG_DATA_HOME")}/quickshell/settings.json` watchChanges: true - // onFileChanged: reload() + onFileChanged: reload() onAdapterUpdated: writeAdapter() blockLoading: true @@ -19,12 +20,18 @@ Singleton { id: jsonAdapter property JsonObject settings: JsonObject { - property int barHeight: 25 + property string wallpaperUrl: Qt.resolvedUrl("root:resources/wallpapers/pixelart0.jpg") property string colorScheme: "scheme-fruit-salad" property string screenshotPath: "/home/koss/Pictures" } + property JsonObject sizing: JsonObject { + property int borderWidth: 5 + property int topBorderWidth: 20 + property int gaps: 5 + } + property var colors: { "background": "#131313", "error": "#ffb4ab", diff --git a/shell/bar/volume/VolumeControl.qml b/shell/bar/volume/VolumeControl.qml index 5a743f0..4423de1 100644 --- a/shell/bar/volume/VolumeControl.qml +++ b/shell/bar/volume/VolumeControl.qml @@ -2,10 +2,8 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import Qt5Compat.GraphicalEffects import Quickshell.Widgets import "../../widgets/" as Widgets -import "../.." WrapperItem { id: root @@ -14,55 +12,16 @@ WrapperItem { ColumnLayout { spacing: 10 - // Toolbar - Rectangle { - id: toolbar - color: ShellSettings.colors["surface_container_highest"] - radius: 10 - layer.enabled: true - layer.effect: OpacityMask { - maskSource: Rectangle { - width: toolbar.width - height: toolbar.height - radius: toolbar.radius - color: "black" - } - } - + Widgets.TabBar { + id: tabBar + model: ["headphones", "tune"] Layout.fillWidth: true - Layout.preferredHeight: 30 - - RowLayout { - spacing: 0 - anchors.fill: parent - - Widgets.FontIconButton { - hoverEnabled: false - iconName: "headphones" - radius: 0 - checked: page.currentIndex === 0 - onClicked: page.currentIndex = 0 - - Layout.fillWidth: true - Layout.fillHeight: true - } - - Widgets.FontIconButton { - hoverEnabled: false - iconName: "tune" - radius: 0 - checked: page.currentIndex === 1 - onClicked: page.currentIndex = 1 - - Layout.fillWidth: true - Layout.fillHeight: true - } - } + Layout.preferredHeight: 35 } StackLayout { id: page - currentIndex: 0 + currentIndex: tabBar.currentIndex Layout.fillWidth: true Layout.preferredHeight: currentItem ? currentItem.implicitHeight : 0 diff --git a/shell/experimental-bar/ActiveWindow.qml b/shell/experimental-bar/ActiveWindow.qml new file mode 100644 index 0000000..ecf7b46 --- /dev/null +++ b/shell/experimental-bar/ActiveWindow.qml @@ -0,0 +1,12 @@ +import QtQuick +import Quickshell.Wayland +import ".." + +Text { + id: windowText + text: ToplevelManager.activeToplevel?.title ?? "" + color: ShellSettings.colors["inverse_surface"] + font.pointSize: 11 + visible: text !== "" + elide: Text.ElideRight +} diff --git a/shell/experimental-bar/Border.qml b/shell/experimental-bar/Border.qml new file mode 100644 index 0000000..30142c7 --- /dev/null +++ b/shell/experimental-bar/Border.qml @@ -0,0 +1,117 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import Quickshell +import ".." + +Scope { + id: root + + required property var screen + property alias topWindow: topPanel + property alias top: topPanel.data + + PanelWindow { + id: overlay + color: "transparent" + screen: root.modelData + mask: Region {} + + anchors { + left: true + right: true + top: true + bottom: true + } + + Item { + anchors.fill: parent + + Rectangle { + anchors.fill: parent + color: ShellSettings.colors["surface"] + // visible: false + + layer.enabled: true + layer.effect: MultiEffect { + maskEnabled: true + maskSource: mask + maskInverted: true // Changed from true to false + maskThresholdMin: 0.5 + maskSpreadAtMin: 1 + } + } + + Item { + id: mask + anchors.fill: parent + layer.enabled: true + visible: false + + Rectangle { + color: "white" + radius: 15 + + anchors { + fill: parent + margins: ShellSettings.sizing.borderWidth + topMargin: ShellSettings.sizing.topBorderWidth + } + } + } + } + } + + PanelWindow { + id: topPanel + screen: root.modelData + color: "transparent" + implicitHeight: ShellSettings.sizing.topBorderWidth + + anchors { + top: true + left: true + right: true + } + } + + PanelWindow { + id: bottomPanel + screen: root.modelData + color: "transparent" + implicitHeight: ShellSettings.sizing.borderWidth + + anchors { + bottom: true + left: true + right: true + } + } + + PanelWindow { + id: leftPanel + screen: root.modelData + color: "transparent" + implicitWidth: ShellSettings.sizing.borderWidth + + anchors { + top: true + bottom: true + left: true + } + } + + PanelWindow { + id: rightPanel + screen: root.modelData + color: "transparent" + implicitWidth: ShellSettings.sizing.borderWidth + + anchors { + top: true + bottom: true + right: true + } + } +} diff --git a/shell/experimental-bar/Clock.qml b/shell/experimental-bar/Clock.qml new file mode 100644 index 0000000..eee72e8 --- /dev/null +++ b/shell/experimental-bar/Clock.qml @@ -0,0 +1,21 @@ +import QtQuick +import Quickshell + +Text { + property string ap: sysClock.hours >= 12 ? "PM" : "AM" + property string minutes: sysClock.minutes.toString().padStart(2, '0') + property string hours: { + var value = sysClock.hours % 12; + if (value === 0) + return 12; + return value; + } + + SystemClock { + id: sysClock + enabled: true + } + + text: `${hours}:${minutes} ${ap}` + font.pointSize: 11 +} diff --git a/shell/experimental-bar/Controller.qml b/shell/experimental-bar/Controller.qml new file mode 100644 index 0000000..51aa6f3 --- /dev/null +++ b/shell/experimental-bar/Controller.qml @@ -0,0 +1,102 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import QtQuick.Layouts +import "power" +import "volume" +import "systray" as SysTray +import "popups" as Popup +import "../widgets" +import ".." + +Scope { + id: root + + Variants { + model: Quickshell.screens + + Border { + id: border + screen: modelData + + required property var modelData + + top: RowLayout { + id: top + spacing: 0 + + anchors { + fill: parent + leftMargin: 8 + rightMargin: 8 + } + + Popup.MenuWindow { + id: popupWindow + bar: border.topWindow + } + + RowLayout { + spacing: 5 + Layout.fillWidth: true + Layout.fillHeight: true + + Workspaces { + screen: border.screen + Layout.fillHeight: true + } + + Separator { + visible: activeWindow.visible + Layout.leftMargin: 5 + Layout.rightMargin: 5 + } + + ActiveWindow { + id: activeWindow + Layout.preferredWidth: 400 + } + } + + RowLayout { + spacing: 5 + Layout.fillWidth: true + Layout.fillHeight: true + Layout.alignment: Qt.AlignRight + + SysTray.SysTray { + id: sysTray + popup: popupWindow + Layout.fillHeight: true + } + + VolumeIndicator { + id: volumeIndicator + popup: popupWindow + Layout.preferredWidth: this.height + Layout.fillHeight: true + Layout.topMargin: 2 + Layout.bottomMargin: 2 + } + + BatteryIndicator { + id: batteryIndicator + popup: popupWindow + Layout.fillHeight: true + } + + Separator { + // Layout.leftMargin: 5 + Layout.rightMargin: 5 + } + + Clock { + id: clock + color: ShellSettings.colors["inverse_surface"] + } + } + } + } + } +} diff --git a/shell/experimental-bar/Workspaces.qml b/shell/experimental-bar/Workspaces.qml new file mode 100644 index 0000000..ee70c79 --- /dev/null +++ b/shell/experimental-bar/Workspaces.qml @@ -0,0 +1,73 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import ".." + +RowLayout { + spacing: 6 + visible: Hyprland.monitors.values.length != 0 + + required property var screen + + Repeater { + id: workspaceButtons + + model: ScriptModel { + values: Hyprland.workspaces.values.slice().filter( + workspace => workspace.monitor === Hyprland.monitorFor(screen) + ) + } + + Rectangle { + required property var modelData + + radius: height / 2 + Layout.alignment: Qt.AlignVCenter + Layout.preferredHeight: 12 + Layout.preferredWidth: { + if (Hyprland.focusedMonitor?.activeWorkspace?.id === modelData?.id) + return 25; + + return 12; + } + + color: { + let value = Qt.color(ShellSettings.colors["secondary"]).darker(2); + + if (!modelData?.id || !Hyprland.focusedMonitor?.activeWorkspace?.id) + return value; + + if (workspaceButton.containsMouse) { + value = ShellSettings.colors["on_primary"]; + } else if (Hyprland.focusedMonitor.activeWorkspace.id === modelData.id) { + value = ShellSettings.colors["primary"]; + } + + return value; + } + + Behavior on Layout.preferredWidth { + SmoothedAnimation { + duration: 150 + velocity: 200 + easing.type: Easing.OutCubic + } + } + + Behavior on color { + ColorAnimation { + duration: 100 + easing.type: Easing.OutQuad + } + } + + MouseArea { + id: workspaceButton + anchors.fill: parent + hoverEnabled: true + onPressed: Hyprland.dispatch(`workspace ${parent.modelData.id}`) + } + } + } +} diff --git a/shell/experimental-bar/notifications/NotificationButton.qml b/shell/experimental-bar/notifications/NotificationButton.qml new file mode 100644 index 0000000..028244f --- /dev/null +++ b/shell/experimental-bar/notifications/NotificationButton.qml @@ -0,0 +1,70 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Qt5Compat.GraphicalEffects +import Quickshell.Widgets +import "../.." + +Item { + id: root + required property var bar + property var implicitSize: 0 + readonly property real actualSize: Math.min(root.width, root.height) + + implicitWidth: parent.height + implicitHeight: parent.height + + NotificationCenter { + id: notificationCenter + } + + Rectangle { + color: mouseArea.containsMouse ? ShellSettings.colors["primary"] : "transparent" + radius: 5 + + anchors { + fill: parent + margins: 1 + } + } + + MouseArea { + id: mouseArea + hoverEnabled: true + anchors.fill: parent + onPressed: { + if (notificationCenter.visible) { + notificationCenter.hide(); + } else { + notificationCenter.show(); + } + } + } + + Item { + implicitWidth: root.implicitSize + implicitHeight: root.implicitSize + anchors.centerIn: parent + + layer.enabled: true + layer.effect: OpacityMask { + source: Rectangle { + width: root.actualSize + height: root.actualSize + color: "white" + } + + maskSource: IconImage { + implicitSize: root.actualSize + source: "root:resources/general/notification.svg" + } + } + + Rectangle { + color: mouseArea.containsMouse ? ShellSettings.colors["inverse_primary"] : ShellSettings.colors["inverse_surface"] + anchors.fill: parent + } + } + + // TODO: notification number overlay +} diff --git a/shell/experimental-bar/popups/MenuWindow.qml b/shell/experimental-bar/popups/MenuWindow.qml new file mode 100644 index 0000000..0f88e1f --- /dev/null +++ b/shell/experimental-bar/popups/MenuWindow.qml @@ -0,0 +1,233 @@ +import Quickshell +import Quickshell.Hyprland +import Quickshell.Widgets +import QtQuick +import QtQuick.Shapes +// import QtQuick.Effects +import "../.." + +// In need of heavy refactor +PopupWindow { + id: root + color: "transparent" + implicitWidth: bar.width + implicitHeight: Math.max(popupContainer.height, 800) + 20 + + mask: Region { + item: popupContainer + } + + anchor { + window: bar + rect: Qt.rect(0, 0, bar.width, bar.height) + edges: Edges.Bottom | Edges.Left + gravity: Edges.Bottom | Edges.Right + adjustment: PopupAdjustment.None + } + + required property var bar + property var isOpen: false + property var padding: ShellSettings.sizing.borderWidth + property var radius: 12 + property var item + property var content + + function set(item, content) { + root.item = item; + root.content = content; + popupContent.data = content; + + let itemPos = item.mapToItem(root.bar.contentItem, 0, root.bar.height, item.width, 0).x; + position(itemPos); + + popupContainer.opacity = 0; + popupContent.opacity = 0; + } + + function position(itemPos) { + if (itemPos === undefined) + return; + + let rightEdge = itemPos + popupContainer.implicitWidth; + let maxRightEdge = root.width - padding; + let isTouchingRightEdge = rightEdge > maxRightEdge; + + if (isTouchingRightEdge) { + // touching right edge, reposition + // console.log("touching right edge"); + popupContainer.x = maxRightEdge - popupContainer.implicitWidth; + popupContainer.y = 0; + popupContainer.bottomLeftRadius = radius; + popupContainer.bottomRightRadius = 0; + } else { + // not touching right edge + popupContainer.x = itemPos; + popupContainer.y = 0; + popupContainer.bottomLeftRadius = radius; + popupContainer.bottomRightRadius = radius; + } + } + + function show() { + grab.active = true; + isOpen = true; + root.visible = true; // set and leave open + root.content.visible = true; + popupContainer.opacity = 1; + popupContent.opacity = 1; + } + + function hide() { + grab.active = false; + isOpen = false; + popupContainer.opacity = 0; + popupContent.opacity = 0; + + root.item = undefined; + root.content = undefined; + popupContent.data = []; + } + + function toggle() { + if (isOpen) { + hide(); + } else { + show(); + } + } + + // RectangularShadow { + // radius: popupContainer.radius + // anchors.fill: popupContainer + // opacity: popupContainer.opacity + // visible: popupContainer.visible + // blur: 10 + // spread: 2 + // } + + Shape { + id: shapeContainer + // anchors.fill: popupContainer + width: implicitWidth + height: implicitHeight + opacity: popupContainer.opacity + + + WrapperRectangle { + id: popupContainer + color: ShellSettings.colors["surface"] + margin: 8 + clip: true + opacity: 0 + // visible: opacity > 0 + // x: root.bar.width + + // spooky, likely to cause problems lol + width: implicitWidth + height: implicitHeight + + onVisibleChanged: root.visible = visible + + // needed to handle occurrences where items are resized while open + onImplicitWidthChanged: { + if (root.isOpen && popupContent.data !== []) { + // console.log("repositioning popup"); + let itemPos = root.item.mapToItem(root.bar.contentItem, 0, root.bar.height, root.item.width, 0).x; + root.position(itemPos); + } + } + + Item { + id: popupContent + implicitWidth: Math.max(root.content?.width, 60) + implicitHeight: Math.max(childrenRect.height, 60) + + Behavior on opacity { + NumberAnimation { + duration: 200 + easing.type: Easing.Linear + from: 0 + to: 1 + } + } + } + + HyprlandFocusGrab { + id: grab + windows: [root, root.bar] + onCleared: { + root.hide(); + } + } + + Behavior on width { + enabled: root.isOpen + SmoothedAnimation { + duration: 200 + easing.type: Easing.Linear + } + } + + Behavior on height { + SmoothedAnimation { + duration: 200 + easing.type: Easing.Linear + } + } + + Behavior on x { + enabled: root.isOpen + SmoothedAnimation { + duration: 200 + easing.type: Easing.OutQuad + } + } + } + + ShapePath { + strokeWidth: -1 + fillColor: ShellSettings.colors["surface"] + startX: popupContainer.x - 25 + startY: popupContainer.y + + PathLine { + relativeX: 25 + relativeY: 0 + } + + PathLine { + relativeX: 0 + relativeY: 25 + } + + PathArc { + direction: PathArc.Counterclockwise + relativeX: -25 + relativeY: -25 + radiusX: 25 + radiusY: 25 + useLargeArc: false + } + + // PathLine { + // x: 0 + // y: 12 + // } // Vertical line down + // PathLine { + // x: 12 + // y: 12 + // } // Horizontal line to the right + // PathLine { + // x: 12 + // y: 0 + // } // Horizontal line back to the top + } + + Behavior on opacity { + NumberAnimation { + duration: 200 + easing.type: Easing.Linear + } + } + } +} diff --git a/shell/experimental-bar/power/BatteryIndicator.qml b/shell/experimental-bar/power/BatteryIndicator.qml new file mode 100644 index 0000000..41681d9 --- /dev/null +++ b/shell/experimental-bar/power/BatteryIndicator.qml @@ -0,0 +1,102 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell.Widgets +import Quickshell.Services.UPower +import "../../widgets" as Widgets +import "../.." + +// todo: redo the tray icon handling +Item { + id: root + implicitWidth: height + 8 // for margin + visible: UPower.displayDevice.isLaptopBattery + + required property var popup + + Widgets.MaterialButton { + id: batteryButton + hoverEnabled: true + onClicked: { + if (root.popup.content == powerMenu) { + root.popup.hide(); + return; + } + + root.popup.set(this, powerMenu); + root.popup.show(); + } + + anchors { + fill: parent + margins: 1 + } + + Item { + implicitWidth: parent.height + implicitHeight: parent.height + anchors.centerIn: parent + layer.enabled: true + layer.effect: OpacityMask { + source: Rectangle { + width: root.width + height: root.height + color: "white" + } + + maskSource: IconImage { + implicitSize: root.width + source: "root:resources/battery/battery.svg" + } + } + + Rectangle { + id: batteryBackground + color: Qt.color(ShellSettings.colors["surface"]).lighter(4) + opacity: 0.75 + anchors { + fill: parent + margins: 2 + } + } + + Rectangle { + id: batteryPercentage + width: (parent.width - 4) * UPower.displayDevice.percentage + color: ShellSettings.colors["inverse_surface"] + + anchors { + left: batteryBackground.left + top: batteryBackground.top + bottom: batteryBackground.bottom + } + } + } + } + + Item { + id: powerMenu + visible: false + implicitWidth: 250 + implicitHeight: 80 + + RowLayout { + anchors.fill: parent + + // ComboBox { + // model: ScriptModel { + // values: ["Power Save", "Balanced", "Performance"] + // } + // + // currentIndex: PowerProfiles.profile + // onCurrentIndexChanged: { + // PowerProfiles.profile = this.currentIndex; + // console.log(PowerProfile.toString(PowerProfiles.profile)); + // } + // } + } + } +} diff --git a/shell/experimental-bar/systray/SysTray.qml b/shell/experimental-bar/systray/SysTray.qml new file mode 100644 index 0000000..b572e3b --- /dev/null +++ b/shell/experimental-bar/systray/SysTray.qml @@ -0,0 +1,96 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.SystemTray +import "../../widgets" as Widgets + +RowLayout { + id: root + spacing: 5 + visible: SystemTray.items.values.length > 0 + + required property var popup + + Repeater { + model: SystemTray.items + + delegate: Item { + id: trayField + Layout.preferredWidth: parent.height + Layout.fillHeight: true + required property SystemTrayItem modelData + + Widgets.MaterialButton { + id: trayButton + hoverEnabled: true + onClicked: { + menuOpener.menu = trayField.modelData.menu; + + if (root.popup.content == trayMenu) { + root.popup.hide(); + return; + } + + root.popup.set(this, trayMenu); + root.popup.show(); + } + + anchors { + fill: parent + margins: 2 + } + + IconImage { + id: trayIcon + anchors.fill: parent + source: { + // console.log(trayField.modelData.id); + switch (trayField.modelData.id) { + case "obs": + return "image://icon/obs-tray"; + default: + return trayField.modelData.icon; + } + } + } + } + + QsMenuOpener { + id: menuOpener + } + + WrapperItem { + id: trayMenu + visible: false + + property var leftItem: false + property var rightItem: false + + ColumnLayout { + id: menuContainer + spacing: 2 + + Repeater { + model: menuOpener.children + + delegate: TrayMenuItem { + id: sysTrayContent + Layout.fillWidth: true + Layout.fillHeight: true + + rootMenu: trayMenu + + onInteracted: { + root.popup.hide(); + menuOpener.menu = null; + } + } + } + } + } + } + } +} diff --git a/shell/experimental-bar/systray/TrayMenuEntry.qml b/shell/experimental-bar/systray/TrayMenuEntry.qml new file mode 100644 index 0000000..8c5c9d4 --- /dev/null +++ b/shell/experimental-bar/systray/TrayMenuEntry.qml @@ -0,0 +1,170 @@ +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "../../widgets" as Widgets +import "../.." + +ColumnLayout { + id: root + required property QsMenuEntry menuData + required property var rootMenu + signal interacted + + Component.onCompleted: { + if (menuData?.buttonType !== QsMenuButtonType.None || menuData?.icon != "") { + rootMenu.leftItem = true; + } + + if (menuData?.hasChildren) { + rootMenu.rightItem = true; + } + } + + WrapperRectangle { + Layout.fillWidth: true + Layout.preferredHeight: 25 + radius: 6 + color: { + if (!root.menuData?.enabled) + return "transparent"; + + if (entryArea.containsMouse) + return ShellSettings.colors["primary"]; + + return "transparent"; + } + + WrapperMouseArea { + id: entryArea + hoverEnabled: true + anchors.fill: parent + onClicked: { + if (!root.menuData?.enabled) + return; + + if (root.menuData?.hasChildren) { + subTrayMenu.visible = !subTrayMenu.visible; + return; + } + + root.menuData?.triggered(); + root.interacted(); + } + + RowLayout { + id: menuEntry + spacing: 5 + Layout.fillWidth: true + + Item { + visible: root.rootMenu.leftItem + Layout.preferredWidth: 20 + Layout.fillHeight: true + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: 5 + + RadioButton { + id: radioButton + visible: (root.menuData?.buttonType === QsMenuButtonType.RadioButton) ?? false + checked: (root.menuData?.checkState) ?? false + anchors.centerIn: parent + } + + CheckBox { + id: checkBox + visible: (root.menuData?.buttonType === QsMenuButtonType.CheckBox) ?? false + checked: (root.menuData?.checkState) ?? false + anchors.centerIn: parent + } + + IconImage { + id: entryImage + visible: (root.menuData?.buttonType === QsMenuButtonType.None && root.menuData?.icon !== "") ?? false + source: (root.menuData?.icon) ?? "" + anchors.fill: parent + } + } + + Text { + id: text + text: root.menuData?.text ?? "" + verticalAlignment: Text.AlignVCenter + color: { + let color = Qt.color(ShellSettings.colors["inverse_surface"]); + + if (!root.menuData?.enabled) + return color.darker(2); + + if (entryArea.containsMouse) + return Qt.color(ShellSettings.colors["inverse_primary"]); + + return color; + } + + Layout.fillWidth: true + Layout.fillHeight: true + } + + Item { + visible: root.rootMenu.rightItem + Layout.preferredHeight: 20 + Layout.preferredWidth: 20 + Layout.rightMargin: 5 + + Widgets.IconButton { + id: arrowButton + visible: root.menuData?.hasChildren ?? false + activeRectangle: false + source: "root:resources/general/right-arrow.svg" + rotation: subTrayMenu.visible ? 90 : 0 + anchors.fill: parent + + Behavior on rotation { + NumberAnimation { + duration: 150 + easing.type: Easing.OutCubic + } + } + + onClicked: { + root.expanded = !root.expanded; + } + } + } + } + } + } + + WrapperRectangle { + id: subTrayMenu + color: ShellSettings.colors["surface_container"] + radius: 8 + visible: false + Layout.fillWidth: true + + QsMenuOpener { + id: menuOpener + menu: root.menuData + } + + ColumnLayout { + id: subTrayContainer + spacing: 2 + Layout.fillWidth: true + + Repeater { + model: menuOpener.children + + delegate: BoundComponent { + id: subMenuEntry + source: "TrayMenuItem.qml" + Layout.fillWidth: true + required property var modelData + property var rootMenu: root.rootMenu + } + } + } + } +} diff --git a/shell/experimental-bar/systray/TrayMenuItem.qml b/shell/experimental-bar/systray/TrayMenuItem.qml new file mode 100644 index 0000000..590cf4c --- /dev/null +++ b/shell/experimental-bar/systray/TrayMenuItem.qml @@ -0,0 +1,29 @@ +import Quickshell +import QtQuick +import QtQuick.Layouts +import "../.." + +ColumnLayout { + id: root + required property QsMenuEntry modelData + required property var rootMenu + property var leftItem + signal interacted + + Rectangle { + visible: (root.modelData?.isSeparator ?? false) + color: ShellSettings.colors["surface_container_high"] + Layout.fillWidth: true + Layout.preferredHeight: 2 + Layout.leftMargin: 8 + Layout.rightMargin: 8 + } + + TrayMenuEntry { + visible: !root.modelData?.isSeparator + rootMenu: root.rootMenu + menuData: root.modelData + Layout.fillWidth: true + onInteracted: root.interacted() + } +} diff --git a/shell/experimental-bar/volume/ApplicationMixer.qml b/shell/experimental-bar/volume/ApplicationMixer.qml new file mode 100644 index 0000000..584be77 --- /dev/null +++ b/shell/experimental-bar/volume/ApplicationMixer.qml @@ -0,0 +1,70 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell.Widgets +import Quickshell.Services.Pipewire +import "../../widgets/" as Widgets +import "../.." + +ColumnLayout { + id: root + + Loader { + id: sinkLoader + active: sink + + property PwNode sink: Pipewire.defaultAudioSink + + sourceComponent: WrapperItem { + PwNodeLinkTracker { + id: linkTracker + node: sinkLoader.sink + } + + ColumnLayout { + Repeater { + model: linkTracker.linkGroups + + delegate: Loader { + id: nodeLoader + active: modelData.source !== null + Layout.preferredWidth: 350 + Layout.preferredHeight: 45 + + required property PwLinkGroup modelData + + sourceComponent: VolumeCard { + id: nodeCard + node: nodeLoader.modelData.source + text: node.properties["media.name"] ?? "" + + // if icon-name is undefined, just gonna fallback on the application name + icon: IconImage { + source: { + if (nodeCard.node.properties["application.icon-name"] !== undefined) + return `image://icon/${nodeCard.node.properties["application.icon-name"]}`; + + let applicationName = nodeCard.node.properties["application.name"]; + return `image://icon/${applicationName?.toLowerCase() ?? "image-missing"}`; + } + } + + button: Widgets.FontIconButton { + hoverEnabled: false + iconName: nodeCard.node.audio.muted ? "volume_off" : "volume_up" + checked: !nodeCard.node.audio.muted + inactiveColor: ShellSettings.colors["surface_container_highest"] + onClicked: { + nodeCard.node.audio.muted = !nodeCard.node.audio.muted; + } + } + + anchors.fill: parent + } + } + } + } + } + } +} diff --git a/shell/experimental-bar/volume/DeviceMixer.qml b/shell/experimental-bar/volume/DeviceMixer.qml new file mode 100644 index 0000000..ed37a83 --- /dev/null +++ b/shell/experimental-bar/volume/DeviceMixer.qml @@ -0,0 +1,64 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Pipewire +import "../../widgets/" as Widgets +import "../.." + +ColumnLayout { + id: root + + // headphones + // don't load until the node is not null + Loader { + id: sinkLoader + active: sink !== null + Layout.preferredWidth: 350 + Layout.preferredHeight: 45 + + property PwNode sink: Pipewire.defaultAudioSink + + sourceComponent: VolumeCard { + id: sinkCard + node: sinkLoader.sink + button: Widgets.FontIconButton { + hoverEnabled: false + iconName: sinkCard.node.audio.muted ? "volume_off" : "volume_up" + checked: !sinkCard.node.audio.muted + inactiveColor: ShellSettings.colors["surface_container_highest"] + onClicked: { + sinkCard.node.audio.muted = !sinkCard.node.audio.muted; + } + } + + anchors.fill: parent + } + } + + // microphone, same as above + Loader { + id: sourceLoader + active: source !== null + Layout.preferredWidth: 350 + Layout.preferredHeight: 45 + + property PwNode source: Pipewire.defaultAudioSource + + sourceComponent: VolumeCard { + id: sourceCard + node: sourceLoader.source + button: Widgets.FontIconButton { + hoverEnabled: false + iconName: sourceCard.node.audio.muted ? "mic_off" : "mic" + checked: !sourceCard.node.audio.muted + inactiveColor: ShellSettings.colors["surface_container_highest"] + onClicked: { + sourceCard.node.audio.muted = !sourceCard.node.audio.muted; + } + } + + anchors.fill: parent + } + } +} diff --git a/shell/experimental-bar/volume/VolumeCard.qml b/shell/experimental-bar/volume/VolumeCard.qml new file mode 100644 index 0000000..32ff535 --- /dev/null +++ b/shell/experimental-bar/volume/VolumeCard.qml @@ -0,0 +1,50 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Widgets +import Quickshell.Services.Pipewire +import "../../widgets/" as Widgets +import "../.." + +WrapperRectangle { + id: root + color: ShellSettings.colors["surface_container"] + radius: width / 2 + margin: 6 + + required property PwNode node + property string text + property Component button + property Component icon + + PwObjectTracker { + id: tracker + objects: [root.node] + } + + RowLayout { + Widgets.MaterialSlider { + value: root.node.audio.volume ?? 0 + text: root.text + icon: root.icon + + onValueChanged: { + // only allow changes when the node is ready other wise you will combust + if (!root.node.ready) + return; + + root.node.audio.volume = value; + } + + Layout.fillWidth: true + Layout.fillHeight: true + } + + Loader { + id: buttonLoader + sourceComponent: root.button + + Layout.preferredWidth: this.height + Layout.fillHeight: true + } + } +} diff --git a/shell/experimental-bar/volume/VolumeControl.qml b/shell/experimental-bar/volume/VolumeControl.qml new file mode 100644 index 0000000..4423de1 --- /dev/null +++ b/shell/experimental-bar/volume/VolumeControl.qml @@ -0,0 +1,34 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell.Widgets +import "../../widgets/" as Widgets + +WrapperItem { + id: root + visible: false + + ColumnLayout { + spacing: 10 + + Widgets.TabBar { + id: tabBar + model: ["headphones", "tune"] + Layout.fillWidth: true + Layout.preferredHeight: 35 + } + + StackLayout { + id: page + currentIndex: tabBar.currentIndex + Layout.fillWidth: true + Layout.preferredHeight: currentItem ? currentItem.implicitHeight : 0 + + readonly property Item currentItem: children[currentIndex] + + DeviceMixer {} + ApplicationMixer {} + } + } +} diff --git a/shell/experimental-bar/volume/VolumeIndicator.qml b/shell/experimental-bar/volume/VolumeIndicator.qml new file mode 100644 index 0000000..66f7a6e --- /dev/null +++ b/shell/experimental-bar/volume/VolumeIndicator.qml @@ -0,0 +1,27 @@ +import QtQuick +import "../../widgets/" as Widgets + +Item { + id: root + + required property var popup + + Widgets.FontIconButton { + id: button + iconName: "volume_up" + anchors.fill: parent + onClicked: { + if (root.popup.content == volumeMenu) { + root.popup.hide(); + return; + } + + root.popup.set(this, volumeMenu); + root.popup.show(); + } + } + + VolumeControl { + id: volumeMenu + } +} diff --git a/shell/shell.qml b/shell/shell.qml index 1372c45..b144241 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -3,7 +3,8 @@ import Quickshell import QtQuick -import "bar" as Bar +// import "bar" as Bar +import "experimental-bar" as Bar import "notifications" as Notifications import "mpris" as Mpris import "volume-osd" as VolumeOSD diff --git a/shell/widgets/FontIcon.qml b/shell/widgets/FontIcon.qml new file mode 100644 index 0000000..310383f --- /dev/null +++ b/shell/widgets/FontIcon.qml @@ -0,0 +1,34 @@ +import QtQuick +import ".." + +Text { + id: textIcon + + property real fill: 0 + + renderType: Text.NativeRendering + textFormat: Text.PlainText + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + + font { + family: "Material Symbols Outlined" + pointSize: Math.max(parent.height * 0.50, 11) + + variableAxes: { + "FILL": fill + } + } + + Behavior on fill { + NumberAnimation { + duration: 200 + } + } + + Behavior on color { + ColorAnimation { + duration: 200 + } + } +} diff --git a/shell/widgets/TabBar.qml b/shell/widgets/TabBar.qml new file mode 100644 index 0000000..7ac2f3c --- /dev/null +++ b/shell/widgets/TabBar.qml @@ -0,0 +1,92 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ".." + +Item { + id: root + property alias model: buttonRepeater.model + property int currentIndex: 0 + + RowLayout { + id: buttonGroup + spacing: 0 + anchors.fill: parent + + Repeater { + id: buttonRepeater + + delegate: MouseArea { + id: button + hoverEnabled: true + Layout.fillWidth: true + Layout.fillHeight: true + Layout.alignment: Qt.AlignHCenter | Qt.AlignTop + + required property var modelData + required property int index + property bool checked: index === root.currentIndex + + onClicked: { + currentIndex = index; + root.updateSelectionBarPosition(); + } + + FontIcon { + text: button.modelData + fill: { + if (button.checked) + return 1; + + return button.containsMouse ? 1 : 0; + } + color: button.checked ? ShellSettings.colors["primary"] : ShellSettings.colors["inverse_surface"] + anchors.fill: parent + anchors.bottomMargin: 5 + } + } + } + } + + Rectangle { + id: selectionBar + implicitWidth: 100 + implicitHeight: 3 + topLeftRadius: width / 2 + topRightRadius: width / 2 + color: ShellSettings.colors["primary"] + anchors.bottom: tabBar.top + + Behavior on x { + NumberAnimation { + duration: 250 + easing.type: Easing.OutCubic + } + } + } + + Rectangle { + id: tabBar + implicitHeight: 1.5 + radius: width / 2 + color: ShellSettings.colors["surface_container"] + + anchors { + top: buttonGroup.bottom + left: parent.left + right: parent.right + } + } + + function updateSelectionBarPosition() { + if (buttonRepeater.count > 0) { + var buttonWidth = buttonGroup.width / buttonRepeater.count; + var targetX = currentIndex * buttonWidth + (buttonWidth - selectionBar.width) / 2; + selectionBar.x = targetX; + } + } + + Component.onCompleted: updateSelectionBarPosition() + onWidthChanged: updateSelectionBarPosition() +}