From f7b46516b58a3a2a484c29388460e44fcb413035 Mon Sep 17 00:00:00 2001 From: end-4 <97237370+end-4@users.noreply.github.com> Date: Sun, 27 Jul 2025 08:52:38 +0700 Subject: [PATCH] stuff --- .config/quickshell/oo/Directories.qml | 29 + .config/quickshell/oo/GlobalStates.qml | 38 + .config/quickshell/oo/ReloadPopup.qml | 157 ++++ .../oo/modules/background/Background.qml | 282 ++++++++ .config/quickshell/oo/modules/bar/Bar.qml | 161 +++++ .../quickshell/oo/modules/bar/BarButton.qml | 11 + .../modules/common/functions/ColorUtils.qml | 114 +++ .../oo/modules/common/functions/FileUtils.qml | 41 ++ .../modules/common/functions/ObjectUtils.qml | 98 +++ .../modules/common/functions/StringUtils.qml | 221 ++++++ .../oo/modules/common/functions/fuzzysort.js | 682 ++++++++++++++++++ .../oo/modules/common/functions/levendist.js | 141 ++++ .../oo/modules/common/widgets/HexRect.qml | 49 ++ .../modules/common/widgets/MaterialSymbol.qml | 33 + .../oo/modules/common/widgets/PolyRect.qml | 46 ++ .../oo/modules/common/widgets/StyledText.qml | 15 + .../oo/scripts/images/find_regions.py | 120 +++ .../oo/scripts/images/least_busy_region.py | 341 +++++++++ .config/quickshell/oo/shell.qml | 24 + .../quickshell/oo/singletons/Appearance.qml | 269 +++++++ .config/quickshell/oo/singletons/Config.qml | 76 ++ .config/quickshell/oo/singletons/DateTime.qml | 52 ++ .../quickshell/oo/singletons/Directories.qml | 30 + .../quickshell/oo/singletons/HyprlandData.qml | 133 ++++ .../oo/singletons/MaterialThemeLoader.qml | 57 ++ 25 files changed, 3220 insertions(+) create mode 100644 .config/quickshell/oo/Directories.qml create mode 100644 .config/quickshell/oo/GlobalStates.qml create mode 100644 .config/quickshell/oo/ReloadPopup.qml create mode 100644 .config/quickshell/oo/modules/background/Background.qml create mode 100644 .config/quickshell/oo/modules/bar/Bar.qml create mode 100644 .config/quickshell/oo/modules/bar/BarButton.qml create mode 100644 .config/quickshell/oo/modules/common/functions/ColorUtils.qml create mode 100644 .config/quickshell/oo/modules/common/functions/FileUtils.qml create mode 100644 .config/quickshell/oo/modules/common/functions/ObjectUtils.qml create mode 100644 .config/quickshell/oo/modules/common/functions/StringUtils.qml create mode 100644 .config/quickshell/oo/modules/common/functions/fuzzysort.js create mode 100644 .config/quickshell/oo/modules/common/functions/levendist.js create mode 100644 .config/quickshell/oo/modules/common/widgets/HexRect.qml create mode 100644 .config/quickshell/oo/modules/common/widgets/MaterialSymbol.qml create mode 100644 .config/quickshell/oo/modules/common/widgets/PolyRect.qml create mode 100644 .config/quickshell/oo/modules/common/widgets/StyledText.qml create mode 100755 .config/quickshell/oo/scripts/images/find_regions.py create mode 100755 .config/quickshell/oo/scripts/images/least_busy_region.py create mode 100644 .config/quickshell/oo/shell.qml create mode 100644 .config/quickshell/oo/singletons/Appearance.qml create mode 100644 .config/quickshell/oo/singletons/Config.qml create mode 100644 .config/quickshell/oo/singletons/DateTime.qml create mode 100644 .config/quickshell/oo/singletons/Directories.qml create mode 100644 .config/quickshell/oo/singletons/HyprlandData.qml create mode 100644 .config/quickshell/oo/singletons/MaterialThemeLoader.qml diff --git a/.config/quickshell/oo/Directories.qml b/.config/quickshell/oo/Directories.qml new file mode 100644 index 000000000..0d4165d38 --- /dev/null +++ b/.config/quickshell/oo/Directories.qml @@ -0,0 +1,29 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common.functions +import Qt.labs.platform +import QtQuick +import Quickshell + +Singleton { + // XDG Dirs, with "file://" + readonly property string config: StandardPaths.standardLocations(StandardPaths.ConfigLocation)[0] + readonly property string state: StandardPaths.standardLocations(StandardPaths.StateLocation)[0] + readonly property string cache: StandardPaths.standardLocations(StandardPaths.CacheLocation)[0] + readonly property string pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0] + readonly property string downloads: StandardPaths.standardLocations(StandardPaths.DownloadLocation)[0] + + // Other dirs used by the shell, without "file://" + property string assetsPath: Quickshell.shellPath("assets") + property string scriptPath: Quickshell.shellPath("scripts") + property string shellConfig: FileUtils.trimFileProtocol(`${Directories.config}/quickshell`) + property string shellConfigName: "oo.json" + property string shellConfigPath: `${Directories.shellConfig}/${Directories.shellConfigName}` + property string notificationsPath: FileUtils.trimFileProtocol(`${Directories.cache}/notifications/notifications.json`) + property string wallpaperSwitchScriptPath: FileUtils.trimFileProtocol(`${Directories.scriptPath}/colors/switchwall.sh`) + // Cleanup on init + Component.onCompleted: { + Quickshell.execDetached(["mkdir", "-p", `${shellConfig}`]) + } +} diff --git a/.config/quickshell/oo/GlobalStates.qml b/.config/quickshell/oo/GlobalStates.qml new file mode 100644 index 000000000..263c0de4f --- /dev/null +++ b/.config/quickshell/oo/GlobalStates.qml @@ -0,0 +1,38 @@ +import qs +import qs.singletons +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +pragma Singleton +pragma ComponentBehavior: Bound + +Singleton { + id: root + // Open states + property bool barOpen: true + property bool launcherOpen: true + + // Smooth screen zoom + IpcHandler { + target: "zoom" + + function zoomIn() { + screenZoom = Math.min(screenZoom + 0.4, 3.0) + } + + function zoomOut() { + screenZoom = Math.max(screenZoom - 0.4, 1) + } + } + + property real screenZoom: 1 + + onScreenZoomChanged: { + Quickshell.execDetached(["hyprctl", "keyword", "cursor:zoom_factor", root.screenZoom.toString()]); + } + + Behavior on screenZoom { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } +} \ No newline at end of file diff --git a/.config/quickshell/oo/ReloadPopup.qml b/.config/quickshell/oo/ReloadPopup.qml new file mode 100644 index 000000000..4b0ecd884 --- /dev/null +++ b/.config/quickshell/oo/ReloadPopup.qml @@ -0,0 +1,157 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Qt5Compat.GraphicalEffects + +Scope { + id: root + property bool failed; + property string errorString; + + // Connect to the Quickshell global to listen for the reload signals. + Connections { + target: Quickshell + + function onReloadCompleted() { + root.failed = false; + popupLoader.loading = true; + } + + function onReloadFailed(error: string) { + // Close any existing popup before making a new one. + popupLoader.active = false; + + root.failed = true; + root.errorString = error; + popupLoader.loading = true; + } + } + + // Keep the popup in a loader because it isn't needed most of the time + LazyLoader { + id: popupLoader + + PanelWindow { + id: popup + + exclusiveZone: 0 + anchors.top: true + margins.top: 0 + + implicitWidth: rect.width + shadow.radius * 2 + implicitHeight: rect.height + shadow.radius * 2 + + // color blending is a bit odd as detailed in the type reference. + color: "transparent" + + Rectangle { + id: rect + anchors.centerIn: parent + color: failed ? "#ffe99195" : "#ffD1E8D5" + + implicitHeight: layout.implicitHeight + 30 + implicitWidth: layout.implicitWidth + 30 + radius: 12 + + // Fills the whole area of the rectangle, making any clicks go to it, + // which dismiss the popup. + MouseArea { + id: mouseArea + anchors.fill: parent + onClicked: { + popupLoader.active = false + } + + // makes the mouse area track mouse hovering, so the hide animation + // can be paused when hovering. + hoverEnabled: true + } + + ColumnLayout { + id: layout + spacing: 10 + anchors { + top: parent.top + topMargin: 10 + horizontalCenter: parent.horizontalCenter + } + + Text { + renderType: Text.NativeRendering + font.family: "Rubik" + font.pointSize: 14 + text: root.failed ? "Quickshell: Reload failed" : "Quickshell reloaded" + color: failed ? "#ff93000A" : "#ff0C1F13" + } + + Text { + renderType: Text.NativeRendering + font.family: "JetBrains Mono NF" + font.pointSize: 11 + text: root.errorString + color: failed ? "#ff93000A" : "#ff0C1F13" + // When visible is false, it also takes up no space. + visible: root.errorString != "" + } + } + + // A progress bar on the bottom of the screen, showing how long until the + // popup is removed. + Rectangle { + z: 2 + id: bar + color: failed ? "#ff93000A" : "#ff0C1F13" + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: 10 + height: 5 + radius: 9999 + + PropertyAnimation { + id: anim + target: bar + property: "width" + from: rect.width - bar.anchors.margins * 2 + to: 0 + duration: failed ? 10000 : 1000 + onFinished: popupLoader.active = false + + // Pause the animation when the mouse is hovering over the popup, + // so it stays onscreen while reading. This updates reactively + // when the mouse moves on and off the popup. + paused: mouseArea.containsMouse + } + } + // Its bg + Rectangle { + z: 1 + id: bar_bg + color: failed ? "#30af1b25" : "#4027643e" + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: 10 + height: 5 + radius: 9999 + width: rect.width - bar.anchors.margins * 2 + } + + // We could set `running: true` inside the animation, but the width of the + // rectangle might not be calculated yet, due to the layout. + // In the `Component.onCompleted` event handler, all of the component's + // properties and children have been initialized. + Component.onCompleted: anim.start() + } + + DropShadow { + id: shadow + anchors.fill: rect + horizontalOffset: 0 + verticalOffset: 2 + radius: 6 + samples: radius * 2 + 1 // Ideally should be 2 * radius + 1, see qt docs + color: "#44000000" + source: rect + } + } + } +} diff --git a/.config/quickshell/oo/modules/background/Background.qml b/.config/quickshell/oo/modules/background/Background.qml new file mode 100644 index 000000000..1f3a7c9c8 --- /dev/null +++ b/.config/quickshell/oo/modules/background/Background.qml @@ -0,0 +1,282 @@ +pragma ComponentBehavior: Bound + +import qs +import qs.singletons +import qs.modules.common.widgets +import qs.modules.common.functions as CF +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + readonly property bool fixedClockPosition: Config.options.background.fixedClockPosition + readonly property real fixedClockX: Config.options.background.clockX + readonly property real fixedClockY: Config.options.background.clockY + + Variants { + model: Quickshell.screens + + PanelWindow { + id: bgRoot + + required property var modelData + // Workspaces + property HyprlandMonitor monitor: Hyprland.monitorFor(modelData) + property list relevantWindows: HyprlandData.windowList.filter(win => win.monitor == monitor.id && win.workspace.id >= 0).sort((a, b) => a.workspace.id - b.workspace.id) + property int firstWorkspaceId: relevantWindows[0]?.workspace.id || 1 + property int lastWorkspaceId: relevantWindows[relevantWindows.length - 1]?.workspace.id || 10 + // Wallpaper + property string wallpaperPath: Config.options.background.wallpaperPath + property bool wallpaperIsVideo: Config.options.background.wallpaperPath.endsWith(".mp4") + || Config.options.background.wallpaperPath.endsWith(".webm") + || Config.options.background.wallpaperPath.endsWith(".mkv") + || Config.options.background.wallpaperPath.endsWith(".avi") + || Config.options.background.wallpaperPath.endsWith(".mov") + property real preferredWallpaperScale: Config.options.background.parallax.workspaceZoom + property real effectiveWallpaperScale: 1 // Some reasonable init value, to be updated + property int wallpaperWidth: modelData.width // Some reasonable init value, to be updated + property int wallpaperHeight: modelData.height // Some reasonable init value, to be updated + property real movableXSpace: (effectiveWallpaperScale - 1) / 2 * screen.width + property real movableYSpace: (effectiveWallpaperScale - 1) / 2 * screen.height + // Position + property real clockX: (modelData.width / 2) + ((Math.random() < 0.5 ? -1 : 1) * modelData.width) + property real clockY: (modelData.height / 2) + ((Math.random() < 0.5 ? -1 : 1) * modelData.height) + property var textHorizontalAlignment: clockX < screen.width / 3 ? Text.AlignLeft : + (clockX > screen.width * 2 / 3 ? Text.AlignRight : Text.AlignHCenter) + // Colors + property color dominantColor: Appearance.colors.colPrimary + property bool dominantColorIsDark: dominantColor.hslLightness < 0.5 + property color colText: CF.ColorUtils.colorWithLightness(Appearance.colors.colPrimary, (dominantColorIsDark ? 0.8 : 0.12)) + + // Layer props + screen: modelData + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: GlobalStates.screenLocked ? WlrLayer.Top : WlrLayer.Bottom + // WlrLayershell.layer: WlrLayer.Bottom + WlrLayershell.namespace: "quickshell:background" + anchors { + top: true + bottom: true + left: true + right: true + } + color: "transparent" + + onWallpaperPathChanged: { + bgRoot.updateZoomScale() + // Clock position gets updated after zoom scale is updated + } + + // Wallpaper zoom scale + function updateZoomScale() { + getWallpaperSizeProc.path = bgRoot.wallpaperPath + getWallpaperSizeProc.running = true; + } + Process { + id: getWallpaperSizeProc + property string path: bgRoot.wallpaperPath + command: [ "magick", "identify", "-format", "%w %h", path ] + stdout: StdioCollector { + id: wallpaperSizeOutputCollector + onStreamFinished: { + const output = wallpaperSizeOutputCollector.text + const [width, height] = output.split(" ").map(Number); + bgRoot.wallpaperWidth = width + bgRoot.wallpaperHeight = height + bgRoot.effectiveWallpaperScale = Math.max(1, Math.min( + bgRoot.preferredWallpaperScale, + width / bgRoot.screen.width, + height / bgRoot.screen.height + )); + + bgRoot.updateClockPosition() + } + } + } + + // Clock positioning + function updateClockPosition() { + // Somehow all this manual setting is needed to make the proc correctly use the new values + leastBusyRegionProc.path = bgRoot.wallpaperPath + leastBusyRegionProc.contentWidth = clock.implicitWidth + leastBusyRegionProc.contentHeight = clock.implicitHeight + leastBusyRegionProc.horizontalPadding = (effectiveWallpaperScale - 1) / 2 * screen.width + 100 + leastBusyRegionProc.verticalPadding = (effectiveWallpaperScale - 1) / 2 * screen.height + 100 + leastBusyRegionProc.running = false; + leastBusyRegionProc.running = true; + } + Process { + id: leastBusyRegionProc + property string path: bgRoot.wallpaperPath + property int contentWidth: 300 + property int contentHeight: 300 + property int horizontalPadding: bgRoot.movableXSpace + property int verticalPadding: bgRoot.movableYSpace + command: [Quickshell.shellPath("scripts/images/least_busy_region.py"), + "--screen-width", bgRoot.screen.width, + "--screen-height", bgRoot.screen.height, + "--width", contentWidth, + "--height", contentHeight, + "--horizontal-padding", horizontalPadding, + "--vertical-padding", verticalPadding, + path + ] + stdout: StdioCollector { + id: leastBusyRegionOutputCollector + onStreamFinished: { + const output = leastBusyRegionOutputCollector.text + // console.log("[Background] Least busy region output:", output) + if (output.length === 0) return; + const parsedContent = JSON.parse(output) + bgRoot.clockX = parsedContent.center_x + bgRoot.clockY = parsedContent.center_y + bgRoot.dominantColor = parsedContent.dominant_color || Appearance.colors.colPrimary + } + } + } + + // Wallpaper + Image { + visible: !bgRoot.wallpaperIsVideo + property real value // 0 to 1, for offset + value: { + // Range = half-groups that workspaces span on + const chunkSize = 5; + const lower = Math.floor(bgRoot.firstWorkspaceId / chunkSize) * chunkSize; + const upper = Math.ceil(bgRoot.lastWorkspaceId / chunkSize) * chunkSize; + const range = upper - lower; + return (Config.options.background.parallax.enableWorkspace ? ((bgRoot.monitor.activeWorkspace.id - lower) / range) : 0.5) + + (0.15 * GlobalStates.sidebarRightOpen * Config.options.background.parallax.enableSidebar) + - (0.15 * GlobalStates.sidebarLeftOpen * Config.options.background.parallax.enableSidebar) + } + property real effectiveValue: Math.max(0, Math.min(1, value)) + x: -(bgRoot.movableXSpace) - (effectiveValue - 0.5) * 2 * bgRoot.movableXSpace + y: -(bgRoot.movableYSpace) + source: bgRoot.wallpaperPath + fillMode: Image.PreserveAspectCrop + Behavior on x { + NumberAnimation { + duration: 600 + easing.type: Easing.OutCubic + } + } + sourceSize { + width: bgRoot.screen.width * bgRoot.effectiveWallpaperScale + height: bgRoot.screen.height * bgRoot.effectiveWallpaperScale + } + + // The clock + Item { + id: clock + anchors { + left: parent.left + top: parent.top + leftMargin: ((root.fixedClockPosition ? root.fixedClockX : bgRoot.clockX * bgRoot.effectiveWallpaperScale) - implicitWidth / 2) + topMargin: ((root.fixedClockPosition ? root.fixedClockY : bgRoot.clockY * bgRoot.effectiveWallpaperScale) - implicitHeight / 2) + Behavior on leftMargin { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on topMargin { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + } + + implicitWidth: clockColumn.implicitWidth + implicitHeight: clockColumn.implicitHeight + + ColumnLayout { + id: clockColumn + anchors.centerIn: parent + spacing: 0 + + StyledText { + Layout.fillWidth: true + horizontalAlignment: bgRoot.textHorizontalAlignment + font { + family: Appearance.font.family.expressive + pixelSize: 90 + weight: Font.Bold + } + color: bgRoot.colText + style: Text.Raised + styleColor: Appearance.colors.colShadow + text: DateTime.time + } + StyledText { + Layout.fillWidth: true + Layout.topMargin: -5 + horizontalAlignment: bgRoot.textHorizontalAlignment + font { + family: Appearance.font.family.expressive + pixelSize: 20 + weight: Font.DemiBold + } + color: bgRoot.colText + style: Text.Raised + styleColor: Appearance.colors.colShadow + text: DateTime.date + } + } + + RowLayout { + anchors { + top: clockColumn.bottom + left: bgRoot.textHorizontalAlignment === Text.AlignLeft ? clockColumn.left : undefined + right: bgRoot.textHorizontalAlignment === Text.AlignRight ? clockColumn.right : undefined + horizontalCenter: bgRoot.textHorizontalAlignment === Text.AlignHCenter ? clockColumn.horizontalCenter : undefined + topMargin: 5 + leftMargin: -5 + rightMargin: -5 + } + opacity: GlobalStates.screenLocked ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Item { Layout.fillWidth: bgRoot.textHorizontalAlignment !== Text.AlignLeft; implicitWidth: 1 } + MaterialSymbol { + text: "lock" + Layout.fillWidth: false + iconSize: Appearance.font.pixelSize.huge + color: bgRoot.colText + } + StyledText { + Layout.fillWidth: false + text: "Locked" + color: bgRoot.colText + font { + pixelSize: Appearance.font.pixelSize.larger + } + } + Item { Layout.fillWidth: bgRoot.textHorizontalAlignment !== Text.AlignRight; implicitWidth: 1 } + + } + } + } + + // Password prompt + StyledText { + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: 30 + } + opacity: (GlobalStates.screenLocked && !GlobalStates.screenLockContainsCharacters) ? 1 : 0 + scale: opacity + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + text: "Enter password" + color: CF.ColorUtils.transparentize(bgRoot.colText, 0.3) + font { + pixelSize: Appearance.font.pixelSize.normal + } + } + } + } +} diff --git a/.config/quickshell/oo/modules/bar/Bar.qml b/.config/quickshell/oo/modules/bar/Bar.qml new file mode 100644 index 000000000..9dfbcda65 --- /dev/null +++ b/.config/quickshell/oo/modules/bar/Bar.qml @@ -0,0 +1,161 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland +import qs +import qs.singletons +import qs.modules.common.widgets + +Scope { + id: root + + Variants { + // For each monitor + model: Quickshell.screens + LazyLoader { + id: barLoader + active: GlobalStates.barOpen + required property ShellScreen modelData + component: PanelWindow { // Bar window + id: barRoot + screen: barLoader.modelData + + exclusionMode: ExclusionMode.Ignore + exclusiveZone: Appearance.sizes.barHeight + Appearance.sizes.barBorder + WlrLayershell.namespace: "oo:bar" + implicitHeight: Appearance.sizes.barHeight + Appearance.sizes.barBorder + mask: Region { + item: barContent + } + color: "transparent" + + anchors { + bottom: true + left: true + right: true + } + + Item { // Bar content region + id: barContent + anchors { + right: parent.right + left: parent.left + bottom: parent.bottom + } + implicitHeight: Appearance.sizes.barHeight + Appearance.sizes.barBorder + + // Background + Rectangle { + id: barBackground + anchors { + fill: parent + topMargin: Appearance.sizes.barBorder + } + color: Appearance.colors.colLayer0 + } + // Border + Rectangle { + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + bottomMargin: Appearance.sizes.barHeight + } + implicitHeight: Appearance.sizes.barBorder + color: Appearance.colors.colOutlineVariant + } + + // Stuff + RowLayout { + anchors { + fill: parent + topMargin: Appearance.sizes.barBorder + } + BarButton { + id: startButton + Layout.fillHeight: true + + property real targetRotation: 0 + onPressed: targetRotation += 180 + rotation: targetRotation + Behavior on rotation { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + onClicked: GlobalStates.launcherOpen = !GlobalStates.launcherOpen + + HexRect { + anchors.centerIn: parent + color: startButton.active ? + (startButton.hovered ? Appearance.colors.colPrimaryHover : Appearance.colors.colPrimary) + : (startButton.hovered ? Appearance.colors.colLayer3Hover : Appearance.colors.colLayer3) + Behavior on borderColor { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + property real size: Appearance.sizes.barHeight * 0.75 + property real sizeDown: size * 0.85 + property real effectiveSize: startButton.down ? sizeDown : size + Behavior on effectiveSize { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + implicitWidth: effectiveSize + implicitHeight: effectiveSize + } + contentItem: MaterialSymbol { + anchors.centerIn: parent + text: "add" + iconSize: Appearance.sizes.barHeight * 0.6 + color: Appearance.colors.colOnLayer3 + } + } + } + } + } + } + } + + IpcHandler { + target: "bar" + + function toggle(): void { + GlobalStates.barOpen = !GlobalStates.barOpen; + } + + function close(): void { + GlobalStates.barOpen = false; + } + + function open(): void { + GlobalStates.barOpen = true; + } + } + + GlobalShortcut { + name: "barToggle" + description: "Toggles bar on press" + + onPressed: { + GlobalStates.barOpen = !GlobalStates.barOpen; + } + } + + GlobalShortcut { + name: "barOpen" + description: "Opens bar on press" + + onPressed: { + GlobalStates.barOpen = true; + } + } + + GlobalShortcut { + name: "barClose" + description: "Closes bar on press" + + onPressed: { + GlobalStates.barOpen = false; + } + } +} diff --git a/.config/quickshell/oo/modules/bar/BarButton.qml b/.config/quickshell/oo/modules/bar/BarButton.qml new file mode 100644 index 000000000..d162acd4a --- /dev/null +++ b/.config/quickshell/oo/modules/bar/BarButton.qml @@ -0,0 +1,11 @@ +import QtQuick +import QtQuick.Controls +import qs.singletons + +Button { + implicitHeight: Appearance.sizes.barHeight + implicitWidth: Appearance.sizes.barHeight + + background: null + contentItem: null +} \ No newline at end of file diff --git a/.config/quickshell/oo/modules/common/functions/ColorUtils.qml b/.config/quickshell/oo/modules/common/functions/ColorUtils.qml new file mode 100644 index 000000000..27d48187a --- /dev/null +++ b/.config/quickshell/oo/modules/common/functions/ColorUtils.qml @@ -0,0 +1,114 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + /** + * Returns a color with the hue of color2 and the saturation, value, and alpha of color1. + * + * @param {string} color1 - The base color (any Qt.color-compatible string). + * @param {string} color2 - The color to take hue from. + * @returns {Qt.rgba} The resulting color. + */ + function colorWithHueOf(color1, color2) { + var c1 = Qt.color(color1); + var c2 = Qt.color(color2); + + // Qt.color hsvHue/hsvSaturation/hsvValue/alpha return 0-1 + var hue = c2.hsvHue; + var sat = c1.hsvSaturation; + var val = c1.hsvValue; + var alpha = c1.a; + + return Qt.hsva(hue, sat, val, alpha); + } + + /** + * Returns a color with the saturation of color2 and the hue/value/alpha of color1. + * + * @param {string} color1 - The base color (any Qt.color-compatible string). + * @param {string} color2 - The color to take saturation from. + * @returns {Qt.rgba} The resulting color. + */ + function colorWithSaturationOf(color1, color2) { + var c1 = Qt.color(color1); + var c2 = Qt.color(color2); + + var hue = c1.hsvHue; + var sat = c2.hsvSaturation; + var val = c1.hsvValue; + var alpha = c1.a; + + return Qt.hsva(hue, sat, val, alpha); + } + + /** + * Returns a color with the given lightness and the hue, saturation, and alpha of the input color (using HSL). + * + * @param {string} color - The base color (any Qt.color-compatible string). + * @param {number} lightness - The lightness value to use (0-1). + * @returns {Qt.rgba} The resulting color. + */ + function colorWithLightness(color, lightness) { + var c = Qt.color(color); + return Qt.hsla(c.hslHue, c.hslSaturation, lightness, c.a); + } + + /** + * Returns a color with the lightness of color2 and the hue, saturation, and alpha of color1 (using HSL). + * + * @param {string} color1 - The base color (any Qt.color-compatible string). + * @param {string} color2 - The color to take lightness from. + * @returns {Qt.rgba} The resulting color. + */ + function colorWithLightnessOf(color1, color2) { + var c2 = Qt.color(color2); + return colorWithLightness(color1, c2.hslLightness); + } + + /** + * Adapts color1 to the accent (hue and saturation) of color2 using HSL, keeping lightness and alpha from color1. + * + * @param {string} color1 - The base color (any Qt.color-compatible string). + * @param {string} color2 - The accent color. + * @returns {Qt.rgba} The resulting color. + */ + function adaptToAccent(color1, color2) { + var c1 = Qt.color(color1); + var c2 = Qt.color(color2); + + var hue = c2.hslHue; + var sat = c2.hslSaturation; + var light = c1.hslLightness; + var alpha = c1.a; + + return Qt.hsla(hue, sat, light, alpha); + } + + /** + * Mixes two colors by a given percentage. + * + * @param {string} color1 - The first color (any Qt.color-compatible string). + * @param {string} color2 - The second color. + * @param {number} percentage - The mix ratio (0-1). 1 = all color1, 0 = all color2. + * @returns {Qt.rgba} The resulting mixed color. + */ + function mix(color1, color2, percentage = 0.5) { + var c1 = Qt.color(color1); + var c2 = Qt.color(color2); + return Qt.rgba(percentage * c1.r + (1 - percentage) * c2.r, percentage * c1.g + (1 - percentage) * c2.g, percentage * c1.b + (1 - percentage) * c2.b, percentage * c1.a + (1 - percentage) * c2.a); + } + + /** + * Transparentizes a color by a given percentage. + * + * @param {string} color - The color (any Qt.color-compatible string). + * @param {number} percentage - The amount to transparentize (0-1). + * @returns {Qt.rgba} The resulting color. + */ + function transparentize(color, percentage = 1) { + var c = Qt.color(color); + return Qt.rgba(c.r, c.g, c.b, c.a * (1 - percentage)); + } +} diff --git a/.config/quickshell/oo/modules/common/functions/FileUtils.qml b/.config/quickshell/oo/modules/common/functions/FileUtils.qml new file mode 100644 index 000000000..c051674ea --- /dev/null +++ b/.config/quickshell/oo/modules/common/functions/FileUtils.qml @@ -0,0 +1,41 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + /** + * Trims the File protocol off the input string + * @param {string} str + * @returns {string} + */ + function trimFileProtocol(str) { + return str.startsWith("file://") ? str.slice(7) : str; + } + + /** + * Extracts the file name from a file path + * @param {string} str + * @returns {string} + */ + function fileNameForPath(str) { + if (typeof str !== "string") return ""; + const trimmed = trimFileProtocol(str); + return trimmed.split(/[\\/]/).pop(); + } + + /** + * Removes the file extension from a file path or name + * @param {string} str + * @returns {string} + */ + function trimFileExt(str) { + if (typeof str !== "string") return ""; + const trimmed = trimFileProtocol(str); + const lastDot = trimmed.lastIndexOf("."); + if (lastDot > -1 && lastDot > trimmed.lastIndexOf("/")) { + return trimmed.slice(0, lastDot); + } + return trimmed; + } +} diff --git a/.config/quickshell/oo/modules/common/functions/ObjectUtils.qml b/.config/quickshell/oo/modules/common/functions/ObjectUtils.qml new file mode 100644 index 000000000..d1204cddc --- /dev/null +++ b/.config/quickshell/oo/modules/common/functions/ObjectUtils.qml @@ -0,0 +1,98 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + function toPlainObject(qtObj) { + if (qtObj === null || typeof qtObj !== "object") return qtObj; + + // Handle true arrays + if (Array.isArray(qtObj)) { + return qtObj.map(item => toPlainObject(item)); + } + + // Handle array-like Qt objects (e.g., have length and numeric keys) + if ( + typeof qtObj.length === "number" && + qtObj.length > 0 && + Object.keys(qtObj).every( + key => !isNaN(key) || key === "length" + ) + ) { + let arr = []; + for (let i = 0; i < qtObj.length; i++) { + arr.push(toPlainObject(qtObj[i])); + } + return arr; + } + + const result = ({}); + for (let key in qtObj) { + if ( + typeof qtObj[key] !== "function" && + !key.startsWith("objectName") && + !key.startsWith("children") && + !key.startsWith("object") && + !key.startsWith("parent") && + !key.startsWith("metaObject") && + !key.startsWith("destroyed") && + !key.startsWith("reloadableId") + ) { + result[key] = toPlainObject(qtObj[key]); + } + } + // console.log(JSON.stringify(result)) + return result; + } + + function applyToQtObject(qtObj, jsonObj) { + // console.log("applyToQtObject", JSON.stringify(qtObj, null, 2), "<<", JSON.stringify(jsonObj, null, 2)); + if (!qtObj || typeof jsonObj !== "object" || jsonObj === null) return; + + // Detect array-like Qt objects + const isQtArrayLike = obj => { + return obj && typeof obj === "object" && + typeof obj.length === "number" && + obj.length > 0 && + Object.keys(obj).every(key => !isNaN(key) || key === "length"); + }; + + // If both are arrays or array-like, update in place or replace + if ((Array.isArray(qtObj) || isQtArrayLike(qtObj)) && Array.isArray(jsonObj)) { + qtObj.length = 0; + for (let i = 0; i < jsonObj.length; i++) { + qtObj.push(jsonObj[i]); + } + return; + } + + // If target is array or array-like but source is not, clear + if ((Array.isArray(qtObj) || isQtArrayLike(qtObj)) && !Array.isArray(jsonObj)) { + qtObj.length = 0; + return; + } + + // If source is array but target is not, assign directly if possible + if (!(Array.isArray(qtObj) || isQtArrayLike(qtObj)) && Array.isArray(jsonObj)) { + return jsonObj; + } + + for (let key in jsonObj) { + if (!qtObj.hasOwnProperty(key)) continue; + const value = qtObj[key]; + const jsonValue = jsonObj[key]; + // console.log("applying to qt obj key:", value, "jsonValue:", jsonValue); + if ((Array.isArray(value) || isQtArrayLike(value)) && Array.isArray(jsonValue)) { + value.length = 0; + for (let i = 0; i < jsonValue.length; i++) { + value.push(jsonValue[i]); + } + } else if (value && typeof value === "object" && !Array.isArray(value) && !isQtArrayLike(value)) { + applyToQtObject(value, jsonValue); + } else { + qtObj[key] = jsonValue; + } + } + } +} diff --git a/.config/quickshell/oo/modules/common/functions/StringUtils.qml b/.config/quickshell/oo/modules/common/functions/StringUtils.qml new file mode 100644 index 000000000..e82418311 --- /dev/null +++ b/.config/quickshell/oo/modules/common/functions/StringUtils.qml @@ -0,0 +1,221 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + /** + * Formats a string according to the args that are passed inc + * @param { string } str + * @param {...any} args + * @returns + */ + function format(str, ...args) { + return str.replace(/{(\d+)}/g, (match, index) => typeof args[index] !== 'undefined' ? args[index] : match); + } + + /** + * Returns the domain of the passed in url or null + * @param { string } url + * @returns { string| null } + */ + function getDomain(url) { + const match = url.match(/^(?:https?:\/\/)?(?:www\.)?([^\/]+)/); + return match ? match[1] : null; + } + + /** + * Returns the base url of the passed in url or null + * @param { string } url + * @returns { string | null } + */ + function getBaseUrl(url) { + const match = url.match(/^(https?:\/\/[^\/]+)(\/.*)?$/); + return match ? match[1] : null; + } + + /** + * Escapes single quotes in shell commands + * @param { string } str + * @returns { string } + */ + function shellSingleQuoteEscape(str) { + // escape single quotes + return String(str) + // .replace(/\\/g, '\\\\') + .replace(/'/g, "'\\''"); + } + + /** + * Splits markdown blocks into three different types: text, think, and code. + * @param { string } markdown + */ + function splitMarkdownBlocks(markdown) { + const regex = /```(\w+)?\n([\s\S]*?)```|([\s\S]*?)<\/think>/g; + /** + * @type {{type: "text" | "think" | "code"; content: string; lang: string | undefined; completed: boolean | undefined}[]} + */ + let result = []; + let lastIndex = 0; + let match; + while ((match = regex.exec(markdown)) !== null) { + if (match.index > lastIndex) { + const text = markdown.slice(lastIndex, match.index); + if (text.trim()) { + result.push({ + type: "text", + content: text + }); + } + } + if (match[0].startsWith('```')) { + if (match[2] && match[2].trim()) { + result.push({ + type: "code", + lang: match[1] || "", + content: match[2], + completed: true + }); + } + } else if (match[0].startsWith('')) { + if (match[3] && match[3].trim()) { + result.push({ + type: "think", + content: match[3], + completed: true + }); + } + } + lastIndex = regex.lastIndex; + } + // Handle any remaining text after the last match + if (lastIndex < markdown.length) { + const text = markdown.slice(lastIndex); + // Check for unfinished block + const thinkStart = text.indexOf(''); + const codeStart = text.indexOf('```'); + if (thinkStart !== -1 && (codeStart === -1 || thinkStart < codeStart)) { + const beforeThink = text.slice(0, thinkStart); + if (beforeThink.trim()) { + result.push({ + type: "text", + content: beforeThink + }); + } + const thinkContent = text.slice(thinkStart + 7); + if (thinkContent.trim()) { + result.push({ + type: "think", + content: thinkContent, + completed: false + }); + } + } else if (codeStart !== -1) { + const beforeCode = text.slice(0, codeStart); + if (beforeCode.trim()) { + result.push({ + type: "text", + content: beforeCode + }); + } + // Try to detect language after ``` + const codeLangMatch = text.slice(codeStart + 3).match(/^(\w+)?\n/); + let lang = ""; + let codeContentStart = codeStart + 3; + if (codeLangMatch) { + lang = codeLangMatch[1] || ""; + codeContentStart += codeLangMatch[0].length; + } else if (text[codeStart + 3] === '\n') { + codeContentStart += 1; + } + const codeContent = text.slice(codeContentStart); + if (codeContent.trim()) { + result.push({ + type: "code", + lang, + content: codeContent, + completed: false + }); + } + } else if (text.trim()) { + result.push({ + type: "text", + content: text + }); + } + } + // console.log(JSON.stringify(result, null, 2)); + return result; + } + + /** + * Returns the original string with backslashes escaped + * @param { string } str + * @returns { string } + */ + function escapeBackslashes(str) { + return str.replace(/\\/g, '\\\\'); + } + + /** + * Wraps words to supplied maximum length + * @param { string | null } str + * @param { number } maxLen + * @returns { string } + */ + function wordWrap(str, maxLen) { + if (!str) + return ""; + let words = str.split(" "); + let lines = []; + let current = ""; + for (let i = 0; i < words.length; ++i) { + if ((current + (current.length > 0 ? " " : "") + words[i]).length > maxLen) { + if (current.length > 0) + lines.push(current); + current = words[i]; + } else { + current += (current.length > 0 ? " " : "") + words[i]; + } + } + if (current.length > 0) + lines.push(current); + return lines.join("\n"); + } + + function cleanMusicTitle(title) { + if (!title) + return ""; + // Brackets + title = title.replace(/^ *\([^)]*\) */g, " "); // Round brackets + title = title.replace(/^ *\[[^\]]*\] */g, " "); // Square brackets + title = title.replace(/^ *\{[^\}]*\} */g, " "); // Curly brackets + // Japenis brackets + title = title.replace(/^ *【[^】]*】/, ""); // Touhou + title = title.replace(/^ *《[^》]*》/, ""); // ?? + title = title.replace(/^ *「[^」]*」/, ""); // OP/ED thingie + title = title.replace(/^ *『[^』]*』/, ""); // OP/ED thingie + + return title.trim(); + } + + function friendlyTimeForSeconds(seconds) { + if (isNaN(seconds) || seconds < 0) + return "0:00"; + seconds = Math.floor(seconds); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) { + return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + } else { + return `${m}:${s.toString().padStart(2, '0')}`; + } + } + + function escapeHtml(str) { + if (typeof str !== 'string') + return str; + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } +} diff --git a/.config/quickshell/oo/modules/common/functions/fuzzysort.js b/.config/quickshell/oo/modules/common/functions/fuzzysort.js new file mode 100644 index 000000000..1c1d9b9db --- /dev/null +++ b/.config/quickshell/oo/modules/common/functions/fuzzysort.js @@ -0,0 +1,682 @@ +.pragma library + +// https://github.com/farzher/fuzzysort +// License: MIT | Copyright (c) 2018 Stephen Kamenar +// A copy of the license is available in the `licenses` folder of this repository + +var single = (search, target) => { + if(!search || !target) return NULL + + var preparedSearch = getPreparedSearch(search) + if(!isPrepared(target)) target = getPrepared(target) + + var searchBitflags = preparedSearch.bitflags + if((searchBitflags & target._bitflags) !== searchBitflags) return NULL + + return algorithm(preparedSearch, target) +} + +var go = (search, targets, options) => { + if(!search) return options?.all ? all(targets, options) : noResults + + var preparedSearch = getPreparedSearch(search) + var searchBitflags = preparedSearch.bitflags + var containsSpace = preparedSearch.containsSpace + + var threshold = denormalizeScore( options?.threshold || 0 ) + var limit = options?.limit || INFINITY + + var resultsLen = 0; var limitedCount = 0 + var targetsLen = targets.length + + function push_result(result) { + if(resultsLen < limit) { q.add(result); ++resultsLen } + else { + ++limitedCount + if(result._score > q.peek()._score) q.replaceTop(result) + } + } + + // This code is copy/pasted 3 times for performance reasons [options.key, options.keys, no keys] + + // options.key + if(options?.key) { + var key = options.key + for(var i = 0; i < targetsLen; ++i) { var obj = targets[i] + var target = getValue(obj, key) + if(!target) continue + if(!isPrepared(target)) target = getPrepared(target) + + if((searchBitflags & target._bitflags) !== searchBitflags) continue + var result = algorithm(preparedSearch, target) + if(result === NULL) continue + if(result._score < threshold) continue + + result.obj = obj + push_result(result) + } + + // options.keys + } else if(options?.keys) { + var keys = options.keys + var keysLen = keys.length + + outer: for(var i = 0; i < targetsLen; ++i) { var obj = targets[i] + + { // early out based on bitflags + var keysBitflags = 0 + for (var keyI = 0; keyI < keysLen; ++keyI) { + var key = keys[keyI] + var target = getValue(obj, key) + if(!target) { tmpTargets[keyI] = noTarget; continue } + if(!isPrepared(target)) target = getPrepared(target) + tmpTargets[keyI] = target + + keysBitflags |= target._bitflags + } + + if((searchBitflags & keysBitflags) !== searchBitflags) continue + } + + if(containsSpace) for(let i=0; i -1000) { + if(keysSpacesBestScores[i] > NEGATIVE_INFINITY) { + var tmp = (keysSpacesBestScores[i] + allowPartialMatchScores[i]) / 4/*bonus score for having multiple matches*/ + if(tmp > keysSpacesBestScores[i]) keysSpacesBestScores[i] = tmp + } + } + if(allowPartialMatchScores[i] > keysSpacesBestScores[i]) keysSpacesBestScores[i] = allowPartialMatchScores[i] + } + } + + if(containsSpace) { + for(let i=0; i -1000) { + if(score > NEGATIVE_INFINITY) { + var tmp = (score + result._score) / 4/*bonus score for having multiple matches*/ + if(tmp > score) score = tmp + } + } + if(result._score > score) score = result._score + } + } + + objResults.obj = obj + objResults._score = score + if(options?.scoreFn) { + score = options.scoreFn(objResults) + if(!score) continue + score = denormalizeScore(score) + objResults._score = score + } + + if(score < threshold) continue + push_result(objResults) + } + + // no keys + } else { + for(var i = 0; i < targetsLen; ++i) { var target = targets[i] + if(!target) continue + if(!isPrepared(target)) target = getPrepared(target) + + if((searchBitflags & target._bitflags) !== searchBitflags) continue + var result = algorithm(preparedSearch, target) + if(result === NULL) continue + if(result._score < threshold) continue + + push_result(result) + } + } + + if(resultsLen === 0) return noResults + var results = new Array(resultsLen) + for(var i = resultsLen - 1; i >= 0; --i) results[i] = q.poll() + results.total = resultsLen + limitedCount + return results +} + + +// this is written as 1 function instead of 2 for minification. perf seems fine ... +// except when minified. the perf is very slow +var highlight = (result, open='', close='') => { + var callback = typeof open === 'function' ? open : undefined + + var target = result.target + var targetLen = target.length + var indexes = result.indexes + var highlighted = '' + var matchI = 0 + var indexesI = 0 + var opened = false + var parts = [] + + for(var i = 0; i < targetLen; ++i) { var char = target[i] + if(indexes[indexesI] === i) { + ++indexesI + if(!opened) { opened = true + if(callback) { + parts.push(highlighted); highlighted = '' + } else { + highlighted += open + } + } + + if(indexesI === indexes.length) { + if(callback) { + highlighted += char + parts.push(callback(highlighted, matchI++)); highlighted = '' + parts.push(target.substr(i+1)) + } else { + highlighted += char + close + target.substr(i+1) + } + break + } + } else { + if(opened) { opened = false + if(callback) { + parts.push(callback(highlighted, matchI++)); highlighted = '' + } else { + highlighted += close + } + } + } + highlighted += char + } + + return callback ? parts : highlighted +} + + +var prepare = (target) => { + if(typeof target === 'number') target = ''+target + else if(typeof target !== 'string') target = '' + var info = prepareLowerInfo(target) + return new_result(target, {_targetLower:info._lower, _targetLowerCodes:info.lowerCodes, _bitflags:info.bitflags}) +} + +var cleanup = () => { preparedCache.clear(); preparedSearchCache.clear() } + + +// Below this point is only internal code +// Below this point is only internal code +// Below this point is only internal code +// Below this point is only internal code + + +class Result { + get ['indexes']() { return this._indexes.slice(0, this._indexes.len).sort((a,b)=>a-b) } + set ['indexes'](indexes) { return this._indexes = indexes } + ['highlight'](open, close) { return highlight(this, open, close) } + get ['score']() { return normalizeScore(this._score) } + set ['score'](score) { this._score = denormalizeScore(score) } +} + +class KeysResult extends Array { + get ['score']() { return normalizeScore(this._score) } + set ['score'](score) { this._score = denormalizeScore(score) } +} + +var new_result = (target, options) => { + const result = new Result() + result['target'] = target + result['obj'] = options.obj ?? NULL + result._score = options._score ?? NEGATIVE_INFINITY + result._indexes = options._indexes ?? [] + result._targetLower = options._targetLower ?? '' + result._targetLowerCodes = options._targetLowerCodes ?? NULL + result._nextBeginningIndexes = options._nextBeginningIndexes ?? NULL + result._bitflags = options._bitflags ?? 0 + return result +} + + +var normalizeScore = score => { + if(score === NEGATIVE_INFINITY) return 0 + if(score > 1) return score + return Math.E ** ( ((-score + 1)**.04307 - 1) * -2) +} +var denormalizeScore = normalizedScore => { + if(normalizedScore === 0) return NEGATIVE_INFINITY + if(normalizedScore > 1) return normalizedScore + return 1 - Math.pow((Math.log(normalizedScore) / -2 + 1), 1 / 0.04307) +} + + +var prepareSearch = (search) => { + if(typeof search === 'number') search = ''+search + else if(typeof search !== 'string') search = '' + search = search.trim() + var info = prepareLowerInfo(search) + + var spaceSearches = [] + if(info.containsSpace) { + var searches = search.split(/\s+/) + searches = [...new Set(searches)] // distinct + for(var i=0; i { + if(target.length > 999) return prepare(target) // don't cache huge targets + var targetPrepared = preparedCache.get(target) + if(targetPrepared !== undefined) return targetPrepared + targetPrepared = prepare(target) + preparedCache.set(target, targetPrepared) + return targetPrepared +} +var getPreparedSearch = (search) => { + if(search.length > 999) return prepareSearch(search) // don't cache huge searches + var searchPrepared = preparedSearchCache.get(search) + if(searchPrepared !== undefined) return searchPrepared + searchPrepared = prepareSearch(search) + preparedSearchCache.set(search, searchPrepared) + return searchPrepared +} + + +var all = (targets, options) => { + var results = []; results.total = targets.length // this total can be wrong if some targets are skipped + + var limit = options?.limit || INFINITY + + if(options?.key) { + for(var i=0;i= limit) return results + } + } else if(options?.keys) { + for(var i=0;i= 0; --keyI) { + var target = getValue(obj, options.keys[keyI]) + if(!target) { objResults[keyI] = noTarget; continue } + if(!isPrepared(target)) target = getPrepared(target) + target._score = NEGATIVE_INFINITY + target._indexes.len = 0 + objResults[keyI] = target + } + objResults.obj = obj + objResults._score = NEGATIVE_INFINITY + results.push(objResults); if(results.length >= limit) return results + } + } else { + for(var i=0;i= limit) return results + } + } + + return results +} + + +var algorithm = (preparedSearch, prepared, allowSpaces=false, allowPartialMatch=false) => { + if(allowSpaces===false && preparedSearch.containsSpace) return algorithmSpaces(preparedSearch, prepared, allowPartialMatch) + + var searchLower = preparedSearch._lower + var searchLowerCodes = preparedSearch.lowerCodes + var searchLowerCode = searchLowerCodes[0] + var targetLowerCodes = prepared._targetLowerCodes + var searchLen = searchLowerCodes.length + var targetLen = targetLowerCodes.length + var searchI = 0 // where we at + var targetI = 0 // where you at + var matchesSimpleLen = 0 + + // very basic fuzzy match; to remove non-matching targets ASAP! + // walk through target. find sequential matches. + // if all chars aren't found then exit + for(;;) { + var isMatch = searchLowerCode === targetLowerCodes[targetI] + if(isMatch) { + matchesSimple[matchesSimpleLen++] = targetI + ++searchI; if(searchI === searchLen) break + searchLowerCode = searchLowerCodes[searchI] + } + ++targetI; if(targetI >= targetLen) return NULL // Failed to find searchI + } + + var searchI = 0 + var successStrict = false + var matchesStrictLen = 0 + + var nextBeginningIndexes = prepared._nextBeginningIndexes + if(nextBeginningIndexes === NULL) nextBeginningIndexes = prepared._nextBeginningIndexes = prepareNextBeginningIndexes(prepared.target) + targetI = matchesSimple[0]===0 ? 0 : nextBeginningIndexes[matchesSimple[0]-1] + + // Our target string successfully matched all characters in sequence! + // Let's try a more advanced and strict test to improve the score + // only count it as a match if it's consecutive or a beginning character! + var backtrackCount = 0 + if(targetI !== targetLen) for(;;) { + if(targetI >= targetLen) { + // We failed to find a good spot for this search char, go back to the previous search char and force it forward + if(searchI <= 0) break // We failed to push chars forward for a better match + + ++backtrackCount; if(backtrackCount > 200) break // exponential backtracking is taking too long, just give up and return a bad match + + --searchI + var lastMatch = matchesStrict[--matchesStrictLen] + targetI = nextBeginningIndexes[lastMatch] + + } else { + var isMatch = searchLowerCodes[searchI] === targetLowerCodes[targetI] + if(isMatch) { + matchesStrict[matchesStrictLen++] = targetI + ++searchI; if(searchI === searchLen) { successStrict = true; break } + ++targetI + } else { + targetI = nextBeginningIndexes[targetI] + } + } + } + + // check if it's a substring match + var substringIndex = searchLen <= 1 ? -1 : prepared._targetLower.indexOf(searchLower, matchesSimple[0]) // perf: this is slow + var isSubstring = !!~substringIndex + var isSubstringBeginning = !isSubstring ? false : substringIndex===0 || prepared._nextBeginningIndexes[substringIndex-1] === substringIndex + + // if it's a substring match but not at a beginning index, let's try to find a substring starting at a beginning index for a better score + if(isSubstring && !isSubstringBeginning) { + for(var i=0; i { + var score = 0 + + var extraMatchGroupCount = 0 + for(var i = 1; i < searchLen; ++i) { + if(matches[i] - matches[i-1] !== 1) {score -= matches[i]; ++extraMatchGroupCount} + } + var unmatchedDistance = matches[searchLen-1] - matches[0] - (searchLen-1) + + score -= (12+unmatchedDistance) * extraMatchGroupCount // penality for more groups + + if(matches[0] !== 0) score -= matches[0]*matches[0]*.2 // penality for not starting near the beginning + + if(!successStrict) { + score *= 1000 + } else { + // successStrict on a target with too many beginning indexes loses points for being a bad target + var uniqueBeginningIndexes = 1 + for(var i = nextBeginningIndexes[0]; i < targetLen; i=nextBeginningIndexes[i]) ++uniqueBeginningIndexes + + if(uniqueBeginningIndexes > 24) score *= (uniqueBeginningIndexes-24)*10 // quite arbitrary numbers here ... + } + + score -= (targetLen - searchLen)/2 // penality for longer targets + + if(isSubstring) score /= 1+searchLen*searchLen*1 // bonus for being a full substring + if(isSubstringBeginning) score /= 1+searchLen*searchLen*1 // bonus for substring starting on a beginningIndex + + score -= (targetLen - searchLen)/2 // penality for longer targets + + return score + } + + if(!successStrict) { + if(isSubstring) for(var i=0; i { + var seen_indexes = new Set() + var score = 0 + var result = NULL + + var first_seen_index_last_search = 0 + var searches = preparedSearch.spaceSearches + var searchesLen = searches.length + var changeslen = 0 + + // Return _nextBeginningIndexes back to its normal state + var resetNextBeginningIndexes = () => { + for(let i=changeslen-1; i>=0; i--) target._nextBeginningIndexes[nextBeginningIndexesChanges[i*2 + 0]] = nextBeginningIndexesChanges[i*2 + 1] + } + + var hasAtLeast1Match = false + for(var i=0; i=0; i--) { + if(toReplace !== target._nextBeginningIndexes[i]) break + target._nextBeginningIndexes[i] = newBeginningIndex + nextBeginningIndexesChanges[changeslen*2 + 0] = i + nextBeginningIndexesChanges[changeslen*2 + 1] = toReplace + changeslen++ + } + } + } + + score += result._score / searchesLen + allowPartialMatchScores[i] = result._score / searchesLen + + // dock points based on order otherwise "c man" returns Manifest.cpp instead of CheatManager.h + if(result._indexes[0] < first_seen_index_last_search) { + score -= (first_seen_index_last_search - result._indexes[0]) * 2 + } + first_seen_index_last_search = result._indexes[0] + + for(var j=0; j score) { + if(allowPartialMatch) { + for(var i=0; i str.replace(/\p{Script=Latin}+/gu, match => match.normalize('NFD')).replace(/[\u0300-\u036f]/g, '') + +var prepareLowerInfo = (str) => { + str = remove_accents(str) + var strLen = str.length + var lower = str.toLowerCase() + var lowerCodes = [] // new Array(strLen) sparse array is too slow + var bitflags = 0 + var containsSpace = false // space isn't stored in bitflags because of how searching with a space works + + for(var i = 0; i < strLen; ++i) { + var lowerCode = lowerCodes[i] = lower.charCodeAt(i) + + if(lowerCode === 32) { + containsSpace = true + continue // it's important that we don't set any bitflags for space + } + + var bit = lowerCode>=97&&lowerCode<=122 ? lowerCode-97 // alphabet + : lowerCode>=48&&lowerCode<=57 ? 26 // numbers + // 3 bits available + : lowerCode<=127 ? 30 // other ascii + : 31 // other utf8 + bitflags |= 1< { + var targetLen = target.length + var beginningIndexes = []; var beginningIndexesLen = 0 + var wasUpper = false + var wasAlphanum = false + for(var i = 0; i < targetLen; ++i) { + var targetCode = target.charCodeAt(i) + var isUpper = targetCode>=65&&targetCode<=90 + var isAlphanum = isUpper || targetCode>=97&&targetCode<=122 || targetCode>=48&&targetCode<=57 + var isBeginning = isUpper && !wasUpper || !wasAlphanum || !isAlphanum + wasUpper = isUpper + wasAlphanum = isAlphanum + if(isBeginning) beginningIndexes[beginningIndexesLen++] = i + } + return beginningIndexes +} +var prepareNextBeginningIndexes = (target) => { + target = remove_accents(target) + var targetLen = target.length + var beginningIndexes = prepareBeginningIndexes(target) + var nextBeginningIndexes = [] // new Array(targetLen) sparse array is too slow + var lastIsBeginning = beginningIndexes[0] + var lastIsBeginningI = 0 + for(var i = 0; i < targetLen; ++i) { + if(lastIsBeginning > i) { + nextBeginningIndexes[i] = lastIsBeginning + } else { + lastIsBeginning = beginningIndexes[++lastIsBeginningI] + nextBeginningIndexes[i] = lastIsBeginning===undefined ? targetLen : lastIsBeginning + } + } + return nextBeginningIndexes +} + +var preparedCache = new Map() +var preparedSearchCache = new Map() + +// the theory behind these being globals is to reduce garbage collection by not making new arrays +var matchesSimple = []; var matchesStrict = [] +var nextBeginningIndexesChanges = [] // allows straw berry to match strawberry well, by modifying the end of a substring to be considered a beginning index for the rest of the search +var keysSpacesBestScores = []; var allowPartialMatchScores = [] +var tmpTargets = []; var tmpResults = [] + +// prop = 'key' 2.5ms optimized for this case, seems to be about as fast as direct obj[prop] +// prop = 'key1.key2' 10ms +// prop = ['key1', 'key2'] 27ms +// prop = obj => obj.tags.join() ??ms +var getValue = (obj, prop) => { + var tmp = obj[prop]; if(tmp !== undefined) return tmp + if(typeof prop === 'function') return prop(obj) // this should run first. but that makes string props slower + var segs = prop + if(!Array.isArray(prop)) segs = prop.split('.') + var len = segs.length + var i = -1 + while (obj && (++i < len)) obj = obj[segs[i]] + return obj +} + +var isPrepared = (x) => { return typeof x === 'object' && typeof x._bitflags === 'number' } +var INFINITY = Infinity; var NEGATIVE_INFINITY = -INFINITY +var noResults = []; noResults.total = 0 +var NULL = null + +var noTarget = prepare('') + +// Hacked version of https://github.com/lemire/FastPriorityQueue.js +var fastpriorityqueue=r=>{var e=[],o=0,a={},v=r=>{for(var a=0,v=e[a],c=1;c>1]=e[a],c=1+(a<<1)}for(var f=a-1>>1;a>0&&v._score>1)e[a]=e[f];e[a]=v};return a.add=(r=>{var a=o;e[o++]=r;for(var v=a-1>>1;a>0&&r._score>1)e[a]=e[v];e[a]=r}),a.poll=(r=>{if(0!==o){var a=e[0];return e[0]=e[--o],v(),a}}),a.peek=(r=>{if(0!==o)return e[0]}),a.replaceTop=(r=>{e[0]=r,v()}),a} +var q = fastpriorityqueue() // reuse this diff --git a/.config/quickshell/oo/modules/common/functions/levendist.js b/.config/quickshell/oo/modules/common/functions/levendist.js new file mode 100644 index 000000000..90180d216 --- /dev/null +++ b/.config/quickshell/oo/modules/common/functions/levendist.js @@ -0,0 +1,141 @@ +// Original code from https://github.com/koeqaife/hyprland-material-you +// Original code license: GPLv3 +// Translated to Js from Cython with an LLM and reviewed + +function min3(a, b, c) { + return a < b && a < c ? a : b < c ? b : c; +} + +function max3(a, b, c) { + return a > b && a > c ? a : b > c ? b : c; +} + +function min2(a, b) { + return a < b ? a : b; +} + +function max2(a, b) { + return a > b ? a : b; +} + +function levenshteinDistance(s1, s2) { + let len1 = s1.length; + let len2 = s2.length; + + if (len1 === 0) return len2; + if (len2 === 0) return len1; + + if (len2 > len1) { + [s1, s2] = [s2, s1]; + [len1, len2] = [len2, len1]; + } + + let prev = new Array(len2 + 1); + let curr = new Array(len2 + 1); + + for (let j = 0; j <= len2; j++) { + prev[j] = j; + } + + for (let i = 1; i <= len1; i++) { + curr[0] = i; + for (let j = 1; j <= len2; j++) { + let cost = s1[i - 1] === s2[j - 1] ? 0 : 1; + curr[j] = min3(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); + } + [prev, curr] = [curr, prev]; + } + + return prev[len2]; +} + +function partialRatio(shortS, longS) { + let lenS = shortS.length; + let lenL = longS.length; + let best = 0.0; + + if (lenS === 0) return 1.0; + + for (let i = 0; i <= lenL - lenS; i++) { + let sub = longS.slice(i, i + lenS); + let dist = levenshteinDistance(shortS, sub); + let score = 1.0 - (dist / lenS); + if (score > best) best = score; + } + + return best; +} + +function computeScore(s1, s2) { + if (s1 === s2) return 1.0; + + let dist = levenshteinDistance(s1, s2); + let maxLen = max2(s1.length, s2.length); + if (maxLen === 0) return 1.0; + + let full = 1.0 - (dist / maxLen); + let part = s1.length < s2.length ? partialRatio(s1, s2) : partialRatio(s2, s1); + + let score = 0.85 * full + 0.15 * part; + + if (s1 && s2 && s1[0] !== s2[0]) { + score -= 0.05; + } + + let lenDiff = Math.abs(s1.length - s2.length); + if (lenDiff >= 3) { + score -= 0.05 * lenDiff / maxLen; + } + + let commonPrefixLen = 0; + let minLen = min2(s1.length, s2.length); + for (let i = 0; i < minLen; i++) { + if (s1[i] === s2[i]) { + commonPrefixLen++; + } else { + break; + } + } + score += 0.02 * commonPrefixLen; + + if (s1.includes(s2) || s2.includes(s1)) { + score += 0.06; + } + + return Math.max(0.0, Math.min(1.0, score)); +} + +function computeTextMatchScore(s1, s2) { + if (s1 === s2) return 1.0; + + let dist = levenshteinDistance(s1, s2); + let maxLen = max2(s1.length, s2.length); + if (maxLen === 0) return 1.0; + + let full = 1.0 - (dist / maxLen); + let part = s1.length < s2.length ? partialRatio(s1, s2) : partialRatio(s2, s1); + + let score = 0.4 * full + 0.6 * part; + + let lenDiff = Math.abs(s1.length - s2.length); + if (lenDiff >= 10) { + score -= 0.02 * lenDiff / maxLen; + } + + let commonPrefixLen = 0; + let minLen = min2(s1.length, s2.length); + for (let i = 0; i < minLen; i++) { + if (s1[i] === s2[i]) { + commonPrefixLen++; + } else { + break; + } + } + score += 0.01 * commonPrefixLen; + + if (s1.includes(s2) || s2.includes(s1)) { + score += 0.2; + } + + return Math.max(0.0, Math.min(1.0, score)); +} diff --git a/.config/quickshell/oo/modules/common/widgets/HexRect.qml b/.config/quickshell/oo/modules/common/widgets/HexRect.qml new file mode 100644 index 000000000..a43c1b3a6 --- /dev/null +++ b/.config/quickshell/oo/modules/common/widgets/HexRect.qml @@ -0,0 +1,49 @@ +import QtQuick +import QtQuick.Shapes + +/** + * Draws a hexagon when width == height. + * Otherwise the hexagon is extended + */ +Item { + id: root + property real radius: Math.min(width, height) / 2 + property real cornerRounding: radius * 0.5 + property color color: "#b7eb34" + property real borderWidth: cornerRounding + property color borderColor: color + + Shape { + id: hexShape + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + id: hexPath + fillColor: root.color + strokeColor: root.borderColor + strokeWidth: root.borderWidth + capStyle: ShapePath.RoundCap + joinStyle: ShapePath.RoundJoin + + property real r: root.radius + property real r60: r * Math.sqrt(3) / 2 + property real r30: r / 2 + property real cr: root.cornerRounding + property real cr60: cr * Math.sqrt(3) / 2 + property real cr30: cr / 2 + property real lineWidthAdjustment: strokeWidth / 2 + property real lineWidthAdjustment60: lineWidthAdjustment * Math.sqrt(3) / 2 + property real lineWidthAdjustment30: lineWidthAdjustment / 2 + + startX: hexPath.r; startY: lineWidthAdjustment; + PathLine { x: hexPath.r + hexPath.r60 - hexPath.lineWidthAdjustment60; y: hexShape.height / 2 - hexPath.r30 + hexPath.lineWidthAdjustment30 } + PathLine { x: hexPath.r + hexPath.r60 - hexPath.lineWidthAdjustment60; y: hexShape.height / 2 + hexPath.r30 - hexPath.lineWidthAdjustment30 } + PathLine { x: hexPath.r; y: hexShape.height - hexPath.lineWidthAdjustment } + PathLine { x: hexPath.r - hexPath.r60 + hexPath.lineWidthAdjustment60; y: hexShape.height - hexPath.r30 - hexPath.lineWidthAdjustment30 } + PathLine { x: hexPath.r - hexPath.r60 + hexPath.lineWidthAdjustment60; y: hexPath.r30 + hexPath.lineWidthAdjustment30 } + // Close the path + PathLine { x: hexPath.r; y: hexPath.lineWidthAdjustment } + } + } +} diff --git a/.config/quickshell/oo/modules/common/widgets/MaterialSymbol.qml b/.config/quickshell/oo/modules/common/widgets/MaterialSymbol.qml new file mode 100644 index 000000000..9dbc1ce6f --- /dev/null +++ b/.config/quickshell/oo/modules/common/widgets/MaterialSymbol.qml @@ -0,0 +1,33 @@ +import qs.singletons +import QtQuick + +Text { + id: root + property real iconSize: Appearance?.font.pixelSize.small ?? 16 + property real fill: 0 + property real truncatedFill: Math.round(fill * 100) / 100 // Reduce memory consumption spikes from constant font remapping + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + renderType: Text.NativeRendering + font { + hintingPreference: Font.PreferFullHinting + family: Appearance?.font.family.iconMaterial ?? "Material Symbols Outlined" + pixelSize: iconSize + weight: Font.Normal + (Font.DemiBold - Font.Normal) * fill + variableAxes: { + "FILL": truncatedFill, + // "wght": font.weight, + // "GRAD": 0, + "opsz": iconSize, + } + } + color: Appearance.m3colors.m3onBackground + + // Behavior on fill { + // NumberAnimation { + // duration: Appearance?.animation.elementMoveFast.duration ?? 200 + // easing.type: Appearance?.animation.elementMoveFast.type ?? Easing.BezierSpline + // easing.bezierCurve: Appearance?.animation.elementMoveFast.bezierCurve ?? [0.34, 0.80, 0.34, 1.00, 1, 1] + // } + // } +} diff --git a/.config/quickshell/oo/modules/common/widgets/PolyRect.qml b/.config/quickshell/oo/modules/common/widgets/PolyRect.qml new file mode 100644 index 000000000..db32c9bfa --- /dev/null +++ b/.config/quickshell/oo/modules/common/widgets/PolyRect.qml @@ -0,0 +1,46 @@ +import QtQuick + +/** + * Draws an octagon when width == height. + * Otherwise it's a rectangle "rounded" with two edges each corner (like 1/4 of an octagon) + */ +Item { + id: root + property real radius: Math.min(width, height) / 2 + property color color: "#b7eb34" + + onWidthChanged: polyRect.requestPaint() + onHeightChanged: polyRect.requestPaint() + onRadiusChanged: polyRect.requestPaint() + onColorChanged: polyRect.requestPaint() + + Canvas { + id: polyRect + anchors.fill: parent + + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + + var r = root.radius; + var r45 = r * Math.SQRT2 / 2; + ctx.beginPath(); + ctx.moveTo(r, 0); + ctx.lineTo(width - r, 0); + ctx.lineTo(width - r + r45, r - r45); + ctx.lineTo(width, r); + ctx.lineTo(width, height - r); + ctx.lineTo(width - r + r45, height - r + r45); + ctx.lineTo(width - r, height); + ctx.lineTo(r, height); + ctx.lineTo(r - r45, height - r + r45); + ctx.lineTo(0, height - r); + ctx.lineTo(0, r); + ctx.lineTo(r - r45, r - r45); + ctx.closePath(); + + ctx.fillStyle = root.color; + ctx.fill(); + } + } +} diff --git a/.config/quickshell/oo/modules/common/widgets/StyledText.qml b/.config/quickshell/oo/modules/common/widgets/StyledText.qml new file mode 100644 index 000000000..7e849e66e --- /dev/null +++ b/.config/quickshell/oo/modules/common/widgets/StyledText.qml @@ -0,0 +1,15 @@ +import qs.singletons +import QtQuick +import QtQuick.Layouts + +Text { + renderType: Text.NativeRendering + verticalAlignment: Text.AlignVCenter + font { + hintingPreference: Font.PreferFullHinting + family: Appearance?.font.family.main ?? "sans-serif" + pixelSize: Appearance?.font.pixelSize.small ?? 15 + } + color: Appearance?.colors.on_background ?? "black" + linkColor: Appearance?.colors.primary ?? "blue" +} diff --git a/.config/quickshell/oo/scripts/images/find_regions.py b/.config/quickshell/oo/scripts/images/find_regions.py new file mode 100755 index 000000000..fe68a4dbe --- /dev/null +++ b/.config/quickshell/oo/scripts/images/find_regions.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 + +import argparse +import cv2 +import json +import numpy as np +import sys + +DEFAULT_IMAGE_PATH = '/tmp/quickshell/media/screenshot/image' + +def iou(boxA, boxB): + # Compute intersection over union for two boxes + xA = max(boxA['x'], boxB['x']) + yA = max(boxA['y'], boxB['y']) + xB = min(boxA['x'] + boxA['width'], boxB['x'] + boxB['width']) + yB = min(boxA['y'] + boxA['height'], boxB['y'] + boxB['height']) + interW = max(0, xB - xA) + interH = max(0, yB - yA) + interArea = interW * interH + boxAArea = boxA['width'] * boxA['height'] + boxBArea = boxB['width'] * boxB['height'] + iou = interArea / float(boxAArea + boxBArea - interArea) if (boxAArea + boxBArea - interArea) > 0 else 0 + return iou + +def non_max_suppression(regions, iou_threshold=0.7): + # Sort by area (largest first) + regions = sorted(regions, key=lambda r: r['width'] * r['height'], reverse=True) + keep = [] + while regions: + current = regions.pop(0) + keep.append(current) + regions = [r for r in regions if iou(current, r) < iou_threshold] + return keep + +def find_regions(image_path, min_width, min_height, max_width=None, max_height=None, quality=False, k=150, min_size=20, sigma=0.8, resize_factor=1.0): + image = cv2.imread(image_path) + if image is None: + print(f'Error: Could not load image {image_path}', file=sys.stderr) + sys.exit(1) + orig_h, orig_w = image.shape[:2] + if resize_factor != 1.0: + image = cv2.resize(image, (int(orig_w * resize_factor), int(orig_h * resize_factor)), interpolation=cv2.INTER_AREA) + ss = cv2.ximgproc.segmentation.createSelectiveSearchSegmentation() + ss.setBaseImage(image) + if quality: + ss.switchToSelectiveSearchQuality(k, min_size, sigma) + else: + ss.switchToSelectiveSearchFast(k, min_size, sigma) + rects = ss.process() + regions = [] + for (x, y, w, h) in rects: + # Scale regions back to original image size if resized + if resize_factor != 1.0: + x = int(x / resize_factor) + y = int(y / resize_factor) + w = int(w / resize_factor) + h = int(h / resize_factor) + # Filter out region that is exactly the same size as the original image + if w == orig_w and h == orig_h and x == 0 and y == 0: + continue + if w > min_width and h > min_height: + if (max_width is None or w < max_width) and (max_height is None or h < max_height): + regions.append({'x': int(x), 'y': int(y), 'width': int(w), 'height': int(h)}) + # Remove duplicates/overlaps + regions = non_max_suppression(regions, iou_threshold=0.7) + return regions, cv2.imread(image_path) # Return original image for drawing + +def draw_regions(image, regions, output_path): + for region in regions: + if 'x' in region: + x, y, w, h = region['x'], region['y'], region['width'], region['height'] + elif 'at' in region and 'size' in region: + x, y = region['at'] + w, h = region['size'] + else: + continue + cv2.rectangle(image, (x, y), (x + w, y + h), (0, 0, 255), 2) + cv2.imwrite(output_path, image) + +def main(): + parser = argparse.ArgumentParser(description='Find regions of interest in an image using selective search.') + parser.add_argument('-i', '--image', default=DEFAULT_IMAGE_PATH, help='Path to input image') + parser.add_argument('-do', '--debug-output', help='Path to save debug image with rectangles') + parser.add_argument('--min-width', type=int, default=200, help='Minimum width of detected region') + parser.add_argument('--min-height', type=int, default=100, help='Minimum height of detected region') + parser.add_argument('--max-width', type=int, help='Maximum width of detected region') + parser.add_argument('--max-height', type=int, help='Maximum height of detected region') + parser.add_argument('--single', action='store_true', help='Only output the most likely (largest) region') + parser.add_argument('--quality', action='store_true', help='Use quality mode for selective search (slower, less sensitive)') + parser.add_argument('--k', type=int, default=3000, help='Segmentation parameter k (default: 150)') + parser.add_argument('--min-size', type=int, default=50, help='Segmentation parameter min_size (default: 20)') + parser.add_argument('--sigma', type=float, default=0.6, help='Segmentation parameter sigma (default: 0.8)') + parser.add_argument('--resize-factor', type=float, default=0.1, help='Resize factor for input image before processing (default: 1.0, e.g. 0.5 for half size)') + parser.add_argument('--hyprctl', action='store_true', help='Mimics hyprctl\'s window output, like {"at": [x, y], "size": [w, h]}') + args = parser.parse_args() + + regions, image = find_regions( + args.image, + min_width=args.min_width, + min_height=args.min_height, + max_width=args.max_width, + max_height=args.max_height, + quality=args.quality, + k=args.k, + min_size=args.min_size, + sigma=args.sigma, + resize_factor=args.resize_factor + ) + if args.single and regions: + largest = max(regions, key=lambda r: r['width'] * r['height']) + regions = [largest] + if args.hyprctl: + regions = [{"at": [r['x'], r['y']], "size": [r['width'], r['height']]} for r in regions] + print(json.dumps(regions)) + if args.debug_output: + draw_regions(image, regions, args.debug_output) + +if __name__ == '__main__': + main() + diff --git a/.config/quickshell/oo/scripts/images/least_busy_region.py b/.config/quickshell/oo/scripts/images/least_busy_region.py new file mode 100755 index 000000000..2b1d104eb --- /dev/null +++ b/.config/quickshell/oo/scripts/images/least_busy_region.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +# Disclaimer: This script was ai-generated and went through minimal revision. + +import os +os.environ["OPENCV_LOG_LEVEL"] = "SILENT" +import cv2 +import numpy as np +import argparse +import json + +def center_crop(img, target_w, target_h): + h, w = img.shape[:2] + if w == target_w and h == target_h: + return img + x1 = max(0, (w - target_w) // 2) + y1 = max(0, (h - target_h) // 2) + x2 = x1 + target_w + y2 = y1 + target_h + return img[y1:y2, x1:x2] + +def find_least_busy_region(image_path, region_width=300, region_height=200, screen_width=None, screen_height=None, verbose=False, stride=2, screen_mode="fill", horizontal_padding=50, vertical_padding=50): + img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) + if img is None: + raise FileNotFoundError(f"Image not found: {image_path}") + orig_h, orig_w = img.shape + scale = 1.0 + if screen_width is not None and screen_height is not None: + scale_w = screen_width / orig_w + scale_h = screen_height / orig_h + if screen_mode == "fill": + scale = max(scale_w, scale_h) + else: + scale = min(scale_w, scale_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + if verbose: + print(f"Scaling image from {orig_w}x{orig_h} to {new_w}x{new_h} (scale: {scale:.3f}, mode: {screen_mode})") + img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + img = center_crop(img, screen_width, screen_height) + if verbose: + print(f"Cropped image to {screen_width}x{screen_height}") + else: + if verbose: + print(f"Using original image size: {orig_w}x{orig_h}") + arr = img.astype(np.float64) + h, w = arr.shape + # Use OpenCV's integral for fast computation + integral = cv2.integral(arr, sdepth=cv2.CV_64F)[1:,1:] + integral_sq = cv2.integral(arr**2, sdepth=cv2.CV_64F)[1:,1:] + def region_sum(ii, x1, y1, x2, y2): + total = ii[y2, x2] + if x1 > 0: + total -= ii[y2, x1-1] + if y1 > 0: + total -= ii[y1-1, x2] + if x1 > 0 and y1 > 0: + total += ii[y1-1, x1-1] + return total + min_var = None + min_coords = (0, 0) + area = region_width * region_height + x_start = horizontal_padding + y_start = vertical_padding + x_end = w - region_width - horizontal_padding + 1 + y_end = h - region_height - vertical_padding + 1 + for y in range(y_start, max(y_end, y_start+1), stride): + for x in range(x_start, max(x_end, x_start+1), stride): + x1, y1 = x, y + x2, y2 = x + region_width - 1, y + region_height - 1 + s = region_sum(integral, x1, y1, x2, y2) + s2 = region_sum(integral_sq, x1, y1, x2, y2) + mean = s / area + var = (s2 / area) - (mean ** 2) + if (min_var is None) or (var < min_var): + min_var = var + min_coords = (x, y) + return min_coords, min_var + +def find_largest_region(image_path, screen_width=None, screen_height=None, verbose=False, stride=2, screen_mode="fill", threshold=100.0, aspect_ratio=1.0, horizontal_padding=50, vertical_padding=50): + img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) + if img is None: + raise FileNotFoundError(f"Image not found: {image_path}") + orig_h, orig_w = img.shape + scale = 1.0 + if screen_width is not None and screen_height is not None: + scale_w = screen_width / orig_w + scale_h = screen_height / orig_h + if screen_mode == "fill": + scale = max(scale_w, scale_h) + else: + scale = min(scale_w, scale_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + if verbose: + print(f"Scaling image from {orig_w}x{orig_h} to {new_w}x{new_h} (scale: {scale:.3f}, mode: {screen_mode})") + img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + img = center_crop(img, screen_width, screen_height) + if verbose: + print(f"Cropped image to {screen_width}x{screen_height}") + else: + if verbose: + print(f"Using original image size: {orig_w}x{orig_h}") + arr = img.astype(np.float64) + h, w = arr.shape + # Use OpenCV's integral for fast computation + integral = cv2.integral(arr, sdepth=cv2.CV_64F)[1:,1:] + integral_sq = cv2.integral(arr**2, sdepth=cv2.CV_64F)[1:,1:] + def region_sum(ii, x1, y1, x2, y2): + total = ii[y2, x2] + if x1 > 0: + total -= ii[y2, x1-1] + if y1 > 0: + total -= ii[y1-1, x2] + if x1 > 0 and y1 > 0: + total += ii[y1-1, x1-1] + return total + min_size = 10 + max_size = min(h, int(w / aspect_ratio)) if aspect_ratio >= 1.0 else min(int(h * aspect_ratio), w) + best = None + best_size = min_size + while min_size <= max_size: + mid = (min_size + max_size) // 2 + if aspect_ratio >= 1.0: + region_h = mid + region_w = int(mid * aspect_ratio) + else: + region_w = mid + region_h = int(mid / aspect_ratio) + if region_w > w or region_h > h: + max_size = mid - 1 + continue + found = False + x_start = horizontal_padding + y_start = vertical_padding + x_end = w - region_w - horizontal_padding + 1 + y_end = h - region_h - vertical_padding + 1 + for y in range(y_start, max(y_end, y_start+1), stride): + for x in range(x_start, max(x_end, x_start+1), stride): + x1, y1 = x, y + x2, y2 = x + region_w - 1, y + region_h - 1 + s = region_sum(integral, x1, y1, x2, y2) + s2 = region_sum(integral_sq, x1, y1, x2, y2) + area = region_w * region_h + mean = s / area + var = (s2 / area) - (mean ** 2) + if var <= threshold: + found = True + best = (x, y, region_w, region_h, var) + break + if found: + break + if found: + best_size = mid + min_size = mid + 1 + else: + max_size = mid - 1 + if best: + x, y, region_w, region_h, var = best + center_x = x + region_w // 2 + center_y = y + region_h // 2 + return (center_x, center_y), (region_w, region_h), var + else: + return None, (0, 0), None + +def draw_region(image_path, coords, region_width=300, region_height=200, output_path='output.png', screen_width=None, screen_height=None, screen_mode="fill"): + img = cv2.imread(image_path) + if img is None: + raise FileNotFoundError(f"Image not found: {image_path}") + orig_h, orig_w = img.shape[:2] + if screen_width is not None and screen_height is not None: + scale_w = screen_width / orig_w + scale_h = screen_height / orig_h + if screen_mode == "fill": + scale = max(scale_w, scale_h) + else: + scale = min(scale_w, scale_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + img = center_crop(img, screen_width, screen_height) + x, y = coords + cv2.rectangle(img, (x, y), (x+region_width-1, y+region_height-1), (0,0,255), 3) + cv2.imwrite(output_path, img) + print(f"Saved output image with rectangle at {output_path}") + +def draw_largest_region(image_path, center, size, output_path='output.png', screen_width=None, screen_height=None, screen_mode="fill"): + img = cv2.imread(image_path) + if img is None: + raise FileNotFoundError(f"Image not found: {image_path}") + orig_h, orig_w = img.shape[:2] + if screen_width is not None and screen_height is not None: + scale_w = screen_width / orig_w + scale_h = screen_height / orig_h + if screen_mode == "fill": + scale = max(scale_w, scale_h) + else: + scale = min(scale_w, scale_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + img = center_crop(img, screen_width, screen_height) + cx, cy = center + region_w, region_h = size + x1 = cx - region_w // 2 + y1 = cy - region_h // 2 + x2 = cx + region_w // 2 - 1 + y2 = cy + region_h // 2 - 1 + cv2.rectangle(img, (x1, y1), (x2, y2), (255,0,0), 3) + cv2.imwrite(output_path, img) + print(f"Saved output image with largest region at {output_path}") + +def get_dominant_color(image_path, x, y, w, h, screen_width=None, screen_height=None, screen_mode="fill"): + img = cv2.imread(image_path) + if img is None: + raise FileNotFoundError(f"Image not found: {image_path}") + orig_h, orig_w = img.shape[:2] + if screen_width is not None and screen_height is not None: + scale_w = screen_width / orig_w + scale_h = screen_height / orig_h + if screen_mode == "fill": + scale = max(scale_w, scale_h) + else: + scale = min(scale_w, scale_h) + new_w = int(orig_w * scale) + new_h = int(orig_h * scale) + img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + img = center_crop(img, screen_width, screen_height) + # Ensure region is within bounds + x = max(0, x) + y = max(0, y) + w = max(1, min(w, img.shape[1] - x)) + h = max(1, min(h, img.shape[0] - y)) + region = img[y:y+h, x:x+w] + if region.size == 0 or region.shape[0] == 0 or region.shape[1] == 0: + return [0, 0, 0] + region = region.reshape((-1, 3)) + # Filter out black pixels (optional, improves accuracy for some images) + non_black = region[np.any(region > 10, axis=1)] + if non_black.shape[0] == 0: + non_black = region + region = np.float32(non_black) + if region.shape[0] < 3: + return [int(x) for x in np.mean(region, axis=0)] + # K-means to find dominant color + criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0) + K = min(3, region.shape[0]) + _, labels, centers = cv2.kmeans(region, K, None, criteria, 10, cv2.KMEANS_RANDOM_CENTERS) + counts = np.bincount(labels.flatten()) + dominant = centers[np.argmax(counts)] + return [int(x) for x in dominant] + +def main(): + parser = argparse.ArgumentParser(description="Find least busy region in an image and output a JSON. Made for determining a suitable position for a wallpaper widget.") + parser.add_argument("image_path", help="Path to the input image") + parser.add_argument("--width", type=int, default=300, help="Region width") + parser.add_argument("--height", type=int, default=200, help="Region height") + parser.add_argument("-v", "--visual-output", action="store_true", help="Output image with rectangle") + parser.add_argument("--screen-width", type=int, default=1920, help="Screen width for wallpaper scaling") + parser.add_argument("--screen-height", type=int, default=1080, help="Screen height for wallpaper scaling") + parser.add_argument("--stride", type=int, default=10, help="Step size for sliding window (higher is faster, less precise)") + parser.add_argument("--screen-mode", choices=["fill", "fit"], default="fill", help="Wallpaper scaling mode: 'fill' (default) or 'fit'") + parser.add_argument("--verbose", action="store_true", help="Print verbose output") + parser.add_argument("-l", "--largest-region", action="store_true", help="Find the largest region under the variance threshold and output its center") + parser.add_argument("-t", "--variance-threshold", type=float, default=1000.0, help="Variance threshold for largest region mode") + parser.add_argument("--aspect-ratio", type=float, default=1.78, help="Aspect ratio (width/height) for largest region mode") + parser.add_argument("--horizontal-padding", "-hp", type=int, default=50, help="Minimum horizontal distance from region to image edge") + parser.add_argument("--vertical-padding", "-vp", type=int, default=50, help="Minimum vertical distance from region to image edge") + args = parser.parse_args() + + if args.largest_region: + center, size, var = find_largest_region( + args.image_path, + screen_width=args.screen_width, + screen_height=args.screen_height, + verbose=args.verbose, + stride=args.stride, + screen_mode=args.screen_mode, + threshold=args.variance_threshold, + aspect_ratio=args.aspect_ratio, + horizontal_padding=args.horizontal_padding, + vertical_padding=args.vertical_padding + ) + if center: + if args.visual_output: + draw_largest_region(args.image_path, center, size, screen_width=args.screen_width, screen_height=args.screen_height, screen_mode=args.screen_mode) + # Extract dominant color + cx, cy = center + region_w, region_h = size + x1 = cx - region_w // 2 + y1 = cy - region_h // 2 + dominant_color = get_dominant_color( + args.image_path, x1, y1, region_w, region_h, + screen_width=args.screen_width, screen_height=args.screen_height, screen_mode=args.screen_mode + ) + dominant_color_hex = '#{:02x}{:02x}{:02x}'.format(*dominant_color) + print(json.dumps({ + "center_x": center[0], + "center_y": center[1], + "width": size[0], + "height": size[1], + "variance": var, + "dominant_color": dominant_color_hex + })) + else: + print(json.dumps({"error": "No region found under the threshold."})) + return + + coords, variance = find_least_busy_region( + args.image_path, + region_width=args.width, + region_height=args.height, + screen_width=args.screen_width, + screen_height=args.screen_height, + verbose=args.verbose, + stride=args.stride, + screen_mode=args.screen_mode, + horizontal_padding=args.horizontal_padding, + vertical_padding=args.vertical_padding + ) + if args.visual_output: + draw_region(args.image_path, coords, region_width=args.width, region_height=args.height, screen_width=args.screen_width, screen_height=args.screen_height, screen_mode=args.screen_mode) + # Output JSON with center point + center_x = coords[0] + args.width // 2 + center_y = coords[1] + args.height // 2 + dominant_color = get_dominant_color( + args.image_path, coords[0], coords[1], args.width, args.height, + screen_width=args.screen_width, screen_height=args.screen_height, screen_mode=args.screen_mode + ) + dominant_color_hex = '#{:02x}{:02x}{:02x}'.format(*dominant_color) + print(json.dumps({ + "center_x": center_x, + "center_y": center_y, + "width": args.width, + "height": args.height, + "variance": variance, + "dominant_color": dominant_color_hex + })) + +if __name__ == "__main__": + main() + diff --git a/.config/quickshell/oo/shell.qml b/.config/quickshell/oo/shell.qml new file mode 100644 index 000000000..507200383 --- /dev/null +++ b/.config/quickshell/oo/shell.qml @@ -0,0 +1,24 @@ +//@ pragma UseQApplication +//@ pragma Env QS_NO_RELOAD_POPUP=1 +//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic + +// Adjust this to make the shell smaller or larger +//@ pragma Env QT_SCALE_FACTOR=1 + +import QtQuick +import QtQuick.Window +import Quickshell +import qs.singletons +import "./modules/background/" +import "./modules/bar/" + +ShellRoot { + // Some initialization + Component.onCompleted: { + MaterialThemeLoader.reapplyTheme() + } + + Background {} + Bar {} +} + diff --git a/.config/quickshell/oo/singletons/Appearance.qml b/.config/quickshell/oo/singletons/Appearance.qml new file mode 100644 index 000000000..096b4b035 --- /dev/null +++ b/.config/quickshell/oo/singletons/Appearance.qml @@ -0,0 +1,269 @@ +import QtQuick +import Quickshell +import qs.modules.common.functions +pragma Singleton +pragma ComponentBehavior: Bound + +Singleton { + id: root + property QtObject m3colors + property QtObject animation + property QtObject animationCurves + property QtObject colors + property QtObject rounding + property QtObject font + property QtObject sizes + property string syntaxHighlightingTheme + + // Extremely conservative transparency values for consistency and readability + // property real transparency: m3colors.darkmode ? 0.2 : 0.1 + // property real contentTransparency: m3colors.darkmode ? 0.55 : 0.55 + property real transparency: 0 + property real contentTransparency: 0 + property color absoluteBackground: m3colors.darkmode ? "#000000" : "#FFFFFF" + + m3colors: QtObject { + property bool darkmode: false + property bool transparent: false + property color m3primary_paletteKeyColor: "#91689E" + property color m3secondary_paletteKeyColor: "#837186" + property color m3tertiary_paletteKeyColor: "#9D6A67" + property color m3neutral_paletteKeyColor: "#7C757B" + property color m3neutral_variant_paletteKeyColor: "#7D747D" + property color m3background: "#161217" + property color m3onBackground: "#EAE0E7" + property color m3surface: "#161217" + property color m3surfaceDim: "#161217" + property color m3surfaceBright: "#3D373D" + property color m3surfaceContainerLowest: "#110D12" + property color m3surfaceContainerLow: "#1F1A1F" + property color m3surfaceContainer: "#231E23" + property color m3surfaceContainerHigh: "#2D282E" + property color m3surfaceContainerHighest: "#383339" + property color m3onSurface: "#EAE0E7" + property color m3surfaceVariant: "#4C444D" + property color m3onSurfaceVariant: "#CFC3CD" + property color m3inverseSurface: "#EAE0E7" + property color m3inverseOnSurface: "#342F34" + property color m3outline: "#988E97" + property color m3outlineVariant: "#4C444D" + property color m3shadow: "#000000" + property color m3scrim: "#000000" + property color m3surfaceTint: "#E5B6F2" + property color m3primary: "#E5B6F2" + property color m3onPrimary: "#452152" + property color m3primaryContainer: "#5D386A" + property color m3onPrimaryContainer: "#F9D8FF" + property color m3inversePrimary: "#775084" + property color m3secondary: "#D5C0D7" + property color m3onSecondary: "#392C3D" + property color m3secondaryContainer: "#534457" + property color m3onSecondaryContainer: "#F2DCF3" + property color m3tertiary: "#F5B7B3" + property color m3onTertiary: "#4C2523" + property color m3tertiaryContainer: "#BA837F" + property color m3onTertiaryContainer: "#000000" + property color m3error: "#FFB4AB" + property color m3onError: "#690005" + property color m3errorContainer: "#93000A" + property color m3onErrorContainer: "#FFDAD6" + property color m3primaryFixed: "#F9D8FF" + property color m3primaryFixedDim: "#E5B6F2" + property color m3onPrimaryFixed: "#2E0A3C" + property color m3onPrimaryFixedVariant: "#5D386A" + property color m3secondaryFixed: "#F2DCF3" + property color m3secondaryFixedDim: "#D5C0D7" + property color m3onSecondaryFixed: "#241727" + property color m3onSecondaryFixedVariant: "#514254" + property color m3tertiaryFixed: "#FFDAD7" + property color m3tertiaryFixedDim: "#F5B7B3" + property color m3onTertiaryFixed: "#331110" + property color m3onTertiaryFixedVariant: "#663B39" + property color m3success: "#B5CCBA" + property color m3onSuccess: "#213528" + property color m3successContainer: "#374B3E" + property color m3onSuccessContainer: "#D1E9D6" + property color term0: "#EDE4E4" + property color term1: "#B52755" + property color term2: "#A97363" + property color term3: "#AF535D" + property color term4: "#A67F7C" + property color term5: "#B2416B" + property color term6: "#8D76AD" + property color term7: "#272022" + property color term8: "#0E0D0D" + property color term9: "#B52755" + property color term10: "#A97363" + property color term11: "#AF535D" + property color term12: "#A67F7C" + property color term13: "#B2416B" + property color term14: "#8D76AD" + property color term15: "#221A1A" + } + + colors: QtObject { + property color colSubtext: m3colors.m3outline + property color colLayer0: ColorUtils.transparentize(ColorUtils.mix(m3colors.m3background, root.absoluteBackground, 0.5), root.transparency) + property color colOnLayer0: m3colors.m3onBackground + property color colLayer0Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer0, colOnLayer0, 0.9, root.contentTransparency)) + property color colLayer0Active: ColorUtils.transparentize(ColorUtils.mix(colLayer0, colOnLayer0, 0.8, root.contentTransparency)) + property color colLayer1: ColorUtils.transparentize(ColorUtils.mix(m3colors.m3surfaceContainerLow, m3colors.m3background, 0.8), root.contentTransparency); + property color colOnLayer1: m3colors.m3onSurfaceVariant; + property color colOnLayer1Inactive: ColorUtils.mix(colOnLayer1, colLayer1, 0.45); + property color colLayer2: ColorUtils.transparentize(m3colors.m3surfaceContainer, root.contentTransparency) + property color colLayer2Border: ColorUtils.mix(colLayer2, m3colors.m3outline, 0.8); + property color colOnLayer2: m3colors.m3onSurface; + property color colOnLayer2Disabled: ColorUtils.mix(colOnLayer2, m3colors.m3background, 0.4); + property color colLayer3: ColorUtils.transparentize(ColorUtils.mix(m3colors.m3surfaceContainerHigh, m3colors.m3onSurface, 0.96), root.contentTransparency) + property color colOnLayer3: m3colors.m3onSurface; + property color colLayer1Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer1, colOnLayer1, 0.92), root.contentTransparency) + property color colLayer1Active: ColorUtils.transparentize(ColorUtils.mix(colLayer1, colOnLayer1, 0.85), root.contentTransparency); + property color colLayer2Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer2, colOnLayer2, 0.90), root.contentTransparency) + property color colLayer2Active: ColorUtils.transparentize(ColorUtils.mix(colLayer2, colOnLayer2, 0.80), root.contentTransparency); + property color colLayer2Disabled: ColorUtils.transparentize(ColorUtils.mix(colLayer2, m3colors.m3background, 0.8), root.contentTransparency); + property color colLayer3Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer3, colOnLayer3, 0.90), root.contentTransparency) + property color colLayer3Active: ColorUtils.transparentize(ColorUtils.mix(colLayer3, colOnLayer3, 0.80), root.contentTransparency); + property color colPrimary: m3colors.m3primary + property color colOnPrimary: m3colors.m3onPrimary + property color colPrimaryHover: ColorUtils.mix(colors.colPrimary, colLayer1Hover, 0.87) + property color colPrimaryActive: ColorUtils.mix(colors.colPrimary, colLayer1Active, 0.7) + property color colPrimaryContainer: m3colors.m3primaryContainer + property color colPrimaryContainerHover: ColorUtils.mix(colors.colPrimaryContainer, colLayer1Hover, 0.7) + property color colPrimaryContainerActive: ColorUtils.mix(colors.colPrimaryContainer, colLayer1Active, 0.6) + property color colOnPrimaryContainer: m3colors.m3onPrimaryContainer + property color colSecondary: m3colors.m3secondary + property color colSecondaryHover: ColorUtils.mix(m3colors.m3secondary, colLayer1Hover, 0.85) + property color colSecondaryActive: ColorUtils.mix(m3colors.m3secondary, colLayer1Active, 0.4) + property color colSecondaryContainer: m3colors.m3secondaryContainer + property color colSecondaryContainerHover: ColorUtils.mix(m3colors.m3secondaryContainer, m3colors.m3onSecondaryContainer, 0.90) + property color colSecondaryContainerActive: ColorUtils.mix(m3colors.m3secondaryContainer, colLayer1Active, 0.54) + property color colOnSecondaryContainer: m3colors.m3onSecondaryContainer + property color colSurfaceContainerLow: ColorUtils.transparentize(m3colors.m3surfaceContainerLow, root.contentTransparency) + property color colSurfaceContainer: ColorUtils.transparentize(m3colors.m3surfaceContainer, root.contentTransparency) + property color colSurfaceContainerHigh: ColorUtils.transparentize(m3colors.m3surfaceContainerHigh, root.contentTransparency) + property color colSurfaceContainerHighest: ColorUtils.transparentize(m3colors.m3surfaceContainerHighest, root.contentTransparency) + property color colSurfaceContainerHighestHover: ColorUtils.mix(m3colors.m3surfaceContainerHighest, m3colors.m3onSurface, 0.95) + property color colSurfaceContainerHighestActive: ColorUtils.mix(m3colors.m3surfaceContainerHighest, m3colors.m3onSurface, 0.85) + property color colTooltip: m3colors.darkmode ? ColorUtils.mix(m3colors.m3background, "#3C4043", 0.5) : "#3C4043" // m3colors.m3inverseSurface in the specs, but the m3 website actually uses #3C4043 + property color colOnTooltip: "#F8F9FA" // m3colors.m3inverseOnSurface in the specs, but the m3 website actually uses this color + property color colScrim: ColorUtils.transparentize(m3colors.m3scrim, 0.5) + property color colShadow: ColorUtils.transparentize(m3colors.m3shadow, 0.7) + property color colOutline: ColorUtils.mix(m3colors.m3outline, m3colors.m3background, 0.7) + property color colOutlineVariant: ColorUtils.mix(m3colors.m3outlineVariant, m3colors.m3background, 0.5) + } + + rounding: QtObject { + property int unsharpen: 2 + property int unsharpenmore: 6 + property int verysmall: 8 + property int small: 12 + property int normal: 17 + property int large: 23 + property int verylarge: 30 + property int full: 9999 + property int screenRounding: large + property int windowRounding: 18 + } + + font: QtObject { + property QtObject family: QtObject { + property string main: "Geist" + property string title: "Gabarito" + property string iconMaterial: "Material Symbols Outlined" + property string iconNerd: "SpaceMono NF" + property string monospace: "JetBrains Mono NF" + property string reading: "Readex Pro" + property string expressive: "Space Grotesk" + } + property QtObject pixelSize: QtObject { + property int smallest: 10 + property int smaller: 12 + property int small: 15 + property int normal: 16 + property int large: 17 + property int larger: 19 + property int huge: 22 + property int hugeass: 23 + property int title: huge + } + } + + animationCurves: QtObject { + readonly property list expressiveFastSpatial: [0.42, 1.67, 0.21, 0.90, 1, 1] // Default, 350ms + readonly property list expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1.00, 1, 1] // Default, 500ms + readonly property list expressiveSlowSpatial: [0.39, 1.29, 0.35, 0.98, 1, 1] // Default, 650ms + readonly property list expressiveEffects: [0.34, 0.80, 0.34, 1.00, 1, 1] // Default, 200ms + readonly property list emphasized: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1] + readonly property list emphasizedFirstHalf: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82] + readonly property list emphasizedLastHalf: [5 / 24, 0.82, 0.25, 1, 1, 1] + readonly property list emphasizedAccel: [0.3, 0, 0.8, 0.15, 1, 1] + readonly property list emphasizedDecel: [0.05, 0.7, 0.1, 1, 1, 1] + readonly property list standard: [0.2, 0, 0, 1, 1, 1] + readonly property list standardAccel: [0.3, 0, 1, 1, 1, 1] + readonly property list standardDecel: [0, 0, 0, 1, 1, 1] + readonly property real expressiveFastSpatialDuration: 350 + readonly property real expressiveDefaultSpatialDuration: 500 + readonly property real expressiveSlowSpatialDuration: 650 + readonly property real expressiveEffectsDuration: 200 + } + + animation: QtObject { + property QtObject elementMove: QtObject { + property int duration: 300 + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.standardDecel + property int velocity: 650 + property Component numberAnimation: Component { + NumberAnimation { + duration: root.animation.elementMove.duration + easing.type: root.animation.elementMove.type + easing.bezierCurve: root.animation.elementMove.bezierCurve + } + } + property Component colorAnimation: Component { + ColorAnimation { + duration: root.animation.elementMove.duration + easing.type: root.animation.elementMove.type + easing.bezierCurve: root.animation.elementMove.bezierCurve + } + } + } + property QtObject elementMoveEnter: QtObject { + property int duration: 400 + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.emphasizedDecel + property int velocity: 650 + property Component numberAnimation: Component { + NumberAnimation { + duration: root.animation.elementMoveEnter.duration + easing.type: root.animation.elementMoveEnter.type + easing.bezierCurve: root.animation.elementMoveEnter.bezierCurve + } + } + } + property QtObject elementMoveFast: QtObject { + property int duration: 175 + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.expressiveEffects + property int velocity: 850 + property Component colorAnimation: Component { ColorAnimation { + duration: 70 + easing.type: root.animation.elementMoveFast.type + easing.bezierCurve: root.animation.elementMoveFast.bezierCurve + }} + property Component numberAnimation: Component { NumberAnimation { + duration: root.animation.elementMoveFast.duration + easing.type: root.animation.elementMoveFast.type + easing.bezierCurve: root.animation.elementMoveFast.bezierCurve + }} + } + } + + sizes: QtObject { + property real hyprlandGapsOut: 5 + property real barHeight: 46 + property real barBorder: 1 + } + + syntaxHighlightingTheme: Appearance.m3colors.darkmode ? "Monokai" : "ayu Light" +} diff --git a/.config/quickshell/oo/singletons/Config.qml b/.config/quickshell/oo/singletons/Config.qml new file mode 100644 index 000000000..6c31e0160 --- /dev/null +++ b/.config/quickshell/oo/singletons/Config.qml @@ -0,0 +1,76 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + property string configFilePath: Directories.shellConfigPath + property alias options: configOptionsJsonAdapter + + function setNestedValue(nestedKey, value) { + let keys = nestedKey.split("."); + let obj = root.options; + let parents = [obj]; + + // Traverse and collect parent objects + for (let i = 0; i < keys.length - 1; ++i) { + if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") { + obj[keys[i]] = {}; + } + obj = obj[keys[i]]; + parents.push(obj); + } + + // Convert value to correct type using JSON.parse when safe + let convertedValue = value; + if (typeof value === "string") { + let trimmed = value.trim(); + if (trimmed === "true" || trimmed === "false" || !isNaN(Number(trimmed))) { + try { + convertedValue = JSON.parse(trimmed); + } catch (e) { + convertedValue = value; + } + } + } + + obj[keys[keys.length - 1]] = convertedValue; + } + + FileView { + path: root.configFilePath + + watchChanges: true + onFileChanged: reload() + onAdapterUpdated: writeAdapter() + onLoadFailed: error => { + if (error == FileViewError.FileNotFound) { + writeAdapter(); + } + } + + JsonAdapter { + id: configOptionsJsonAdapter + + property JsonObject background: JsonObject { + property bool fixedClockPosition: false + property real clockX: -500 + property real clockY: -500 + property string wallpaperPath: "" + property JsonObject parallax: JsonObject { + property bool enableWorkspace: true + property real workspaceZoom: 1.07 // Relative to your screen, not wallpaper size + property bool enableSidebar: true + } + } + + property JsonObject time: JsonObject { + // https://doc.qt.io/qt-6/qtime.html#toString + property string format: "hh:mm" + property string dateFormat: "ddd, dd/MM" + } + } + } +} diff --git a/.config/quickshell/oo/singletons/DateTime.qml b/.config/quickshell/oo/singletons/DateTime.qml new file mode 100644 index 000000000..225d86e07 --- /dev/null +++ b/.config/quickshell/oo/singletons/DateTime.qml @@ -0,0 +1,52 @@ +import qs.singletons +import QtQuick +import Quickshell +import Quickshell.Io +pragma Singleton +pragma ComponentBehavior: Bound + +/** + * A nice wrapper for date and time strings. + */ +Singleton { + property string time: Qt.locale().toString(clock.date, Config.options?.time.format ?? "hh:mm") + property string date: Qt.locale().toString(clock.date, Config.options?.time.dateFormat ?? "dddd, dd/MM") + property string collapsedCalendarFormat: Qt.locale().toString(clock.date, "dd MMMM yyyy") + property string uptime: "0h, 0m" + + SystemClock { + id: clock + precision: SystemClock.Minutes + } + + Timer { + interval: 10 + running: true + repeat: true + onTriggered: { + fileUptime.reload() + const textUptime = fileUptime.text() + const uptimeSeconds = Number(textUptime.split(" ")[0] ?? 0) + + // Convert seconds to days, hours, and minutes + const days = Math.floor(uptimeSeconds / 86400) + const hours = Math.floor((uptimeSeconds % 86400) / 3600) + const minutes = Math.floor((uptimeSeconds % 3600) / 60) + + // Build the formatted uptime string + let formatted = "" + if (days > 0) formatted += `${days}d` + if (hours > 0) formatted += `${formatted ? ", " : ""}${hours}h` + if (minutes > 0 || !formatted) formatted += `${formatted ? ", " : ""}${minutes}m` + uptime = formatted + interval = Config.options?.resources?.updateInterval ?? 3000 + } + } + + FileView { + id: fileUptime + + path: "/proc/uptime" + } + +} diff --git a/.config/quickshell/oo/singletons/Directories.qml b/.config/quickshell/oo/singletons/Directories.qml new file mode 100644 index 000000000..9f077afaa --- /dev/null +++ b/.config/quickshell/oo/singletons/Directories.qml @@ -0,0 +1,30 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.modules.common.functions +import Qt.labs.platform +import QtQuick +import Quickshell + +Singleton { + // XDG Dirs, with "file://" + readonly property string config: StandardPaths.standardLocations(StandardPaths.ConfigLocation)[0] + readonly property string state: StandardPaths.standardLocations(StandardPaths.StateLocation)[0] + readonly property string cache: StandardPaths.standardLocations(StandardPaths.CacheLocation)[0] + readonly property string pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0] + readonly property string downloads: StandardPaths.standardLocations(StandardPaths.DownloadLocation)[0] + + // Other dirs used by the shell, without "file://" + property string assetsPath: Quickshell.shellPath("assets") + property string generatedMaterialThemePath: FileUtils.trimFileProtocol(`${Directories.state}/user/generated/colors.json`) + property string notificationsPath: FileUtils.trimFileProtocol(`${Directories.cache}/notifications/notifications.json`) + property string scriptPath: Quickshell.shellPath("scripts") + property string shellConfig: FileUtils.trimFileProtocol(`${Directories.config}/quickshell`) + property string shellConfigName: "oo.json" + property string shellConfigPath: `${Directories.shellConfig}/${Directories.shellConfigName}` + property string wallpaperSwitchScriptPath: FileUtils.trimFileProtocol(`${Directories.scriptPath}/colors/switchwall.sh`) + // Cleanup on init + Component.onCompleted: { + Quickshell.execDetached(["mkdir", "-p", `${shellConfig}`]) + } +} diff --git a/.config/quickshell/oo/singletons/HyprlandData.qml b/.config/quickshell/oo/singletons/HyprlandData.qml new file mode 100644 index 000000000..6488ded82 --- /dev/null +++ b/.config/quickshell/oo/singletons/HyprlandData.qml @@ -0,0 +1,133 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +/** + * Provides access to some Hyprland data not available in Quickshell.Hyprland. + */ +Singleton { + id: root + property var windowList: [] + property var addresses: [] + property var windowByAddress: ({}) + property var workspaces: [] + property var workspaceIds: [] + property var workspaceById: ({}) + property var activeWorkspace: null + property var monitors: [] + property var layers: ({}) + + function updateWindowList() { + getClients.running = true; + } + + function updateLayers() { + getLayers.running = true; + } + + function updateMonitors() { + getMonitors.running = true; + } + + function updateWorkspaces() { + getWorkspaces.running = true; + getActiveWorkspace.running = true; + } + + function updateAll() { + updateWindowList(); + updateMonitors(); + updateLayers(); + updateWorkspaces(); + } + + function biggestWindowForWorkspace(workspaceId) { + const windowsInThisWorkspace = HyprlandData.windowList.filter(w => w.workspace.id == workspaceId); + return windowsInThisWorkspace.reduce((maxWin, win) => { + const maxArea = (maxWin?.size?.[0] ?? 0) * (maxWin?.size?.[1] ?? 0); + const winArea = (win?.size?.[0] ?? 0) * (win?.size?.[1] ?? 0); + return winArea > maxArea ? win : maxWin; + }, null); + } + + Component.onCompleted: { + updateAll(); + } + + Connections { + target: Hyprland + + function onRawEvent(event) { + // console.log("Hyprland raw event:", event.name); + updateAll() + } + } + + Process { + id: getClients + command: ["bash", "-c", "hyprctl clients -j | jq -c"] + stdout: SplitParser { + onRead: data => { + root.windowList = JSON.parse(data); + let tempWinByAddress = {}; + for (var i = 0; i < root.windowList.length; ++i) { + var win = root.windowList[i]; + tempWinByAddress[win.address] = win; + } + root.windowByAddress = tempWinByAddress; + root.addresses = root.windowList.map(win => win.address); + } + } + } + + Process { + id: getMonitors + command: ["bash", "-c", "hyprctl monitors -j | jq -c"] + stdout: SplitParser { + onRead: data => { + root.monitors = JSON.parse(data); + } + } + } + + Process { + id: getLayers + command: ["bash", "-c", "hyprctl layers -j | jq -c"] + stdout: SplitParser { + onRead: data => { + root.layers = JSON.parse(data); + } + } + } + + Process { + id: getWorkspaces + command: ["bash", "-c", "hyprctl workspaces -j | jq -c"] + stdout: SplitParser { + onRead: data => { + root.workspaces = JSON.parse(data); + let tempWorkspaceById = {}; + for (var i = 0; i < root.workspaces.length; ++i) { + var ws = root.workspaces[i]; + tempWorkspaceById[ws.id] = ws; + } + root.workspaceById = tempWorkspaceById; + root.workspaceIds = root.workspaces.map(ws => ws.id); + } + } + } + + Process { + id: getActiveWorkspace + command: ["bash", "-c", "hyprctl activeworkspace -j | jq -c"] + stdout: SplitParser { + onRead: data => { + root.activeWorkspace = JSON.parse(data); + } + } + } +} diff --git a/.config/quickshell/oo/singletons/MaterialThemeLoader.qml b/.config/quickshell/oo/singletons/MaterialThemeLoader.qml new file mode 100644 index 000000000..a2d506c1b --- /dev/null +++ b/.config/quickshell/oo/singletons/MaterialThemeLoader.qml @@ -0,0 +1,57 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io + +/** + * Automatically reloads generated material colors. + * It is necessary to run reapplyTheme() on startup because Singletons are lazily loaded. + */ +Singleton { + id: root + property string filePath: Directories.generatedMaterialThemePath + + function reapplyTheme() { + themeFileView.reload() + } + + function applyColors(fileContent) { + const json = JSON.parse(fileContent) + for (const key in json) { + if (json.hasOwnProperty(key)) { + // Convert snake_case to CamelCase + const camelCaseKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase()) + const m3Key = `m3${camelCaseKey}` + Appearance.m3colors[m3Key] = json[key] + } + } + + Appearance.m3colors.darkmode = (Appearance.m3colors.m3background.hslLightness < 0.5) + } + + Timer { + id: delayedFileRead + interval: Config.options?.hacks?.arbitraryRaceConditionDelay ?? 100 + repeat: false + running: false + onTriggered: { + root.applyColors(themeFileView.text()) + } + } + + FileView { + id: themeFileView + path: Qt.resolvedUrl(root.filePath) + watchChanges: true + onFileChanged: { + this.reload() + delayedFileRead.start() + } + onLoadedChanged: { + const fileContent = themeFileView.text() + root.applyColors(fileContent) + } + } +}