From 237a62ea8adc671d3d30d430d1cb2dce92f02e8f Mon Sep 17 00:00:00 2001 From: imxyy_soope_ Date: Sun, 13 Jul 2025 22:10:17 +0800 Subject: [PATCH] feat: init quickshell --- .../style => assets/wallpapers}/wallpaper.png | Bin flake.nix | 3 +- .../config/components/bar/Battery.qml | 48 -- .../config/components/bar/Clock.qml | 16 - .../config/components/bar/Mpris.qml | 14 - .../config/components/bar/Resources.qml | 54 -- .../quickshell/config/components/bar/Text.qml | 5 - .../quickshell/config/components/bar/Tray.qml | 14 - .../components/bar/workspaces/Workspace.qml | 26 - .../components/bar/workspaces/Workspaces.qml | 55 -- modules/desktop/quickshell/config/shell.qml | 6 - .../quickshell/config/utils/Colors.qml | 9 - .../quickshell/config/utils/HyprlandUtils.qml | 67 -- .../quickshell/config/utils/Resources.qml | 67 -- .../desktop/quickshell/config/utils/Time.qml | 29 - .../desktop/quickshell/config/windows/Bar.qml | 79 -- modules/desktop/quickshell/default.nix | 28 +- modules/desktop/quickshell/qml/.gitignore | 2 + .../quickshell/qml/Assets/UserProfile.gif | Bin 0 -> 38210 bytes .../desktop/quickshell/qml/Core/Corners.qml | 84 ++ .../quickshell/qml/Core/LoaderManager.qml | 48 ++ .../quickshell/qml/Core/ProcessManager.qml | 189 +++++ .../quickshell/qml/Data/MatugenManager.qml | 38 + .../desktop/quickshell/qml/Data/Settings.qml | 315 ++++++++ .../quickshell/qml/Data/ThemeManager.qml | 240 ++++++ .../quickshell/qml/Data/Themes/Catppuccin.qml | 76 ++ .../quickshell/qml/Data/Themes/Dracula.qml | 76 ++ .../quickshell/qml/Data/Themes/Gruvbox.qml | 76 ++ .../quickshell/qml/Data/Themes/Matugen.qml | 141 ++++ .../quickshell/qml/Data/Themes/Oxocarbon.qml | 76 ++ .../quickshell/qml/Data/quickshell-colors.qml | 60 ++ .../desktop/quickshell/qml/Data/settings.json | 7 + modules/desktop/quickshell/qml/Layout/Bar.qml | 35 + .../desktop/quickshell/qml/Layout/Border.qml | 575 +++++++++++++ .../desktop/quickshell/qml/Layout/Desktop.qml | 302 +++++++ .../qml/Services/AppLauncherService.qml | 394 +++++++++ .../qml/Services/MatugenService.qml | 155 ++++ .../qml/Services/NotificationService.qml | 97 +++ .../qml/Services/WeatherService.qml | 267 ++++++ .../qml/Widgets/Calendar/Calendar.qml | 119 +++ .../qml/Widgets/Calendar/CalendarPopup.qml | 127 +++ .../desktop/quickshell/qml/Widgets/Clock.qml | 64 ++ .../qml/Widgets/ControlPanel/ControlPanel.qml | 140 ++++ .../ControlPanel/ControlPanelContent.qml | 110 +++ .../ControlPanel/ControlPanelWindow.qml | 185 +++++ .../components/controls/Controls.qml | 111 +++ .../controls/PerformanceControls.qml | 122 +++ .../components/controls/SystemButton.qml | 118 +++ .../components/controls/SystemControls.qml | 60 ++ .../components/media/MusicPlayer.qml | 666 +++++++++++++++ .../components/navigation/TabContainer.qml | 112 +++ .../components/navigation/TabNavigation.qml | 139 ++++ .../settings/AppearanceSettings.qml | 758 ++++++++++++++++++ .../settings/MusicPlayerSettings.qml | 129 +++ .../settings/NightLightSettings.qml | 531 ++++++++++++ .../settings/NotificationSettings.qml | 536 +++++++++++++ .../components/settings/SettingsCategory.qml | 103 +++ .../components/settings/SystemSettings.qml | 77 ++ .../components/settings/WeatherSettings.qml | 207 +++++ .../components/system/NotificationBar.qml | 92 +++ .../components/system/TopPanelTrigger.qml | 56 ++ .../components/system/TrayMenu.qml | 230 ++++++ .../components/widgets/CalendarButton.qml | 75 ++ .../components/widgets/NightLight.qml | 309 +++++++ .../components/widgets/RecordingButton.qml | 66 ++ .../components/widgets/ThemeToggle.qml | 45 ++ .../components/widgets/UserProfile.qml | 239 ++++++ .../components/widgets/WeatherDisplay.qml | 383 +++++++++ .../Widgets/ControlPanel/tabs/CalendarTab.qml | 143 ++++ .../ControlPanel/tabs/ClipboardTab.qml | 112 +++ .../ControlPanel/tabs/MainDashboard.qml | 159 ++++ .../Widgets/ControlPanel/tabs/MusicTab.qml | 46 ++ .../ControlPanel/tabs/NotificationTab.qml | 108 +++ .../Widgets/ControlPanel/tabs/SettingsTab.qml | 151 ++++ .../Widgets/Notifications/Notification.qml | 372 +++++++++ .../Notifications/NotificationHistory.qml | 262 ++++++ .../quickshell/qml/Widgets/Panel/TopPanel.qml | 142 ++++ .../Widgets/Panel/modules/CalendarButton.qml | 72 ++ .../qml/Widgets/Panel/modules/Controls.qml | 107 +++ .../Widgets/Panel/modules/NotificationBar.qml | 92 +++ .../qml/Widgets/Panel/modules/Panel.qml | 683 ++++++++++++++++ .../Panel/modules/PerformanceControls.qml | 122 +++ .../Widgets/Panel/modules/RecordingButton.qml | 60 ++ .../Widgets/Panel/modules/SystemButton.qml | 112 +++ .../Widgets/Panel/modules/SystemControls.qml | 59 ++ .../qml/Widgets/Panel/modules/ThemeToggle.qml | 42 + .../Widgets/Panel/modules/TopPanelTrigger.qml | 52 ++ .../qml/Widgets/Panel/modules/TrayMenu.qml | 216 +++++ .../qml/Widgets/Panel/modules/UserProfile.qml | 228 ++++++ .../Widgets/Panel/modules/WeatherDisplay.qml | 332 ++++++++ .../qml/Widgets/System/Cliphist.qml | 556 +++++++++++++ .../qml/Widgets/System/CustomTrayMenu.qml | 161 ++++ .../qml/Widgets/System/NiriWorkspaces.qml | 455 +++++++++++ .../quickshell/qml/Widgets/System/OSD.qml | 228 ++++++ .../qml/Widgets/System/SystemTray.qml | 183 +++++ .../qml/Widgets/System/VolumeOSD.qml | 218 +++++ .../quickshell/qml/Widgets/Workspace.qml | 56 ++ .../qml/scripts/test-integration.qml | 46 ++ .../quickshell/qml/scripts/test-matugen.qml | 76 ++ .../quickshell/qml/scripts/test-simple.qml | 40 + modules/desktop/quickshell/qml/shell.qml | 119 +++ modules/desktop/wm/niri/config.nix | 3 +- modules/desktop/wm/niri/wallpaper.png | Bin 26003 -> 0 bytes 103 files changed, 14997 insertions(+), 498 deletions(-) rename {modules/desktop/style => assets/wallpapers}/wallpaper.png (100%) delete mode 100644 modules/desktop/quickshell/config/components/bar/Battery.qml delete mode 100644 modules/desktop/quickshell/config/components/bar/Clock.qml delete mode 100644 modules/desktop/quickshell/config/components/bar/Mpris.qml delete mode 100644 modules/desktop/quickshell/config/components/bar/Resources.qml delete mode 100644 modules/desktop/quickshell/config/components/bar/Text.qml delete mode 100644 modules/desktop/quickshell/config/components/bar/Tray.qml delete mode 100644 modules/desktop/quickshell/config/components/bar/workspaces/Workspace.qml delete mode 100644 modules/desktop/quickshell/config/components/bar/workspaces/Workspaces.qml delete mode 100644 modules/desktop/quickshell/config/shell.qml delete mode 100644 modules/desktop/quickshell/config/utils/Colors.qml delete mode 100644 modules/desktop/quickshell/config/utils/HyprlandUtils.qml delete mode 100644 modules/desktop/quickshell/config/utils/Resources.qml delete mode 100644 modules/desktop/quickshell/config/utils/Time.qml delete mode 100644 modules/desktop/quickshell/config/windows/Bar.qml create mode 100644 modules/desktop/quickshell/qml/.gitignore create mode 100644 modules/desktop/quickshell/qml/Assets/UserProfile.gif create mode 100644 modules/desktop/quickshell/qml/Core/Corners.qml create mode 100644 modules/desktop/quickshell/qml/Core/LoaderManager.qml create mode 100644 modules/desktop/quickshell/qml/Core/ProcessManager.qml create mode 100644 modules/desktop/quickshell/qml/Data/MatugenManager.qml create mode 100644 modules/desktop/quickshell/qml/Data/Settings.qml create mode 100644 modules/desktop/quickshell/qml/Data/ThemeManager.qml create mode 100644 modules/desktop/quickshell/qml/Data/Themes/Catppuccin.qml create mode 100644 modules/desktop/quickshell/qml/Data/Themes/Dracula.qml create mode 100644 modules/desktop/quickshell/qml/Data/Themes/Gruvbox.qml create mode 100644 modules/desktop/quickshell/qml/Data/Themes/Matugen.qml create mode 100644 modules/desktop/quickshell/qml/Data/Themes/Oxocarbon.qml create mode 100644 modules/desktop/quickshell/qml/Data/quickshell-colors.qml create mode 100644 modules/desktop/quickshell/qml/Data/settings.json create mode 100644 modules/desktop/quickshell/qml/Layout/Bar.qml create mode 100644 modules/desktop/quickshell/qml/Layout/Border.qml create mode 100644 modules/desktop/quickshell/qml/Layout/Desktop.qml create mode 100644 modules/desktop/quickshell/qml/Services/AppLauncherService.qml create mode 100644 modules/desktop/quickshell/qml/Services/MatugenService.qml create mode 100644 modules/desktop/quickshell/qml/Services/NotificationService.qml create mode 100644 modules/desktop/quickshell/qml/Services/WeatherService.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Calendar/Calendar.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Calendar/CalendarPopup.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Clock.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/ControlPanel.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/ControlPanelContent.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/ControlPanelWindow.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/controls/Controls.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/controls/PerformanceControls.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/controls/SystemButton.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/controls/SystemControls.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/media/MusicPlayer.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/navigation/TabContainer.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/navigation/TabNavigation.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/AppearanceSettings.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/MusicPlayerSettings.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/NightLightSettings.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/NotificationSettings.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/SettingsCategory.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/SystemSettings.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/WeatherSettings.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/system/NotificationBar.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/system/TopPanelTrigger.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/system/TrayMenu.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/CalendarButton.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/NightLight.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/RecordingButton.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/ThemeToggle.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/UserProfile.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/WeatherDisplay.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/CalendarTab.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/ClipboardTab.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/MainDashboard.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/MusicTab.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/NotificationTab.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/SettingsTab.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Notifications/Notification.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Notifications/NotificationHistory.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Panel/TopPanel.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Panel/modules/CalendarButton.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Panel/modules/Controls.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Panel/modules/NotificationBar.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Panel/modules/Panel.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Panel/modules/PerformanceControls.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Panel/modules/RecordingButton.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Panel/modules/SystemButton.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Panel/modules/SystemControls.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Panel/modules/ThemeToggle.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Panel/modules/TopPanelTrigger.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Panel/modules/TrayMenu.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Panel/modules/UserProfile.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Panel/modules/WeatherDisplay.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/System/Cliphist.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/System/CustomTrayMenu.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/System/NiriWorkspaces.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/System/OSD.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/System/SystemTray.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/System/VolumeOSD.qml create mode 100644 modules/desktop/quickshell/qml/Widgets/Workspace.qml create mode 100644 modules/desktop/quickshell/qml/scripts/test-integration.qml create mode 100644 modules/desktop/quickshell/qml/scripts/test-matugen.qml create mode 100644 modules/desktop/quickshell/qml/scripts/test-simple.qml create mode 100644 modules/desktop/quickshell/qml/shell.qml delete mode 100644 modules/desktop/wm/niri/wallpaper.png diff --git a/modules/desktop/style/wallpaper.png b/assets/wallpapers/wallpaper.png similarity index 100% rename from modules/desktop/style/wallpaper.png rename to assets/wallpapers/wallpaper.png diff --git a/flake.nix b/flake.nix index c137c23..bfc87f6 100644 --- a/flake.nix +++ b/flake.nix @@ -144,7 +144,7 @@ withJemalloc = true; withQtSvg = true; withWayland = true; - withPipewire = false; + withPipewire = true; withPam = false; withX11 = false; withHyprland = false; @@ -186,6 +186,7 @@ hostname ; sopsRoot = ./secrets; + flake = ./.; } // vars; modules = (lib.umport { diff --git a/modules/desktop/quickshell/config/components/bar/Battery.qml b/modules/desktop/quickshell/config/components/bar/Battery.qml deleted file mode 100644 index bc5e32f..0000000 --- a/modules/desktop/quickshell/config/components/bar/Battery.qml +++ /dev/null @@ -1,48 +0,0 @@ -import Quickshell -import Quickshell.Services.UPower -import QtQuick -import QtQuick.Layouts -import org.kde.kirigami - -Rectangle { - id: bat - - Layout.preferredWidth: batIcon.width - Layout.fillHeight: true - color: 'transparent' - - readonly property var battery: UPower.displayDevice - readonly property int percentage: Math.round(battery.percentage * 100) - property var size: height * 0.4 - - visible: battery.isLaptopBattery - - Icon { - id: batIcon - anchors.centerIn: parent - - implicitHeight: bat.size - implicitWidth: bat.size - - // This recolors the entire svg, instead of only classless components. - // Hopefully in the future classes can be selected for recoloring. - isMask: true - color: 'white' - - source: { - const nearestTen = Math.round(bat.percentage / 10) * 10; - const number = nearestTen.toString().padStart(2, "0"); - let charging; - - if (bat.battery.state == UPowerDeviceState.Charging) { - charging = "-charging"; - } else if (bat.battery.state.toString() == UPowerDeviceState.FullyCharged) { - charging = "-charged"; - } else { - charging = ""; - } - - return Quickshell.iconPath(`battery-level-${number}${charging}-symbolic`); - } - } -} diff --git a/modules/desktop/quickshell/config/components/bar/Clock.qml b/modules/desktop/quickshell/config/components/bar/Clock.qml deleted file mode 100644 index 8256b8a..0000000 --- a/modules/desktop/quickshell/config/components/bar/Clock.qml +++ /dev/null @@ -1,16 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import "../../utils" - -Rectangle { - Layout.fillHeight: true - color: "transparent" - implicitWidth: clockText.width - - Text { - id: clockText - text: Time.time - color: Colors.fg - anchors.centerIn: parent - } -} diff --git a/modules/desktop/quickshell/config/components/bar/Mpris.qml b/modules/desktop/quickshell/config/components/bar/Mpris.qml deleted file mode 100644 index d4d1529..0000000 --- a/modules/desktop/quickshell/config/components/bar/Mpris.qml +++ /dev/null @@ -1,14 +0,0 @@ -import QtQuick -import QtQuick.Layouts - -Rectangle { - Layout.fillHeight: true - color: "salmon" - implicitWidth: mprisText.width - - Text { - id: mprisText - text: "Mpris" - anchors.centerIn: parent - } -} diff --git a/modules/desktop/quickshell/config/components/bar/Resources.qml b/modules/desktop/quickshell/config/components/bar/Resources.qml deleted file mode 100644 index d1b2bbb..0000000 --- a/modules/desktop/quickshell/config/components/bar/Resources.qml +++ /dev/null @@ -1,54 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import QtQuick.Controls -import "../../utils" - -Rectangle { - id: resources - - Layout.fillHeight: true - color: "transparent" - implicitWidth: rowLayout.width - - property int valueSize: 8 - property int textSize: 6 - - property string valueColor: "white" - property string textColor: "lightgray" - - RowLayout { - id: rowLayout - anchors.centerIn: parent - - ColumnLayout { - id: cpuColumn - Label { - color: textColor - font.pointSize: textSize - text: "CPU" - Layout.alignment: Qt.AlignCenter - } - Label { - color: valueColor - font.pointSize: valueSize - text: Resources.cpu_percent + "%" - Layout.alignment: Qt.AlignCenter - } - } - - ColumnLayout { - Label { - color: textColor - font.pointSize: textSize - text: "MEM" - Layout.alignment: Qt.AlignCenter - } - Label { - color: valueColor - font.pointSize: valueSize - text: Resources.mem_percent + "%" - Layout.alignment: Qt.AlignCenter - } - } - } -} diff --git a/modules/desktop/quickshell/config/components/bar/Text.qml b/modules/desktop/quickshell/config/components/bar/Text.qml deleted file mode 100644 index 90e0758..0000000 --- a/modules/desktop/quickshell/config/components/bar/Text.qml +++ /dev/null @@ -1,5 +0,0 @@ -import QtQuick - -Text { - renderType: Text.NativeRendering -} diff --git a/modules/desktop/quickshell/config/components/bar/Tray.qml b/modules/desktop/quickshell/config/components/bar/Tray.qml deleted file mode 100644 index 475c83b..0000000 --- a/modules/desktop/quickshell/config/components/bar/Tray.qml +++ /dev/null @@ -1,14 +0,0 @@ -import QtQuick -import QtQuick.Layouts - -Rectangle { - Layout.fillHeight: true - color: "lightblue" - implicitWidth: trayText.width - - Text { - id: trayText - text: "Tray" - anchors.centerIn: parent - } -} diff --git a/modules/desktop/quickshell/config/components/bar/workspaces/Workspace.qml b/modules/desktop/quickshell/config/components/bar/workspaces/Workspace.qml deleted file mode 100644 index f41e838..0000000 --- a/modules/desktop/quickshell/config/components/bar/workspaces/Workspace.qml +++ /dev/null @@ -1,26 +0,0 @@ -import QtQuick -import QtQuick.Layouts - -Rectangle { - id: ws - - property bool hovered: false - - Layout.preferredWidth: parent.height * 0.4 - Layout.preferredHeight: parent.height * 0.4 - Layout.alignment: Qt.AlignHCenter - radius: height / 2 - - MouseArea { - anchors.fill: parent - hoverEnabled: true - - onEntered: () => { - ws.hovered = true; - } - onExited: () => { - ws.hovered = false; - } - onClicked: () => console.log(`workspace ?`) - } -} diff --git a/modules/desktop/quickshell/config/components/bar/workspaces/Workspaces.qml b/modules/desktop/quickshell/config/components/bar/workspaces/Workspaces.qml deleted file mode 100644 index b47f570..0000000 --- a/modules/desktop/quickshell/config/components/bar/workspaces/Workspaces.qml +++ /dev/null @@ -1,55 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import "../../../utils" -import Quickshell.Hyprland - -Rectangle { - id: workspaces - - color: 'transparent' - - width: workspacesRow.implicitWidth - Layout.fillHeight: true - - RowLayout { - id: workspacesRow - - height: parent.height - implicitWidth: (parent.height * 0.5 + spacing) * 2 - spacing - anchors.centerIn: parent - - spacing: height / 7 - - Repeater { - id: repeater - - model: HyprlandUtils.maxWorkspace - - Workspace { - id: ws - required property int index - property HyprlandWorkspace currWorkspace: Hyprland.workspaces.values.find(e => e.id == index + 1) || null - property bool nonexistent: currWorkspace === null - property bool focused: index + 1 === Hyprland.focusedMonitor.activeWorkspace.id - - Layout.preferredWidth: { - if (focused) { - return parent.height * 0.8; - } else { - return parent.height * 0.4; - } - } - - color: { - if (nonexistent) { - return Colors.bgBlur; - } else { - return Colors.monitorColors[Hyprland.monitors.values.indexOf(Hyprland.workspaces.values.find(e => e.id === index + 1).monitor)]; - } - } - } - } - } -} diff --git a/modules/desktop/quickshell/config/shell.qml b/modules/desktop/quickshell/config/shell.qml deleted file mode 100644 index 932a8cb..0000000 --- a/modules/desktop/quickshell/config/shell.qml +++ /dev/null @@ -1,6 +0,0 @@ -import "./windows" -import Quickshell // for ShellRoot and PanelWindow - -ShellRoot { - Bar {} -} diff --git a/modules/desktop/quickshell/config/utils/Colors.qml b/modules/desktop/quickshell/config/utils/Colors.qml deleted file mode 100644 index 1608de8..0000000 --- a/modules/desktop/quickshell/config/utils/Colors.qml +++ /dev/null @@ -1,9 +0,0 @@ -import Quickshell -pragma Singleton - -Singleton { - property var bgBar: Qt.rgba(0, 0, 0, 0.21) - property var bgBlur: Qt.rgba(0, 0, 0, 0.3) - property var fg: "white" - property list monitorColors: ["#e06c75", "#e5c07b", "#98c379", "#61afef"] -} diff --git a/modules/desktop/quickshell/config/utils/HyprlandUtils.qml b/modules/desktop/quickshell/config/utils/HyprlandUtils.qml deleted file mode 100644 index 6620061..0000000 --- a/modules/desktop/quickshell/config/utils/HyprlandUtils.qml +++ /dev/null @@ -1,67 +0,0 @@ -pragma Singleton - -import Quickshell -import Quickshell.Hyprland -import QtQuick - -Singleton { - id: hyprland - - property list workspaces: sortWorkspaces(Hyprland.workspaces.values) - property HyprlandWorkspace focusedWorkspace: Hyprland.focusedMonitor?.activeWorkspace - property int maxWorkspace: findMaxId() - - function sortWorkspaces(ws) { - return [...ws].sort((a, b) => a?.id - b?.id); - } - - function switchWorkspace(w: int): void { - console.log(`workspace: focus ${focusedWorkspace.id} -> ${w}`); - Hyprland.dispatch(`workspace ${w}`); - } - - function findMaxId(): int { - let num = hyprland.workspaces.length; - return hyprland.workspaces[num - 1]?.id; - } - - Connections { - target: Hyprland - function onRawEvent(event) { - // console.log("EVENT NAME", event.name); - // consow.wg("EVENT DATA", event.data); - let eventName = event.name; - - switch (eventName) { - // Both of these are required in order to detect workspace changes - // even when switching monitors. - // case "workspacev2": - // { - // // hyprland.focusedWorkspace = Hyprland.focusedMonitor?.activeWorkspace; - // console.log(`workspace: ${hyprland.focusedWorkspace.id}`); - // console.log(`num workspaces ${hyprland.workspaces.length}`) - // console.log(`num workspaces (real) ${Hyprland.workspaces.values.length}`) - // break; - // } - // case "focusedmonv2": - // { - // // hyprland.focusedWorkspace = Hyprland.focusedMonitor?.activeWorkspace; - // console.log(`workspace: ${hyprland.focusedWorkspace.id}`); - // console.log(`num workspaces ${hyprland.workspaces.length}`) - // console.log(`num workspaces (real) ${Hyprland.workspaces.values.length}`) - // break; - // } - case "createworkspacev2": - { - hyprland.workspaces = hyprland.sortWorkspaces(Hyprland.workspaces.values); - hyprland.maxWorkspace = findMaxId(); - } - case "destroyworkspacev2": - { - hyprland.workspaces = hyprland.sortWorkspaces(Hyprland.workspaces.values); - hyprland.maxWorkspace = findMaxId(); - } - } - } - } -} diff --git a/modules/desktop/quickshell/config/utils/Resources.qml b/modules/desktop/quickshell/config/utils/Resources.qml deleted file mode 100644 index 3a19d80..0000000 --- a/modules/desktop/quickshell/config/utils/Resources.qml +++ /dev/null @@ -1,67 +0,0 @@ -pragma Singleton - -import Quickshell -import Quickshell.Io -import QtQuick - -Singleton { - property int cpu_percent - property string cpu_freq - property int mem_percent - property string mem_used - - Process { - id: process_cpu_percent - running: true - command: ["sh", "-c", "top -bn1 | rg '%Cpu' | awk '{print 100-$8}'"] - stdout: SplitParser { - onRead: data => cpu_percent = Math.round(data) - } - } - - Process { - id: process_cpu_freq - running: true - command: ["sh", "-c", "lscpu --parse=MHZ"] - stdout: SplitParser { - onRead: data => { - // delete the first 4 lines (comments) - const mhz = data.split("\n").slice(4); - // compute mean frequency - const freq = mhz.reduce((acc, e) => acc + Number(e), 0) / mhz.length; - - cpu_freq = Math.round(freq) + " MHz"; - } - } - } - - Process { - id: process_mem_percent - running: true - command: ["sh", "-c", "free | awk 'NR==2{print $3/$2*100}'"] - stdout: SplitParser { - onRead: data => mem_percent = Math.round(data) - } - } - - Process { - id: process_mem_used - running: true - command: ["sh", "-c", "free --si -h | awk 'NR==2{print $3}'"] - stdout: SplitParser { - onRead: data => mem_used = data - } - } - - Timer { - interval: 2000 - running: true - repeat: true - onTriggered: () => { - process_cpu_percent.running = true - process_cpu_freq.running = true - process_mem_percent.running = true - process_mem_used.running = true - } - } -} diff --git a/modules/desktop/quickshell/config/utils/Time.qml b/modules/desktop/quickshell/config/utils/Time.qml deleted file mode 100644 index f03e134..0000000 --- a/modules/desktop/quickshell/config/utils/Time.qml +++ /dev/null @@ -1,29 +0,0 @@ -pragma Singleton - -import Quickshell -import Quickshell.Io -import QtQuick - -Singleton { - property var locale: Qt.locale() - - function createDate(): string { - let date = new Date(); - let hh = date.getHours().toString().padStart(2, 0); - let mm = date.getMinutes().toString().padStart(2, 0) - let weekday = locale.dayName(date.getDay(), Locale.ShortFormat) - let month = locale.monthName(date.getMonth(), Locale.ShortFormat) - let day = date.getDate() - - return `${weekday} ${month} ${day} ${hh}:${mm}` - } - - property var time: createDate() - - Timer { - interval: 1000 - running: true - repeat: true - onTriggered: time = createDate() - } -} diff --git a/modules/desktop/quickshell/config/windows/Bar.qml b/modules/desktop/quickshell/config/windows/Bar.qml deleted file mode 100644 index 8bd8137..0000000 --- a/modules/desktop/quickshell/config/windows/Bar.qml +++ /dev/null @@ -1,79 +0,0 @@ -//@ pragma NativeTextRendering - -import Quickshell -import QtQuick -import QtQuick.Layouts -import "../utils" -import "../components/bar" -import "../components/bar/workspaces" - -Scope { - PanelWindow { - id: barWindow - screen: Quickshell.screens[0] - - anchors { - top: true - left: true - right: true - } - height: 32 - - color: "transparent" - - Rectangle { - id: bar - anchors.fill: parent - - color: Colors.bgBlur - - // left - // RowLayout { - // id: barLeft - // - // anchors.bottom: parent.bottom - // anchors.left: parent.left - // anchors.top: parent.top - // - // anchors.leftMargin: height / 4 - // anchors.rightMargin: height / 4 - // spacing: height / 4 - // - // Workspaces {} - // } - - // middle - RowLayout { - id: barMiddle - - anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - - anchors.leftMargin: height / 4 - anchors.rightMargin: height / 4 - spacing: height / 4 - - Mpris {} - } - - // right - RowLayout { - id: barRight - - anchors.bottom: parent.bottom - anchors.right: parent.right - anchors.top: parent.top - - anchors.leftMargin: height / 4 - anchors.rightMargin: height / 4 - spacing: height / 4 - - Tray {} - Resources {} - Battery {} - Clock {} - } - } - } -} diff --git a/modules/desktop/quickshell/default.nix b/modules/desktop/quickshell/default.nix index 96ef609..bc527a9 100644 --- a/modules/desktop/quickshell/default.nix +++ b/modules/desktop/quickshell/default.nix @@ -4,6 +4,12 @@ pkgs, ... }: +let + # FIXME: symlink + homeDir = config.my.home.home.homeDirectory; + quickshellDir = "${homeDir}/workspace/nixos-dotfiles/modules/desktop/quickshell/qml"; + quickshellTarget = "${homeDir}/.config/quickshell"; +in lib.my.makeSwitch { inherit config; default = false; @@ -13,17 +19,25 @@ lib.my.makeSwitch { "quickshell" ]; config' = { - my.home = { - home.packages = [ pkgs.quickshell ]; - home.sessionVariables.QML2_IMPORT_PATH = lib.concatStringsSep ":" [ + my.home.home = { + packages = with pkgs; [ + quickshell + qt6Packages.qt5compat + libsForQt5.qt5.qtgraphicaleffects + kdePackages.qtbase + kdePackages.qtdeclarative + + material-symbols + material-icons + ]; + sessionVariables.QML2_IMPORT_PATH = lib.concatStringsSep ":" [ "${pkgs.quickshell}/lib/qt-6/qml" "${pkgs.kdePackages.qtdeclarative}/lib/qt-6/qml" "${pkgs.kdePackages.kirigami.unwrapped}/lib/qt-6/qml" ]; - xdg.configFile."quickshell" = { - source = ./config; - recursive = true; - }; + activation.symlinkQuickshellAndFaceIcon = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + ln -sfn "${quickshellDir}" "${quickshellTarget}" + ''; }; }; } diff --git a/modules/desktop/quickshell/qml/.gitignore b/modules/desktop/quickshell/qml/.gitignore new file mode 100644 index 0000000..df1ef9e --- /dev/null +++ b/modules/desktop/quickshell/qml/.gitignore @@ -0,0 +1,2 @@ +/debug.log +/quickshell.log diff --git a/modules/desktop/quickshell/qml/Assets/UserProfile.gif b/modules/desktop/quickshell/qml/Assets/UserProfile.gif new file mode 100644 index 0000000000000000000000000000000000000000..d600a22d4ae61ac73c158c7b1c4ae7ae73002776 GIT binary patch literal 38210 zcmZ?wbhEHb{KfE#;afZd0|NsmJ3AK_mmnXXu&}VaoSeLZf|8Pwj+&afmX?l=j)A(m zk(QRep`o#{v6Zp0m6es7m6fZjtGBmzh?`q*aBy5)T!OoMs<(GaP*84AP)cNEUR+#N zNJv>+Tt$3*YD!98a&mTdc5!lYNk&FhPEJX2aaDPFO-)U6Y;1R3U2AJ=Z$m?OclXTh z?#YuU&zd!BK~vMprlzG`UF$kJ7EYS9aPHilbLTEvv}pC})!UXY-?3o9mK7`ZtY5!n z%a+}{cORNM_0atJhZZb2w`R@Nojd>T-@onf;iE^7o;`Bp%$YNnFJHcU_wL)HNB z`1Q=0|7XuWx_|%eojd=pT={?Z?)?WK@b>xhCvV=oe*OCY)2AQaz5DC%u7<53Ef})9m{a-^uQ*%pe zTYE=mS9ecuU;l)OlO|7@I&J!lnX_iknLBU(f`y9~FIl>5`HGdRRKIR zx^4T8ox67L*}HH5frEz*A31vL_=%IJPM`HPpYUcY(!?)`_4pFV&2`tAFVpTBmEFVoq#Wc(`3a z*=vr+#zjZFC5*G~oY=Vdc)x;kmyGA8B_}6q1h0xYxoPR?=?2NC=6G&ic6PQ!@vA#0 zH!nXw-=UdH)@#cOQwER8N~Wj0R$f}bz~UOa^gzqi)nST%O02$$Tw;zt)WvI^sOi6) zF-TF>piGBl^FJ%u9|s(-w{xy%c~z7t{Vn(UY!B`&ri|etK{_>e#kT$2y0+u!d@hC1 zNfB$}=XgvGiF}m$CQV03A>FBq*?ZF7srzHtcoulQ(&uwYcAE8gYwAsdiB6TVjSbEI zOKz7*-gEsJKg(nKjen0?8QyswJX$tWsdd*B-P-xPU0&TXo4#buqV~CZOaFX*b39#X zsZg2sblrE|-A|Ja9%5m9sqVO;KEB z&%}d^*u6XwquotBPBshrNhnm!VB8QbE}S;8wK}=fBSFd2@yAgyt*jXeooh6|w{|=| zDL$<`hE;P(kWt!4mVV{1e-VqaIs8sMoDjgJ`B*I2XQJy=VXhrg$qXB=FRAozxNv}3 zz=GkBpr_2jXR~s)y?i#ybIk(=7XB-ojLae_oyyaFKFv7FI8ir{87YYh&+sU5GThWb5pV&YR-_-5)BPJPlI17m^z);V)ixBa@No7V;1vh zV7{cltb2e(EF?j{pzTfg{p$(sxd-!uI-5h5hN#!x5RVX9(>Z1Hq*rF<9*z^X-TI+t z_&vw5$zU~;V}0=VyP?t%tUS6NHk*`U1sj|mP1wG{slKIZqVp3C3v~;IGU2|Bm+rPc zJ$$dYv$DR{s*xS!v$9{oV#4I~l`9CefDAqx@bP zvYbJ}Difax+AopO7dx?dgQ2kp=PT=DlQx=)CI0K?Z<@lYe|%1FaYlZ=rr4r;T-m8? z3<(Dku3NkBJ2ivLfx#?0@nP$lSJEQuO3$?DzxtUK@NsXi=_mXDjrD@ z6wKqf94)kegOd8H7d-(y-hTv6OxKls6kEPIiAB})&=G-j3!57UI6NGhY(H)6?{Ajj zahfin9(B@7+lxoU>B4cr6bFtAUDH%nZ&~!vW1~@?&g@W^e-0k29v;xL;#QPpZ*>#x zeLm$=U}JDe$V_#Sg&e*WPqmi^NXD+@@Kl<`)T?5=|42$v62k{Z)?5j@q>_tfOMTR& zb{}X8UB>3Rp(a(Q+u`()D;(47q9&#+yCHU9sk_bX?s&uY!ZS{X)T6F0Jg0Kil2z%- z0yeV^jfdhMdu5*5G^=K(nwfgaQr$I%hH*a_`HBR*J~Ad`)d=3Zu2x_*q0_iyu}`n? zcgr=OE*NpQOgdze>gj7euW-I*&_&hw&-%@W6bv^7cNv6!3(#}hVkFGEvQpsX;_De- zIDHSS)c$x=tae%P{EnV@eeJ`olbRMLCv%D~o6=<69_KId^oh4mYSh2ECVJ2N-p@=- z{W7!l;f;>@x2`Cx&0%Mlyf8<{%YuQ?BC_@DoM)*=T2?T;(wMgL)Rbk6)#r6Jzl1W( z{WyQ6rq)EEVAf}MeT^Qsyz>6J=(^6CE$bOBrG~S*DZftm;;j@k(f+7Z#HLF6k z3Gyj0C@{WBMY(V6<4RnDR!&4d@5SgEZfa1d|}s)T_x+*C+fPL>^OVl zRrl7Ax6jQAr5A6rI9}c2{5C(-dhy;@liU(csrF`RiRmaj-0h*jRB_xoPJ0WhU0>{T zfAyPJ^*BVgvv%CNvZtV>u`7XTnd+SU#e!Goy))ezmdh<&E4>2B5Um4+AM=#)?&MxuIxy5Y+`eJ(8xdG2D65lVOQW9Ms{PdLxOy# ziwkdCZfwY_zV!5clB9LCr(4n7k1KhEtR6HrFFe=x%q-eDm>c z-BBKf7fPQyPn9V3icFcISg*jbf%g#mqd9u|`p3^DAN>4HXu-ooj|Gf%)xGQZwq*KM z-!f}+Gtm=0`=ZeEmi3Q^pTzxY%|$d0ME_xK_~u}i!rvj~=@d~i<>*#Mp5Tb9Yy4)* ztiNa-^>6vlYd3ZISC-G!iLPyoWXjNJ)joPaKP16+-t9#ip|yt2%nLam@R{$Jx;DE| z_v?~>)h2ZuEB4pdMs`otYfB6bd!6rZeCMzb)0~;M+Pyaq1U1PD&8=RR+u_Dg@m?%q z??GAL8t=rLrxwM{k-5;YY?ZW^oyTRC&@d-e9&2gU-!~3P^PMhWUU=u^rMW$3eOWgv zHl*v%kbPg#wy|pZo+lewzMu5Y)l^BVigl}1X1EbycQtk~Q|IJu``T=p@^2eIx4OT5 z>6V5aqB9s+v{|k4QhIl+VejGYXucCGbo%*Zqnsy}^;QBl7m^y%{bLy8kL+PRrnkJ2 zSu-x)ip_wbrK;%Pm#O{?hwlCSE49Rc;hm%Vkso?)VVgdS^N2Dy)_$0+|6VDP-RQ)g zXXg8x-}Bj&3H^}bNN8x5-yrj#)1T#v*s3qrbrv!u{f=VgV(>ZM{psniO9xrQ&Q$Zq zIIuI7bnSX{k7Ien9kV2vAZsSqkixGH%$gpJ?2GLGiexNcKj_a@aNM=V&EjZOidSw`UA1*mPE-aaZ%D+6fC7*c0l0 zhBvTPFmQO7{Z>$U^WE_2BN5#XrEcY}zQXx06jbaQSZ6!3dONVr7Hsmq5T<;iG1P%o z<44_WMpnfL)&PSh0}jSl|H2zyOcy`tBr~VXJ@FtbhXcbFbx!e!#$N(0E*dJF-?cV3 zsa{PC^j@KT_)@rHU`x;n5%(E7zg6V7sIrPq7WpVz9rvPjNfSdtFk}2SmXeEAWnbie zUkT0G=C536_Ue%S{G;NUrj4gRMuoVB268Y+OwrRbYR{d>?2*`hVoG}@hYj?URT1e3+Y zrc4w0?(CYpu&C3KwfMW#$_`)ArCm`g1ZB#Y)`xNMDKOp%Qk!wY%-*2z=m}ju14j1; z%o!S?dvcGb2?Y`-LVL!)H-XRYl*Ml%;_ z)<`sLNMc>JqkGN`L6!q~lRq_gPw0Ph%=q1ByVDPqlqa-4;!u!VSaVNYr6EIeF*Aem z1TFTLZ7RYleF`dK3s~}toGwjf5ea8LBq(k@WN z^L2_`(S)E%SHqWm-G;r9D=QcZdXhGVv>oVPOtkpy+LwDljV$-$iNwGl`}eK&gj}Xqvz&~zMnHDNY0#OIde+n z%xRS~XUv>AYv;^4H)qcKIdg&JtVNcymPF24Ryk|M%vq~;&RTPG*1DgwHb~CiWI20F zgwvo)YPVulJ1g{$;HK!>+74Fo4dQaCpR?AY-yQ1 zdGgHZ(`U_^wXCCKTTjp8DO1)@p1gdA6*_ZmnE-aND+{yLVsTvgPKMEiblgdAMiKhn+kB?cICy z=+X1Xj-5Gk=I-(1moHzwdFj&KyLTU)KK&-nn!C!GnhnAHI6_?A5DR zpPxPj^$Fj<|M%+EmoHyHeL`w?2-*KNqz|i}-NnToleJ<`ZCQDFdB9>XS*``EuC5BU zkG<>lm4_iXo>j0yU|~)Rb69JUYH4W0%Q!I6;0DgkovwgWc!wvC%IS4?KtD`S&ebd z*GU&AxprqeRr%A|%1U zz`}GuE@pG>x0$&gS$A|WFsiI*u9rC0s?fMk_K1Aji^%7pnKCmB-Qto}(iyrgTz&SS z`KqVJiLhyNGm_gR+6o^_WW2oIANu$1^4T5BS_|YDSY!UF9qZ5fn~CRTLuF&w@#266NkhC z$EgjkRMqBlJo)J#dc@E}y)>=lq12-M*v~a-XITJ3tObHdACAdg&0-M&VdMgJ@ z{}uTYH)e>X%sA*8AL{r~EPvU|1U88se%mz}zRomMS;6qf;MIm_Pa9HgLk$^YOS-cz zzFOTsak*oHVo3JetvBMLn>g>xI>02l;O@0-F2#%s3l;b~7BsFovMsw!SgOTA!v5r} ziw{<^2dqqP1Boo z!ul@~H0-|K+nsfai>r5WvW&Iv);NwCr~EFdTmQ}r<@0$P&mFq=!Pgu4p|+c>Rh@m( zS0`I29xYCZzt$=eTVUY8==geS69bEd&6|6y0uBv~qQcs&;sJlW_^@nXyy5@#IFU{?Gpcsh_F z;XrK%`x=!@ey%5*=Y7nWY|Gr2mBhX4%nzn3(q9b~o1SaTlyJ*%Omft2wytjEa$6wn z#Gr0q*%5iiF2c5G%9@sUP1l$WSRBuWEG>{@NwjQJ(UIX>tEFMW&wJQti3{t-wg)Rt zaV+@ItS50XHu*=Ra8v;UbIB_s?sS&$W_bHo&i_F@{pkTYVMUdg|;f0bHj~z2v zCD=RtCu38a$X4}vXWL4*C?C3Z!)?bLkMtsuMHU}4PLv4>IR_X&lDU2Ggd5-MiT^rN zlotN`(d!iz6f)z8f9S6jdp)PEpD>>%MC0w^lS1D<^gnWN*x5D1>$e4?p;keo=tHLU z0!tiNbsKJ{My=dyxT1iCQ72v~J9C~oqe4FCYA=~~OD}Grp2_zdl#CU3iYC5tP5dA{ zo%MLaN@KI!*`JKPBNY^yBu_k;YkGi5(ye3j_J+w*ubL=WZoJ8xoAo}|Wgh2hsfK^cwX9ex;F$h+# zo)A)a%S_|VrBxB7v;0I4ocH7Vn*Crkm+sr(D_fPOuKZS*6_aheYo*kC-6|G`P6K7j zO@#~AKm3s1Hs|E*OrdKl7TD$l7jBI>HtXQVX^R(5Jaa*c;mxINuE#Rrsm^S28V?wE zoL!q&_}e}s&dtiV%0Kw%0_~S8eAlJyc$s{5!L1!S4$Qh0jp?07ji>)qVEKMwP3?(- zK>eGqcdzAI@3K0ijmPpT*M~;!$&0eso-W&_wYrtNVOP09f2=CQ&A+QFxuPyKCtX?a z*!G4^<80QJRo`9TxUQ9cSgrDpeb?(*-#53Hr5Yc)5NT0z?C6H_;#1ACA08Z5_-4@I zDARg>(zJ~%aqnZMdmm|s|lX?jm1r@+A@>~=T1?njGzUuB*8EmODurN!+RGU2CoC>e1YJYeLXaNvxr#pju) zdm6cIZmw1D%MLlYAYsRziRO9>CWUU7xukb|=a#^=>M@H7QX~`}a&)+D5ftcUk)HDN zERRw1^#9VKFJr#wmezG^3I}TF3ZA;$66FxU{3!XwpURC<9p_RPIWTeGPzZjfm>|~j zfYJQ^^Zh%Hh1Q#$ig9i7S6DjDq_A20xxv5JtrsLV96Z@_AIYODv3KDv!x+VKoq7-l`V5R&2fLG3L-!4*OW2sH{|(z?l2qG5!FvuK%hF zQ=?`V^NUUCIr7s(;^k7V^dE-;;6Bs z8Lz_XErYIewWO8beWv#7m;0(o>7P%`ej_RS`)2*UPpen|6Xl3e4?D}PSw2BWfr+mm zY1M%vKaSmJe`^@9`_|N3-|CpXxcCwrII526{j_Cp(3tq_$|8qGt}l1rhcY;^Y7~@c z-Q1pC^>dzAfa(^Fgau6I4q6%07ql#9I>;oVacD(cz2xSNM;Mx!7kyY~z@{|e0n3Gc zyDy#%&4vYvJdHsI7z93;t&yr%4f`l1sM3(alk$McE02MxEpj%m5iLq67ybBD?`f)h>VmxKjAr2z!_x{PIW&Z# zH#THG@e$!*djDK7>>z`Vv$3T?Y|v+Bl?Y?a2MlQs848U3zp5~E{=2}?K=HkW6})PLJkJ;B<1v_wi7KnYhDPl z1}HEjFobXE)DJ40bED0LLuPSB*Bpy-#s#Vy%-ih}I<{1FtWT9+a-wrthko(%D2b+) z(hAXX4)L84U29&nT3T5C`)G48L)6@@XLm)<(yQIk4T|T5RWAsup1+a%@6w5mD;d4D zuA%!yd+)wzvGiyx*vRm3N6fRpo~Io>vVoCD4>6<{HtHSe%YVqsbAUmofbIQ_4$Fw% zXNir|75W5)#QuBqP2tFASkUwOTO->{`RX72D`qq>&Fp73u$U<@vDk5<=*{l!JKVli zaLD|W=vgtr`NRaD2cdBr+-5ZdCq7__dmxebV&Vr0p)j%JISs)ROZ8e4CaVRCTXQmW zxwkViO>sP#e%g>h*PzMn0+a3%j{iGEoNjX5y&)2Mv*q{;5y5L5@sIlRE7;;^icS(p zNvY&atDKg#b6U>LX?Z`V6-Z7mvYcKLIlZiMdc{o4o}D(aJ-aso9MC>n0&1U)o10sZ zmseO=SWZq(Nl8gnPftfj2Q)!vW#wsW>*C_#?&<06;}hWS9_Hs46ciK}8X6lLo9^qI z<>!|h8d?GFuvNsxrKYB)WoBk)XO|=Ykc^6lXP1gnjRi*mu&cOxUW5^;7HE8nFJ(vLItCLK^XZMu42-`UyUMOT}zzV^a* zj>)7|j7ed!4M#dY%Y=4$Uq7STUaah8^EfWj^x&8Gd+j}HB^T5%FmNCE|Mbwjy}$Gy zv2K}?^~Aec@BM2zh5$K-w6Eb0wjZd_yf{5Rxx)AAfyQ_RhXQJ{m!5m* z@GwS9>LLS^pwb6MAq#_#acmq01(QMyYPKH?tvP$OA>`u8Q08tPt`7+wL0dn>vKd@r zZ46O667)G>KNa!b7@QAp|e+8MS>Cpr&ln%5fkT&e(QO6lFh!S)1w)# zoQUKLz4$JUzL@jpKoUb8T_s0S7k23lCncUVm)^BeQftm@BVk zM*2(fhy+L01tAF+TiJtj8*Z?hZuynPV;bWb%WH9&s8TwS-H zNlQ;moW*jkP;hR@+IY?n?epgsi!wB9T~f(?pdh|Qd!0yRLBZ7W%aRlI+`fb|zKVIv z)(|fqVsfWIyihZ+Rgm=`OMrH=8;`*O_E3X|Z}vSdQwZH0T2fPx&ve;Su(wxrkHLw^ zGoh^^+(yq{vX}f_mA~L0(}suDn*CaWr+aq(IXeIMF`kd&UIGRUGgOT)7G4Rz^Wwf} zpz%c)0S6buyz?uVY(KcVY<~Ujq5|{ok{Ck=t*etl1vlvLWP2uF;x>C*joGEl^?#2} z%*$6jurEGg!uGq#{w<*_6V~3GezE4gH|tDq=|r7v$Gn#9OytcGSR-J3XF)@V_Je@K zyYJtPofy*gYOTSoOkq3mh3ffiJ3?2m&-rnI;mxX<%?x{FtT&V}aym@ecsFk94K3#{ zYjdJji8X&X9PAj*S72o~ODKQcwp5;fE8ojq{M;k?-}uc{@D~II6wLAnsMXB z@w~miik@;7yxX{PU(x&CnfI4IyVlAiCQ$$LG^0?$ga+n@ev5y>G7OT+4m(}?;yH58 zob7t7xzMzQ;rT)F*gL@ocNm;WoP1j0*pen=-4Fi8;a)fYXFZ*_E8?uX>6QlJLfRU z-P@*dRIh5IdBUBZOq(A^G%h{m%8NQU`9-=R(1E3IEphU1h{|T z;bd44Bqv?@+-;`kQ~TB_eP!Q%u(|}cMm5ag7MA-bdGck(a>kBM28lTKoo5Y#Er1ds(&#Tg4W#FIjA%m^rzQom^tcsVk z%{X}PRO_r{j7x0vVfx!)`d7)N`O`5rHIJ2E;k$ywrZjl_XT`?)bsS3Ac#>6Z2P4;F z17@KK4Ejo}>YmT9N;AJIXcvf<5?fWIek|c2XF#cw;G$XoLL3trQzmCKX-<79UOCgm zEb_(Fh~r*iIz9`v#6O=7UMzK0J>+WmBQ9~#mJL_0a&FZZYxNaB@kD#>OCK)N+z64X z1)N|+i zmGDE`WesBYC^T_KJUH&aXT+-K;JJUnryY-XYu6r>j`NvVp{H?xfz8U{saA00Da9Ly z()#Aasb1c`?I73Y^;Xu;48ynU2g)__RUCL`_P&8d;>4%41rCo@f{nfEMs zew<5mf)ShEfv;Q!HEt^UsyS>f4KKDlfA(r6(^O{xCjOR&Q@6Ui#ny>5b_wfDF8bvp z+HymFGV{L^T=5s0*6$Qpuy2bl153n_T+WR@ntbM(EUMb0b$;cKW6cM5_I>XcTEP61 zwZX$lA%m|?uf;*A^PZ&_!@;t{+8o!UQ#^fo&RVX#l<<{tz1Frsm&xV&dd(4m-@;eg zHXBuW-u-;<>NNQi@87Oaol$qd`)>EXFT0<+m#8&;zcJnES@Qq6_f@|9zIkrnkFELw z*JNLuc;KI*a%_I>(`H2@fe!2BDGaC1rY10d*^+KsJXO!bv+3`R=hYo9Qq#7GzFjJr zn_S+<(6C3nVbUf^*%ZCS6C36qXxRO{p{R&~rGTNkfq`j4SZyLplB1+(M&mknfmC6w z8LFZ!MqG7f{ome9Sw}2w&1_s5e91AqM7VTgt_UPQ$?0Ic^ zXPzSW;m2KDSh^bpxi_B>+qI*6&yDVVKe`V{^c=G2ITF!xtfJ?{jGj|Fdd}SFIrpRI zf<*5ni{2{{z1J#wZ_MbuwWC+=&W+xCKYAZX^gXiZdlJ$2tfKG5jJ{Vp`rh2=n+@-$ zWTEv_xVQxP`GtgpApH~_9UUWBKgHX|#?{r;6Fj{X;N%qG?;q>$4_Zzb5)u~|m+s?} z;piTx>ok|ESfxd^~8xQXU^O> zWeT+CvV8fr6)X0vTD5ue=KUKtZr`zE&z?QESFb*_XV014yKipU@_y;kCu`TfUA5}N z%9W4y?D@WXH)MwC%$YNnjvv2r=FFW_r!HN(bmi8qJ9qB9JaXjw(WCzk9Qbnn{J%42 z9$dNd@b2B$ckhChAKtnJnrC|T>i?4`pWeRx|N8aYpFcqh4?lnYLZwM2_J0jo!*y=y z9)rhRuBlf-R)xAsYB)4FFfs?MkGrb!)pOS7%y}NAT4g$FEC2ajSoX8AbmbLBLA4ii zCnN|ocojVLJ9&G-$`IzF%Pj)lngx$cjIL=-^-}oAc5L#Je-(3o{ zONm)GLqC39KBv&qgI^XN>|d^$x8m1L;~RTds27(VDqr~U{2YJf_&OZ&J2aBm5aqxP8~f^9&~oXB8ij^ z$LFPQmMSirruK2A8p!v8Fz;%&jT%$ByHzybzYE=%jv*uJR@xu=Zv(hL!5zg zN>|KRkzLVPv323h6^oS9I9rjyiZ~DR4*W8121=u7)1!6SzZ@t;QZdbysb}^~o5JOg} zKmWo43q%uFe9dm%Q`7-@%i1{L|Wyf~!}6ZfS)zr3>mAx&v_VnF` z1FX(oA!0Z7%d2M7|I5#~o%Qw_$s@BhV;N4UuebU1fN8<+cae|Hf7=APPGI}^ z?pd!5gY=R{w~zlGi1*L=e4$x)@B2R1Nrq6ASd^1$CzH z|5K*@GO?gJWAeE#_XM3j+8i`d;$URgxS=C_PM61M!xX;q9n8E+J=~Th7unxmJoNZT zn(>!S4{xn%ux3z5ODpNT{`ZsmydNLEJ2R4%-r1OKzIRi<V^OB-EC`%D!fT?13rXB4;}e9Wi)ib&2aj>$9127hY|fcI%Fs z*t&w{${#(Lp2xU}wzht@AM8*ZqTiDm89GKax zG{vqgX+_^A<+k=u#}d&{ zEphDDpE94V^OzOICV)q*F zbxf*05TVe+*!y!=hQq^!GhdgkSzo?pm4|-F`SlAESwv1auo*NkUYr)TMf8M&b&WD{jT@S(wDO}GCW{Q?*EQ4C+HXyQ ze_dnSp>g4D*O3oRPp&Q7%PPoS^SE0t?b7$TXM8@?8aFik%k0$P*7DF#XYes);F)n? z``*0GA6_R^UUE3pzi>mfeox}G!iXqAwMKr&3#V)X>vh|hCPC6 z$%2^+_n+EY*nM!tW7*5x`*+!1>Yu#(kx9zQB3HAbogcbeJK_`mjF8T*X?@c+NNOx|YJXFCDLC!JQp1@q6|5I( zr+)s@?RZzmWJUv%hD51ar~@N^!Ojbeai*(1Q`y9O8rVD?nl`l0ZISvng@G-E!&yZ6 z)63ZI<35QW9N62eoA_%Km_s}cOK636Osr}Y0#!cDZH{7J7 z!0*p(CfVAu39YZ3yQw0J5X*yc}L?aj;T-+tJ;Z`wD{k9XzI z?_4C&*EjQWOoWDW%enS-$}_WLJUJB-JIxCkF3)^-dcum#_D(&e#ql>>y^b49DG=ve z6@2#v&yITs?V8##^=tN-1t)~y`}Pk^~2Id{d+zL7AP<|ePB_Kh~r@08~r5pO2eMq9O0&Xn^ki4 zGNOL1>z{Tf&6ZdEHRp4TY|zuQZ0Uj2_~8W; zVI|iW(d*kU5Tr@#6ytUb{D)j?gUZu51QPtJ8W%sJzh*S&VP?GBgw z{9S^%!}`e~<0tK0f0t`0muU;`U}knU5P#grz8KVJSu>%SYlkw&!{Yvp%t;Tck6*M| zTTmDGV0sgSf&uGc$utAj#DuC@f^5MTtkM&G=UwMIEncDE-n{CIgp@Nw>jW8x8EpTQ zxX-Ysi64j&9pewo#swPf0;)F_>Dn^XZ+W21YF@~5 zUHKflpxh+s@)_L1>4KFrIMy!W<`(U6&oJYC(cYTDZ8xdI&Pl#&hG^f9jtLT-lPo%? zM08H8=$tX5bJmW|IX61z{pegE(Y45;Ye_`cvWl)1GrCso=vs55Yu%5o4HDg(EV{Qu zbZ@KZ-Z7&av!Ak4f&)4w5{cB+U}0fl;pF7v=4KZY;}8P@QE6#ONl7Jnc_k$!EhQyw zZEa(1Z4(m{TN4u-8yhzp8y6QBFE6ifH@7$+AFsf`u&}VG;NZx}$aF8Sbnr%|oY>gv zkdTV__>`2C+?14@oSf>Uq~hY@nv#;*+S?UPHACF zIOz9wR>vzw_aNbjgNMYpc^LnNo|F?3SfF>!jg>(p;`X=JdrU>Vt{jdt3R+y*g%T8O zmg-KE5p$B^)8SandOX63#iE--Tj5o6%c&_2MCaeS8Lt zGj^4}xn({p_WV3;ji+~42XqRh)(I+GZtxO);KVucSi}YK*3#>R4h%&So?EXrMJ8@t z(9F;DEiUP!AjeVB2p>m>W`;ch_Zr09CdxL68BRFRsj+Ouqb}Z-LvxcuBO+MEJQOAz zU}7)nnAv(Yu{^=$ZQ@r(u`rR$Clfv7{<rIo=E9tTAY_co1bi<=Mhy8?hhCE>Wd3 zMHwUv)R!d(O?j3&ecGNIVLdi(2e0umym%qep^)Xd$Vn(d!I4Gi%gg7?dOeyegzCgz zEoaRXTM#xSGOo~-kKg6PAwjp0EEoP6-y9C}m+85PGBk2&y;^Z^W4l;uPgyM60|7~h`RUf zb$;5Jyw=wnU#acZ5>tKgRWhT_P={fy1VWRD9q&U{aWzN{+I zH`je7!po=h;TWrMN8(Xln?L87)=zr=<$99R$5%{E1-7B*Uh%E?c2&eYV2%C$y#}m8 zcN=rfqr(&oU8gebNjNGZq!G1!ao@h2R-s^tg6WigRLYoHhkN?|L;%HL;6mZ76>+p_}&r$L7m?Qt_61V*TS1U-9h} zDP+^%GsEnNrlKu>OF|>>YWe@`MJ?|z9OgLhuD@4s;$Auauz$Qy^!8r9uWMkp^1a@F zz8}wT|GiuEmRsu6$qyc)b$|5k7rf=xzq7#hpWBVz@3#)x{R!1EWssP~cszx%cB``f zv*-oee+V&M)$wUc=DM@)A*3y%2x(AGA2Q>-Hs%yQr` z-x?3awtb(v84@^lYN}{@+R216pFGyL=8i!7;!BGB^2eE6H4|Jlv>5o@o*vdzInemr zKuKt_#B$Ypx`_-0VRMdH=;(84F?MUWi)p5@{)u?iTTqkCH|4{6O&-Mt{)&d{3g*o0 zArJZ+{yb*=$I&RRurSEdpy}J1f}^}m$-N@K`Hr!CSmk+W)ug2d=k721C$L)_^cii98WqX-oHC8If$9*fZICPMSwT;z2 z?3pUVbBohky$X)RD>l~udCa?OR>;(qHtWsw52%^^Z1>GQ^q|V})YH2%p{$HEH`_g5 z!q4mB&?K^ifo-+$Ibk1#CSeOjuBShExBm$>y6WQOc%Ey8LpiHBL&c}U!-f+59m-nj z91j=rr8Wu7IB-z*-)a4Kl`OGdi&tM_S+IB&=SmODD(e%R%l)|CPSiNg(%NqHaH-JJ z7Zyb+Zh?x4h7vQLv3a&I%1FFj?!c>cHmAUGx2nm~Yn@J_X(npzeqWaQug&mfJ^Ig6 zN=n;_VZs5iW@Gs!^V+8;Z*uZp$Q3-Zt9jWyqo!w42Tk=k9acxjPA=FJHTPGIk57Px zws6CTpq0N)am{(%d+gg|b5)Or8-fCybRKuLPX2ZxP$M?O;JAsGj))^8PXYrwqruhd z5eFT+GXfqUbtQ5Ck1S0Zx3gR;JxboyneC^|^iTLMf~6m%$5PeV%2zp;?F1>daAHhWRs%Th}~1F|8}E zWO3z}7dl>1A+Cp*1V1U=_xU~ZY-5`-dw9h?zgbZgk$J|AQ+_>lO#aT$8M0)?tqc|k zj|Yrq6D^JSjnk6y;Ch_&hIrMEVy zZQ43B;f>mb3R(7!2HtHaO_Wq0+$fRXpX3AvKM zd&mFoVdRUr&Bxszc0 zy`3X6Dd%Up;&#CeRIt&_=B&0Ky^$aafn9>Zi$f=;Fg@6?>A5lM zuSSV8?k$U^w_e@Y8q1Mown2h_L9oySW{U?*t$`8}6EgIeCCfb|WXxE|qT^is zm?7n9gW=@o{u2^ZlZDF8OpJw4N=O`9`k&LVKnXwQNLt9I;woau0S!GhDP zS6|({`S9Vxr%#{0eEIU7J9pk4KmPUX*?(8A{J(h<+VOe(`2U+XppMUn5C8xF$KTIk z`{yvbz(Yr>|HP7_lQ~xNFRUo>S{t?g#+tIzZ>#n{*ihl4H~ajHEmddpcHjT7qsDLj z?e{;!HY@^8V;P$ zVP+5sSh>mZ^z<+ekp_c_^Rvv`{TLY@9KAS&XOdRwqe;x|d~Wlv zaaeujWMoj@q9l5&)RU2s!LEZ<%T(#;_JYSjUz7`-x&-~M=jB$fQ9U+E_3tlUpNfKG zcJan9WOuJ=czk? zj-Hu$egUVW>Fh}Y0e$~-|8cl7T&-W0QLpL1u#DkA`~ChP#s-F>1>55*XK5D7*U8AV zHZgqhb#h=5iJ)J7P! zKAjeM%l)Y!>y6`6a;r2wm#A2kerV+IcdC5Iqf~RDkuiPJL@DONx&_bY)qFd-oMWnB zAOpMd8%w9z{xfHuWMEqI;Mro$r46^TGYZ5Sn9Z4vNirz4EPtWO&LhCkGr_KkshNFA zVhV%$NAu=B=V?b)E;qV%lY^0|fWeuoV_Mb2bp{0uvm``#6_|MR_bh1QGGE5f%(2MN zpn3BaJxfm?&R?r^*>~NnIy%8{;R9_hpO@ilxBu(XnBc&e)j8d1lYYSJwO;Zf6C4)A zHhj6r_-IL~{ve@ArU`4VGj7d{Zk+a=wLvT@;*jeVhDg~nUuGKydVQE^o|K!s zQjA41<$6r;L6ObryMMlN%h;qlk3%eF10%b9#9GlgjoVl3c+@=og+(Waf@4Z(_|&=I zd)G((K5^x@<$)1J)X{;#HlFY4y+C0h;eRvFOtf)`>sIvI#IWak zohrl90#8(zUrAg1`}l1H9hK^zC3s=dXGoF zhvDDPOX>ovT^Kd?+tiC1JYY~&ZLFL9b!{)hlS`G%W)V^EcTcRpvTfVztA6HeB@#Ri z!Og-iju;+`y2r!!;lOc$hQ#@t2^Bd`4jxlf8rL42A13p`fypAlL`cP#SIeM*>49(a zo5Fhv224(893I$z3+}A{u$Vz&-P1PTkBQk53#7d+d~lMAaA00!Zaa0wftrujeN0*k zE^AXBSU$^660QH_B61|Zj&GOj6rl;GQ*(G3{@!|&bi%{hbeT$Ch70GFe|_}i2#R5mAw zb6PvSOt$Bmr{bg@D zXN&G}unKCsc;3o5S9Z@W`TCdH*3Ua7EA*O*znpm<^=4(qq%Q&U8G0u^lQ3)$O+8S{ zcvYQ?X@Z~g9rsjDRt@{do`!o}uk;J&w)~lNxWS~$mFd>yJ|?TuK-07@GbI@rR$lwp zWZFAn-4eC6Q&qcIl`nOuX*n15F#KQ-=ACCE^m-OgN|0*HBpvl;ThT}Uwi+`INv!l> zSdkGlRV!HP8p~~*Vr!8{3>lxN0x|$tqD|27n9XpeS?*K!jhS{d2 zQ4GO4(GfHJyje}JOxqMEz{o5hDmI<>#Y%<;vo{~jWl-&CUY=yYCCFDBrt!N52O1 zDP4w7%MUncIq;+iG_(Exey~3E>BBS6eDhcvmi&G=?ITk{1ABGEj{8$T1T!pcm|8YD znk9gNWy|45V&)1LxVK3E#kE(7f}cy`p$_>`$gG8*ZN3wQ-qH*6CPlW6L8;mF2l?4lum>V5wFg#<@Ty z;HJ97uS>c=vKfT_ivD8@sFXCOvLYR;vCpY=dlwAmo;Qq_cfOLdveza~{`x!Hp4lGr~11Zf{%NP|9ofpn+3B@5Y&h=9jcSY<7OA z!D{0ub;+eEbe@C4O?S(K>@8-j8RoyYi8L>EpSk%svmiszuDd~k6Av6Wwc5rp!%rvg zyGBFj{bx)!n>kOsO^N(&RF>`u&W`e2GlePTnTye*LolfU{5NUAN+~51a z#s^2swjFMG7NPqp^ts=6%?q(P5fxQBDvw@v<+>)u^{YC6_&HfNOG|mB;2$TB_3oS= z34MB<)sa01ek?Z0|0JaRJXoOnr;?Kr>pUHSRn_kRETet>`9hj#xzkEH+qIN`tV)AaLyo>~9@d7*#bm*xI{ zUq%1_b>sZLZ`;rReOLYe_XGa@KT_pcphN!14N?{s7FG@pZfZqq)koT z-QDxs+NMsOI&0RfC0$*M7cXA5YSq$>8#iy>ynFZVd+XO90&h`(v__vFKK$zN;csWp zf=1%+-Fy7_@tZeq{y%y0|Mlx{-@g4PVkCYzHAn@Nz2;=BXmIToIn2XxK=43APb`h|Lq8tuR>6ezC;^DY)YU}C-h8TN0D+LAzj@A0^3<3!YW&|_J1&JU zG|l*P*~5eq)=Qo|bRJLDrXlmq#^J?Nk(Dmikxn;$gc&)&|Ewm1#E` z>-p~)g|drPJ#TC%vY6UX&Zt*$xltfs`+QyYO*fPp3=Rc0G^R>ABsX!XaWJr6IU9bO zMWyA#z5Z>b4WiNxk_AZ|F->N&Trq8$X&kaswm7tH;5n%>^>SqAGD&gS=kl|gS_^^; znb#RzomCPiu|(=v?vvE{VJlN(=B;|;xnjQLt>-TmdwyX%%spiu!<|L?2OF4{>L2)U zAW3|$(UOuGc5R7C7g%S#TG*&}?bRv|nd1&CFB?9XSh(IK!J+Qd-U&?04>0}C-k^Wx zf}_8i$%F%&&X~P^v-wpsi&ZCdYkaEK&M-2Sij)R!v^ z91*`Cb1vA&$yH+f?|Vbz4X2;ZVY%JbbXYBS^^t}SzC{Hmn&#dTIz3-3Kx~)JVfV(#ZZ=X` z%IthGpS?}xBn~XSrq`fw)?Qn@vC*?+N=jp(W)K6DYimRFCDR27i`c{~3QX53Pubzv z-kek`$tl~AP$Vcd<3(ejo9BjNrK7dEW>FkpW}9%Cn^tVkrAtCaBFZU@8&#lWG;UTrcR( zzt_vJ=j{$Pm~u4zS^YcH1CO?|%iC~8m4?q=xmW(u&JSm9_epaYHBYd7)FHxN^ZAB9 zU%^A&;QEfQH`~u!aB>@LVB}H}_7SJKwm@K}>mszvW<-CB0qYvlvdMm>l&sa8Y;;Ne9%a!uL+wa0; zUbd#VEnJKzmA*A>mN8(FHtzhFCFV4D!iL&Y+PrI@P1oQ5QQw$VsNwF0XZC{E9<-^k z9NAeWu`Vo>t#PI%Uv%4yEgD^XY9G8FDM&OhoYY*-$PwVMSztq>r{L;fS%(9gu6S5K z{Mi+(vEUlZzjxn`ajYne%$|Luu*`h!p0%&k?lj+Rx+Ssiu}XU(?+#HuNyfRHGd}iA zs9|KDQ^dd~bD*`4({Y>7fn&_)5*XP!4oJVh#&9t45W{Mz>pNPKCR(0)W)!kUVUL8% zsk<3Uek+@}<~f+Q1lDdys9n8OZ0R?txffj+MLHV3)V-WL(ba)DW>?$^FH0Xk2X{vO zPF3T-S7yu)3=UzAxMB7`SlMUOo7w9e9$M&@G%d<`z$89FZS94dT&3$ayNc}KR1|mh z&ujfW&;Hc&-^((+^Ii%p5EEKq<9*fN_OI~5|A8+oxnBhoI)zw@B%H3^a&vjxsznxJ zdsk#77|dqnc`)JHrwpyWf1fQT9#UuQQ;s+j_mP2Zf|iMa0%KK76!VO>DSny?j9gM1 zJOcOlSgLU>^RD&M=B{87dl1aWIe$k8!yl0r>s8Mka*adfH=SW|i(IjFMwHgWj-#T1 zPhU0ek=pe0YJWn?)`YOHDGM|UzpBN(neW~sVZ?l7%Nh&sRu4auCdpSUY$x|Ha5xcAXCmsoz#wedTeu>150tdH3zcwC01pNzN-oUV1V-d$J~8dc%skugPo`tNmVOG_dX|3)~VbG}mLoTANpz414yTnJ~d^ zY0<4?VayhK3n!g+=f2swsIzHZZk_1A;{kf7cCGSU!nozO`nyY>ny#`(^Kuu*eUtOt z^YZQV+M7?}%noj!HG9VTtan@Q1+jAMU%2x9`Cs0urHntOaftEE@Ho(L^ujAw!MdKN z>|+o9A4p(H>|S59`=!Kky`=|cIOR8e-TmO!HV#(X$*m2to8?|C__4>lQ!mQw%qHPG zOLkvwJb1^YHHtIZfmvktiC)gq1lj1;X6Z+g*>=Q4R?5o+*yfjqp=Ys$T1?yWE{aEB0TG$KN zcdA$%WEH#6P(ni-lUbMF-T-Ea&J4!l+MF>vn1+P;TKo?V;@rrSRZvD{mNgxcG;?^H7KHvBm)bMnB+ z;J%`4g%=Dri&r)FrAb?}Iy|@W-)Z`&#rMtC8%n8;FH_a_eO~hKc=byO$7P-h1)nGE zZBS8@Jnt&-VdJ9vEHA^pA9J&s^on)4HAA9UXE{S!(dziQ(tex`sjQ!iX6*UK>i=@7 z$2aY!O$7(uJqZxoB9y>zX6p-<@|J1E)s_v0+-j_x77nZ-0ZpPRe`Qu)`EufBrMk^h zAto+Tk00OVu=J&c;)@BhlI#HgP{0Aoyw(aJ;aoZcfWG6`{vFP+{oC($h>EXfRZDl;vzU zQftfjv)dKC1smKZsQ57pK1^rb87{0K(WGS2q!Q7jR?(y}qe*K=lg^DMy&p{m63s>y z%_b4eW);mAGn%b-G~3*0w)@fSAkpGv(c%)(;#Se(F{8z6M~lym7QY`Y0TQi27Of#W zgdAaKK3O3T>TW-~A-c=0?4=w;!TH_Ezm;DEzXBQ~a#6ONlyJXtvH`x-M2rks@4uwBKX z{LrOSRL}EJj>YEge~e5#MYHZ`Zhk(EiLr_6YG=dpKpl?AGX)!`XdYf3$ssT!@T)2l z6YGY!#sdwG-Rr`-yLyux4sdR9-oGmLH;WSIj<~s(N|h9wS;ZSgxLD7g`<`ad6AY zWr@MUnhp+4-csgG-12rd?7Vk&b*?F!*~rlJoP*C{K3}Zjp)*~T+f6bRl^WhZ*xSy3 zAX@vz6CTAa2?ysLW#bTfbb7vjeH|<33vsR+{14W>l~G}taG^o-;#7g4`!~PF^C?z{ zA86)(w^V>xrse=6yY!r|_qnA1MVx447tNb+Kw#(BC3l+|FCMtybVF2yfq`#9M>w}I z=Y>Zm=J!^#GOJB!nB0?bF~Nz`W!`~<0#-(w1tPc%PtJBq)M#Ot@V}DbK&k4Z6B8<9 zvO*t-2ix>>ah+fxBX<)9umi2n2^o4*Gv!xd85N29-FH4(cdxB&`y~ebeslwM*YBD5DV?B|| zXtrvXf)C@5vWS^w^PCPe-*075VHb356lwYQU|(t^=fg&Kh6(09YzLF?Tzcfd_9HEz zahJxl1V^?b_biX>{rN20aee#R5BUcra}1a`U8Xe@bV)=UVB}Qs*)wJ8-M&wggXYW% zVNC0eWG_0QXUMj0!M2SbWKYM|7A(~f@a-(}6q?X;q;c=vH2yZ}2F!Ff_4C&(NK6Mz(oT z!1X|H-IYZZN;mXe7fw>_|%d+^9_!K-nD$PM~CURt6t?3Mi0Mx_v`;( zf3UIHJ>*A_k^b(kM?L0zV$)bsHpEr0*?#U9=l7$}8UAhJK3BlREn}f@FuOnZ-_P4# z`<)sXy07yxR!%ibyPa`QmN%uGQKWvxdqd`i3U$5(OE&hHGpIk~6%}!iWpuW8Yi44z z33o_6sym~Rfn&~8%cv{TesS80GYf8=RkQd$htWdO?|aT?Tf-vfL5)*B$v$I?%9;M)QpUdb3oe=& zCp<}ha$>iP$ET{94|-ICO{7X3Qzpz=!_~WO$AU;RA-$q0^Egj5d-;T1GBJMuGcgA?>6&o<~q%bgB%~+r*vH4VTm9WPfriL8_m8$E`Jg~l% zl4QRBq~Gl;fphvy8Y~xn;R@aMc^)^{bBl|Qjz$_PBy9MhYRCR-M)|wV+L8hnx>6GT zo36cO(=Bk+{GWV5k1NB0hxdS$tkp8H0ygFcEhijTPr49y%C}PHt0b?@KhARnkyn#y5ct@VYmCqhFxhWB;roUo?3*K}F-Tsc~v@bW@IQ+xhQuM5pDmR-?4cqQ5Q zj8+4K!OoMs{zc3J4RYtR_U`Ul)MRq>0Jq`+#_N{TO0@zSBfZ1RMRS;}JWe)lQB4lswWZL^Pu;-H_|f48fmlJs zsYP54DU#O>86MA>HK}>wgsYEOne@%_%~r;wFv#C5Ji8!t{=X;7!=|z>@P6Ps)iWm1 z#C)+$ESKm!|Lv(lj0(qG*L}V_E%C9H=&T^KvO`Ql1&mISGmn+!x$|UAXu5d7XMNMV z&lX7v%>P98oY*UK_KS6r0JHz%E3*%N5sY(r%jdV)p?g8!tgeJDSsu~fWK1_Sa>a#3 z37o6ceE-G&2aioJU&aAuzvyR%iA)Xg+)Mjd7dW^rW8?2&*meEvI#XvQk^ZN5Cf_`F zj&sXeWq!-YsjOx_4-@oXX6I)Al9{u!EYv__9%sG5?SjT{yU%`np!VO#M_J%LqmYmT zqtu@VOXfQ6{yaCVLWPC#z{U06Q6EHkk1()F)E{IC@}ApQedlpX+Kw651=V(3U{DfX z(a7Tw+sv-OA2K7bL+xqQ%GY0b^wcy8Y$TlSue#GbXtMQCu>@%g23)y zAq|Y$QUZ)m{%#iix5a^l`NnsPihT*J5rJ$*6^<7@_ZnxtT(2d)W4=J0P~N014h)}{ zvY)>gbduV-W0y7@ut8*}Nc6?=!dBVUxq5aR?Ph3`8 z*?&H>FJ9KXwU4jEW`l~T#&r>c=%0%=)N1-)W6zx-9IY|oU#@YqHRB&&p%ucnrCA#o z4nNRoy*}L~QSyMl;IeZYYAhQ*zb#PxRPJ0kIpM-5*Mg_bOWyE{9lY{gbz9EalXGR> z%-;0yWM$L6`L#=*o=;;?y&yVg66eYf{)voHk_^+u9{kh#V0rTE@}{eKbN~PQ{r^9M zL<5sW14~2$TSWuMj0Ub94LmpO8~A=S2uL&vSu~17G>TO;O3Y}K+R-R;qfzchBY~Fl zj3#(X*bC7TZbKPEmXH9gYfx6!*49=vFfakH6L)cS^>=mk@%9c02?>vkjEag%2@A`K zjg865N={DB$;l}KFKwtVEv>DsZKMONGIv=aeGYV+8WZx7{MMQyH%m_It%x37gQw)u3&YkSM;tUJ!?mWoE{`BGP+zVk}udKFu5gx^S-&0LIjOmF&LK^=U^{^D)j024IoO&z7 z_!L?eI7EIiytL3aXseh5Yi25gL#votp=0XWzt10q$P~Ru@@G1cdXqVGEe8XirdncB zuh}Cr(Fta34ofBmzx`)&fKk=trb@r(3kN68p9k9yGYgbFSTb3sL_w6rC`U7GvXMi9 zgkMDH&Bd{*0b$dVtvDDM*l!jJ9B4OGZ3u11whC*SqnOgxJg4N|(b=69yFwZmLSB7{ zn_nilu$k|~$z{xfvN8n>IZF!-vHA#3Pr^ zUgT?#-D9A3nhGmWCfUc^>-}zeot-7 z1?Fq+^Le)?JD*vb(fvQS=k}xS^Ak!RGz<5M?o$+%{P=XL>zQUjhb5{NPqIYg@;n97 z7(TrGKdWySn`Fg?gKY9O8<<{{iN1T_`Jd6@Q4@PWney9((se?4#S66;Fit(W)B3}< z;J%9toE@A?8y=c@GI2D$WvlwEzVu}7?0+Q}^>%$0lT~QgnD9NKDK+2C&d#&jEUF+% z@q(3Y%_O0Njmx;ao*!V0=Ul+R{zH`EodV}JWrjaY4XbX<$Z)rugi8>;ZpvanFkmUVF@KeqbhlwT*2EuQy zFf&bV)a-rPR5|k37ApGrcFW+}wX+ zqXL7>8IKiQsfuDTg3TM6qG#7Eb5CEI!Ljh<5;gOGl1&zTdF%^THn@v%+B~StYUjF{ zteM%a(Pz^>adpdW%~(l>h+{>WtBRJY=KtIplh-n3&!1Ur8Y=}8*oxEl>7+4gEnpUu zyPSOTP6y)?k)!F$vZpOhSzyY$tkFNUIOO(|Fq8U~XA6q5XRJu!NZpotE<|$&D|qTJ2M*I6TwzQC|5ij_`eMd9V}8lK%L_`r zEnaCU+Qh4OeP5{E1fzE^1?%^m;@_}JWR>8Ah8WckjUpDFtWmL(`7Z2G6)HK<>(F|h z>5Zu1b|#VK3{RsT=uFs|^mMDc;*VqncFBDaAJzN$4A(?=JLq)qD#Y?1R`}hNdXzyV zY;!{HK{lHQyLcD8+bT2R!pjut=o@7TlXvKn@a{{}TT#jJ^QCUEV#nIg8U?X4rX&xY-vc0FM2Q#j4kGud^= zL)N8SjQSx#O$w5*Ioc`Ls?kDdFD>r5OxN z@A5WhMJ0Stx^}Q%;Q)ig1}~)vXXfwEHOP{eNqq3KVS}jH#zgr!_k;MdN;m=wgQq66 zc$z4%C~!P(Dr!i+lNPi+>*T>F5_>LiOWzQh_VC>-IkppOyFCN;Jj%CAsR{JnBNU|M z@T9v$V%4U&7d1iijHa$H;|x94Ha~BTgDmrB2GyLKx?G=5~FFQRqc9P7x|?_HiXyXRzU%6aFy_0J6#389y$1}GsIy7lW54wYcn*WIl>gy@o~J@h*r_sX6(4f&RS6O z+bSV$t+kwb=RGu8C0_0~Ibdbp|J2n_a!Ka(;Lh^Tzwa~f?>NBj_n}Gp&jSws9f!ov zeQ2}(^H89F#}ReEk6qD!9!Z?vam@VO$G+-6j}`cLo^bd3G->vqCmQ}cPlccRG;Q~v zrw08y&+rRzKvx>nBefElnVH!*IJkLu*hEG71q4LJ#KggEMQJrPWo2beH8ph&4Q*|0 zLlqShU0q`n6FXB=a|;U_8yg2(TQ3_MOE))XXJ=1$&^h`cuCBqpzER%b6$#*R)C?b= zl+e(u(9pE#=;%~@G}`T5NS1r7D}Q;LhHf|u5|b#{W5);2cIZf>5|**T-P7qrZF_Ux6-&AXbL zLC2lUnzei0yv5+<4SN?Z+`Vqyvdx>fY}vAN`*z52C#M!JJi21V>9uQbEn9YS)235< z_FUh#?eW^RpVqH`x?#h+ty>@N+4E)R&i{M%96562o<|9|Dmmpga<-?{Vn@#7cIpFe-`0bd#lFHV{l}u_jSGH%<`jI1L1dQU`-a0sL;{64Ap^MeA9X3%njjSL^s&N1~onRwW}4k)$Oc@#J>HEexwTrO|IzmCTRqT9Ad^lxTd=}^ST@PIL3_h!_Zf^+ey25m=YT?_Riwdn(!r$06Tw!ElSja9}wIDk`=8llS5xMj! zq0?kN5|%{<_QbjXA)5sZhdKV;?`~n@GEuxG%w_XnBa1`sRHm4t zVLf>pRo_qDBrp4+DmXfPmd@v$LL#RovuIWn964qDeNQ-_+>8SZd8V17j3@2X*REn( z9<+K=7{7+@){N>cQ&(KpbgX?Rsh=^cnK$mR!^bNHHw#$=Zm~{mR*Yy*abOJ0{@t>u z^W3bARVk~N=OsmLymF1{d0XMkc`dIqQ}4ZEHMDs6Xda8i+{6!W+_{Bg8X8%(Ljqex zf^yzjJ`dGUWP7w|D`Ta^iuQ{v_nGUzY?Iw~&1N-Aord+oO_g`%Rj*sJI_r2?l%&EQ zMy1@N%d2E1CO9y$W=yDFalu_)t98$Jy9h}aJ$n@H1fs@wFR`Oi1c!9zrZZ_;6Z1=pMz!dPB6>=aOk?@ z$n=IOgZ*v6^X@yGPTUdZ8@ByTZZm$vR60dMi%)W4bZ=*J@-EinmRep6b}F|eUxaP9 z{*>FX)|!K(zvGC|Gd{k`2~LY96wiDTvCOEqCS@VRjm=XoGFpaKEm2Yack<+xlw(Gm z5gr^y4y|`)2HBq7beO~JL6a)S>!}-hmg@9>V7hwa(logW#zWrsI(DHz)-Fc`n-TF%hdX0Rp4m7&l$(){r;mNOMk6&eH?`STi@8y_{WdU?#`6iR3` zI-_W!cDvd6uWMlaMMfUM1P2zIXB&HqzATnYb`W?IFwJ<5V*kxY(>=c?vm4Dx)n;S# zVe@dfJgaU2$Jb8pvs?{9D=#jbRlDZd4G;6_$2eIG6n8WR-KFuJVwPa`xO}!(f4T+49=akGBczJQW(L@OR@>ZIA66H?*!P&Ez&- zCCOmSTp3-^9xcE9Q0X3flSURBhBoynjadmC%tA3{e#|W5-0OLBN*{Xf(q#0DP7z=T zWhi3!$Kqnn&8Vl>$SlFgaKSh_Fv zaTPHzEMUm2+rhxV)9^%Pes{oq=Np1v#aEtuS{4vDO==+n!^LVwMsdRw2DFY)g#WTqy||#%Z~3HTX8+DkJz{r+!G?K(_|mghGJmCyFe?X&@?3D_w>~f<+%tx4UELKZL_%nPoq|Mj^alf)P$SeLAc&{a^d<6Lb~8CS7$NpxgB z&kEk&-E;1CIrKl-G;>AnsnEod-ODnB8*b$6`SY|(AdX3;oh5!5!;FJGB}cz#`>4Em zFwOGv)12%~re(2@pGUOsRBUNyC^^>3KfOd{`S#e2;vKbj8QKT7$4c1$7rrApagK{~+w+-%Wm8vodM;o(kZ^3S z_o9kbYj3TZAvHhf?}Nf4ADNEyTo1N1m;AqC>Y+$Aj?KE)N_i5B@4P$ueL4TJ52o)p zG?PF4mY%}$dd7tXS6Ux(=FV6vuCm~uQ%M5z{o@N*&WMV?iBA44S?*%9(}{E6#CZq! zl0$x97jt%HKUrR{uU@0~oa6NM`UXeQg^cz>9QL{@jdc%LmL8G}E!1Lqz~a7%y{3Te zxf$;dc8yIR7*0=aI^7(__Mq~bvI=KIl;A%GPQeW}91ewd0-BY*TEHfb)y6ru_-J=giy8 z)3mDtQj zQH!vILy$IcrI zM7Mt}Y`D?6U(k@#Af)p|*Zx3*X2-7fAkGg;`l*7Uyar9%B{+Ew=r#Z7p0+@ABYWqO z9~>!DdT^b$=!oaM#dkmYK1lR`vgrR3(f_TY|Hq8}UpxB$-01)Jqn|-?0+Zzg7EU4f zbzd1sT^R-j25#_OUvhGCii(QbDk{3Vx~7_%#>U1rMn+avR<2f7Zf?7+Z+u&}!D@Uqz0y2!}X)YPo3tdgXp>g43&tSra@@{Osf zU1@2}IXRvA`8}nj-8D6nOG{^f&yb&6UEKs;Z9TcA1$27+q)D@GbXaCobF$}vhr?#xT z47%3$CalY*@OBs`h+P{K_Rxb^=t+=&o?twOo z!`t6W8f14i@jC4IpuV~K@29Uq6&ev7=_e)_7PlRY^iGg;VOu=o{gh))x3$~~b_zwX z&i!k2Oj_yy%TZY#jer9UjA;xjf~(nNL$)$~2u3g;-Ti@$ z%j}S(VQP^3O93S&sV6-QJP-7gduDvT5$gY+^P8iDZc^9oi2RI#c-HLDxFQZ>_A2He0{Ti6^!!loql2!r-XfoGW;&iRaz%=_O|^+c_(Q zJx?Um?Dq{&QcR3Im{6yq$Cjv6{&a4 z6AS`xIfpz-SDhBW>ZwbUg3$@q3G*#~-i?hDdA=g)_}z$r`EC*ukBG#7*r3WdyXe$Q z29}yzsu4aPLSJ;f(u{Ig9?i#abn%9HhOVm@N-+pdjZsku%iCDykkyoGma(`XJ#*Tu z2^+=Qb>bD@bWZ4hCaWM;a?*@@*~%ZWjav14=C(R)dLWj*cFF&c@ACvqt}q;8d1Ix( zw8P4P;o$1@kPQ9S#5tT%3_KgG8JXX=X>UmS_{+L+I;W>Z8*2iu)rZ5Kt6bk77Wwx- zO{|M~Yq*=FT*Fo8qfE{d7xODjeDc zP&^dNW}z4!le0N@+oJnF)V|#*IlufG!-HGBA1&hcxqZK%cs?N^F->yAn#E1*1`U=8 ziV1Bg4eoZP5|cvXEO$(q&&YV^MuTd@lLhVb-aM-=Kg_^1p;PaxxZResE&Lq+&Q|do z*=sd1Zfj*0OJC)`*12!v<-gVpf$Gr=&o4E6Eq(9Fzdr2A8Ogaij3?X|+kAGf7i{L% zZF%tb`@egSb@p)!pa0W(R*y-ccFmRPb-WQp=z=$THV(JMzfE8BZ)d(#LZ%~s*#T=tzJNUS$BleuCmiKk7?{|&7jn339O8S^ z&}^k~f3eI7-u}pfbQTuzmz&s@o)278tINXhFV15B@e>o;0+k$&M(p6-@Mc20NN@v# zh(W7b)9TLBS(9hKd%~*YVdz)Vbb0PFhStL$&)1(ZP!ej_m}l6;-R^oN@#L%yRxP)U z++jJwB2DI7|2;h0vr%c0*%6LSl69SZL9gcveB{tJdG|4@_KcLmsu~S%6nHz@HrSw zV))>&x302PYsu!BuYG)LP6aGXQ00tIx~*>io9UcMR)UkL)(YKBPT&0oU!3`Lh1Ha~ z8bYgrJw$Dnu-_~8eKTh{&-07CK8{kCBzr}2(h{14D;6*?v-C{0J|N39PwWC`s!=45 z=;4)Ft5|)W1ve%XHkf~P`*&rk*;ShdUazWW%$h&DOE-|UL?v`LqZogOBe#f`|0b<1 zS7tet)j3bI!rya+ZzynB_dmuha%z^Gp^qY0hwqvs!S2=@bJ>@0 zuTqav?!LC{n8-Q~0|$<0LI=brz0Ezbge}6s;DW-`2TYPT4zL*=xW$u@z$~%j0QY9=!zA}NPx=O(UrGf)2zry=?zCUG^@^EZUXJLMCw{^x6Muv`;X$&mt!Tay@e(d>N z+$uFWf#-&G;TjL+23ha71zaCCe*LsNLGkhNX(1mBCb_aVGDsXa**A+xY)%40fS>|{ zE=M%~mM5BW4UAlIyVje=$fQVSJ1*W5($ro(J=wti6i+(irYm^`)41X*Wa?6d-zUsz z(z-tbERT z#b=?kTyM|%9hcZX74P_>CN{b6onHM{?$jvVr!4dx6W2COCl zjt3%NC)amJTO~D_#gKg*6>b9pAgSq1NA?lfo7 zzdZ9zj@iCN;(KduJ)XyL;Q3y@4HZ&+u`bT1Ra&^V_%Mr4I8<^l=q`u#`N&HhRZ32$ zS`}jsFmKrFAmXq+`jPDE+40WR51<%hlkJu)yulc1r&e%s6i3@b8)+rLM4m z!Fa|k5rGfiZ(98O$o7I^O$0mZrP$_orQ$5lrkiZHNZw$4M$m+3|ChB{qPN9ooVSrO zo4??=Z(7>4)ps6O)og70?&{C*Bl}JA`(Jl|$9+rR@vhIrxvlU2vPh-{Zm*{2Yv}4Q zFfbX|9WC&;IJ74#a~r$Jm(}P09xVL-yVWq@lXRm*MC!X47ZdjVx~R35;SJCCtcPbB zm;RFe!Mri^+`^(wi}t)tSTq0U&Ljn^r&cTx4TTR`j6YN~EN8G~V6}M~d^EBC`*+o! z&&BLE*c?4r|1w<7`CGkbl8W#Ou|10i}o*Uyzrp$G<%cc4((n40-L^; zH!XPJ)ZbL6`=V(EyRzaA4TgZKxsE>lO!b>Kh?)yCUn*y_d%$G0fFa;`bIEbeBNL_W zuxPt^aDSLA#k{;GXhnDk2g5O=7FAa5>!IAcCre#>)Ea*xq_UAg<^Y3MMeBY?w$Eu| z+Y%H$i8%#C@aCQf%~@Dq>)6be(!6|u^0g1`c`rC6)l^(oNT&&;@0y@|b3sR{5f_^S z1Jeh?Gy&GQ11zhVv=+V4Wd9&I%|dwzgV>cV1p!H2%V)^1T;ZHhpq_HMOYURWh6pac zC1UE0-9HLDf|7*xb#NW*5Sr#8cD$n}*F&u3K=YXhj&nbHIt*Gb-QY-w=NA~?8?jQE-vn?s)C$0Kc}XqrKJUOpYgn& zo+(qN%$huT=FFMPy1KUZ^emn>(;HH zYm8T~KD2rB(Y`w|DQhBOq`Nd?@6d(~EZ zzaH(>Ox6fq6?1aa($muol26U?+`R1UY>VPocTR3zety0~GncH_mK8{=cxEzyR`Fc9 zvNF_N(IFr~yJ^k*C0-WJ({fl>ZCuB6K-1JQa??Md3x|SsPg84P6jhl}QJvP-!WAEW z)z!Gcsr9g^#sbMdL0Js@CJ3IeT`G3wwtPgQl^XNUw+$zkUGb}2tz4YUz-Uo!=j_(b zEk55ru;Q;(+tW=;9XH?E^GjFa^1>xuFKX4yA5YtJa$4ZI>z!8^mn^B%*ga?c#~FK0 zx4hhB>r=WZsllUgp|w^6o8OVivcku#GasA?EY>@`wC=CrT*K-6RkkwTH~3l`E>pAn zWI0>yx57sGlU^Ee>**>jPu&@xedIt)EOkwIxf13$MmdFY-d^5lGr9P zZR!2OX+Ea%-KvW+6uq=JSsW4aF;H~n)(pDDz%0-acuc~i>|dgCkCs%2LihZOs!yi` z2`Nn#Ntx)8+$(CdGN> z0lxju5)QAw;HLeSUsq*OhTWc*=6BZJaa%Qw&12q;r=k6&(wdd2Tt}pZFU@?dmy*I# zAj-iosiU>Q*kk6c-FuG)q%po)G|hogq{6UO{EwPXn+UrD~L;?dN%fAFoS4QTV z2S<AJ#-w=sjQFl#MVrTCx~;-qdPZTgsw7i# zd(1aUffvif4&8lLJ=N4`+1zjG%r|VmFy*yp>7CV8ZoU;Sx@K+p33-nX2l=t9DXE*I!}J~IP*W-frtg_I`2bnYn_>%Vk546m49h}tucehk$-u6 z!Yd2P9~@kx+Qe;`kXL;s;m89gZlwx_BOi7@n%D60wDa9t*&B70r*faUJy-hMP4_PA zm;c(k@9omRUT**GTZ&{}-LD`+hF#+J%)$SDSynT)2&qpCm0%8 zUaT|!@Hkfg+4cQ%IvrnM+;@oNWS}XSz41tfnlz4 zTS%5W8)MsqXnqz3j`$U3l@kk0b!`swiBxc}3~6XH>Q?;UAYsJSx61j@2Sq_=vt|~P z1h$uj4}~5+Wl>{T*udT*B2>-ZAn*6FEG4rcWXX@C+YJslEV;Glk6ZY$md?WX+!m4h z3=VESyvmGammFmu-o4f{6XC*Hd9(vPbzKK-07MhcasA~ZtMyUMn#<8b5-ys_M6^@a0wc8Xl@lx`j`I>yq|$-tw=o#}BQBRa!MP|_;Anle2m9ahEtrv)g`Cq8FM)5T2**UZdkygEpl~*SHy)g zS0=S8_dZ~dTYUXUIAI7-a0#nXF=?!?+3x57X7eke$@kdH`L6lH#6vPCtizu}lei0X}R z+vc2oS(Y$+;hwI`Wy@2abWh@7xZdaJUCH`(;~|v=Mh(+klz+W#ytO#yUeQ9^OS-WI zr#p2UVhulEbK zRw)OSZ+z!px(U-q1pB*eR*D&hzJju`HmMI$;xf2{x zBrYsC;nBv(U11>4%D&^+UFE}nlP)x{6inz4iC|!1VQ*l1F#Yfc(`HT!hi68UcQVTw zd}fW95GBCA!$o-6{Nhc!j%XC#+_8J^#}1F^xuOLMEb0~qIhcGioSGY$4Fb+hH!WFn z@tfGbFNar!Dc^81nj?64!rM)Gr%z>yPkzcO6Tm38&Y?AE_AIWL9fvpt&Q70O^y})1 zPbEuQoeyvJ(Ubi4M}JMAs$anB11`}~rHk$hU5)*B-0GU_m9Vw@vx9x*IaXch+qUNX zgY>}lom?6TTLb353dyXt-hH!}iCM^@=}X1d>fjymF$S^z3_71;uiEMNu29JNnl#BY z#W6hK<#YWjCd#wlf7-ey(j`Cl{%nh1@8r+^d;6xL`M|9hhZl|$1Ku|avvbG^IC3{E z_q*VGutF)K$M_BB`v7$jZnxCef&Br6-OgQ1K{GX9wEgCpw0rffuZ}y@)*dKo-o3~C zwVnBkB?SpT6&3Wu--T_Rns8z9syWYf|NqRIVbCnA@-DNiTP8_GK|kxknV5b1-kzI! z;d7$5Y*2;u9hvTRhxG2*T=6VeeR<)#;%AYjDeML2@$dSQPi*;@n|C!}(ax|7jG|{^ zH>aMTm&mPfa$$92W)$~EXW5Q`F~G8meTx(K~Y>cB0&eTM-XW zSoz5ul8)Q#u3CL1S^Ti%v3E(a*^C~(Wd;+!?fV|FwA|FXyJe{xsXan&XoHuXR?(qhdZV%WjY}s_|`}Mj7&3509S-tvTc|FP$gzmxb8bRChJ~>1_IFfYVg)Jc5h^UEw$KP5HqJ;?IODAPIFDQ~;>oF9Do+ti-7GTUwLo-U#} zX_@)v$F56)y5ALaPZ4F|`oK`RT$59QvEWA!Z-7ItBBQ_u$@gwD`!mcrReJs%F=Q?A z=y==v-nc}mFs-RUc_}L)Oxcrys2PI zH`Y3pX1-`~>)wq8M}MR}_K5t=!61G@Tw-~lo&j^hW(H%2x+99L@(N7)8Xj^#Cn`uz zTCd2Yuz|V$gkR2a-k%at&#zA4zbR>8E)m42_Zq4_pta!^t4#7p@f9!%bFm7`v<{)pj}S=%RvY}9n&WZ(>_yPy=oJAGncWQX?7 z2|ABB{$7_1iJTJFnHVoF8GlpS3nbQ~?CQo?ScVwYdaTxy; z*I&~XnsPEvW_V>hrD=mGvq9zDjngMA2vWZ)%Bi65wX?jll3yoj#xD)dq|ahKFV*>W zPV011ooVT}{P4`;nTpFT8TN|@CkRY#e>7pYc#w)So5Q8fNKI94f#3s@!9g49e;7=E z<|zKXg5!#d`1WU<>4*4_b#jJWnsAwu>-2dG{-Irrc}O@_km1V_$Cmh&cm z@J()D%Wh(Om@;qX2Gw^bIZcG-e~Fy`t#ba4ne%_`od4(M{C_{^Ge|99vRc3rwScW^ z0mrNbT)P(V+*-i*Yk`2&LLsY#B2f#)suoJjS}3(^q0Fs?a=#WTNG(#bTBH)SNUds- M#;iqu1UMM10jU0P2LJ#7 literal 0 HcmV?d00001 diff --git a/modules/desktop/quickshell/qml/Core/Corners.qml b/modules/desktop/quickshell/qml/Core/Corners.qml new file mode 100644 index 0000000..ac600b2 --- /dev/null +++ b/modules/desktop/quickshell/qml/Core/Corners.qml @@ -0,0 +1,84 @@ +import QtQuick +import QtQuick.Shapes +import "root:/Data" as Settings + +// Concave corner shape component for rounded panel edges +Shape { + id: root + + property string position: "topleft" // Corner position: topleft/topright/bottomleft/bottomright + property real size: 1.0 // Scale multiplier for entire corner + property int concaveWidth: 100 * size + property int concaveHeight: 60 * size + property int offsetX: -20 + property int offsetY: -20 + property color fillColor: Settings.Colors.bgColor + property int arcRadius: 20 * size + + // Position flags derived from position string + property bool _isTop: position.includes("top") + property bool _isLeft: position.includes("left") + property bool _isRight: position.includes("right") + property bool _isBottom: position.includes("bottom") + + // Base coordinates for left corner shape + property real _baseStartX: 30 * size + property real _baseStartY: _isTop ? 20 * size : 0 + property real _baseLineX: 30 * size + property real _baseLineY: _isTop ? 0 : 20 * size + property real _baseArcX: 50 * size + property real _baseArcY: _isTop ? 20 * size : 0 + + // Mirror coordinates for right corners + property real _startX: _isRight ? (concaveWidth - _baseStartX) : _baseStartX + property real _startY: _baseStartY + property real _lineX: _isRight ? (concaveWidth - _baseLineX) : _baseLineX + property real _lineY: _baseLineY + property real _arcX: _isRight ? (concaveWidth - _baseArcX) : _baseArcX + property real _arcY: _baseArcY + + // Arc direction varies by corner to maintain proper concave shape + property int _arcDirection: { + if (_isTop && _isLeft) + return PathArc.Counterclockwise; + if (_isTop && _isRight) + return PathArc.Clockwise; + if (_isBottom && _isLeft) + return PathArc.Clockwise; + if (_isBottom && _isRight) + return PathArc.Counterclockwise; + return PathArc.Counterclockwise; + } + + width: concaveWidth + height: concaveHeight + // Position relative to parent based on corner type + x: _isLeft ? offsetX : (parent ? parent.width - width + offsetX : 0) + y: _isTop ? offsetY : (parent ? parent.height - height + offsetY : 0) + preferredRendererType: Shape.CurveRenderer + layer.enabled: true + layer.samples: 4 + + ShapePath { + strokeWidth: 0 + fillColor: root.fillColor + strokeColor: root.fillColor // Use same color as fill to eliminate artifacts + + startX: root._startX + startY: root._startY + + PathLine { + x: root._lineX + y: root._lineY + } + + PathArc { + x: root._arcX + y: root._arcY + radiusX: root.arcRadius + radiusY: root.arcRadius + useLargeArc: false + direction: root._arcDirection + } + } +} diff --git a/modules/desktop/quickshell/qml/Core/LoaderManager.qml b/modules/desktop/quickshell/qml/Core/LoaderManager.qml new file mode 100644 index 0000000..9d58363 --- /dev/null +++ b/modules/desktop/quickshell/qml/Core/LoaderManager.qml @@ -0,0 +1,48 @@ +import QtQuick + +QtObject { + id: root + + // Keep track of loaded components + property var activeLoaders: ({}) + + // Dynamically load a QML component + function load(componentUrl, parent, properties) { + if (!activeLoaders[componentUrl]) { + var loader = Qt.createQmlObject(` + import QtQuick + Loader { + active: false + asynchronous: true + visible: false + } + `, parent); + + loader.source = componentUrl; + loader.active = true; + + if (properties) { + for (var prop in properties) { + loader[prop] = properties[prop]; + } + } + + activeLoaders[componentUrl] = loader; + } + return activeLoaders[componentUrl]; + } + + // Destroy and remove a loaded component + function unload(componentUrl) { + if (activeLoaders[componentUrl]) { + activeLoaders[componentUrl].active = false; + activeLoaders[componentUrl].destroy(); + delete activeLoaders[componentUrl]; + } + } + + // Check if a component is loaded + function isLoaded(componentUrl) { + return !!activeLoaders[componentUrl]; + } +} diff --git a/modules/desktop/quickshell/qml/Core/ProcessManager.qml b/modules/desktop/quickshell/qml/Core/ProcessManager.qml new file mode 100644 index 0000000..64d0ea3 --- /dev/null +++ b/modules/desktop/quickshell/qml/Core/ProcessManager.qml @@ -0,0 +1,189 @@ +pragma Singleton +import QtQuick +import Quickshell.Io + +// System process and resource monitoring +QtObject { + id: root + + // System resource metrics + property real cpuUsage: 0 + property real ramUsage: 0 + property real totalRam: 0 + property real usedRam: 0 + + // System control processes + property Process shutdownProcess: Process { + command: ["shutdown", "-h", "now"] + } + + property Process rebootProcess: Process { + command: ["reboot"] + } + + property Process lockProcess: Process { + command: ["hyprlock"] + } + + property Process logoutProcess: Process { + command: ["loginctl", "terminate-user", "$USER"] + } + + property Process pavucontrolProcess: Process { + command: ["pavucontrol"] + } + + // Resource monitoring processes + property Process cpuProcess: Process { + command: ["sh", "-c", "grep '^cpu ' /proc/stat | awk '{usage=($2+$3+$4)*100/($2+$3+$4+$5)} END {print usage}'"] + stdout: SplitParser { + onRead: data => { + root.cpuUsage = parseFloat(data); + } + } + } + + property Process ramProcess: Process { + command: ["sh", "-c", "free -b | awk '/Mem:/ {print $2\" \"$3\" \"$3/$2*100}'"] + stdout: SplitParser { + onRead: data => { + var parts = data.trim().split(/\s+/); + if (parts.length >= 3) { + root.totalRam = parseFloat(parts[0]) / (1024 * 1024 * 1024); + root.usedRam = parseFloat(parts[1]) / (1024 * 1024 * 1024); + root.ramUsage = parseFloat(parts[2]); + } + } + } + } + + // Monitoring timers (start manually when needed) + property Timer cpuTimer: Timer { + interval: 30000 + running: false + repeat: true + onTriggered: { + cpuProcess.running = false; + cpuProcess.running = true; + } + } + + property Timer ramTimer: Timer { + interval: 30000 + running: false + repeat: true + onTriggered: { + ramProcess.running = false; + ramProcess.running = true; + } + } + + // System control functions + function shutdown() { + console.log("Executing shutdown command"); + shutdownProcess.running = true; + } + + function reboot() { + console.log("Executing reboot command"); + rebootProcess.running = true; + } + + function lock() { + console.log("Executing lock command"); + lockProcess.running = true; + } + + function logout() { + console.log("Executing logout command"); + logoutProcess.running = true; + } + + function openPavuControl() { + console.log("Opening PavuControl"); + pavucontrolProcess.running = true; + } + + // Performance monitoring control + function startMonitoring() { + console.log("Starting system monitoring"); + cpuTimer.running = true; + ramTimer.running = true; + } + + function stopMonitoring() { + console.log("Stopping system monitoring"); + cpuTimer.running = false; + ramTimer.running = false; + } + + function setMonitoringInterval(intervalMs) { + console.log("Setting monitoring interval to", intervalMs, "ms"); + cpuTimer.interval = intervalMs; + ramTimer.interval = intervalMs; + } + + function refreshSystemStats() { + console.log("Manually refreshing system stats"); + cpuProcess.running = false; + cpuProcess.running = true; + ramProcess.running = false; + ramProcess.running = true; + } + + // Process state queries + function isShutdownRunning() { + return shutdownProcess.running; + } + function isRebootRunning() { + return rebootProcess.running; + } + function isLogoutRunning() { + return logoutProcess.running; + } + function isPavuControlRunning() { + return pavucontrolProcess.running; + } + function isMonitoringActive() { + return cpuTimer.running && ramTimer.running; + } + + function stopPavuControl() { + pavucontrolProcess.running = false; + } + + // Formatted output helpers + function getCpuUsageFormatted() { + return Math.round(cpuUsage) + "%"; + } + + function getRamUsageFormatted() { + return Math.round(ramUsage) + "% (" + usedRam.toFixed(1) + "GB/" + totalRam.toFixed(1) + "GB)"; + } + + function getRamUsageSimple() { + return Math.round(ramUsage) + "%"; + } + + Component.onDestruction: { + // Stop all timers + cpuTimer.running = false; + ramTimer.running = false; + + // Stop monitoring processes + cpuProcess.running = false; + ramProcess.running = false; + + // Stop control processes if running + if (shutdownProcess.running) + shutdownProcess.running = false; + if (rebootProcess.running) + rebootProcess.running = false; + if (lockProcess.running) + lockProcess.running = false; + if (logoutProcess.running) + logoutProcess.running = false; + if (pavucontrolProcess.running) + pavucontrolProcess.running = false; + } +} diff --git a/modules/desktop/quickshell/qml/Data/MatugenManager.qml b/modules/desktop/quickshell/qml/Data/MatugenManager.qml new file mode 100644 index 0000000..6e5d1a4 --- /dev/null +++ b/modules/desktop/quickshell/qml/Data/MatugenManager.qml @@ -0,0 +1,38 @@ +pragma Singleton +import QtQuick + +QtObject { + property var service: null + + // Expose current colors from the service + readonly property color primary: service?.colors?.raw?.primary || "#7ed7b8" + readonly property color on_primary: service?.colors?.raw?.on_primary || "#00382a" + readonly property color primary_container: service?.colors?.raw?.primary_container || "#454b03" + readonly property color on_primary_container: service?.colors?.raw?.on_primary_container || "#e2e993" + readonly property color secondary: service?.colors?.raw?.secondary || "#c8c9a6" + readonly property color surface_bright: service?.colors?.raw?.surface_bright || "#373b30" + readonly property bool hasColors: service?.isLoaded || false + + // Expose all raw Material 3 colors for complete access + readonly property var rawColors: service?.colors?.raw || ({}) + + function setService(matugenService) { + service = matugenService; + console.log("MatugenManager: Service registered"); + } + + function reloadColors() { + if (service && service.reloadColors) { + console.log("MatugenManager: Triggering color reload"); + service.reloadColors(); + return true; + } else { + console.warn("MatugenManager: No service available for reload"); + return false; + } + } + + function isAvailable() { + return service !== null; + } +} diff --git a/modules/desktop/quickshell/qml/Data/Settings.qml b/modules/desktop/quickshell/qml/Data/Settings.qml new file mode 100644 index 0000000..e76c5ca --- /dev/null +++ b/modules/desktop/quickshell/qml/Data/Settings.qml @@ -0,0 +1,315 @@ +pragma Singleton +import Quickshell +import QtQuick +import Quickshell.Io + +Singleton { + id: settings + + // Prevent auto-saving during initial load + property bool isLoading: true + + // Settings persistence with atomic writes + FileView { + id: settingsFile + path: "settings.json" + blockWrites: true + atomicWrites: true + watchChanges: false + + onLoaded: { + settings.isLoading = true; // Disable auto-save during loading + try { + var content = JSON.parse(text()); + if (content) { + // Load with fallback defaults + settings.isDarkTheme = content.isDarkTheme ?? true; + settings.currentTheme = content.currentTheme ?? (content.isDarkTheme !== false ? "oxocarbon_dark" : "oxocarbon_light"); + settings.useCustomAccent = content.useCustomAccent ?? false; + settings.avatarSource = content.avatarSource ?? "/home/imxyy/Pictures/icon.jpg"; + settings.weatherLocation = content.weatherLocation ?? "Dinslaken"; + settings.useFahrenheit = content.useFahrenheit ?? false; + settings.displayTime = content.displayTime ?? 6000; + settings.videoPath = content.videoPath ?? "~/Videos/"; + settings.customDarkAccent = content.customDarkAccent ?? "#be95ff"; + settings.customLightAccent = content.customLightAccent ?? "#8a3ffc"; + settings.autoSwitchPlayer = content.autoSwitchPlayer ?? true; + settings.alwaysShowPlayerDropdown = content.alwaysShowPlayerDropdown ?? true; + settings.historyLimit = content.historyLimit ?? 25; + settings.nightLightEnabled = content.nightLightEnabled ?? false; + settings.nightLightWarmth = content.nightLightWarmth ?? 0.4; + settings.nightLightAuto = content.nightLightAuto ?? false; + settings.nightLightStartHour = content.nightLightStartHour ?? 20; + settings.nightLightEndHour = content.nightLightEndHour ?? 6; + settings.nightLightManualOverride = content.nightLightManualOverride ?? false; + settings.nightLightManuallyEnabled = content.nightLightManuallyEnabled ?? false; + settings.ignoredApps = content.ignoredApps ?? []; + settings.workspaceBurstEnabled = content.workspaceBurstEnabled ?? true; + settings.workspaceGlowEnabled = content.workspaceGlowEnabled ?? true; + } + } catch (e) { + console.log("Error parsing user settings:", e); + } + // Re-enable auto-save after loading is complete + settings.isLoading = false; + } + } + + // User-configurable settings + property string avatarSource: "/home/imxyy/Pictures/icon.jpg" + property bool isDarkTheme: true // Keep for backwards compatibility + property string currentTheme: "oxocarbon_dark" // New theme system + property bool useCustomAccent: false // Whether to use custom accent colors + property string weatherLocation: "Dinslaken" + property bool useFahrenheit: false // Temperature unit setting + property int displayTime: 6000 // Notification display time in ms + property var ignoredApps: [] // Apps to ignore notifications from (case-insensitive) + property int historyLimit: 25 // Notification history limit + property string videoPath: "~/Videos/" + property string customDarkAccent: "#be95ff" + property string customLightAccent: "#8a3ffc" + + // Music Player settings + property bool autoSwitchPlayer: true + property bool alwaysShowPlayerDropdown: true + + // Night Light settings + property bool nightLightEnabled: false + property real nightLightWarmth: 0.4 + property bool nightLightAuto: false + property int nightLightStartHour: 20 // 8 PM + property int nightLightEndHour: 6 // 6 AM + property bool nightLightManualOverride: false // Track manual user actions + property bool nightLightManuallyEnabled: false // Track if user manually enabled it + + // Animation settings + property bool workspaceBurstEnabled: true + property bool workspaceGlowEnabled: true + + // UI constants + readonly property real borderWidth: 9 + readonly property real cornerRadius: 20 + + signal settingsChanged + + // Helper functions for managing ignored apps + function addIgnoredApp(appName) { + if (appName && appName.trim() !== "") { + var trimmedName = appName.trim(); + // Case-insensitive check for existing apps + var exists = false; + for (var i = 0; i < ignoredApps.length; i++) { + if (ignoredApps[i].toLowerCase() === trimmedName.toLowerCase()) { + exists = true; + break; + } + } + if (!exists) { + var newApps = ignoredApps.slice(); // Create a copy + newApps.push(trimmedName); + ignoredApps = newApps; + console.log("Added ignored app:", trimmedName, "Current list:", ignoredApps); + // Force save immediately (only if not loading) + if (!isLoading) { + saveSettings(); + } + return true; + } + } + return false; + } + + function removeIgnoredApp(appName) { + var index = ignoredApps.indexOf(appName); + if (index > -1) { + var newApps = ignoredApps.slice(); // Create a copy + newApps.splice(index, 1); + ignoredApps = newApps; + console.log("Removed ignored app:", appName, "Current list:", ignoredApps); + // Force save immediately (only if not loading) + if (!isLoading) { + saveSettings(); + } + return true; + } + return false; + } + + function saveSettings() { + try { + var content = { + isDarkTheme: settings.isDarkTheme, + currentTheme: settings.currentTheme, + useCustomAccent: settings.useCustomAccent, + avatarSource: settings.avatarSource, + weatherLocation: settings.weatherLocation, + useFahrenheit: settings.useFahrenheit, + displayTime: settings.displayTime, + videoPath: settings.videoPath, + customDarkAccent: settings.customDarkAccent, + customLightAccent: settings.customLightAccent, + autoSwitchPlayer: settings.autoSwitchPlayer, + alwaysShowPlayerDropdown: settings.alwaysShowPlayerDropdown, + historyLimit: settings.historyLimit, + nightLightEnabled: settings.nightLightEnabled, + nightLightWarmth: settings.nightLightWarmth, + nightLightAuto: settings.nightLightAuto, + nightLightStartHour: settings.nightLightStartHour, + nightLightEndHour: settings.nightLightEndHour, + nightLightManualOverride: settings.nightLightManualOverride, + nightLightManuallyEnabled: settings.nightLightManuallyEnabled, + ignoredApps: settings.ignoredApps, + workspaceBurstEnabled: settings.workspaceBurstEnabled, + workspaceGlowEnabled: settings.workspaceGlowEnabled + }; + var jsonContent = JSON.stringify(content, null, 4); + settingsFile.setText(jsonContent); + } catch (e) { + console.log("Error saving user settings:", e); + } + } + + // Auto-save watchers (only save when not loading) + onIsDarkThemeChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onCurrentThemeChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onUseCustomAccentChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onAvatarSourceChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onWeatherLocationChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onUseFahrenheitChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onDisplayTimeChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onHistoryLimitChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onVideoPathChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onCustomDarkAccentChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onCustomLightAccentChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onAutoSwitchPlayerChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onAlwaysShowPlayerDropdownChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onNightLightEnabledChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onNightLightWarmthChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onNightLightAutoChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onNightLightStartHourChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onNightLightEndHourChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onNightLightManualOverrideChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onNightLightManuallyEnabledChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onIgnoredAppsChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onWorkspaceBurstEnabledChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + onWorkspaceGlowEnabledChanged: { + if (!isLoading) { + settingsChanged(); + saveSettings(); + } + } + + Component.onCompleted: { + settingsFile.reload(); + } +} diff --git a/modules/desktop/quickshell/qml/Data/ThemeManager.qml b/modules/desktop/quickshell/qml/Data/ThemeManager.qml new file mode 100644 index 0000000..570cbac --- /dev/null +++ b/modules/desktop/quickshell/qml/Data/ThemeManager.qml @@ -0,0 +1,240 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io +import "Themes" as Themes + +Singleton { + id: themeManager + + // Import all theme definitions + property var oxocarbon: Themes.Oxocarbon + property var dracula: Themes.Dracula + property var gruvbox: Themes.Gruvbox + property var catppuccin: Themes.Catppuccin + property var matugen: Themes.Matugen + + // Available theme definitions + readonly property var themes: ({ + "oxocarbon_dark": oxocarbon.dark, + "oxocarbon_light": oxocarbon.light, + "dracula_dark": dracula.dark, + "dracula_light": dracula.light, + "gruvbox_dark": gruvbox.dark, + "gruvbox_light": gruvbox.light, + "catppuccin_dark": catppuccin.dark, + "catppuccin_light": catppuccin.light, + "matugen_dark": matugen.dark, + "matugen_light": matugen.light + }) + + // Current theme selection - defaults to oxocarbon_dark if not set + readonly property string currentThemeId: Settings.currentTheme || "oxocarbon_dark" + readonly property var currentTheme: themes[currentThemeId] || themes["oxocarbon_dark"] + + // Auto-update accents when Matugen colors change + Connections { + target: MatugenManager + function onPrimaryChanged() { + if (currentThemeId.startsWith("matugen_")) { + updateMatugenAccents(); + } + } + } + + // Connect to MatugenService signals for automatic accent updates + Connections { + target: MatugenManager.service + function onMatugenColorsUpdated() { + if (currentThemeId.startsWith("matugen_")) { + console.log("ThemeManager: Received matugen colors update signal"); + updateMatugenAccents(); + } + } + } + + // Initialize currentTheme in settings if not present + Component.onCompleted: { + if (!Settings.currentTheme) { + console.log("Initializing currentTheme in settings"); + Settings.currentTheme = "oxocarbon_dark"; + Settings.saveSettings(); + } + + // Matugen theme is now self-contained with service-based colors + console.log("Matugen theme initialized with service-based colors"); + + // Update accents if already using matugen theme + if (currentThemeId.startsWith("matugen_")) { + updateMatugenAccents(); + } + } + + // Custom accent colors (can be changed by user) + property string customDarkAccent: Settings.customDarkAccent || "#be95ff" + property string customLightAccent: Settings.customLightAccent || "#8a3ffc" + + // Dynamic color properties based on current theme + readonly property color base00: currentTheme.base00 + readonly property color base01: currentTheme.base01 + readonly property color base02: currentTheme.base02 + readonly property color base03: currentTheme.base03 + readonly property color base04: currentTheme.base04 + readonly property color base05: currentTheme.base05 + readonly property color base06: currentTheme.base06 + readonly property color base07: currentTheme.base07 + readonly property color base08: currentTheme.base08 + readonly property color base09: currentTheme.base09 + readonly property color base0A: currentTheme.base0A + readonly property color base0B: currentTheme.base0B + readonly property color base0C: currentTheme.base0C + readonly property color base0D: currentTheme.base0D + readonly property color base0E: Settings.useCustomAccent ? (currentTheme.type === "dark" ? customDarkAccent : customLightAccent) : currentTheme.base0E + readonly property color base0F: currentTheme.base0F + + // Common UI color mappings + readonly property color bgColor: base00 + readonly property color bgLight: base01 + readonly property color bgLighter: base02 + readonly property color fgColor: base04 + readonly property color fgColorBright: base05 + readonly property color accentColor: base0E + readonly property color accentColorBright: base0D + readonly property color highlightBg: Qt.rgba(base0E.r, base0E.g, base0E.b, 0.15) + readonly property color errorColor: base08 + readonly property color greenColor: base0B + readonly property color redColor: base08 + + // Alternative semantic aliases for convenience + readonly property color background: base00 + readonly property color panelBackground: base01 + readonly property color selection: base02 + readonly property color border: base03 + readonly property color secondaryText: base04 + readonly property color primaryText: base05 + readonly property color brightText: base06 + readonly property color brightestText: base07 + readonly property color error: base08 + readonly property color warning: base09 + readonly property color highlight: base0A + readonly property color success: base0B + readonly property color info: base0C + readonly property color primary: base0D + readonly property color accent: base0E + readonly property color special: base0F + + // UI styling constants + readonly property real borderWidth: 9 + readonly property real cornerRadius: 20 + + // Color utility functions + function withOpacity(color, opacity) { + return Qt.rgba(color.r, color.g, color.b, opacity); + } + + function withHighlight(color) { + return Qt.rgba(color.r, color.g, color.b, 0.15); + } + + // Theme management functions + function setTheme(themeId) { + if (themes[themeId]) { + const previousThemeId = Settings.currentTheme; + Settings.currentTheme = themeId; + + // Check if switching between matugen light/dark modes + if (themeId.startsWith("matugen_") && previousThemeId && previousThemeId.startsWith("matugen_")) { + const newMode = themeId.includes("_light") ? "light" : "dark"; + const oldMode = previousThemeId.includes("_light") ? "light" : "dark"; + + if (newMode !== oldMode) { + console.log(`🎨 Switching matugen from ${oldMode} to ${newMode} mode`); + } + } + + // Auto-update accents for Matugen themes + if (themeId.startsWith("matugen_")) { + updateMatugenAccents(); + } + + Settings.saveSettings(); + return true; + } + return false; + } + + // Auto-update accent colors when using Matugen theme + function updateMatugenAccents() { + if (MatugenManager.isAvailable() && MatugenManager.hasColors) { + // Get colors from the raw matugen palette + const rawColors = MatugenManager.rawColors; + + // Use primary for both dark and light themes - it's generated appropriately by matugen + const accent = rawColors.primary; + + // Debug log the colors we're using + console.log("Raw colors available:", Object.keys(rawColors)); + console.log("Selected accent for both themes:", accent); + + // Update custom accents - use the same accent for both + setCustomAccent(accent, accent); + + // Enable custom accents for Matugen theme + Settings.useCustomAccent = true; + Settings.saveSettings(); + + console.log("Auto-updated Matugen accents from service:", accent); + } else { + console.log("MatugenManager service not available or no colors loaded yet"); + } + } + + function getThemeList() { + return Object.keys(themes).map(function (key) { + return { + id: key, + name: themes[key].name, + type: themes[key].type + }; + }); + } + + function getDarkThemes() { + return getThemeList().filter(function (theme) { + return theme.type === "dark"; + }); + } + + function getLightThemes() { + return getThemeList().filter(function (theme) { + return theme.type === "light"; + }); + } + + function setCustomAccent(darkColor, lightColor) { + customDarkAccent = darkColor; + customLightAccent = lightColor; + Settings.customDarkAccent = darkColor; + Settings.customLightAccent = lightColor; + Settings.saveSettings(); + } + + function toggleCustomAccent() { + Settings.useCustomAccent = !Settings.useCustomAccent; + Settings.saveSettings(); + } + + // Legacy function for backwards compatibility + function toggleTheme() { + // Switch between dark and light variants of current theme family + var currentFamily = currentThemeId.replace(/_dark|_light/, ""); + var newThemeId = currentTheme.type === "dark" ? currentFamily + "_light" : currentFamily + "_dark"; + + // If the opposite variant doesn't exist, switch to oxocarbon + if (!themes[newThemeId]) { + newThemeId = currentTheme.type === "dark" ? "oxocarbon_light" : "oxocarbon_dark"; + } + + setTheme(newThemeId); + } +} diff --git a/modules/desktop/quickshell/qml/Data/Themes/Catppuccin.qml b/modules/desktop/quickshell/qml/Data/Themes/Catppuccin.qml new file mode 100644 index 0000000..fc141a4 --- /dev/null +++ b/modules/desktop/quickshell/qml/Data/Themes/Catppuccin.qml @@ -0,0 +1,76 @@ +pragma Singleton +import QtQuick + +QtObject { + readonly property var dark: ({ + name: "Catppuccin Mocha", + type: "dark", + base00: "#1e1e2e" // Base + , + base01: "#181825" // Mantle + , + base02: "#313244" // Surface0 + , + base03: "#45475a" // Surface1 + , + base04: "#585b70" // Surface2 + , + base05: "#cdd6f4" // Text + , + base06: "#f5e0dc" // Rosewater + , + base07: "#b4befe" // Lavender + , + base08: "#f38ba8" // Red + , + base09: "#fab387" // Peach + , + base0A: "#f9e2af" // Yellow + , + base0B: "#a6e3a1" // Green + , + base0C: "#94e2d5" // Teal + , + base0D: "#89b4fa" // Blue + , + base0E: "#cba6f7" // Mauve + , + base0F: "#f2cdcd" // Flamingo + }) + + readonly property var light: ({ + name: "Catppuccin Latte", + type: "light", + base00: "#eff1f5" // Base + , + base01: "#e6e9ef" // Mantle + , + base02: "#ccd0da" // Surface0 + , + base03: "#bcc0cc" // Surface1 + , + base04: "#acb0be" // Surface2 + , + base05: "#4c4f69" // Text + , + base06: "#dc8a78" // Rosewater + , + base07: "#7287fd" // Lavender + , + base08: "#d20f39" // Red + , + base09: "#fe640b" // Peach + , + base0A: "#df8e1d" // Yellow + , + base0B: "#40a02b" // Green + , + base0C: "#179299" // Teal + , + base0D: "#1e66f5" // Blue + , + base0E: "#8839ef" // Mauve + , + base0F: "#dd7878" // Flamingo + }) +} diff --git a/modules/desktop/quickshell/qml/Data/Themes/Dracula.qml b/modules/desktop/quickshell/qml/Data/Themes/Dracula.qml new file mode 100644 index 0000000..40789c2 --- /dev/null +++ b/modules/desktop/quickshell/qml/Data/Themes/Dracula.qml @@ -0,0 +1,76 @@ +pragma Singleton +import QtQuick + +QtObject { + readonly property var dark: ({ + name: "Dracula", + type: "dark", + base00: "#282a36" // Background + , + base01: "#44475a" // Current line + , + base02: "#565761" // Selection + , + base03: "#6272a4" // Comment + , + base04: "#6272a4" // Dark foreground + , + base05: "#f8f8f2" // Foreground + , + base06: "#f8f8f2" // Light foreground + , + base07: "#ffffff" // Light background + , + base08: "#ff5555" // Red + , + base09: "#ffb86c" // Orange + , + base0A: "#f1fa8c" // Yellow + , + base0B: "#50fa7b" // Green + , + base0C: "#8be9fd" // Cyan + , + base0D: "#bd93f9" // Blue + , + base0E: "#ff79c6" // Magenta + , + base0F: "#ffb86c" // Orange + }) + + readonly property var light: ({ + name: "Dracula Light", + type: "light", + base00: "#f8f8f2" // Light background + , + base01: "#ffffff" // Lighter background + , + base02: "#e5e5e5" // Selection + , + base03: "#bfbfbf" // Comment + , + base04: "#6272a4" // Dark foreground + , + base05: "#282a36" // Dark text + , + base06: "#21222c" // Darker text + , + base07: "#191a21" // Darkest + , + base08: "#e74c3c" // Red (adjusted for light) + , + base09: "#f39c12" // Orange + , + base0A: "#f1c40f" // Yellow + , + base0B: "#27ae60" // Green + , + base0C: "#17a2b8" // Cyan + , + base0D: "#6c7ce0" // Blue + , + base0E: "#e91e63" // Magenta + , + base0F: "#f39c12" // Orange + }) +} diff --git a/modules/desktop/quickshell/qml/Data/Themes/Gruvbox.qml b/modules/desktop/quickshell/qml/Data/Themes/Gruvbox.qml new file mode 100644 index 0000000..ad03912 --- /dev/null +++ b/modules/desktop/quickshell/qml/Data/Themes/Gruvbox.qml @@ -0,0 +1,76 @@ +pragma Singleton +import QtQuick + +QtObject { + readonly property var dark: ({ + name: "Gruvbox Dark", + type: "dark", + base00: "#282828" // Dark background + , + base01: "#3c3836" // Dark1 + , + base02: "#504945" // Dark2 + , + base03: "#665c54" // Dark3 + , + base04: "#bdae93" // Light4 + , + base05: "#d5c4a1" // Light3 + , + base06: "#ebdbb2" // Light2 + , + base07: "#fbf1c7" // Light1 + , + base08: "#fb4934" // Red + , + base09: "#fe8019" // Orange + , + base0A: "#fabd2f" // Yellow + , + base0B: "#b8bb26" // Green + , + base0C: "#8ec07c" // Cyan + , + base0D: "#83a598" // Blue + , + base0E: "#d3869b" // Purple + , + base0F: "#d65d0e" // Brown + }) + + readonly property var light: ({ + name: "Gruvbox Light", + type: "light", + base00: "#fbf1c7" // Light background + , + base01: "#ebdbb2" // Light1 + , + base02: "#d5c4a1" // Light2 + , + base03: "#bdae93" // Light3 + , + base04: "#665c54" // Dark3 + , + base05: "#504945" // Dark2 + , + base06: "#3c3836" // Dark1 + , + base07: "#282828" // Dark background + , + base08: "#cc241d" // Red + , + base09: "#d65d0e" // Orange + , + base0A: "#d79921" // Yellow + , + base0B: "#98971a" // Green + , + base0C: "#689d6a" // Cyan + , + base0D: "#458588" // Blue + , + base0E: "#b16286" // Purple + , + base0F: "#d65d0e" // Brown + }) +} diff --git a/modules/desktop/quickshell/qml/Data/Themes/Matugen.qml b/modules/desktop/quickshell/qml/Data/Themes/Matugen.qml new file mode 100644 index 0000000..1383d7f --- /dev/null +++ b/modules/desktop/quickshell/qml/Data/Themes/Matugen.qml @@ -0,0 +1,141 @@ +pragma Singleton +import QtQuick + +QtObject { + // Reference to the MatugenService + property var matugenService: null + + // Debug helper to check service status + function debugServiceStatus() { + console.log("🔍 Debug: matugenService =", matugenService); + console.log("🔍 Debug: matugenService.isLoaded =", matugenService ? matugenService.isLoaded : "N/A"); + console.log("🔍 Debug: matugenService.colorVersion =", matugenService ? matugenService.colorVersion : "N/A"); + console.log("🔍 Debug: condition result =", (matugenService && matugenService.isLoaded && matugenService.colorVersion >= 0)); + if (matugenService && matugenService.colors) { + console.log("🔍 Debug: service.colors.dark =", JSON.stringify(matugenService.colors.dark)); + } + } + + // Map matugen colors to base16 scheme - using the service when available + // The colorVersion dependency forces re-evaluation when colors update + readonly property var dark: { + debugServiceStatus(); + if (matugenService && matugenService.isLoaded && matugenService.colorVersion >= 0) { + // Use service colors if available, or generate fallback if we have light colors + return matugenService.colors.dark || { + name: "Matugen Dark (Generated from Light)", + type: "dark", + // If we only have light colors, create dark fallback + base00: "#141311", + base01: "#1c1c19", + base02: "#20201d", + base03: "#2a2a27", + base04: "#c9c7ba", + base05: "#e5e2de", + base06: "#31302e", + base07: "#e5e2de", + base08: "#ffb4ab", + base09: "#b5ccb9", + base0A: "#e4e5c1", + base0B: "#c8c7b7", + base0C: "#c8c9a6", + base0D: "#c8c9a6", + base0E: "#47483b", + base0F: "#000000" + }; + } else { + return { + name: "Matugen Dark", + type: "dark", + // Updated fallback colors to match current quickshell-colors.qml + base00: "#141311", + base01: "#1c1c19", + base02: "#20201d", + base03: "#2a2a27", + base04: "#c9c7ba", + base05: "#e5e2de", + base06: "#31302e", + base07: "#e5e2de", + base08: "#ffb4ab", + base09: "#b5ccb9", + base0A: "#e4e5c1", + base0B: "#c8c7b7", + base0C: "#c8c9a6", + base0D: "#c8c9a6", + base0E: "#47483b", + base0F: "#000000" + }; + } + } + + readonly property var light: { + if (matugenService && matugenService.isLoaded && matugenService.colorVersion >= 0) { + // Use service colors if available, or generate fallback if we have dark colors + return matugenService.colors.light || { + name: "Matugen Light (Generated from Dark)", + type: "light", + // If we only have dark colors, create light fallback + base00: "#ffffff", + base01: "#f5f5f5", + base02: "#e8e8e8", + base03: "#d0d0d0", + base04: "#666666", + base05: "#1a1a1a", + base06: "#000000", + base07: "#ffffff", + base08: "#d32f2f", + base09: "#7b1fa2", + base0A: "#f57c00", + base0B: "#388e3c", + base0C: "#0097a7", + base0D: "#1976d2", + base0E: "#5e35b1", + base0F: "#000000" + }; + } else { + return { + name: "Matugen Light", + type: "light", + // Updated fallback colors based on current colors + base00: "#ffffff", + base01: "#f5f5f5", + base02: "#e8e8e8", + base03: "#d0d0d0", + base04: "#666666", + base05: "#1a1a1a", + base06: "#000000", + base07: "#ffffff", + base08: "#d32f2f", + base09: "#7b1fa2", + base0A: "#f57c00", + base0B: "#388e3c", + base0C: "#0097a7", + base0D: "#1976d2", + base0E: "#5e35b1", + base0F: "#000000" + }; + } + } + + // Direct access to primary colors for accent updates + readonly property color primary: (matugenService && matugenService.getColor && matugenService.colorVersion >= 0) ? matugenService.getColor("primary") || "#c8c9a6" : "#c8c9a6" + readonly property color on_primary: (matugenService && matugenService.getColor && matugenService.colorVersion >= 0) ? matugenService.getColor("on_primary") || "#303219" : "#303219" + + // Function to set the service reference + function setMatugenService(service) { + matugenService = service; + console.log("🔌 MatugenService connected to theme:", service); + + // Connect to service signals for automatic updates + if (service) { + service.matugenColorsUpdated.connect(function () { + console.log("🎨 Matugen colors updated in theme (version " + service.colorVersion + ")"); + debugServiceStatus(); + }); + } + } + + Component.onCompleted: { + console.log("Matugen theme loaded, waiting for MatugenService connection"); + } +} diff --git a/modules/desktop/quickshell/qml/Data/Themes/Oxocarbon.qml b/modules/desktop/quickshell/qml/Data/Themes/Oxocarbon.qml new file mode 100644 index 0000000..5ba5c69 --- /dev/null +++ b/modules/desktop/quickshell/qml/Data/Themes/Oxocarbon.qml @@ -0,0 +1,76 @@ +pragma Singleton +import QtQuick + +QtObject { + readonly property var dark: ({ + name: "Oxocarbon Dark", + type: "dark", + base00: "#161616" // OLED-friendly background + , + base01: "#262626" // Surface 1 + , + base02: "#393939" // Surface 2 + , + base03: "#525252" // Surface 3 + , + base04: "#6f6f6f" // Text secondary + , + base05: "#c6c6c6" // Text primary + , + base06: "#e0e0e0" // Text on color + , + base07: "#f4f4f4" // Text inverse + , + base08: "#ff7eb6" // Red (pink) + , + base09: "#ee5396" // Magenta + , + base0A: "#42be65" // Green + , + base0B: "#be95ff" // Purple + , + base0C: "#3ddbd9" // Cyan + , + base0D: "#78a9ff" // Blue + , + base0E: "#be95ff" // Purple (accent) + , + base0F: "#08bdba" // Teal + }) + + readonly property var light: ({ + name: "Oxocarbon Light", + type: "light", + base00: "#f4f4f4" // Light background + , + base01: "#ffffff" // Surface 1 + , + base02: "#e0e0e0" // Surface 2 + , + base03: "#c6c6c6" // Surface 3 + , + base04: "#525252" // Text secondary + , + base05: "#262626" // Text primary + , + base06: "#161616" // Text on color + , + base07: "#000000" // Text inverse + , + base08: "#da1e28" // Red + , + base09: "#d12771" // Magenta + , + base0A: "#198038" // Green + , + base0B: "#8a3ffc" // Purple + , + base0C: "#007d79" // Cyan + , + base0D: "#0f62fe" // Blue + , + base0E: "#8a3ffc" // Purple (accent) + , + base0F: "#005d5d" // Teal + }) +} diff --git a/modules/desktop/quickshell/qml/Data/quickshell-colors.qml b/modules/desktop/quickshell/qml/Data/quickshell-colors.qml new file mode 100644 index 0000000..ce7a7f0 --- /dev/null +++ b/modules/desktop/quickshell/qml/Data/quickshell-colors.qml @@ -0,0 +1,60 @@ +pragma Singleton +import Quickshell +import QtQuick + +Singleton { + readonly property color background: "#13140c" + readonly property color error: "#ffb4ab" + readonly property color error_container: "#93000a" + readonly property color inverse_on_surface: "#313128" + readonly property color inverse_primary: "#5d631c" + readonly property color inverse_surface: "#e5e3d6" + readonly property color on_background: "#e5e3d6" + readonly property color on_error: "#690005" + readonly property color on_error_container: "#ffdad6" + readonly property color on_primary: "#2f3300" + readonly property color on_primary_container: "#e2e993" + readonly property color on_primary_fixed: "#1b1d00" + readonly property color on_primary_fixed_variant: "#454b03" + readonly property color on_secondary: "#30321a" + readonly property color on_secondary_container: "#e4e5c1" + readonly property color on_secondary_fixed: "#1b1d07" + readonly property color on_secondary_fixed_variant: "#47492e" + readonly property color on_surface: "#e5e3d6" + readonly property color on_surface_variant: "#c8c7b7" + readonly property color on_tertiary: "#07372c" + readonly property color on_tertiary_container: "#beecdc" + readonly property color on_tertiary_fixed: "#002019" + readonly property color on_tertiary_fixed_variant: "#234e42" + readonly property color outline: "#929182" + readonly property color outline_variant: "#47483b" + readonly property color primary: "#c5cc7a" + readonly property color primary_container: "#454b03" + readonly property color primary_fixed: "#e2e993" + readonly property color primary_fixed_dim: "#c5cc7a" + readonly property color scrim: "#000000" + readonly property color secondary: "#c8c9a6" + readonly property color secondary_container: "#47492e" + readonly property color secondary_fixed: "#e4e5c1" + readonly property color secondary_fixed_dim: "#c8c9a6" + readonly property color shadow: "#000000" + readonly property color surface: "#13140c" + readonly property color surface_bright: "#3a3a31" + readonly property color surface_container: "#202018" + readonly property color surface_container_high: "#2a2a22" + readonly property color surface_container_highest: "#35352c" + readonly property color surface_container_low: "#1c1c14" + readonly property color surface_container_lowest: "#0e0f08" + readonly property color surface_dim: "#13140c" + readonly property color surface_tint: "#c5cc7a" + + readonly property color surface_variant: "#47483b" + readonly property color tertiary: "#a3d0c0" + readonly property color tertiary_container: "#234e42" + readonly property color tertiary_fixed: "#beecdc" + readonly property color tertiary_fixed_dim: "#a3d0c0" + + function withAlpha(color: color, alpha: real): color { + return Qt.rgba(color.r, color.g, color.b, alpha); + } +} diff --git a/modules/desktop/quickshell/qml/Data/settings.json b/modules/desktop/quickshell/qml/Data/settings.json new file mode 100644 index 0000000..bce1864 --- /dev/null +++ b/modules/desktop/quickshell/qml/Data/settings.json @@ -0,0 +1,7 @@ +{ + "isDarkTheme": true, + "avatarSource": "/home/imxyy/Pictures/icon.jpg", + "weatherLocation": "Dinslaken", + "displayTime": 6000, + "videoPath": "~/Videos/" +} diff --git a/modules/desktop/quickshell/qml/Layout/Bar.qml b/modules/desktop/quickshell/qml/Layout/Bar.qml new file mode 100644 index 0000000..63e6efa --- /dev/null +++ b/modules/desktop/quickshell/qml/Layout/Bar.qml @@ -0,0 +1,35 @@ +import QtQuick +import QtQuick.Effects +import "root:/Data" as Data +import "root:/Widgets/System" as System +import "root:/Widgets/Calendar" as Calendar + +// Vertical sidebar layout +Rectangle { + id: bar + + // Clean bar background + color: Data.ThemeManager.bgColor + + // Workspace indicator at top + System.NiriWorkspaces { + id: workspaceIndicator + anchors { + top: parent.top + horizontalCenter: parent.horizontalCenter + horizontalCenterOffset: Data.Settings.borderWidth / 2 + topMargin: 20 + } + } + + // Clock at bottom + Calendar.Clock { + id: clockWidget + anchors { + bottom: parent.bottom + horizontalCenter: parent.horizontalCenter + horizontalCenterOffset: Data.Settings.borderWidth / 2 + bottomMargin: 20 + } + } +} diff --git a/modules/desktop/quickshell/qml/Layout/Border.qml b/modules/desktop/quickshell/qml/Layout/Border.qml new file mode 100644 index 0000000..7c36687 --- /dev/null +++ b/modules/desktop/quickshell/qml/Layout/Border.qml @@ -0,0 +1,575 @@ +import QtQuick +import QtQuick.Shapes +import Qt5Compat.GraphicalEffects +import QtQuick.Effects +import "root:/Data" as Data + +// Screen border with shadow effects +Shape { + id: borderShape + + // Border dimensions + property real borderWidth: Data.Settings.borderWidth + property real radius: Data.Settings.cornerRadius + property real innerX: borderWidth + property real innerY: borderWidth + property real innerWidth: borderShape.width - (borderWidth * 2) + property real innerHeight: borderShape.height - (borderWidth * 2) + + // Widget references for shadow positioning + property var workspaceIndicator: null + property var volumeOSD: null + property var clockWidget: null + + // Initialization state to prevent ShaderEffect warnings + property bool effectsReady: false + + // Burst effect properties - controlled by workspace indicator + property real masterProgress: workspaceIndicator ? workspaceIndicator.masterProgress : 0.0 + property bool effectsActive: workspaceIndicator ? workspaceIndicator.effectsActive : false + property color effectColor: workspaceIndicator ? workspaceIndicator.effectColor : Data.ThemeManager.accent + + // Delay graphics effects until component is fully loaded + Timer { + id: initTimer + interval: 100 + running: true + onTriggered: borderShape.effectsReady = true + } + + // Burst effect overlays (DISABLED - using unified overlay) + Item { + id: burstEffects + anchors.fill: parent + visible: false // Disabled in favor of unified overlay + z: 5 + } + + // Individual widget shadows (positioned separately) + + // Workspace indicator shadow + Shape { + id: workspaceDropShadow + visible: borderShape.workspaceIndicator !== null + x: borderShape.workspaceIndicator ? borderShape.workspaceIndicator.x : 0 // Exact match + y: borderShape.workspaceIndicator ? borderShape.workspaceIndicator.y : 0 + width: borderShape.workspaceIndicator ? borderShape.workspaceIndicator.width : 0 // Exact match + height: borderShape.workspaceIndicator ? borderShape.workspaceIndicator.height : 0 + z: -1 + + layer.enabled: borderShape.workspaceIndicator !== null + layer.effect: DropShadow { + transparentBorder: true + horizontalOffset: 1 + verticalOffset: 1 + radius: 12 + (effectsActive && Data.Settings.workspaceGlowEnabled ? Math.sin(masterProgress * Math.PI) * 4 : 0) + samples: 25 + color: { + if (!effectsActive) + return Qt.rgba(0, 0, 0, 0.4); + if (!Data.Settings.workspaceGlowEnabled) + return Qt.rgba(0, 0, 0, 0.4); + // Use accent color directly with reduced intensity + const intensity = Math.sin(masterProgress * Math.PI) * 0.4; + return Qt.rgba(effectColor.r * intensity + 0.08, effectColor.g * intensity + 0.08, effectColor.b * intensity + 0.08, 0.4 + intensity * 0.2); + } + cached: true + spread: 0.2 + (effectsActive && Data.Settings.workspaceGlowEnabled ? Math.sin(masterProgress * Math.PI) * 0.15 : 0) + } + + ShapePath { + strokeWidth: 0 + fillColor: "black" + + startX: 12 + startY: 0 + + // Right side - standard rounded corners + PathLine { + x: workspaceDropShadow.width - 16 + y: 0 + } + + PathArc { + x: workspaceDropShadow.width + y: 16 + radiusX: 16 + radiusY: 16 + direction: PathArc.Clockwise + } + + PathLine { + x: workspaceDropShadow.width + y: workspaceDropShadow.height - 16 + } + + PathArc { + x: workspaceDropShadow.width - 16 + y: workspaceDropShadow.height + radiusX: 16 + radiusY: 16 + direction: PathArc.Clockwise + } + + PathLine { + x: 12 + y: workspaceDropShadow.height + } + + // Left side - concave curves for border integration + PathLine { + x: 0 + y: workspaceDropShadow.height - 12 + } + PathArc { + x: 12 + y: workspaceDropShadow.height - 24 + radiusX: 12 + radiusY: 12 + direction: PathArc.Clockwise + } + + PathLine { + x: 12 + y: 24 + } + + PathArc { + x: 0 + y: 12 + radiusX: 12 + radiusY: 12 + direction: PathArc.Clockwise + } + PathLine { + x: 12 + y: 0 + } + } + } + + // Volume OSD shadow + Rectangle { + id: volumeOsdDropShadow + visible: borderShape.volumeOSD !== null && borderShape.volumeOSD.visible + opacity: borderShape.volumeOSD ? borderShape.volumeOSD.opacity : 0 + x: parent.width - 45 + y: (parent.height - 250) / 2 + width: 45 + height: 250 + color: "black" + topLeftRadius: 20 + bottomLeftRadius: 20 + topRightRadius: 0 + bottomRightRadius: 0 + z: -1 + + // Sync opacity animations with volume OSD + Behavior on opacity { + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + } + } + + layer.enabled: borderShape.volumeOSD !== null + layer.effect: DropShadow { + transparentBorder: true + horizontalOffset: -1 + verticalOffset: 1 + radius: 12 // Much more subtle + samples: 25 + color: Qt.rgba(0, 0, 0, 0.4) // Very light shadow + cached: false + spread: 0.2 // Minimal spread + } + } + + // Clock shadow + Rectangle { + id: clockDropShadow + visible: borderShape.clockWidget !== null + x: borderShape.clockWidget ? borderShape.clockWidget.x : 0 + y: borderShape.clockWidget ? borderShape.clockWidget.y : 0 + width: borderShape.clockWidget ? borderShape.clockWidget.width : 0 + height: borderShape.clockWidget ? borderShape.clockWidget.height : 0 + color: "black" + topLeftRadius: 0 + topRightRadius: borderShape.clockWidget ? borderShape.clockWidget.height / 2 : 16 + bottomLeftRadius: 0 + bottomRightRadius: 0 + z: -2 // Lower z-index to render behind border corners + + layer.enabled: borderShape.clockWidget !== null + layer.effect: DropShadow { + transparentBorder: true + horizontalOffset: 1 + verticalOffset: -1 + radius: 12 // Much more subtle + samples: 25 + color: Qt.rgba(0, 0, 0, 0.4) // Very light shadow + cached: false + spread: 0.2 // Minimal spread + } + } + + // Shadow rendering source (hidden) + Item { + id: shadowSource + anchors.fill: parent + visible: false + + Shape { + id: borderShadowShape + anchors.fill: parent + + layer.enabled: true + layer.samples: 4 + + ShapePath { + fillColor: "black" + strokeWidth: 0 + fillRule: ShapePath.OddEvenFill + + // Outer rectangle (full screen) + PathMove { + x: 0 + y: 0 + } + PathLine { + x: shadowSource.width + y: 0 + } + PathLine { + x: shadowSource.width + y: shadowSource.height + } + PathLine { + x: 0 + y: shadowSource.height + } + PathLine { + x: 0 + y: 0 + } + + // Inner rounded cutout creates border + PathMove { + x: borderShape.innerX + borderShape.radius + y: borderShape.innerY + } + + PathLine { + x: borderShape.innerX + borderShape.innerWidth - borderShape.radius + y: borderShape.innerY + } + + PathArc { + x: borderShape.innerX + borderShape.innerWidth + y: borderShape.innerY + borderShape.radius + radiusX: borderShape.radius + radiusY: borderShape.radius + direction: PathArc.Clockwise + } + + PathLine { + x: borderShape.innerX + borderShape.innerWidth + y: borderShape.innerY + borderShape.innerHeight - borderShape.radius + } + + PathArc { + x: borderShape.innerX + borderShape.innerWidth - borderShape.radius + y: borderShape.innerY + borderShape.innerHeight + radiusX: borderShape.radius + radiusY: borderShape.radius + direction: PathArc.Clockwise + } + + PathLine { + x: borderShape.innerX + borderShape.radius + y: borderShape.innerY + borderShape.innerHeight + } + + PathArc { + x: borderShape.innerX + y: borderShape.innerY + borderShape.innerHeight - borderShape.radius + radiusX: borderShape.radius + radiusY: borderShape.radius + direction: PathArc.Clockwise + } + + PathLine { + x: borderShape.innerX + y: borderShape.innerY + borderShape.radius + } + + PathArc { + x: borderShape.innerX + borderShape.radius + y: borderShape.innerY + radiusX: borderShape.radius + radiusY: borderShape.radius + direction: PathArc.Clockwise + } + } + } + + // Workspace indicator shadow with concave curves + Shape { + id: workspaceShadowShape + visible: borderShape.workspaceIndicator !== null + x: borderShape.workspaceIndicator ? borderShape.workspaceIndicator.x : 0 // Exact match + y: borderShape.workspaceIndicator ? borderShape.workspaceIndicator.y : 0 + width: borderShape.workspaceIndicator ? borderShape.workspaceIndicator.width : 0 // Exact match + height: borderShape.workspaceIndicator ? borderShape.workspaceIndicator.height : 0 + preferredRendererType: Shape.CurveRenderer + + layer.enabled: borderShape.workspaceIndicator !== null + layer.samples: 8 + layer.effect: DropShadow { + transparentBorder: true + horizontalOffset: 2 + verticalOffset: 3 + radius: 25 + (effectsActive && Data.Settings.workspaceGlowEnabled ? Math.sin(masterProgress * Math.PI) * 6 : 0) + samples: 40 + color: { + if (!effectsActive) + return Qt.rgba(0, 0, 0, 0.8); + if (!Data.Settings.workspaceGlowEnabled) + return Qt.rgba(0, 0, 0, 0.8); + // Accent color glow with reduced intensity + const intensity = Math.sin(masterProgress * Math.PI) * 0.3; + return Qt.rgba(effectColor.r * intensity + 0.1, effectColor.g * intensity + 0.1, effectColor.b * intensity + 0.1, 0.6 + intensity * 0.15); + } + cached: false + spread: 0.5 + (effectsActive && Data.Settings.workspaceGlowEnabled ? Math.sin(masterProgress * Math.PI) * 0.2 : 0) + } + + ShapePath { + strokeWidth: 0 + fillColor: "black" + strokeColor: "black" + + startX: 12 + startY: 0 + + // Right side - standard rounded corners + PathLine { + x: workspaceShadowShape.width - 16 + y: 0 + } + + PathArc { + x: workspaceShadowShape.width + y: 16 + radiusX: 16 + radiusY: 16 + direction: PathArc.Clockwise + } + + PathLine { + x: workspaceShadowShape.width + y: workspaceShadowShape.height - 16 + } + + PathArc { + x: workspaceShadowShape.width - 16 + y: workspaceShadowShape.height + radiusX: 16 + radiusY: 16 + direction: PathArc.Clockwise + } + + PathLine { + x: 12 + y: workspaceShadowShape.height + } + + // Left side - concave curves for border integration + PathLine { + x: 0 + y: workspaceShadowShape.height - 12 + } + PathArc { + x: 12 + y: workspaceShadowShape.height - 24 + radiusX: 12 + radiusY: 12 + direction: PathArc.Clockwise + } + + PathLine { + x: 12 + y: 24 + } + + PathArc { + x: 0 + y: 12 + radiusX: 12 + radiusY: 12 + direction: PathArc.Clockwise + } + PathLine { + x: 12 + y: 0 + } + } + } + + // Volume OSD shadow + Rectangle { + id: volumeOsdShadowShape + visible: borderShape.volumeOSD !== null && borderShape.volumeOSD.visible + x: shadowSource.width - 45 + y: (shadowSource.height - 250) / 2 + width: 45 + height: 250 + color: "black" + topLeftRadius: 20 + bottomLeftRadius: 20 + topRightRadius: 0 + bottomRightRadius: 0 + + layer.enabled: borderShape.volumeOSD !== null && borderShape.volumeOSD.visible + layer.effect: DropShadow { + transparentBorder: true + horizontalOffset: -2 // Shadow to the left for right-side widget + verticalOffset: 3 + radius: 25 + samples: 40 + color: Qt.rgba(0, 0, 0, 0.8) + cached: false + spread: 0.5 + } + } + + // Clock shadow + Rectangle { + id: clockShadowShape + visible: borderShape.clockWidget !== null + x: borderShape.clockWidget ? borderShape.clockWidget.x : 0 + y: borderShape.clockWidget ? borderShape.clockWidget.y : 0 + width: borderShape.clockWidget ? borderShape.clockWidget.width : 0 + height: borderShape.clockWidget ? borderShape.clockWidget.height : 0 + color: "black" + topLeftRadius: 0 + topRightRadius: borderShape.clockWidget ? borderShape.clockWidget.height / 2 : 16 + bottomLeftRadius: 0 + bottomRightRadius: 0 + + layer.enabled: borderShape.clockWidget !== null + layer.effect: DropShadow { + transparentBorder: true + horizontalOffset: 2 + verticalOffset: -2 // Shadow upward for bottom widget + radius: 25 + samples: 40 + color: Qt.rgba(0, 0, 0, 0.8) + cached: false + spread: 0.5 + } + } + } + + // Apply shadow effect to entire border shape + layer.enabled: true + layer.samples: 8 + layer.smooth: true + layer.effect: DropShadow { + transparentBorder: true + horizontalOffset: 1 + verticalOffset: 2 + radius: 30 // Slightly less dramatic + samples: 45 + color: Qt.rgba(0, 0, 0, 0.75) // A bit lighter + cached: false + spread: 0.5 // Less spread + } + + // Main border shape + ShapePath { + fillColor: Data.ThemeManager.bgColor + strokeWidth: 0 + fillRule: ShapePath.OddEvenFill + + // Outer rectangle + PathMove { + x: 0 + y: 0 + } + PathLine { + x: borderShape.width + y: 0 + } + PathLine { + x: borderShape.width + y: borderShape.height + } + PathLine { + x: 0 + y: borderShape.height + } + PathLine { + x: 0 + y: 0 + } + + // Inner rounded cutout + PathMove { + x: borderShape.innerX + borderShape.radius + y: borderShape.innerY + } + + PathLine { + x: borderShape.innerX + borderShape.innerWidth - borderShape.radius + y: borderShape.innerY + } + + PathArc { + x: borderShape.innerX + borderShape.innerWidth + y: borderShape.innerY + borderShape.radius + radiusX: borderShape.radius + radiusY: borderShape.radius + direction: PathArc.Clockwise + } + + PathLine { + x: borderShape.innerX + borderShape.innerWidth + y: borderShape.innerY + borderShape.innerHeight - borderShape.radius + } + + PathArc { + x: borderShape.innerX + borderShape.innerWidth - borderShape.radius + y: borderShape.innerY + borderShape.innerHeight + radiusX: borderShape.radius + radiusY: borderShape.radius + direction: PathArc.Clockwise + } + + PathLine { + x: borderShape.innerX + borderShape.radius + y: borderShape.innerY + borderShape.innerHeight + } + + PathArc { + x: borderShape.innerX + y: borderShape.innerY + borderShape.innerHeight - borderShape.radius + radiusX: borderShape.radius + radiusY: borderShape.radius + direction: PathArc.Clockwise + } + + PathLine { + x: borderShape.innerX + y: borderShape.innerY + borderShape.radius + } + + PathArc { + x: borderShape.innerX + borderShape.radius + y: borderShape.innerY + radiusX: borderShape.radius + radiusY: borderShape.radius + direction: PathArc.Clockwise + } + } +} diff --git a/modules/desktop/quickshell/qml/Layout/Desktop.qml b/modules/desktop/quickshell/qml/Layout/Desktop.qml new file mode 100644 index 0000000..145a7b5 --- /dev/null +++ b/modules/desktop/quickshell/qml/Layout/Desktop.qml @@ -0,0 +1,302 @@ +import QtQuick +import QtQuick.Shapes +import Quickshell +import Quickshell.Wayland +import Qt5Compat.GraphicalEffects +import "root:/Data" as Data +import "root:/Widgets/System" as System +import "root:/Core" as Core +import "root:/Widgets" as Widgets +import "root:/Widgets/Notifications" as Notifications +import "root:/Widgets/ControlPanel" as ControlPanel + +// Desktop with borders and UI widgets +Scope { + id: desktop + + property var shell + property var notificationService + + // Desktop UI layer per screen + Variants { + model: Quickshell.screens + + PanelWindow { + required property var modelData + screen: modelData + + implicitWidth: Screen.width + implicitHeight: Screen.height + color: "transparent" + exclusiveZone: 0 + + WlrLayershell.namespace: "quickshell-desktop" + + // Interactive mask for workspace indicator only + mask: Region { + item: workspaceIndicator + } + + anchors { + top: true + left: true + bottom: true + right: true + } + + // Workspace indicator at left border + System.NiriWorkspaces { + id: workspaceIndicator + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + leftMargin: Data.Settings.borderWidth + } + z: 10 + width: 32 + } + + // Volume OSD at right border (primary screen only) + System.OSD { + id: osd + shell: desktop.shell + visible: modelData === Quickshell.primaryScreen + anchors { + right: parent.right + verticalCenter: parent.verticalCenter + rightMargin: Data.Settings.borderWidth + } + z: 10 + } + + // Widget shadows (positioned behind border for proper layering) + + // Workspace indicator shadow + Rectangle { + id: workspaceShadow + visible: workspaceIndicator !== null + x: workspaceIndicator.x + y: workspaceIndicator.y + width: workspaceIndicator.width + height: workspaceIndicator.height + color: "black" + radius: 16 + z: -10 // Behind border + + layer.enabled: true + layer.effect: DropShadow { + transparentBorder: true + horizontalOffset: 2 + verticalOffset: 2 + radius: 8 + (workspaceIndicator.effectsActive && Data.Settings.workspaceGlowEnabled ? Math.sin(workspaceIndicator.masterProgress * Math.PI) * 3 : 0) + samples: 17 + color: { + if (!workspaceIndicator.effectsActive) + return Qt.rgba(0, 0, 0, 0.3); + if (!Data.Settings.workspaceGlowEnabled) + return Qt.rgba(0, 0, 0, 0.3); + // Use accent color glow with reduced intensity + const intensity = Math.sin(workspaceIndicator.masterProgress * Math.PI) * 0.3; + return Qt.rgba(workspaceIndicator.effectColor.r * intensity + 0.05, workspaceIndicator.effectColor.g * intensity + 0.05, workspaceIndicator.effectColor.b * intensity + 0.05, 0.3 + intensity * 0.15); + } + cached: true + spread: 0.1 + (workspaceIndicator.effectsActive && Data.Settings.workspaceGlowEnabled ? Math.sin(workspaceIndicator.masterProgress * Math.PI) * 0.1 : 0) + } + } + + // Clock widget shadow + Rectangle { + id: clockShadow + visible: clockWidget !== null + x: clockWidget.x + y: clockWidget.y + width: clockWidget.width + height: clockWidget.height + color: "black" + topLeftRadius: 0 + topRightRadius: clockWidget.height / 2 + bottomLeftRadius: 0 + bottomRightRadius: 0 + z: -10 // Behind border + + layer.enabled: true + layer.effect: DropShadow { + transparentBorder: true + horizontalOffset: 1 + verticalOffset: -1 + radius: 8 + samples: 17 + color: Qt.rgba(0, 0, 0, 0.3) + cached: true + spread: 0.1 + } + } + + // Border background with shadow + Border { + id: screenBorder + anchors.fill: parent + workspaceIndicator: workspaceIndicator + volumeOSD: volumeOsd + clockWidget: clockWidget + z: -5 // Behind UI elements to prevent shadow from covering control panel + } + + // Unified Wave Overlay - simple burst effect + Item { + id: waveOverlay + anchors.fill: parent + visible: workspaceIndicator.effectsActive && Data.Settings.workspaceBurstEnabled + z: 15 + + property real progress: workspaceIndicator.masterProgress + property color waveColor: workspaceIndicator.effectColor + + // Workspace indicator burst effects + Item { + x: workspaceIndicator.x + y: workspaceIndicator.y + width: workspaceIndicator.width + height: workspaceIndicator.height + + // Expanding pill burst - positioned at current workspace index (mimics pill shape) + Rectangle { + x: parent.width / 2 - width / 2 + y: { + // Find current workspace index directly from currentWorkspace + let focusedIndex = 0; + for (let i = 0; i < workspaceIndicator.workspaces.count; i++) { + const workspace = workspaceIndicator.workspaces.get(i); + if (workspace && workspace.id === workspaceIndicator.currentWorkspace) { + focusedIndex = i; + break; + } + } + + // Calculate position accounting for Column centering and pill sizes + let cumulativeHeight = 0; + for (let i = 0; i < focusedIndex; i++) { + const ws = workspaceIndicator.workspaces.get(i); + cumulativeHeight += (ws && ws.isFocused ? 36 : 22) + 6; // pill height + spacing + } + + // Current pill height + const currentWs = workspaceIndicator.workspaces.get(focusedIndex); + const currentPillHeight = (currentWs && currentWs.isFocused ? 36 : 22); + + // Column is centered, so start from center and calculate offset + const columnHeight = parent.height - 24; // Total available height minus padding + const columnTop = 12; // Top padding + + return columnTop + cumulativeHeight + currentPillHeight / 2 - height / 2; + } + width: 20 + waveOverlay.progress * 30 + height: 36 + waveOverlay.progress * 20 // Pill-like height + radius: width / 2 // Pill-like rounded shape + color: "transparent" + border.width: 2 + border.color: Qt.rgba(waveOverlay.waveColor.r, waveOverlay.waveColor.g, waveOverlay.waveColor.b, 1.0 - waveOverlay.progress) + opacity: Math.max(0, 1.0 - waveOverlay.progress) + } + + // Secondary expanding pill burst - positioned at current workspace index + Rectangle { + x: parent.width / 2 - width / 2 + y: { + // Find current workspace index directly from currentWorkspace + let focusedIndex = 0; + for (let i = 0; i < workspaceIndicator.workspaces.count; i++) { + const workspace = workspaceIndicator.workspaces.get(i); + if (workspace && workspace.id === workspaceIndicator.currentWorkspace) { + focusedIndex = i; + break; + } + } + + // Calculate position accounting for Column centering and pill sizes + let cumulativeHeight = 0; + for (let i = 0; i < focusedIndex; i++) { + const ws = workspaceIndicator.workspaces.get(i); + cumulativeHeight += (ws && ws.isFocused ? 36 : 22) + 6; // pill height + spacing + } + + // Current pill height + const currentWs = workspaceIndicator.workspaces.get(focusedIndex); + const currentPillHeight = (currentWs && currentWs.isFocused ? 36 : 22); + + // Column is centered, so start from center and calculate offset + const columnHeight = parent.height - 24; // Total available height minus padding + const columnTop = 12; // Top padding + + return columnTop + cumulativeHeight + currentPillHeight / 2 - height / 2; + } + width: 18 + waveOverlay.progress * 45 + height: 30 + waveOverlay.progress * 35 // Pill-like height + radius: width / 2 // Pill-like rounded shape + color: "transparent" + border.width: 1.5 + border.color: Qt.rgba(waveOverlay.waveColor.r, waveOverlay.waveColor.g, waveOverlay.waveColor.b, 0.6) + opacity: Math.max(0, 0.8 - waveOverlay.progress * 1.2) + visible: waveOverlay.progress > 0.2 + } + } + } + + // Clock at bottom-left corner + Widgets.Clock { + id: clockWidget + anchors { + bottom: parent.bottom + left: parent.left + bottomMargin: Data.Settings.borderWidth + leftMargin: Data.Settings.borderWidth + } + z: 10 + } + + // Notification popups (primary screen only) + Notifications.Notification { + id: notificationPopup + visible: (modelData === (Quickshell.primaryScreen || Quickshell.screens[0])) && calculatedHeight > 20 + anchors { + top: parent.top + right: parent.right + rightMargin: Data.Settings.borderWidth + 20 + topMargin: 0 + } + width: 420 + height: calculatedHeight + shell: desktop.shell + notificationServer: desktop.notificationService ? desktop.notificationService.notificationServer : null + z: 15 + + Component.onCompleted: { + let targetScreen = Quickshell.primaryScreen || Quickshell.screens[0]; + if (modelData === targetScreen) { + desktop.shell.notificationWindow = notificationPopup; + } + } + } + + // UI overlay layer for modal components + Item { + id: uiLayer + anchors.fill: parent + z: 20 + + ControlPanel.ControlPanel { + id: controlPanelComponent + shell: desktop.shell + } + } + } + } + + // Handle dynamic screen configuration changes + Connections { + target: Quickshell + function onScreensChanged() { + // Screen changes handled by Variants automatically + } + } +} diff --git a/modules/desktop/quickshell/qml/Services/AppLauncherService.qml b/modules/desktop/quickshell/qml/Services/AppLauncherService.qml new file mode 100644 index 0000000..fc4acf6 --- /dev/null +++ b/modules/desktop/quickshell/qml/Services/AppLauncherService.qml @@ -0,0 +1,394 @@ +import QtQuick +import Quickshell +import Quickshell.Io + +// App launcher service - discovers and manages applications +Item { + id: appService + + property var applications: [] + property bool isLoading: false + + // Categories for apps + property var categories: { + "AudioVideo": "🎵", + "Audio": "🎵", + "Video": "🎬", + "Development": "💻", + "Education": "📚", + "Game": "🎮", + "Graphics": "🎨", + "Network": "🌐", + "Office": "📄", + "Science": "🔬", + "Settings": "⚙️", + "System": "🔧", + "Utility": "🛠️", + "Other": "📦" + } + + property string userName: "" + property string homeDirectory: "" + property bool userInfoLoaded: false + property var currentApp: ({}) + property var pendingSearchPaths: [] + + Component.onCompleted: { + // First get user info, then load applications + loadUserInfo(); + } + + function loadUserInfo() { + userNameProcess.running = true; + } + + Process { + id: userNameProcess + command: ["whoami"] + running: false + + stdout: SplitParser { + splitMarker: "\n" + onRead: line => { + if (line.trim()) { + userName = line.trim(); + } + } + } + + onExited: { + // Now get home directory + homeDirProcess.running = true; + } + } + + Process { + id: homeDirProcess + command: ["sh", "-c", "echo $HOME"] + running: false + + stdout: SplitParser { + splitMarker: "\n" + onRead: line => { + if (line.trim()) { + homeDirectory = line.trim(); + } + } + } + + onExited: { + // Now we have user info, start loading applications + userInfoLoaded = true; + loadApplications(); + } + } + + function loadApplications() { + if (!userInfoLoaded) { + console.log("User info not loaded yet, skipping application scan"); + return; + } + + isLoading = true; + applications = []; + + console.log("DEBUG: Starting application scan with user:", userName, "home:", homeDirectory); + + // Comprehensive search paths for maximum Linux compatibility + appService.pendingSearchPaths = [ + // User-specific locations (highest priority) + homeDirectory + "/.local/share/applications/", + + // Standard FreeDesktop.org locations + "/usr/share/applications/", "/usr/local/share/applications/", + + // Flatpak locations + "/var/lib/flatpak/exports/share/applications/", homeDirectory + "/.local/share/flatpak/exports/share/applications/", + + // Snap locations + "/var/lib/snapd/desktop/applications/", "/snap/bin/", + + // AppImage locations (common user directories) + homeDirectory + "/Applications/", homeDirectory + "/AppImages/", + + // Distribution-specific paths + "/opt/*/share/applications/" // For manually installed software + , "/usr/share/applications/kde4/" // KDE4 legacy + + , + + // NixOS-specific (will be ignored on non-NixOS systems) + "/run/current-system/sw/share/applications/", "/etc/profiles/per-user/" + userName + "/share/applications/"]; + + console.log("DEBUG: Starting with essential paths:", JSON.stringify(appService.pendingSearchPaths)); + + // Add XDG and home-manager paths + getXdgDataDirs.running = true; + } + + Process { + id: getXdgDataDirs + command: ["sh", "-c", "echo $XDG_DATA_DIRS"] + running: false + + stdout: SplitParser { + splitMarker: "\n" + onRead: line => { + if (line.trim()) { + var xdgDirs = line.trim().split(":"); + for (var i = 0; i < xdgDirs.length; i++) { + if (xdgDirs[i].trim()) { + var xdgPath = xdgDirs[i].trim() + "/applications/"; + if (appService.pendingSearchPaths.indexOf(xdgPath) === -1) { + appService.pendingSearchPaths.push(xdgPath); + console.log("DEBUG: Added XDG path:", xdgPath); + } + } + } + } + } + } + + onExited: { + // Now add home-manager path + getHomeManagerPaths.running = true; + } + } + + Process { + id: getHomeManagerPaths + command: ["sh", "-c", "find /nix/store -maxdepth 1 -name '*home-manager-path*' -type d 2>/dev/null | head -1"] + running: false + + stdout: SplitParser { + splitMarker: "\n" + onRead: line => { + if (line.trim()) { + var homeManagerPath = line.trim() + "/share/applications/"; + appService.pendingSearchPaths.push(homeManagerPath); + console.log("DEBUG: Added home-manager path:", homeManagerPath); + } + } + } + + onExited: { + // CRITICAL: Always ensure these essential directories are included + var essentialPaths = ["/run/current-system/sw/share/applications/", "/usr/share/applications/", "/usr/local/share/applications/"]; + + for (var i = 0; i < essentialPaths.length; i++) { + var path = essentialPaths[i]; + if (appService.pendingSearchPaths.indexOf(path) === -1) { + appService.pendingSearchPaths.push(path); + console.log("DEBUG: Added missing essential path:", path); + } + } + + // Start bulk parsing with all paths including XDG and home-manager + startBulkParsing(appService.pendingSearchPaths); + } + } + + function startBulkParsing(searchPaths) { + // BULLETPROOF: Ensure critical system directories are always included + var criticalPaths = ["/run/current-system/sw/share/applications/", "/usr/share/applications/", "/usr/local/share/applications/"]; + + for (var i = 0; i < criticalPaths.length; i++) { + var path = criticalPaths[i]; + if (searchPaths.indexOf(path) === -1) { + searchPaths.push(path); + console.log("DEBUG: BULLETPROOF: Added missing critical path:", path); + } + } + + console.log("DEBUG: Final directories to scan:", searchPaths.join(", ")); + + // Single command to parse all .desktop files at once + // Only parse fields from the main [Desktop Entry] section, ignore [Desktop Action] sections + var cmd = 'for dir in ' + searchPaths.map(p => "'" + p + "'").join(" ") + '; do ' + 'if [ -d "$dir" ]; then ' + 'find "$dir" -name "*.desktop" 2>/dev/null | while read file; do ' + 'echo "===FILE:$file"; ' + 'sed -n \'/^\\[Desktop Entry\\]/,/^\\[.*\\]/{/^\\[Desktop Entry\\]/d; /^\\[.*\\]/q; /^Name=/p; /^Exec=/p; /^Icon=/p; /^Comment=/p; /^Categories=/p; /^Hidden=/p; /^NoDisplay=/p}\' "$file" 2>/dev/null || true; ' + 'done; ' + 'fi; ' + 'done'; + + bulkParseProcess.command = ["sh", "-c", cmd]; + bulkParseProcess.running = true; + } + + Process { + id: bulkParseProcess + running: false + + stdout: SplitParser { + splitMarker: "\n" + onRead: line => { + if (line.startsWith("===FILE:")) { + // Start of new file + if (appService.currentApp.name && appService.currentApp.exec && !appService.currentApp.hidden && !appService.currentApp.noDisplay) { + applications.push(appService.currentApp); + } + appService.currentApp = { + name: "", + exec: "", + icon: "", + comment: "", + categories: [], + hidden: false, + noDisplay: false, + filePath: line.substring(8) // Remove "===FILE:" prefix + }; + } else if (line.startsWith("Name=")) { + appService.currentApp.name = line.substring(5); + } else if (line.startsWith("Exec=")) { + appService.currentApp.exec = line.substring(5); + } else if (line.startsWith("Icon=")) { + appService.currentApp.icon = line.substring(5); + } else if (line.startsWith("Comment=")) { + appService.currentApp.comment = line.substring(8); + } else if (line.startsWith("Categories=")) { + appService.currentApp.categories = line.substring(11).split(";").filter(cat => cat.length > 0); + } else if (line === "Hidden=true") { + appService.currentApp.hidden = true; + } else if (line === "NoDisplay=true") { + appService.currentApp.noDisplay = true; + } + } + } + + onStarted: { + appService.currentApp = {}; + } + + onExited: { + // Process the last app + if (appService.currentApp.name && appService.currentApp.exec && !appService.currentApp.hidden && !appService.currentApp.noDisplay) { + applications.push(appService.currentApp); + } + + console.log("DEBUG: Before deduplication: Found", applications.length, "applications"); + + // Deduplicate applications - prefer user installations over system ones + var uniqueApps = {}; + var finalApps = []; + + for (var i = 0; i < applications.length; i++) { + var app = applications[i]; + var key = app.name + "|" + app.exec.split(" ")[0]; // Use name + base command as key + + if (!uniqueApps[key]) { + // First occurrence of this app + uniqueApps[key] = app; + finalApps.push(app); + } else { + // Duplicate found - check if this version should replace the existing one + var existing = uniqueApps[key]; + var shouldReplace = false; + + // Priority order (higher priority replaces lower): + // 1. User local applications (highest priority) + // 2. Home-manager applications + // 3. User profile applications + // 4. System applications (lowest priority) + + if (app.filePath.includes("/.local/share/applications/")) { + shouldReplace = true; // User local always wins + } else if (app.filePath.includes("home-manager-path") && !existing.filePath.includes("/.local/share/applications/")) { + shouldReplace = true; // Home-manager beats system + } else if (app.filePath.includes("/home/") && !existing.filePath.includes("/.local/share/applications/") && !existing.filePath.includes("home-manager-path")) { + shouldReplace = true; // User profile beats system + } + + if (shouldReplace) { + // Replace the existing app in finalApps array + for (var j = 0; j < finalApps.length; j++) { + if (finalApps[j] === existing) { + finalApps[j] = app; + uniqueApps[key] = app; + break; + } + } + } + // If not replacing, just ignore the duplicate + } + } + + applications = finalApps; + console.log("DEBUG: After deduplication: Found", applications.length, "unique applications"); + + isLoading = false; + applicationsChanged(); + } + } + + function launchApplication(app) { + if (!app || !app.exec) + return; + + // Clean up the exec command (remove field codes like %f, %F, %u, %U) + var cleanExec = app.exec.replace(/%[fFuU]/g, "").trim(); + + launchProcess.command = ["sh", "-c", cleanExec]; + launchProcess.running = true; + + console.log("Launching:", app.name, "with command:", cleanExec); + } + + Process { + id: launchProcess + running: false + + onExited: { + if (exitCode !== 0) { + console.log("Failed to launch application, exit code:", exitCode); + } + } + } + + // Fuzzy search function + function fuzzySearch(query, apps) { + if (!query || query.length === 0) { + return apps; + } + + query = query.toLowerCase(); + + return apps.filter(app => { + var searchText = (app.name + " " + app.comment).toLowerCase(); + + // Simple fuzzy matching - check if all characters of query appear in order + var queryIndex = 0; + for (var i = 0; i < searchText.length && queryIndex < query.length; i++) { + if (searchText[i] === query[queryIndex]) { + queryIndex++; + } + } + + return queryIndex === query.length; + }).sort((a, b) => { + // Sort by relevance - exact matches first, then by name + var aName = a.name.toLowerCase(); + var bName = b.name.toLowerCase(); + + var aExact = aName.includes(query); + var bExact = bName.includes(query); + + if (aExact && !bExact) + return -1; + if (!aExact && bExact) + return 1; + + return aName.localeCompare(bName); + }); + } + + function getCategoryIcon(app) { + if (!app.categories || app.categories.length === 0) { + return categories["Other"]; + } + + // Find the first matching category + for (var i = 0; i < app.categories.length; i++) { + var category = app.categories[i]; + if (categories[category]) { + return categories[category]; + } + } + + return categories["Other"]; + } +} diff --git a/modules/desktop/quickshell/qml/Services/MatugenService.qml b/modules/desktop/quickshell/qml/Services/MatugenService.qml new file mode 100644 index 0000000..0fff494 --- /dev/null +++ b/modules/desktop/quickshell/qml/Services/MatugenService.qml @@ -0,0 +1,155 @@ +import QtQuick +import Quickshell.Io +import "root:/Data" as Data + +// Matugen color integration service +Item { + id: service + + property var shell + property var colors: ({}) + property bool isLoaded: false + property int colorVersion: 0 // Increments every time colors update to force QML re-evaluation + + // Signals to notify when colors change + signal matugenColorsUpdated + signal matugenColorsLoaded + + // File watcher for the matugen quickshell-colors.qml + FileView { + id: matugenFile + path: "/home/imxyy/.config/quickshell/Data/quickshell-colors.qml" + blockWrites: true + + onLoaded: { + parseColors(text()); + } + + onTextChanged: { + parseColors(text()); + } + } + + // Parse QML color definitions and map them to base16 colors + function parseColors(qmlText) { + if (!qmlText) { + console.warn("MatugenService: No QML content to parse"); + return; + } + + const lines = qmlText.split('\n'); + const parsedColors = {}; + + // Extract readonly property color definitions + for (const line of lines) { + const match = line.match(/readonly\s+property\s+color\s+(\w+):\s*"(#[0-9a-fA-F]{6})"/); + if (match) { + const colorName = match[1]; + const colorValue = match[2]; + parsedColors[colorName] = colorValue; + } + } + + // Detect if this is a light or dark theme based on surface luminance + const surfaceColor = parsedColors.surface || "#000000"; + const isLightTheme = getLuminance(surfaceColor) > 0.5; + + console.log(`MatugenService: Detected ${isLightTheme ? 'light' : 'dark'} theme from surface color: ${surfaceColor}`); + + // Use Material Design 3 colors directly with better contrast + const baseMapping = { + base00: parsedColors.surface || (isLightTheme ? "#ffffff" : "#000000") // Background + , + base01: parsedColors.surface_container_low || (isLightTheme ? "#f8f9fa" : "#1a1a1a") // Panel bg + , + base02: parsedColors.surface_container || (isLightTheme ? "#e9ecef" : "#2a2a2a") // Selection + , + base03: parsedColors.surface_container_high || (isLightTheme ? "#dee2e6" : "#3a3a3a") // Border/separator + , + base04: parsedColors.on_surface_variant || (isLightTheme ? "#6c757d" : "#adb5bd") // Secondary text (better contrast) + , + base05: parsedColors.on_surface || (isLightTheme ? "#212529" : "#f8f9fa") // Primary text (high contrast) + , + base06: parsedColors.on_background || (isLightTheme ? "#000000" : "#ffffff") // Bright text + , + base07: isLightTheme ? parsedColors.surface_container_lowest || "#ffffff" : parsedColors.surface_bright || "#ffffff" // Brightest + , + base08: isLightTheme ? parsedColors.on_error || "#dc3545" : parsedColors.error || "#ff6b6b" // Error (theme appropriate) + , + base09: parsedColors.tertiary || (isLightTheme ? "#6f42c1" : "#a855f7") // Purple + , + base0A: parsedColors.primary_fixed || (isLightTheme ? "#fd7e14" : "#fbbf24") // Orange/Yellow + , + base0B: parsedColors.secondary || (isLightTheme ? "#198754" : "#10b981") // Green + , + base0C: parsedColors.surface_tint || (isLightTheme ? "#0dcaf0" : "#06b6d4") // Cyan + , + base0D: parsedColors.primary_container || (isLightTheme ? "#0d6efd" : "#3b82f6") // Blue + , + base0E: parsedColors.primary || (isLightTheme ? "#6610f2" : parsedColors.secondary || "#8b5cf6") // Accent - use primary for light, secondary for dark + , + base0F: parsedColors.scrim || "#000000" // Special/black + }; + + // Create the theme object + const theme = Object.assign({ + name: isLightTheme ? "Matugen Light" : "Matugen Dark", + type: isLightTheme ? "light" : "dark" + }, baseMapping); + + // Store colors in the appropriate theme slot + colors = { + raw: parsedColors, + [isLightTheme ? 'light' : 'dark']: theme, + // Keep the other theme as null or use fallback + [isLightTheme ? 'dark' : 'light']: null + }; + + isLoaded = true; + colorVersion++; // Increment version to force QML property updates + + console.log("MatugenService: Colors loaded successfully from QML (version " + colorVersion + ")"); + console.log("Available colors:", Object.keys(parsedColors).join(", ")); + + // Emit signals to notify theme system + matugenColorsUpdated(); + matugenColorsLoaded(); + } + + // Calculate luminance of a hex color + function getLuminance(hexColor) { + // Remove # if present + const hex = hexColor.replace('#', ''); + + // Convert to RGB + const r = parseInt(hex.substr(0, 2), 16) / 255; + const g = parseInt(hex.substr(2, 2), 16) / 255; + const b = parseInt(hex.substr(4, 2), 16) / 255; + + // Calculate relative luminance + const rs = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4); + const gs = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4); + const bs = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4); + + return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; + } + + // Reload colors from file + function reloadColors() { + matugenFile.reload(); + } + + // Get specific color by name + function getColor(colorName) { + return colors.raw ? colors.raw[colorName] : null; + } + + // Check if matugen colors are available + function isAvailable() { + return isLoaded && colors.raw && Object.keys(colors.raw).length > 0; + } + + Component.onCompleted: { + console.log("MatugenService: Initialized, watching quickshell-colors.qml"); + } +} diff --git a/modules/desktop/quickshell/qml/Services/NotificationService.qml b/modules/desktop/quickshell/qml/Services/NotificationService.qml new file mode 100644 index 0000000..fb50c42 --- /dev/null +++ b/modules/desktop/quickshell/qml/Services/NotificationService.qml @@ -0,0 +1,97 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Notifications +import "root:/Data/" as Data + +// Notification service with app filtering +Item { + id: service + + property var shell + property alias notificationServer: notificationServer + + property int maxHistorySize: Data.Settings.historyLimit + property int cleanupThreshold: maxHistorySize + 10 + + // Periodic cleanup every 30 minutes + Timer { + interval: 1800000 + running: true + repeat: true + onTriggered: cleanupNotifications() + } + + function cleanupNotifications() { + if (shell.notificationHistory && shell.notificationHistory.count > cleanupThreshold) { + const removeCount = shell.notificationHistory.count - maxHistorySize; + shell.notificationHistory.remove(maxHistorySize, removeCount); + } + + // Remove invalid entries + if (shell.notificationHistory) { + for (let i = shell.notificationHistory.count - 1; i >= 0; i--) { + const item = shell.notificationHistory.get(i); + if (!item || !item.appName) { + shell.notificationHistory.remove(i); + } + } + } + } + + NotificationServer { + id: notificationServer + actionsSupported: true + bodyMarkupSupported: true + imageSupported: true + keepOnReload: false + persistenceSupported: true + + onNotification: notification => { + // Filter empty notifications + if (!notification.appName && !notification.summary && !notification.body) { + if (typeof notification.dismiss === 'function') { + notification.dismiss(); + } + return; + } + + // Filter ignored applications (case-insensitive) + var shouldIgnore = false; + if (notification.appName && Data.Settings.ignoredApps && Data.Settings.ignoredApps.length > 0) { + for (var i = 0; i < Data.Settings.ignoredApps.length; i++) { + if (Data.Settings.ignoredApps[i].toLowerCase() === notification.appName.toLowerCase()) { + shouldIgnore = true; + break; + } + } + } + + if (shouldIgnore) { + if (typeof notification.dismiss === 'function') { + notification.dismiss(); + } + return; + } + + // Add to history and cleanup if needed + if (shell.notificationHistory) { + shell.addToNotificationHistory(notification, maxHistorySize); + + if (shell.notificationHistory.count > cleanupThreshold) { + cleanupNotifications(); + } + } + + // Show notification window + if (shell.notificationWindow && shell.notificationWindow.screen === Quickshell.primaryScreen) { + shell.notificationWindow.visible = true; + } + } + } + + Component.onDestruction: { + if (shell.notificationHistory) { + shell.notificationHistory.clear(); + } + } +} diff --git a/modules/desktop/quickshell/qml/Services/WeatherService.qml b/modules/desktop/quickshell/qml/Services/WeatherService.qml new file mode 100644 index 0000000..866f96d --- /dev/null +++ b/modules/desktop/quickshell/qml/Services/WeatherService.qml @@ -0,0 +1,267 @@ +import QtQuick +import "root:/Data" as Data + +// Weather service using Open-Meteo API +Item { + id: service + + property var shell + + property string city: Data.Settings.weatherLocation + property bool isAmerican: Data.Settings.useFahrenheit + property int updateInterval: 3600 // 1 hour to reduce API calls + property string weatherDescription: "" + property var weather: null + + property Timer retryTimer: Timer { + interval: 30000 + repeat: false + running: false + onTriggered: getGeocoding() + } + + Timer { + interval: service.updateInterval * 1000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: getGeocoding() + } + + // Watch for settings changes and refresh weather data + Connections { + target: Data.Settings + function onWeatherLocationChanged() { + console.log("Weather location changed to:", Data.Settings.weatherLocation); + retryTimer.stop(); + getGeocoding(); + } + + function onUseFahrenheitChanged() { + console.log("Temperature unit changed to:", Data.Settings.useFahrenheit ? "Fahrenheit" : "Celsius"); + retryTimer.stop(); + getGeocoding(); + } + } + + // WMO weather code descriptions (Open-Meteo standard) + property var weatherConsts: { + "omapiCodeDesc": { + 0: "Clear sky", + 1: "Mainly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Fog", + 48: "Depositing rime fog", + 51: "Light drizzle", + 53: "Moderate drizzle", + 55: "Dense drizzle", + 56: "Light freezing drizzle", + 57: "Dense freezing drizzle", + 61: "Slight rain", + 63: "Moderate rain", + 65: "Heavy rain", + 66: "Light freezing rain", + 67: "Heavy freezing rain", + 71: "Slight snow fall", + 73: "Moderate snow fall", + 75: "Heavy snow fall", + 77: "Snow grains", + 80: "Slight rain showers", + 81: "Moderate rain showers", + 82: "Violent rain showers", + 85: "Slight snow showers", + 86: "Heavy snow showers", + 95: "Thunderstorm", + 96: "Thunderstorm with slight hail", + 99: "Thunderstorm with heavy hail" + } + } + + function getTemp(temp, tempUnit) { + return temp + tempUnit; + } + + function updateWeather() { + if (!weather || !weather.current || !weather.current_units) { + console.warn("Weather data incomplete, skipping update"); + return; + } + + const weatherCode = weather.current.weather_code; + const temp = getTemp(Math.round(weather.current.temperature_2m || 0), weather.current_units.temperature_2m || "°C"); + + // Build 3-day forecast + const forecast = []; + const today = new Date(); + + if (weather.daily && weather.daily.time && weather.daily.weather_code && weather.daily.temperature_2m_min && weather.daily.temperature_2m_max) { + for (let i = 0; i < Math.min(3, weather.daily.time.length); i++) { + let dayName; + if (i === 0) { + dayName = "Today"; + } else if (i === 1) { + dayName = "Tomorrow"; + } else { + const futureDate = new Date(today); + futureDate.setDate(today.getDate() + i); + dayName = Qt.formatDate(futureDate, "ddd MMM d"); + } + + const dailyWeatherCode = weather.daily.weather_code[i]; + const condition = weatherConsts.omapiCodeDesc[dailyWeatherCode] || "Unknown"; + + forecast.push({ + dayName: dayName, + condition: condition, + minTemp: Math.round(weather.daily.temperature_2m_min[i]), + maxTemp: Math.round(weather.daily.temperature_2m_max[i]) + }); + } + } + + // Update shell weather data in expected format + shell.weatherData = { + location: city, + currentTemp: temp, + currentCondition: weatherConsts.omapiCodeDesc[weatherCode] || "Unknown", + details: ["Wind: " + Math.round(weather.current.wind_speed_10m || 0) + " km/h"], + forecast: forecast + }; + + weatherDescription = weatherConsts.omapiCodeDesc[weatherCode] || "Unknown"; + shell.weatherLoading = false; + } + + // XHR pool to prevent memory leaks + property var activeXHRs: [] + + function cleanupXHR(xhr) { + if (xhr) { + xhr.abort(); + xhr.onreadystatechange = null; + xhr.onerror = null; + + const index = activeXHRs.indexOf(xhr); + if (index > -1) { + activeXHRs.splice(index, 1); + } + } + } + + function getGeocoding() { + if (!city || city.trim() === "") { + console.warn("Weather location is empty, skipping weather request"); + shell.weatherLoading = false; + return; + } + + shell.weatherLoading = true; + const xhr = new XMLHttpRequest(); + activeXHRs.push(xhr); + + xhr.open("GET", `https://geocoding-api.open-meteo.com/v1/search?name=${city}&count=1&language=en&format=json`); + xhr.onreadystatechange = function () { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) { + try { + const geocoding = JSON.parse(xhr.responseText); + if (geocoding.results && geocoding.results.length > 0) { + const lat = geocoding.results[0].latitude; + const lng = geocoding.results[0].longitude; + getWeather(lat, lng); + } else { + console.warn("No geocoding results found for location:", city); + retryTimer.running = true; + shell.weatherLoading = false; + } + } catch (e) { + console.error("Failed to parse geocoding response:", e); + retryTimer.running = true; + shell.weatherLoading = false; + } + } else if (xhr.status === 0) { + // Silent handling of network issues + if (!retryTimer.running) { + console.warn("Weather service: Network unavailable, will retry automatically"); + } + retryTimer.running = true; + shell.weatherLoading = false; + } else { + console.error("Geocoding request failed with status:", xhr.status); + retryTimer.running = true; + shell.weatherLoading = false; + } + cleanupXHR(xhr); + } + }; + xhr.onerror = function () { + console.error("Geocoding request failed with network error"); + retryTimer.running = true; + shell.weatherLoading = false; + cleanupXHR(xhr); + }; + xhr.send(); + } + + function getWeather(lat, lng) { + const xhr = new XMLHttpRequest(); + activeXHRs.push(xhr); + + xhr.open("GET", `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,is_day,weather_code,wind_speed_10m&daily=temperature_2m_max,temperature_2m_min,weather_code&forecast_days=3&temperature_unit=` + (isAmerican ? "fahrenheit" : "celsius")); + xhr.onreadystatechange = function () { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) { + try { + weather = JSON.parse(xhr.responseText); + updateWeather(); + } catch (e) { + console.error("Failed to parse weather response:", e); + retryTimer.running = true; + shell.weatherLoading = false; + } + } else if (xhr.status === 0) { + // Silent handling of network issues + if (!retryTimer.running) { + console.warn("Weather service: Network unavailable for weather data"); + } + retryTimer.running = true; + shell.weatherLoading = false; + } else { + console.error("Weather request failed with status:", xhr.status); + retryTimer.running = true; + shell.weatherLoading = false; + } + cleanupXHR(xhr); + } + }; + xhr.onerror = function () { + console.error("Weather request failed with network error"); + retryTimer.running = true; + shell.weatherLoading = false; + cleanupXHR(xhr); + }; + xhr.send(); + } + + function loadWeather() { + getGeocoding(); + } + + Component.onCompleted: getGeocoding() + + Component.onDestruction: { + // Cleanup all active XHR requests + for (let i = 0; i < activeXHRs.length; i++) { + if (activeXHRs[i]) { + activeXHRs[i].abort(); + activeXHRs[i].onreadystatechange = null; + activeXHRs[i].onerror = null; + } + } + activeXHRs = []; + weather = null; + shell.weatherData = null; + weatherDescription = ""; + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Calendar/Calendar.qml b/modules/desktop/quickshell/qml/Widgets/Calendar/Calendar.qml new file mode 100644 index 0000000..9e427a0 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Calendar/Calendar.qml @@ -0,0 +1,119 @@ +// Calendar.qml +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import "root:/Data" as Data + +// Calendar widget with navigation +Rectangle { + id: calendarRoot + property var shell + + radius: 20 + color: Qt.lighter(Data.ThemeManager.bgColor, 1.2) + + readonly property date currentDate: new Date() + property int month: currentDate.getMonth() + property int year: currentDate.getFullYear() + readonly property int currentDay: currentDate.getDate() + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 12 + + // Month/Year header + RowLayout { + Layout.fillWidth: true + spacing: 8 + + // Current month and year display + Text { + text: Qt.locale("en_US").monthName(calendarRoot.month) + " " + calendarRoot.year + color: Data.ThemeManager.accentColor + font.bold: true + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.pixelSize: 18 + } + } + + // Weekday headers (Monday-Sunday) + Grid { + columns: 7 + rowSpacing: 4 + columnSpacing: 0 + Layout.leftMargin: 2 + Layout.fillWidth: true + + Repeater { + model: ["M", "T", "W", "T", "F", "S", "S"] + delegate: Text { + text: modelData + color: Data.ThemeManager.fgColor + font.bold: true + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + width: parent.width / 7 + font.pixelSize: 14 + } + } + } + + // Calendar grid + MonthGrid { + id: monthGrid + month: calendarRoot.month + year: calendarRoot.year + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 4 + leftPadding: 0 + rightPadding: 0 + locale: Qt.locale("en_US") + implicitHeight: 400 + + delegate: Rectangle { + width: 30 + height: 30 + radius: 15 + + readonly property bool isCurrentMonth: model.month === calendarRoot.month + readonly property bool isToday: model.day === calendarRoot.currentDay && model.month === calendarRoot.currentDate.getMonth() && calendarRoot.year === calendarRoot.currentDate.getFullYear() && isCurrentMonth + + // Dynamic styling: today = accent color, current month = normal, other months = dimmed + color: isToday ? Data.ThemeManager.accentColor : isCurrentMonth ? Data.ThemeManager.bgColor : Qt.darker(Data.ThemeManager.bgColor, 1.4) + + Text { + text: model.day + anchors.centerIn: parent + color: isToday ? Data.ThemeManager.bgColor : isCurrentMonth ? Data.ThemeManager.fgColor : Qt.darker(Data.ThemeManager.fgColor, 1.5) + font.bold: isToday + font.pixelSize: 14 + font.family: "monospace" + } + } + } + } + + // Reusable navigation button + component NavButton: AbstractButton { + property alias buttonText: buttonLabel.text + implicitWidth: 30 + implicitHeight: 30 + + background: Rectangle { + radius: 15 + color: parent.down ? Qt.darker(Data.ThemeManager.accentColor, 1.2) : parent.hovered ? Qt.lighter(Data.ThemeManager.highlightBg, 1.1) : Data.ThemeManager.highlightBg + } + + Text { + id: buttonLabel + anchors.centerIn: parent + color: Data.ThemeManager.fgColor + font.pixelSize: 16 + font.bold: true + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Calendar/CalendarPopup.qml b/modules/desktop/quickshell/qml/Widgets/Calendar/CalendarPopup.qml new file mode 100644 index 0000000..b822675 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Calendar/CalendarPopup.qml @@ -0,0 +1,127 @@ +import QtQuick +import QtQuick.Controls +import "root:/Data" as Data + +// Calendar popup with animations +Popup { + id: calendarPopup + property bool hovered: false + property bool clickMode: false // Persistent mode - stays open until clicked again + property var shell + property int targetX: 0 + readonly property int targetY: Screen.height - height + + width: 280 + height: 280 + modal: false + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + padding: 15 + + // Animation state properties + property bool _visible: false + property real animX: targetX - 20 + property real animOpacity: 0 + + x: animX + y: targetY + opacity: animOpacity + visible: _visible + + // Smooth slide-in animation + Behavior on animX { + NumberAnimation { + duration: 200 + easing.type: Easing.InOutQuad + } + } + Behavior on animOpacity { + NumberAnimation { + duration: 200 + easing.type: Easing.InOutQuad + } + } + + // Hover mode: show/hide based on mouse state + onHoveredChanged: { + if (!clickMode) { + if (hovered) { + _visible = true; + animX = targetX; + animOpacity = 1; + } else { + animX = targetX - 20; + animOpacity = 0; + } + } + } + + // Click mode: persistent visibility toggle + onClickModeChanged: { + if (clickMode) { + _visible = true; + animX = targetX; + animOpacity = 1; + } else { + animX = targetX - 20; + animOpacity = 0; + } + } + + // Hide when animation completes + onAnimOpacityChanged: { + if (animOpacity === 0 && !hovered && !clickMode) { + _visible = false; + } + } + + function setHovered(state) { + hovered = state; + } + + function setClickMode(state) { + clickMode = state; + } + + // Hover detection + MouseArea { + id: hoverArea + anchors.fill: parent + hoverEnabled: true + propagateComposedEvents: true + anchors.margins: 10 // Larger area to reduce flicker + + onEntered: { + if (!clickMode) { + setHovered(true); + } + } + onExited: { + if (!clickMode) { + // Delayed exit check to prevent hover flicker + Qt.callLater(() => { + if (!hoverArea.containsMouse) { + setHovered(false); + } + }); + } + } + } + + // Lazy-loaded calendar content + Loader { + anchors.fill: parent + active: calendarPopup._visible + source: active ? "Calendar.qml" : "" + onLoaded: { + if (item) { + item.shell = calendarPopup.shell; + } + } + } + + background: Rectangle { + color: Data.ThemeManager.bgColor + topRightRadius: 20 + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Clock.qml b/modules/desktop/quickshell/qml/Widgets/Clock.qml new file mode 100644 index 0000000..f63ad78 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Clock.qml @@ -0,0 +1,64 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import "root:/Data" as Data +import "root:/Core" as Core + +// Clock with border integration +Item { + id: clockRoot + width: clockBackground.width + height: clockBackground.height + + Rectangle { + id: clockBackground + width: clockText.implicitWidth + 24 + height: 32 + + color: Data.ThemeManager.bgColor + + // Rounded corner for border integration + topRightRadius: height / 2 + + Text { + id: clockText + anchors.centerIn: parent + font.family: "monospace" + font.pixelSize: 14 + font.bold: true + color: Data.ThemeManager.accentColor + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: Qt.formatTime(new Date(), "HH:mm") + } + } + + // Update every minute + Timer { + interval: 60000 + running: true + repeat: true + onTriggered: clockText.text = Qt.formatTime(new Date(), "HH:mm") + } + + // Border integration corner pieces + Core.Corners { + id: topLeftCorner + position: "topleft" + size: 1.3 + fillColor: Data.ThemeManager.bgColor + offsetX: -39 + offsetY: -26 + z: 0 // Same z-level as clock background + } + + Core.Corners { + id: topLeftCorner2 + position: "topleft" + size: 1.3 + fillColor: Data.ThemeManager.bgColor + offsetX: 20 + offsetY: 6 + z: 0 // Same z-level as clock background + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/ControlPanel.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/ControlPanel.qml new file mode 100644 index 0000000..67b87de --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/ControlPanel.qml @@ -0,0 +1,140 @@ +import QtQuick +import "root:/Data" as Data +import "root:/Core" as Core + +// Main control panel coordinator - handles recording and system actions +Item { + id: controlPanelContainer + + required property var shell + property bool isRecording: false + property int currentTab: 0 // 0=main, 1=calendar, 2=clipboard, 3=notifications, 4=music, 5=settings + property var tabIcons: ["widgets", "calendar_month", "content_paste", "notifications", "music_note", "settings"] + + property bool isShown: false + property var recordingProcess: null + + signal recordingRequested + signal stopRecordingRequested + signal systemActionRequested(string action) + signal performanceActionRequested(string action) + + // Screen recording + onRecordingRequested: { + var currentDate = new Date(); + var hours = String(currentDate.getHours()).padStart(2, '0'); + var minutes = String(currentDate.getMinutes()).padStart(2, '0'); + var day = String(currentDate.getDate()).padStart(2, '0'); + var month = String(currentDate.getMonth() + 1).padStart(2, '0'); + var year = currentDate.getFullYear(); + + var filename = hours + "-" + minutes + "-" + day + "-" + month + "-" + year + ".mp4"; + var outputPath = Data.Settings.videoPath + filename; + var command = "gpu-screen-recorder -w portal -f 60 -a default_output -o " + outputPath; + + var qmlString = 'import Quickshell.Io; Process { command: ["sh", "-c", "' + command + '"]; running: true }'; + + try { + recordingProcess = Qt.createQmlObject(qmlString, controlPanelContainer); + isRecording = true; + } catch (e) { + console.error("Failed to start recording:", e); + } + } + + // Stop recording with cleanup + onStopRecordingRequested: { + if (recordingProcess && isRecording) { + var stopQmlString = 'import Quickshell.Io; Process { command: ["sh", "-c", "pkill -SIGINT -f \'gpu-screen-recorder.*portal\'"]; running: true; onExited: function() { destroy() } }'; + + try { + var stopProcess = Qt.createQmlObject(stopQmlString, controlPanelContainer); + + var cleanupTimer = Qt.createQmlObject('import QtQuick; Timer { interval: 3000; running: true; repeat: false }', controlPanelContainer); + cleanupTimer.triggered.connect(function () { + if (recordingProcess) { + recordingProcess.running = false; + recordingProcess.destroy(); + recordingProcess = null; + } + + var forceKillQml = 'import Quickshell.Io; Process { command: ["sh", "-c", "pkill -9 -f \'gpu-screen-recorder.*portal\' 2>/dev/null || true"]; running: true; onExited: function() { destroy() } }'; + var forceKillProcess = Qt.createQmlObject(forceKillQml, controlPanelContainer); + + cleanupTimer.destroy(); + }); + } catch (e) { + console.error("Failed to stop recording:", e); + } + } + isRecording = false; + } + + // System action routing + onSystemActionRequested: function (action) { + switch (action) { + case "lock": + Core.ProcessManager.lock(); + break; + case "reboot": + Core.ProcessManager.reboot(); + break; + case "shutdown": + Core.ProcessManager.shutdown(); + break; + } + } + + onPerformanceActionRequested: function (action) { + console.log("Performance action requested:", action); + } + + // Control panel window component + ControlPanelWindow { + id: controlPanelWindow + + // Pass through properties + shell: controlPanelContainer.shell + isRecording: controlPanelContainer.isRecording + currentTab: controlPanelContainer.currentTab + tabIcons: controlPanelContainer.tabIcons + isShown: controlPanelContainer.isShown + + // Bind state changes back to parent + onCurrentTabChanged: controlPanelContainer.currentTab = currentTab + onIsShownChanged: controlPanelContainer.isShown = isShown + + // Forward signals + onRecordingRequested: controlPanelContainer.recordingRequested() + onStopRecordingRequested: controlPanelContainer.stopRecordingRequested() + onSystemActionRequested: function (action) { + controlPanelContainer.systemActionRequested(action); + } + onPerformanceActionRequested: function (action) { + controlPanelContainer.performanceActionRequested(action); + } + } + + // Clean up processes on destruction + Component.onDestruction: { + if (recordingProcess) { + try { + if (recordingProcess.running) { + recordingProcess.terminate(); + } + recordingProcess.destroy(); + } catch (e) { + console.warn("Error cleaning up recording process:", e); + } + recordingProcess = null; + } + + // Force kill any remaining gpu-screen-recorder processes + var forceCleanupCmd = 'import Quickshell.Io; Process { command: ["sh", "-c", "pkill -9 -f gpu-screen-recorder 2>/dev/null || true"]; running: true; onExited: function() { destroy() } }'; + try { + Qt.createQmlObject(forceCleanupCmd, controlPanelContainer); + } catch (e) { + console.warn("Error in force cleanup:", e); + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/ControlPanelContent.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/ControlPanelContent.qml new file mode 100644 index 0000000..3d4ed38 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/ControlPanelContent.qml @@ -0,0 +1,110 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import "root:/Data" as Data +import "./components/navigation" as Navigation + +// Panel content with tab layout - now clean and organized! +Item { + id: contentRoot + + // Properties passed from parent + required property var shell + required property bool isRecording + property int currentTab: 0 + property var tabIcons: [] + required property var triggerMouseArea + + // Signals to forward to parent + signal recordingRequested + signal stopRecordingRequested + signal systemActionRequested(string action) + signal performanceActionRequested(string action) + + // Hover detection for auto-hide + property bool isHovered: { + const mouseStates = { + triggerHovered: triggerMouseArea.containsMouse, + backgroundHovered: backgroundMouseArea.containsMouse, + tabSidebarHovered: tabNavigation.containsMouse, + tabContainerHovered: tabContainer.isHovered, + tabContentActive: currentTab !== 0 // Non-main tabs stay open + , + tabNavigationActive: tabNavigation.containsMouse + }; + return Object.values(mouseStates).some(state => state); + } + + // Expose text input focus state for keyboard management + property bool textInputFocused: tabContainer.textInputFocused + + // Panel background with bottom-only rounded corners + Rectangle { + anchors.fill: parent + color: Data.ThemeManager.bgColor + topLeftRadius: 0 + topRightRadius: 0 + bottomLeftRadius: 20 + bottomRightRadius: 20 + z: -10 // Far behind everything to avoid layering conflicts + } + + // Main content container with tab layout + Rectangle { + id: mainContainer + anchors.fill: parent + anchors.margins: 9 + color: "transparent" + radius: 12 + + MouseArea { + id: backgroundMouseArea + anchors.fill: parent + hoverEnabled: true + propagateComposedEvents: true + property alias containsMouse: backgroundMouseArea.containsMouse + } + + // Left sidebar with tab navigation + Navigation.TabNavigation { + id: tabNavigation + width: 40 + height: parent.height + anchors.left: parent.left + anchors.leftMargin: 9 + anchors.top: parent.top + anchors.topMargin: 18 + + currentTab: contentRoot.currentTab + tabIcons: contentRoot.tabIcons + + onCurrentTabChanged: contentRoot.currentTab = currentTab + } + + // Main tab content area with sliding animation + Navigation.TabContainer { + id: tabContainer + width: parent.width - tabNavigation.width - 45 + height: parent.height - 36 + anchors.left: tabNavigation.right + anchors.leftMargin: 9 + anchors.top: parent.top + anchors.topMargin: 18 + + shell: contentRoot.shell + isRecording: contentRoot.isRecording + triggerMouseArea: contentRoot.triggerMouseArea + currentTab: contentRoot.currentTab + + onRecordingRequested: contentRoot.recordingRequested() + onStopRecordingRequested: contentRoot.stopRecordingRequested() + onSystemActionRequested: function (action) { + contentRoot.systemActionRequested(action); + } + onPerformanceActionRequested: function (action) { + contentRoot.performanceActionRequested(action); + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/ControlPanelWindow.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/ControlPanelWindow.qml new file mode 100644 index 0000000..f10daba --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/ControlPanelWindow.qml @@ -0,0 +1,185 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Wayland +import "root:/Data" as Data +import "root:/Core" as Core + +// Control panel window and trigger +PanelWindow { + id: controlPanelWindow + + // Properties passed from parent ControlPanel + required property var shell + required property bool isRecording + property int currentTab: 0 + property var tabIcons: [] + property bool isShown: false + + // Signals to forward to parent + signal recordingRequested + signal stopRecordingRequested + signal systemActionRequested(string action) + signal performanceActionRequested(string action) + + screen: Quickshell.primaryScreen || Quickshell.screens[0] + anchors.top: true + anchors.left: true + anchors.right: true + margins.bottom: 0 + margins.left: (screen ? screen.width / 2 - 400 : 0) // Centered + margins.right: (screen ? screen.width / 2 - 400 : 0) + implicitWidth: 640 + implicitHeight: isShown ? 400 : 8 // Expand/collapse animation + + Behavior on implicitHeight { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + exclusiveZone: (panelContent && panelContent.textInputFocused) ? -1 : 0 + color: "transparent" + visible: true + + WlrLayershell.namespace: "quickshell-controlpanel" + WlrLayershell.keyboardFocus: (panelContent && panelContent.textInputFocused) ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.OnDemand + + // Hover trigger area at screen top + MouseArea { + id: triggerMouseArea + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + width: 600 + height: 8 + hoverEnabled: true + onContainsMouseChanged: { + if (containsMouse) { + show(); + } + } + } + + // Main panel content + ControlPanelContent { + id: panelContent + + width: 600 + height: 380 + + anchors.top: parent.top + anchors.topMargin: 8 // Trigger area space + anchors.horizontalCenter: parent.horizontalCenter + visible: isShown + opacity: isShown ? 1.0 : 0.0 + + Behavior on opacity { + NumberAnimation { + duration: 150 + easing.type: Easing.OutCubic + } + } + + // Pass through properties + shell: controlPanelWindow.shell + isRecording: controlPanelWindow.isRecording + currentTab: controlPanelWindow.currentTab + tabIcons: controlPanelWindow.tabIcons + triggerMouseArea: triggerMouseArea + + // Bind state changes + onCurrentTabChanged: controlPanelWindow.currentTab = currentTab + + // Forward signals + onRecordingRequested: controlPanelWindow.recordingRequested() + onStopRecordingRequested: controlPanelWindow.stopRecordingRequested() + onSystemActionRequested: function (action) { + controlPanelWindow.systemActionRequested(action); + } + onPerformanceActionRequested: function (action) { + controlPanelWindow.performanceActionRequested(action); + } + + // Hover state management + onIsHoveredChanged: { + if (isHovered) { + hideTimer.stop(); + } else { + hideTimer.restart(); + } + } + } + + // Border integration corners (positioned to match panel edges) + Core.Corners { + id: controlPanelLeftCorner + position: "bottomright" + size: 1.3 + fillColor: Data.ThemeManager.bgColor + offsetX: -661 + offsetY: -313 + visible: isShown + z: 1 // Higher z-index to render above shadow effects + + // Disable implicit animations to prevent corner sliding + Behavior on x { + enabled: false + } + Behavior on y { + enabled: false + } + } + + Core.Corners { + id: controlPanelRightCorner + position: "bottomleft" + size: 1.3 + fillColor: Data.ThemeManager.bgColor + offsetX: 661 + offsetY: -313 + visible: isShown + z: 1 // Higher z-index to render above shadow effects + + Behavior on x { + enabled: false + } + Behavior on y { + enabled: false + } + } + + // Auto-hide timer + Timer { + id: hideTimer + interval: 400 + repeat: false + onTriggered: hide() + } + + function show() { + if (isShown) + return; + isShown = true; + hideTimer.stop(); + } + + function hide() { + if (!isShown) + return; + // Only hide if on main tab and nothing is being hovered + if (currentTab === 0 && !panelContent.isHovered && !triggerMouseArea.containsMouse) { + isShown = false; + } else + // For non-main tabs, only hide if explicitly not hovered and no trigger hover + if (currentTab !== 0 && !panelContent.isHovered && !triggerMouseArea.containsMouse) { + // Add delay for non-main tabs to prevent accidental hiding + Qt.callLater(function () { + if (!panelContent.isHovered && !triggerMouseArea.containsMouse) { + isShown = false; + } + }); + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/controls/Controls.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/controls/Controls.qml new file mode 100644 index 0000000..fac5954 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/controls/Controls.qml @@ -0,0 +1,111 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "root:/Data" as Data +import "." as Controls + +// Dual-section control panel +Row { + id: root + spacing: 16 + visible: true + height: 80 + + required property bool isRecording + required property var shell + signal performanceActionRequested(string action) + signal systemActionRequested(string action) + signal mouseChanged(bool containsMouse) + + // Combined hover state from both sections + property bool containsMouse: performanceSection.containsMouse || systemSection.containsMouse + onContainsMouseChanged: mouseChanged(containsMouse) + + // Performance controls section (left half) + Rectangle { + id: performanceSection + width: (parent.width - parent.spacing) / 2 + height: parent.height + radius: 20 + color: Qt.darker(Data.ThemeManager.bgColor, 1.15) + visible: true + + // Hover tracking with coordination between background and content + property bool containsMouse: performanceMouseArea.containsMouse || performanceControls.containsMouse + + MouseArea { + id: performanceMouseArea + anchors.fill: parent + hoverEnabled: true + propagateComposedEvents: true + onContainsMouseChanged: { + if (containsMouse) { + performanceSection.containsMouse = true; + } else if (!performanceControls.containsMouse) { + performanceSection.containsMouse = false; + } + } + } + + Controls.PerformanceControls { + id: performanceControls + anchors.fill: parent + anchors.margins: 12 + shell: root.shell + onPerformanceActionRequested: function (action) { + root.performanceActionRequested(action); + } + onMouseChanged: function (containsMouse) { + if (containsMouse) { + performanceSection.containsMouse = true; + } else if (!performanceMouseArea.containsMouse) { + performanceSection.containsMouse = false; + } + } + } + } + + // System controls section (right half) + Rectangle { + id: systemSection + width: (parent.width - parent.spacing) / 2 + height: parent.height + radius: 20 + color: Qt.darker(Data.ThemeManager.bgColor, 1.15) + visible: true + + // Hover tracking with coordination between background and content + property bool containsMouse: systemMouseArea.containsMouse || systemControls.containsMouse + + MouseArea { + id: systemMouseArea + anchors.fill: parent + hoverEnabled: true + propagateComposedEvents: true + onContainsMouseChanged: { + if (containsMouse) { + systemSection.containsMouse = true; + } else if (!systemControls.containsMouse) { + systemSection.containsMouse = false; + } + } + } + + Controls.SystemControls { + id: systemControls + anchors.fill: parent + anchors.margins: 12 + shell: root.shell + onSystemActionRequested: function (action) { + root.systemActionRequested(action); + } + onMouseChanged: function (containsMouse) { + if (containsMouse) { + systemSection.containsMouse = true; + } else if (!systemMouseArea.containsMouse) { + systemSection.containsMouse = false; + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/controls/PerformanceControls.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/controls/PerformanceControls.qml new file mode 100644 index 0000000..a4d985e --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/controls/PerformanceControls.qml @@ -0,0 +1,122 @@ +import QtQuick +import QtQuick.Controls +import Quickshell.Services.UPower + +// Power profile controls +Column { + id: root + required property var shell + + spacing: 8 + signal performanceActionRequested(string action) + signal mouseChanged(bool containsMouse) + + readonly property bool containsMouse: performanceButton.containsMouse || balancedButton.containsMouse || powerSaverButton.containsMouse + + // Safe UPower service access with fallback checks + readonly property bool upowerReady: typeof PowerProfiles !== 'undefined' && PowerProfiles + readonly property int currentProfile: upowerReady ? PowerProfiles.profile : 0 + + onContainsMouseChanged: root.mouseChanged(containsMouse) + + opacity: visible ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + } + } + + Row { + spacing: 8 + width: parent.width + + // Performance mode button + SystemButton { + id: performanceButton + width: (parent.width - parent.spacing * 2) / 3 + height: 52 + + shell: root.shell + iconText: "speed" + + isActive: root.upowerReady && (typeof PowerProfile !== 'undefined') ? root.currentProfile === PowerProfile.Performance : false + + onClicked: { + if (root.upowerReady && typeof PowerProfile !== 'undefined') { + PowerProfiles.profile = PowerProfile.Performance; + root.performanceActionRequested("performance"); + } else { + console.warn("PowerProfiles not available"); + } + } + onMouseChanged: function (containsMouse) { + if (!containsMouse && !root.containsMouse) { + root.mouseChanged(false); + } + } + } + + // Balanced mode button + SystemButton { + id: balancedButton + width: (parent.width - parent.spacing * 2) / 3 + height: 52 + + shell: root.shell + iconText: "balance" + + isActive: root.upowerReady && (typeof PowerProfile !== 'undefined') ? root.currentProfile === PowerProfile.Balanced : false + + onClicked: { + if (root.upowerReady && typeof PowerProfile !== 'undefined') { + PowerProfiles.profile = PowerProfile.Balanced; + root.performanceActionRequested("balanced"); + } else { + console.warn("PowerProfiles not available"); + } + } + onMouseChanged: function (containsMouse) { + if (!containsMouse && !root.containsMouse) { + root.mouseChanged(false); + } + } + } + + // Power saver mode button + SystemButton { + id: powerSaverButton + width: (parent.width - parent.spacing * 2) / 3 + height: 52 + + shell: root.shell + iconText: "battery_saver" + + isActive: root.upowerReady && (typeof PowerProfile !== 'undefined') ? root.currentProfile === PowerProfile.PowerSaver : false + + onClicked: { + if (root.upowerReady && typeof PowerProfile !== 'undefined') { + PowerProfiles.profile = PowerProfile.PowerSaver; + root.performanceActionRequested("powersaver"); + } else { + console.warn("PowerProfiles not available"); + } + } + onMouseChanged: function (containsMouse) { + if (!containsMouse && !root.containsMouse) { + root.mouseChanged(false); + } + } + } + } + + // Ensure UPower service initialization + Component.onCompleted: { + Qt.callLater(function () { + if (!root.upowerReady) { + console.warn("UPower service not ready - performance controls may not work correctly"); + } + }); + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/controls/SystemButton.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/controls/SystemButton.qml new file mode 100644 index 0000000..ed62051 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/controls/SystemButton.qml @@ -0,0 +1,118 @@ +import QtQuick +import QtQuick.Controls +import "root:/Data" as Data + +// System button +Rectangle { + id: root + required property var shell + required property string iconText + property string labelText: "" + + property bool isActive: false + + radius: 20 + + // Dynamic color based on active and hover states + color: { + if (isActive) { + return mouseArea.containsMouse ? Qt.lighter(Data.ThemeManager.accentColor, 1.1) : Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.3); + } else { + return mouseArea.containsMouse ? Qt.lighter(Data.ThemeManager.accentColor, 1.2) : Qt.lighter(Data.ThemeManager.bgColor, 1.15); + } + } + + border.width: isActive ? 2 : 1 + border.color: isActive ? Data.ThemeManager.accentColor : Qt.lighter(Data.ThemeManager.bgColor, 1.3) + + signal clicked + signal mouseChanged(bool containsMouse) + property bool isHovered: mouseArea.containsMouse + readonly property alias containsMouse: mouseArea.containsMouse + + // Smooth color transitions + Behavior on color { + ColorAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + + Behavior on border.color { + ColorAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + + // Hover scale animation + scale: isHovered ? 1.05 : 1.0 + Behavior on scale { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + + // Button content with icon and optional label + Column { + anchors.centerIn: parent + spacing: 2 + + // System action icon + Text { + text: root.iconText + font.family: "Material Symbols Outlined" + font.pixelSize: 16 + anchors.horizontalCenter: parent.horizontalCenter + color: { + if (root.isActive) { + return root.isHovered ? "#ffffff" : Data.ThemeManager.accentColor; + } else { + return root.isHovered ? "#ffffff" : Data.ThemeManager.accentColor; + } + } + + Behavior on color { + ColorAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + } + + // Optional text label + Label { + text: root.labelText + font.family: "monospace" + font.pixelSize: 8 + color: { + if (root.isActive) { + return root.isHovered ? "#ffffff" : Data.ThemeManager.accentColor; + } else { + return root.isHovered ? "#ffffff" : Data.ThemeManager.accentColor; + } + } + anchors.horizontalCenter: parent.horizontalCenter + font.weight: root.isActive ? Font.Bold : Font.Medium + visible: root.labelText !== "" + + Behavior on color { + ColorAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + } + } + + // Click and hover handling + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + + onContainsMouseChanged: root.mouseChanged(containsMouse) + onClicked: root.clicked() + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/controls/SystemControls.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/controls/SystemControls.qml new file mode 100644 index 0000000..3932d19 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/controls/SystemControls.qml @@ -0,0 +1,60 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +// System action buttons +RowLayout { + id: root + required property var shell + + spacing: 8 + signal systemActionRequested(string action) + signal mouseChanged(bool containsMouse) + + readonly property bool containsMouse: lockButton.containsMouse || rebootButton.containsMouse || shutdownButton.containsMouse + + onContainsMouseChanged: root.mouseChanged(containsMouse) + + opacity: visible ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + } + } + + // Reboot Button + SystemButton { + id: rebootButton + Layout.fillHeight: true + Layout.fillWidth: true + + shell: root.shell + iconText: "restart_alt" + + onClicked: root.systemActionRequested("reboot") + onMouseChanged: function (containsMouse) { + if (!containsMouse && !root.containsMouse) { + root.mouseChanged(false); + } + } + } + + // Shutdown Button + SystemButton { + id: shutdownButton + Layout.fillHeight: true + Layout.fillWidth: true + + shell: root.shell + iconText: "power_settings_new" + + onClicked: root.systemActionRequested("shutdown") + onMouseChanged: function (containsMouse) { + if (!containsMouse && !root.containsMouse) { + root.mouseChanged(false); + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/media/MusicPlayer.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/media/MusicPlayer.qml new file mode 100644 index 0000000..a209bcf --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/media/MusicPlayer.qml @@ -0,0 +1,666 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell.Services.Mpris +import "root:/Data" as Data + +// Music player with MPRIS integration +Rectangle { + id: musicPlayer + + property var shell + property var currentPlayer: null + property real currentPosition: 0 + property int selectedPlayerIndex: 0 + + color: "transparent" + + // Get all available players + function getAvailablePlayers() { + if (!Mpris.players || !Mpris.players.values) { + return []; + } + + let allPlayers = Mpris.players.values; + let controllablePlayers = []; + + for (let i = 0; i < allPlayers.length; i++) { + let player = allPlayers[i]; + if (player && player.canControl) { + controllablePlayers.push(player); + } + } + + return controllablePlayers; + } + + // Find the active player (either selected or first available) + function findActivePlayer() { + let availablePlayers = getAvailablePlayers(); + if (availablePlayers.length === 0) { + return null; + } + + // Auto-switch to playing player if enabled + if (Data.Settings.autoSwitchPlayer) { + for (let i = 0; i < availablePlayers.length; i++) { + if (availablePlayers[i].isPlaying) { + selectedPlayerIndex = i; + return availablePlayers[i]; + } + } + } + + // Use selected player if valid, otherwise use first available + if (selectedPlayerIndex < availablePlayers.length) { + return availablePlayers[selectedPlayerIndex]; + } else { + selectedPlayerIndex = 0; + return availablePlayers[0]; + } + } + + // Update current player + function updateCurrentPlayer() { + let newPlayer = findActivePlayer(); + if (newPlayer !== currentPlayer) { + currentPlayer = newPlayer; + currentPosition = currentPlayer ? currentPlayer.position : 0; + } + } + + // Timer to update progress bar position + Timer { + id: positionTimer + interval: 1000 + running: currentPlayer && currentPlayer.isPlaying + repeat: true + onTriggered: { + if (currentPlayer) { + currentPosition = currentPlayer.position; + } + } + } + + // Timer to check for auto-switching to playing players + Timer { + id: autoSwitchTimer + interval: 2000 // Check every 2 seconds + running: Data.Settings.autoSwitchPlayer + repeat: true + onTriggered: { + if (Data.Settings.autoSwitchPlayer) { + let availablePlayers = getAvailablePlayers(); + for (let i = 0; i < availablePlayers.length; i++) { + if (availablePlayers[i].isPlaying && selectedPlayerIndex !== i) { + selectedPlayerIndex = i; + updateCurrentPlayer(); + updatePlayerList(); + break; + } + } + } + } + } + + // Update player list for dropdown + function updatePlayerList() { + if (!playerComboBox) + return; + let availablePlayers = getAvailablePlayers(); + let playerNames = availablePlayers.map(player => player.identity || "Unknown Player"); + + playerComboBox.model = playerNames; + + if (selectedPlayerIndex >= playerNames.length) { + selectedPlayerIndex = 0; + } + + playerComboBox.currentIndex = selectedPlayerIndex; + } + + // Monitor for player changes + Connections { + target: Mpris.players + function onValuesChanged() { + updatePlayerList(); + updateCurrentPlayer(); + } + function onRowsInserted() { + updatePlayerList(); + updateCurrentPlayer(); + } + function onRowsRemoved() { + updatePlayerList(); + updateCurrentPlayer(); + } + function onObjectInsertedPost() { + updatePlayerList(); + updateCurrentPlayer(); + } + function onObjectRemovedPost() { + updatePlayerList(); + updateCurrentPlayer(); + } + } + + // Monitor for settings changes + Connections { + target: Data.Settings + function onAutoSwitchPlayerChanged() { + console.log("Auto-switch player setting changed to:", Data.Settings.autoSwitchPlayer); + updateCurrentPlayer(); + } + function onAlwaysShowPlayerDropdownChanged() { + console.log("Always show dropdown setting changed to:", Data.Settings.alwaysShowPlayerDropdown); + // Dropdown visibility is automatically handled by the binding + } + } + + Component.onCompleted: { + updatePlayerList(); + updateCurrentPlayer(); + } + + Column { + anchors.fill: parent + spacing: 10 + + // No music player available state + Item { + width: parent.width + height: parent.height + visible: !currentPlayer + + Column { + anchors.centerIn: parent + spacing: 16 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "music_note" + font.family: "Material Symbols Outlined" + font.pixelSize: 48 + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: getAvailablePlayers().length > 0 ? "No controllable player selected" : "No music player detected" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.6) + font.family: "monospace" + font.pixelSize: 14 + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: getAvailablePlayers().length > 0 ? "Select a player from the dropdown above" : "Start a music player to see controls" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.4) + font.family: "monospace" + font.pixelSize: 12 + } + } + } + + // Music player controls + Column { + width: parent.width + spacing: 12 + visible: currentPlayer + + // Player info and artwork + Rectangle { + width: parent.width + height: 130 + radius: 20 + color: Qt.darker(Data.ThemeManager.bgColor, 1.1) + border.color: Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) + border.width: 1 + + Row { + anchors.fill: parent + anchors.margins: 16 + spacing: 16 + + // Album artwork + Rectangle { + id: albumArtwork + width: 90 + height: 90 + radius: 20 + color: Qt.darker(Data.ThemeManager.bgColor, 1.3) + border.color: Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.3) + border.width: 1 + + Image { + id: albumArt + anchors.fill: parent + anchors.margins: 2 + fillMode: Image.PreserveAspectCrop + smooth: true + source: currentPlayer ? (currentPlayer.trackArtUrl || "") : "" + visible: source.toString() !== "" + + // Rounded corners using layer + layer.enabled: true + layer.effect: OpacityMask { + cached: true // Cache to reduce ShaderEffect issues + maskSource: Rectangle { + width: albumArt.width + height: albumArt.height + radius: 20 + visible: false + } + } + } + + // Fallback music icon + Text { + anchors.centerIn: parent + text: "album" + font.family: "Material Symbols Outlined" + font.pixelSize: 32 + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.4) + visible: !albumArt.visible + } + } + + // Track info + Column { + width: parent.width - albumArtwork.width - parent.spacing + height: parent.height + spacing: 4 + + Text { + width: parent.width + text: currentPlayer ? (currentPlayer.trackTitle || "Unknown Track") : "" + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 18 + font.bold: true + elide: Text.ElideRight + wrapMode: Text.Wrap + maximumLineCount: 2 + } + + Text { + width: parent.width + text: currentPlayer ? (currentPlayer.trackArtist || "Unknown Artist") : "" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.8) + font.family: "monospace" + font.pixelSize: 18 + elide: Text.ElideRight + } + + Text { + width: parent.width + text: currentPlayer ? (currentPlayer.trackAlbum || "Unknown Album") : "" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.6) + font.family: "monospace" + font.pixelSize: 15 + elide: Text.ElideRight + } + } + } + } + + // Interactive progress bar with seek functionality + Rectangle { + id: progressBarBackground + width: parent.width + height: 8 + radius: 20 + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.15) + + property real progressRatio: currentPlayer && currentPlayer.length > 0 ? (currentPosition / currentPlayer.length) : 0 + + Rectangle { + id: progressFill + width: progressBarBackground.progressRatio * parent.width + height: parent.height + radius: parent.radius + color: Data.ThemeManager.accentColor + + Behavior on width { + NumberAnimation { + duration: 200 + } + } + } + + // Interactive progress handle (circle) + Rectangle { + id: progressHandle + width: 16 + height: 16 + radius: 8 + color: Data.ThemeManager.accentColor + border.color: Qt.lighter(Data.ThemeManager.accentColor, 1.3) + border.width: 1 + + x: Math.max(0, Math.min(parent.width - width, progressFill.width - width / 2)) + anchors.verticalCenter: parent.verticalCenter + + visible: currentPlayer && currentPlayer.length > 0 + scale: progressMouseArea.containsMouse || progressMouseArea.pressed ? 1.2 : 1.0 + + Behavior on scale { + NumberAnimation { + duration: 150 + } + } + } + + // Mouse area for seeking + MouseArea { + id: progressMouseArea + anchors.fill: parent + hoverEnabled: true + enabled: currentPlayer && currentPlayer.length > 0 && currentPlayer.canSeek + + onClicked: function (mouse) { + if (currentPlayer && currentPlayer.length > 0) { + let ratio = mouse.x / width; + let seekPosition = ratio * currentPlayer.length; + currentPlayer.position = seekPosition; + currentPosition = seekPosition; + } + } + + onPositionChanged: function (mouse) { + if (pressed && currentPlayer && currentPlayer.length > 0) { + let ratio = Math.max(0, Math.min(1, mouse.x / width)); + let seekPosition = ratio * currentPlayer.length; + currentPlayer.position = seekPosition; + currentPosition = seekPosition; + } + } + } + } + + // Player selection dropdown (conditional visibility) + Rectangle { + width: parent.width + height: 38 + radius: 20 + color: Qt.darker(Data.ThemeManager.bgColor, 1.1) + border.color: Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) + border.width: 1 + visible: { + let playerCount = getAvailablePlayers().length; + let alwaysShow = Data.Settings.alwaysShowPlayerDropdown; + let shouldShow = alwaysShow || playerCount > 1; + return shouldShow; + } + + Row { + anchors.fill: parent + anchors.margins: 6 + anchors.leftMargin: 12 + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Player:" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.7) + font.family: "monospace" + font.pixelSize: 12 + font.bold: true + } + + ComboBox { + id: playerComboBox + anchors.verticalCenter: parent.verticalCenter + width: parent.width - parent.children[0].width - parent.spacing + height: 26 + model: [] + + onActivated: function (index) { + selectedPlayerIndex = index; + updateCurrentPlayer(); + } + + background: Rectangle { + color: Qt.darker(Data.ThemeManager.bgColor, 1.3) + border.color: Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) + border.width: 1 + radius: 20 + } + + contentItem: Text { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.right: parent.right + anchors.rightMargin: 22 + anchors.verticalCenter: parent.verticalCenter + text: playerComboBox.currentText || "No players" + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 12 + font.bold: true + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + + indicator: Text { + anchors.right: parent.right + anchors.rightMargin: 4 + anchors.verticalCenter: parent.verticalCenter + text: "expand_more" + font.family: "Material Symbols Outlined" + font.pixelSize: 12 + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.6) + } + + popup: Popup { + y: playerComboBox.height + 2 + width: playerComboBox.width + implicitHeight: contentItem.implicitHeight + 4 + + background: Rectangle { + color: Qt.darker(Data.ThemeManager.bgColor, 1.2) + border.color: Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.3) + border.width: 1 + radius: 20 + } + + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: playerComboBox.popup.visible ? playerComboBox.delegateModel : null + currentIndex: playerComboBox.highlightedIndex + + ScrollIndicator.vertical: ScrollIndicator {} + } + } + + delegate: ItemDelegate { + width: playerComboBox.width + height: 28 + + background: Rectangle { + color: parent.hovered ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.15) : "transparent" + radius: 20 + } + + contentItem: Text { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: modelData || "" + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 12 + font.bold: true + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + } + } + } + } + + // Media controls + Row { + width: parent.width + height: 35 + spacing: 6 + + // Previous button + Rectangle { + width: (parent.width - parent.spacing * 4) * 0.2 + height: parent.height + radius: height / 2 + color: previousButton.containsMouse ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) : Qt.darker(Data.ThemeManager.bgColor, 1.1) + border.color: Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.3) + border.width: 1 + + MouseArea { + id: previousButton + anchors.fill: parent + hoverEnabled: true + enabled: currentPlayer && currentPlayer.canGoPrevious + onClicked: if (currentPlayer) + currentPlayer.previous() + } + + Text { + anchors.centerIn: parent + text: "skip_previous" + font.family: "Material Symbols Outlined" + font.pixelSize: 18 + color: previousButton.enabled ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + } + } + + // Play/Pause button + Rectangle { + width: (parent.width - parent.spacing * 4) * 0.3 + height: parent.height + radius: height / 2 + color: playButton.containsMouse ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) : Qt.darker(Data.ThemeManager.bgColor, 1.1) + border.color: Data.ThemeManager.accentColor + border.width: 2 + + MouseArea { + id: playButton + anchors.fill: parent + hoverEnabled: true + enabled: currentPlayer && (currentPlayer.canPlay || currentPlayer.canPause) + onClicked: { + if (currentPlayer) { + if (currentPlayer.isPlaying) { + currentPlayer.pause(); + } else { + currentPlayer.play(); + } + } + } + } + + Text { + anchors.centerIn: parent + text: currentPlayer && currentPlayer.isPlaying ? "pause" : "play_arrow" + font.family: "Material Symbols Outlined" + font.pixelSize: 20 + color: playButton.enabled ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + } + } + + // Next button + Rectangle { + width: (parent.width - parent.spacing * 4) * 0.2 + height: parent.height + radius: height / 2 + color: nextButton.containsMouse ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) : Qt.darker(Data.ThemeManager.bgColor, 1.1) + border.color: Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.3) + border.width: 1 + + MouseArea { + id: nextButton + anchors.fill: parent + hoverEnabled: true + enabled: currentPlayer && currentPlayer.canGoNext + onClicked: if (currentPlayer) + currentPlayer.next() + } + + Text { + anchors.centerIn: parent + text: "skip_next" + font.family: "Material Symbols Outlined" + font.pixelSize: 18 + color: nextButton.enabled ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + } + } + + // Shuffle button + Rectangle { + width: (parent.width - parent.spacing * 4) * 0.15 + height: parent.height + radius: height / 2 + color: shuffleButton.containsMouse ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) : Qt.darker(Data.ThemeManager.bgColor, 1.1) + border.color: currentPlayer && currentPlayer.shuffle ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.3) + border.width: 1 + + MouseArea { + id: shuffleButton + anchors.fill: parent + hoverEnabled: true + enabled: currentPlayer && currentPlayer.canControl && currentPlayer.shuffleSupported + onClicked: { + if (currentPlayer && currentPlayer.shuffleSupported) { + currentPlayer.shuffle = !currentPlayer.shuffle; + } + } + } + + Text { + anchors.centerIn: parent + text: "shuffle" + font.family: "Material Symbols Outlined" + font.pixelSize: 12 + color: shuffleButton.enabled ? (currentPlayer && currentPlayer.shuffle ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.6)) : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + } + } + + // Repeat button + Rectangle { + width: (parent.width - parent.spacing * 4) * 0.15 + height: parent.height + radius: height / 2 + color: repeatButton.containsMouse ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) : Qt.darker(Data.ThemeManager.bgColor, 1.1) + border.color: currentPlayer && currentPlayer.loopState !== MprisLoopState.None ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.3) + border.width: 1 + + MouseArea { + id: repeatButton + anchors.fill: parent + hoverEnabled: true + enabled: currentPlayer && currentPlayer.canControl && currentPlayer.loopSupported + onClicked: { + if (currentPlayer && currentPlayer.loopSupported) { + if (currentPlayer.loopState === MprisLoopState.None) { + currentPlayer.loopState = MprisLoopState.Track; + } else if (currentPlayer.loopState === MprisLoopState.Track) { + currentPlayer.loopState = MprisLoopState.Playlist; + } else { + currentPlayer.loopState = MprisLoopState.None; + } + } + } + } + + Text { + anchors.centerIn: parent + text: currentPlayer && currentPlayer.loopState === MprisLoopState.Track ? "repeat_one" : "repeat" + font.family: "Material Symbols Outlined" + font.pixelSize: 12 + color: repeatButton.enabled ? (currentPlayer && currentPlayer.loopState !== MprisLoopState.None ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3)) : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + } + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/navigation/TabContainer.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/navigation/TabContainer.qml new file mode 100644 index 0000000..39eca67 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/navigation/TabContainer.qml @@ -0,0 +1,112 @@ +import QtQuick +import "../../tabs" as Tabs + +// Tab container with sliding animation +Item { + id: tabContainer + + // Properties from parent + required property var shell + required property bool isRecording + required property var triggerMouseArea + property int currentTab: 0 + + // Signals to forward + signal recordingRequested + signal stopRecordingRequested + signal systemActionRequested(string action) + signal performanceActionRequested(string action) + + // Hover detection combining all tab hovers + property bool isHovered: { + const tabHovers = [mainDashboard.isHovered, true // Calendar tab should stay open when active + , true // Clipboard tab should stay open when active + , true // Notification tab should stay open when active + , true // Music tab should stay open when active + , true // Settings tab should stay open when active + ]; + return tabHovers[currentTab] || false; + } + + // Track when text inputs have focus for keyboard management + property bool textInputFocused: currentTab === 5 && settingsTab.anyTextInputFocused + + clip: true + + // Sliding content container + Row { + id: slidingRow + width: parent.width * 7 // 7 tabs wide + height: parent.height + spacing: 0 + + // Animate horizontal position based on current tab + x: -tabContainer.currentTab * tabContainer.width + + Behavior on x { + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + } + } + + Tabs.MainDashboard { + id: mainDashboard + width: tabContainer.width + height: parent.height + + shell: tabContainer.shell + isRecording: tabContainer.isRecording + triggerMouseArea: tabContainer.triggerMouseArea + + onRecordingRequested: tabContainer.recordingRequested() + onStopRecordingRequested: tabContainer.stopRecordingRequested() + onSystemActionRequested: function (action) { + tabContainer.systemActionRequested(action); + } + onPerformanceActionRequested: function (action) { + tabContainer.performanceActionRequested(action); + } + } + + Tabs.CalendarTab { + id: calendarTab + width: tabContainer.width + height: parent.height + shell: tabContainer.shell + isActive: tabContainer.currentTab === 1 || Math.abs(tabContainer.currentTab - 1) <= 1 + } + + Tabs.ClipboardTab { + id: clipboardTab + width: tabContainer.width + height: parent.height + shell: tabContainer.shell + isActive: tabContainer.currentTab === 2 || Math.abs(tabContainer.currentTab - 2) <= 1 + } + + Tabs.NotificationTab { + id: notificationTab + width: tabContainer.width + height: parent.height + shell: tabContainer.shell + isActive: tabContainer.currentTab === 3 || Math.abs(tabContainer.currentTab - 3) <= 1 + } + + Tabs.MusicTab { + id: musicTab + width: tabContainer.width + height: parent.height + shell: tabContainer.shell + isActive: tabContainer.currentTab === 5 || Math.abs(tabContainer.currentTab - 5) <= 1 + } + + Tabs.SettingsTab { + id: settingsTab + width: tabContainer.width + height: parent.height + shell: tabContainer.shell + isActive: tabContainer.currentTab === 6 || Math.abs(tabContainer.currentTab - 6) <= 1 + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/navigation/TabNavigation.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/navigation/TabNavigation.qml new file mode 100644 index 0000000..49f7ba5 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/navigation/TabNavigation.qml @@ -0,0 +1,139 @@ +import QtQuick +import "root:/Data" as Data + +// Tab navigation sidebar +Item { + id: tabNavigation + + property int currentTab: 0 + property var tabIcons: [] + property bool containsMouse: sidebarMouseArea.containsMouse || tabColumn.containsMouse + + MouseArea { + id: sidebarMouseArea + anchors.fill: parent + hoverEnabled: true + propagateComposedEvents: true + } + + // Tab button background - matches system controls + Rectangle { + width: 38 + height: tabColumn.height + 12 + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + color: Qt.lighter(Data.ThemeManager.bgColor, 1.15) + radius: 19 + border.width: 1 + border.color: Qt.lighter(Data.ThemeManager.bgColor, 1.3) + + // Subtle inner shadow effect + Rectangle { + anchors.fill: parent + anchors.margins: 1 + color: Qt.darker(Data.ThemeManager.bgColor, 1.05) + radius: parent.radius - 1 + opacity: 0.3 + } + } + + // Tab icon buttons + Column { + id: tabColumn + spacing: 6 + anchors.top: parent.top + anchors.topMargin: 6 + anchors.horizontalCenter: parent.horizontalCenter + + property bool containsMouse: { + for (let i = 0; i < tabRepeater.count; i++) { + const tab = tabRepeater.itemAt(i); + if (tab && tab.mouseArea && tab.mouseArea.containsMouse) { + return true; + } + } + return false; + } + + Repeater { + id: tabRepeater + model: 7 + delegate: Rectangle { + width: 30 + height: 30 + radius: 15 + + // Dynamic background based on state + color: { + if (tabNavigation.currentTab === index) { + return Data.ThemeManager.accentColor; + } else if (tabMouseArea.containsMouse) { + return Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.15); + } else { + return "transparent"; + } + } + + // Subtle shadow for active tab + Rectangle { + anchors.fill: parent + radius: parent.radius + color: "transparent" + border.color: Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.3) + border.width: tabNavigation.currentTab === index ? 0 : (tabMouseArea.containsMouse ? 1 : 0) + visible: tabNavigation.currentTab !== index + } + + property alias mouseArea: tabMouseArea + + MouseArea { + id: tabMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + tabNavigation.currentTab = index; + } + } + + Text { + anchors.centerIn: parent + text: tabNavigation.tabIcons[index] || "" + font.family: "Material Symbols Outlined" + font.pixelSize: 16 + color: { + if (tabNavigation.currentTab === index) { + return Data.ThemeManager.bgColor; + } else if (tabMouseArea.containsMouse) { + return Data.ThemeManager.accentColor; + } else { + return Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.7); + } + } + + // Smooth color transitions + Behavior on color { + ColorAnimation { + duration: 150 + } + } + } + + // Smooth transitions + Behavior on color { + ColorAnimation { + duration: 150 + } + } + + // Subtle scale effect on hover + scale: tabMouseArea.containsMouse ? 1.05 : 1.0 + Behavior on scale { + NumberAnimation { + duration: 150 + easing.type: Easing.OutCubic + } + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/AppearanceSettings.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/AppearanceSettings.qml new file mode 100644 index 0000000..649989a --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/AppearanceSettings.qml @@ -0,0 +1,758 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "root:/Data" as Data + +// Appearance settings content +Column { + width: parent.width + spacing: 20 + + // Theme Setting in Collapsible Section + SettingsCategory { + width: parent.width + title: "Theme Setting" + icon: "palette" + + content: Component { + Column { + width: parent.width + spacing: 30 // Increased spacing between major sections + + // Dark/Light Mode Switch + Column { + width: parent.width + spacing: 12 + + Text { + text: "Theme Mode" + color: Data.ThemeManager.fgColor + font.pixelSize: 15 + font.bold: true + font.family: "monospace" + } + + Row { + spacing: 16 + anchors.horizontalCenter: parent.horizontalCenter + + Text { + text: "Light" + color: Data.ThemeManager.fgColor + font.pixelSize: 14 + font.family: "monospace" + anchors.verticalCenter: parent.verticalCenter + } + + // Toggle switch - enhanced design + Rectangle { + width: 64 + height: 32 + radius: 16 + color: Data.ThemeManager.currentTheme.type === "dark" ? Qt.lighter(Data.ThemeManager.accentColor, 0.8) : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.2) + border.width: 2 + border.color: Data.ThemeManager.currentTheme.type === "dark" ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.4) + anchors.verticalCenter: parent.verticalCenter + + // Inner track shadow + Rectangle { + anchors.fill: parent + anchors.margins: 2 + radius: parent.radius - 2 + color: "transparent" + border.width: 1 + border.color: Qt.rgba(0, 0, 0, 0.1) + } + + // Toggle handle + Rectangle { + id: toggleHandle + width: 26 + height: 26 + radius: 13 + color: Data.ThemeManager.currentTheme.type === "dark" ? Data.ThemeManager.bgColor : Data.ThemeManager.panelBackground + border.width: 2 + border.color: Data.ThemeManager.currentTheme.type === "dark" ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor + anchors.verticalCenter: parent.verticalCenter + x: Data.ThemeManager.currentTheme.type === "dark" ? parent.width - width - 3 : 3 + + // Handle shadow + Rectangle { + anchors.centerIn: parent + anchors.verticalCenterOffset: 1 + width: parent.width - 2 + height: parent.height - 2 + radius: parent.radius - 1 + color: Qt.rgba(0, 0, 0, 0.1) + z: -1 + } + + // Handle highlight + Rectangle { + anchors.centerIn: parent + width: parent.width - 6 + height: parent.height - 6 + radius: parent.radius - 3 + color: Qt.rgba(255, 255, 255, 0.15) + } + + Behavior on x { + NumberAnimation { + duration: 250 + easing.type: Easing.OutBack + easing.overshoot: 0.3 + } + } + + Behavior on border.color { + ColorAnimation { + duration: 200 + } + } + } + + // Background color transition + Behavior on color { + ColorAnimation { + duration: 200 + } + } + + Behavior on border.color { + ColorAnimation { + duration: 200 + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + + onClicked: { + console.log("Theme switch clicked, current:", Data.ThemeManager.currentThemeId); + var currentFamily = Data.ThemeManager.currentThemeId.replace(/_dark$|_light$/, ""); + var newType = Data.ThemeManager.currentTheme.type === "dark" ? "light" : "dark"; + var newThemeId = currentFamily + "_" + newType; + console.log("Switching to:", newThemeId); + Data.ThemeManager.setTheme(newThemeId); + + // Force update the settings if currentTheme isn't being saved properly + if (!Data.Settings.currentTheme) { + Data.Settings.currentTheme = newThemeId; + Data.Settings.saveSettings(); + } + } + + onEntered: { + parent.scale = 1.05; + } + + onExited: { + parent.scale = 1.0; + } + } + + Behavior on scale { + NumberAnimation { + duration: 150 + easing.type: Easing.OutQuad + } + } + } + + Text { + text: "Dark" + color: Data.ThemeManager.fgColor + font.pixelSize: 14 + font.family: "monospace" + anchors.verticalCenter: parent.verticalCenter + } + } + } + + // Separator + Rectangle { + width: parent.width - 40 + height: 1 + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.1) + anchors.horizontalCenter: parent.horizontalCenter + } + + // Theme Selection + Column { + width: parent.width + spacing: 12 + + Text { + text: "Theme Family" + color: Data.ThemeManager.fgColor + font.pixelSize: 15 + font.bold: true + font.family: "monospace" + } + + Text { + text: "Choose your preferred theme family" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.7) + font.pixelSize: 13 + font.family: "monospace" + anchors.horizontalCenter: parent.horizontalCenter + } + + // Compact 2x2 grid for themes + GridLayout { + columns: 2 + columnSpacing: 8 + rowSpacing: 8 + anchors.horizontalCenter: parent.horizontalCenter + + property var themeFamily: { + var currentFamily = Data.ThemeManager.currentThemeId.replace(/_dark$|_light$/, ""); + return currentFamily; + } + + property var themeFamilies: [ + { + id: "oxocarbon", + name: "Oxocarbon", + description: "IBM Carbon" + }, + { + id: "dracula", + name: "Dracula", + description: "Vibrant" + }, + { + id: "gruvbox", + name: "Gruvbox", + description: "Retro" + }, + { + id: "catppuccin", + name: "Catppuccin", + description: "Pastel" + }, + { + id: "matugen", + name: "Matugen", + description: "Generated" + } + ] + + Repeater { + model: parent.themeFamilies + delegate: Rectangle { + Layout.preferredWidth: 140 + Layout.preferredHeight: 50 + radius: 10 + color: parent.themeFamily === modelData.id ? Data.ThemeManager.accentColor : Qt.lighter(Data.ThemeManager.bgColor, 1.15) + border.width: parent.themeFamily === modelData.id ? 2 : 1 + border.color: parent.themeFamily === modelData.id ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + spacing: 6 + + // Compact theme preview colors + Row { + spacing: 1 + property var previewTheme: Data.ThemeManager.themes[modelData.id + "_" + Data.ThemeManager.currentTheme.type] || Data.ThemeManager.themes[modelData.id + "_dark"] + Rectangle { + width: 4 + height: 14 + radius: 1 + color: parent.previewTheme.base00 + } + Rectangle { + width: 4 + height: 14 + radius: 1 + color: parent.previewTheme.base0E + } + Rectangle { + width: 4 + height: 14 + radius: 1 + color: parent.previewTheme.base0D + } + Rectangle { + width: 4 + height: 14 + radius: 1 + color: parent.previewTheme.base0B + } + } + + Column { + spacing: 1 + anchors.verticalCenter: parent.verticalCenter + + Text { + text: modelData.name + color: parent.parent.parent.parent.themeFamily === modelData.id ? Data.ThemeManager.bgColor : Data.ThemeManager.fgColor + font.pixelSize: 12 + font.bold: parent.parent.parent.parent.themeFamily === modelData.id + font.family: "monospace" + } + + Text { + text: modelData.description + color: parent.parent.parent.parent.themeFamily === modelData.id ? Qt.rgba(Data.ThemeManager.bgColor.r, Data.ThemeManager.bgColor.g, Data.ThemeManager.bgColor.b, 0.8) : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.6) + font.pixelSize: 9 + font.family: "monospace" + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + + onClicked: { + var themeType = Data.ThemeManager.currentTheme.type; + var newThemeId = modelData.id + "_" + themeType; + console.log("Theme card clicked:", newThemeId); + Data.ThemeManager.setTheme(newThemeId); + } + + onEntered: { + parent.scale = 1.02; + } + + onExited: { + parent.scale = 1.0; + } + } + + Behavior on scale { + NumberAnimation { + duration: 150 + easing.type: Easing.OutQuad + } + } + } + } + } + } + + // Separator + Rectangle { + width: parent.width - 40 + height: 1 + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.1) + anchors.horizontalCenter: parent.horizontalCenter + } + + // Accent Colors + Column { + width: parent.width + spacing: 12 + + Text { + text: "Accent Colors" + color: Data.ThemeManager.fgColor + font.pixelSize: 15 + font.bold: true + font.family: "monospace" + } + + Text { + text: "Choose your preferred accent color for " + Data.ThemeManager.currentTheme.name + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.7) + font.pixelSize: 13 + font.family: "monospace" + wrapMode: Text.Wrap + width: parent.width + anchors.horizontalCenter: parent.horizontalCenter + } + + // Compact flow layout for accent colors + Flow { + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width - 20 // Margins to prevent clipping + spacing: 8 + + property var accentColors: { + var currentFamily = Data.ThemeManager.currentThemeId.replace(/_dark$|_light$/, ""); + var themeColors = []; + + // Theme-specific accent colors - reduced to 5 per theme for compactness + if (currentFamily === "dracula") { + themeColors.push({ + name: "Magenta", + dark: "#ff79c6", + light: "#e91e63" + }, { + name: "Purple", + dark: "#bd93f9", + light: "#6c7ce0" + }, { + name: "Cyan", + dark: "#8be9fd", + light: "#17a2b8" + }, { + name: "Green", + dark: "#50fa7b", + light: "#27ae60" + }, { + name: "Orange", + dark: "#ffb86c", + light: "#f39c12" + }); + } else if (currentFamily === "gruvbox") { + themeColors.push({ + name: "Orange", + dark: "#fe8019", + light: "#d65d0e" + }, { + name: "Red", + dark: "#fb4934", + light: "#cc241d" + }, { + name: "Yellow", + dark: "#fabd2f", + light: "#d79921" + }, { + name: "Green", + dark: "#b8bb26", + light: "#98971a" + }, { + name: "Purple", + dark: "#d3869b", + light: "#b16286" + }); + } else if (currentFamily === "catppuccin") { + themeColors.push({ + name: "Mauve", + dark: "#cba6f7", + light: "#8839ef" + }, { + name: "Blue", + dark: "#89b4fa", + light: "#1e66f5" + }, { + name: "Teal", + dark: "#94e2d5", + light: "#179299" + }, { + name: "Green", + dark: "#a6e3a1", + light: "#40a02b" + }, { + name: "Peach", + dark: "#fab387", + light: "#fe640b" + }); + } else if (currentFamily === "matugen") { + // Use dynamic matugen colors if available + if (Data.ThemeManager.matugen && Data.ThemeManager.matugen.isMatugenActive()) { + themeColors.push({ + name: "Primary", + dark: Data.ThemeManager.matugen.getMatugenColor("primary") || "#adc6ff", + light: Data.ThemeManager.matugen.getMatugenColor("primary") || "#0f62fe" + }, { + name: "Secondary", + dark: Data.ThemeManager.matugen.getMatugenColor("secondary") || "#bfc6dc", + light: Data.ThemeManager.matugen.getMatugenColor("secondary") || "#6272a4" + }, { + name: "Tertiary", + dark: Data.ThemeManager.matugen.getMatugenColor("tertiary") || "#debcdf", + light: Data.ThemeManager.matugen.getMatugenColor("tertiary") || "#b16286" + }, { + name: "Surface", + dark: Data.ThemeManager.matugen.getMatugenColor("surface_tint") || "#adc6ff", + light: Data.ThemeManager.matugen.getMatugenColor("surface_tint") || "#0f62fe" + }, { + name: "Error", + dark: Data.ThemeManager.matugen.getMatugenColor("error") || "#ffb4ab", + light: Data.ThemeManager.matugen.getMatugenColor("error") || "#ba1a1a" + }); + } else { + // Fallback matugen colors + themeColors.push({ + name: "Primary", + dark: "#adc6ff", + light: "#0f62fe" + }, { + name: "Secondary", + dark: "#bfc6dc", + light: "#6272a4" + }, { + name: "Tertiary", + dark: "#debcdf", + light: "#b16286" + }, { + name: "Surface", + dark: "#adc6ff", + light: "#0f62fe" + }, { + name: "Error", + dark: "#ffb4ab", + light: "#ba1a1a" + }); + } + } else { + // oxocarbon and fallback + themeColors.push({ + name: "Purple", + dark: "#be95ff", + light: "#8a3ffc" + }, { + name: "Blue", + dark: "#78a9ff", + light: "#0f62fe" + }, { + name: "Cyan", + dark: "#3ddbd9", + light: "#007d79" + }, { + name: "Green", + dark: "#42be65", + light: "#198038" + }, { + name: "Pink", + dark: "#ff7eb6", + light: "#d12771" + }); + } + + return themeColors; + } + + Repeater { + model: parent.accentColors + delegate: Rectangle { + width: 60 + height: 50 + radius: 10 + color: Qt.lighter(Data.ThemeManager.bgColor, 1.15) + border.width: Data.ThemeManager.accentColor.toString() === (Data.ThemeManager.currentTheme.type === "dark" ? modelData.dark : modelData.light) ? 3 : 1 + border.color: Data.ThemeManager.accentColor.toString() === (Data.ThemeManager.currentTheme.type === "dark" ? modelData.dark : modelData.light) ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + + Column { + anchors.centerIn: parent + spacing: 4 + + Rectangle { + width: 20 + height: 20 + radius: 10 + color: Data.ThemeManager.currentTheme.type === "dark" ? modelData.dark : modelData.light + anchors.horizontalCenter: parent.horizontalCenter + } + + Text { + text: modelData.name + color: Data.ThemeManager.fgColor + font.pixelSize: 9 + font.family: "monospace" + font.bold: true + anchors.horizontalCenter: parent.horizontalCenter + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + + onClicked: { + // Set custom accent + Data.Settings.useCustomAccent = true; + Data.ThemeManager.setCustomAccent(modelData.dark, modelData.light); + } + + onEntered: { + parent.scale = 1.05; + } + + onExited: { + parent.scale = 1.0; + } + } + + Behavior on scale { + NumberAnimation { + duration: 150 + easing.type: Easing.OutQuad + } + } + } + } + } + } + } + } + } + + // Animation Settings in Collapsible Section + SettingsCategory { + width: parent.width + title: "Animation Settings" + icon: "animation" + + content: Component { + Column { + width: parent.width + spacing: 20 + + Text { + text: "Configure workspace change animations" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.7) + font.pixelSize: 13 + font.family: "monospace" + anchors.horizontalCenter: parent.horizontalCenter + } + + // Workspace Burst Toggle + Row { + width: parent.width + height: 40 + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 80 + + Text { + text: "Workspace Burst Effect" + color: Data.ThemeManager.fgColor + font.pixelSize: 14 + font.bold: true + font.family: "monospace" + } + + Text { + text: "Expanding rings when switching workspaces" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.6) + font.pixelSize: 11 + font.family: "monospace" + } + } + + // Toggle switch for burst + Rectangle { + width: 50 + height: 25 + radius: 12.5 + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + color: Data.Settings.workspaceBurstEnabled ? Qt.lighter(Data.ThemeManager.accentColor, 0.8) : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.2) + border.width: 1 + border.color: Data.Settings.workspaceBurstEnabled ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.4) + + Rectangle { + width: 20 + height: 20 + radius: 10 + color: Data.ThemeManager.bgColor + border.width: 1.5 + border.color: Data.Settings.workspaceBurstEnabled ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor + anchors.verticalCenter: parent.verticalCenter + x: Data.Settings.workspaceBurstEnabled ? parent.width - width - 2.5 : 2.5 + + Behavior on x { + NumberAnimation { + duration: 200 + easing.type: Easing.OutQuad + } + } + } + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + Behavior on border.color { + ColorAnimation { + duration: 200 + } + } + + MouseArea { + anchors.fill: parent + onClicked: { + Data.Settings.workspaceBurstEnabled = !Data.Settings.workspaceBurstEnabled; + } + } + } + } + + // Workspace Glow Toggle + Row { + width: parent.width + height: 40 + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 80 + + Text { + text: "Workspace Shadow Glow" + color: Data.ThemeManager.fgColor + font.pixelSize: 14 + font.bold: true + font.family: "monospace" + } + + Text { + text: "Accent color glow in workspace shadow" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.6) + font.pixelSize: 11 + font.family: "monospace" + } + } + + // Toggle switch for glow + Rectangle { + width: 50 + height: 25 + radius: 12.5 + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + color: Data.Settings.workspaceGlowEnabled ? Qt.lighter(Data.ThemeManager.accentColor, 0.8) : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.2) + border.width: 1 + border.color: Data.Settings.workspaceGlowEnabled ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.4) + + Rectangle { + width: 20 + height: 20 + radius: 10 + color: Data.ThemeManager.bgColor + border.width: 1.5 + border.color: Data.Settings.workspaceGlowEnabled ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor + anchors.verticalCenter: parent.verticalCenter + x: Data.Settings.workspaceGlowEnabled ? parent.width - width - 2.5 : 2.5 + + Behavior on x { + NumberAnimation { + duration: 200 + easing.type: Easing.OutQuad + } + } + } + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + Behavior on border.color { + ColorAnimation { + duration: 200 + } + } + + MouseArea { + anchors.fill: parent + onClicked: { + Data.Settings.workspaceGlowEnabled = !Data.Settings.workspaceGlowEnabled; + } + } + } + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/MusicPlayerSettings.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/MusicPlayerSettings.qml new file mode 100644 index 0000000..3173958 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/MusicPlayerSettings.qml @@ -0,0 +1,129 @@ +import QtQuick +import QtQuick.Controls +import "root:/Data" as Data + +// Music Player settings content +Column { + width: parent.width + spacing: 20 + + // Auto-switch to active player + Column { + width: parent.width + spacing: 12 + + Text { + text: "Auto-switch to Active Player" + color: Data.ThemeManager.fgColor + font.pixelSize: 16 + font.bold: true + font.family: "monospace" + } + + Text { + text: "Automatically switch to the player that starts playing music" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.7) + font.pixelSize: 13 + font.family: "monospace" + wrapMode: Text.Wrap + width: parent.width + } + + Rectangle { + width: 200 + height: 35 + radius: 18 + color: Data.Settings.autoSwitchPlayer ? Data.ThemeManager.accentColor : Qt.lighter(Data.ThemeManager.bgColor, 1.15) + border.width: 1 + border.color: Data.ThemeManager.accentColor + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + + Text { + anchors.centerIn: parent + text: Data.Settings.autoSwitchPlayer ? "Enabled" : "Disabled" + color: Data.Settings.autoSwitchPlayer ? Data.ThemeManager.bgColor : Data.ThemeManager.fgColor + font.pixelSize: 14 + font.bold: true + font.family: "monospace" + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + } + + MouseArea { + anchors.fill: parent + onClicked: { + Data.Settings.autoSwitchPlayer = !Data.Settings.autoSwitchPlayer; + } + } + } + } + + // Always show player dropdown + Column { + width: parent.width + spacing: 12 + + Text { + text: "Always Show Player Dropdown" + color: Data.ThemeManager.fgColor + font.pixelSize: 16 + font.bold: true + font.family: "monospace" + } + + Text { + text: "Show the player selection dropdown even with only one player" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.7) + font.pixelSize: 13 + font.family: "monospace" + wrapMode: Text.Wrap + width: parent.width + } + + Rectangle { + width: 200 + height: 35 + radius: 18 + color: Data.Settings.alwaysShowPlayerDropdown ? Data.ThemeManager.accentColor : Qt.lighter(Data.ThemeManager.bgColor, 1.15) + border.width: 1 + border.color: Data.ThemeManager.accentColor + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + + Text { + anchors.centerIn: parent + text: Data.Settings.alwaysShowPlayerDropdown ? "Enabled" : "Disabled" + color: Data.Settings.alwaysShowPlayerDropdown ? Data.ThemeManager.bgColor : Data.ThemeManager.fgColor + font.pixelSize: 14 + font.bold: true + font.family: "monospace" + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + } + + MouseArea { + anchors.fill: parent + onClicked: { + Data.Settings.alwaysShowPlayerDropdown = !Data.Settings.alwaysShowPlayerDropdown; + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/NightLightSettings.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/NightLightSettings.qml new file mode 100644 index 0000000..8e1f059 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/NightLightSettings.qml @@ -0,0 +1,531 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "root:/Data" as Data + +// Night Light settings content +Item { + id: nightLightSettings + width: parent.width + height: contentColumn.height + + Column { + id: contentColumn + width: parent.width + spacing: 20 + + // Night Light Enable Toggle + Row { + width: parent.width + spacing: 16 + + Column { + width: parent.width - nightLightToggle.width - 16 + spacing: 4 + + Text { + text: "Enable Night Light" + color: Data.ThemeManager.fgColor + font.pixelSize: 16 + font.bold: true + font.family: "monospace" + } + + Text { + text: "Reduces blue light to help protect your eyes and improve sleep" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.7) + font.pixelSize: 13 + font.family: "monospace" + wrapMode: Text.Wrap + } + } + + Rectangle { + id: nightLightToggle + width: 50 + height: 28 + radius: 14 + color: Data.Settings.nightLightEnabled ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + + Rectangle { + width: 20 + height: 20 + radius: 10 + color: Data.ThemeManager.bgColor + x: Data.Settings.nightLightEnabled ? parent.width - width - 4 : 4 + anchors.verticalCenter: parent.verticalCenter + + Behavior on x { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: { + Data.Settings.nightLightEnabled = !Data.Settings.nightLightEnabled; + } + onEntered: { + parent.scale = 1.05; + } + onExited: { + parent.scale = 1.0; + } + } + + Behavior on scale { + NumberAnimation { + duration: 150 + easing.type: Easing.OutQuad + } + } + } + } + + // Warmth Level Slider + Column { + width: parent.width + spacing: 12 + + Text { + text: "Warmth Level" + color: Data.ThemeManager.fgColor + font.pixelSize: 16 + font.bold: true + font.family: "monospace" + } + + Text { + text: "Adjust how warm the screen filter appears" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.7) + font.pixelSize: 13 + font.family: "monospace" + wrapMode: Text.Wrap + width: parent.width + } + + Row { + width: parent.width + spacing: 12 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Cool" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.6) + font.pixelSize: 12 + font.family: "monospace" + } + + Slider { + id: warmthSlider + width: parent.width - 120 + height: 30 + from: 0.1 + to: 1.0 + value: Data.Settings.nightLightWarmth || 0.4 + stepSize: 0.1 + + onValueChanged: { + Data.Settings.nightLightWarmth = value; + } + + background: Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: parent.width + height: 6 + radius: 3 + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.2) + + Rectangle { + width: warmthSlider.visualPosition * parent.width + height: parent.height + radius: parent.radius + color: Qt.rgba(1.0, 0.8 - warmthSlider.value * 0.3, 0.4, 1.0) + } + } + + handle: Rectangle { + x: warmthSlider.leftPadding + warmthSlider.visualPosition * (warmthSlider.availableWidth - width) + y: warmthSlider.topPadding + warmthSlider.availableHeight / 2 - height / 2 + width: 20 + height: 20 + radius: 10 + color: Data.ThemeManager.accentColor + border.color: Qt.lighter(Data.ThemeManager.accentColor, 1.2) + border.width: 2 + } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Warm" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.6) + font.pixelSize: 12 + font.family: "monospace" + } + } + } + + // Auto-enable Toggle + Row { + width: parent.width + spacing: 16 + + Column { + width: parent.width - autoToggle.width - 16 + spacing: 4 + + Text { + text: "Auto-enable Schedule" + color: Data.ThemeManager.fgColor + font.pixelSize: 16 + font.bold: true + font.family: "monospace" + } + + Text { + text: "Automatically turn on night light at sunset/bedtime" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.7) + font.pixelSize: 13 + font.family: "monospace" + wrapMode: Text.Wrap + } + } + + Rectangle { + id: autoToggle + width: 50 + height: 28 + radius: 14 + color: Data.Settings.nightLightAuto ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + + Rectangle { + width: 20 + height: 20 + radius: 10 + color: Data.ThemeManager.bgColor + x: Data.Settings.nightLightAuto ? parent.width - width - 4 : 4 + anchors.verticalCenter: parent.verticalCenter + + Behavior on x { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: { + Data.Settings.nightLightAuto = !Data.Settings.nightLightAuto; + } + onEntered: { + parent.scale = 1.05; + } + onExited: { + parent.scale = 1.0; + } + } + + Behavior on scale { + NumberAnimation { + duration: 150 + easing.type: Easing.OutQuad + } + } + } + } + + // Schedule Time Controls - visible when auto-enable is on + Column { + width: parent.width + spacing: 16 + visible: Data.Settings.nightLightAuto + opacity: Data.Settings.nightLightAuto ? 1.0 : 0.0 + + Behavior on opacity { + NumberAnimation { + duration: 200 + } + } + + Text { + text: "Schedule Times" + color: Data.ThemeManager.fgColor + font.pixelSize: 16 + font.bold: true + font.family: "monospace" + } + + // Start and End Time Row + Row { + width: parent.width + spacing: 20 + + // Start Time + Column { + id: startTimeColumn + width: (parent.width - parent.spacing) / 2 + spacing: 8 + + Text { + text: "Start Time" + color: Data.ThemeManager.fgColor + font.pixelSize: 14 + font.bold: true + font.family: "monospace" + } + + Text { + text: "Night light turns on" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.7) + font.pixelSize: 12 + font.family: "monospace" + } + + Rectangle { + id: startTimeButton + width: parent.width + height: 40 + radius: 8 + color: Qt.lighter(Data.ThemeManager.bgColor, 1.15) + border.width: 1 + border.color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + + Row { + anchors.centerIn: parent + spacing: 8 + + Text { + text: (Data.Settings.nightLightStartHour || 20).toString().padStart(2, '0') + ":00" + color: Data.ThemeManager.fgColor + font.pixelSize: 16 + font.bold: true + font.family: "monospace" + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + anchors.fill: parent + onClicked: { + startTimePopup.open(); + } + } + } + + // Start Time Popup + Popup { + id: startTimePopup + width: startTimeButton.width + height: 170 + modal: true + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + y: startTimeButton.y - height - 10 + x: startTimeButton.x + dim: false + + background: Rectangle { + color: Data.ThemeManager.bgColor + radius: 12 + border.width: 2 + border.color: Data.ThemeManager.accentColor + } + + Column { + anchors.centerIn: parent + spacing: 12 + + Text { + text: "Select Start Hour" + color: Data.ThemeManager.fgColor + font.pixelSize: 14 + font.bold: true + font.family: "monospace" + anchors.horizontalCenter: parent.horizontalCenter + } + + GridLayout { + columns: 6 + columnSpacing: 6 + rowSpacing: 6 + anchors.horizontalCenter: parent.horizontalCenter + + Repeater { + model: 24 + delegate: Rectangle { + width: 24 + height: 24 + radius: 4 + color: (Data.Settings.nightLightStartHour || 20) === modelData ? Data.ThemeManager.accentColor : Qt.lighter(Data.ThemeManager.bgColor, 1.15) + border.width: 1 + border.color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + + Text { + anchors.centerIn: parent + text: modelData.toString().padStart(2, '0') + color: (Data.Settings.nightLightStartHour || 20) === modelData ? Data.ThemeManager.bgColor : Data.ThemeManager.fgColor + font.pixelSize: 10 + font.bold: true + font.family: "monospace" + } + + MouseArea { + anchors.fill: parent + onClicked: { + Data.Settings.nightLightStartHour = modelData; + startTimePopup.close(); + } + } + } + } + } + } + } + } + + // End Time + Column { + id: endTimeColumn + width: (parent.width - parent.spacing) / 2 + spacing: 8 + + Text { + text: "End Time" + color: Data.ThemeManager.fgColor + font.pixelSize: 14 + font.bold: true + font.family: "monospace" + } + + Text { + text: "Night light turns off" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.7) + font.pixelSize: 12 + font.family: "monospace" + } + + Rectangle { + id: endTimeButton + width: parent.width + height: 40 + radius: 8 + color: Qt.lighter(Data.ThemeManager.bgColor, 1.15) + border.width: 1 + border.color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + + Row { + anchors.centerIn: parent + spacing: 8 + + Text { + text: (Data.Settings.nightLightEndHour || 6).toString().padStart(2, '0') + ":00" + color: Data.ThemeManager.fgColor + font.pixelSize: 16 + font.bold: true + font.family: "monospace" + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + anchors.fill: parent + onClicked: { + endTimePopup.open(); + } + } + } + + // End Time Popup + Popup { + id: endTimePopup + width: endTimeButton.width + height: 170 + modal: true + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + y: endTimeButton.y - height - 10 + x: endTimeButton.x + dim: false + + background: Rectangle { + color: Data.ThemeManager.bgColor + radius: 12 + border.width: 2 + border.color: Data.ThemeManager.accentColor + } + + Column { + anchors.centerIn: parent + spacing: 12 + + Text { + text: "Select End Hour" + color: Data.ThemeManager.fgColor + font.pixelSize: 14 + font.bold: true + font.family: "monospace" + anchors.horizontalCenter: parent.horizontalCenter + } + + GridLayout { + columns: 6 + columnSpacing: 6 + rowSpacing: 6 + anchors.horizontalCenter: parent.horizontalCenter + + Repeater { + model: 24 + delegate: Rectangle { + width: 24 + height: 24 + radius: 4 + color: (Data.Settings.nightLightEndHour || 6) === modelData ? Data.ThemeManager.accentColor : Qt.lighter(Data.ThemeManager.bgColor, 1.15) + border.width: 1 + border.color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + + Text { + anchors.centerIn: parent + text: modelData.toString().padStart(2, '0') + color: (Data.Settings.nightLightEndHour || 6) === modelData ? Data.ThemeManager.bgColor : Data.ThemeManager.fgColor + font.pixelSize: 10 + font.bold: true + font.family: "monospace" + } + + MouseArea { + anchors.fill: parent + onClicked: { + Data.Settings.nightLightEndHour = modelData; + endTimePopup.close(); + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/NotificationSettings.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/NotificationSettings.qml new file mode 100644 index 0000000..bad702a --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/NotificationSettings.qml @@ -0,0 +1,536 @@ +import QtQuick +import QtQuick.Controls +import "root:/Data" as Data + +// Notification settings content +Item { + id: notificationSettings + width: parent.width + height: contentColumn.height + + // Expose the text input focus for parent keyboard management + property bool anyTextInputFocused: appNameInput.activeFocus + + Column { + id: contentColumn + width: parent.width + spacing: 20 + + // Display Time Setting + Column { + width: parent.width + spacing: 12 + + Text { + text: "Display Time" + color: Data.ThemeManager.fgColor + font.pixelSize: 16 + font.bold: true + font.family: "monospace" + } + + Text { + text: "How long notifications stay visible on screen" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.7) + font.pixelSize: 13 + font.family: "monospace" + wrapMode: Text.Wrap + width: parent.width + } + + Row { + spacing: 16 + width: parent.width + + Slider { + id: displayTimeSlider + width: parent.width - timeLabel.width - 16 + height: 30 + from: 2000 + to: 15000 + stepSize: 1000 + value: Data.Settings.displayTime + + onValueChanged: { + Data.Settings.displayTime = value; + } + + background: Rectangle { + width: displayTimeSlider.availableWidth + height: 6 + radius: 3 + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + width: displayTimeSlider.visualPosition * parent.width + height: parent.height + radius: parent.radius + color: Data.ThemeManager.accentColor + } + } + + handle: Rectangle { + x: displayTimeSlider.leftPadding + displayTimeSlider.visualPosition * (displayTimeSlider.availableWidth - width) + y: displayTimeSlider.topPadding + displayTimeSlider.availableHeight / 2 - height / 2 + width: 20 + height: 20 + radius: 10 + color: Data.ThemeManager.accentColor + border.color: Qt.lighter(Data.ThemeManager.accentColor, 1.2) + border.width: 2 + + scale: displayTimeSlider.pressed ? 1.2 : 1.0 + + Behavior on scale { + NumberAnimation { + duration: 150 + } + } + } + } + + Text { + id: timeLabel + text: (displayTimeSlider.value / 1000).toFixed(1) + "s" + color: Data.ThemeManager.fgColor + font.pixelSize: 14 + font.family: "monospace" + anchors.verticalCenter: parent.verticalCenter + width: 40 + } + } + } + + // Max History Items + Column { + width: parent.width + spacing: 12 + + Text { + text: "History Limit" + color: Data.ThemeManager.fgColor + font.pixelSize: 16 + font.bold: true + font.family: "monospace" + } + + Text { + text: "Maximum number of notifications to keep in history" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.7) + font.pixelSize: 13 + font.family: "monospace" + wrapMode: Text.Wrap + width: parent.width + } + + Row { + spacing: 16 + width: parent.width + + Slider { + id: historySlider + width: parent.width - historyLabel.width - 16 + height: 30 + from: 10 + to: 100 + stepSize: 5 + value: Data.Settings.historyLimit + + onValueChanged: { + Data.Settings.historyLimit = value; + } + + background: Rectangle { + width: historySlider.availableWidth + height: 6 + radius: 3 + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + width: historySlider.visualPosition * parent.width + height: parent.height + radius: parent.radius + color: Data.ThemeManager.accentColor + } + } + + handle: Rectangle { + x: historySlider.leftPadding + historySlider.visualPosition * (historySlider.availableWidth - width) + y: historySlider.topPadding + historySlider.availableHeight / 2 - height / 2 + width: 20 + height: 20 + radius: 10 + color: Data.ThemeManager.accentColor + border.color: Qt.lighter(Data.ThemeManager.accentColor, 1.2) + border.width: 2 + + scale: historySlider.pressed ? 1.2 : 1.0 + + Behavior on scale { + NumberAnimation { + duration: 150 + } + } + } + } + + Text { + id: historyLabel + text: historySlider.value + " items" + color: Data.ThemeManager.fgColor + font.pixelSize: 14 + font.family: "monospace" + anchors.verticalCenter: parent.verticalCenter + width: 60 + } + } + } + + // Ignored Apps Setting + Column { + width: parent.width + spacing: 12 + + Text { + text: "Ignored Applications" + color: Data.ThemeManager.fgColor + font.pixelSize: 16 + font.bold: true + font.family: "monospace" + } + + Text { + text: "Applications that won't show notifications" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.7) + font.pixelSize: 13 + font.family: "monospace" + wrapMode: Text.Wrap + width: parent.width + } + + // Current ignored apps list + Rectangle { + width: parent.width + height: Math.max(100, ignoredAppsFlow.height + 16) + radius: 12 + color: Qt.lighter(Data.ThemeManager.bgColor, 1.15) + border.width: 1 + border.color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + + Flow { + id: ignoredAppsFlow + anchors.fill: parent + anchors.margins: 8 + spacing: 6 + + Repeater { + model: Data.Settings.ignoredApps + delegate: Rectangle { + width: appNameText.width + removeButton.width + 16 + height: 28 + radius: 14 + color: Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.15) + border.width: 1 + border.color: Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.3) + + Row { + anchors.centerIn: parent + spacing: 4 + + Text { + id: appNameText + anchors.verticalCenter: parent.verticalCenter + text: modelData + color: Data.ThemeManager.fgColor + font.pixelSize: 12 + font.family: "monospace" + } + + Rectangle { + id: removeButton + width: 18 + height: 18 + radius: 9 + color: removeMouseArea.containsMouse ? Qt.rgba(1, 0.3, 0.3, 0.8) : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.5) + + Behavior on color { + ColorAnimation { + duration: 150 + } + } + + Text { + anchors.centerIn: parent + text: "×" + color: "white" + font.pixelSize: 12 + font.bold: true + } + + MouseArea { + id: removeMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + Data.Settings.removeIgnoredApp(modelData); + } + } + } + } + } + } + + // Add new app button + Rectangle { + width: addAppText.width + 36 + height: 28 + radius: 14 + color: addAppMouseArea.containsMouse ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) : Qt.lighter(Data.ThemeManager.bgColor, 1.2) + border.width: 2 + border.color: Data.ThemeManager.accentColor + + Behavior on color { + ColorAnimation { + duration: 150 + } + } + + Row { + anchors.centerIn: parent + spacing: 6 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "add" + font.family: "Material Symbols Outlined" + font.pixelSize: 14 + color: Data.ThemeManager.accentColor + } + + Text { + id: addAppText + anchors.verticalCenter: parent.verticalCenter + text: "Add App" + color: Data.ThemeManager.accentColor + font.pixelSize: 12 + font.bold: true + font.family: "monospace" + } + } + + MouseArea { + id: addAppMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: addAppPopup.open() + } + } + } + } + + // Quick suggestions + Column { + width: parent.width + spacing: 8 + + Text { + text: "Common Apps" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.7) + font.pixelSize: 12 + font.family: "monospace" + } + + Flow { + width: parent.width + spacing: 6 + + Repeater { + model: ["Discord", "Spotify", "Steam", "Firefox", "Chrome", "VSCode", "Slack"] + delegate: Rectangle { + width: suggestedAppText.width + 16 + height: 24 + radius: 12 + color: suggestionMouseArea.containsMouse ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.1) : "transparent" + border.width: 1 + border.color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + + Text { + id: suggestedAppText + anchors.centerIn: parent + text: modelData + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.7) + font.pixelSize: 11 + font.family: "monospace" + } + + MouseArea { + id: suggestionMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + Data.Settings.addIgnoredApp(modelData); + } + } + } + } + } + } + } + } + + // Add app popup + Popup { + id: addAppPopup + parent: notificationSettings + width: 280 + height: 160 + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + modal: true + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + color: Data.ThemeManager.bgColor + border.color: Data.ThemeManager.accentColor + border.width: 2 + radius: 20 + } + + Column { + anchors.centerIn: parent + spacing: 16 + width: parent.width - 40 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "Add Ignored App" + color: Data.ThemeManager.accentColor + font.pixelSize: 16 + font.bold: true + font.family: "monospace" + } + + Rectangle { + width: parent.width + height: 40 + radius: 20 + color: Qt.lighter(Data.ThemeManager.bgColor, 1.15) + border.width: appNameInput.activeFocus ? 2 : 1 + border.color: appNameInput.activeFocus ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + + Behavior on border.color { + ColorAnimation { + duration: 150 + } + } + + TextInput { + id: appNameInput + anchors.fill: parent + anchors.margins: 12 + color: Data.ThemeManager.fgColor + font.pixelSize: 14 + font.family: "monospace" + selectByMouse: true + clip: true + verticalAlignment: TextInput.AlignVCenter + focus: true + activeFocusOnTab: true + inputMethodHints: Qt.ImhNone + + Keys.onPressed: function (event) { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + addAppButton.clicked(); + event.accepted = true; + } + } + + // Placeholder text implementation + Text { + anchors.fill: parent + anchors.margins: 12 + text: "App name (e.g. Discord)" + color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.5) + font.pixelSize: 14 + font.family: "monospace" + verticalAlignment: Text.AlignVCenter + visible: appNameInput.text === "" + } + } + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: 12 + + Rectangle { + width: 80 + height: 32 + radius: 16 + color: cancelMouseArea.containsMouse ? Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.1) : "transparent" + border.width: 1 + border.color: Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + + Text { + anchors.centerIn: parent + text: "Cancel" + color: Data.ThemeManager.fgColor + font.pixelSize: 12 + font.family: "monospace" + } + + MouseArea { + id: cancelMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + appNameInput.text = ""; + addAppPopup.close(); + } + } + } + + Rectangle { + id: addAppButton + width: 80 + height: 32 + radius: 16 + color: addMouseArea.containsMouse ? Qt.lighter(Data.ThemeManager.accentColor, 1.1) : Data.ThemeManager.accentColor + + signal clicked + onClicked: { + if (appNameInput.text.trim() !== "") { + if (Data.Settings.addIgnoredApp(appNameInput.text.trim())) { + appNameInput.text = ""; + addAppPopup.close(); + } + } + } + + Text { + anchors.centerIn: parent + text: "Add" + color: Data.ThemeManager.bgColor + font.pixelSize: 12 + font.bold: true + font.family: "monospace" + } + + MouseArea { + id: addMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: parent.clicked() + } + } + } + } + + onOpened: { + appNameInput.forceActiveFocus(); + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/SettingsCategory.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/SettingsCategory.qml new file mode 100644 index 0000000..2433788 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/SettingsCategory.qml @@ -0,0 +1,103 @@ +import QtQuick +import QtQuick.Controls +import "root:/Data" as Data + +// Reusable collapsible settings category component +Item { + id: categoryRoot + + property string title: "" + property string icon: "" + property bool expanded: false + property alias content: contentLoader.sourceComponent + + height: headerRect.height + (expanded ? contentLoader.height + 20 : 0) + + Behavior on height { + NumberAnimation { + duration: 250 + easing.type: Easing.OutCubic + } + } + + // Category header + Rectangle { + id: headerRect + width: parent.width + height: 50 + radius: 12 + color: expanded ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.1) : Qt.lighter(Data.ThemeManager.bgColor, 1.15) + border.width: expanded ? 2 : 1 + border.color: expanded ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 16 + spacing: 12 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: categoryRoot.icon + font.family: "Material Symbols Outlined" + font.pixelSize: 20 + color: expanded ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: categoryRoot.title + color: expanded ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor + font.pixelSize: 16 + font.bold: true + font.family: "monospace" + } + } + + // Expand/collapse arrow + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.rightMargin: 16 + text: expanded ? "expand_less" : "expand_more" + font.family: "Material Symbols Outlined" + font.pixelSize: 20 + color: expanded ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor + + Behavior on rotation { + NumberAnimation { + duration: 250 + easing.type: Easing.OutCubic + } + } + } + + MouseArea { + anchors.fill: parent + onClicked: { + categoryRoot.expanded = !categoryRoot.expanded; + } + } + } + + // Category content + Loader { + id: contentLoader + anchors.top: headerRect.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: expanded ? 20 : 0 + anchors.leftMargin: 16 + anchors.rightMargin: 16 + + visible: expanded + opacity: expanded ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 250 + easing.type: Easing.OutCubic + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/SystemSettings.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/SystemSettings.qml new file mode 100644 index 0000000..d5ab6b0 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/SystemSettings.qml @@ -0,0 +1,77 @@ +import QtQuick +import QtQuick.Controls +import "root:/Data" as Data + +// System settings content +Item { + id: systemSettings + width: parent.width + height: contentColumn.height + + // Expose the text input focus for parent keyboard management + property bool anyTextInputFocused: videoPathInput.activeFocus + + Column { + id: contentColumn + width: parent.width + spacing: 20 + + // Video Recording Path + Column { + width: parent.width + spacing: 8 + + Text { + text: "Video Recording Path" + color: Data.ThemeManager.fgColor + font.pixelSize: 16 + font.bold: true + font.family: "monospace" + } + + Rectangle { + width: parent.width + height: 40 + radius: 8 + color: Qt.lighter(Data.ThemeManager.bgColor, 1.15) + border.width: videoPathInput.activeFocus ? 2 : 1 + border.color: videoPathInput.activeFocus ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + + Behavior on border.color { + ColorAnimation { + duration: 150 + } + } + + TextInput { + id: videoPathInput + anchors.fill: parent + anchors.margins: 12 + text: Data.Settings.videoPath + color: Data.ThemeManager.fgColor + font.pixelSize: 14 + font.family: "monospace" + selectByMouse: true + clip: true + verticalAlignment: TextInput.AlignVCenter + focus: true + activeFocusOnTab: true + inputMethodHints: Qt.ImhNone + + onTextChanged: { + Data.Settings.videoPath = text; + } + + Keys.onPressed: function (event) {} + } + + MouseArea { + anchors.fill: parent + onClicked: { + videoPathInput.forceActiveFocus(); + } + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/WeatherSettings.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/WeatherSettings.qml new file mode 100644 index 0000000..5f944a0 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/settings/WeatherSettings.qml @@ -0,0 +1,207 @@ +import QtQuick +import QtQuick.Controls +import "root:/Data" as Data + +// Weather settings content +Item { + id: weatherSettings + width: parent.width + height: contentColumn.height + + required property var shell + + // Expose the text input focus for parent keyboard management + property bool anyTextInputFocused: locationInput.activeFocus + + Column { + id: contentColumn + width: parent.width + spacing: 20 + + // Location Setting + Column { + width: parent.width + spacing: 8 + + Text { + text: "Location" + color: Data.ThemeManager.fgColor + font.pixelSize: 16 + font.bold: true + font.family: "monospace" + } + + Row { + width: parent.width + spacing: 12 + + Rectangle { + width: parent.width - applyButton.width - 12 + height: 40 + radius: 8 + color: Qt.lighter(Data.ThemeManager.bgColor, 1.15) + border.width: locationInput.activeFocus ? 2 : 1 + border.color: locationInput.activeFocus ? Data.ThemeManager.accentColor : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + + Behavior on border.color { + ColorAnimation { + duration: 150 + } + } + + TextInput { + id: locationInput + anchors.fill: parent + anchors.margins: 12 + text: Data.Settings.weatherLocation + color: Data.ThemeManager.fgColor + font.pixelSize: 14 + font.family: "monospace" + selectByMouse: true + clip: true + verticalAlignment: TextInput.AlignVCenter + focus: true + activeFocusOnTab: true + inputMethodHints: Qt.ImhNone + + Keys.onPressed: function (event) { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + applyButton.clicked(); + event.accepted = true; + } + } + + MouseArea { + anchors.fill: parent + onClicked: { + locationInput.forceActiveFocus(); + } + } + } + } + + Rectangle { + id: applyButton + width: 80 + height: 40 + radius: 8 + color: applyMouseArea.containsMouse ? Qt.lighter(Data.ThemeManager.accentColor, 1.1) : Data.ThemeManager.accentColor + + signal clicked + onClicked: { + Data.Settings.weatherLocation = locationInput.text; + weatherSettings.shell.weatherService.loadWeather(); + } + + Text { + anchors.centerIn: parent + text: "Apply" + color: Data.ThemeManager.bgColor + font.pixelSize: 12 + font.bold: true + font.family: "monospace" + } + + MouseArea { + id: applyMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: parent.clicked() + } + } + } + } + + // Temperature Units + Column { + width: parent.width + spacing: 12 + + Text { + text: "Temperature Units" + color: Data.ThemeManager.fgColor + font.pixelSize: 16 + font.bold: true + font.family: "monospace" + } + + Row { + spacing: 12 + + Rectangle { + width: 80 + height: 35 + radius: 18 + color: !Data.Settings.useFahrenheit ? Data.ThemeManager.accentColor : Qt.lighter(Data.ThemeManager.bgColor, 1.15) + border.width: 1 + border.color: Data.ThemeManager.accentColor + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + + Text { + anchors.centerIn: parent + text: "°C" + color: !Data.Settings.useFahrenheit ? Data.ThemeManager.bgColor : Data.ThemeManager.fgColor + font.pixelSize: 14 + font.bold: true + font.family: "monospace" + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + } + + MouseArea { + anchors.fill: parent + onClicked: { + Data.Settings.useFahrenheit = false; + } + } + } + + Rectangle { + width: 80 + height: 35 + radius: 18 + color: Data.Settings.useFahrenheit ? Data.ThemeManager.accentColor : Qt.lighter(Data.ThemeManager.bgColor, 1.15) + border.width: 1 + border.color: Data.ThemeManager.accentColor + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + + Text { + anchors.centerIn: parent + text: "°F" + color: Data.Settings.useFahrenheit ? Data.ThemeManager.bgColor : Data.ThemeManager.fgColor + font.pixelSize: 14 + font.bold: true + font.family: "monospace" + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + } + + MouseArea { + anchors.fill: parent + onClicked: { + Data.Settings.useFahrenheit = true; + } + } + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/system/NotificationBar.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/system/NotificationBar.qml new file mode 100644 index 0000000..38722dc --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/system/NotificationBar.qml @@ -0,0 +1,92 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "root:/Data" as Data + +// Dual-button notification and clipboard history toggle bar +Rectangle { + id: root + width: 42 + color: Qt.darker(Data.ThemeManager.bgColor, 1.15) + radius: 12 + z: 2 // Above notification history overlay + + required property bool notificationHistoryVisible + required property bool clipboardHistoryVisible + required property var notificationHistory + signal notificationToggleRequested + signal clipboardToggleRequested + + // Combined hover state for parent component tracking + property bool containsMouse: notifButtonMouseArea.containsMouse || clipButtonMouseArea.containsMouse + + property real buttonHeight: 38 + height: buttonHeight * 2 + 4 // Two buttons with spacing + + Item { + anchors.fill: parent + anchors.margins: 2 + + // Notifications toggle button (top half) + Rectangle { + id: notificationPill + anchors { + top: parent.top + left: parent.left + right: parent.right + bottom: parent.verticalCenter + bottomMargin: 2 // Half of button spacing + } + radius: 12 + color: notifButtonMouseArea.containsMouse || root.notificationHistoryVisible ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.05) + border.color: notifButtonMouseArea.containsMouse || root.notificationHistoryVisible ? Data.ThemeManager.accentColor : "transparent" + border.width: 1 + + MouseArea { + id: notifButtonMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: root.notificationToggleRequested() + } + + Label { + anchors.centerIn: parent + text: "notifications" + font.family: "Material Symbols Outlined" + font.pixelSize: 16 + color: notifButtonMouseArea.containsMouse || root.notificationHistoryVisible ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor + } + } + + // Clipboard toggle button (bottom half) + Rectangle { + id: clipboardPill + anchors { + top: parent.verticalCenter + left: parent.left + right: parent.right + bottom: parent.bottom + topMargin: 2 // Half of button spacing + } + radius: 12 + color: clipButtonMouseArea.containsMouse || root.clipboardHistoryVisible ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.05) + border.color: clipButtonMouseArea.containsMouse || root.clipboardHistoryVisible ? Data.ThemeManager.accentColor : "transparent" + border.width: 1 + + MouseArea { + id: clipButtonMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: root.clipboardToggleRequested() + } + + Label { + anchors.centerIn: parent + text: "content_paste" + font.family: "Material Symbols Outlined" + font.pixelSize: 16 + color: clipButtonMouseArea.containsMouse || root.clipboardHistoryVisible ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/system/TopPanelTrigger.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/system/TopPanelTrigger.qml new file mode 100644 index 0000000..800bae5 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/system/TopPanelTrigger.qml @@ -0,0 +1,56 @@ +import QtQuick + +// Top-edge hover trigger +Rectangle { + id: root + width: 360 + height: 1 + color: "red" + anchors.top: parent.top + + signal triggered + + // Hover detection area at screen top edge + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + + property bool isHovered: containsMouse + + // Timer coordination + onIsHoveredChanged: { + if (isHovered) { + showTimer.start(); + hideTimer.stop(); + } else { + hideTimer.start(); + showTimer.stop(); + } + } + + onEntered: hideTimer.stop() + } + + // Delayed show trigger to prevent accidental activation + Timer { + id: showTimer + interval: 200 + onTriggered: root.triggered() + } + + // Hide delay timer (controlled by parent) + Timer { + id: hideTimer + interval: 500 + } + + // Public interface + readonly property alias containsMouse: mouseArea.containsMouse + function stopHideTimer() { + hideTimer.stop(); + } + function startHideTimer() { + hideTimer.start(); + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/system/TrayMenu.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/system/TrayMenu.qml new file mode 100644 index 0000000..2183e40 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/system/TrayMenu.qml @@ -0,0 +1,230 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import "root:/Data" as Data + +// System tray context menu +Rectangle { + id: root + width: parent.width + height: visible ? calculatedHeight : 0 + visible: false + enabled: visible + clip: true + color: Data.ThemeManager.bgColor + border.color: Data.ThemeManager.accentColor + border.width: 2 + radius: 20 + + required property var menu + required property var systemTrayY + required property var systemTrayHeight + + property bool containsMouse: trayMenuMouseArea.containsMouse + property bool menuJustOpened: false + property point triggerPoint: Qt.point(0, 0) + property Item originalParent + property int totalCount: opener.children ? opener.children.values.length : 0 + + signal hideRequested + + MouseArea { + id: trayMenuMouseArea + anchors.fill: parent + hoverEnabled: true + preventStealing: true + propagateComposedEvents: false + } + + onVisibleChanged: { + if (visible) { + menuJustOpened = true; + Qt.callLater(function () { + menuJustOpened = false; + }); + } + } + + function toggle() { + visible = !visible; + if (visible) { + menuJustOpened = true; + Qt.callLater(function () { + menuJustOpened = false; + }); + } + } + + function show(point, parentItem) { + visible = true; + menuJustOpened = true; + Qt.callLater(function () { + menuJustOpened = false; + }); + } + + function hide() { + visible = false; + menuJustOpened = false; + // Small delay before notifying hide to prevent control panel flicker + Qt.callLater(function () { + hideRequested(); + }); + } + + // Smart positioning to avoid screen edges + y: { + var preferredY = systemTrayY + systemTrayHeight + 10; + var availableSpace = parent.height - preferredY - 20; + if (calculatedHeight > availableSpace) { + return systemTrayY - height - 10; + } + return preferredY; + } + + Behavior on height { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + + // Dynamic height calculation based on menu item count and types + property int calculatedHeight: { + if (totalCount === 0) + return 40; + var separatorCount = 0; + var regularItemCount = 0; + + if (opener.children && opener.children.values) { + for (var i = 0; i < opener.children.values.length; i++) { + if (opener.children.values[i].isSeparator) { + separatorCount++; + } else { + regularItemCount++; + } + } + } + + // Calculate total height: separators + grid rows + margins + var separatorHeight = separatorCount * 12; + var regularItemRows = Math.ceil(regularItemCount / 2); + var regularItemHeight = regularItemRows * 32; + return Math.max(80, 35 + separatorHeight + regularItemHeight + 40); + } + + // Menu opener handles the native menu integration + QsMenuOpener { + id: opener + menu: root.menu + } + + // Grid layout for menu items (2 columns) + GridView { + id: gridView + anchors.fill: parent + anchors.margins: 20 + cellWidth: width / 2 + cellHeight: 32 + interactive: false + flow: GridView.FlowLeftToRight + layoutDirection: Qt.LeftToRight + + model: ScriptModel { + values: opener.children ? [...opener.children.values] : [] + } + + delegate: Item { + id: entry + required property var modelData + required property int index + + width: gridView.cellWidth - 4 + height: modelData.isSeparator ? 12 : 30 + + // Separator line + Rectangle { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + anchors.topMargin: 4 + anchors.bottomMargin: 4 + visible: modelData.isSeparator + color: "transparent" + + Rectangle { + anchors.centerIn: parent + width: parent.width * 0.8 + height: 1 + color: Qt.darker(Data.ThemeManager.accentColor, 1.5) + opacity: 0.6 + } + } + + // Regular menu item + Rectangle { + id: itemBackground + anchors.fill: parent + anchors.margins: 2 + visible: !modelData.isSeparator + color: "transparent" + radius: 6 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 6 + + Image { + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + source: modelData?.icon ?? "" + visible: (modelData?.icon ?? "") !== "" + fillMode: Image.PreserveAspectFit + } + + Text { + Layout.fillWidth: true + color: mouseArea.containsMouse ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor + text: modelData?.text ?? "" + font.pixelSize: 11 + font.family: "monospace" + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + maximumLineCount: 1 + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + enabled: (modelData?.enabled ?? true) && root.visible && !modelData.isSeparator + + onEntered: itemBackground.color = Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.15) + onExited: itemBackground.color = "transparent" + onClicked: { + modelData.triggered(); + root.hide(); + } + } + } + } + } + + // Empty state indicator + Item { + anchors.centerIn: gridView + visible: gridView.count === 0 + + Label { + anchors.centerIn: parent + text: "No tray items available" + color: Qt.darker(Data.ThemeManager.fgColor, 2) + font.pixelSize: 14 + font.family: "monospace" + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/CalendarButton.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/CalendarButton.qml new file mode 100644 index 0000000..752b2c0 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/CalendarButton.qml @@ -0,0 +1,75 @@ +import QtQuick +import QtQuick.Controls +import "root:/Data" as Data + +// Calendar button +Rectangle { + id: calendarButton + width: 40 + height: 80 + color: Qt.darker(Data.ThemeManager.bgColor, 1.15) + radius: 20 + + property bool containsMouse: calendarMouseArea.containsMouse + property bool calendarVisible: false + property var calendarPopup: null + property var shell: null // Shell reference from parent + + signal entered + signal exited + + // Hover state management + onContainsMouseChanged: { + if (containsMouse) { + entered(); + } else { + exited(); + } + } + + MouseArea { + id: calendarMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + toggleCalendar(); + } + } + + // Calendar icon + Label { + anchors.centerIn: parent + text: "calendar_month" + font.pixelSize: 24 + font.family: "Material Symbols Outlined" + color: calendarButton.containsMouse || calendarButton.calendarVisible ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor + } + + // Toggle calendar popup + function toggleCalendar() { + if (!calendarPopup) { + var component = Qt.createComponent("root:/Widgets/Calendar/CalendarPopup.qml"); + if (component.status === Component.Ready) { + calendarPopup = component.createObject(calendarButton.parent, { + "targetX": calendarButton.x + calendarButton.width + 10, + "shell": calendarButton.shell + }); + } else if (component.status === Component.Error) { + console.log("Error loading calendar:", component.errorString()); + return; + } + } + + if (calendarPopup) { + calendarVisible = !calendarVisible; + calendarPopup.setClickMode(calendarVisible); + } + } + + function hideCalendar() { + if (calendarPopup) { + calendarVisible = false; + calendarPopup.setClickMode(false); + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/NightLight.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/NightLight.qml new file mode 100644 index 0000000..72f88bc --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/NightLight.qml @@ -0,0 +1,309 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Wayland +import "root:/Data" as Data + +// Night light widget with pure Qt overlay (no external dependencies) +Rectangle { + id: root + property var shell: null + color: Qt.darker(Data.ThemeManager.bgColor, 1.15) + radius: 20 + + property bool containsMouse: nightLightMouseArea.containsMouse + property bool isActive: Data.Settings.nightLightEnabled + property real warmth: Data.Settings.nightLightWarmth // 0=no filter, 1=very warm (0-1 scale) + property real strength: isActive ? warmth : 0 + property bool autoSchedulerActive: false // Flag to prevent manual override during auto changes + + signal entered + signal exited + + // Night light overlay window + property var overlayWindow: null + + // Hover state management for parent components + onContainsMouseChanged: { + if (containsMouse) { + entered(); + } else { + exited(); + } + } + + // Background with warm tint when active + Rectangle { + anchors.fill: parent + radius: parent.radius + color: isActive ? Qt.rgba(1.0, 0.6, 0.2, 0.15) : "transparent" + + Behavior on color { + ColorAnimation { + duration: 300 + } + } + } + + MouseArea { + id: nightLightMouseArea + anchors.fill: parent + hoverEnabled: true + + // Right-click to cycle through warmth levels + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: function (mouse) { + if (mouse.button === Qt.RightButton) { + cycleWarmth(); + } else { + toggleNightLight(); + } + } + } + + // Night light icon with dynamic color + Text { + anchors.centerIn: parent + text: isActive ? "light_mode" : "dark_mode" + font.pixelSize: 24 + font.family: "Material Symbols Outlined" + color: isActive ? Qt.rgba(1.0, 0.8 - strength * 0.3, 0.4 - strength * 0.2, 1.0) : // Warm orange when active + (containsMouse ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor) + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + } + + // Warmth indicator dots + Row { + anchors.bottom: parent.bottom + anchors.bottomMargin: 6 + anchors.horizontalCenter: parent.horizontalCenter + spacing: 3 + visible: isActive && containsMouse + + Repeater { + model: 3 + delegate: Rectangle { + width: 4 + height: 4 + radius: 2 + color: index < Math.ceil(warmth * 3) ? Qt.rgba(1.0, 0.7 - index * 0.2, 0.3, 0.8) : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.3) + + Behavior on color { + ColorAnimation { + duration: 150 + } + } + } + } + } + + // Watch for settings changes + Connections { + target: Data.Settings + function onNightLightEnabledChanged() { + if (Data.Settings.nightLightEnabled) { + createOverlay(); + } else { + removeOverlay(); + } + + // Set manual override flag if this wasn't an automatic change + if (!autoSchedulerActive) { + Data.Settings.nightLightManualOverride = true; + Data.Settings.nightLightManuallyEnabled = Data.Settings.nightLightEnabled; + console.log("Manual night light change detected - override enabled, manually set to:", Data.Settings.nightLightEnabled); + } + } + function onNightLightWarmthChanged() { + updateOverlay(); + } + } + + // Functions to control night light + function toggleNightLight() { + Data.Settings.nightLightEnabled = !Data.Settings.nightLightEnabled; + } + + function cycleWarmth() { + // Cycle through warmth levels: 0.2 -> 0.4 -> 0.6 -> 1.0 -> 0.2 + var newWarmth = warmth >= 1.0 ? 0.2 : (warmth >= 0.6 ? 1.0 : warmth + 0.2); + Data.Settings.nightLightWarmth = newWarmth; + } + + function createOverlay() { + if (overlayWindow) + return; + + var qmlString = ` +import QtQuick +import Quickshell +import Quickshell.Wayland + +PanelWindow { + id: nightLightOverlay + screen: Quickshell.primaryScreen || Quickshell.screens[0] + + anchors.top: true + anchors.left: true + anchors.right: true + anchors.bottom: true + + color: "transparent" + + WlrLayershell.layer: WlrLayershell.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + WlrLayershell.namespace: "quickshell-nightlight" + exclusiveZone: 0 + + // Click-through overlay + mask: Region {} + + Rectangle { + id: overlayRect + anchors.fill: parent + color: "transparent" // Initial color, will be set by parent + + // Smooth transitions when warmth changes + Behavior on color { + ColorAnimation { duration: 300 } + } + } + + // Function to update overlay color + function updateColor(newWarmth) { + overlayRect.color = Qt.rgba(1.0, 0.8 - newWarmth * 0.4, 0.3 - newWarmth * 0.25, 0.1 + newWarmth * 0.2) + } +} + ` + + try { + overlayWindow = Qt.createQmlObject(qmlString, root); + // Set initial color + updateOverlay(); + } catch (e) { + console.error("Failed to create night light overlay:", e); + } + } + + function updateOverlay() { + if (overlayWindow && overlayWindow.updateColor) { + overlayWindow.updateColor(warmth); + } + } + + function removeOverlay() { + if (overlayWindow) { + overlayWindow.destroy(); + overlayWindow = null; + } + } + + // Preset warmth levels for easy access + function setLow() { + Data.Settings.nightLightWarmth = 0.2; + } // Light warmth + function setMedium() { + Data.Settings.nightLightWarmth = 0.4; + } // Medium warmth + function setHigh() { + Data.Settings.nightLightWarmth = 0.6; + } // High warmth + function setMax() { + Data.Settings.nightLightWarmth = 1.0; + } // Maximum warmth + + // Auto-enable based on time (basic sunset/sunrise simulation) + Timer { + interval: 60000 // Check every minute + running: true + repeat: true + onTriggered: checkAutoEnable() + } + + function checkAutoEnable() { + if (!Data.Settings.nightLightAuto) + return; + var now = new Date(); + var hour = now.getHours(); + var minute = now.getMinutes(); + var startHour = Data.Settings.nightLightStartHour || 20; + var endHour = Data.Settings.nightLightEndHour || 6; + + // Handle overnight schedules (e.g., 20:00 to 6:00) + var shouldBeActive = false; + if (startHour > endHour) { + // Overnight: active from startHour onwards OR before endHour + shouldBeActive = (hour >= startHour || hour < endHour); + } else if (startHour < endHour) { + // Same day: active between startHour and endHour + shouldBeActive = (hour >= startHour && hour < endHour); + } else { + // startHour === endHour: never auto-enable + shouldBeActive = false; + } + + // Debug logging + console.log(`Night Light Auto Check: ${hour}:${minute.toString().padStart(2, '0')} - Should be active: ${shouldBeActive}, Currently active: ${Data.Settings.nightLightEnabled}, Manual override: ${Data.Settings.nightLightManualOverride}`); + + // Smart override logic - only block conflicting actions + if (Data.Settings.nightLightManualOverride) { + // If user manually enabled, allow auto-disable but block auto-enable + if (Data.Settings.nightLightManuallyEnabled && !shouldBeActive && Data.Settings.nightLightEnabled) { + console.log("Auto-disabling night light (respecting schedule after manual enable)"); + autoSchedulerActive = true; + Data.Settings.nightLightEnabled = false; + Data.Settings.nightLightManualOverride = false; // Reset after respecting schedule + autoSchedulerActive = false; + return; + } else + // If user manually disabled, block auto-enable until next cycle + if (!Data.Settings.nightLightManuallyEnabled && shouldBeActive && !Data.Settings.nightLightEnabled) { + // Check if this is the start of a new schedule cycle + var isNewCycle = (hour === startHour && minute === 0); + if (isNewCycle) { + console.log("New schedule cycle starting - resetting manual override"); + Data.Settings.nightLightManualOverride = false; + } else { + console.log("Manual disable override active - skipping auto-enable"); + return; + } + } else + // Other cases - reset override and continue + { + Data.Settings.nightLightManualOverride = false; + } + } + + // Auto-enable when schedule starts + if (shouldBeActive && !Data.Settings.nightLightEnabled) { + console.log("Auto-enabling night light"); + autoSchedulerActive = true; + Data.Settings.nightLightEnabled = true; + autoSchedulerActive = false; + } else + // Auto-disable when schedule ends + if (!shouldBeActive && Data.Settings.nightLightEnabled) { + console.log("Auto-disabling night light"); + autoSchedulerActive = true; + Data.Settings.nightLightEnabled = false; + autoSchedulerActive = false; + } + } + + // Cleanup on destruction + Component.onDestruction: { + removeOverlay(); + } + + // Initialize overlay state based on settings + Component.onCompleted: { + if (Data.Settings.nightLightEnabled) { + createOverlay(); + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/RecordingButton.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/RecordingButton.qml new file mode 100644 index 0000000..ac07383 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/RecordingButton.qml @@ -0,0 +1,66 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import "root:/Data" as Data + +// Screen recording toggle button +Rectangle { + id: root + required property var shell + required property bool isRecording + radius: 20 + + signal recordingRequested + signal stopRecordingRequested + signal mouseChanged(bool containsMouse) + + // Dynamic color: accent when recording/hovered, gray otherwise + color: isRecording ? Data.ThemeManager.accentColor : (mouseArea.containsMouse ? Data.ThemeManager.accentColor : Qt.darker(Data.ThemeManager.bgColor, 1.15)) + + property bool isHovered: mouseArea.containsMouse + readonly property alias containsMouse: mouseArea.containsMouse + + // Button content with icon and text + RowLayout { + anchors.centerIn: parent + spacing: 10 + + // Recording state icon + Text { + text: isRecording ? "stop_circle" : "radio_button_unchecked" + font.family: "Material Symbols Outlined" + font.pixelSize: 16 + color: isRecording || mouseArea.containsMouse ? "#ffffff" : Data.ThemeManager.fgColor + + Layout.alignment: Qt.AlignVCenter + } + + // Recording state label + Label { + text: isRecording ? "Stop Recording" : "Start Recording" + font.family: "monospace" + font.pixelSize: 13 + font.weight: Font.Medium + color: isRecording || mouseArea.containsMouse ? "#ffffff" : Data.ThemeManager.fgColor + + Layout.alignment: Qt.AlignVCenter + } + } + + // Click handling and hover detection + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + + onContainsMouseChanged: root.mouseChanged(containsMouse) + + onClicked: { + if (isRecording) { + root.stopRecordingRequested(); + } else { + root.recordingRequested(); + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/ThemeToggle.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/ThemeToggle.qml new file mode 100644 index 0000000..b7ecce8 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/ThemeToggle.qml @@ -0,0 +1,45 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "root:/Data" as Data + +// Simple theme toggle button with hover feedback +Rectangle { + id: root + property var shell: null + color: Qt.darker(Data.ThemeManager.bgColor, 1.15) + radius: 20 + + property bool containsMouse: themeMouseArea.containsMouse + property bool menuJustOpened: false + + signal entered + signal exited + + // Hover state management for parent components + onContainsMouseChanged: { + if (containsMouse) { + entered(); + } else if (!menuJustOpened) { + exited(); + } + } + + MouseArea { + id: themeMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + Data.ThemeManager.toggleTheme(); + } + } + + // Theme toggle icon with color feedback + Label { + anchors.centerIn: parent + text: "contrast" + font.pixelSize: 24 + font.family: "Material Symbols Outlined" + color: containsMouse ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/UserProfile.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/UserProfile.qml new file mode 100644 index 0000000..1a3d091 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/UserProfile.qml @@ -0,0 +1,239 @@ +import Quickshell.Io +import QtQuick +import QtQuick.Controls +import Qt5Compat.GraphicalEffects +import "root:/Data/" as Data + +// User profile card +Rectangle { + id: root + required property var shell + property url avatarSource: Data.Settings.avatarSource + property string userName: "" // will be set by process output + property string userInfo: "" // will hold uptime string + + property bool isActive: false + property bool isHovered: false // track hover state + + radius: 20 + width: 220 + height: 80 + + // Dynamic color based on hover and active states + color: { + if (isActive) { + return isHovered ? Qt.lighter(Data.ThemeManager.accentColor, 1.1) : Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.3); + } else { + return isHovered ? Qt.lighter(Data.ThemeManager.accentColor, 1.2) : Qt.lighter(Data.ThemeManager.bgColor, 1.15); + } + } + + border.width: isActive ? 2 : 1 + border.color: isActive ? Data.ThemeManager.accentColor : Qt.lighter(Data.ThemeManager.bgColor, 1.3) + + Row { + anchors.fill: parent + anchors.margins: 14 + spacing: 12 + anchors.verticalCenter: parent.verticalCenter + + // Avatar + Rectangle { + id: avatarCircle + width: 52 + height: 52 + radius: 20 + clip: true + border.color: Data.ThemeManager.accentColor + border.width: 3 + color: "transparent" + + Image { + id: avatarImage + anchors.fill: parent + anchors.margins: 2 + source: Data.Settings.avatarSource + fillMode: Image.PreserveAspectCrop + cache: false + visible: false // Hidden for masking + asynchronous: true + sourceSize.width: 48 // Memory optimization + sourceSize.height: 48 + } + + // Apply circular mask to avatar + OpacityMask { + anchors.fill: avatarImage + source: avatarImage + cached: true // Cache to reduce ShaderEffect issues + maskSource: Rectangle { + width: avatarImage.width + height: avatarImage.height + radius: 18 // Proportional to parent radius + visible: false + } + } + } + + // User information text + Column { + spacing: 4 + anchors.verticalCenter: parent.verticalCenter + width: parent.width - avatarCircle.width - gifContainer.width - parent.spacing * 2 + + Text { + width: parent.width + text: root.userName === "" ? "Loading..." : root.userName + font.family: "monospace" + font.pixelSize: 16 + font.bold: true + color: isHovered || root.isActive ? "#ffffff" : Data.ThemeManager.accentColor + elide: Text.ElideRight + maximumLineCount: 1 + } + + Text { + width: parent.width + text: root.userInfo === "" ? "Loading uptime..." : root.userInfo + font.family: "monospace" + font.pixelSize: 11 + font.bold: true + color: isHovered || root.isActive ? "#cccccc" : Qt.lighter(Data.ThemeManager.accentColor, 1.6) + elide: Text.ElideRight + maximumLineCount: 1 + } + } + + // Animated GIF with rounded corners + Rectangle { + id: gifContainer + width: 80 + height: 80 + radius: 12 + color: "transparent" + anchors.verticalCenter: parent.verticalCenter + + AnimatedImage { + id: animatedImage + source: "root:/Assets/UserProfile.gif" + anchors.fill: parent + fillMode: Image.PreserveAspectFit + playing: true + cache: false + speed: 1.0 + asynchronous: true + } + + // Apply rounded corner mask to GIF + layer.enabled: true + layer.effect: OpacityMask { + cached: true // Cache to reduce ShaderEffect issues + maskSource: Rectangle { + width: gifContainer.width + height: gifContainer.height + radius: gifContainer.radius + visible: false + } + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: root.isHovered = true + onExited: root.isHovered = false + } + + // Get current username + Process { + id: usernameProcess + running: true // Always run to get username + command: ["sh", "-c", "whoami"] + + stdout: SplitParser { + splitMarker: "\n" + onRead: data => { + const line = data.trim(); + if (line.length > 0) { + root.userName = line.charAt(0).toUpperCase() + line.slice(1); + } + } + } + } + + // Get system uptime with parsing for readable format + Process { + id: uptimeProcess + running: false + command: ["sh", "-c", "uptime"] // Use basic uptime command + + stdout: SplitParser { + splitMarker: "\n" + onRead: data => { + const line = data.trim(); + if (line.length > 0) { + // Parse uptime output: " 10:30:00 up 1:23, 2 users, load average: 0.08, 0.02, 0.01" + const match = line.match(/up\s+(.+?),\s+\d+\s+user/); + if (match && match[1]) { + root.userInfo = "Up: " + match[1].trim(); + } else { + // Fallback parsing for different uptime formats + const upIndex = line.indexOf("up "); + if (upIndex !== -1) { + const afterUp = line.substring(upIndex + 3); + const commaIndex = afterUp.indexOf(","); + if (commaIndex !== -1) { + root.userInfo = "Up: " + afterUp.substring(0, commaIndex).trim(); + } else { + root.userInfo = "Up: " + afterUp.trim(); + } + } else { + root.userInfo = "Uptime unknown"; + } + } + } else { + root.userInfo = "Uptime unknown"; + } + } + } + + stderr: SplitParser { + splitMarker: "\n" + onRead: data => { + console.log("Uptime error:", data); + root.userInfo = "Uptime error"; + } + } + } + + // Update uptime every 5 minutes + Timer { + id: uptimeTimer + interval: 300000 // Update every 5 minutes + running: true // Always run the uptime timer + repeat: true + onTriggered: { + uptimeProcess.running = false; + uptimeProcess.running = true; + } + } + + Component.onCompleted: { + uptimeProcess.running = true; // Start uptime process on component load + } + + Component.onDestruction: { + if (usernameProcess.running) { + usernameProcess.running = false; + } + if (uptimeProcess.running) { + uptimeProcess.running = false; + } + if (uptimeTimer.running) { + uptimeTimer.running = false; + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/WeatherDisplay.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/WeatherDisplay.qml new file mode 100644 index 0000000..f933ba0 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/components/widgets/WeatherDisplay.qml @@ -0,0 +1,383 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "root:/Data" as Data + +// Weather display widget +Rectangle { + id: root + required property var shell + color: Qt.darker(Data.ThemeManager.bgColor, 1.15) + radius: 20 + + property bool containsMouse: weatherMouseArea.containsMouse || (forecastPopup.visible && forecastPopup.containsMouse) + property bool menuJustOpened: false + + signal entered + signal exited + + // Hover state management for parent components + onContainsMouseChanged: { + if (containsMouse) { + entered(); + } else if (!menuJustOpened && !forecastPopup.visible) { + exited(); + } + } + + // Maps WMO weather condition codes and text descriptions to Material Design icons + function getWeatherIcon(condition) { + if (!condition) + return "light_mode"; + + const c = condition.toString(); + + // WMO weather interpretation codes to Material Design icons + const iconMap = { + "0": "light_mode" // Clear sky + , + "1": "light_mode" // Mainly clear + , + "2": "cloud" // Partly cloudy + , + "3": "cloud" // Overcast + , + "45": "foggy" // Fog + , + "48": "foggy" // Depositing rime fog + , + "51": "water_drop" // Light drizzle + , + "53": "water_drop" // Moderate drizzle + , + "55": "water_drop" // Dense drizzle + , + "61": "water_drop" // Slight rain + , + "63": "water_drop" // Moderate rain + , + "65": "water_drop" // Heavy rain + , + "71": "ac_unit" // Slight snow + , + "73": "ac_unit" // Moderate snow + , + "75": "ac_unit" // Heavy snow + , + "80": "water_drop" // Slight rain showers + , + "81": "water_drop" // Moderate rain showers + , + "82": "water_drop" // Violent rain showers + , + "95": "thunderstorm" // Thunderstorm + , + "96": "thunderstorm" // Thunderstorm with light hail + , + "99": "thunderstorm" // Thunderstorm with heavy hail + }; + + if (iconMap[c]) + return iconMap[c]; + + // Fallback text matching for non-WMO weather APIs + const textMap = { + "clear sky": "light_mode", + "mainly clear": "light_mode", + "partly cloudy": "cloud", + "overcast": "cloud", + "fog": "foggy", + "drizzle": "water_drop", + "rain": "water_drop", + "snow": "ac_unit", + "thunderstorm": "thunderstorm" + }; + + const lower = condition.toLowerCase(); + for (let key in textMap) { + if (lower.includes(key)) + return textMap[key]; + } + + return "help"; // Unknown condition fallback + } + + // Hover trigger for forecast popup + MouseArea { + id: weatherMouseArea + anchors.fill: parent + hoverEnabled: true + onEntered: { + menuJustOpened = true; + forecastPopup.open(); + Qt.callLater(() => menuJustOpened = false); + } + onExited: { + if (!forecastPopup.containsMouse && !menuJustOpened) { + forecastPopup.close(); + } + } + } + + // Compact weather display (icon and temperature) + RowLayout { + id: weatherLayout + anchors.centerIn: parent + spacing: 8 + + ColumnLayout { + spacing: 2 + Layout.alignment: Qt.AlignVCenter + + // Weather condition icon + Label { + text: { + if (shell.weatherLoading) + return "refresh"; + if (!shell.weatherData) + return "help"; + return root.getWeatherIcon(shell.weatherData.currentCondition); + } + font.pixelSize: 28 + font.family: "Material Symbols Outlined" + color: Data.ThemeManager.accentColor + Layout.alignment: Qt.AlignHCenter + } + + // Current temperature + Label { + text: { + if (shell.weatherLoading) + return "Loading..."; + if (!shell.weatherData) + return "No weather data"; + return shell.weatherData.currentTemp; + } + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 20 + font.bold: true + Layout.alignment: Qt.AlignHCenter + } + } + } + + // Forecast popup + Popup { + id: forecastPopup + y: parent.height + 28 + x: Math.min(0, parent.width - width) + width: 300 + height: 226 + padding: 12 + background: Rectangle { + color: Qt.darker(Data.ThemeManager.bgColor, 1.15) + radius: 20 + border.width: 1 + border.color: Qt.lighter(Data.ThemeManager.bgColor, 1.3) + } + + property bool containsMouse: forecastMouseArea.containsMouse + + onVisibleChanged: { + if (visible) { + entered(); + } else if (!weatherMouseArea.containsMouse && !menuJustOpened) { + exited(); + } + } + + // Hover area for popup persistence + MouseArea { + id: forecastMouseArea + anchors.fill: parent + hoverEnabled: true + onExited: { + if (!weatherMouseArea.containsMouse && !menuJustOpened) { + forecastPopup.close(); + } + } + } + + ColumnLayout { + id: forecastColumn + anchors.fill: parent + anchors.margins: 10 + spacing: 8 + + // Current weather detailed view + RowLayout { + Layout.fillWidth: true + spacing: 12 + + // Large weather icon + Label { + text: shell.weatherData ? root.getWeatherIcon(shell.weatherData.currentCondition) : "" + font.pixelSize: 48 + font.family: "Material Symbols Outlined" + color: Data.ThemeManager.accentColor + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + // Weather condition description + Label { + text: shell.weatherData ? shell.weatherData.currentCondition : "" + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 14 + font.bold: true + Layout.fillWidth: true + elide: Text.ElideRight + } + + // Weather metrics: temperature, wind, direction + RowLayout { + spacing: 8 + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + + // Temperature metric + RowLayout { + spacing: 4 + Layout.alignment: Qt.AlignVCenter + Label { + text: "thermostat" + font.family: "Material Symbols Outlined" + font.pixelSize: 12 + color: Data.ThemeManager.accentColor + } + Label { + text: shell.weatherData ? shell.weatherData.currentTemp : "" + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 12 + } + } + + Rectangle { + width: 1 + height: 12 + color: Qt.lighter(Data.ThemeManager.bgColor, 1.3) + } + + // Wind speed metric + RowLayout { + spacing: 4 + Layout.alignment: Qt.AlignVCenter + Label { + text: "air" + font.family: "Material Symbols Outlined" + font.pixelSize: 12 + color: Data.ThemeManager.accentColor + } + Label { + text: { + if (!shell.weatherData || !shell.weatherData.details) + return ""; + const windInfo = shell.weatherData.details.find(d => d.startsWith("Wind:")); + return windInfo ? windInfo.split(": ")[1] : ""; + } + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 12 + } + } + + Rectangle { + width: 1 + height: 12 + color: Qt.lighter(Data.ThemeManager.bgColor, 1.3) + } + + // Wind direction metric + RowLayout { + spacing: 4 + Layout.alignment: Qt.AlignVCenter + Label { + text: "explore" + font.family: "Material Symbols Outlined" + font.pixelSize: 12 + color: Data.ThemeManager.accentColor + } + Label { + text: { + if (!shell.weatherData || !shell.weatherData.details) + return ""; + const dirInfo = shell.weatherData.details.find(d => d.startsWith("Direction:")); + return dirInfo ? dirInfo.split(": ")[1] : ""; + } + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 12 + } + } + + Item { + Layout.fillWidth: true + } + } + } + } + + // Section separator + Rectangle { + height: 1 + Layout.fillWidth: true + color: Qt.lighter(Data.ThemeManager.bgColor, 1.3) + } + + Label { + text: "3-Day Forecast" + color: Data.ThemeManager.accentColor + font.family: "monospace" + font.pixelSize: 12 + font.bold: true + } + + // Three-column forecast cards + Row { + spacing: 8 + Layout.fillWidth: true + + Repeater { + model: shell.weatherData ? shell.weatherData.forecast : [] + delegate: Column { + width: (parent.width - 16) / 3 + spacing: 2 + + // Day name + Label { + text: modelData.dayName + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 10 + font.bold: true + anchors.horizontalCenter: parent.horizontalCenter + } + + // Weather icon + Label { + text: root.getWeatherIcon(modelData.condition) + font.pixelSize: 16 + font.family: "Material Symbols Outlined" + color: Data.ThemeManager.accentColor + anchors.horizontalCenter: parent.horizontalCenter + } + + // Temperature range + Label { + text: modelData.minTemp + "° - " + modelData.maxTemp + "°" + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 10 + anchors.horizontalCenter: parent.horizontalCenter + } + } + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/CalendarTab.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/CalendarTab.qml new file mode 100644 index 0000000..16a8219 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/CalendarTab.qml @@ -0,0 +1,143 @@ +import QtQuick +import QtQuick.Controls +import "root:/Data" as Data + +// Calendar tab content +Item { + id: calendarTab + + required property var shell + property bool isActive: false + + Column { + anchors.fill: parent + spacing: 12 + + Text { + text: "Calendar" + color: Data.ThemeManager.accentColor + font.pixelSize: 18 + font.bold: true + font.family: "monospace" + } + + Rectangle { + width: parent.width + height: parent.height - parent.children[0].height - parent.spacing + color: Qt.lighter(Data.ThemeManager.bgColor, 1.2) + radius: 20 + clip: true + + Loader { + anchors.fill: parent + anchors.margins: 16 + active: calendarTab.isActive + sourceComponent: active ? calendarComponent : null + } + } + } + + Component { + id: calendarComponent + Item { + id: calendarRoot + property var shell: calendarTab.shell + + readonly property date currentDate: new Date() + property int month: currentDate.getMonth() + property int year: currentDate.getFullYear() + readonly property int currentDay: currentDate.getDate() + + Column { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + // Month/Year header + Text { + text: Qt.locale("en_US").monthName(calendarRoot.month) + " " + calendarRoot.year + color: Data.ThemeManager.accentColor + font.bold: true + width: parent.width + horizontalAlignment: Text.AlignHCenter + font.pixelSize: 16 + height: 24 + } + + // Weekday headers (Monday-Sunday) + Grid { + columns: 7 + rowSpacing: 2 + columnSpacing: 0 + width: parent.width + height: 18 + + Repeater { + model: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + delegate: Text { + text: modelData + color: Data.ThemeManager.fgColor + font.bold: true + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + width: parent.width / 7 + height: 18 + font.pixelSize: 11 + } + } + } + + // Calendar grid - single unified grid + Grid { + columns: 7 + rowSpacing: 3 + columnSpacing: 3 + width: parent.width + + property int firstDayOfMonth: new Date(calendarRoot.year, calendarRoot.month, 1).getDay() + property int daysInMonth: new Date(calendarRoot.year, calendarRoot.month + 1, 0).getDate() + property int startOffset: (firstDayOfMonth === 0) ? 6 : firstDayOfMonth - 1 // Convert Sunday=0 to Monday=0 + property int prevMonthDays: new Date(calendarRoot.year, calendarRoot.month, 0).getDate() + + // Single repeater for all 42 calendar cells (6 weeks × 7 days) + Repeater { + model: 42 + delegate: Rectangle { + width: (parent.width - (parent.columnSpacing * 6)) / 7 + height: 26 + radius: 13 + + // Calculate which day this cell represents + readonly property int dayNumber: { + if (index < parent.startOffset) { + // Previous month + return parent.prevMonthDays - parent.startOffset + index + 1; + } else if (index < parent.startOffset + parent.daysInMonth) { + // Current month + return index - parent.startOffset + 1; + } else { + // Next month + return index - parent.startOffset - parent.daysInMonth + 1; + } + } + + readonly property bool isCurrentMonth: index >= parent.startOffset && index < (parent.startOffset + parent.daysInMonth) + readonly property bool isToday: isCurrentMonth && dayNumber === calendarRoot.currentDay && calendarRoot.month === calendarRoot.currentDate.getMonth() && calendarRoot.year === calendarRoot.currentDate.getFullYear() + + color: isToday ? Data.ThemeManager.accentColor : isCurrentMonth ? Data.ThemeManager.bgColor : Qt.darker(Data.ThemeManager.bgColor, 1.4) + + Text { + text: dayNumber + anchors.centerIn: parent + color: isToday ? Data.ThemeManager.bgColor : isCurrentMonth ? Data.ThemeManager.fgColor : Qt.darker(Data.ThemeManager.fgColor, 1.5) + font.bold: isToday + font.pixelSize: 12 + font.family: "monospace" + } + } + } + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/ClipboardTab.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/ClipboardTab.qml new file mode 100644 index 0000000..ac014e0 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/ClipboardTab.qml @@ -0,0 +1,112 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "root:/Data" as Data +import "root:/Widgets/System" as System + +// Clipboard tab content +Item { + id: clipboardTab + + required property var shell + property bool isActive: false + + Column { + anchors.fill: parent + spacing: 16 + + RowLayout { + width: parent.width + spacing: 16 + + Text { + text: "Clipboard History" + color: Data.ThemeManager.accentColor + font.pixelSize: 18 + font.bold: true + font.family: "monospace" + } + + Item { + Layout.fillWidth: true + } + + Rectangle { + width: clearClipText.implicitWidth + 16 + height: 24 + radius: 12 + color: clearClipMouseArea.containsMouse ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) : "transparent" + border.color: Data.ThemeManager.accentColor + border.width: 1 + + Text { + id: clearClipText + anchors.centerIn: parent + text: "Clear All" + color: Data.ThemeManager.accentColor + font.family: "monospace" + font.pixelSize: 11 + } + + MouseArea { + id: clearClipMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + if (clipboardLoader.item && clipboardLoader.item.children[0]) { + let clipComponent = clipboardLoader.item.children[0]; + if (clipComponent.clearClipboardHistory) { + clipComponent.clearClipboardHistory(); + } + } + } + } + } + } + + Rectangle { + width: parent.width + height: parent.height - parent.children[0].height - parent.spacing + color: Qt.lighter(Data.ThemeManager.bgColor, 1.2) + radius: 20 + clip: true + + Loader { + id: clipboardLoader + anchors.fill: parent + anchors.margins: 20 + active: clipboardTab.isActive + sourceComponent: active ? clipboardHistoryComponent : null + onLoaded: { + if (item && item.children[0]) { + item.children[0].refreshClipboardHistory(); + } + } + } + } + } + + Component { + id: clipboardHistoryComponent + Item { + anchors.fill: parent + + System.Cliphist { + id: cliphistComponent + anchors.fill: parent + shell: clipboardTab.shell + + Component.onCompleted: { + for (let i = 0; i < children.length; i++) { + let child = children[i]; + if (child.objectName === "contentColumn" || child.toString().includes("ColumnLayout")) { + if (child.children && child.children.length > 0) { + child.children[0].visible = false; + } + } + } + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/MainDashboard.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/MainDashboard.qml new file mode 100644 index 0000000..a332059 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/MainDashboard.qml @@ -0,0 +1,159 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "root:/Data" as Data +import "root:/Widgets/System" as System +import "../components/widgets" as Widgets +import "../components/controls" as Controls +import "../components/system" as SystemComponents + +// Main dashboard content (tab 0) +Item { + id: mainDashboard + + // Properties from parent + required property var shell + required property bool isRecording + required property var triggerMouseArea + + // Signals to forward + signal recordingRequested + signal stopRecordingRequested + signal systemActionRequested(string action) + signal performanceActionRequested(string action) + + // Hover detection for auto-hide + property bool isHovered: { + const mouseStates = { + userProfileHovered: userProfile ? userProfile.isHovered : false, + weatherDisplayHovered: weatherDisplay ? weatherDisplay.containsMouse : false, + recordingButtonHovered: recordingButton ? recordingButton.isHovered : false, + controlsHovered: controls ? controls.containsMouse : false, + trayHovered: trayMouseArea ? trayMouseArea.containsMouse : false, + systemTrayHovered: systemTrayModule ? systemTrayModule.containsMouse : false, + trayMenuHovered: inlineTrayMenu ? inlineTrayMenu.containsMouse : false, + trayMenuVisible: inlineTrayMenu ? inlineTrayMenu.visible : false + }; + return Object.values(mouseStates).some(state => state); + } + + // Night Light overlay controller (invisible - manages screen overlay) + Widgets.NightLight { + id: nightLightController + shell: mainDashboard.shell + visible: false // This widget manages overlay windows, doesn't need to be visible + } + + Column { + anchors.fill: parent + spacing: 28 + + // User profile row with weather + Row { + width: parent.width + spacing: 18 + + Widgets.UserProfile { + id: userProfile + width: parent.width - weatherDisplay.width - parent.spacing + height: 80 + shell: mainDashboard.shell + } + + Widgets.WeatherDisplay { + id: weatherDisplay + width: parent.width * 0.18 + height: userProfile.height + shell: mainDashboard.shell + } + } + + // Recording and system controls section + Column { + width: parent.width + spacing: 28 + + Widgets.RecordingButton { + id: recordingButton + width: parent.width + height: 48 + shell: mainDashboard.shell + isRecording: mainDashboard.isRecording + + onRecordingRequested: mainDashboard.recordingRequested() + onStopRecordingRequested: mainDashboard.stopRecordingRequested() + } + + Controls.Controls { + id: controls + width: parent.width + isRecording: mainDashboard.isRecording + shell: mainDashboard.shell + onPerformanceActionRequested: function (action) { + mainDashboard.performanceActionRequested(action); + } + onSystemActionRequested: function (action) { + mainDashboard.systemActionRequested(action); + } + } + } + + // System tray integration with menu + Column { + id: systemTraySection + width: parent.width + spacing: 8 + + property bool containsMouse: trayMouseArea.containsMouse || systemTrayModule.containsMouse + + Rectangle { + id: trayBackground + width: parent.width + height: 40 + radius: 20 + color: Qt.darker(Data.ThemeManager.bgColor, 1.15) + + property bool isActive: false + + MouseArea { + id: trayMouseArea + anchors.fill: parent + anchors.margins: -10 + hoverEnabled: true + propagateComposedEvents: true + preventStealing: false + onEntered: trayBackground.isActive = true + onExited: { + // Only deactivate if we're not hovering over tray menu or system tray module + if (!inlineTrayMenu.visible && !inlineTrayMenu.containsMouse) { + Qt.callLater(function () { + if (!systemTrayModule.containsMouse && !inlineTrayMenu.containsMouse && !inlineTrayMenu.visible) { + trayBackground.isActive = false; + } + }); + } + } + } + + System.SystemTray { + id: systemTrayModule + anchors.centerIn: parent + shell: mainDashboard.shell + bar: parent + trayMenu: inlineTrayMenu + } + } + } + + SystemComponents.TrayMenu { + id: inlineTrayMenu + parent: mainDashboard + width: parent.width + menu: null + systemTrayY: systemTraySection.y + systemTrayHeight: systemTraySection.height + z: 100 // High z-index to appear above other content + onHideRequested: trayBackground.isActive = false + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/MusicTab.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/MusicTab.qml new file mode 100644 index 0000000..df1842a --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/MusicTab.qml @@ -0,0 +1,46 @@ +import QtQuick +import "root:/Data" as Data +import "../components/media" as Media + +// Music tab content +Item { + id: musicTab + + required property var shell + property bool isActive: false + + Column { + anchors.fill: parent + spacing: 16 + + Text { + text: "Music Player" + color: Data.ThemeManager.accentColor + font.pixelSize: 18 + font.bold: true + font.family: "monospace" + } + + Rectangle { + width: parent.width + height: parent.height - parent.children[0].height - parent.spacing + color: Qt.lighter(Data.ThemeManager.bgColor, 1.2) + radius: 20 + clip: true + + Loader { + anchors.fill: parent + anchors.margins: 20 + active: musicTab.isActive + sourceComponent: active ? musicPlayerComponent : null + } + } + } + + Component { + id: musicPlayerComponent + Media.MusicPlayer { + shell: musicTab.shell + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/NotificationTab.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/NotificationTab.qml new file mode 100644 index 0000000..23fb641 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/NotificationTab.qml @@ -0,0 +1,108 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "root:/Data" as Data +import "root:/Widgets/Notifications" as Notifications + +// Notification tab content +Item { + id: notificationTab + + required property var shell + property bool isActive: false + + Column { + anchors.fill: parent + spacing: 16 + + RowLayout { + width: parent.width + spacing: 16 + + Text { + text: "Notification History" + color: Data.ThemeManager.accentColor + font.pixelSize: 18 + font.bold: true + font.family: "monospace" + } + + Text { + text: "(" + (notificationTab.shell.notificationHistory ? notificationTab.shell.notificationHistory.count : 0) + ")" + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 12 + opacity: 0.7 + Layout.alignment: Qt.AlignVCenter + } + + Item { + Layout.fillWidth: true + } + + Rectangle { + width: clearNotifText.implicitWidth + 16 + height: 24 + radius: 12 + color: clearNotifMouseArea.containsMouse ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) : "transparent" + border.color: Data.ThemeManager.accentColor + border.width: 1 + + Text { + id: clearNotifText + anchors.centerIn: parent + text: "Clear All" + color: Data.ThemeManager.accentColor + font.family: "monospace" + font.pixelSize: 11 + } + + MouseArea { + id: clearNotifMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: notificationTab.shell.notificationHistory.clear() + } + } + } + + Rectangle { + width: parent.width + height: parent.height - parent.children[0].height - parent.spacing + color: Qt.lighter(Data.ThemeManager.bgColor, 1.2) + radius: 20 + clip: true + + Loader { + anchors.fill: parent + anchors.margins: 20 + active: notificationTab.isActive + sourceComponent: active ? notificationHistoryComponent : null + } + } + } + + Component { + id: notificationHistoryComponent + Item { + anchors.fill: parent + + Notifications.NotificationHistory { + anchors.fill: parent + shell: notificationTab.shell + clip: true + + Component.onCompleted: { + for (let i = 0; i < children.length; i++) { + let child = children[i]; + if (child.objectName === "contentColumn" || child.toString().includes("ColumnLayout")) { + if (child.children && child.children.length > 0) { + child.children[0].visible = false; + } + } + } + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/SettingsTab.qml b/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/SettingsTab.qml new file mode 100644 index 0000000..17b55b0 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/ControlPanel/tabs/SettingsTab.qml @@ -0,0 +1,151 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "root:/Data" as Data +import "../components/settings" as SettingsComponents + +// Settings tab content with modular, collapsible categories +Item { + id: settingsTab + + required property var shell + property bool isActive: false + + // Track when any text input has focus for keyboard management + property bool anyTextInputFocused: { + try { + return (notificationSettings && notificationSettings.anyTextInputFocused) || (systemSettings && systemSettings.anyTextInputFocused) || (weatherSettings && weatherSettings.anyTextInputFocused); + } catch (e) { + return false; + } + } + + // Header + Text { + id: header + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 20 + text: "Settings" + color: Data.ThemeManager.accentColor + font.pixelSize: 24 + font.bold: true + font.family: "monospace" + } + + // Scrollable content + ScrollView { + anchors.top: header.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.topMargin: 16 + anchors.leftMargin: 20 + anchors.rightMargin: 20 + anchors.bottomMargin: 20 + + clip: true + contentWidth: width - 5 // Reserve space for scrollbar + + ScrollBar.vertical.policy: ScrollBar.AsNeeded + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + Column { + width: parent.width - 15 // Match contentWidth + spacing: 16 + + // VISUAL SETTINGS + // Appearance Category + SettingsComponents.SettingsCategory { + id: appearanceCategory + width: parent.width + title: "Appearance" + icon: "palette" + + content: Component { + SettingsComponents.AppearanceSettings { + width: parent.width + } + } + } + + // ⚙️ CORE SYSTEM SETTINGS + // System Category + SettingsComponents.SettingsCategory { + id: systemCategory + width: parent.width + title: "System" + icon: "settings" + + content: Component { + SettingsComponents.SystemSettings { + id: systemSettings + width: parent.width + } + } + } + + // Notifications Category + SettingsComponents.SettingsCategory { + id: notificationsCategory + width: parent.width + title: "Notifications" + icon: "notifications" + + content: Component { + SettingsComponents.NotificationSettings { + id: notificationSettings + width: parent.width + } + } + } + + // 🎵 MEDIA & EXTERNAL SERVICES + // Music Player Category + SettingsComponents.SettingsCategory { + id: musicPlayerCategory + width: parent.width + title: "Music Player" + icon: "music_note" + + content: Component { + SettingsComponents.MusicPlayerSettings { + width: parent.width + } + } + } + + // Weather Category + SettingsComponents.SettingsCategory { + id: weatherCategory + width: parent.width + title: "Weather" + icon: "wb_sunny" + + content: Component { + SettingsComponents.WeatherSettings { + id: weatherSettings + width: parent.width + shell: settingsTab.shell + } + } + } + + // ACCESSIBILITY & COMFORT + // Night Light Category + SettingsComponents.SettingsCategory { + id: nightLightCategory + width: parent.width + title: "Night Light" + icon: "dark_mode" + + content: Component { + SettingsComponents.NightLightSettings { + width: parent.width + } + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Notifications/Notification.qml b/modules/desktop/quickshell/qml/Widgets/Notifications/Notification.qml new file mode 100644 index 0000000..ad3dca8 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Notifications/Notification.qml @@ -0,0 +1,372 @@ +// System notification manager +import QtQuick +import QtQuick.Controls +import Quickshell.Services.Notifications +import "root:/Data" as Data +import "root:/Core" as Core + +Item { + id: root + required property var shell + required property var notificationServer + + // Dynamic height based on visible notifications + property int calculatedHeight: Math.min(notifications.length, maxNotifications) * 100 + 100 // Add 100px for bottom margin + + // Simple array to store notifications with tracking + property var notifications: [] + property int maxNotifications: 5 + property var animatedNotificationIds: ({}) // Track which notifications have been animated + + // Handle new notifications + Connections { + target: notificationServer + function onNotification(notification) { + if (!notification || !notification.id) + return; + + // Filter empty notifications + if (!notification.appName && !notification.summary && !notification.body) { + return; + } + + // Filter ignored applications (case-insensitive) - same logic as NotificationService + var shouldIgnore = false; + if (notification.appName && Data.Settings.ignoredApps && Data.Settings.ignoredApps.length > 0) { + for (var i = 0; i < Data.Settings.ignoredApps.length; i++) { + if (Data.Settings.ignoredApps[i].toLowerCase() === notification.appName.toLowerCase()) { + shouldIgnore = true; + break; + } + } + } + + if (shouldIgnore) { + // Don't display ignored notifications + return; + } + + // Create simple notification object + let newNotification = { + "id": notification.id, + "appName": notification.appName || "App", + "summary": notification.summary || "", + "body": notification.body || "", + "timestamp": Date.now(), + "shouldSlideOut": false, + "icon": notification.icon || notification.image || notification.appIcon || "", + "rawNotification": notification // Keep reference to original + }; + + // Add to beginning + notifications.unshift(newNotification); + + // Trigger model update first to let new notification animate + notificationsChanged(); + + // Delay trimming to let new notification animate + if (notifications.length > maxNotifications) { + trimTimer.restart(); + } + } + } + + // Timer to delay trimming notifications (let new ones animate first) + Timer { + id: trimTimer + interval: 500 // Wait 500ms before trimming + running: false + repeat: false + onTriggered: { + if (notifications.length > maxNotifications) { + notifications = notifications.slice(0, maxNotifications); + notificationsChanged(); + } + } + } + + // Global timer to check for expired notifications + Timer { + id: cleanupTimer + interval: Math.min(500, Data.Settings.displayTime / 10) // Check every 500ms or 1/10th of display time, whichever is shorter + running: true + repeat: true + onTriggered: { + let currentTime = Date.now(); + let hasExpiredNotifications = false; + + // Mark notifications older than displayTime setting for slide-out + for (let i = 0; i < notifications.length; i++) { + let notification = notifications[i]; + let age = currentTime - notification.timestamp; + if (age >= Data.Settings.displayTime && !notification.shouldSlideOut) { + notification.shouldSlideOut = true; + hasExpiredNotifications = true; + } + } + + // Trigger update if any notifications were marked for slide-out + if (hasExpiredNotifications) { + notificationsChanged(); + } + } + } + + function removeNotification(notificationId) { + let initialLength = notifications.length; + notifications = notifications.filter(function (n) { + return n.id !== notificationId; + }); + if (notifications.length !== initialLength) { + // Remove from animated tracking + delete animatedNotificationIds[notificationId]; + notificationsChanged(); + } + } + + // Simple Column with Repeater + Column { + anchors.right: parent.right + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.leftMargin: 40 // Create space on left for top-left corner + anchors.rightMargin: Data.Settings.borderWidth + 20 // Border width plus corner space + anchors.bottomMargin: 100 // Create more space at bottom for bottom corner + spacing: 0 + + Repeater { + model: notifications.length // Show all notifications, not just maxNotifications + + delegate: Rectangle { + id: notificationRect + property var notification: index < notifications.length ? notifications[index] : null + + width: 400 + height: 100 + color: Data.ThemeManager.bgColor + + // Only bottom visible notification gets bottom-left radius + radius: 0 + bottomLeftRadius: index === Math.min(notifications.length, maxNotifications) - 1 && index < maxNotifications ? 15 : 0 + + // Only show if within maxNotifications limit + visible: index < maxNotifications + + // Animation state + property bool hasSlideIn: false + + // Check for expiration and start slide-out if needed + onNotificationChanged: { + if (notification && notification.shouldSlideOut && !slideOutAnimation.running) { + slideOutAnimation.start(); + } + } + + // Start off-screen for new notifications + Component.onCompleted: { + if (notification) { + // Check if notification should slide out immediately + if (notification.shouldSlideOut) { + slideOutAnimation.start(); + return; + } + + // Check if this notification is truly new (recently added) + let notificationAge = Date.now() - notification.timestamp; + let shouldAnimate = !animatedNotificationIds[notification.id] && notificationAge < 1000; // Only animate if less than 1 second old + + if (shouldAnimate) { + x = 420; + opacity = 0; + hasSlideIn = false; + slideInAnimation.start(); + // Mark as animated + animatedNotificationIds[notification.id] = true; + } else { + x = 0; + opacity = 1; + hasSlideIn = true; + // Mark as animated if not already + animatedNotificationIds[notification.id] = true; + } + } + } + + // Slide-in animation + ParallelAnimation { + id: slideInAnimation + NumberAnimation { + target: notificationRect + property: "x" + to: 0 + duration: 300 + easing.type: Easing.OutCubic + } + NumberAnimation { + target: notificationRect + property: "opacity" + to: 1 + duration: 300 + easing.type: Easing.OutCubic + } + onFinished: { + hasSlideIn = true; + } + } + + // Slide-out animation + ParallelAnimation { + id: slideOutAnimation + NumberAnimation { + target: notificationRect + property: "x" + to: 420 + duration: 250 + easing.type: Easing.InCubic + } + NumberAnimation { + target: notificationRect + property: "opacity" + to: 0 + duration: 250 + easing.type: Easing.InCubic + } + onFinished: { + if (notification) { + removeNotification(notification.id); + } + } + } + + // Click to dismiss + MouseArea { + anchors.fill: parent + onClicked: slideOutAnimation.start() + } + + // Content + Row { + anchors.fill: parent + anchors.margins: 15 + spacing: 12 + + // App icon + Rectangle { + width: 32 + height: 32 + radius: 16 + color: Qt.rgba(255, 255, 255, 0.1) + anchors.verticalCenter: parent.verticalCenter + + // Application icon (if available) + Image { + id: appIcon + source: { + if (!notification || !notification.icon) + return ""; + + let icon = notification.icon; + + // Apply same processing as tray system + if (icon.includes("?path=")) { + const [name, path] = icon.split("?path="); + const fileName = name.substring(name.lastIndexOf("/") + 1); + return `file://${path}/${fileName}`; + } + + // Handle file paths properly + if (icon.startsWith('/')) { + return "file://" + icon; + } + + return icon; + } + anchors.fill: parent + anchors.margins: 2 + fillMode: Image.PreserveAspectFit + smooth: true + visible: source.toString() !== "" + + onStatusChanged: + // Icon status handling can be added here if needed + {} + } + + // Fallback text (first letter of app name) + Text { + anchors.centerIn: parent + text: notification && notification.appName ? notification.appName.charAt(0).toUpperCase() : "!" + color: Data.ThemeManager.accentColor + font.family: "monospace" + font.pixelSize: 16 + font.bold: true + visible: !appIcon.visible + } + } + + // Content + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 60 + spacing: 4 + + Text { + text: notification ? notification.appName : "" + color: Data.ThemeManager.accentColor + font.family: "monospace" + font.bold: true + font.pixelSize: 15 + width: Math.min(parent.width, 250) // Earlier line break + elide: Text.ElideRight + } + + Text { + text: notification ? notification.summary : "" + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 14 + width: Math.min(parent.width, 250) // Earlier line break + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + visible: text.length > 0 + } + + Text { + text: notification ? notification.body : "" + color: Qt.lighter(Data.ThemeManager.fgColor, 1.3) + font.family: "monospace" + font.pixelSize: 13 + width: Math.min(parent.width, 250) // Earlier line break + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + visible: text.length > 0 + } + } + } + + // Top corner for first notification + Core.Corners { + position: "bottomright" + size: 1.3 + fillColor: Data.ThemeManager.bgColor + offsetX: -361 + offsetY: -13 + visible: index === 0 && index < maxNotifications + } + + // Bottom corner for last visible notification + Core.Corners { + position: "bottomright" + size: 1.3 + fillColor: Data.ThemeManager.bgColor + offsetX: 39 + offsetY: 78 + visible: index === Math.min(notifications.length, maxNotifications) - 1 && index < maxNotifications + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Notifications/NotificationHistory.qml b/modules/desktop/quickshell/qml/Widgets/Notifications/NotificationHistory.qml new file mode 100644 index 0000000..ca8cee0 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Notifications/NotificationHistory.qml @@ -0,0 +1,262 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import "root:/Data" as Data + +// Notification history viewer +Item { + id: root + implicitHeight: 400 + + required property var shell + property bool hovered: false + property real targetX: 0 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + // Header with title, count, and clear all button + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + spacing: 8 + + Text { + text: "Notification History" + color: Data.ThemeManager.accentColor + font.pixelSize: 18 + font.bold: true + font.family: "monospace" + } + + Text { + text: "(" + (shell.notificationHistory ? shell.notificationHistory.count : 0) + ")" + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 12 + opacity: 0.7 + } + + Item { + Layout.fillWidth: true + } + + Rectangle { + visible: shell.notificationHistory && shell.notificationHistory.count > 0 + width: clearText.implicitWidth + 16 + height: 24 + radius: 12 + color: clearMouseArea.containsMouse ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) : "transparent" + border.color: Data.ThemeManager.accentColor + border.width: 1 + + Text { + id: clearText + anchors.centerIn: parent + text: "Clear All" + color: Data.ThemeManager.accentColor + font.family: "monospace" + font.pixelSize: 11 + } + + MouseArea { + id: clearMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: shell.notificationHistory.clear() + } + } + } + + // Scrollable notification list + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + ScrollView { + id: scrollView + anchors.fill: parent + clip: true + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + interactive: true + visible: notificationListView.contentHeight > notificationListView.height + contentItem: Rectangle { + implicitWidth: 6 + radius: width / 2 + color: parent.pressed ? Data.ThemeManager.accentColor : parent.hovered ? Qt.lighter(Data.ThemeManager.accentColor, 1.2) : Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.7) + } + } + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ListView { + id: notificationListView + model: shell.notificationHistory + spacing: 12 + cacheBuffer: 50 // Memory optimization + reuseItems: true + boundsBehavior: Flickable.StopAtBounds + maximumFlickVelocity: 2500 + flickDeceleration: 1500 + clip: true + interactive: true + + // Smooth scrolling behavior + property real targetY: contentY + Behavior on targetY { + NumberAnimation { + duration: 200 + easing.type: Easing.OutQuad + } + } + + onTargetYChanged: { + if (!moving && !dragging) { + contentY = targetY; + } + } + + delegate: Rectangle { + width: notificationListView.width + height: Math.max(80, contentLayout.implicitHeight + 24) + radius: 8 + color: mouseArea.containsMouse ? Qt.darker(Data.ThemeManager.bgColor, 1.15) : Qt.darker(Data.ThemeManager.bgColor, 1.1) + border.color: Data.ThemeManager.accentColor + border.width: 1 + + // View optimization - only render visible items + visible: y + height > notificationListView.contentY - height && y < notificationListView.contentY + notificationListView.height + height + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + } + + // Main notification content layout + RowLayout { + id: contentLayout + anchors.fill: parent + anchors.margins: 12 + spacing: 12 + + // App icon area + Item { + Layout.preferredWidth: 24 + Layout.preferredHeight: 24 + Layout.alignment: Qt.AlignTop + + Image { + width: 24 + height: 24 + source: model.icon || "" + visible: source.toString() !== "" + } + } + + // Notification text content + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 6 + + // App name and timestamp row + RowLayout { + Layout.fillWidth: true + + Text { + Layout.fillWidth: true + text: model.appName || "Unknown" + color: Data.ThemeManager.accentColor + font.family: "monospace" + font.pixelSize: 13 + font.bold: true + } + + Text { + text: Qt.formatDateTime(model.timestamp, "hh:mm") + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 10 + opacity: 0.7 + } + } + + // Notification summary + Text { + Layout.fillWidth: true + visible: model.summary && model.summary.length > 0 + text: model.summary || "" + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 13 + font.bold: true + wrapMode: Text.WordWrap + lineHeight: 1.2 + } + + // Notification body text + Text { + Layout.fillWidth: true + visible: model.body && model.body.length > 0 + text: model.body || "" + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 12 + opacity: 0.9 + wrapMode: Text.WordWrap + maximumLineCount: 4 + elide: Text.ElideRight + lineHeight: 1.2 + } + } + } + + // Individual delete button + Rectangle { + width: 24 + height: 24 + radius: 12 + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + color: deleteArea.containsMouse ? Qt.rgba(255, 0, 0, 0.2) : "transparent" + border.color: deleteArea.containsMouse ? "#ff4444" : Data.ThemeManager.fgColor + border.width: 1 + opacity: deleteArea.containsMouse ? 1 : 0.5 + + Text { + anchors.centerIn: parent + text: "×" + color: deleteArea.containsMouse ? "#ff4444" : Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 16 + } + + MouseArea { + id: deleteArea + anchors.fill: parent + hoverEnabled: true + onClicked: shell.notificationHistory.remove(model.index) + } + } + } + } + } + + // Empty state message + Text { + anchors.centerIn: parent + visible: !notificationListView.count + text: "No notifications" + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 14 + opacity: 0.7 + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Panel/TopPanel.qml b/modules/desktop/quickshell/qml/Widgets/Panel/TopPanel.qml new file mode 100644 index 0000000..6312308 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Panel/TopPanel.qml @@ -0,0 +1,142 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import QtQuick.Effects +import Quickshell +import Quickshell.Io +import "root:/Data" as Data +import "root:/Core/" as Core +import "./modules" as Modules + +// Top panel wrapper with recording +Item { + id: topPanelRoot + required property var shell + + visible: true + + property bool isRecording: false + property var recordingProcess: null + property string lastError: "" + + signal slideBarVisibilityChanged(bool visible) + + function triggerTopPanel() { + panel.show(); + } + + // Auto-trigger panel + onVisibleChanged: { + if (visible) { + triggerTopPanel(); + } + } + + // Main panel instance + Modules.Panel { + id: panel + shell: topPanelRoot.shell + isRecording: topPanelRoot.isRecording + + anchors.top: topPanelRoot.top + anchors.right: topPanelRoot.right + anchors.topMargin: 8 + anchors.rightMargin: 8 + + onVisibleChanged: slideBarVisibilityChanged(visible) + + onRecordingRequested: startRecording() + onStopRecordingRequested: { + stopRecording(); + // Hide entire TopPanel after stop recording + if (topPanelRoot.parent && topPanelRoot.parent.hide) { + topPanelRoot.parent.hide(); + } + } + onSystemActionRequested: function (action) { + performSystemAction(action); + // Hide entire TopPanel after system action + if (topPanelRoot.parent && topPanelRoot.parent.hide) { + topPanelRoot.parent.hide(); + } + } + onPerformanceActionRequested: function (action) { + performPerformanceAction(action); + // Hide entire TopPanel after performance action + if (topPanelRoot.parent && topPanelRoot.parent.hide) { + topPanelRoot.parent.hide(); + } + } + } + + // Start screen recording + function startRecording() { + var currentDate = new Date(); + var hours = String(currentDate.getHours()).padStart(2, '0'); + var minutes = String(currentDate.getMinutes()).padStart(2, '0'); + var day = String(currentDate.getDate()).padStart(2, '0'); + var month = String(currentDate.getMonth() + 1).padStart(2, '0'); + var year = currentDate.getFullYear(); + + var filename = hours + "-" + minutes + "-" + day + "-" + month + "-" + year + ".mp4"; + var outputPath = Data.Settings.videoPath + filename; + var command = "gpu-screen-recorder -w portal -f 60 -a default_output -o " + outputPath; + + var qmlString = 'import Quickshell.Io; Process { command: ["sh", "-c", "' + command + '"]; running: true }'; + + recordingProcess = Qt.createQmlObject(qmlString, topPanelRoot); + isRecording = true; + } + + // Stop recording with cleanup + function stopRecording() { + if (recordingProcess && isRecording) { + var stopQmlString = 'import Quickshell.Io; Process { command: ["sh", "-c", "pkill -SIGINT -f \'gpu-screen-recorder.*portal\'"]; running: true; onExited: function() { destroy() } }'; + + var stopProcess = Qt.createQmlObject(stopQmlString, topPanelRoot); + + var cleanupTimer = Qt.createQmlObject('import QtQuick; Timer { interval: 3000; running: true; repeat: false }', topPanelRoot); + cleanupTimer.triggered.connect(function () { + if (recordingProcess) { + recordingProcess.running = false; + recordingProcess.destroy(); + recordingProcess = null; + } + + var forceKillQml = 'import Quickshell.Io; Process { command: ["sh", "-c", "pkill -9 -f \'gpu-screen-recorder.*portal\' 2>/dev/null || true"]; running: true; onExited: function() { destroy() } }'; + var forceKillProcess = Qt.createQmlObject(forceKillQml, topPanelRoot); + + cleanupTimer.destroy(); + }); + } + isRecording = false; + } + + // System action router (lock, reboot, shutdown) + function performSystemAction(action) { + switch (action) { + case "lock": + Core.ProcessManager.lock(); + break; + case "reboot": + Core.ProcessManager.reboot(); + break; + case "shutdown": + Core.ProcessManager.shutdown(); + break; + } + } + + function performPerformanceAction(action) { + // Performance actions handled silently + } + + // Clean up processes on destruction + Component.onDestruction: { + if (recordingProcess) { + recordingProcess.running = false; + recordingProcess.destroy(); + recordingProcess = null; + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Panel/modules/CalendarButton.qml b/modules/desktop/quickshell/qml/Widgets/Panel/modules/CalendarButton.qml new file mode 100644 index 0000000..44abbab --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Panel/modules/CalendarButton.qml @@ -0,0 +1,72 @@ +import QtQuick +import QtQuick.Controls +import "root:/Data" as Data + +// Calendar button for the top panel +Rectangle { + id: calendarButton + width: 40 + height: 80 + color: Qt.darker(Data.ThemeManager.bgColor, 1.15) + radius: 20 + + property bool containsMouse: calendarMouseArea.containsMouse + property bool calendarVisible: false + property var calendarPopup: null + property var shell: null // Shell reference from parent + + signal entered + signal exited + + onContainsMouseChanged: { + if (containsMouse) { + entered(); + } else { + exited(); + } + } + + MouseArea { + id: calendarMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + toggleCalendar(); + } + } + + Label { + anchors.centerIn: parent + text: "calendar_month" + font.pixelSize: 24 + font.family: "Material Symbols Outlined" + color: calendarButton.containsMouse || calendarButton.calendarVisible ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor + } + + function toggleCalendar() { + if (!calendarPopup) { + var component = Qt.createComponent("root:/Widgets/Calendar/CalendarPopup.qml"); + if (component.status === Component.Ready) { + calendarPopup = component.createObject(calendarButton.parent, { + "targetX": calendarButton.x + calendarButton.width + 10, + "shell": calendarButton.shell + }); + } else if (component.status === Component.Error) { + console.log("Error loading calendar:", component.errorString()); + return; + } + } + + if (calendarPopup) { + calendarVisible = !calendarVisible; + calendarPopup.setClickMode(calendarVisible); + } + } + + function hideCalendar() { + if (calendarPopup) { + calendarVisible = false; + calendarPopup.setClickMode(false); + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Panel/modules/Controls.qml b/modules/desktop/quickshell/qml/Widgets/Panel/modules/Controls.qml new file mode 100644 index 0000000..1bcde4c --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Panel/modules/Controls.qml @@ -0,0 +1,107 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "root:/Data" as Data + +Row { + id: root + spacing: 16 + visible: true + height: 80 + + required property bool isRecording + required property var shell + signal performanceActionRequested(string action) + signal systemActionRequested(string action) + signal mouseChanged(bool containsMouse) + + // Add hover tracking property + property bool containsMouse: performanceSection.containsMouse || systemSection.containsMouse + onContainsMouseChanged: mouseChanged(containsMouse) + + Rectangle { + id: performanceSection + width: (parent.width - parent.spacing) / 2 + height: parent.height + radius: 20 + color: Qt.darker(Data.ThemeManager.bgColor, 1.15) + visible: true + + // Add hover tracking for performance section + property bool containsMouse: performanceMouseArea.containsMouse || performanceControls.containsMouse + + MouseArea { + id: performanceMouseArea + anchors.fill: parent + hoverEnabled: true + propagateComposedEvents: true + onContainsMouseChanged: { + if (containsMouse) { + performanceSection.containsMouse = true; + } else if (!performanceControls.containsMouse) { + performanceSection.containsMouse = false; + } + } + } + + PerformanceControls { + id: performanceControls + anchors.fill: parent + anchors.margins: 12 + shell: root.shell + onPerformanceActionRequested: function (action) { + root.performanceActionRequested(action); + } + onMouseChanged: function (containsMouse) { + if (containsMouse) { + performanceSection.containsMouse = true; + } else if (!performanceMouseArea.containsMouse) { + performanceSection.containsMouse = false; + } + } + } + } + + Rectangle { + id: systemSection + width: (parent.width - parent.spacing) / 2 + height: parent.height + radius: 20 + color: Qt.darker(Data.ThemeManager.bgColor, 1.15) + visible: true + + // Add hover tracking for system section + property bool containsMouse: systemMouseArea.containsMouse || systemControls.containsMouse + + MouseArea { + id: systemMouseArea + anchors.fill: parent + hoverEnabled: true + propagateComposedEvents: true + onContainsMouseChanged: { + if (containsMouse) { + systemSection.containsMouse = true; + } else if (!systemControls.containsMouse) { + systemSection.containsMouse = false; + } + } + } + + SystemControls { + id: systemControls + anchors.fill: parent + anchors.margins: 12 + shell: root.shell + onSystemActionRequested: function (action) { + root.systemActionRequested(action); + } + onMouseChanged: function (containsMouse) { + if (containsMouse) { + systemSection.containsMouse = true; + } else if (!systemMouseArea.containsMouse) { + systemSection.containsMouse = false; + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Panel/modules/NotificationBar.qml b/modules/desktop/quickshell/qml/Widgets/Panel/modules/NotificationBar.qml new file mode 100644 index 0000000..e22815c --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Panel/modules/NotificationBar.qml @@ -0,0 +1,92 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "root:/Data" as Data + +Rectangle { + id: root + width: 42 + color: Qt.darker(Data.ThemeManager.bgColor, 1.15) + radius: 12 + z: 2 // Keep it above notification history + + required property bool notificationHistoryVisible + required property bool clipboardHistoryVisible + required property var notificationHistory + signal notificationToggleRequested + signal clipboardToggleRequested + + // Add containsMouse property for panel hover tracking + property bool containsMouse: notifButtonMouseArea.containsMouse || clipButtonMouseArea.containsMouse + + // Ensure minimum height for buttons even when recording + property real buttonHeight: 38 + height: buttonHeight * 2 + 4 // 4px spacing between buttons + + Item { + anchors.fill: parent + anchors.margins: 2 + + // Top pill (Notifications) + Rectangle { + id: notificationPill + anchors { + top: parent.top + left: parent.left + right: parent.right + bottom: parent.verticalCenter + bottomMargin: 2 // Half of the spacing + } + radius: 12 + color: notifButtonMouseArea.containsMouse || root.notificationHistoryVisible ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.05) + border.color: notifButtonMouseArea.containsMouse || root.notificationHistoryVisible ? Data.ThemeManager.accentColor : "transparent" + border.width: 1 + + MouseArea { + id: notifButtonMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: root.notificationToggleRequested() + } + + Label { + anchors.centerIn: parent + text: "notifications" + font.family: "Material Symbols Outlined" + font.pixelSize: 16 + color: notifButtonMouseArea.containsMouse || root.notificationHistoryVisible ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor + } + } + + // Bottom pill (Clipboard) + Rectangle { + id: clipboardPill + anchors { + top: parent.verticalCenter + left: parent.left + right: parent.right + bottom: parent.bottom + topMargin: 2 // Half of the spacing + } + radius: 12 + color: clipButtonMouseArea.containsMouse || root.clipboardHistoryVisible ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) : Qt.rgba(Data.ThemeManager.fgColor.r, Data.ThemeManager.fgColor.g, Data.ThemeManager.fgColor.b, 0.05) + border.color: clipButtonMouseArea.containsMouse || root.clipboardHistoryVisible ? Data.ThemeManager.accentColor : "transparent" + border.width: 1 + + MouseArea { + id: clipButtonMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: root.clipboardToggleRequested() + } + + Label { + anchors.centerIn: parent + text: "content_paste" + font.family: "Material Symbols Outlined" + font.pixelSize: 16 + color: clipButtonMouseArea.containsMouse || root.clipboardHistoryVisible ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Panel/modules/Panel.qml b/modules/desktop/quickshell/qml/Widgets/Panel/modules/Panel.qml new file mode 100644 index 0000000..8bd12b8 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Panel/modules/Panel.qml @@ -0,0 +1,683 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Services.SystemTray +import "root:/Data" as Data +import "root:/Core" as Core +import "root:/Widgets/System" as System +import "root:/Widgets/Notifications" as Notifications +import "." as Modules + +// Main tabbed panel +Item { + id: root + + // Size calculation + width: mainContainer.implicitWidth + 18 + height: mainContainer.implicitHeight + 18 + + required property var shell + + property bool isShown: false + property int currentTab: 0 // 0=main, 1=calendar, 2=clipboard, 3=notifications + property real bgOpacity: 0.0 + property bool isRecording: false + + property var tabIcons: ["widgets", "calendar_month", "content_paste", "notifications"] + + signal recordingRequested + signal stopRecordingRequested + signal systemActionRequested(string action) + signal performanceActionRequested(string action) + + // Animation state management + visible: opacity > 0 + opacity: 0 + x: width + + property var tabNames: ["Main", "Calendar", "Clipboard", "Notifications"] + + Behavior on opacity { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + + Behavior on x { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + + // Background with bottom-only rounded corners + Rectangle { + anchors.fill: parent + color: Data.ThemeManager.bgColor + topLeftRadius: 0 + topRightRadius: 0 + bottomLeftRadius: 20 + bottomRightRadius: 20 + } + + // Shadow effect preparation + Rectangle { + id: shadowSource + anchors.fill: mainContainer + color: "transparent" + visible: false + bottomLeftRadius: 20 + bottomRightRadius: 20 + } + + DropShadow { + anchors.fill: shadowSource + horizontalOffset: 0 + verticalOffset: 2 + radius: 8.0 + samples: 17 + color: "#80000000" + source: shadowSource + z: 1 + } + + // Main container with tab-based content layout + Rectangle { + id: mainContainer + anchors.fill: parent + anchors.margins: 9 + color: "transparent" + radius: 12 + + implicitWidth: 600 // Fixed width for consistency + implicitHeight: 360 + + Behavior on height { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + + MouseArea { + id: backgroundMouseArea + anchors.fill: parent + hoverEnabled: true + propagateComposedEvents: true + } + + // Left sidebar with tab navigation + Item { + id: tabSidebar + width: 40 + height: parent.height + anchors.left: parent.left + anchors.leftMargin: 9 + anchors.top: parent.top + anchors.topMargin: 54 + + property bool containsMouse: sidebarMouseArea.containsMouse || tabColumn.containsMouse + + // Tab button background + Rectangle { + width: 36 + height: tabColumn.height + 8 + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + color: Qt.darker(Data.ThemeManager.bgColor, 1.05) + radius: 18 + border.color: Qt.darker(Data.ThemeManager.bgColor, 1.2) + border.width: 1 + } + + MouseArea { + id: sidebarMouseArea + anchors.fill: parent + hoverEnabled: true + propagateComposedEvents: true + onEntered: hideTimer.stop() + onExited: { + if (!root.isHovered) { + hideTimer.restart(); + } + } + } + + // Tab icon buttons + Column { + id: tabColumn + spacing: 4 + anchors.top: parent.top + anchors.topMargin: 4 + anchors.horizontalCenter: parent.horizontalCenter + + property bool containsMouse: { + for (let i = 0; i < tabRepeater.count; i++) { + let tab = tabRepeater.itemAt(i); + if (tab && tab.children[0] && tab.children[0].containsMouse) { + return true; + } + } + return false; + } + + Repeater { + id: tabRepeater + model: 5 + delegate: Rectangle { + width: 32 + height: 32 + radius: 16 + color: currentTab === index ? Data.ThemeManager.accentColor : Qt.darker(Data.ThemeManager.bgColor, 1.15) + + property bool isHovered: tabMouseArea.containsMouse + + MouseArea { + id: tabMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: root.currentTab = index + onEntered: hideTimer.stop() + onExited: { + if (!root.isHovered) { + hideTimer.restart(); + } + } + } + + Text { + anchors.centerIn: parent + text: root.tabIcons[index] + font.family: "Material Symbols Outlined" + font.pixelSize: 16 + color: currentTab === index ? Data.ThemeManager.bgColor : parent.isHovered ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor + } + } + } + } + } + + // Main content area (positioned right of tab sidebar) + Column { + id: mainColumn + width: parent.width - tabSidebar.width - 45 + anchors.left: tabSidebar.right + anchors.leftMargin: 9 + anchors.top: parent.top + anchors.margins: 18 + spacing: 28 + clip: true + + // Tab 0: Main dashboard content + Column { + width: parent.width + spacing: 28 + visible: root.currentTab === 0 + + // User profile row with theme toggle and weather + Row { + width: parent.width + spacing: 18 + + UserProfile { + id: userProfile + width: parent.width - themeToggle.width - weatherDisplay.width - (parent.spacing * 2) + height: 80 + shell: root.shell + } + + ThemeToggle { + id: themeToggle + width: 40 + height: userProfile.height + } + + WeatherDisplay { + id: weatherDisplay + width: parent.width * 0.18 + height: userProfile.height + shell: root.shell + onEntered: hideTimer.stop() + onExited: hideTimer.restart() + visible: root.visible + enabled: visible + } + } + + // Controls section + Row { + width: parent.width + spacing: 18 + + Column { + width: parent.width + spacing: 28 + + RecordingButton { + id: recordingButton + width: parent.width + height: 48 + shell: root.shell + isRecording: root.isRecording + + onRecordingRequested: root.recordingRequested() + onStopRecordingRequested: root.stopRecordingRequested() + } + + Controls { + id: controls + width: parent.width + isRecording: root.isRecording + shell: root.shell + onPerformanceActionRequested: function (action) { + root.performanceActionRequested(action); + } + onSystemActionRequested: function (action) { + root.systemActionRequested(action); + } + onMouseChanged: function (containsMouse) { + if (containsMouse) { + hideTimer.stop(); + } else if (!root.isHovered) { + hideTimer.restart(); + } + } + } + } + } + + // System tray section with inline menu + Column { + id: systemTraySection + width: parent.width + spacing: 8 + + property bool containsMouse: trayMouseArea.containsMouse || systemTrayModule.containsMouse + + Rectangle { + id: trayBackground + width: parent.width + height: 40 + radius: 20 + color: Qt.darker(Data.ThemeManager.bgColor, 1.15) + + property bool isActive: false + + MouseArea { + id: trayMouseArea + anchors.fill: parent + anchors.margins: -10 + hoverEnabled: true + propagateComposedEvents: true + preventStealing: false + onEntered: trayBackground.isActive = true + onExited: { + if (!inlineTrayMenu.visible) { + Qt.callLater(function () { + if (!systemTrayModule.containsMouse) { + trayBackground.isActive = false; + } + }); + } + } + } + + System.SystemTray { + id: systemTrayModule + anchors.centerIn: parent + shell: root.shell + bar: parent + trayMenu: inlineTrayMenu + } + } + } + + TrayMenu { + id: inlineTrayMenu + parent: mainContainer + width: parent.width + menu: null + systemTrayY: systemTraySection.y + systemTrayHeight: systemTraySection.height + onHideRequested: trayBackground.isActive = false + } + } + + // Tab 1: Calendar content with lazy loading + Column { + width: parent.width + height: 310 + visible: root.currentTab === 1 + spacing: 16 + + Text { + text: "Calendar" + color: Data.ThemeManager.accentColor + font.pixelSize: 18 + font.bold: true + font.family: "FiraCode Nerd Font" + } + + Rectangle { + width: parent.width + height: parent.height - parent.children[0].height - parent.spacing + color: Qt.lighter(Data.ThemeManager.bgColor, 1.2) + radius: 20 + clip: true + + Loader { + anchors.fill: parent + anchors.margins: 20 + active: visible && root.currentTab === 1 + source: active ? "root:/Widgets/Calendar/Calendar.qml" : "" + onLoaded: { + if (item) { + item.shell = root.shell; + } + } + } + } + } + + // Tab 2: Clipboard history with clear button + Column { + width: parent.width + height: 310 + visible: root.currentTab === 2 + spacing: 16 + + RowLayout { + width: parent.width + spacing: 16 + + Text { + text: "Clipboard History" + color: Data.ThemeManager.accentColor + font.pixelSize: 18 + font.bold: true + font.family: "monospace" + } + + Item { + Layout.fillWidth: true + } + + Rectangle { + width: clearClipText.implicitWidth + 16 + height: 24 + radius: 12 + color: clearClipMouseArea.containsMouse ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) : "transparent" + border.color: Data.ThemeManager.accentColor + border.width: 1 + + Text { + id: clearClipText + anchors.centerIn: parent + text: "Clear All" + color: Data.ThemeManager.accentColor + font.family: "monospace" + font.pixelSize: 11 + } + + MouseArea { + id: clearClipMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + // Navigate to clipboard component and call clear + let clipLoader = parent.parent.parent.children[1].children[0]; + if (clipLoader && clipLoader.item && clipLoader.item.children[0]) { + let clipComponent = clipLoader.item.children[0]; + if (clipComponent.clearClipboardHistory) { + clipComponent.clearClipboardHistory(); + } + } + } + } + } + } + + Rectangle { + width: parent.width + height: parent.height - parent.children[0].height - parent.spacing + color: Qt.lighter(Data.ThemeManager.bgColor, 1.2) + radius: 20 + clip: true + + Loader { + anchors.fill: parent + anchors.margins: 20 + active: visible && root.currentTab === 2 + sourceComponent: active ? clipboardHistoryComponent : null + onLoaded: { + if (item && item.children[0]) { + item.children[0].refreshClipboardHistory(); + } + } + } + } + } + + // Tab 3: Notification history with clear button + Column { + width: parent.width + height: 310 + visible: root.currentTab === 3 + spacing: 16 + + RowLayout { + width: parent.width + spacing: 16 + + Text { + text: "Notification History" + color: Data.ThemeManager.accentColor + font.pixelSize: 18 + font.bold: true + font.family: "monospace" + } + + Text { + text: "(" + (root.shell.notificationHistory ? root.shell.notificationHistory.count : 0) + ")" + color: Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 12 + opacity: 0.7 + Layout.alignment: Qt.AlignVCenter + } + + Item { + Layout.fillWidth: true + } + + Rectangle { + width: clearNotifText.implicitWidth + 16 + height: 24 + radius: 12 + color: clearNotifMouseArea.containsMouse ? Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.2) : "transparent" + border.color: Data.ThemeManager.accentColor + border.width: 1 + + Text { + id: clearNotifText + anchors.centerIn: parent + text: "Clear All" + color: Data.ThemeManager.accentColor + font.family: "monospace" + font.pixelSize: 11 + } + + MouseArea { + id: clearNotifMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: root.shell.notificationHistory.clear() + } + } + } + + Rectangle { + width: parent.width + height: parent.height - parent.children[0].height - parent.spacing + color: Qt.lighter(Data.ThemeManager.bgColor, 1.2) + radius: 20 + clip: true + + Loader { + anchors.fill: parent + anchors.margins: 20 + active: visible && root.currentTab === 3 + sourceComponent: active ? notificationHistoryComponent : null + } + } + } + } + } + + // Lazy-loaded components for tab content + Component { + id: clipboardHistoryComponent + Item { + anchors.fill: parent + + System.Cliphist { + id: cliphistComponent + anchors.fill: parent + shell: root.shell + + // Hide built-in header (we provide our own) + Component.onCompleted: { + for (let i = 0; i < children.length; i++) { + let child = children[i]; + if (child.objectName === "contentColumn" || child.toString().includes("ColumnLayout")) { + if (child.children && child.children.length > 0) { + child.children[0].visible = false; + } + } + } + } + } + } + } + + Component { + id: notificationHistoryComponent + Item { + anchors.fill: parent + + Notifications.NotificationHistory { + anchors.fill: parent + shell: root.shell + clip: true + + // Hide built-in header (we provide our own) + Component.onCompleted: { + for (let i = 0; i < children.length; i++) { + let child = children[i]; + if (child.objectName === "contentColumn" || child.toString().includes("ColumnLayout")) { + if (child.children && child.children.length > 0) { + child.children[0].visible = false; + } + } + } + } + } + } + } + + // Complex hover state calculation for auto-hide behavior + property bool isHovered: { + const menuStates = { + inlineMenuActive: inlineTrayMenu.menuJustOpened || inlineTrayMenu.visible, + trayActive: trayBackground.isActive, + tabContentActive: currentTab !== 0 + }; + + if (menuStates.inlineMenuActive || menuStates.trayActive || menuStates.tabContentActive) + return true; + + const mouseStates = { + backgroundHovered: backgroundMouseArea.containsMouse, + recordingHovered: recordingButton.containsMouse, + controlsHovered: controls.containsMouse, + profileHovered: userProfile.isHovered, + themeToggleHovered: themeToggle.containsMouse, + systemTrayHovered: systemTraySection.containsMouse || trayMouseArea.containsMouse || systemTrayModule.containsMouse, + menuHovered: inlineTrayMenu.containsMouse, + weatherHovered: weatherDisplay.containsMouse, + tabSidebarHovered: tabSidebar.containsMouse, + mainContentHovered: mainColumn.children[0].visible && backgroundMouseArea.containsMouse + }; + + return Object.values(mouseStates).some(state => state); + } + + // Auto-hide timer + Timer { + id: hideTimer + interval: 500 + repeat: false + onTriggered: hide() + } + + onIsHoveredChanged: { + if (isHovered) { + hideTimer.stop(); + } else if (!inlineTrayMenu.visible && !trayBackground.isActive && !tabSidebar.containsMouse && !tabColumn.containsMouse) { + hideTimer.restart(); + } + } + + function show() { + if (isShown) + return; + isShown = true; + hideTimer.stop(); + opacity = 1; + x = 0; + } + + function hide() { + if (!isShown || inlineTrayMenu.menuJustOpened || inlineTrayMenu.visible) + return; + // Only hide on main tab when nothing is hovered + if (currentTab === 0 && !isHovered) { + isShown = false; + x = width; + opacity = 0; + + // Hide parent TopPanel as well + if (parent && parent.parent && parent.parent.hide) { + parent.parent.hide(); + } + } + } + + Component.onCompleted: { + Qt.callLater(function () { + mainColumn.visible = true; + }); + } + + // Border integration corners + Core.Corners { + id: topLeftCorner + position: "bottomright" + size: 1.3 + fillColor: Data.ThemeManager.bgColor + offsetX: 0 + offsetY: 0 + } + + Core.Corners { + id: topRightCorner + position: "bottomleft" + size: 1.3 + fillColor: Data.ThemeManager.bgColor + offsetX: root.width + offsetY: 0 + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Panel/modules/PerformanceControls.qml b/modules/desktop/quickshell/qml/Widgets/Panel/modules/PerformanceControls.qml new file mode 100644 index 0000000..261b47f --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Panel/modules/PerformanceControls.qml @@ -0,0 +1,122 @@ +import QtQuick +import QtQuick.Controls +import Quickshell.Services.UPower + +Column { + id: root + required property var shell + + spacing: 8 + signal performanceActionRequested(string action) + signal mouseChanged(bool containsMouse) + + readonly property bool containsMouse: performanceButton.containsMouse || balancedButton.containsMouse || powerSaverButton.containsMouse + + // Safe property access with fallbacks + readonly property bool upowerReady: typeof PowerProfiles !== 'undefined' && PowerProfiles + readonly property int currentProfile: upowerReady ? PowerProfiles.profile : 0 + + onContainsMouseChanged: root.mouseChanged(containsMouse) + + opacity: visible ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + } + } + + Row { + spacing: 8 + width: parent.width + + // Performance Profile Button + SystemButton { + id: performanceButton + width: (parent.width - parent.spacing * 2) / 3 + height: 52 + + shell: root.shell + iconText: "speed" + + isActive: root.upowerReady && (typeof PowerProfile !== 'undefined') ? root.currentProfile === PowerProfile.Performance : false + + onClicked: { + if (root.upowerReady && typeof PowerProfile !== 'undefined') { + PowerProfiles.profile = PowerProfile.Performance; + root.performanceActionRequested("performance"); + } else { + console.warn("PowerProfiles not available"); + } + } + onMouseChanged: function (containsMouse) { + if (!containsMouse && !root.containsMouse) { + root.mouseChanged(false); + } + } + } + + // Balanced Profile Button + SystemButton { + id: balancedButton + width: (parent.width - parent.spacing * 2) / 3 + height: 52 + + shell: root.shell + iconText: "balance" + + isActive: root.upowerReady && (typeof PowerProfile !== 'undefined') ? root.currentProfile === PowerProfile.Balanced : false + + onClicked: { + if (root.upowerReady && typeof PowerProfile !== 'undefined') { + PowerProfiles.profile = PowerProfile.Balanced; + root.performanceActionRequested("balanced"); + } else { + console.warn("PowerProfiles not available"); + } + } + onMouseChanged: function (containsMouse) { + if (!containsMouse && !root.containsMouse) { + root.mouseChanged(false); + } + } + } + + // Power Saver Profile Button + SystemButton { + id: powerSaverButton + width: (parent.width - parent.spacing * 2) / 3 + height: 52 + + shell: root.shell + iconText: "battery_saver" + + isActive: root.upowerReady && (typeof PowerProfile !== 'undefined') ? root.currentProfile === PowerProfile.PowerSaver : false + + onClicked: { + if (root.upowerReady && typeof PowerProfile !== 'undefined') { + PowerProfiles.profile = PowerProfile.PowerSaver; + root.performanceActionRequested("powersaver"); + } else { + console.warn("PowerProfiles not available"); + } + } + onMouseChanged: function (containsMouse) { + if (!containsMouse && !root.containsMouse) { + root.mouseChanged(false); + } + } + } + } + + // Optional: Add a small delay to ensure services are ready + Component.onCompleted: { + // Small delay to ensure UPower service is fully initialized + Qt.callLater(function () { + if (!root.upowerReady) { + console.warn("UPower service not ready - performance controls may not work correctly"); + } + }); + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Panel/modules/RecordingButton.qml b/modules/desktop/quickshell/qml/Widgets/Panel/modules/RecordingButton.qml new file mode 100644 index 0000000..3fcf704 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Panel/modules/RecordingButton.qml @@ -0,0 +1,60 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import "root:/Data" as Data + +Rectangle { + id: root + required property var shell + required property bool isRecording + radius: 20 + + signal recordingRequested + signal stopRecordingRequested + signal mouseChanged(bool containsMouse) + + // Gray by default, accent color on hover or when recording + color: isRecording ? Data.ThemeManager.accentColor : (mouseArea.containsMouse ? Data.ThemeManager.accentColor : Qt.darker(Data.ThemeManager.bgColor, 1.15)) + + property bool isHovered: mouseArea.containsMouse + readonly property alias containsMouse: mouseArea.containsMouse + + RowLayout { + anchors.centerIn: parent + spacing: 10 + + Text { + text: isRecording ? "stop_circle" : "radio_button_unchecked" + font.family: "Material Symbols Outlined" + font.pixelSize: 16 + color: isRecording || mouseArea.containsMouse ? "#ffffff" : Data.ThemeManager.fgColor + + Layout.alignment: Qt.AlignVCenter + } + + Label { + text: isRecording ? "Stop Recording" : "Start Recording" + font.pixelSize: 13 + font.weight: Font.Medium + color: isRecording || mouseArea.containsMouse ? "#ffffff" : Data.ThemeManager.fgColor + + Layout.alignment: Qt.AlignVCenter + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + + onContainsMouseChanged: root.mouseChanged(containsMouse) + + onClicked: { + if (isRecording) { + root.stopRecordingRequested(); + } else { + root.recordingRequested(); + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Panel/modules/SystemButton.qml b/modules/desktop/quickshell/qml/Widgets/Panel/modules/SystemButton.qml new file mode 100644 index 0000000..11e2f66 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Panel/modules/SystemButton.qml @@ -0,0 +1,112 @@ +import QtQuick +import QtQuick.Controls +import "root:/Data" as Data + +Rectangle { + id: root + required property var shell + required property string iconText + property string labelText: "" + + // Add active state property + property bool isActive: false + + radius: 20 + + // Modified color logic to handle active state + color: { + if (isActive) { + return mouseArea.containsMouse ? Qt.lighter(Data.ThemeManager.accentColor, 1.1) : Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.3); + } else { + return mouseArea.containsMouse ? Qt.lighter(Data.ThemeManager.accentColor, 1.2) : Qt.lighter(Data.ThemeManager.bgColor, 1.15); + } + } + + border.width: isActive ? 2 : 1 + border.color: isActive ? Data.ThemeManager.accentColor : Qt.lighter(Data.ThemeManager.bgColor, 1.3) + + signal clicked + signal mouseChanged(bool containsMouse) + property bool isHovered: mouseArea.containsMouse + readonly property alias containsMouse: mouseArea.containsMouse + + Behavior on color { + ColorAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + + Behavior on border.color { + ColorAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + + scale: isHovered ? 1.05 : 1.0 + Behavior on scale { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + + Column { + anchors.centerIn: parent + spacing: 2 + + Text { + text: root.iconText + font.family: "Material Symbols Outlined" + font.pixelSize: 16 + anchors.horizontalCenter: parent.horizontalCenter + color: { + if (root.isActive) { + return root.isHovered ? "#ffffff" : Data.ThemeManager.accentColor; + } else { + return root.isHovered ? "#ffffff" : Data.ThemeManager.accentColor; + } + } + + Behavior on color { + ColorAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + } + + Label { + text: root.labelText + font.family: "monospace" + font.pixelSize: 8 + color: { + if (root.isActive) { + return root.isHovered ? "#ffffff" : Data.ThemeManager.accentColor; + } else { + return root.isHovered ? "#ffffff" : Data.ThemeManager.accentColor; + } + } + anchors.horizontalCenter: parent.horizontalCenter + font.weight: root.isActive ? Font.Bold : Font.Medium + visible: root.labelText !== "" + + Behavior on color { + ColorAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + + onContainsMouseChanged: root.mouseChanged(containsMouse) + onClicked: root.clicked() + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Panel/modules/SystemControls.qml b/modules/desktop/quickshell/qml/Widgets/Panel/modules/SystemControls.qml new file mode 100644 index 0000000..c591b95 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Panel/modules/SystemControls.qml @@ -0,0 +1,59 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +RowLayout { + id: root + required property var shell + + spacing: 8 + signal systemActionRequested(string action) + signal mouseChanged(bool containsMouse) + + readonly property bool containsMouse: lockButton.containsMouse || rebootButton.containsMouse || shutdownButton.containsMouse + + onContainsMouseChanged: root.mouseChanged(containsMouse) + + opacity: visible ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + } + } + + // Reboot Button + SystemButton { + id: rebootButton + Layout.fillHeight: true + Layout.fillWidth: true + + shell: root.shell + iconText: "restart_alt" + + onClicked: root.systemActionRequested("reboot") + onMouseChanged: function (containsMouse) { + if (!containsMouse && !root.containsMouse) { + root.mouseChanged(false); + } + } + } + + // Shutdown Button + SystemButton { + id: shutdownButton + Layout.fillHeight: true + Layout.fillWidth: true + + shell: root.shell + iconText: "power_settings_new" + + onClicked: root.systemActionRequested("shutdown") + onMouseChanged: function (containsMouse) { + if (!containsMouse && !root.containsMouse) { + root.mouseChanged(false); + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Panel/modules/ThemeToggle.qml b/modules/desktop/quickshell/qml/Widgets/Panel/modules/ThemeToggle.qml new file mode 100644 index 0000000..000ae03 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Panel/modules/ThemeToggle.qml @@ -0,0 +1,42 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "root:/Data" as Data + +Rectangle { + id: root + property var shell: null + color: Qt.darker(Data.ThemeManager.bgColor, 1.15) + radius: 20 + + property bool containsMouse: themeMouseArea.containsMouse + property bool menuJustOpened: false + + signal entered + signal exited + + onContainsMouseChanged: { + if (containsMouse) { + entered(); + } else if (!menuJustOpened) { + exited(); + } + } + + MouseArea { + id: themeMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + Data.ThemeManager.toggleTheme(); + } + } + + Label { + anchors.centerIn: parent + text: "contrast" + font.pixelSize: 24 + font.family: "Material Symbols Outlined" + color: containsMouse ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Panel/modules/TopPanelTrigger.qml b/modules/desktop/quickshell/qml/Widgets/Panel/modules/TopPanelTrigger.qml new file mode 100644 index 0000000..a821429 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Panel/modules/TopPanelTrigger.qml @@ -0,0 +1,52 @@ +import QtQuick + +Rectangle { + id: root + width: 360 + height: 1 + color: "red" + anchors.top: parent.top + + signal triggered + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + + property bool isHovered: containsMouse + + onIsHoveredChanged: { + if (isHovered) { + showTimer.start(); + hideTimer.stop(); + } else { + hideTimer.start(); + showTimer.stop(); + } + } + + onEntered: hideTimer.stop() + } + + // Smooth show/hide timers + Timer { + id: showTimer + interval: 200 + onTriggered: root.triggered() + } + + Timer { + id: hideTimer + interval: 500 + } + + // Exposed properties and functions + readonly property alias containsMouse: mouseArea.containsMouse + function stopHideTimer() { + hideTimer.stop(); + } + function startHideTimer() { + hideTimer.start(); + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Panel/modules/TrayMenu.qml b/modules/desktop/quickshell/qml/Widgets/Panel/modules/TrayMenu.qml new file mode 100644 index 0000000..b4c3b21 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Panel/modules/TrayMenu.qml @@ -0,0 +1,216 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import "root:/Data" as Data + +Rectangle { + id: root + width: parent.width + height: visible ? calculatedHeight : 0 + visible: false + enabled: visible + clip: true + color: Data.ThemeManager.bgColor + radius: 20 + + required property var menu + required property var systemTrayY + required property var systemTrayHeight + + property bool containsMouse: trayMenuMouseArea.containsMouse + property bool menuJustOpened: false + property point triggerPoint: Qt.point(0, 0) + property Item originalParent + property int totalCount: opener.children ? opener.children.values.length : 0 + + signal hideRequested + + MouseArea { + id: trayMenuMouseArea + anchors.fill: parent + hoverEnabled: true + preventStealing: true + propagateComposedEvents: false + } + + onVisibleChanged: { + if (visible) { + menuJustOpened = true; + Qt.callLater(function () { + menuJustOpened = false; + }); + } + } + + function toggle() { + visible = !visible; + if (visible) { + menuJustOpened = true; + Qt.callLater(function () { + menuJustOpened = false; + }); + } + } + + function show(point, parentItem) { + visible = true; + menuJustOpened = true; + Qt.callLater(function () { + menuJustOpened = false; + }); + } + + function hide() { + visible = false; + menuJustOpened = false; + hideRequested(); + } + + y: { + var preferredY = systemTrayY + systemTrayHeight + 10; + var availableSpace = parent.height - preferredY - 20; + if (calculatedHeight > availableSpace) { + return systemTrayY - height - 10; + } + return preferredY; + } + + Behavior on height { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + + property int calculatedHeight: { + if (totalCount === 0) + return 40; + var separatorCount = 0; + var regularItemCount = 0; + + if (opener.children && opener.children.values) { + for (var i = 0; i < opener.children.values.length; i++) { + if (opener.children.values[i].isSeparator) { + separatorCount++; + } else { + regularItemCount++; + } + } + } + + var separatorHeight = separatorCount * 12; + var regularItemRows = Math.ceil(regularItemCount / 2); + var regularItemHeight = regularItemRows * 32; + return Math.max(80, 35 + separatorHeight + regularItemHeight + 40); + } + + QsMenuOpener { + id: opener + menu: root.menu + } + + GridView { + id: gridView + anchors.fill: parent + anchors.margins: 20 + cellWidth: width / 2 + cellHeight: 32 + interactive: false + flow: GridView.FlowLeftToRight + layoutDirection: Qt.LeftToRight + + model: ScriptModel { + values: opener.children ? [...opener.children.values] : [] + } + + delegate: Item { + id: entry + required property var modelData + required property int index + + width: gridView.cellWidth - 4 + height: modelData.isSeparator ? 12 : 30 + + Rectangle { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + anchors.topMargin: 4 + anchors.bottomMargin: 4 + visible: modelData.isSeparator + color: "transparent" + + Rectangle { + anchors.centerIn: parent + width: parent.width * 0.8 + height: 1 + color: Qt.darker(Data.ThemeManager.accentColor, 1.5) + opacity: 0.6 + } + } + + Rectangle { + id: itemBackground + anchors.fill: parent + anchors.margins: 2 + visible: !modelData.isSeparator + color: "transparent" + radius: 6 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 6 + + Image { + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + source: modelData?.icon ?? "" + visible: (modelData?.icon ?? "") !== "" + fillMode: Image.PreserveAspectFit + } + + Text { + Layout.fillWidth: true + color: mouseArea.containsMouse ? Data.ThemeManager.accentColor : Data.ThemeManager.fgColor + text: modelData?.text ?? "" + font.pixelSize: 11 + font.family: "FiraCode Nerd Font" + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + maximumLineCount: 1 + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + enabled: (modelData?.enabled ?? true) && root.visible && !modelData.isSeparator + + onEntered: itemBackground.color = Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.15) + onExited: itemBackground.color = "transparent" + onClicked: { + modelData.triggered(); + root.hide(); + } + } + } + } + } + + Item { + anchors.centerIn: gridView + visible: gridView.count === 0 + + Label { + anchors.centerIn: parent + text: "No tray items available" + color: Qt.darker(Data.ThemeManager.fgColor, 2) + font.pixelSize: 14 + font.family: "FiraCode Nerd Font" + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Panel/modules/UserProfile.qml b/modules/desktop/quickshell/qml/Widgets/Panel/modules/UserProfile.qml new file mode 100644 index 0000000..8e129dc --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Panel/modules/UserProfile.qml @@ -0,0 +1,228 @@ +import Quickshell.Io +import QtQuick +import QtQuick.Controls +import Qt5Compat.GraphicalEffects +import "root:/Data/" as Data + +Rectangle { + id: root + required property var shell + property url avatarSource: Data.Settings.avatarSource + property string userName: "" // will be set by process output + property string userInfo: "" // will hold uptime string + + property bool isActive: false + property bool isHovered: false // track hover state + + radius: 20 + width: 220 + height: 80 + + color: { + if (isActive) { + return isHovered ? Qt.lighter(Data.ThemeManager.accentColor, 1.1) : Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.3); + } else { + return isHovered ? Qt.lighter(Data.ThemeManager.accentColor, 1.2) : Qt.lighter(Data.ThemeManager.bgColor, 1.15); + } + } + + border.width: isActive ? 2 : 1 + border.color: isActive ? Data.ThemeManager.accentColor : Qt.lighter(Data.ThemeManager.bgColor, 1.3) + + Row { + anchors.fill: parent + anchors.margins: 14 + spacing: 12 + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + id: avatarCircle + width: 52 + height: 52 + radius: 20 + clip: true + border.color: Data.ThemeManager.accentColor + border.width: 3 + color: "transparent" + + Image { + id: avatarImage + anchors.fill: parent + anchors.margins: 2 + source: Data.Settings.avatarSource + fillMode: Image.PreserveAspectCrop + cache: false + visible: false // Hide the original image + asynchronous: true + sourceSize.width: 48 // Limit image resolution to save memory + sourceSize.height: 48 + } + + OpacityMask { + anchors.fill: avatarImage + source: avatarImage + maskSource: Rectangle { + width: avatarImage.width + height: avatarImage.height + radius: 18 // Proportionally smaller than parent (48/52 * 20 ≈ 18) + visible: false + } + } + } + + Column { + spacing: 4 + anchors.verticalCenter: parent.verticalCenter + width: parent.width - avatarCircle.width - gifContainer.width - parent.spacing * 2 + + Text { + width: parent.width + text: root.userName === "" ? "Loading..." : root.userName + font.family: "monospace" + font.pixelSize: 16 + font.bold: true + color: isHovered || root.isActive ? Data.ThemeManager.bgColor : Data.ThemeManager.accentColor + elide: Text.ElideRight + maximumLineCount: 1 + } + + Text { + width: parent.width + text: root.userInfo === "" ? "Loading uptime..." : root.userInfo + font.family: "monospace" + font.pixelSize: 11 + font.bold: true + color: isHovered || root.isActive ? "#cccccc" : Qt.lighter(Data.ThemeManager.accentColor, 1.6) + elide: Text.ElideRight + maximumLineCount: 1 + } + } + + Rectangle { + id: gifContainer + width: 80 + height: 80 + radius: 12 + color: "transparent" + anchors.verticalCenter: parent.verticalCenter + + AnimatedImage { + id: animatedImage + source: "root:/Assets/UserProfile.gif" + anchors.fill: parent + fillMode: Image.PreserveAspectFit + playing: true + cache: false + speed: 1.0 + asynchronous: true + } + + // Always enable layer effects for rounded corners + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: gifContainer.width + height: gifContainer.height + radius: gifContainer.radius + visible: false + } + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: root.isHovered = true + onExited: root.isHovered = false + } + + Process { + id: usernameProcess + running: true // Always run to get username + command: ["sh", "-c", "whoami"] + + stdout: SplitParser { + splitMarker: "\n" + onRead: data => { + const line = data.trim(); + if (line.length > 0) { + root.userName = line.charAt(0).toUpperCase() + line.slice(1); + } + } + } + } + + Process { + id: uptimeProcess + running: false + command: ["sh", "-c", "uptime"] // Use basic uptime command + + stdout: SplitParser { + splitMarker: "\n" + onRead: data => { + const line = data.trim(); + if (line.length > 0) { + // Parse traditional uptime output: " 10:30:00 up 1:23, 2 users, load average: 0.08, 0.02, 0.01" + const match = line.match(/up\s+(.+?),\s+\d+\s+user/); + if (match && match[1]) { + root.userInfo = "Up: " + match[1].trim(); + } else { + // Fallback parsing + const upIndex = line.indexOf("up "); + if (upIndex !== -1) { + const afterUp = line.substring(upIndex + 3); + const commaIndex = afterUp.indexOf(","); + if (commaIndex !== -1) { + root.userInfo = "Up: " + afterUp.substring(0, commaIndex).trim(); + } else { + root.userInfo = "Up: " + afterUp.trim(); + } + } else { + root.userInfo = "Uptime unknown"; + } + } + } else { + root.userInfo = "Uptime unknown"; + } + } + } + + stderr: SplitParser { + splitMarker: "\n" + onRead: data => { + console.log("Uptime error:", data); + root.userInfo = "Uptime error"; + } + } + } + + Timer { + id: uptimeTimer + interval: 300000 // Update every 5 minutes + running: true // Always run the uptime timer + repeat: true + onTriggered: { + uptimeProcess.running = false; + uptimeProcess.running = true; + } + } + + Component.onCompleted: { + uptimeProcess.running = true; // Start uptime process on component load + } + + Component.onDestruction: { + if (usernameProcess.running) { + usernameProcess.running = false; + } + if (uptimeProcess.running) { + uptimeProcess.running = false; + } + if (uptimeTimer.running) { + uptimeTimer.running = false; + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Panel/modules/WeatherDisplay.qml b/modules/desktop/quickshell/qml/Widgets/Panel/modules/WeatherDisplay.qml new file mode 100644 index 0000000..4fa1440 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Panel/modules/WeatherDisplay.qml @@ -0,0 +1,332 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "root:/Data" as Data + +Rectangle { + id: root + required property var shell + color: Qt.darker(Data.ThemeManager.bgColor, 1.15) + radius: 20 + + property bool containsMouse: weatherMouseArea.containsMouse || (forecastPopup.visible && forecastPopup.containsMouse) + property bool menuJustOpened: false + + signal entered + signal exited + + onContainsMouseChanged: { + if (containsMouse) { + entered(); + } else if (!menuJustOpened && !forecastPopup.visible) { + exited(); + } + } + + function getWeatherIcon(condition) { + if (!condition) + return "light_mode"; + + const c = condition.toString(); + + const iconMap = { + "0": "light_mode", + "1": "light_mode", + "2": "cloud", + "3": "cloud", + "45": "foggy", + "48": "foggy", + "51": "water_drop", + "53": "water_drop", + "55": "water_drop", + "61": "water_drop", + "63": "water_drop", + "65": "water_drop", + "71": "ac_unit", + "73": "ac_unit", + "75": "ac_unit", + "80": "water_drop", + "81": "water_drop", + "82": "water_drop", + "95": "thunderstorm", + "96": "thunderstorm", + "99": "thunderstorm" + }; + + if (iconMap[c]) + return iconMap[c]; + + const textMap = { + "clear sky": "light_mode", + "mainly clear": "light_mode", + "partly cloudy": "cloud", + "overcast": "cloud", + "fog": "foggy", + "drizzle": "water_drop", + "rain": "water_drop", + "snow": "ac_unit", + "thunderstorm": "thunderstorm" + }; + + const lower = condition.toLowerCase(); + for (let key in textMap) { + if (lower.includes(key)) + return textMap[key]; + } + + return "help"; + } + + MouseArea { + id: weatherMouseArea + anchors.fill: parent + hoverEnabled: true + onEntered: { + menuJustOpened = true; + forecastPopup.open(); + Qt.callLater(() => menuJustOpened = false); + } + onExited: { + if (!forecastPopup.containsMouse && !menuJustOpened) { + forecastPopup.close(); + } + } + } + + RowLayout { + id: weatherLayout + anchors.centerIn: parent + spacing: 8 + + ColumnLayout { + spacing: 2 + Layout.alignment: Qt.AlignVCenter + + Label { + text: { + if (shell.weatherLoading) + return "refresh"; + if (!shell.weatherData) + return "help"; + return root.getWeatherIcon(shell.weatherData.currentCondition); + } + font.pixelSize: 28 + font.family: "Material Symbols Outlined" + color: Data.ThemeManager.accentColor + Layout.alignment: Qt.AlignHCenter + } + + Label { + text: { + if (shell.weatherLoading) + return "Loading..."; + if (!shell.weatherData) + return "No weather data"; + return shell.weatherData.currentTemp; + } + color: Data.ThemeManager.fgColor + font.pixelSize: 20 + font.bold: true + Layout.alignment: Qt.AlignHCenter + } + } + } + + Popup { + id: forecastPopup + y: parent.height + 28 + x: Math.min(0, parent.width - width) + width: 300 + height: 226 + padding: 12 + background: Rectangle { + color: Qt.darker(Data.ThemeManager.bgColor, 1.15) + radius: 20 + border.width: 1 + border.color: Qt.lighter(Data.ThemeManager.bgColor, 1.3) + } + + property bool containsMouse: forecastMouseArea.containsMouse + + onVisibleChanged: { + if (visible) { + entered(); + } else if (!weatherMouseArea.containsMouse && !menuJustOpened) { + exited(); + } + } + + MouseArea { + id: forecastMouseArea + anchors.fill: parent + hoverEnabled: true + onExited: { + if (!weatherMouseArea.containsMouse && !menuJustOpened) { + forecastPopup.close(); + } + } + } + + ColumnLayout { + id: forecastColumn + anchors.fill: parent + anchors.margins: 10 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Label { + text: shell.weatherData ? root.getWeatherIcon(shell.weatherData.currentCondition) : "" + font.pixelSize: 48 + font.family: "Material Symbols Outlined" + color: Data.ThemeManager.accentColor + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Label { + text: shell.weatherData ? shell.weatherData.currentCondition : "" + color: Data.ThemeManager.fgColor + font.pixelSize: 14 + font.bold: true + Layout.fillWidth: true + elide: Text.ElideRight + } + + RowLayout { + spacing: 8 + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + + RowLayout { + spacing: 4 + Layout.alignment: Qt.AlignVCenter + Label { + text: "thermostat" + font.family: "Material Symbols Outlined" + font.pixelSize: 12 + color: Data.ThemeManager.accentColor + } + Label { + text: shell.weatherData ? shell.weatherData.currentTemp : "" + color: Data.ThemeManager.fgColor + font.pixelSize: 12 + } + } + + Rectangle { + width: 1 + height: 12 + color: Qt.lighter(Data.ThemeManager.bgColor, 1.3) + } + + RowLayout { + spacing: 4 + Layout.alignment: Qt.AlignVCenter + Label { + text: "air" + font.family: "Material Symbols Outlined" + font.pixelSize: 12 + color: Data.ThemeManager.accentColor + } + Label { + text: { + if (!shell.weatherData || !shell.weatherData.details) + return ""; + const windInfo = shell.weatherData.details.find(d => d.startsWith("Wind:")); + return windInfo ? windInfo.split(": ")[1] : ""; + } + color: Data.ThemeManager.fgColor + font.pixelSize: 12 + } + } + + Rectangle { + width: 1 + height: 12 + color: Qt.lighter(Data.ThemeManager.bgColor, 1.3) + } + + RowLayout { + spacing: 4 + Layout.alignment: Qt.AlignVCenter + Label { + text: "explore" + font.family: "Material Symbols Outlined" + font.pixelSize: 12 + color: Data.ThemeManager.accentColor + } + Label { + text: { + if (!shell.weatherData || !shell.weatherData.details) + return ""; + const dirInfo = shell.weatherData.details.find(d => d.startsWith("Direction:")); + return dirInfo ? dirInfo.split(": ")[1] : ""; + } + color: Data.ThemeManager.fgColor + font.pixelSize: 12 + } + } + + Item { + Layout.fillWidth: true + } + } + } + } + + Rectangle { + height: 1 + Layout.fillWidth: true + color: Qt.lighter(Data.ThemeManager.bgColor, 1.3) + } + + Label { + text: "3-Day Forecast" + color: Data.ThemeManager.accentColor + font.pixelSize: 12 + font.bold: true + } + + Row { + spacing: 8 + Layout.fillWidth: true + + Repeater { + model: shell.weatherData ? shell.weatherData.forecast : [] + delegate: Column { + width: (parent.width - 16) / 3 + spacing: 2 + + Label { + text: modelData.dayName + color: Data.ThemeManager.fgColor + font.pixelSize: 10 + font.bold: true + anchors.horizontalCenter: parent.horizontalCenter + } + + Label { + text: root.getWeatherIcon(modelData.condition) + font.pixelSize: 16 + font.family: "Material Symbols Outlined" + color: Data.ThemeManager.accentColor + anchors.horizontalCenter: parent.horizontalCenter + } + + Label { + text: modelData.minTemp + "° - " + modelData.maxTemp + "°" + color: Data.ThemeManager.fgColor + font.pixelSize: 10 + anchors.horizontalCenter: parent.horizontalCenter + } + } + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/System/Cliphist.qml b/modules/desktop/quickshell/qml/Widgets/System/Cliphist.qml new file mode 100644 index 0000000..42e83a6 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/System/Cliphist.qml @@ -0,0 +1,556 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Shapes +import Quickshell +import Quickshell.Io +import "root:/Data" as Data + +// Clipboard history manager with cliphist integration +Item { + id: root + required property var shell + property string selectedWidget: "cliphist" + + property bool isVisible: false + property real bgOpacity: 0.0 + + transformOrigin: Item.Center + + function show() { + showAnimation.start(); + } + function hide() { + hideAnimation.start(); + } + function toggle() { + isVisible ? hide() : show(); + } + + // Smooth show/hide animations + ParallelAnimation { + id: showAnimation + PropertyAction { + target: root + property: "isVisible" + value: true + } + PropertyAnimation { + target: root + property: "opacity" + from: 0.0 + to: 1.0 + duration: 200 + easing.type: Easing.OutCubic + } + PropertyAnimation { + target: root + property: "scale" + from: 0.9 + to: 1.0 + duration: 200 + easing.type: Easing.OutCubic + } + } + + ParallelAnimation { + id: hideAnimation + PropertyAnimation { + target: root + property: "opacity" + to: 0.0 + duration: 150 + easing.type: Easing.InCubic + } + PropertyAnimation { + target: root + property: "scale" + to: 0.95 + duration: 150 + easing.type: Easing.InCubic + } + PropertyAction { + target: root + property: "isVisible" + value: false + } + } + + ColumnLayout { + id: contentColumn + anchors.fill: parent + spacing: 12 + + // Header + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 30 + + Label { + text: "Clipboard History" + font.pixelSize: 16 + font.weight: Font.Medium + color: Data.ThemeManager.fgColor + Layout.fillWidth: true + } + + Button { + id: clearButton + text: "Clear" + implicitWidth: 60 + implicitHeight: 25 + background: Rectangle { + radius: 12 + color: parent.down ? Qt.darker(Data.ThemeManager.accentColor, 1.2) : parent.hovered ? Qt.lighter(Data.ThemeManager.accentColor, 1.1) : Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.8) + } + contentItem: Label { + text: parent.text + font.pixelSize: 11 + color: Data.ThemeManager.fgColor + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + onClicked: { + clearClipboardHistory(); + clickScale.target = clearButton; + clickScale.start(); + } + } + } + + // Scrollable clipboard history list + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + ScrollView { + id: scrollView + anchors.fill: parent + clip: true + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + interactive: true + visible: cliphistList.contentHeight > cliphistList.height + contentItem: Rectangle { + implicitWidth: 6 + radius: width / 2 + color: parent.pressed ? Data.ThemeManager.accentColor : parent.hovered ? Qt.lighter(Data.ThemeManager.accentColor, 1.2) : Qt.rgba(Data.ThemeManager.accentColor.r, Data.ThemeManager.accentColor.g, Data.ThemeManager.accentColor.b, 0.7) + } + } + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ListView { + id: cliphistList + model: cliphistModel + spacing: 6 + cacheBuffer: 50 // Memory optimization + reuseItems: true + boundsBehavior: Flickable.StopAtBounds + maximumFlickVelocity: 2500 + flickDeceleration: 1500 + + // Smooth scrolling behavior + property real targetY: contentY + Behavior on targetY { + NumberAnimation { + duration: 200 + easing.type: Easing.OutQuad + } + } + + onTargetYChanged: { + if (!moving && !dragging) { + contentY = targetY; + } + } + + delegate: Rectangle { + width: cliphistList.width + height: Math.max(50, contentText.contentHeight + 20) + radius: 8 + color: mouseArea.containsMouse ? Qt.darker(Data.ThemeManager.bgColor, 1.15) : Qt.darker(Data.ThemeManager.bgColor, 1.1) + border.color: Data.ThemeManager.accentColor + border.width: 1 + + // View optimization - only render visible items + visible: y + height > cliphistList.contentY - height && y < cliphistList.contentY + cliphistList.height + height + + RowLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + + // Content type icon + Label { + text: model.type === "image" ? "🖼️" : model.type === "url" ? "🔗" : "📝" + font.pixelSize: 16 + Layout.alignment: Qt.AlignTop + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 4 + + Label { + id: contentText + text: model.type === "image" ? "[Image Data]" : (model.content.length > 100 ? model.content.substring(0, 100) + "..." : model.content) + font.pixelSize: 12 + color: Data.ThemeManager.fgColor + wrapMode: Text.WordWrap + Layout.fillWidth: true + Layout.fillHeight: true + elide: Text.ElideRight + maximumLineCount: 4 + } + + RowLayout { + Layout.fillWidth: true + Item { + Layout.fillWidth: true + } + Label { + text: model.type === "image" ? "Image" : (model.content.length + " chars") + font.pixelSize: 10 + color: Qt.darker(Data.ThemeManager.fgColor, 1.5) + } + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + copyToClipboard(model.id, model.type); + clickScale.target = parent; + clickScale.start(); + } + } + } + } + + // Empty state message + Label { + anchors.centerIn: parent + text: "No clipboard history\nCopy something to get started" + font.pixelSize: 14 + color: Qt.darker(Data.ThemeManager.fgColor, 1.5) + horizontalAlignment: Text.AlignHCenter + visible: cliphistList.count === 0 + opacity: 0.7 + } + } + } + } + } + + // Click feedback animation + NumberAnimation { + id: clickScale + property Item target + properties: "scale" + from: 0.95 + to: 1.0 + duration: 150 + easing.type: Easing.OutCubic + } + + ListModel { + id: cliphistModel + } + + property var currentEntries: [] + + // Main cliphist process for fetching clipboard history + Process { + id: cliphistProcess + command: ["cliphist", "list"] + running: false + + property var tempEntries: [] + + onRunningChanged: { + if (running) { + tempEntries = []; + } else { + // Process completed, apply smart diff update + updateModelIfChanged(tempEntries); + } + } + + stdout: SplitParser { + onRead: data => { + try { + const line = data.toString().trim(); + + // Skip empty lines and error messages + if (line === "" || line.includes("ERROR") || line.includes("WARN") || line.includes("error:") || line.includes("warning:")) { + return; + } + + // Parse cliphist output format: ID + spaces + content + const match = line.match(/^(\d+)\s+(.+)$/); + if (match) { + const id = match[1]; + const content = match[2]; + + cliphistProcess.tempEntries.push({ + id: id, + content: content, + type: detectContentType(content) + }); + } else { + console.log("Failed to parse line:", line); + } + } catch (e) { + console.error("Error parsing cliphist line:", e); + } + } + } + } + + // Clear entire clipboard history + Process { + id: clearCliphistProcess + command: ["cliphist", "wipe"] + running: false + + onRunningChanged: { + if (!running) { + cliphistModel.clear(); + currentEntries = []; + console.log("Clipboard history cleared"); + } + } + + stderr: SplitParser { + onRead: data => { + console.error("Clear clipboard error:", data.toString()); + } + } + } + + // Delete specific clipboard entry + Process { + id: deleteEntryProcess + property string entryId: "" + command: ["cliphist", "delete-query", entryId] + running: false + + onRunningChanged: { + if (!running && entryId !== "") { + // Remove deleted entry from model + for (let i = 0; i < cliphistModel.count; i++) { + if (cliphistModel.get(i).id === entryId) { + cliphistModel.remove(i); + currentEntries = currentEntries.filter(entry => entry.id !== entryId); + break; + } + } + console.log("Deleted entry:", entryId); + entryId = ""; + } + } + + stderr: SplitParser { + onRead: data => { + console.error("Delete entry error:", data.toString()); + } + } + } + + // Copy plain text to clipboard + Process { + id: copyTextProcess + property string textToCopy: "" + command: ["wl-copy", textToCopy] + running: false + + stderr: SplitParser { + onRead: data => { + console.error("wl-copy error:", data.toString()); + } + } + } + + // Copy from clipboard history + Process { + id: copyHistoryProcess + property string entryId: "" + command: ["sh", "-c", "printf '%s' '" + entryId + "' | cliphist decode | wl-copy"] + running: false + + stderr: SplitParser { + onRead: data => { + console.error("Copy history error:", data.toString()); + } + } + } + + // Periodic refresh timer (disabled by default) + Timer { + id: refreshTimer + interval: 30000 + running: false // Only enable when needed + repeat: true + onTriggered: { + if (!cliphistProcess.running && root.isVisible) { + refreshClipboardHistory(); + } + } + } + + // Component initialization + Component.onCompleted: { + refreshClipboardHistory(); + } + + onIsVisibleChanged: { + if (isVisible && cliphistModel.count === 0) { + refreshClipboardHistory(); + } + } + + // Smart model update - only changes when content differs + function updateModelIfChanged(newEntries) { + // Quick length check + if (newEntries.length !== currentEntries.length) { + updateModel(newEntries); + return; + } + + // Compare content for changes + let hasChanges = false; + for (let i = 0; i < newEntries.length; i++) { + if (i >= currentEntries.length || newEntries[i].id !== currentEntries[i].id || newEntries[i].content !== currentEntries[i].content) { + hasChanges = true; + break; + } + } + + if (hasChanges) { + updateModel(newEntries); + } + } + + // Efficient model update with scroll position preservation + function updateModel(newEntries) { + const scrollPos = cliphistList.contentY; + + // Remove obsolete items + for (let i = cliphistModel.count - 1; i >= 0; i--) { + const modelItem = cliphistModel.get(i); + const found = newEntries.some(entry => entry.id === modelItem.id); + if (!found) { + cliphistModel.remove(i); + } + } + + // Add or update items + for (let i = 0; i < newEntries.length; i++) { + const newEntry = newEntries[i]; + let found = false; + + // Check if item exists and update position + for (let j = 0; j < cliphistModel.count; j++) { + const modelItem = cliphistModel.get(j); + if (modelItem.id === newEntry.id) { + if (modelItem.content !== newEntry.content) { + cliphistModel.set(j, newEntry); + } + if (j !== i && i < cliphistModel.count) { + cliphistModel.move(j, i, 1); + } + found = true; + break; + } + } + + // Add new item + if (!found) { + if (i < cliphistModel.count) { + cliphistModel.insert(i, newEntry); + } else { + cliphistModel.append(newEntry); + } + } + } + + // Restore scroll position + cliphistList.contentY = scrollPos; + currentEntries = newEntries.slice(); + } + + // Content type detection based on patterns + function detectContentType(content) { + // Binary/image data detection + if (content.includes('\x00') || content.startsWith('\x89PNG') || content.startsWith('\xFF\xD8\xFF')) { + return "image"; + } + if (content.includes('[[ binary data ') || content.includes('')) { + return "image"; + } + // URL detection + if (/^https?:\/\/\S+$/.test(content.trim())) + return "url"; + // Code detection + if (content.includes('\n') && (content.includes('{') || content.includes('function') || content.includes('=>'))) + return "code"; + // Command detection + if (content.startsWith('sudo ') || content.startsWith('pacman ') || content.startsWith('apt ')) + return "command"; + return "text"; + } + + function formatTimestamp(timestamp) { + const now = new Date(); + const entryDate = new Date(parseInt(timestamp)); + const diff = (now - entryDate) / 1000; + + if (diff < 60) + return "Just now"; + if (diff < 3600) + return Math.floor(diff / 60) + " min ago"; + if (diff < 86400) + return Math.floor(diff / 3600) + " hour" + (Math.floor(diff / 3600) === 1 ? "" : "s") + " ago"; + return Qt.formatDateTime(entryDate, "MMM d h:mm AP"); + } + + function clearClipboardHistory() { + clearCliphistProcess.running = true; + } + + function deleteClipboardEntry(entryId) { + deleteEntryProcess.entryId = entryId; + deleteEntryProcess.running = true; + } + + function refreshClipboardHistory() { + cliphistProcess.running = true; + } + + // Copy handler - chooses appropriate method based on content type + function copyToClipboard(entryIdOrText, contentType) { + if (contentType === "image" || typeof entryIdOrText === "string" && entryIdOrText.match(/^\d+$/)) { + // Use cliphist decode for binary data and numbered entries + copyHistoryProcess.entryId = entryIdOrText; + copyHistoryProcess.running = true; + } else { + // Use wl-copy for plain text + copyTextProcess.textToCopy = entryIdOrText; + copyTextProcess.running = true; + } + } + + // Clean up all processes on destruction + Component.onDestruction: { + cliphistProcess.running = false; + clearCliphistProcess.running = false; + deleteEntryProcess.running = false; + copyTextProcess.running = false; + copyHistoryProcess.running = false; + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/System/CustomTrayMenu.qml b/modules/desktop/quickshell/qml/Widgets/System/CustomTrayMenu.qml new file mode 100644 index 0000000..2904ed0 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/System/CustomTrayMenu.qml @@ -0,0 +1,161 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import "root:/Data/" as Data + +// Custom system tray menu +Rectangle { + id: trayMenu + implicitWidth: 360 + implicitHeight: Math.max(40, listView.contentHeight + 12 + 16) + clip: true + color: Data.ThemeManager.bgColor + border.color: Data.ThemeManager.accentColor + border.width: 3 + radius: 20 + visible: false + enabled: visible + + property QsMenuHandle menu + property point triggerPoint: Qt.point(0, 0) + property Item originalParent + + // Menu opener handles native menu integration + QsMenuOpener { + id: opener + menu: trayMenu.menu + } + + // Full-screen overlay to capture outside clicks + Rectangle { + id: overlay + x: -trayMenu.x + y: -trayMenu.y + width: Screen.width + height: Screen.height + color: "transparent" + visible: trayMenu.visible + z: -1 + + MouseArea { + anchors.fill: parent + enabled: trayMenu.visible + acceptedButtons: Qt.AllButtons + onPressed: { + trayMenu.hide(); + } + } + } + + // Flatten hierarchical menu structure into single list + function flattenMenuItems(menuHandle) { + var result = []; + if (!menuHandle || !menuHandle.children) { + return result; + } + + var childrenArray = []; + for (var i = 0; i < menuHandle.children.length; i++) { + childrenArray.push(menuHandle.children[i]); + } + + for (var i = 0; i < childrenArray.length; i++) { + var item = childrenArray[i]; + + if (item.isSeparator) { + result.push(item); + } else if (item.menu) { + // Add parent item and its submenu items + result.push(item); + var submenuItems = flattenMenuItems(item.menu); + result = result.concat(submenuItems); + } else { + result.push(item); + } + } + return result; + } + + // Menu item list + ListView { + id: listView + anchors.fill: parent + anchors.margins: 6 + anchors.topMargin: 3 + anchors.bottomMargin: 9 + model: ScriptModel { + values: flattenMenuItems(opener.menu) + } + interactive: false + + delegate: Rectangle { + id: entry + required property var modelData + + width: listView.width - 12 + height: modelData.isSeparator ? 10 : 28 + color: modelData.isSeparator ? Data.ThemeManager.bgColor : (mouseArea.containsMouse ? Data.ThemeManager.highlightBg : "transparent") + radius: modelData.isSeparator ? 0 : 4 + + // Separator line rendering + Item { + anchors.fill: parent + visible: modelData.isSeparator + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + width: parent.width * 0.85 + height: 1 + color: Data.ThemeManager.accentColor + opacity: 0.3 + } + } + + // Menu item content (text and icon) + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 6 + visible: !modelData.isSeparator + + Text { + Layout.fillWidth: true + color: (modelData?.enabled ?? true) ? Data.ThemeManager.fgColor : Qt.darker(Data.ThemeManager.fgColor, 1.8) + text: modelData?.text ?? "" + font.pixelSize: 12 + font.family: "FiraCode Nerd Font" + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + maximumLineCount: 1 + } + + Image { + Layout.preferredWidth: 14 + Layout.preferredHeight: 14 + source: modelData?.icon ?? "" + visible: (modelData?.icon ?? "") !== "" + fillMode: Image.PreserveAspectFit + } + } + + // Click handling + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + enabled: (modelData?.enabled ?? true) && trayMenu.visible && !modelData.isSeparator + + onClicked: { + if (modelData) { + modelData.triggered(); + trayMenu.hide(); + } + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/System/NiriWorkspaces.qml b/modules/desktop/quickshell/qml/Widgets/System/NiriWorkspaces.qml new file mode 100644 index 0000000..35c0b2e --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/System/NiriWorkspaces.qml @@ -0,0 +1,455 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Shapes +import Qt5Compat.GraphicalEffects +import Quickshell.Io +import "root:/Data" as Data +import "root:/Core" as Core + +// Niri workspace indicator +Rectangle { + id: root + + property ListModel workspaces: ListModel {} + property int currentWorkspace: -1 + property bool isDestroying: false + + // Signal for workspace change bursts + signal workspaceChanged(int workspaceId, color accentColor) + + // MASTER ANIMATION CONTROLLER - drives Desktop overlay burst effect + property real masterProgress: 0.0 + property bool effectsActive: false + property color effectColor: Data.ThemeManager.accent + + // Single master animation that controls Desktop overlay burst + function triggerUnifiedWave() { + effectColor = Data.ThemeManager.accent; + masterAnimation.restart(); + } + + SequentialAnimation { + id: masterAnimation + + PropertyAction { + target: root + property: "effectsActive" + value: true + } + + NumberAnimation { + target: root + property: "masterProgress" + from: 0.0 + to: 1.0 + duration: 1000 + easing.type: Easing.OutQuint + } + + PropertyAction { + target: root + property: "effectsActive" + value: false + } + + PropertyAction { + target: root + property: "masterProgress" + value: 0.0 + } + } + + color: Data.ThemeManager.bgColor + width: 32 + height: workspaceColumn.implicitHeight + 24 + + // Smooth height animation + Behavior on height { + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + } + } + + // Right-side rounded corners + topRightRadius: width / 2 + bottomRightRadius: width / 2 + topLeftRadius: 0 + bottomLeftRadius: 0 + + // Wave effects overlay - unified animation system (DISABLED - using Desktop overlay) + Item { + id: waveEffects + anchors.fill: parent + visible: false // Disabled in favor of unified overlay + z: 2 + } + + // Niri event stream listener + Process { + id: niriProcess + command: ["niri", "msg", "event-stream"] + running: true + + stdout: SplitParser { + onRead: data => { + const lines = data.split('\n'); + for (const line of lines) { + if (line.trim()) { + parseNiriEvent(line.trim()); + } + } + } + } + + onExited: { + // Auto-restart on failure to maintain workspace sync (but not during destruction) + if (exitCode !== 0 && !root.isDestroying) { + Qt.callLater(() => running = true); + } + } + } + + // Parse Niri event stream messages + function parseNiriEvent(line) { + try { + // Handle workspace focus changes + if (line.startsWith("Workspace focused: ")) { + const workspaceId = parseInt(line.replace("Workspace focused: ", "")); + if (!isNaN(workspaceId)) { + const previousWorkspace = root.currentWorkspace; + root.currentWorkspace = workspaceId; + updateWorkspaceFocus(workspaceId); + + // Trigger burst effect if workspace actually changed + if (previousWorkspace !== workspaceId && previousWorkspace !== -1) { + root.triggerUnifiedWave(); + root.workspaceChanged(workspaceId, Data.ThemeManager.accent); + } + } + } else + // Handle workspace list updates + if (line.startsWith("Workspaces changed: ")) { + const workspaceData = line.replace("Workspaces changed: ", ""); + parseWorkspaceList(workspaceData); + } + } catch (e) { + console.log("Error parsing niri event:", e); + } + } + + // Update workspace focus states + function updateWorkspaceFocus(focusedWorkspaceId) { + for (let i = 0; i < root.workspaces.count; i++) { + const workspace = root.workspaces.get(i); + const wasFocused = workspace.isFocused; + const isFocused = workspace.id === focusedWorkspaceId; + const isActive = workspace.id === focusedWorkspaceId; + + // Only update changed properties to trigger animations + if (wasFocused !== isFocused) { + root.workspaces.setProperty(i, "isFocused", isFocused); + root.workspaces.setProperty(i, "isActive", isActive); + } + } + } + + // Parse workspace data from Niri's Rust-style output format + function parseWorkspaceList(data) { + try { + const workspaceMatches = data.match(/Workspace \{[^}]+\}/g); + if (!workspaceMatches) { + return; + } + + const newWorkspaces = []; + + for (const match of workspaceMatches) { + const idMatch = match.match(/id: (\d+)/); + const idxMatch = match.match(/idx: (\d+)/); + const nameMatch = match.match(/name: Some\("([^"]+)"\)|name: None/); + const outputMatch = match.match(/output: Some\("([^"]+)"\)/); + const isActiveMatch = match.match(/is_active: (true|false)/); + const isFocusedMatch = match.match(/is_focused: (true|false)/); + const isUrgentMatch = match.match(/is_urgent: (true|false)/); + + if (idMatch && idxMatch && outputMatch) { + const workspace = { + id: parseInt(idMatch[1]), + idx: parseInt(idxMatch[1]), + name: nameMatch && nameMatch[1] ? nameMatch[1] : "", + output: outputMatch[1], + isActive: isActiveMatch ? isActiveMatch[1] === "true" : false, + isFocused: isFocusedMatch ? isFocusedMatch[1] === "true" : false, + isUrgent: isUrgentMatch ? isUrgentMatch[1] === "true" : false + }; + + newWorkspaces.push(workspace); + + if (workspace.isFocused) { + root.currentWorkspace = workspace.id; + } + } + } + + // Sort by index and update model + newWorkspaces.sort((a, b) => a.idx - b.idx); + root.workspaces.clear(); + root.workspaces.append(newWorkspaces); + } catch (e) { + console.log("Error parsing workspace list:", e); + } + } + + // Vertical workspace indicator pills + Column { + id: workspaceColumn + anchors.centerIn: parent + spacing: 6 + + Repeater { + model: root.workspaces + + Rectangle { + id: workspacePill + + // Dynamic sizing based on focus state + width: model.isFocused ? 18 : 16 + height: model.isFocused ? 36 : 22 + radius: width / 2 + scale: model.isFocused ? 1.0 : 0.9 + + // Material Design 3 inspired colors + color: { + if (model.isFocused) { + return Data.ThemeManager.accent; + } + if (model.isActive) { + return Qt.rgba(Data.ThemeManager.accent.r, Data.ThemeManager.accent.g, Data.ThemeManager.accent.b, 0.5); + } + if (model.isUrgent) { + return Data.ThemeManager.error; + } + return Qt.rgba(Data.ThemeManager.primaryText.r, Data.ThemeManager.primaryText.g, Data.ThemeManager.primaryText.b, 0.4); + } + + // Workspace pill burst overlay (DISABLED - using unified overlay) + Rectangle { + id: pillBurst + anchors.centerIn: parent + width: parent.width + 8 + height: parent.height + 8 + radius: width / 2 + color: Data.ThemeManager.accent + opacity: 0 // Disabled in favor of unified overlay + visible: false + z: -1 + } + + // Subtle pulse for inactive pills during workspace changes + Rectangle { + id: inactivePillPulse + anchors.fill: parent + radius: parent.radius + color: Data.ThemeManager.accent + opacity: { + // Only pulse inactive pills during effects + if (model.isFocused || !root.effectsActive) + return 0; + + // More subtle pulse that peaks mid-animation + if (root.masterProgress < 0.3) { + return (root.masterProgress / 0.3) * 0.15; + } else if (root.masterProgress < 0.7) { + return 0.15; + } else { + return 0.15 * (1.0 - (root.masterProgress - 0.7) / 0.3); + } + } + z: -0.5 // Behind the pill content but visible + } + + // Enhanced corner shadows for burst effect (DISABLED - using unified overlay) + Rectangle { + id: cornerBurst + anchors.centerIn: parent + width: parent.width + 4 + height: parent.height + 4 + radius: width / 2 + color: "transparent" + border.color: Data.ThemeManager.accent + border.width: 0 // Disabled + opacity: 0 // Disabled in favor of unified overlay + visible: false + z: 1 + } + + // Elevation shadow + Rectangle { + anchors.fill: parent + anchors.topMargin: model.isFocused ? 1 : 0 + anchors.leftMargin: model.isFocused ? 0.5 : 0 + anchors.rightMargin: model.isFocused ? -0.5 : 0 + anchors.bottomMargin: model.isFocused ? -1 : 0 + radius: parent.radius + color: Qt.rgba(0, 0, 0, model.isFocused ? 0.15 : 0) + z: -1 + visible: model.isFocused + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + } + + // Smooth Material Design transitions + Behavior on width { + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + } + } + Behavior on height { + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + } + } + Behavior on scale { + NumberAnimation { + duration: 250 + easing.type: Easing.OutCubic + } + } + Behavior on color { + ColorAnimation { + duration: 200 + } + } + + // Workspace number text + Text { + anchors.centerIn: parent + text: model.idx.toString() + color: model.isFocused ? Data.ThemeManager.background : Data.ThemeManager.primaryText + font.pixelSize: model.isFocused ? 10 : 8 + font.bold: model.isFocused + font.family: "monospace" + visible: model.isFocused || model.isActive + + Behavior on font.pixelSize { + NumberAnimation { + duration: 200 + } + } + Behavior on color { + ColorAnimation { + duration: 200 + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: { + // Switch workspace via Niri command + switchProcess.command = ["niri", "msg", "action", "focus-workspace", model.idx.toString()]; + switchProcess.running = true; + } + + // Hover feedback + onEntered: { + if (!model.isFocused) { + workspacePill.color = Qt.rgba(Data.ThemeManager.primaryText.r, Data.ThemeManager.primaryText.g, Data.ThemeManager.primaryText.b, 0.6); + } + } + + onExited: { + // Reset to normal color + if (!model.isFocused) { + if (model.isActive) { + workspacePill.color = Qt.rgba(Data.ThemeManager.accent.r, Data.ThemeManager.accent.g, Data.ThemeManager.accent.b, 0.5); + } else if (model.isUrgent) { + workspacePill.color = Data.ThemeManager.error; + } else { + workspacePill.color = Qt.rgba(Data.ThemeManager.primaryText.r, Data.ThemeManager.primaryText.g, Data.ThemeManager.primaryText.b, 0.4); + } + } + } + } + } + } + } + + // Workspace switching command process + Process { + id: switchProcess + running: false + onExited: { + running = false; + if (exitCode !== 0) { + console.log("Failed to switch workspace:", exitCode); + } + } + } + + // Border integration corners + Core.Corners { + id: topLeftCorner + position: "topleft" + size: 1.3 + fillColor: Data.ThemeManager.bgColor + offsetX: -41 + offsetY: -25 + } + + // Top-left corner wave overlay (DISABLED - using unified overlay) + Shape { + id: topLeftWave + width: topLeftCorner.width + height: topLeftCorner.height + x: topLeftCorner.x + y: topLeftCorner.y + visible: false // Disabled in favor of unified overlay + preferredRendererType: Shape.CurveRenderer + layer.enabled: true + layer.samples: 4 + } + + Core.Corners { + id: bottomLeftCorner + position: "bottomleft" + size: 1.3 + fillColor: Data.ThemeManager.bgColor + offsetX: -41 + offsetY: 78 + } + + // Bottom-left corner wave overlay (DISABLED - using unified overlay) + Shape { + id: bottomLeftWave + width: bottomLeftCorner.width + height: bottomLeftCorner.height + x: bottomLeftCorner.x + y: bottomLeftCorner.y + visible: false // Disabled in favor of unified overlay + preferredRendererType: Shape.CurveRenderer + layer.enabled: true + layer.samples: 4 + } + + // Clean up processes on destruction + Component.onDestruction: { + root.isDestroying = true; + + if (niriProcess.running) { + niriProcess.running = false; + } + if (switchProcess.running) { + switchProcess.running = false; + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/System/OSD.qml b/modules/desktop/quickshell/qml/Widgets/System/OSD.qml new file mode 100644 index 0000000..efb63cf --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/System/OSD.qml @@ -0,0 +1,228 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import QtQuick.Layouts +import QtQuick.Shapes +import "root:/Data/" as Data +import "root:/Core" as Core + +Item { + id: osd + property var shell + + QtObject { + id: modeEnum + readonly property int volume: 0 + readonly property int brightness: 1 + } + + property int mode: -1 + property int lastVolume: -1 + property int lastBrightness: -1 + + width: osdBackground.width + height: osdBackground.height + visible: false + + Timer { + id: hideTimer + interval: 2500 + onTriggered: hideOsd() + } + + FileView { + id: brightnessFile + path: "/tmp/brightness_osd_level" + watchChanges: true + blockLoading: true + + onLoaded: updateBrightness() + onFileChanged: { + brightnessFile.reload(); + updateBrightness(); + } + + function updateBrightness() { + const val = parseInt(brightnessFile.text()); + if (!isNaN(val) && val !== lastBrightness) { + lastBrightness = val; + mode = modeEnum.brightness; + showOsd(); + } + } + } + + Connections { + target: shell + function onVolumeChanged() { + if (shell.volume !== lastVolume && lastVolume !== -1) { + lastVolume = shell.volume; + mode = modeEnum.volume; + showOsd(); + } + lastVolume = shell.volume; + } + } + + Component.onCompleted: { + if (shell?.volume !== undefined) + lastVolume = shell.volume; + } + + function showOsd() { + if (!osd.visible) { + osd.visible = true; + slideInAnimation.start(); + } + hideTimer.restart(); + } + + function hideOsd() { + slideOutAnimation.start(); + } + + NumberAnimation { + id: slideInAnimation + target: osdBackground + property: "x" + from: osd.width + to: 0 + duration: 300 + easing.type: Easing.OutCubic + } + + NumberAnimation { + id: slideOutAnimation + target: osdBackground + property: "x" + from: 0 + to: osd.width + duration: 250 + easing.type: Easing.InCubic + onFinished: { + osd.visible = false; + osdBackground.x = 0; + } + } + + Rectangle { + id: osdBackground + width: 45 + height: 250 + color: Data.ThemeManager.bgColor + topLeftRadius: 20 + bottomLeftRadius: 20 + + Column { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Text { + id: osdIcon + font.family: "monospace" + font.pixelSize: 16 + color: Data.ThemeManager.fgColor + text: { + if (mode === modeEnum.volume) { + if (!shell || shell.volume === undefined) + return "󰝟"; + const vol = shell.volume; + return vol === 0 ? "󰝟" : vol < 33 ? "󰕿" : vol < 66 ? "󰖀" : "󰕾"; + } else if (mode === modeEnum.brightness) { + const b = lastBrightness; + return b < 0 ? "󰃞" : b < 33 ? "󰃟" : b < 66 ? "󰃠" : "󰃝"; + } + return ""; + } + anchors.horizontalCenter: parent.horizontalCenter + + Behavior on text { + SequentialAnimation { + PropertyAnimation { + target: osdIcon + property: "scale" + to: 1.2 + duration: 100 + } + PropertyAnimation { + target: osdIcon + property: "scale" + to: 1.0 + duration: 100 + } + } + } + } + + Rectangle { + width: 10 + height: parent.height - osdIcon.height - osdLabel.height - 36 + radius: 5 + color: Qt.darker(Data.ThemeManager.accentColor, 1.5) + border.color: Qt.darker(Data.ThemeManager.accentColor, 2.0) + border.width: 1 + anchors.horizontalCenter: parent.horizontalCenter + + Rectangle { + id: fillBar + width: parent.width - 2 + radius: parent.radius - 1 + x: 1 + color: Data.ThemeManager.accentColor + anchors.bottom: parent.bottom + anchors.bottomMargin: 1 + height: { + const val = mode === modeEnum.volume ? shell?.volume : lastBrightness; + const maxHeight = parent.height - 2; + return maxHeight * Math.max(0, Math.min(1, val / 100)); + } + Behavior on height { + NumberAnimation { + duration: 250 + easing.type: Easing.OutCubic + } + } + } + } + + Text { + id: osdLabel + text: { + const val = mode === modeEnum.volume ? shell?.volume : lastBrightness; + return val >= 0 ? val + "%" : "0%"; + } + font.pixelSize: 10 + font.weight: Font.Bold + color: Data.ThemeManager.fgColor + anchors.horizontalCenter: parent.horizontalCenter + + Behavior on text { + PropertyAnimation { + target: osdLabel + property: "opacity" + from: 0.7 + to: 1.0 + duration: 150 + } + } + } + } + } + + Core.Corners { + position: "bottomright" + size: 1.3 + fillColor: Data.ThemeManager.bgColor + offsetX: 39 + osdBackground.x + offsetY: 78 + } + + Core.Corners { + position: "topright" + size: 1.3 + fillColor: Data.ThemeManager.bgColor + offsetX: 39 + osdBackground.x + offsetY: -26 + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/System/SystemTray.qml b/modules/desktop/quickshell/qml/Widgets/System/SystemTray.qml new file mode 100644 index 0000000..243b306 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/System/SystemTray.qml @@ -0,0 +1,183 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Services.SystemTray +import "root:/Data" as Data + +// System tray with optimized icon caching +Row { + property var bar + property var shell + property var trayMenu + spacing: 8 + Layout.alignment: Qt.AlignVCenter + + property bool containsMouse: false + property var systemTray: SystemTray + + // Custom icon cache for memory optimization + property var iconCache: ({}) + property var iconCacheCount: ({}) + + // Cache cleanup to prevent memory leaks + Timer { + interval: 120000 + repeat: true + running: systemTray.items.length > 0 + onTriggered: { + // Decrement counters and remove unused icons + for (let icon in iconCacheCount) { + iconCacheCount[icon]--; + if (iconCacheCount[icon] <= 0) { + delete iconCache[icon]; + delete iconCacheCount[icon]; + } + } + + // Enforce maximum cache size + const maxCacheSize = 10; + const cacheKeys = Object.keys(iconCache); + if (cacheKeys.length > maxCacheSize) { + const toRemove = cacheKeys.slice(0, cacheKeys.length - maxCacheSize); + toRemove.forEach(key => { + delete iconCache[key]; + delete iconCacheCount[key]; + }); + } + } + } + + Repeater { + model: systemTray.items + delegate: Item { + width: 24 + height: 24 + property bool isHovered: trayMouseArea.containsMouse + + onIsHoveredChanged: updateParentHoverState() + Component.onCompleted: updateParentHoverState() + + function updateParentHoverState() { + let anyHovered = false; + for (let i = 0; i < parent.children.length; i++) { + if (parent.children[i].isHovered) { + anyHovered = true; + break; + } + } + parent.containsMouse = anyHovered; + } + + // Hover animations + scale: isHovered ? 1.15 : 1.0 + Behavior on scale { + enabled: isHovered + NumberAnimation { + duration: 150 + easing.type: Easing.OutCubic + } + } + + rotation: isHovered ? 5 : 0 + Behavior on rotation { + enabled: isHovered + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + + Image { + id: trayIcon + anchors.centerIn: parent + width: 18 + height: 18 + sourceSize.width: 18 + sourceSize.height: 18 + smooth: false // Memory savings + asynchronous: true + cache: false // Use custom cache instead + source: { + let icon = modelData?.icon || ""; + if (!icon) + return ""; + + // Return cached icon if available + if (iconCache[icon]) { + iconCacheCount[icon] = 2; + return iconCache[icon]; + } + + // Process icon path + let finalPath = icon; + if (icon.includes("?path=")) { + const [name, path] = icon.split("?path="); + const fileName = name.substring(name.lastIndexOf("/") + 1); + finalPath = `file://${path}/${fileName}`; + } + + // Cache the processed path + iconCache[icon] = finalPath; + iconCacheCount[icon] = 2; + return finalPath; + } + opacity: status === Image.Ready ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + } + } + } + + Component.onDestruction: { + let icon = modelData?.icon || ""; + if (icon) { + delete iconCache[icon]; + delete iconCacheCount[icon]; + } + } + + MouseArea { + id: trayMouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onClicked: mouse => { + if (!modelData) + return; + + if (mouse.button === Qt.LeftButton) { + if (trayMenu && trayMenu.visible) { + trayMenu.hide(); + } + if (!modelData.onlyMenu) { + modelData.activate(); + } + } else if (mouse.button === Qt.MiddleButton) { + if (trayMenu && trayMenu.visible) { + trayMenu.hide(); + } + modelData.secondaryActivate && modelData.secondaryActivate(); + } else if (mouse.button === Qt.RightButton) { + if (trayMenu && trayMenu.visible) { + trayMenu.hide(); + return; + } + // Show context menu if available + if (modelData.hasMenu && modelData.menu && trayMenu) { + trayMenu.menu = modelData.menu; + const iconCenter = Qt.point(width / 2, height); + const iconPos = mapToItem(trayMenu.parent, 0, 0); + const menuX = iconPos.x - (trayMenu.width / 2) + (width / 2); + const menuY = iconPos.y + height + 15; + trayMenu.show(Qt.point(menuX, menuY), trayMenu.parent); + } + } + } + } + } + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/System/VolumeOSD.qml b/modules/desktop/quickshell/qml/Widgets/System/VolumeOSD.qml new file mode 100644 index 0000000..8bb94de --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/System/VolumeOSD.qml @@ -0,0 +1,218 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import QtQuick.Layouts +import QtQuick.Shapes +import "root:/Data/" as Data +import "root:/Core" as Core + +// Volume OSD with slide animation +Item { + id: volumeOsd + property var shell + + // Size and visibility + width: osdBackground.width + height: osdBackground.height + visible: false + + // Auto-hide timer (2.5 seconds of inactivity) + Timer { + id: hideTimer + interval: 2500 + onTriggered: hideOsd() + } + + property int lastVolume: -1 + + // Monitor volume changes from shell and trigger OSD + Connections { + target: shell + function onVolumeChanged() { + if (shell.volume !== lastVolume && lastVolume !== -1) { + showOsd(); + } + lastVolume = shell.volume; + } + } + + Component.onCompleted: { + // Initialize lastVolume on startup + if (shell && shell.volume !== undefined) { + lastVolume = shell.volume; + } + } + + // Show OSD + function showOsd() { + if (!volumeOsd.visible) { + volumeOsd.visible = true; + slideInAnimation.start(); + } + hideTimer.restart(); + } + + // Start slide-out animation to hide OSD + function hideOsd() { + slideOutAnimation.start(); + } + + // Slide in from right edge + NumberAnimation { + id: slideInAnimation + target: osdBackground + property: "x" + from: volumeOsd.width + to: 0 + duration: 300 + easing.type: Easing.OutCubic + } + + // Slide out to right edge + NumberAnimation { + id: slideOutAnimation + target: osdBackground + property: "x" + from: 0 + to: volumeOsd.width + duration: 250 + easing.type: Easing.InCubic + onFinished: { + volumeOsd.visible = false; + osdBackground.x = 0; // Reset position + } + } + + Rectangle { + id: osdBackground + width: 45 + height: 250 + color: Data.ThemeManager.bgColor + topLeftRadius: 20 + bottomLeftRadius: 20 + + Column { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + // Dynamic volume icon + Text { + id: volumeIcon + font.family: "monospace" + font.pixelSize: 16 + color: Data.ThemeManager.fgColor + text: { + if (!shell || shell.volume === undefined) + return "󰝟"; + var vol = shell.volume; + if (vol === 0) + return "󰝟"; + else + // Muted + if (vol < 33) + return "󰕿"; + else + // Low + if (vol < 66) + return "󰖀"; + else + // Medium + return "󰕾"; // High + } + anchors.horizontalCenter: parent.horizontalCenter + + // Scale animation on volume change + Behavior on text { + SequentialAnimation { + PropertyAnimation { + target: volumeIcon + property: "scale" + to: 1.2 + duration: 100 + } + PropertyAnimation { + target: volumeIcon + property: "scale" + to: 1.0 + duration: 100 + } + } + } + } + + // Vertical volume bar + Rectangle { + width: 10 + height: parent.height - volumeIcon.height - volumeLabel.height - 36 + radius: 5 + color: Qt.darker(Data.ThemeManager.accentColor, 1.5) + border.color: Qt.darker(Data.ThemeManager.accentColor, 2.0) + border.width: 1 + anchors.horizontalCenter: parent.horizontalCenter + + // Animated volume fill indicator + Rectangle { + id: volumeFill + width: parent.width - 2 + radius: parent.radius - 1 + x: 1 + color: Data.ThemeManager.accentColor + anchors.bottom: parent.bottom + anchors.bottomMargin: 1 + height: { + if (!shell || shell.volume === undefined) + return 0; + var maxHeight = parent.height - 2; + return maxHeight * Math.max(0, Math.min(1, shell.volume / 100)); + } + Behavior on height { + NumberAnimation { + duration: 250 + easing.type: Easing.OutCubic + } + } + } + } + + // Volume percentage text + Text { + id: volumeLabel + text: (shell && shell.volume !== undefined ? shell.volume + "%" : "0%") + font.pixelSize: 10 + font.weight: Font.Bold + color: Data.ThemeManager.fgColor + anchors.horizontalCenter: parent.horizontalCenter + + // Fade animation on volume change + Behavior on text { + PropertyAnimation { + target: volumeLabel + property: "opacity" + from: 0.7 + to: 1.0 + duration: 150 + } + } + } + } + } + + Core.Corners { + id: bottomRightCorner + position: "bottomright" + size: 1.3 + fillColor: Data.ThemeManager.bgColor + offsetX: 39 + osdBackground.x + offsetY: 78 + } + + Core.Corners { + id: topRightCorner + position: "topright" + size: 1.3 + fillColor: Data.ThemeManager.bgColor + offsetX: 39 + osdBackground.x + offsetY: -26 + } +} diff --git a/modules/desktop/quickshell/qml/Widgets/Workspace.qml b/modules/desktop/quickshell/qml/Widgets/Workspace.qml new file mode 100644 index 0000000..6051cd0 --- /dev/null +++ b/modules/desktop/quickshell/qml/Widgets/Workspace.qml @@ -0,0 +1,56 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Hyprland +import "root:/Data" as Data + +// Hyprland workspace indicator +Column { + id: root + property var shell + spacing: 8 + + Repeater { + model: Hyprland.workspaces + + delegate: Rectangle { + width: 22 + height: 22 + radius: 6 + color: modelData.active ? Data.ThemeManager.accentColor : "transparent" + border.color: Data.ThemeManager.accentColor + border.width: modelData.active ? 0 : 1 + opacity: modelData.active ? 1 : 0.6 + + Text { + anchors.centerIn: parent + text: modelData.name || modelData.id + color: modelData.active ? Data.ThemeManager.bgColor : Data.ThemeManager.fgColor + font.family: "monospace" + font.pixelSize: 12 + font.bold: modelData.active + } + + MouseArea { + anchors.fill: parent + onClicked: modelData.activate() + onPressAndHold: { + // Move focused window to workspace (regular workspaces only) + if (modelData.id > 0) { + Hyprland.dispatch(`movetoworkspace ${modelData.id}`); + } + } + } + } + } + + // Workspace synchronization + Connections { + target: Hyprland + function onFocusedWorkspaceChanged() { + Hyprland.refreshWorkspaces(); + } + } + + Component.onCompleted: Hyprland.refreshWorkspaces() +} diff --git a/modules/desktop/quickshell/qml/scripts/test-integration.qml b/modules/desktop/quickshell/qml/scripts/test-integration.qml new file mode 100644 index 0000000..d490bb3 --- /dev/null +++ b/modules/desktop/quickshell/qml/scripts/test-integration.qml @@ -0,0 +1,46 @@ +import QtQuick +import "../Services" as Services +import "../Data" as Data + +// Test matugen integration with full shell context +Item { + Services.MatugenService { + id: matugenService + + Component.onCompleted: { + console.log("MatugenService test initialized"); + + // Connect to the matugen theme + if (Data.ThemeManager.matugen) { + Data.ThemeManager.matugen.matugenService = matugenService; + console.log("Connected service to theme"); + } + } + + onMatugenColorsLoaded: { + console.log("✓ Colors loaded signal received"); + console.log("✓ Service reports available:", isAvailable()); + console.log("✓ Theme reports active:", Data.ThemeManager.matugen.isMatugenActive()); + + if (Data.ThemeManager.matugen.dark) { + console.log("✓ Dark theme background:", Data.ThemeManager.matugen.dark.base00); + console.log("✓ Dark theme primary:", Data.ThemeManager.matugen.dark.base0D); + } + + Qt.exit(0); + } + } + + Timer { + interval: 3000 + running: true + onTriggered: { + console.log("✗ Timeout - colors didn't load"); + Qt.exit(1); + } + } + + Component.onCompleted: { + console.log("Testing matugen integration in shell context..."); + } +} diff --git a/modules/desktop/quickshell/qml/scripts/test-matugen.qml b/modules/desktop/quickshell/qml/scripts/test-matugen.qml new file mode 100644 index 0000000..2163030 --- /dev/null +++ b/modules/desktop/quickshell/qml/scripts/test-matugen.qml @@ -0,0 +1,76 @@ +import QtQuick +import Quickshell.Io + +// Simple test script for matugen integration +// Run with: quickshell scripts/test-matugen.qml + +Item { + FileView { + id: matugenFile + path: "Data/colors.css" + blockWrites: true + + onLoaded: { + console.log("✓ Matugen colors.css found!"); + console.log("File size:", text().length, "bytes"); + + const lines = text().split('\n'); + const colors = {}; + let colorCount = 0; + + // Parse colors + for (const line of lines) { + const match = line.match(/@define-color\s+(\w+)\s+(#[0-9a-fA-F]{6});/); + if (match) { + colors[match[1]] = match[2]; + colorCount++; + } + } + + console.log("✓ Found", colorCount, "color definitions"); + console.log("\nMaterial You colors detected:"); + + // Check for key Material You colors + const keyColors = ["background", "surface", "primary", "secondary", "tertiary", "on_background", "on_surface", "on_primary", "on_secondary", "on_tertiary", "surface_container", "surface_tint", "error", "outline"]; + + for (const colorName of keyColors) { + if (colors[colorName]) { + console.log(` ${colorName}: ${colors[colorName]}`); + } + } + + if (colorCount > 10) { + console.log("\n✓ Matugen integration should work perfectly!"); + console.log("✓ Switch to 'Matugen' theme in your quickshell appearance settings"); + } else { + console.log("\n⚠ Limited color palette detected"); + console.log("⚠ Make sure you've run matugen with a wallpaper or image"); + } + + Qt.exit(0); + } + + onTextChanged: { + console.log("Matugen colors updated!"); + } + } + + Timer { + interval: 2000 + running: true + onTriggered: { + if (!matugenFile.loaded) { + console.log("✗ Matugen colors.css not found at Data/colors.css"); + console.log("✗ Please copy your matugen colors.css to Data/colors.css"); + console.log(" cp ~/.cache/matugen/colors.css Data/colors.css"); + console.log("✗ Or generate matugen colors directly to this location"); + Qt.exit(1); + } + } + } + + Component.onCompleted: { + console.log("Testing matugen integration..."); + console.log("Looking for Data/colors.css..."); + } +} diff --git a/modules/desktop/quickshell/qml/scripts/test-simple.qml b/modules/desktop/quickshell/qml/scripts/test-simple.qml new file mode 100644 index 0000000..fcdc16a --- /dev/null +++ b/modules/desktop/quickshell/qml/scripts/test-simple.qml @@ -0,0 +1,40 @@ +import QtQuick +import "../Data" as Data + +// Simple test for direct matugen import +Item { + Component.onCompleted: { + console.log("Testing direct matugen import..."); + + if (Data.ThemeManager.matugen) { + console.log("✓ Matugen theme available"); + console.log("✓ Is active:", Data.ThemeManager.matugen.isMatugenActive()); + + if (Data.ThemeManager.matugen.dark) { + console.log("✓ Dark theme available"); + console.log(" Background:", Data.ThemeManager.matugen.dark.base00); + console.log(" Primary:", Data.ThemeManager.matugen.dark.base0D); + console.log(" Accent:", Data.ThemeManager.matugen.dark.base0E); + } + + if (Data.ThemeManager.matugen.light) { + console.log("✓ Light theme available"); + console.log(" Background:", Data.ThemeManager.matugen.light.base00); + console.log(" Primary:", Data.ThemeManager.matugen.light.base0D); + console.log(" Accent:", Data.ThemeManager.matugen.light.base0E); + } + + // Test raw color access + const primaryColor = Data.ThemeManager.matugen.getMatugenColor("primary"); + if (primaryColor) { + console.log("✓ Raw primary color:", primaryColor); + } + + console.log("✅ Matugen integration working perfectly!"); + } else { + console.log("✗ Matugen theme not found"); + } + + Qt.exit(0); + } +} diff --git a/modules/desktop/quickshell/qml/shell.qml b/modules/desktop/quickshell/qml/shell.qml new file mode 100644 index 0000000..f93cc20 --- /dev/null +++ b/modules/desktop/quickshell/qml/shell.qml @@ -0,0 +1,119 @@ +import Quickshell +import Quickshell.Io +import Quickshell.Services.Notifications +import Quickshell.Services.Pipewire +import QtQuick + +import "root:/Data" as Data +import "root:/Services" as Services +import "root:/Layout" as Layout + +ShellRoot { + id: root + + property var shellInstance: Quickshell.shell + property var notificationService + + property var defaultAudioSink: Pipewire.defaultAudioSink + property int volume: defaultAudioSink && defaultAudioSink.audio ? Math.round(defaultAudioSink.audio.volume * 100) : 0 + + property var notificationWindow: null // Set by Desktop.qml + property var notificationServer: notificationService ? notificationService.notificationServer : null + + // Notification history management + property ListModel notificationHistory: ListModel { + Component.onDestruction: clear() + } + property int maxHistoryItems: Data.Settings.historyLimit + property int cleanupThreshold: maxHistoryItems + 5 + + property string weatherLocation: Data.Settings.weatherLocation + property var weatherData: null + property bool weatherLoading: false + property alias weatherService: weatherService + + Layout.Desktop { + id: desktop + shell: root + notificationService: notificationService + } + + Services.NotificationService { + id: notificationService + shell: root + } + + Services.WeatherService { + id: weatherService + shell: root + } + + Services.MatugenService { + id: matugenService + shell: root + } + + Component.onCompleted: { + weatherService.loadWeather(); + + // Connect MatugenService to the Matugen theme + Data.ThemeManager.matugen.setMatugenService(matugenService); + console.log("MatugenService connected to Matugen theme"); + + // Register service with MatugenManager for global access + Data.MatugenManager.setService(matugenService); + } + + PwObjectTracker { + objects: [Pipewire.defaultAudioSink] + } + + function addToNotificationHistory(notification) { + notificationHistory.insert(0, { + appName: notification.appName, + summary: notification.summary, + body: notification.body, + timestamp: new Date(), + icon: notification.appIcon ? String(notification.appIcon) : "" + }); + + // Immediate cleanup when threshold exceeded + if (notificationHistory.count > cleanupThreshold) { + const removeCount = notificationHistory.count - maxHistoryItems; + notificationHistory.remove(maxHistoryItems, removeCount); + } + } + + // Periodic cleanup every 30 minutes + Timer { + interval: 1800000 + running: true + repeat: true + onTriggered: { + if (notificationHistory.count > maxHistoryItems) { + const removeCount = notificationHistory.count - maxHistoryItems; + notificationHistory.remove(maxHistoryItems, removeCount); + } + + gc(); + } + } + + // Aggressive memory cleanup every 10 minutes + Timer { + interval: 600000 + running: true + repeat: true + onTriggered: { + // More aggressive cleanup threshold + if (notificationHistory.count > maxHistoryItems * 0.8) { + const removeCount = notificationHistory.count - Math.floor(maxHistoryItems * 0.7); + notificationHistory.remove(Math.floor(maxHistoryItems * 0.7), removeCount); + } + + // Force garbage collection + gc(); + Qt.callLater(gc); + } + } +} diff --git a/modules/desktop/wm/niri/config.nix b/modules/desktop/wm/niri/config.nix index c2c97c7..e40822c 100644 --- a/modules/desktop/wm/niri/config.nix +++ b/modules/desktop/wm/niri/config.nix @@ -2,6 +2,7 @@ config, lib, pkgs, + flake, ... }: { @@ -103,7 +104,7 @@ "-m" "fill" "-i" - (toString ./wallpaper.png) + (toString (flake + /assets/wallpapers/wallpaper.png)) ] [ "wl-paste" diff --git a/modules/desktop/wm/niri/wallpaper.png b/modules/desktop/wm/niri/wallpaper.png deleted file mode 100644 index 8e439ac59f7e94b222780c3a25eb30932a6a369f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26003 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