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 0000000..d600a22 Binary files /dev/null and b/modules/desktop/quickshell/qml/Assets/UserProfile.gif differ 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 8e439ac..0000000 Binary files a/modules/desktop/wm/niri/wallpaper.png and /dev/null differ