From da076827644b771004399f88164366cb45175bda Mon Sep 17 00:00:00 2001 From: inorishio Date: Mon, 20 Oct 2025 10:53:55 +0200 Subject: [PATCH] Evernight & Caelestia --- Modules/Areapicker.qml | 1 + Modules/Picker.qml | 293 +++++ Modules/background/Background.qml | 75 ++ Modules/background/DesktopClock.qml | 18 + Modules/background/Visualiser.qml | 120 ++ Modules/background/Wallpaper.qml | 143 +++ components/Anim.qml | 8 + components/CAnim.qml | 8 + components/MaterialIcon.qml | 16 + components/StateLayer.qml | 94 ++ components/StyledClippingRect.qml | 12 + components/StyledRect.qml | 11 + components/StyledText.qml | 48 + components/containers/StyledFlickable.qml | 14 + components/containers/StyledListView.qml | 14 + components/containers/StyledWindow.qml | 9 + components/controls/CircularIndicator.qml | 108 ++ components/controls/CircularProgress.qml | 69 ++ components/controls/CustomMouseArea.qml | 21 + components/controls/CustomSpinBox.qml | 108 ++ components/controls/FilledSlider.qml | 146 +++ components/controls/IconButton.qml | 83 ++ components/controls/IconTextButton.qml | 85 ++ components/controls/Menu.qml | 113 ++ components/controls/MenuItem.qml | 11 + components/controls/SplitButton.qml | 164 +++ components/controls/StyledRadioButton.qml | 57 + components/controls/StyledScrollBar.qml | 108 ++ components/controls/StyledSlider.qml | 57 + components/controls/StyledSwitch.qml | 152 +++ components/controls/StyledTextField.qml | 76 ++ components/controls/TextButton.qml | 78 ++ components/effects/ColouredIcon.qml | 35 + components/effects/Colouriser.qml | 14 + components/effects/Elevation.qml | 18 + components/effects/InnerBorder.qml | 44 + components/effects/OpacityMask.qml | 9 + components/filedialog/CurrentItem.qml | 102 ++ components/filedialog/DialogButtons.qml | 93 ++ components/filedialog/FileDialog.qml | 102 ++ components/filedialog/FolderContents.qml | 229 ++++ components/filedialog/HeaderBar.qml | 142 +++ components/filedialog/Sidebar.qml | 113 ++ components/filedialog/Sizes.qml | 8 + components/images/CachingIconImage.qml | 42 + components/images/CachingImage.qml | 28 + components/misc/CustomShortcut.qml | 5 + components/misc/Ref.qml | 8 + components/widgets/ExtraIndicator.qml | 51 + config/Appearance.qml | 14 + config/AppearanceConfig.qml | 92 ++ config/BackgroundConfig.qml | 18 + config/BarConfig.qml | 102 ++ config/BorderConfig.qml | 6 + config/Config.qml | 76 ++ config/ControlCenterConfig.qml | 10 + config/DashboardConfig.qml | 25 + config/GeneralConfig.qml | 59 + config/LauncherConfig.qml | 138 +++ config/LockConfig.qml | 14 + config/NotifsConfig.qml | 17 + config/OsdConfig.qml | 14 + config/ServiceConfig.qml | 20 + config/SessionConfig.qml | 21 + config/SidebarConfig.qml | 11 + config/UserPaths.qml | 8 + config/UtilitiesConfig.qml | 34 + config/WInfoConfig.qml | 10 + services/Audio.qml | 122 ++ services/Brightness.qml | 225 ++++ services/Colours.qml | 237 ++++ services/GameMode.qml | 76 ++ services/Hypr.qml | 143 +++ services/IdleInhibitor.qml | 56 + services/Network.qml | 190 +++ services/Notifs.qml | 334 ++++++ services/Players.qml | 126 ++ services/Recorder.qml | 82 ++ services/SystemUsage.qml | 222 ++++ services/Time.qml | 20 + services/VPN.qml | 176 +++ services/Visibilities.qml | 16 + services/Wallpapers.qml | 93 ++ services/Weather.qml | 40 + shell.qml | 2 - utils/Icons.qml | 228 ++++ utils/Images.qml | 12 + utils/Paths.qml | 36 + utils/Searcher.qml | 56 + utils/SysInfo.qml | 72 ++ utils/scripts/fuzzysort.js | 705 +++++++++++ utils/scripts/fzf.js | 1307 +++++++++++++++++++++ 92 files changed, 8626 insertions(+), 2 deletions(-) create mode 100644 Modules/Picker.qml create mode 100644 Modules/background/Background.qml create mode 100644 Modules/background/DesktopClock.qml create mode 100644 Modules/background/Visualiser.qml create mode 100644 Modules/background/Wallpaper.qml create mode 100644 components/Anim.qml create mode 100644 components/CAnim.qml create mode 100644 components/MaterialIcon.qml create mode 100644 components/StateLayer.qml create mode 100644 components/StyledClippingRect.qml create mode 100644 components/StyledRect.qml create mode 100644 components/StyledText.qml create mode 100644 components/containers/StyledFlickable.qml create mode 100644 components/containers/StyledListView.qml create mode 100644 components/containers/StyledWindow.qml create mode 100644 components/controls/CircularIndicator.qml create mode 100644 components/controls/CircularProgress.qml create mode 100644 components/controls/CustomMouseArea.qml create mode 100644 components/controls/CustomSpinBox.qml create mode 100644 components/controls/FilledSlider.qml create mode 100644 components/controls/IconButton.qml create mode 100644 components/controls/IconTextButton.qml create mode 100644 components/controls/Menu.qml create mode 100644 components/controls/MenuItem.qml create mode 100644 components/controls/SplitButton.qml create mode 100644 components/controls/StyledRadioButton.qml create mode 100644 components/controls/StyledScrollBar.qml create mode 100644 components/controls/StyledSlider.qml create mode 100644 components/controls/StyledSwitch.qml create mode 100644 components/controls/StyledTextField.qml create mode 100644 components/controls/TextButton.qml create mode 100644 components/effects/ColouredIcon.qml create mode 100644 components/effects/Colouriser.qml create mode 100644 components/effects/Elevation.qml create mode 100644 components/effects/InnerBorder.qml create mode 100644 components/effects/OpacityMask.qml create mode 100644 components/filedialog/CurrentItem.qml create mode 100644 components/filedialog/DialogButtons.qml create mode 100644 components/filedialog/FileDialog.qml create mode 100644 components/filedialog/FolderContents.qml create mode 100644 components/filedialog/HeaderBar.qml create mode 100644 components/filedialog/Sidebar.qml create mode 100644 components/filedialog/Sizes.qml create mode 100644 components/images/CachingIconImage.qml create mode 100644 components/images/CachingImage.qml create mode 100644 components/misc/CustomShortcut.qml create mode 100644 components/misc/Ref.qml create mode 100644 components/widgets/ExtraIndicator.qml create mode 100644 config/Appearance.qml create mode 100644 config/AppearanceConfig.qml create mode 100644 config/BackgroundConfig.qml create mode 100644 config/BarConfig.qml create mode 100644 config/BorderConfig.qml create mode 100644 config/Config.qml create mode 100644 config/ControlCenterConfig.qml create mode 100644 config/DashboardConfig.qml create mode 100644 config/GeneralConfig.qml create mode 100644 config/LauncherConfig.qml create mode 100644 config/LockConfig.qml create mode 100644 config/NotifsConfig.qml create mode 100644 config/OsdConfig.qml create mode 100644 config/ServiceConfig.qml create mode 100644 config/SessionConfig.qml create mode 100644 config/SidebarConfig.qml create mode 100644 config/UserPaths.qml create mode 100644 config/UtilitiesConfig.qml create mode 100644 config/WInfoConfig.qml create mode 100644 services/Audio.qml create mode 100644 services/Brightness.qml create mode 100644 services/Colours.qml create mode 100644 services/GameMode.qml create mode 100644 services/Hypr.qml create mode 100644 services/IdleInhibitor.qml create mode 100644 services/Network.qml create mode 100644 services/Notifs.qml create mode 100644 services/Players.qml create mode 100644 services/Recorder.qml create mode 100644 services/SystemUsage.qml create mode 100644 services/Time.qml create mode 100644 services/VPN.qml create mode 100644 services/Visibilities.qml create mode 100644 services/Wallpapers.qml create mode 100644 services/Weather.qml create mode 100644 utils/Icons.qml create mode 100644 utils/Images.qml create mode 100644 utils/Paths.qml create mode 100644 utils/Searcher.qml create mode 100644 utils/SysInfo.qml create mode 100644 utils/scripts/fuzzysort.js create mode 100644 utils/scripts/fzf.js diff --git a/Modules/Areapicker.qml b/Modules/Areapicker.qml index 7ff051f..859aa0c 100644 --- a/Modules/Areapicker.qml +++ b/Modules/Areapicker.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import qs.components.containers import qs.components.misc +import qs.Modules import Quickshell import Quickshell.Wayland import Quickshell.Io diff --git a/Modules/Picker.qml b/Modules/Picker.qml new file mode 100644 index 0000000..9eefe53 --- /dev/null +++ b/Modules/Picker.qml @@ -0,0 +1,293 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import Quickshell +import Quickshell.Wayland +import QtQuick +import QtQuick.Effects + +MouseArea { + id: root + + required property LazyLoader loader + required property ShellScreen screen + + property bool onClient + + property real realBorderWidth: onClient ? (Hypr.options["general:border_size"] ?? 1) : 2 + property real realRounding: onClient ? (Hypr.options["decoration:rounding"] ?? 0) : 0 + + property real ssx + property real ssy + + property real sx: 0 + property real sy: 0 + property real ex: screen.width + property real ey: screen.height + + property real rsx: Math.min(sx, ex) + property real rsy: Math.min(sy, ey) + property real sw: Math.abs(sx - ex) + property real sh: Math.abs(sy - ey) + + property list clients: { + const mon = Hypr.monitorFor(screen); + if (!mon) + return []; + + const special = mon.lastIpcObject.specialWorkspace; + const wsId = special.name ? special.id : mon.activeWorkspace.id; + + return Hypr.toplevels.values.filter(c => c.workspace?.id === wsId).sort((a, b) => { + // Pinned first, then fullscreen, then floating, then any other + const ac = a.lastIpcObject; + const bc = b.lastIpcObject; + return (bc.pinned - ac.pinned) || ((bc.fullscreen !== 0) - (ac.fullscreen !== 0)) || (bc.floating - ac.floating); + }); + } + + function checkClientRects(x: real, y: real): void { + for (const client of clients) { + if (!client) + continue; + + let { + at: [cx, cy], + size: [cw, ch] + } = client.lastIpcObject; + cx -= screen.x; + cy -= screen.y; + if (cx <= x && cy <= y && cx + cw >= x && cy + ch >= y) { + onClient = true; + sx = cx; + sy = cy; + ex = cx + cw; + ey = cy + ch; + break; + } + } + } + + function save(): void { + const tmpfile = Qt.resolvedUrl(`/tmp/caelestia-picker-${Quickshell.processId}-${Date.now()}.png`); + CUtils.saveItem(screencopy, tmpfile, Qt.rect(Math.ceil(rsx), Math.ceil(rsy), Math.floor(sw), Math.floor(sh)), path => Quickshell.execDetached(["swappy", "-f", path])); + closeAnim.start(); + } + + onClientsChanged: checkClientRects(mouseX, mouseY) + + anchors.fill: parent + opacity: 0 + hoverEnabled: true + cursorShape: Qt.CrossCursor + + Component.onCompleted: { + Hypr.extras.refreshOptions(); + + // Break binding if frozen + if (loader.freeze) + clients = clients; + + opacity = 1; + + const c = clients[0]; + if (c) { + const cx = c.lastIpcObject.at[0] - screen.x; + const cy = c.lastIpcObject.at[1] - screen.y; + onClient = true; + sx = cx; + sy = cy; + ex = cx + c.lastIpcObject.size[0]; + ey = cy + c.lastIpcObject.size[1]; + } else { + sx = screen.width / 2 - 100; + sy = screen.height / 2 - 100; + ex = screen.width / 2 + 100; + ey = screen.height / 2 + 100; + } + } + + onPressed: event => { + ssx = event.x; + ssy = event.y; + } + + onReleased: { + if (closeAnim.running) + return; + + if (root.loader.freeze) { + save(); + } else { + overlay.visible = border.visible = false; + screencopy.visible = false; + screencopy.active = true; + } + } + + onPositionChanged: event => { + const x = event.x; + const y = event.y; + + if (pressed) { + onClient = false; + sx = ssx; + sy = ssy; + ex = x; + ey = y; + } else { + checkClientRects(x, y); + } + } + + focus: true + Keys.onEscapePressed: closeAnim.start() + + SequentialAnimation { + id: closeAnim + + PropertyAction { + target: root.loader + property: "closing" + value: true + } + ParallelAnimation { + Anim { + target: root + property: "opacity" + to: 0 + duration: Appearance.anim.durations.large + } + ExAnim { + target: root + properties: "rsx,rsy" + to: 0 + } + ExAnim { + target: root + property: "sw" + to: root.screen.width + } + ExAnim { + target: root + property: "sh" + to: root.screen.height + } + } + PropertyAction { + target: root.loader + property: "activeAsync" + value: false + } + } + + Loader { + id: screencopy + + anchors.fill: parent + + active: root.loader.freeze + asynchronous: true + + sourceComponent: ScreencopyView { + captureSource: root.screen + + onHasContentChanged: { + if (hasContent && !root.loader.freeze) { + overlay.visible = border.visible = true; + root.save(); + } + } + } + } + + StyledRect { + id: overlay + + anchors.fill: parent + color: Colours.palette.m3secondaryContainer + opacity: 0.3 + + layer.enabled: true + layer.effect: MultiEffect { + maskSource: selectionWrapper + maskEnabled: true + maskInverted: true + maskSpreadAtMin: 1 + maskThresholdMin: 0.5 + } + } + + Item { + id: selectionWrapper + + anchors.fill: parent + layer.enabled: true + visible: false + + Rectangle { + id: selectionRect + + radius: root.realRounding + x: root.rsx + y: root.rsy + implicitWidth: root.sw + implicitHeight: root.sh + } + } + + Rectangle { + id: border + + color: "transparent" + radius: root.realRounding > 0 ? root.realRounding + root.realBorderWidth : 0 + border.width: root.realBorderWidth + border.color: Colours.palette.m3primary + + x: selectionRect.x - root.realBorderWidth + y: selectionRect.y - root.realBorderWidth + implicitWidth: selectionRect.implicitWidth + root.realBorderWidth * 2 + implicitHeight: selectionRect.implicitHeight + root.realBorderWidth * 2 + + Behavior on border.color { + CAnim {} + } + } + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.large + } + } + + Behavior on rsx { + enabled: !root.pressed + + ExAnim {} + } + + Behavior on rsy { + enabled: !root.pressed + + ExAnim {} + } + + Behavior on sw { + enabled: !root.pressed + + ExAnim {} + } + + Behavior on sh { + enabled: !root.pressed + + ExAnim {} + } + + component ExAnim: Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } +} diff --git a/Modules/background/Background.qml b/Modules/background/Background.qml new file mode 100644 index 0000000..bdba570 --- /dev/null +++ b/Modules/background/Background.qml @@ -0,0 +1,75 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.containers +import qs.services +import qs.config +import Quickshell +import Quickshell.Wayland +import QtQuick + +Loader { + asynchronous: true + active: Config.background.enabled + + sourceComponent: Variants { + model: Quickshell.screens + + StyledWindow { + id: win + + required property ShellScreen modelData + + screen: modelData + name: "background" + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Background + color: "black" + + anchors.top: true + anchors.bottom: true + anchors.left: true + anchors.right: true + + Wallpaper { + id: wallpaper + } + + Loader { + readonly property bool shouldBeActive: Config.background.visualiser.enabled && (!Config.background.visualiser.autoHide || Hypr.monitorFor(win.modelData).activeWorkspace.toplevels.values.every(t => t.lastIpcObject.floating)) ? 1 : 0 + property real offset: shouldBeActive ? 0 : win.modelData.height * 0.2 + + anchors.fill: parent + anchors.topMargin: offset + anchors.bottomMargin: -offset + opacity: shouldBeActive ? 1 : 0 + active: opacity > 0 + asynchronous: true + + sourceComponent: Visualiser { + screen: win.modelData + wallpaper: wallpaper + } + + Behavior on offset { + Anim {} + } + + Behavior on opacity { + Anim {} + } + } + + Loader { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Appearance.padding.large + + active: Config.background.desktopClock.enabled + asynchronous: true + + source: "DesktopClock.qml" + } + } + } +} diff --git a/Modules/background/DesktopClock.qml b/Modules/background/DesktopClock.qml new file mode 100644 index 0000000..6dc6b6b --- /dev/null +++ b/Modules/background/DesktopClock.qml @@ -0,0 +1,18 @@ +import qs.components +import qs.services +import qs.config +import QtQuick + +Item { + implicitWidth: timeText.implicitWidth + Appearance.padding.large * 2 + implicitHeight: timeText.implicitHeight + Appearance.padding.large * 2 + + StyledText { + id: timeText + + anchors.centerIn: parent + text: Time.format(Config.services.useTwelveHourClock ? "hh:mm:ss A" : "hh:mm:ss") + font.pointSize: Appearance.font.size.extraLarge + font.bold: true + } +} diff --git a/Modules/background/Visualiser.qml b/Modules/background/Visualiser.qml new file mode 100644 index 0000000..e5a8a9b --- /dev/null +++ b/Modules/background/Visualiser.qml @@ -0,0 +1,120 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import qs.config +import Caelestia.Services +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Effects + +Item { + id: root + + required property ShellScreen screen + required property Wallpaper wallpaper + + ServiceRef { + service: Audio.cava + } + + MultiEffect { + anchors.fill: parent + source: root.wallpaper + maskSource: wrapper + maskEnabled: true + blurEnabled: true + blur: 1 + blurMax: 32 + autoPaddingEnabled: false + } + + Item { + id: wrapper + + anchors.fill: parent + layer.enabled: true + + Item { + id: content + + anchors.fill: parent + anchors.margins: Config.border.thickness + anchors.leftMargin: Visibilities.bars.get(root.screen).exclusiveZone + Appearance.spacing.small * Config.background.visualiser.spacing + + Side {} + Side { + isRight: true + } + + Behavior on anchors.leftMargin { + Anim {} + } + } + } + + component Side: Repeater { + id: side + + property bool isRight + + model: Config.services.visualiserBars + + ClippingRectangle { + id: bar + + required property int modelData + property real value: Math.max(0, Math.min(1, Audio.cava.values[side.isRight ? modelData : side.count - modelData - 1])) + + clip: true + + x: modelData * ((content.width * 0.4) / Config.services.visualiserBars) + (side.isRight ? content.width * 0.6 : 0) + implicitWidth: (content.width * 0.4) / Config.services.visualiserBars - Appearance.spacing.small * Config.background.visualiser.spacing + + y: content.height - height + implicitHeight: bar.value * content.height * 0.4 + + color: "transparent" + topLeftRadius: Appearance.rounding.small * Config.background.visualiser.rounding + topRightRadius: Appearance.rounding.small * Config.background.visualiser.rounding + + Rectangle { + topLeftRadius: parent.topLeftRadius + topRightRadius: parent.topRightRadius + + gradient: Gradient { + orientation: Gradient.Vertical + + GradientStop { + position: 0 + color: Qt.alpha(Colours.palette.m3primary, 0.7) + + Behavior on color { + CAnim {} + } + } + GradientStop { + position: 1 + color: Qt.alpha(Colours.palette.m3inversePrimary, 0.7) + + Behavior on color { + CAnim {} + } + } + } + + anchors.left: parent.left + anchors.right: parent.right + y: parent.height - height + implicitHeight: content.height * 0.4 + } + + Behavior on value { + Anim { + duration: Appearance.anim.durations.small + } + } + } + } +} diff --git a/Modules/background/Wallpaper.qml b/Modules/background/Wallpaper.qml new file mode 100644 index 0000000..a4d99ee --- /dev/null +++ b/Modules/background/Wallpaper.qml @@ -0,0 +1,143 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.components.images +import qs.components.filedialog +import qs.services +import qs.config +import qs.utils +import QtQuick + +Item { + id: root + + property string source: Wallpapers.current + property Image current: one + + anchors.fill: parent + + onSourceChanged: { + if (!source) + current = null; + else if (current === one) + two.update(); + else + one.update(); + } + + Loader { + anchors.fill: parent + + active: !root.source + asynchronous: true + + sourceComponent: StyledRect { + color: Colours.palette.m3surfaceContainer + + Row { + anchors.centerIn: parent + spacing: Appearance.spacing.large + + MaterialIcon { + text: "sentiment_stressed" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.extraLarge * 5 + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: Appearance.spacing.small + + StyledText { + text: qsTr("Wallpaper missing?") + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.extraLarge * 2 + font.bold: true + } + + StyledRect { + implicitWidth: selectWallText.implicitWidth + Appearance.padding.large * 2 + implicitHeight: selectWallText.implicitHeight + Appearance.padding.small * 2 + + radius: Appearance.rounding.full + color: Colours.palette.m3primary + + FileDialog { + id: dialog + + title: qsTr("Select a wallpaper") + filterLabel: qsTr("Image files") + filters: Images.validImageExtensions + onAccepted: path => Wallpapers.setWallpaper(path) + } + + StateLayer { + radius: parent.radius + color: Colours.palette.m3onPrimary + + function onClicked(): void { + dialog.open(); + } + } + + StyledText { + id: selectWallText + + anchors.centerIn: parent + + text: qsTr("Set it now!") + color: Colours.palette.m3onPrimary + font.pointSize: Appearance.font.size.large + } + } + } + } + } + } + + Img { + id: one + } + + Img { + id: two + } + + component Img: CachingImage { + id: img + + function update(): void { + if (path === root.source) + root.current = this; + else + path = root.source; + } + + anchors.fill: parent + + opacity: 0 + scale: Wallpapers.showPreview ? 1 : 0.8 + + onStatusChanged: { + if (status === Image.Ready) + root.current = this; + } + + states: State { + name: "visible" + when: root.current === img + + PropertyChanges { + img.opacity: 1 + img.scale: 1 + } + } + + transitions: Transition { + Anim { + target: img + properties: "opacity,scale" + } + } + } +} diff --git a/components/Anim.qml b/components/Anim.qml new file mode 100644 index 0000000..6883a79 --- /dev/null +++ b/components/Anim.qml @@ -0,0 +1,8 @@ +import qs.config +import QtQuick + +NumberAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standard +} diff --git a/components/CAnim.qml b/components/CAnim.qml new file mode 100644 index 0000000..49484b7 --- /dev/null +++ b/components/CAnim.qml @@ -0,0 +1,8 @@ +import qs.config +import QtQuick + +ColorAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standard +} diff --git a/components/MaterialIcon.qml b/components/MaterialIcon.qml new file mode 100644 index 0000000..a1d19d3 --- /dev/null +++ b/components/MaterialIcon.qml @@ -0,0 +1,16 @@ +import qs.services +import qs.config + +StyledText { + property real fill + property int grade: Colours.light ? 0 : -25 + + font.family: Appearance.font.family.material + font.pointSize: Appearance.font.size.larger + font.variableAxes: ({ + FILL: fill.toFixed(1), + GRAD: grade, + opsz: fontInfo.pixelSize, + wght: fontInfo.weight + }) +} diff --git a/components/StateLayer.qml b/components/StateLayer.qml new file mode 100644 index 0000000..d86e782 --- /dev/null +++ b/components/StateLayer.qml @@ -0,0 +1,94 @@ +import qs.services +import qs.config +import QtQuick + +MouseArea { + id: root + + property bool disabled + property color color: Colours.palette.m3onSurface + property real radius: parent?.radius ?? 0 + property alias rect: hoverLayer + + function onClicked(): void { + } + + anchors.fill: parent + + enabled: !disabled + cursorShape: disabled ? undefined : Qt.PointingHandCursor + hoverEnabled: true + + onPressed: event => { + if (disabled) + return; + + rippleAnim.x = event.x; + rippleAnim.y = event.y; + + const dist = (ox, oy) => ox * ox + oy * oy; + rippleAnim.radius = Math.sqrt(Math.max(dist(event.x, event.y), dist(event.x, height - event.y), dist(width - event.x, event.y), dist(width - event.x, height - event.y))); + + rippleAnim.restart(); + } + + onClicked: event => !disabled && onClicked(event) + + SequentialAnimation { + id: rippleAnim + + property real x + property real y + property real radius + + PropertyAction { + target: ripple + property: "x" + value: rippleAnim.x + } + PropertyAction { + target: ripple + property: "y" + value: rippleAnim.y + } + PropertyAction { + target: ripple + property: "opacity" + value: 0.08 + } + Anim { + target: ripple + properties: "implicitWidth,implicitHeight" + from: 0 + to: rippleAnim.radius * 2 + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + Anim { + target: ripple + property: "opacity" + to: 0 + } + } + + StyledClippingRect { + id: hoverLayer + + anchors.fill: parent + + color: Qt.alpha(root.color, root.disabled ? 0 : root.pressed ? 0.1 : root.containsMouse ? 0.08 : 0) + radius: root.radius + + StyledRect { + id: ripple + + radius: Appearance.rounding.full + color: root.color + opacity: 0 + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } +} diff --git a/components/StyledClippingRect.qml b/components/StyledClippingRect.qml new file mode 100644 index 0000000..8f2630c --- /dev/null +++ b/components/StyledClippingRect.qml @@ -0,0 +1,12 @@ +import Quickshell.Widgets +import QtQuick + +ClippingRectangle { + id: root + + color: "transparent" + + Behavior on color { + CAnim {} + } +} diff --git a/components/StyledRect.qml b/components/StyledRect.qml new file mode 100644 index 0000000..f5d5143 --- /dev/null +++ b/components/StyledRect.qml @@ -0,0 +1,11 @@ +import QtQuick + +Rectangle { + id: root + + color: "transparent" + + Behavior on color { + CAnim {} + } +} diff --git a/components/StyledText.qml b/components/StyledText.qml new file mode 100644 index 0000000..ed961d2 --- /dev/null +++ b/components/StyledText.qml @@ -0,0 +1,48 @@ +pragma ComponentBehavior: Bound + +import qs.services +import qs.config +import QtQuick + +Text { + id: root + + property bool animate: false + property string animateProp: "scale" + property real animateFrom: 0 + property real animateTo: 1 + property int animateDuration: Appearance.anim.durations.normal + + renderType: Text.NativeRendering + textFormat: Text.PlainText + color: Colours.palette.m3onSurface + font.family: Appearance.font.family.sans + font.pointSize: Appearance.font.size.smaller + + Behavior on color { + CAnim {} + } + + Behavior on text { + enabled: root.animate + + SequentialAnimation { + Anim { + to: root.animateFrom + easing.bezierCurve: Appearance.anim.curves.standardAccel + } + PropertyAction {} + Anim { + to: root.animateTo + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + } + } + + component Anim: NumberAnimation { + target: root + property: root.animateProp + duration: root.animateDuration / 2 + easing.type: Easing.BezierSpline + } +} diff --git a/components/containers/StyledFlickable.qml b/components/containers/StyledFlickable.qml new file mode 100644 index 0000000..bc6ae0f --- /dev/null +++ b/components/containers/StyledFlickable.qml @@ -0,0 +1,14 @@ +import ".." +import QtQuick + +Flickable { + id: root + + maximumFlickVelocity: 3000 + + rebound: Transition { + Anim { + properties: "x,y" + } + } +} diff --git a/components/containers/StyledListView.qml b/components/containers/StyledListView.qml new file mode 100644 index 0000000..626d206 --- /dev/null +++ b/components/containers/StyledListView.qml @@ -0,0 +1,14 @@ +import ".." +import QtQuick + +ListView { + id: root + + maximumFlickVelocity: 3000 + + rebound: Transition { + Anim { + properties: "x,y" + } + } +} diff --git a/components/containers/StyledWindow.qml b/components/containers/StyledWindow.qml new file mode 100644 index 0000000..8c6e39f --- /dev/null +++ b/components/containers/StyledWindow.qml @@ -0,0 +1,9 @@ +import Quickshell +import Quickshell.Wayland + +PanelWindow { + required property string name + + WlrLayershell.namespace: `caelestia-${name}` + color: "transparent" +} diff --git a/components/controls/CircularIndicator.qml b/components/controls/CircularIndicator.qml new file mode 100644 index 0000000..957899e --- /dev/null +++ b/components/controls/CircularIndicator.qml @@ -0,0 +1,108 @@ +import ".." +import qs.services +import qs.config +import Caelestia.Internal +import QtQuick +import QtQuick.Templates + +BusyIndicator { + id: root + + enum AnimType { + Advance = 0, + Retreat + } + + enum AnimState { + Stopped, + Running, + Completing + } + + property real implicitSize: Appearance.font.size.normal * 3 + property real strokeWidth: Appearance.padding.small * 0.8 + property color fgColour: Colours.palette.m3primary + property color bgColour: Colours.palette.m3secondaryContainer + + property alias type: manager.indeterminateAnimationType + readonly property alias progress: manager.progress + + property real internalStrokeWidth: strokeWidth + property int animState + + padding: 0 + implicitWidth: implicitSize + implicitHeight: implicitSize + + Component.onCompleted: { + if (running) { + running = false; + running = true; + } + } + + onRunningChanged: { + if (running) { + manager.completeEndProgress = 0; + animState = CircularIndicator.Running; + } else { + if (animState == CircularIndicator.Running) + animState = CircularIndicator.Completing; + } + } + + states: State { + name: "stopped" + when: !root.running + + PropertyChanges { + root.opacity: 0 + root.internalStrokeWidth: root.strokeWidth / 3 + } + } + + transitions: Transition { + Anim { + properties: "opacity,internalStrokeWidth" + duration: manager.completeEndDuration * Appearance.anim.durations.scale + } + } + + contentItem: CircularProgress { + anchors.fill: parent + strokeWidth: root.internalStrokeWidth + fgColour: root.fgColour + bgColour: root.bgColour + padding: root.padding + rotation: manager.rotation + startAngle: manager.startFraction * 360 + value: manager.endFraction - manager.startFraction + } + + CircularIndicatorManager { + id: manager + } + + NumberAnimation { + running: root.animState !== CircularIndicator.Stopped + loops: Animation.Infinite + target: manager + property: "progress" + from: 0 + to: 1 + duration: manager.duration * Appearance.anim.durations.scale + } + + NumberAnimation { + running: root.animState === CircularIndicator.Completing + target: manager + property: "completeEndProgress" + from: 0 + to: 1 + duration: manager.completeEndDuration * Appearance.anim.durations.scale + onFinished: { + if (root.animState === CircularIndicator.Completing) + root.animState = CircularIndicator.Stopped; + } + } +} diff --git a/components/controls/CircularProgress.qml b/components/controls/CircularProgress.qml new file mode 100644 index 0000000..a15cd90 --- /dev/null +++ b/components/controls/CircularProgress.qml @@ -0,0 +1,69 @@ +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Shapes + +Shape { + id: root + + property real value + property int startAngle: -90 + property int strokeWidth: Appearance.padding.smaller + property int padding: 0 + property int spacing: Appearance.spacing.small + property color fgColour: Colours.palette.m3primary + property color bgColour: Colours.palette.m3secondaryContainer + + readonly property real size: Math.min(width, height) + readonly property real arcRadius: (size - padding - strokeWidth) / 2 + readonly property real vValue: value || 1 / 360 + readonly property real gapAngle: ((spacing + strokeWidth) / (arcRadius || 1)) * (180 / Math.PI) + + preferredRendererType: Shape.CurveRenderer + asynchronous: true + + ShapePath { + fillColor: "transparent" + strokeColor: root.bgColour + strokeWidth: root.strokeWidth + capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + + PathAngleArc { + startAngle: root.startAngle + 360 * root.vValue + root.gapAngle + sweepAngle: Math.max(-root.gapAngle, 360 * (1 - root.vValue) - root.gapAngle * 2) + radiusX: root.arcRadius + radiusY: root.arcRadius + centerX: root.size / 2 + centerY: root.size / 2 + } + + Behavior on strokeColor { + CAnim { + duration: Appearance.anim.durations.large + } + } + } + + ShapePath { + fillColor: "transparent" + strokeColor: root.fgColour + strokeWidth: root.strokeWidth + capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + + PathAngleArc { + startAngle: root.startAngle + sweepAngle: 360 * root.vValue + radiusX: root.arcRadius + radiusY: root.arcRadius + centerX: root.size / 2 + centerY: root.size / 2 + } + + Behavior on strokeColor { + CAnim { + duration: Appearance.anim.durations.large + } + } + } +} diff --git a/components/controls/CustomMouseArea.qml b/components/controls/CustomMouseArea.qml new file mode 100644 index 0000000..7c973c2 --- /dev/null +++ b/components/controls/CustomMouseArea.qml @@ -0,0 +1,21 @@ +import QtQuick + +MouseArea { + property int scrollAccumulatedY: 0 + + function onWheel(event: WheelEvent): void { + } + + onWheel: event => { + // Update accumulated scroll + if (Math.sign(event.angleDelta.y) !== Math.sign(scrollAccumulatedY)) + scrollAccumulatedY = 0; + scrollAccumulatedY += event.angleDelta.y; + + // Trigger handler and reset if above threshold + if (Math.abs(scrollAccumulatedY) >= 120) { + onWheel(event); + scrollAccumulatedY = 0; + } + } +} diff --git a/components/controls/CustomSpinBox.qml b/components/controls/CustomSpinBox.qml new file mode 100644 index 0000000..e2ed508 --- /dev/null +++ b/components/controls/CustomSpinBox.qml @@ -0,0 +1,108 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +RowLayout { + id: root + + property int value + property real max: Infinity + property real min: -Infinity + property alias repeatRate: timer.interval + + signal valueModified(value: int) + + spacing: Appearance.spacing.small + + StyledTextField { + inputMethodHints: Qt.ImhFormattedNumbersOnly + text: root.value + onAccepted: root.valueModified(text) + + padding: Appearance.padding.small + leftPadding: Appearance.padding.normal + rightPadding: Appearance.padding.normal + + background: StyledRect { + implicitWidth: 100 + radius: Appearance.rounding.small + color: Colours.tPalette.m3surfaceContainerHigh + } + } + + StyledRect { + radius: Appearance.rounding.small + color: Colours.palette.m3primary + + implicitWidth: implicitHeight + implicitHeight: upIcon.implicitHeight + Appearance.padding.small * 2 + + StateLayer { + id: upState + + color: Colours.palette.m3onPrimary + + onPressAndHold: timer.start() + onReleased: timer.stop() + + function onClicked(): void { + root.valueModified(Math.min(root.max, root.value + 1)); + } + } + + MaterialIcon { + id: upIcon + + anchors.centerIn: parent + text: "keyboard_arrow_up" + color: Colours.palette.m3onPrimary + } + } + + StyledRect { + radius: Appearance.rounding.small + color: Colours.palette.m3primary + + implicitWidth: implicitHeight + implicitHeight: downIcon.implicitHeight + Appearance.padding.small * 2 + + StateLayer { + id: downState + + color: Colours.palette.m3onPrimary + + onPressAndHold: timer.start() + onReleased: timer.stop() + + function onClicked(): void { + root.valueModified(Math.max(root.min, root.value - 1)); + } + } + + MaterialIcon { + id: downIcon + + anchors.centerIn: parent + text: "keyboard_arrow_down" + color: Colours.palette.m3onPrimary + } + } + + Timer { + id: timer + + interval: 100 + repeat: true + triggeredOnStart: true + onTriggered: { + if (upState.pressed) + upState.onClicked(); + else if (downState.pressed) + downState.onClicked(); + } + } +} diff --git a/components/controls/FilledSlider.qml b/components/controls/FilledSlider.qml new file mode 100644 index 0000000..78b8a5c --- /dev/null +++ b/components/controls/FilledSlider.qml @@ -0,0 +1,146 @@ +import ".." +import "../effects" +import qs.services +import qs.config +import QtQuick +import QtQuick.Templates + +Slider { + id: root + + required property string icon + property real oldValue + property bool initialized + + orientation: Qt.Vertical + + background: StyledRect { + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.full + + StyledRect { + anchors.left: parent.left + anchors.right: parent.right + + y: root.handle.y + implicitHeight: parent.height - y + + color: Colours.palette.m3secondary + radius: parent.radius + } + } + + handle: Item { + id: handle + + property alias moving: icon.moving + + y: root.visualPosition * (root.availableHeight - height) + implicitWidth: root.width + implicitHeight: root.width + + Elevation { + anchors.fill: parent + radius: rect.radius + level: handleInteraction.containsMouse ? 2 : 1 + } + + StyledRect { + id: rect + + anchors.fill: parent + + color: Colours.palette.m3inverseSurface + radius: Appearance.rounding.full + + MouseArea { + id: handleInteraction + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.NoButton + } + + MaterialIcon { + id: icon + + property bool moving + + function update(): void { + animate = !moving; + binding.when = moving; + font.pointSize = moving ? Appearance.font.size.small : Appearance.font.size.larger; + font.family = moving ? Appearance.font.family.sans : Appearance.font.family.material; + } + + text: root.icon + color: Colours.palette.m3inverseOnSurface + anchors.centerIn: parent + + onMovingChanged: anim.restart() + + Binding { + id: binding + + target: icon + property: "text" + value: Math.round(root.value * 100) + when: false + } + + SequentialAnimation { + id: anim + + Anim { + target: icon + property: "scale" + to: 0 + duration: Appearance.anim.durations.normal / 2 + easing.bezierCurve: Appearance.anim.curves.standardAccel + } + ScriptAction { + script: icon.update() + } + Anim { + target: icon + property: "scale" + to: 1 + duration: Appearance.anim.durations.normal / 2 + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + } + } + } + } + + onPressedChanged: handle.moving = pressed + + onValueChanged: { + if (!initialized) { + initialized = true; + return; + } + if (Math.abs(value - oldValue) < 0.01) + return; + oldValue = value; + handle.moving = true; + stateChangeDelay.restart(); + } + + Timer { + id: stateChangeDelay + + interval: 500 + onTriggered: { + if (!root.pressed) + handle.moving = false; + } + } + + Behavior on value { + Anim { + duration: Appearance.anim.durations.large + } + } +} diff --git a/components/controls/IconButton.qml b/components/controls/IconButton.qml new file mode 100644 index 0000000..dc3b04b --- /dev/null +++ b/components/controls/IconButton.qml @@ -0,0 +1,83 @@ +import ".." +import qs.services +import qs.config +import QtQuick + +StyledRect { + id: root + + enum Type { + Filled, + Tonal, + Text + } + + property alias icon: label.text + property bool checked + property bool toggle + property real padding: type === IconButton.Text ? Appearance.padding.small / 2 : Appearance.padding.smaller + property alias font: label.font + property int type: IconButton.Filled + property bool disabled + + property alias stateLayer: stateLayer + property alias label: label + property alias radiusAnim: radiusAnim + + property bool internalChecked + property color activeColour: type === IconButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondary + property color inactiveColour: { + if (!toggle && type === IconButton.Filled) + return Colours.palette.m3primary; + return type === IconButton.Filled ? Colours.tPalette.m3surfaceContainer : Colours.palette.m3secondaryContainer; + } + property color activeOnColour: type === IconButton.Filled ? Colours.palette.m3onPrimary : type === IconButton.Tonal ? Colours.palette.m3onSecondary : Colours.palette.m3primary + property color inactiveOnColour: { + if (!toggle && type === IconButton.Filled) + return Colours.palette.m3onPrimary; + return type === IconButton.Tonal ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant; + } + property color disabledColour: Qt.alpha(Colours.palette.m3onSurface, 0.1) + property color disabledOnColour: Qt.alpha(Colours.palette.m3onSurface, 0.38) + + signal clicked + + onCheckedChanged: internalChecked = checked + + radius: internalChecked ? Appearance.rounding.small : implicitHeight / 2 + color: type === IconButton.Text ? "transparent" : disabled ? disabledColour : internalChecked ? activeColour : inactiveColour + + implicitWidth: implicitHeight + implicitHeight: label.implicitHeight + padding * 2 + + StateLayer { + id: stateLayer + + color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour + disabled: root.disabled + + function onClicked(): void { + if (root.toggle) + root.internalChecked = !root.internalChecked; + root.clicked(); + } + } + + MaterialIcon { + id: label + + anchors.centerIn: parent + color: root.disabled ? root.disabledOnColour : root.internalChecked ? root.activeOnColour : root.inactiveOnColour + fill: !root.toggle || root.internalChecked ? 1 : 0 + + Behavior on fill { + Anim {} + } + } + + Behavior on radius { + Anim { + id: radiusAnim + } + } +} diff --git a/components/controls/IconTextButton.qml b/components/controls/IconTextButton.qml new file mode 100644 index 0000000..78e7c5b --- /dev/null +++ b/components/controls/IconTextButton.qml @@ -0,0 +1,85 @@ +import ".." +import qs.services +import qs.config +import QtQuick + +StyledRect { + id: root + + enum Type { + Filled, + Tonal, + Text + } + + property alias icon: iconLabel.text + property alias text: label.text + property bool checked + property bool toggle + property real horizontalPadding: Appearance.padding.normal + property real verticalPadding: Appearance.padding.smaller + property alias font: label.font + property int type: IconTextButton.Filled + + property alias stateLayer: stateLayer + property alias iconLabel: iconLabel + property alias label: label + + property bool internalChecked + property color activeColour: type === IconTextButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondary + property color inactiveColour: type === IconTextButton.Filled ? Colours.tPalette.m3surfaceContainer : Colours.palette.m3secondaryContainer + property color activeOnColour: type === IconTextButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondary + property color inactiveOnColour: type === IconTextButton.Filled ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer + + signal clicked + + onCheckedChanged: internalChecked = checked + + radius: internalChecked ? Appearance.rounding.small : implicitHeight / 2 + color: type === IconTextButton.Text ? "transparent" : internalChecked ? activeColour : inactiveColour + + implicitWidth: row.implicitWidth + horizontalPadding * 2 + implicitHeight: row.implicitHeight + verticalPadding * 2 + + StateLayer { + id: stateLayer + + color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour + + function onClicked(): void { + if (root.toggle) + root.internalChecked = !root.internalChecked; + root.clicked(); + } + } + + Row { + id: row + + anchors.centerIn: parent + spacing: Appearance.spacing.small + + MaterialIcon { + id: iconLabel + + anchors.verticalCenter: parent.verticalCenter + color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour + fill: root.internalChecked ? 1 : 0 + + Behavior on fill { + Anim {} + } + } + + StyledText { + id: label + + anchors.verticalCenter: parent.verticalCenter + color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour + } + } + + Behavior on radius { + Anim {} + } +} diff --git a/components/controls/Menu.qml b/components/controls/Menu.qml new file mode 100644 index 0000000..c763b54 --- /dev/null +++ b/components/controls/Menu.qml @@ -0,0 +1,113 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../effects" +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +Elevation { + id: root + + property list items + property MenuItem active: items[0] ?? null + property bool expanded + + signal itemSelected(item: MenuItem) + + radius: Appearance.rounding.small / 2 + level: 2 + + implicitWidth: Math.max(200, column.implicitWidth) + implicitHeight: root.expanded ? column.implicitHeight : 0 + opacity: root.expanded ? 1 : 0 + + StyledClippingRect { + anchors.fill: parent + radius: parent.radius + color: Colours.palette.m3surfaceContainer + + ColumnLayout { + id: column + + anchors.left: parent.left + anchors.right: parent.right + spacing: 0 + + Repeater { + model: root.items + + StyledRect { + id: item + + required property int index + required property MenuItem modelData + readonly property bool active: modelData === root.active + + Layout.fillWidth: true + implicitWidth: menuOptionRow.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: menuOptionRow.implicitHeight + Appearance.padding.normal * 2 + + color: Qt.alpha(Colours.palette.m3secondaryContainer, active ? 1 : 0) + + StateLayer { + color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + disabled: !root.expanded + + function onClicked(): void { + root.itemSelected(item.modelData); + root.active = item.modelData; + root.expanded = false; + } + } + + RowLayout { + id: menuOptionRow + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + spacing: Appearance.spacing.small + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + text: item.modelData.icon + color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + text: item.modelData.text + color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + } + + Loader { + Layout.alignment: Qt.AlignVCenter + active: item.modelData.trailingIcon.length > 0 + visible: active + + sourceComponent: MaterialIcon { + text: item.modelData.trailingIcon + color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + } + } + } + } + } + } + } + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + } + } + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } +} diff --git a/components/controls/MenuItem.qml b/components/controls/MenuItem.qml new file mode 100644 index 0000000..5348bbe --- /dev/null +++ b/components/controls/MenuItem.qml @@ -0,0 +1,11 @@ +import QtQuick + +QtObject { + required property string text + property string icon + property string trailingIcon + property string activeIcon: icon + property string activeText: text + + signal clicked +} diff --git a/components/controls/SplitButton.qml b/components/controls/SplitButton.qml new file mode 100644 index 0000000..d7f2651 --- /dev/null +++ b/components/controls/SplitButton.qml @@ -0,0 +1,164 @@ +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +Row { + id: root + + enum Type { + Filled, + Tonal + } + + property real horizontalPadding: Appearance.padding.normal + property real verticalPadding: Appearance.padding.smaller + property int type: SplitButton.Filled + property bool disabled + property bool menuOnTop + property string fallbackIcon + property string fallbackText + + property alias menuItems: menu.items + property alias active: menu.active + property alias expanded: menu.expanded + property alias menu: menu + property alias iconLabel: iconLabel + property alias label: label + property alias stateLayer: stateLayer + + property color colour: type == SplitButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondaryContainer + property color textColour: type == SplitButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondaryContainer + property color disabledColour: Qt.alpha(Colours.palette.m3onSurface, 0.1) + property color disabledTextColour: Qt.alpha(Colours.palette.m3onSurface, 0.38) + + spacing: Math.floor(Appearance.spacing.small / 2) + + StyledRect { + radius: implicitHeight / 2 + topRightRadius: Appearance.rounding.small / 2 + bottomRightRadius: Appearance.rounding.small / 2 + color: root.disabled ? root.disabledColour : root.colour + + implicitWidth: textRow.implicitWidth + root.horizontalPadding * 2 + implicitHeight: expandBtn.implicitHeight + + StateLayer { + id: stateLayer + + rect.topRightRadius: parent.topRightRadius + rect.bottomRightRadius: parent.bottomRightRadius + color: root.textColour + disabled: root.disabled + + function onClicked(): void { + root.active?.clicked(); + } + } + + RowLayout { + id: textRow + + anchors.centerIn: parent + anchors.horizontalCenterOffset: Math.floor(root.verticalPadding / 4) + spacing: Appearance.spacing.small + + MaterialIcon { + id: iconLabel + + Layout.alignment: Qt.AlignVCenter + animate: true + text: root.active?.activeIcon ?? root.fallbackIcon + color: root.disabled ? root.disabledTextColour : root.textColour + fill: 1 + } + + StyledText { + id: label + + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: implicitWidth + animate: true + text: root.active?.activeText ?? root.fallbackText + color: root.disabled ? root.disabledTextColour : root.textColour + clip: true + + Behavior on Layout.preferredWidth { + Anim { + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + } + } + } + + StyledRect { + id: expandBtn + + property real rad: root.expanded ? implicitHeight / 2 : Appearance.rounding.small / 2 + + radius: implicitHeight / 2 + topLeftRadius: rad + bottomLeftRadius: rad + color: root.disabled ? root.disabledColour : root.colour + + implicitWidth: implicitHeight + implicitHeight: expandIcon.implicitHeight + root.verticalPadding * 2 + + StateLayer { + id: expandStateLayer + + rect.topLeftRadius: parent.topLeftRadius + rect.bottomLeftRadius: parent.bottomLeftRadius + color: root.textColour + disabled: root.disabled + + function onClicked(): void { + root.expanded = !root.expanded; + } + } + + MaterialIcon { + id: expandIcon + + anchors.centerIn: parent + anchors.horizontalCenterOffset: root.expanded ? 0 : -Math.floor(root.verticalPadding / 4) + + text: "expand_more" + color: root.disabled ? root.disabledTextColour : root.textColour + rotation: root.expanded ? 180 : 0 + + Behavior on anchors.horizontalCenterOffset { + Anim {} + } + + Behavior on rotation { + Anim {} + } + } + + Behavior on rad { + Anim {} + } + + Menu { + id: menu + + states: State { + when: root.menuOnTop + + AnchorChanges { + target: menu + anchors.top: undefined + anchors.bottom: expandBtn.top + } + } + + anchors.top: parent.bottom + anchors.right: parent.right + anchors.topMargin: Appearance.spacing.small + anchors.bottomMargin: Appearance.spacing.small + } + } +} diff --git a/components/controls/StyledRadioButton.qml b/components/controls/StyledRadioButton.qml new file mode 100644 index 0000000..b72fc77 --- /dev/null +++ b/components/controls/StyledRadioButton.qml @@ -0,0 +1,57 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Templates + +RadioButton { + id: root + + font.pointSize: Appearance.font.size.smaller + + implicitWidth: implicitIndicatorWidth + implicitContentWidth + contentItem.anchors.leftMargin + implicitHeight: Math.max(implicitIndicatorHeight, implicitContentHeight) + + indicator: Rectangle { + id: outerCircle + + implicitWidth: 20 + implicitHeight: 20 + radius: Appearance.rounding.full + color: "transparent" + border.color: root.checked ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + border.width: 2 + anchors.verticalCenter: parent.verticalCenter + + StateLayer { + anchors.margins: -Appearance.padding.smaller + color: root.checked ? Colours.palette.m3onSurface : Colours.palette.m3primary + z: -1 + + function onClicked(): void { + root.click(); + } + } + + StyledRect { + anchors.centerIn: parent + implicitWidth: 8 + implicitHeight: 8 + + radius: Appearance.rounding.full + color: Qt.alpha(Colours.palette.m3primary, root.checked ? 1 : 0) + } + + Behavior on border.color { + CAnim {} + } + } + + contentItem: StyledText { + text: root.text + font.pointSize: root.font.pointSize + anchors.verticalCenter: parent.verticalCenter + anchors.left: outerCircle.right + anchors.leftMargin: Appearance.spacing.smaller + } +} diff --git a/components/controls/StyledScrollBar.qml b/components/controls/StyledScrollBar.qml new file mode 100644 index 0000000..fc641b5 --- /dev/null +++ b/components/controls/StyledScrollBar.qml @@ -0,0 +1,108 @@ +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Templates + +ScrollBar { + id: root + + required property Flickable flickable + property bool shouldBeActive + property real nonAnimPosition + property bool animating + + onHoveredChanged: { + if (hovered) + shouldBeActive = true; + else + shouldBeActive = flickable.moving; + } + + onPositionChanged: { + if (position === nonAnimPosition) + animating = false; + else if (!animating) + nonAnimPosition = position; + } + + position: nonAnimPosition + implicitWidth: Appearance.padding.small + + contentItem: StyledRect { + anchors.left: parent.left + anchors.right: parent.right + opacity: { + if (root.size === 1) + return 0; + if (fullMouse.pressed) + return 1; + if (mouse.containsMouse) + return 0.8; + if (root.policy === ScrollBar.AlwaysOn || root.shouldBeActive) + return 0.6; + return 0; + } + radius: Appearance.rounding.full + color: Colours.palette.m3secondary + + MouseArea { + id: mouse + + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + Behavior on opacity { + Anim {} + } + } + + Connections { + target: root.flickable + + function onMovingChanged(): void { + if (root.flickable.moving) + root.shouldBeActive = true; + else + hideDelay.restart(); + } + } + + Timer { + id: hideDelay + + interval: 600 + onTriggered: root.shouldBeActive = root.flickable.moving || root.hovered + } + + CustomMouseArea { + id: fullMouse + + anchors.fill: parent + preventStealing: true + + onPressed: event => { + root.animating = true; + root.nonAnimPosition = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2)); + } + + onPositionChanged: event => root.nonAnimPosition = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2)) + + function onWheel(event: WheelEvent): void { + root.animating = true; + if (event.angleDelta.y > 0) + root.nonAnimPosition = Math.max(0, root.nonAnimPosition - 0.1); + else if (event.angleDelta.y < 0) + root.nonAnimPosition = Math.min(1 - root.size, root.nonAnimPosition + 0.1); + } + } + + Behavior on position { + enabled: !fullMouse.pressed + + Anim {} + } +} diff --git a/components/controls/StyledSlider.qml b/components/controls/StyledSlider.qml new file mode 100644 index 0000000..92c8aa8 --- /dev/null +++ b/components/controls/StyledSlider.qml @@ -0,0 +1,57 @@ +import qs.components +import qs.services +import qs.config +import QtQuick +import QtQuick.Templates + +Slider { + id: root + + background: Item { + StyledRect { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.topMargin: root.implicitHeight / 3 + anchors.bottomMargin: root.implicitHeight / 3 + + implicitWidth: root.handle.x - root.implicitHeight / 6 + + color: Colours.palette.m3primary + radius: Appearance.rounding.full + topRightRadius: root.implicitHeight / 15 + bottomRightRadius: root.implicitHeight / 15 + } + + StyledRect { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.topMargin: root.implicitHeight / 3 + anchors.bottomMargin: root.implicitHeight / 3 + + implicitWidth: parent.width - root.handle.x - root.handle.implicitWidth - root.implicitHeight / 6 + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.full + topLeftRadius: root.implicitHeight / 15 + bottomLeftRadius: root.implicitHeight / 15 + } + } + + handle: StyledRect { + x: root.visualPosition * root.availableWidth - implicitWidth / 2 + + implicitWidth: root.implicitHeight / 4.5 + implicitHeight: root.implicitHeight + + color: Colours.palette.m3primary + radius: Appearance.rounding.full + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: Qt.PointingHandCursor + } + } +} diff --git a/components/controls/StyledSwitch.qml b/components/controls/StyledSwitch.qml new file mode 100644 index 0000000..ce93cd5 --- /dev/null +++ b/components/controls/StyledSwitch.qml @@ -0,0 +1,152 @@ +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Templates +import QtQuick.Shapes + +Switch { + id: root + + property int cLayer: 1 + + implicitWidth: implicitIndicatorWidth + implicitHeight: implicitIndicatorHeight + + indicator: StyledRect { + radius: Appearance.rounding.full + color: root.checked ? Colours.palette.m3primary : Colours.layer(Colours.palette.m3surfaceContainerHighest, root.cLayer) + + implicitWidth: implicitHeight * 1.7 + implicitHeight: Appearance.font.size.normal + Appearance.padding.smaller * 2 + + StyledRect { + readonly property real nonAnimWidth: root.pressed ? implicitHeight * 1.3 : implicitHeight + + radius: Appearance.rounding.full + color: root.checked ? Colours.palette.m3onPrimary : Colours.layer(Colours.palette.m3outline, root.cLayer + 1) + + x: root.checked ? parent.implicitWidth - nonAnimWidth - Appearance.padding.small / 2 : Appearance.padding.small / 2 + implicitWidth: nonAnimWidth + implicitHeight: parent.implicitHeight - Appearance.padding.small + anchors.verticalCenter: parent.verticalCenter + + StyledRect { + anchors.fill: parent + radius: parent.radius + + color: root.checked ? Colours.palette.m3primary : Colours.palette.m3onSurface + opacity: root.pressed ? 0.1 : root.hovered ? 0.08 : 0 + + Behavior on opacity { + Anim {} + } + } + + Shape { + id: icon + + property point start1: { + if (root.pressed) + return Qt.point(width * 0.2, height / 2); + if (root.checked) + return Qt.point(width * 0.15, height / 2); + return Qt.point(width * 0.15, height * 0.15); + } + property point end1: { + if (root.pressed) { + if (root.checked) + return Qt.point(width * 0.4, height / 2); + return Qt.point(width * 0.8, height / 2); + } + if (root.checked) + return Qt.point(width * 0.4, height * 0.7); + return Qt.point(width * 0.85, height * 0.85); + } + property point start2: { + if (root.pressed) { + if (root.checked) + return Qt.point(width * 0.4, height / 2); + return Qt.point(width * 0.2, height / 2); + } + if (root.checked) + return Qt.point(width * 0.4, height * 0.7); + return Qt.point(width * 0.15, height * 0.85); + } + property point end2: { + if (root.pressed) + return Qt.point(width * 0.8, height / 2); + if (root.checked) + return Qt.point(width * 0.85, height * 0.2); + return Qt.point(width * 0.85, height * 0.15); + } + + anchors.centerIn: parent + width: height + height: parent.implicitHeight - Appearance.padding.small * 2 + preferredRendererType: Shape.CurveRenderer + asynchronous: true + + ShapePath { + strokeWidth: Appearance.font.size.larger * 0.15 + strokeColor: root.checked ? Colours.palette.m3primary : Colours.palette.m3surfaceContainerHighest + fillColor: "transparent" + capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + + startX: icon.start1.x + startY: icon.start1.y + + PathLine { + x: icon.end1.x + y: icon.end1.y + } + PathMove { + x: icon.start2.x + y: icon.start2.y + } + PathLine { + x: icon.end2.x + y: icon.end2.y + } + + Behavior on strokeColor { + CAnim {} + } + } + + Behavior on start1 { + PropAnim {} + } + Behavior on end1 { + PropAnim {} + } + Behavior on start2 { + PropAnim {} + } + Behavior on end2 { + PropAnim {} + } + } + + Behavior on x { + Anim {} + } + + Behavior on implicitWidth { + Anim {} + } + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + enabled: false + } + + component PropAnim: PropertyAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standard + } +} diff --git a/components/controls/StyledTextField.qml b/components/controls/StyledTextField.qml new file mode 100644 index 0000000..4db87e9 --- /dev/null +++ b/components/controls/StyledTextField.qml @@ -0,0 +1,76 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Controls + +TextField { + id: root + + color: Colours.palette.m3onSurface + placeholderTextColor: Colours.palette.m3outline + font.family: Appearance.font.family.sans + font.pointSize: Appearance.font.size.smaller + renderType: TextField.NativeRendering + cursorVisible: !readOnly + + background: null + + cursorDelegate: StyledRect { + id: cursor + + property bool disableBlink + + implicitWidth: 2 + color: Colours.palette.m3primary + radius: Appearance.rounding.normal + + Connections { + target: root + + function onCursorPositionChanged(): void { + if (root.activeFocus && root.cursorVisible) { + cursor.opacity = 1; + cursor.disableBlink = true; + enableBlink.restart(); + } + } + } + + Timer { + id: enableBlink + + interval: 100 + onTriggered: cursor.disableBlink = false + } + + Timer { + running: root.activeFocus && root.cursorVisible && !cursor.disableBlink + repeat: true + triggeredOnStart: true + interval: 500 + onTriggered: parent.opacity = parent.opacity === 1 ? 0 : 1 + } + + Binding { + when: !root.activeFocus || !root.cursorVisible + cursor.opacity: 0 + } + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.small + } + } + } + + Behavior on color { + CAnim {} + } + + Behavior on placeholderTextColor { + CAnim {} + } +} diff --git a/components/controls/TextButton.qml b/components/controls/TextButton.qml new file mode 100644 index 0000000..ef84185 --- /dev/null +++ b/components/controls/TextButton.qml @@ -0,0 +1,78 @@ +import ".." +import qs.services +import qs.config +import QtQuick + +StyledRect { + id: root + + enum Type { + Filled, + Tonal, + Text + } + + property alias text: label.text + property bool checked + property bool toggle + property real horizontalPadding: Appearance.padding.normal + property real verticalPadding: Appearance.padding.smaller + property alias font: label.font + property int type: TextButton.Filled + + property alias stateLayer: stateLayer + property alias label: label + + property bool internalChecked + property color activeColour: type === TextButton.Filled ? Colours.palette.m3primary : Colours.palette.m3secondary + property color inactiveColour: { + if (!toggle && type === TextButton.Filled) + return Colours.palette.m3primary; + return type === TextButton.Filled ? Colours.tPalette.m3surfaceContainer : Colours.palette.m3secondaryContainer; + } + property color activeOnColour: { + if (type === TextButton.Text) + return Colours.palette.m3primary; + return type === TextButton.Filled ? Colours.palette.m3onPrimary : Colours.palette.m3onSecondary; + } + property color inactiveOnColour: { + if (!toggle && type === TextButton.Filled) + return Colours.palette.m3onPrimary; + if (type === TextButton.Text) + return Colours.palette.m3primary; + return type === TextButton.Filled ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer; + } + + signal clicked + + onCheckedChanged: internalChecked = checked + + radius: internalChecked ? Appearance.rounding.small : implicitHeight / 2 + color: type === TextButton.Text ? "transparent" : internalChecked ? activeColour : inactiveColour + + implicitWidth: label.implicitWidth + horizontalPadding * 2 + implicitHeight: label.implicitHeight + verticalPadding * 2 + + StateLayer { + id: stateLayer + + color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour + + function onClicked(): void { + if (root.toggle) + root.internalChecked = !root.internalChecked; + root.clicked(); + } + } + + StyledText { + id: label + + anchors.centerIn: parent + color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour + } + + Behavior on radius { + Anim {} + } +} diff --git a/components/effects/ColouredIcon.qml b/components/effects/ColouredIcon.qml new file mode 100644 index 0000000..5ef4d4c --- /dev/null +++ b/components/effects/ColouredIcon.qml @@ -0,0 +1,35 @@ +pragma ComponentBehavior: Bound + +import Caelestia +import Quickshell.Widgets +import QtQuick + +IconImage { + id: root + + required property color colour + + asynchronous: true + + layer.enabled: true + layer.effect: Colouriser { + sourceColor: analyser.dominantColour + colorizationColor: root.colour + } + + layer.onEnabledChanged: { + if (layer.enabled && status === Image.Ready) + analyser.requestUpdate(); + } + + onStatusChanged: { + if (layer.enabled && status === Image.Ready) + analyser.requestUpdate(); + } + + ImageAnalyser { + id: analyser + + sourceItem: root + } +} diff --git a/components/effects/Colouriser.qml b/components/effects/Colouriser.qml new file mode 100644 index 0000000..2948155 --- /dev/null +++ b/components/effects/Colouriser.qml @@ -0,0 +1,14 @@ +import ".." +import QtQuick +import QtQuick.Effects + +MultiEffect { + property color sourceColor: "black" + + colorization: 1 + brightness: 1 - sourceColor.hslLightness + + Behavior on colorizationColor { + CAnim {} + } +} diff --git a/components/effects/Elevation.qml b/components/effects/Elevation.qml new file mode 100644 index 0000000..fb29f16 --- /dev/null +++ b/components/effects/Elevation.qml @@ -0,0 +1,18 @@ +import ".." +import qs.services +import QtQuick +import QtQuick.Effects + +RectangularShadow { + property int level + property real dp: [0, 1, 3, 6, 8, 12][level] + + color: Qt.alpha(Colours.palette.m3shadow, 0.7) + blur: (dp * 5) ** 0.7 + spread: -dp * 0.3 + (dp * 0.1) ** 2 + offset.y: dp / 2 + + Behavior on dp { + Anim {} + } +} diff --git a/components/effects/InnerBorder.qml b/components/effects/InnerBorder.qml new file mode 100644 index 0000000..d4a751f --- /dev/null +++ b/components/effects/InnerBorder.qml @@ -0,0 +1,44 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Effects + +StyledRect { + property alias innerRadius: maskInner.radius + property alias thickness: maskInner.anchors.margins + property alias leftThickness: maskInner.anchors.leftMargin + property alias topThickness: maskInner.anchors.topMargin + property alias rightThickness: maskInner.anchors.rightMargin + property alias bottomThickness: maskInner.anchors.bottomMargin + + anchors.fill: parent + color: Colours.tPalette.m3surfaceContainer + + layer.enabled: true + layer.effect: MultiEffect { + maskSource: mask + maskEnabled: true + maskInverted: true + maskThresholdMin: 0.5 + maskSpreadAtMin: 1 + } + + Item { + id: mask + + anchors.fill: parent + layer.enabled: true + visible: false + + Rectangle { + id: maskInner + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + radius: Appearance.rounding.small + } + } +} diff --git a/components/effects/OpacityMask.qml b/components/effects/OpacityMask.qml new file mode 100644 index 0000000..22e4249 --- /dev/null +++ b/components/effects/OpacityMask.qml @@ -0,0 +1,9 @@ +import Quickshell +import QtQuick + +ShaderEffect { + required property Item source + required property Item maskSource + + fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/opacitymask.frag.qsb`) +} diff --git a/components/filedialog/CurrentItem.qml b/components/filedialog/CurrentItem.qml new file mode 100644 index 0000000..bb87133 --- /dev/null +++ b/components/filedialog/CurrentItem.qml @@ -0,0 +1,102 @@ +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Shapes + +Item { + id: root + + required property var currentItem + + implicitWidth: content.implicitWidth + Appearance.padding.larger + content.anchors.rightMargin + implicitHeight: currentItem ? content.implicitHeight + Appearance.padding.normal + content.anchors.bottomMargin : 0 + + Shape { + preferredRendererType: Shape.CurveRenderer + + ShapePath { + id: path + + readonly property real rounding: Appearance.rounding.small + readonly property bool flatten: root.implicitHeight < rounding * 2 + readonly property real roundingY: flatten ? root.implicitHeight / 2 : rounding + + strokeWidth: -1 + fillColor: Colours.tPalette.m3surfaceContainer + + startX: root.implicitWidth + startY: root.implicitHeight + + PathLine { + relativeX: -(root.implicitWidth + path.rounding) + relativeY: 0 + } + PathArc { + relativeX: path.rounding + relativeY: -path.roundingY + radiusX: path.rounding + radiusY: Math.min(path.rounding, root.implicitHeight) + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: 0 + relativeY: -(root.implicitHeight - path.roundingY * 2) + } + PathArc { + relativeX: path.rounding + relativeY: -path.roundingY + radiusX: path.rounding + radiusY: Math.min(path.rounding, root.implicitHeight) + } + PathLine { + relativeX: root.implicitHeight > 0 ? root.implicitWidth - path.rounding * 2 : root.implicitWidth + relativeY: 0 + } + PathArc { + relativeX: path.rounding + relativeY: -path.rounding + radiusX: path.rounding + radiusY: path.rounding + direction: PathArc.Counterclockwise + } + + Behavior on fillColor { + CAnim {} + } + } + } + + Item { + anchors.fill: parent + clip: true + + StyledText { + id: content + + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: Appearance.padding.larger - Appearance.padding.small + anchors.bottomMargin: Appearance.padding.normal - Appearance.padding.small + + Connections { + target: root + + function onCurrentItemChanged(): void { + if (root.currentItem) + content.text = qsTr(`"%1" selected`).arg(root.currentItem.modelData.name); + } + } + } + } + + Behavior on implicitWidth { + enabled: !!root.currentItem + + Anim {} + } + + Behavior on implicitHeight { + Anim {} + } +} diff --git a/components/filedialog/DialogButtons.qml b/components/filedialog/DialogButtons.qml new file mode 100644 index 0000000..bde9ac2 --- /dev/null +++ b/components/filedialog/DialogButtons.qml @@ -0,0 +1,93 @@ +import ".." +import qs.services +import qs.config +import QtQuick.Layouts + +StyledRect { + id: root + + required property var dialog + required property FolderContents folder + + implicitHeight: inner.implicitHeight + Appearance.padding.normal * 2 + + color: Colours.tPalette.m3surfaceContainer + + RowLayout { + id: inner + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.small + + StyledText { + text: qsTr("Filter:") + } + + StyledRect { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.rightMargin: Appearance.spacing.normal + + color: Colours.tPalette.m3surfaceContainerHigh + radius: Appearance.rounding.small + + StyledText { + anchors.fill: parent + anchors.margins: Appearance.padding.normal + + text: `${root.dialog.filterLabel} (${root.dialog.filters.map(f => `*.${f}`).join(", ")})` + } + } + + StyledRect { + color: Colours.tPalette.m3surfaceContainerHigh + radius: Appearance.rounding.small + + implicitWidth: cancelText.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: cancelText.implicitHeight + Appearance.padding.normal * 2 + + StateLayer { + disabled: !root.dialog.selectionValid + + function onClicked(): void { + root.dialog.accepted(root.folder.currentItem.modelData.path); + } + } + + StyledText { + id: selectText + + anchors.centerIn: parent + anchors.margins: Appearance.padding.normal + + text: qsTr("Select") + color: root.dialog.selectionValid ? Colours.palette.m3onSurface : Colours.palette.m3outline + } + } + + StyledRect { + color: Colours.tPalette.m3surfaceContainerHigh + radius: Appearance.rounding.small + + implicitWidth: cancelText.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: cancelText.implicitHeight + Appearance.padding.normal * 2 + + StateLayer { + function onClicked(): void { + root.dialog.rejected(); + } + } + + StyledText { + id: cancelText + + anchors.centerIn: parent + anchors.margins: Appearance.padding.normal + + text: qsTr("Cancel") + } + } + } +} diff --git a/components/filedialog/FileDialog.qml b/components/filedialog/FileDialog.qml new file mode 100644 index 0000000..f3187a5 --- /dev/null +++ b/components/filedialog/FileDialog.qml @@ -0,0 +1,102 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.services +import Quickshell +import QtQuick +import QtQuick.Layouts + +LazyLoader { + id: loader + + property list cwd: ["Home"] + property string filterLabel: "All files" + property list filters: ["*"] + property string title: qsTr("Select a file") + + signal accepted(path: string) + signal rejected + + function open(): void { + activeAsync = true; + } + + function close(): void { + rejected(); + } + + onAccepted: activeAsync = false + onRejected: activeAsync = false + + FloatingWindow { + id: root + + property list cwd: loader.cwd + property string filterLabel: loader.filterLabel + property list filters: loader.filters + + readonly property bool selectionValid: { + const file = folderContents.currentItem?.modelData; + return (file && !file.isDir && (filters.includes("*") || filters.includes(file.suffix))) ?? false; + } + + function accepted(path: string): void { + loader.accepted(path); + } + + function rejected(): void { + loader.rejected(); + } + + implicitWidth: 1000 + implicitHeight: 600 + color: Colours.tPalette.m3surface + title: loader.title + + onVisibleChanged: { + if (!visible) + rejected(); + } + + RowLayout { + anchors.fill: parent + + spacing: 0 + + Sidebar { + Layout.fillHeight: true + dialog: root + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + + spacing: 0 + + HeaderBar { + Layout.fillWidth: true + dialog: root + } + + FolderContents { + id: folderContents + + Layout.fillWidth: true + Layout.fillHeight: true + dialog: root + } + + DialogButtons { + Layout.fillWidth: true + dialog: root + folder: folderContents + } + } + } + + Behavior on color { + CAnim {} + } + } +} diff --git a/components/filedialog/FolderContents.qml b/components/filedialog/FolderContents.qml new file mode 100644 index 0000000..c3b371b --- /dev/null +++ b/components/filedialog/FolderContents.qml @@ -0,0 +1,229 @@ +pragma ComponentBehavior: Bound + +import ".." +import "../controls" +import "../images" +import qs.services +import qs.config +import qs.utils +import Caelestia.Models +import Quickshell +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects + +Item { + id: root + + required property var dialog + property alias currentItem: view.currentItem + + StyledRect { + anchors.fill: parent + color: Colours.tPalette.m3surfaceContainer + + layer.enabled: true + layer.effect: MultiEffect { + maskSource: mask + maskEnabled: true + maskInverted: true + maskThresholdMin: 0.5 + maskSpreadAtMin: 1 + } + } + + Item { + id: mask + + anchors.fill: parent + layer.enabled: true + visible: false + + Rectangle { + anchors.fill: parent + anchors.margins: Appearance.padding.small + radius: Appearance.rounding.small + } + } + + Loader { + anchors.centerIn: parent + + opacity: view.count === 0 ? 1 : 0 + active: opacity > 0 + asynchronous: true + + sourceComponent: ColumnLayout { + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "scan_delete" + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.extraLarge * 2 + font.weight: 500 + } + + StyledText { + text: qsTr("This folder is empty") + color: Colours.palette.m3outline + font.pointSize: Appearance.font.size.large + font.weight: 500 + } + } + + Behavior on opacity { + Anim {} + } + } + + GridView { + id: view + + anchors.fill: parent + anchors.margins: Appearance.padding.small + Appearance.padding.normal + + cellWidth: Sizes.itemWidth + Appearance.spacing.small + cellHeight: Sizes.itemWidth + Appearance.spacing.small * 2 + Appearance.padding.normal * 2 + 1 + + clip: true + focus: true + currentIndex: -1 + Keys.onEscapePressed: currentIndex = -1 + + Keys.onReturnPressed: { + if (root.dialog.selectionValid) + root.dialog.accepted(currentItem.modelData.path); + } + Keys.onEnterPressed: { + if (root.dialog.selectionValid) + root.dialog.accepted(currentItem.modelData.path); + } + + StyledScrollBar.vertical: StyledScrollBar { + flickable: view + } + + model: FileSystemModel { + path: { + if (root.dialog.cwd[0] === "Home") + return `${Paths.home}/${root.dialog.cwd.slice(1).join("/")}`; + else + return root.dialog.cwd.join("/"); + } + onPathChanged: view.currentIndex = -1 + } + + delegate: StyledRect { + id: item + + required property int index + required property FileSystemEntry modelData + + readonly property real nonAnimHeight: icon.implicitHeight + name.anchors.topMargin + name.implicitHeight + Appearance.padding.normal * 2 + + implicitWidth: Sizes.itemWidth + implicitHeight: nonAnimHeight + + radius: Appearance.rounding.normal + color: Qt.alpha(Colours.tPalette.m3surfaceContainerHighest, GridView.isCurrentItem ? Colours.tPalette.m3surfaceContainerHighest.a : 0) + z: GridView.isCurrentItem || implicitHeight !== nonAnimHeight ? 1 : 0 + clip: true + + StateLayer { + onDoubleClicked: { + if (item.modelData.isDir) + root.dialog.cwd.push(item.modelData.name); + else if (root.dialog.selectionValid) + root.dialog.accepted(item.modelData.path); + } + + function onClicked(): void { + view.currentIndex = item.index; + } + } + + CachingIconImage { + id: icon + + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: Appearance.padding.normal + + implicitSize: Sizes.itemWidth - Appearance.padding.normal * 2 + + Component.onCompleted: { + const file = item.modelData; + if (file.isImage) + source = Qt.resolvedUrl(file.path); + else if (!file.isDir) + source = Quickshell.iconPath(file.mimeType.replace("/", "-"), "application-x-zerosize"); + else if (root.dialog.cwd.length === 1 && ["Desktop", "Documents", "Downloads", "Music", "Pictures", "Public", "Templates", "Videos"].includes(file.name)) + source = Quickshell.iconPath(`folder-${file.name.toLowerCase()}`); + else + source = Quickshell.iconPath("inode-directory"); + } + } + + StyledText { + id: name + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: icon.bottom + anchors.topMargin: Appearance.spacing.small + anchors.margins: Appearance.padding.normal + + horizontalAlignment: Text.AlignHCenter + elide: item.GridView.isCurrentItem ? Text.ElideNone : Text.ElideRight + wrapMode: item.GridView.isCurrentItem ? Text.WrapAtWordBoundaryOrAnywhere : Text.NoWrap + + Component.onCompleted: text = item.modelData.name + } + + Behavior on implicitHeight { + Anim {} + } + } + + add: Transition { + Anim { + properties: "opacity,scale" + from: 0 + to: 1 + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + remove: Transition { + Anim { + property: "opacity" + to: 0 + } + Anim { + property: "scale" + to: 0.5 + } + } + + displaced: Transition { + Anim { + properties: "opacity,scale" + to: 1 + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + Anim { + properties: "x,y" + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } + + CurrentItem { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Appearance.padding.small + + currentItem: view.currentItem + } +} diff --git a/components/filedialog/HeaderBar.qml b/components/filedialog/HeaderBar.qml new file mode 100644 index 0000000..b6e5dba --- /dev/null +++ b/components/filedialog/HeaderBar.qml @@ -0,0 +1,142 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property var dialog + + implicitWidth: inner.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: inner.implicitHeight + Appearance.padding.normal * 2 + + color: Colours.tPalette.m3surfaceContainer + + RowLayout { + id: inner + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + spacing: Appearance.spacing.small + + Item { + implicitWidth: implicitHeight + implicitHeight: upIcon.implicitHeight + Appearance.padding.small * 2 + + StateLayer { + radius: Appearance.rounding.small + disabled: root.dialog.cwd.length === 1 + + function onClicked(): void { + root.dialog.cwd.pop(); + } + } + + MaterialIcon { + id: upIcon + + anchors.centerIn: parent + text: "drive_folder_upload" + color: root.dialog.cwd.length === 1 ? Colours.palette.m3outline : Colours.palette.m3onSurface + grade: 200 + } + } + + StyledRect { + Layout.fillWidth: true + + radius: Appearance.rounding.small + color: Colours.tPalette.m3surfaceContainerHigh + + implicitHeight: pathComponents.implicitHeight + pathComponents.anchors.margins * 2 + + RowLayout { + id: pathComponents + + anchors.fill: parent + anchors.margins: Appearance.padding.small / 2 + anchors.leftMargin: 0 + + spacing: Appearance.spacing.small + + Repeater { + model: root.dialog.cwd + + RowLayout { + id: folder + + required property string modelData + required property int index + + spacing: 0 + + Loader { + Layout.rightMargin: Appearance.spacing.small + active: folder.index > 0 + asynchronous: true + sourceComponent: StyledText { + text: "/" + color: Colours.palette.m3onSurfaceVariant + font.bold: true + } + } + + Item { + implicitWidth: homeIcon.implicitWidth + (homeIcon.active ? Appearance.padding.small : 0) + folderName.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: folderName.implicitHeight + Appearance.padding.small * 2 + + Loader { + anchors.fill: parent + active: folder.index < root.dialog.cwd.length - 1 + asynchronous: true + sourceComponent: StateLayer { + radius: Appearance.rounding.small + + function onClicked(): void { + root.dialog.cwd = root.dialog.cwd.slice(0, folder.index + 1); + } + } + } + + Loader { + id: homeIcon + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Appearance.padding.normal + + active: folder.index === 0 && folder.modelData === "Home" + asynchronous: true + sourceComponent: MaterialIcon { + text: "home" + color: root.dialog.cwd.length === 1 ? Colours.palette.m3onSurface : Colours.palette.m3onSurfaceVariant + fill: 1 + } + } + + StyledText { + id: folderName + + anchors.left: homeIcon.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: homeIcon.active ? Appearance.padding.small : 0 + + text: folder.modelData + color: folder.index < root.dialog.cwd.length - 1 ? Colours.palette.m3onSurfaceVariant : Colours.palette.m3onSurface + font.bold: true + } + } + } + } + + Item { + Layout.fillWidth: true + } + } + } + } +} diff --git a/components/filedialog/Sidebar.qml b/components/filedialog/Sidebar.qml new file mode 100644 index 0000000..b55d7b3 --- /dev/null +++ b/components/filedialog/Sidebar.qml @@ -0,0 +1,113 @@ +pragma ComponentBehavior: Bound + +import ".." +import qs.services +import qs.config +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property var dialog + + implicitWidth: Sizes.sidebarWidth + implicitHeight: inner.implicitHeight + Appearance.padding.normal * 2 + + color: Colours.tPalette.m3surfaceContainer + + ColumnLayout { + id: inner + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Appearance.padding.normal + spacing: Appearance.spacing.small / 2 + + StyledText { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Appearance.padding.small / 2 + Layout.bottomMargin: Appearance.spacing.normal + text: qsTr("Files") + color: Colours.palette.m3onSurface + font.pointSize: Appearance.font.size.larger + font.bold: true + } + + Repeater { + model: ["Home", "Downloads", "Desktop", "Documents", "Music", "Pictures", "Videos"] + + StyledRect { + id: place + + required property string modelData + readonly property bool selected: modelData === root.dialog.cwd[root.dialog.cwd.length - 1] + + Layout.fillWidth: true + implicitHeight: placeInner.implicitHeight + Appearance.padding.normal * 2 + + radius: Appearance.rounding.full + color: Qt.alpha(Colours.palette.m3secondaryContainer, selected ? 1 : 0) + + StateLayer { + color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + + function onClicked(): void { + if (place.modelData === "Home") + root.dialog.cwd = ["Home"]; + else + root.dialog.cwd = ["Home", place.modelData]; + } + } + + RowLayout { + id: placeInner + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + anchors.leftMargin: Appearance.padding.large + anchors.rightMargin: Appearance.padding.large + + spacing: Appearance.spacing.normal + + MaterialIcon { + text: { + const p = place.modelData; + if (p === "Home") + return "home"; + if (p === "Downloads") + return "file_download"; + if (p === "Desktop") + return "desktop_windows"; + if (p === "Documents") + return "description"; + if (p === "Music") + return "music_note"; + if (p === "Pictures") + return "image"; + if (p === "Videos") + return "video_library"; + return "folder"; + } + color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + font.pointSize: Appearance.font.size.large + fill: place.selected ? 1 : 0 + + Behavior on fill { + Anim {} + } + } + + StyledText { + Layout.fillWidth: true + text: place.modelData + color: place.selected ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + font.pointSize: Appearance.font.size.normal + elide: Text.ElideRight + } + } + } + } + } +} diff --git a/components/filedialog/Sizes.qml b/components/filedialog/Sizes.qml new file mode 100644 index 0000000..2ad31f9 --- /dev/null +++ b/components/filedialog/Sizes.qml @@ -0,0 +1,8 @@ +pragma Singleton + +import Quickshell + +Singleton { + property int itemWidth: 103 + property int sidebarWidth: 200 +} diff --git a/components/images/CachingIconImage.qml b/components/images/CachingIconImage.qml new file mode 100644 index 0000000..1acc6a1 --- /dev/null +++ b/components/images/CachingIconImage.qml @@ -0,0 +1,42 @@ +pragma ComponentBehavior: Bound + +import qs.utils +import Quickshell.Widgets +import QtQuick + +Item { + id: root + + readonly property int status: loader.item?.status ?? Image.Null + readonly property real actualSize: Math.min(width, height) + property real implicitSize + property url source + + implicitWidth: implicitSize + implicitHeight: implicitSize + + Loader { + id: loader + + anchors.fill: parent + sourceComponent: root.source ? root.source.toString().startsWith("image://icon/") ? iconImage : cachingImage : null + } + + Component { + id: cachingImage + + CachingImage { + path: Paths.toLocalFile(root.source) + fillMode: Image.PreserveAspectFit + } + } + + Component { + id: iconImage + + IconImage { + source: root.source + asynchronous: true + } + } +} diff --git a/components/images/CachingImage.qml b/components/images/CachingImage.qml new file mode 100644 index 0000000..e8f957a --- /dev/null +++ b/components/images/CachingImage.qml @@ -0,0 +1,28 @@ +import qs.utils +import Caelestia.Internal +import Quickshell +import QtQuick + +Image { + id: root + + property alias path: manager.path + + asynchronous: true + fillMode: Image.PreserveAspectCrop + + Connections { + target: QsWindow.window + + function onDevicePixelRatioChanged(): void { + manager.updateSource(); + } + } + + CachingImageManager { + id: manager + + item: root + cacheDir: Qt.resolvedUrl(Paths.imagecache) + } +} diff --git a/components/misc/CustomShortcut.qml b/components/misc/CustomShortcut.qml new file mode 100644 index 0000000..aa35ed8 --- /dev/null +++ b/components/misc/CustomShortcut.qml @@ -0,0 +1,5 @@ +import Quickshell.Hyprland + +GlobalShortcut { + appid: "caelestia" +} diff --git a/components/misc/Ref.qml b/components/misc/Ref.qml new file mode 100644 index 0000000..0a694a4 --- /dev/null +++ b/components/misc/Ref.qml @@ -0,0 +1,8 @@ +import QtQuick + +QtObject { + required property var service + + Component.onCompleted: service.refCount++ + Component.onDestruction: service.refCount-- +} diff --git a/components/widgets/ExtraIndicator.qml b/components/widgets/ExtraIndicator.qml new file mode 100644 index 0000000..db73ea0 --- /dev/null +++ b/components/widgets/ExtraIndicator.qml @@ -0,0 +1,51 @@ +import ".." +import "../effects" +import qs.services +import qs.config +import QtQuick + +StyledRect { + required property int extra + + anchors.right: parent.right + anchors.margins: Appearance.padding.normal + + color: Colours.palette.m3tertiary + radius: Appearance.rounding.small + + implicitWidth: count.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: count.implicitHeight + Appearance.padding.small * 2 + + opacity: extra > 0 ? 1 : 0 + scale: extra > 0 ? 1 : 0.5 + + Elevation { + anchors.fill: parent + radius: parent.radius + opacity: parent.opacity + z: -1 + level: 2 + } + + StyledText { + id: count + + anchors.centerIn: parent + animate: parent.opacity > 0 + text: qsTr("+%1").arg(parent.extra) + color: Colours.palette.m3onTertiary + } + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + } + } + + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } +} diff --git a/config/Appearance.qml b/config/Appearance.qml new file mode 100644 index 0000000..241c21a --- /dev/null +++ b/config/Appearance.qml @@ -0,0 +1,14 @@ +pragma Singleton + +import Quickshell + +Singleton { + // Literally just here to shorten accessing stuff :woe: + // Also kinda so I can keep accessing it with `Appearance.xxx` instead of `Config.appearance.xxx` + readonly property AppearanceConfig.Rounding rounding: Config.appearance.rounding + readonly property AppearanceConfig.Spacing spacing: Config.appearance.spacing + readonly property AppearanceConfig.Padding padding: Config.appearance.padding + readonly property AppearanceConfig.FontStuff font: Config.appearance.font + readonly property AppearanceConfig.Anim anim: Config.appearance.anim + readonly property AppearanceConfig.Transparency transparency: Config.appearance.transparency +} diff --git a/config/AppearanceConfig.qml b/config/AppearanceConfig.qml new file mode 100644 index 0000000..b25945b --- /dev/null +++ b/config/AppearanceConfig.qml @@ -0,0 +1,92 @@ +import Quickshell.Io + +JsonObject { + property Rounding rounding: Rounding {} + property Spacing spacing: Spacing {} + property Padding padding: Padding {} + property FontStuff font: FontStuff {} + property Anim anim: Anim {} + property Transparency transparency: Transparency {} + + component Rounding: JsonObject { + property real scale: 1 + property int small: 12 * scale + property int normal: 17 * scale + property int large: 25 * scale + property int full: 1000 * scale + } + + component Spacing: JsonObject { + property real scale: 1 + property int small: 7 * scale + property int smaller: 10 * scale + property int normal: 12 * scale + property int larger: 15 * scale + property int large: 20 * scale + } + + component Padding: JsonObject { + property real scale: 1 + property int small: 5 * scale + property int smaller: 7 * scale + property int normal: 10 * scale + property int larger: 12 * scale + property int large: 15 * scale + } + + component FontFamily: JsonObject { + property string sans: "Rubik" + property string mono: "CaskaydiaCove NF" + property string material: "Material Symbols Rounded" + property string clock: "Rubik" + } + + component FontSize: JsonObject { + property real scale: 1 + property int small: 11 * scale + property int smaller: 12 * scale + property int normal: 13 * scale + property int larger: 15 * scale + property int large: 18 * scale + property int extraLarge: 28 * scale + } + + component FontStuff: JsonObject { + property FontFamily family: FontFamily {} + property FontSize size: FontSize {} + } + + component AnimCurves: JsonObject { + property list emphasized: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1] + property list emphasizedAccel: [0.3, 0, 0.8, 0.15, 1, 1] + property list emphasizedDecel: [0.05, 0.7, 0.1, 1, 1, 1] + property list standard: [0.2, 0, 0, 1, 1, 1] + property list standardAccel: [0.3, 0, 1, 1, 1, 1] + property list standardDecel: [0, 0, 0, 1, 1, 1] + property list expressiveFastSpatial: [0.42, 1.67, 0.21, 0.9, 1, 1] + property list expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1] + property list expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1] + } + + component AnimDurations: JsonObject { + property real scale: 1 + property int small: 200 * scale + property int normal: 400 * scale + property int large: 600 * scale + property int extraLarge: 1000 * scale + property int expressiveFastSpatial: 350 * scale + property int expressiveDefaultSpatial: 500 * scale + property int expressiveEffects: 200 * scale + } + + component Anim: JsonObject { + property AnimCurves curves: AnimCurves {} + property AnimDurations durations: AnimDurations {} + } + + component Transparency: JsonObject { + property bool enabled: false + property real base: 0.85 + property real layers: 0.4 + } +} diff --git a/config/BackgroundConfig.qml b/config/BackgroundConfig.qml new file mode 100644 index 0000000..af46053 --- /dev/null +++ b/config/BackgroundConfig.qml @@ -0,0 +1,18 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property DesktopClock desktopClock: DesktopClock {} + property Visualiser visualiser: Visualiser {} + + component DesktopClock: JsonObject { + property bool enabled: false + } + + component Visualiser: JsonObject { + property bool enabled: false + property bool autoHide: true + property real rounding: 1 + property real spacing: 1 + } +} diff --git a/config/BarConfig.qml b/config/BarConfig.qml new file mode 100644 index 0000000..86c2a40 --- /dev/null +++ b/config/BarConfig.qml @@ -0,0 +1,102 @@ +import Quickshell.Io + +JsonObject { + property bool persistent: true + property bool showOnHover: true + property int dragThreshold: 20 + property ScrollActions scrollActions: ScrollActions {} + property Workspaces workspaces: Workspaces {} + property Tray tray: Tray {} + property Status status: Status {} + property Clock clock: Clock {} + property Sizes sizes: Sizes {} + + property list entries: [ + { + id: "logo", + enabled: true + }, + { + id: "workspaces", + enabled: true + }, + { + id: "spacer", + enabled: true + }, + { + id: "activeWindow", + enabled: true + }, + { + id: "spacer", + enabled: true + }, + { + id: "tray", + enabled: true + }, + { + id: "clock", + enabled: true + }, + { + id: "statusIcons", + enabled: true + }, + { + id: "power", + enabled: true + } + ] + + component ScrollActions: JsonObject { + property bool workspaces: true + property bool volume: true + property bool brightness: true + } + + component Workspaces: JsonObject { + property int shown: 5 + property bool activeIndicator: true + property bool occupiedBg: false + property bool showWindows: true + property bool showWindowsOnSpecialWorkspaces: showWindows + property bool activeTrail: false + property bool perMonitorWorkspaces: true + property string label: " " // if empty, will show workspace name's first letter + property string occupiedLabel: "󰮯" + property string activeLabel: "󰮯" + property string capitalisation: "preserve" // upper, lower, or preserve - relevant only if label is empty + property list specialWorkspaceIcons: [] + } + + component Tray: JsonObject { + property bool background: false + property bool recolour: false + property bool compact: false + property list iconSubs: [] + } + + component Status: JsonObject { + property bool showAudio: false + property bool showMicrophone: false + property bool showKbLayout: false + property bool showNetwork: true + property bool showBluetooth: true + property bool showBattery: true + property bool showLockStatus: true + } + + component Clock: JsonObject { + property bool showIcon: true + } + + component Sizes: JsonObject { + property int innerWidth: 40 + property int windowPreviewSize: 400 + property int trayMenuWidth: 300 + property int batteryWidth: 250 + property int networkWidth: 320 + } +} diff --git a/config/BorderConfig.qml b/config/BorderConfig.qml new file mode 100644 index 0000000..b15811f --- /dev/null +++ b/config/BorderConfig.qml @@ -0,0 +1,6 @@ +import Quickshell.Io + +JsonObject { + property int thickness: Appearance.padding.normal + property int rounding: Appearance.rounding.large +} diff --git a/config/Config.qml b/config/Config.qml new file mode 100644 index 0000000..818b04a --- /dev/null +++ b/config/Config.qml @@ -0,0 +1,76 @@ +pragma Singleton + +import qs.utils +import Quickshell +import Quickshell.Io + +Singleton { + id: root + + property alias appearance: adapter.appearance + property alias general: adapter.general + property alias background: adapter.background + property alias bar: adapter.bar + property alias border: adapter.border + property alias dashboard: adapter.dashboard + property alias controlCenter: adapter.controlCenter + property alias launcher: adapter.launcher + property alias notifs: adapter.notifs + property alias osd: adapter.osd + property alias session: adapter.session + property alias winfo: adapter.winfo + property alias lock: adapter.lock + property alias utilities: adapter.utilities + property alias sidebar: adapter.sidebar + property alias services: adapter.services + property alias paths: adapter.paths + + ElapsedTimer { + id: timer + } + + FileView { + path: `${Paths.config}/shell.json` + watchChanges: true + onFileChanged: { + timer.restart(); + reload(); + } + onLoaded: { + try { + JSON.parse(text()); + if (adapter.utilities.toasts.configLoaded) + Toaster.toast(qsTr("Config loaded"), qsTr("Config loaded in %1ms").arg(timer.elapsedMs()), "rule_settings"); + } catch (e) { + Toaster.toast(qsTr("Failed to load config"), e.message, "settings_alert", Toast.Error); + } + } + onLoadFailed: err => { + if (err !== FileViewError.FileNotFound) + Toaster.toast(qsTr("Failed to read config file"), FileViewError.toString(err), "settings_alert", Toast.Warning); + } + onSaveFailed: err => Toaster.toast(qsTr("Failed to save config"), FileViewError.toString(err), "settings_alert", Toast.Error) + + JsonAdapter { + id: adapter + + property AppearanceConfig appearance: AppearanceConfig {} + property GeneralConfig general: GeneralConfig {} + property BackgroundConfig background: BackgroundConfig {} + property BarConfig bar: BarConfig {} + property BorderConfig border: BorderConfig {} + property DashboardConfig dashboard: DashboardConfig {} + property ControlCenterConfig controlCenter: ControlCenterConfig {} + property LauncherConfig launcher: LauncherConfig {} + property NotifsConfig notifs: NotifsConfig {} + property OsdConfig osd: OsdConfig {} + property SessionConfig session: SessionConfig {} + property WInfoConfig winfo: WInfoConfig {} + property LockConfig lock: LockConfig {} + property UtilitiesConfig utilities: UtilitiesConfig {} + property SidebarConfig sidebar: SidebarConfig {} + property ServiceConfig services: ServiceConfig {} + property UserPaths paths: UserPaths {} + } + } +} diff --git a/config/ControlCenterConfig.qml b/config/ControlCenterConfig.qml new file mode 100644 index 0000000..a588949 --- /dev/null +++ b/config/ControlCenterConfig.qml @@ -0,0 +1,10 @@ +import Quickshell.Io + +JsonObject { + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + property real heightMult: 0.7 + property real ratio: 16 / 9 + } +} diff --git a/config/DashboardConfig.qml b/config/DashboardConfig.qml new file mode 100644 index 0000000..030292b --- /dev/null +++ b/config/DashboardConfig.qml @@ -0,0 +1,25 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property bool showOnHover: true + property int mediaUpdateInterval: 500 + property int dragThreshold: 50 + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + readonly property int tabIndicatorHeight: 3 + readonly property int tabIndicatorSpacing: 5 + readonly property int infoWidth: 200 + readonly property int infoIconSize: 25 + readonly property int dateTimeWidth: 110 + readonly property int mediaWidth: 200 + readonly property int mediaProgressSweep: 180 + readonly property int mediaProgressThickness: 8 + readonly property int resourceProgessThickness: 10 + readonly property int weatherWidth: 250 + readonly property int mediaCoverArtSize: 150 + readonly property int mediaVisualiserSize: 80 + readonly property int resourceSize: 200 + } +} diff --git a/config/GeneralConfig.qml b/config/GeneralConfig.qml new file mode 100644 index 0000000..eecca01 --- /dev/null +++ b/config/GeneralConfig.qml @@ -0,0 +1,59 @@ +import Quickshell.Io + +JsonObject { + property Apps apps: Apps {} + property Idle idle: Idle {} + property Battery battery: Battery {} + + component Apps: JsonObject { + property list terminal: ["foot"] + property list audio: ["pavucontrol"] + property list playback: ["mpv"] + property list explorer: ["thunar"] + } + + component Idle: JsonObject { + property bool lockBeforeSleep: true + property bool inhibitWhenAudio: true + property list timeouts: [ + { + timeout: 180, + idleAction: "lock" + }, + { + timeout: 300, + idleAction: "dpms off", + returnAction: "dpms on" + }, + { + timeout: 600, + idleAction: ["systemctl", "suspend-then-hibernate"] + } + ] + } + + component Battery: JsonObject { + property list warnLevels: [ + { + level: 20, + title: qsTr("Low battery"), + message: qsTr("You might want to plug in a charger"), + icon: "battery_android_frame_2" + }, + { + level: 10, + title: qsTr("Did you see the previous message?"), + message: qsTr("You should probably plug in a charger now"), + icon: "battery_android_frame_1" + }, + { + level: 5, + title: qsTr("Critical battery level"), + message: qsTr("PLUG THE CHARGER RIGHT NOW!!"), + icon: "battery_android_alert", + critical: true + }, + ] + property int criticalLevel: 3 + } +} diff --git a/config/LauncherConfig.qml b/config/LauncherConfig.qml new file mode 100644 index 0000000..9d9c50c --- /dev/null +++ b/config/LauncherConfig.qml @@ -0,0 +1,138 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property bool showOnHover: false + property int maxShown: 7 + property int maxWallpapers: 9 // Warning: even numbers look bad + property string specialPrefix: "@" + property string actionPrefix: ">" + property bool enableDangerousActions: false // Allow actions that can cause losing data, like shutdown, reboot and logout + property int dragThreshold: 50 + property bool vimKeybinds: false + property list hiddenApps: [] + property UseFuzzy useFuzzy: UseFuzzy {} + property Sizes sizes: Sizes {} + + component UseFuzzy: JsonObject { + property bool apps: false + property bool actions: false + property bool schemes: false + property bool variants: false + property bool wallpapers: false + } + + component Sizes: JsonObject { + property int itemWidth: 600 + property int itemHeight: 57 + property int wallpaperWidth: 280 + property int wallpaperHeight: 200 + } + + property list actions: [ + { + name: "Calculator", + icon: "calculate", + description: "Do simple math equations (powered by Qalc)", + command: ["autocomplete", "calc"], + enabled: true, + dangerous: false + }, + { + name: "Scheme", + icon: "palette", + description: "Change the current colour scheme", + command: ["autocomplete", "scheme"], + enabled: true, + dangerous: false + }, + { + name: "Wallpaper", + icon: "image", + description: "Change the current wallpaper", + command: ["autocomplete", "wallpaper"], + enabled: true, + dangerous: false + }, + { + name: "Variant", + icon: "colors", + description: "Change the current scheme variant", + command: ["autocomplete", "variant"], + enabled: true, + dangerous: false + }, + { + name: "Transparency", + icon: "opacity", + description: "Change shell transparency", + command: ["autocomplete", "transparency"], + enabled: false, + dangerous: false + }, + { + name: "Random", + icon: "casino", + description: "Switch to a random wallpaper", + command: ["caelestia", "wallpaper", "-r"], + enabled: true, + dangerous: false + }, + { + name: "Light", + icon: "light_mode", + description: "Change the scheme to light mode", + command: ["setMode", "light"], + enabled: true, + dangerous: false + }, + { + name: "Dark", + icon: "dark_mode", + description: "Change the scheme to dark mode", + command: ["setMode", "dark"], + enabled: true, + dangerous: false + }, + { + name: "Shutdown", + icon: "power_settings_new", + description: "Shutdown the system", + command: ["systemctl", "poweroff"], + enabled: true, + dangerous: true + }, + { + name: "Reboot", + icon: "cached", + description: "Reboot the system", + command: ["systemctl", "reboot"], + enabled: true, + dangerous: true + }, + { + name: "Logout", + icon: "exit_to_app", + description: "Log out of the current session", + command: ["loginctl", "terminate-user", ""], + enabled: true, + dangerous: true + }, + { + name: "Lock", + icon: "lock", + description: "Lock the current session", + command: ["loginctl", "lock-session"], + enabled: true, + dangerous: false + }, + { + name: "Sleep", + icon: "bedtime", + description: "Suspend then hibernate", + command: ["systemctl", "suspend-then-hibernate"], + enabled: true, + dangerous: false + } + ] +} diff --git a/config/LockConfig.qml b/config/LockConfig.qml new file mode 100644 index 0000000..2af4e2c --- /dev/null +++ b/config/LockConfig.qml @@ -0,0 +1,14 @@ +import Quickshell.Io + +JsonObject { + property bool recolourLogo: false + property bool enableFprint: true + property int maxFprintTries: 3 + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + property real heightMult: 0.7 + property real ratio: 16 / 9 + property int centerWidth: 600 + } +} diff --git a/config/NotifsConfig.qml b/config/NotifsConfig.qml new file mode 100644 index 0000000..25d8680 --- /dev/null +++ b/config/NotifsConfig.qml @@ -0,0 +1,17 @@ +import Quickshell.Io + +JsonObject { + property bool expire: true + property int defaultExpireTimeout: 5000 + property real clearThreshold: 0.3 + property int expandThreshold: 20 + property bool actionOnClick: false + property int groupPreviewNum: 3 + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + property int width: 400 + property int image: 41 + property int badge: 20 + } +} diff --git a/config/OsdConfig.qml b/config/OsdConfig.qml new file mode 100644 index 0000000..543fc41 --- /dev/null +++ b/config/OsdConfig.qml @@ -0,0 +1,14 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property int hideDelay: 2000 + property bool enableBrightness: true + property bool enableMicrophone: false + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + property int sliderWidth: 30 + property int sliderHeight: 150 + } +} diff --git a/config/ServiceConfig.qml b/config/ServiceConfig.qml new file mode 100644 index 0000000..36a51aa --- /dev/null +++ b/config/ServiceConfig.qml @@ -0,0 +1,20 @@ +import Quickshell.Io +import QtQuick + +JsonObject { + property string weatherLocation: "" // A lat,long pair or empty for autodetection, e.g. "37.8267,-122.4233" + property bool useFahrenheit: [Locale.ImperialUSSystem, Locale.ImperialSystem].includes(Qt.locale().measurementSystem) + property bool useTwelveHourClock: Qt.locale().timeFormat(Locale.ShortFormat).toLowerCase().includes("a") + property string gpuType: "" + property int visualiserBars: 45 + property real audioIncrement: 0.1 + property real maxVolume: 1.0 + property bool smartScheme: true + property string defaultPlayer: "Spotify" + property list playerAliases: [ + { + "from": "com.github.th_ch.youtube_music", + "to": "YT Music" + } + ] +} diff --git a/config/SessionConfig.qml b/config/SessionConfig.qml new file mode 100644 index 0000000..f65ec6d --- /dev/null +++ b/config/SessionConfig.qml @@ -0,0 +1,21 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property int dragThreshold: 30 + property bool vimKeybinds: false + property Commands commands: Commands {} + + property Sizes sizes: Sizes {} + + component Commands: JsonObject { + property list logout: ["loginctl", "terminate-user", ""] + property list shutdown: ["systemctl", "poweroff"] + property list hibernate: ["systemctl", "hibernate"] + property list reboot: ["systemctl", "reboot"] + } + + component Sizes: JsonObject { + property int button: 80 + } +} diff --git a/config/SidebarConfig.qml b/config/SidebarConfig.qml new file mode 100644 index 0000000..a871562 --- /dev/null +++ b/config/SidebarConfig.qml @@ -0,0 +1,11 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property int dragThreshold: 80 + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + property int width: 430 + } +} diff --git a/config/UserPaths.qml b/config/UserPaths.qml new file mode 100644 index 0000000..f8de267 --- /dev/null +++ b/config/UserPaths.qml @@ -0,0 +1,8 @@ +import qs.utils +import Quickshell.Io + +JsonObject { + property string wallpaperDir: `${Paths.pictures}/Wallpapers` + property string sessionGif: "root:/assets/kurukuru.gif" + property string mediaGif: "root:/assets/bongocat.gif" +} diff --git a/config/UtilitiesConfig.qml b/config/UtilitiesConfig.qml new file mode 100644 index 0000000..5779d88 --- /dev/null +++ b/config/UtilitiesConfig.qml @@ -0,0 +1,34 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property int maxToasts: 4 + + property Sizes sizes: Sizes {} + property Toasts toasts: Toasts {} + property Vpn vpn: Vpn {} + + component Sizes: JsonObject { + property int width: 430 + property int toastWidth: 430 + } + + component Toasts: JsonObject { + property bool configLoaded: true + property bool chargingChanged: true + property bool gameModeChanged: true + property bool dndChanged: true + property bool audioOutputChanged: true + property bool audioInputChanged: true + property bool capsLockChanged: true + property bool numLockChanged: true + property bool kbLayoutChanged: true + property bool vpnChanged: true + property bool nowPlaying: false + } + + component Vpn: JsonObject { + property bool enabled: false + property list provider: ["netbird"] + } +} diff --git a/config/WInfoConfig.qml b/config/WInfoConfig.qml new file mode 100644 index 0000000..5025780 --- /dev/null +++ b/config/WInfoConfig.qml @@ -0,0 +1,10 @@ +import Quickshell.Io + +JsonObject { + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + property real heightMult: 0.7 + property real detailsWidth: 500 + } +} diff --git a/services/Audio.qml b/services/Audio.qml new file mode 100644 index 0000000..f71e2e4 --- /dev/null +++ b/services/Audio.qml @@ -0,0 +1,122 @@ +pragma Singleton + +import qs.config +import Quickshell +import Quickshell.Services.Pipewire +import QtQuick + +Singleton { + id: root + + property string previousSinkName: "" + property string previousSourceName: "" + + readonly property var nodes: Pipewire.nodes.values.reduce((acc, node) => { + if (!node.isStream) { + if (node.isSink) + acc.sinks.push(node); + else if (node.audio) + acc.sources.push(node); + } + return acc; + }, { + sources: [], + sinks: [] + }) + + readonly property list sinks: nodes.sinks + readonly property list sources: nodes.sources + + readonly property PwNode sink: Pipewire.defaultAudioSink + readonly property PwNode source: Pipewire.defaultAudioSource + + readonly property bool muted: !!sink?.audio?.muted + readonly property real volume: sink?.audio?.volume ?? 0 + + readonly property bool sourceMuted: !!source?.audio?.muted + readonly property real sourceVolume: source?.audio?.volume ?? 0 + + readonly property alias cava: cava + readonly property alias beatTracker: beatTracker + + function setVolume(newVolume: real): void { + if (sink?.ready && sink?.audio) { + sink.audio.muted = false; + sink.audio.volume = Math.max(0, Math.min(Config.services.maxVolume, newVolume)); + } + } + + function incrementVolume(amount: real): void { + setVolume(volume + (amount || Config.services.audioIncrement)); + } + + function decrementVolume(amount: real): void { + setVolume(volume - (amount || Config.services.audioIncrement)); + } + + function setSourceVolume(newVolume: real): void { + if (source?.ready && source?.audio) { + source.audio.muted = false; + source.audio.volume = Math.max(0, Math.min(Config.services.maxVolume, newVolume)); + } + } + + function incrementSourceVolume(amount: real): void { + setSourceVolume(sourceVolume + (amount || Config.services.audioIncrement)); + } + + function decrementSourceVolume(amount: real): void { + setSourceVolume(sourceVolume - (amount || Config.services.audioIncrement)); + } + + function setAudioSink(newSink: PwNode): void { + Pipewire.preferredDefaultAudioSink = newSink; + } + + function setAudioSource(newSource: PwNode): void { + Pipewire.preferredDefaultAudioSource = newSource; + } + + onSinkChanged: { + if (!sink?.ready) + return; + + const newSinkName = sink.description || sink.name || qsTr("Unknown Device"); + + if (previousSinkName && previousSinkName !== newSinkName && Config.utilities.toasts.audioOutputChanged) + Toaster.toast(qsTr("Audio output changed"), qsTr("Now using: %1").arg(newSinkName), "volume_up"); + + previousSinkName = newSinkName; + } + + onSourceChanged: { + if (!source?.ready) + return; + + const newSourceName = source.description || source.name || qsTr("Unknown Device"); + + if (previousSourceName && previousSourceName !== newSourceName && Config.utilities.toasts.audioInputChanged) + Toaster.toast(qsTr("Audio input changed"), qsTr("Now using: %1").arg(newSourceName), "mic"); + + previousSourceName = newSourceName; + } + + Component.onCompleted: { + previousSinkName = sink?.description || sink?.name || qsTr("Unknown Device"); + previousSourceName = source?.description || source?.name || qsTr("Unknown Device"); + } + + PwObjectTracker { + objects: [...root.sinks, ...root.sources] + } + + CavaProvider { + id: cava + + bars: Config.services.visualiserBars + } + + BeatTracker { + id: beatTracker + } +} diff --git a/services/Brightness.qml b/services/Brightness.qml new file mode 100644 index 0000000..ac905fd --- /dev/null +++ b/services/Brightness.qml @@ -0,0 +1,225 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.components.misc +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property list ddcMonitors: [] + readonly property list monitors: variants.instances + property bool appleDisplayPresent: false + + function getMonitorForScreen(screen: ShellScreen): var { + return monitors.find(m => m.modelData === screen); + } + + function getMonitor(query: string): var { + if (query === "active") { + return monitors.find(m => Hypr.monitorFor(m.modelData)?.focused); + } + + if (query.startsWith("model:")) { + const model = query.slice(6); + return monitors.find(m => m.modelData.model === model); + } + + if (query.startsWith("serial:")) { + const serial = query.slice(7); + return monitors.find(m => m.modelData.serialNumber === serial); + } + + if (query.startsWith("id:")) { + const id = parseInt(query.slice(3), 10); + return monitors.find(m => Hypr.monitorFor(m.modelData)?.id === id); + } + + return monitors.find(m => m.modelData.name === query); + } + + function increaseBrightness(): void { + const monitor = getMonitor("active"); + if (monitor) + monitor.setBrightness(monitor.brightness + 0.1); + } + + function decreaseBrightness(): void { + const monitor = getMonitor("active"); + if (monitor) + monitor.setBrightness(monitor.brightness - 0.1); + } + + onMonitorsChanged: { + ddcMonitors = []; + ddcProc.running = true; + } + + Variants { + id: variants + + model: Quickshell.screens + + Monitor {} + } + + Process { + running: true + command: ["sh", "-c", "asdbctl get"] // To avoid warnings if asdbctl is not installed + stdout: StdioCollector { + onStreamFinished: root.appleDisplayPresent = text.trim().length > 0 + } + } + + Process { + id: ddcProc + + command: ["ddcutil", "detect", "--brief"] + stdout: StdioCollector { + onStreamFinished: root.ddcMonitors = text.trim().split("\n\n").filter(d => d.startsWith("Display ")).map(d => ({ + busNum: d.match(/I2C bus:[ ]*\/dev\/i2c-([0-9]+)/)[1], + connector: d.match(/DRM connector:\s+(.*)/)[1].replace(/^card\d+-/, "") // strip "card1-" + })) + } + } + + CustomShortcut { + name: "brightnessUp" + description: "Increase brightness" + onPressed: root.increaseBrightness() + } + + CustomShortcut { + name: "brightnessDown" + description: "Decrease brightness" + onPressed: root.decreaseBrightness() + } + + IpcHandler { + target: "brightness" + + function get(): real { + return getFor("active"); + } + + // Allows searching by active/model/serial/id/name + function getFor(query: string): real { + return root.getMonitor(query)?.brightness ?? -1; + } + + function set(value: string): string { + return setFor("active", value); + } + + // Handles brightness value like brightnessctl: 0.1, +0.1, 0.1-, 10%, +10%, 10%- + function setFor(query: string, value: string): string { + const monitor = root.getMonitor(query); + if (!monitor) + return "Invalid monitor: " + query; + + let targetBrightness; + if (value.endsWith("%-")) { + const percent = parseFloat(value.slice(0, -2)); + targetBrightness = monitor.brightness - (percent / 100); + } else if (value.startsWith("+") && value.endsWith("%")) { + const percent = parseFloat(value.slice(1, -1)); + targetBrightness = monitor.brightness + (percent / 100); + } else if (value.endsWith("%")) { + const percent = parseFloat(value.slice(0, -1)); + targetBrightness = percent / 100; + } else if (value.startsWith("+")) { + const increment = parseFloat(value.slice(1)); + targetBrightness = monitor.brightness + increment; + } else if (value.endsWith("-")) { + const decrement = parseFloat(value.slice(0, -1)); + targetBrightness = monitor.brightness - decrement; + } else if (value.includes("%") || value.includes("-") || value.includes("+")) { + return `Invalid brightness format: ${value}\nExpected: 0.1, +0.1, 0.1-, 10%, +10%, 10%-`; + } else { + targetBrightness = parseFloat(value); + } + + if (isNaN(targetBrightness)) + return `Failed to parse value: ${value}\nExpected: 0.1, +0.1, 0.1-, 10%, +10%, 10%-`; + + monitor.setBrightness(targetBrightness); + + return `Set monitor ${monitor.modelData.name} brightness to ${+monitor.brightness.toFixed(2)}`; + } + } + + component Monitor: QtObject { + id: monitor + + required property ShellScreen modelData + readonly property bool isDdc: root.ddcMonitors.some(m => m.connector === modelData.name) + readonly property string busNum: root.ddcMonitors.find(m => m.connector === modelData.name)?.busNum ?? "" + readonly property bool isAppleDisplay: root.appleDisplayPresent && modelData.model.startsWith("StudioDisplay") + property real brightness + property real queuedBrightness: NaN + + readonly property Process initProc: Process { + stdout: StdioCollector { + onStreamFinished: { + if (monitor.isAppleDisplay) { + const val = parseInt(text.trim()); + monitor.brightness = val / 101; + } else { + const [, , , cur, max] = text.split(" "); + monitor.brightness = parseInt(cur) / parseInt(max); + } + } + } + } + + readonly property Timer timer: Timer { + interval: 500 + onTriggered: { + if (!isNaN(monitor.queuedBrightness)) { + monitor.setBrightness(monitor.queuedBrightness); + monitor.queuedBrightness = NaN; + } + } + } + + function setBrightness(value: real): void { + value = Math.max(0, Math.min(1, value)); + const rounded = Math.round(value * 100); + if (Math.round(brightness * 100) === rounded) + return; + + if (isDdc && timer.running) { + queuedBrightness = value; + return; + } + + brightness = value; + + if (isAppleDisplay) + Quickshell.execDetached(["asdbctl", "set", rounded]); + else if (isDdc) + Quickshell.execDetached(["ddcutil", "-b", busNum, "setvcp", "10", rounded]); + else + Quickshell.execDetached(["brightnessctl", "s", `${rounded}%`]); + + if (isDdc) + timer.restart(); + } + + function initBrightness(): void { + if (isAppleDisplay) + initProc.command = ["asdbctl", "get"]; + else if (isDdc) + initProc.command = ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"]; + else + initProc.command = ["sh", "-c", "echo a b c $(brightnessctl g) $(brightnessctl m)"]; + + initProc.running = true; + } + + onBusNumChanged: initBrightness() + Component.onCompleted: initBrightness() + } +} diff --git a/services/Colours.qml b/services/Colours.qml new file mode 100644 index 0000000..cd86c8f --- /dev/null +++ b/services/Colours.qml @@ -0,0 +1,237 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.config +import qs.utils +import Caelestia +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property bool showPreview + property string scheme + property string flavour + readonly property bool light: showPreview ? previewLight : currentLight + property bool currentLight + property bool previewLight + readonly property M3Palette palette: showPreview ? preview : current + readonly property M3TPalette tPalette: M3TPalette {} + readonly property M3Palette current: M3Palette {} + readonly property M3Palette preview: M3Palette {} + readonly property Transparency transparency: Transparency {} + readonly property alias wallLuminance: analyser.luminance + + function getLuminance(c: color): real { + if (c.r == 0 && c.g == 0 && c.b == 0) + return 0; + return Math.sqrt(0.299 * (c.r ** 2) + 0.587 * (c.g ** 2) + 0.114 * (c.b ** 2)); + } + + function alterColour(c: color, a: real, layer: int): color { + const luminance = getLuminance(c); + + const offset = (!light || layer == 1 ? 1 : -layer / 2) * (light ? 0.2 : 0.3) * (1 - transparency.base) * (1 + wallLuminance * (light ? (layer == 1 ? 3 : 1) : 2.5)); + const scale = (luminance + offset) / luminance; + const r = Math.max(0, Math.min(1, c.r * scale)); + const g = Math.max(0, Math.min(1, c.g * scale)); + const b = Math.max(0, Math.min(1, c.b * scale)); + + return Qt.rgba(r, g, b, a); + } + + function layer(c: color, layer: var): color { + if (!transparency.enabled) + return c; + + return layer === 0 ? Qt.alpha(c, transparency.base) : alterColour(c, transparency.layers, layer ?? 1); + } + + function on(c: color): color { + if (c.hslLightness < 0.5) + return Qt.hsla(c.hslHue, c.hslSaturation, 0.9, 1); + return Qt.hsla(c.hslHue, c.hslSaturation, 0.1, 1); + } + + function load(data: string, isPreview: bool): void { + const colours = isPreview ? preview : current; + const scheme = JSON.parse(data); + + if (!isPreview) { + root.scheme = scheme.name; + flavour = scheme.flavour; + currentLight = scheme.mode === "light"; + } else { + previewLight = scheme.mode === "light"; + } + + for (const [name, colour] of Object.entries(scheme.colours)) { + const propName = name.startsWith("term") ? name : `m3${name}`; + if (colours.hasOwnProperty(propName)) + colours[propName] = `#${colour}`; + } + } + + function setMode(mode: string): void { + Quickshell.execDetached(["caelestia", "scheme", "set", "--notify", "-m", mode]); + } + + FileView { + path: `${Paths.state}/scheme.json` + watchChanges: true + onFileChanged: reload() + onLoaded: root.load(text(), false) + } + + ImageAnalyser { + id: analyser + + source: Wallpapers.current + } + + component Transparency: QtObject { + readonly property bool enabled: Appearance.transparency.enabled + readonly property real base: Appearance.transparency.base - (root.light ? 0.1 : 0) + readonly property real layers: Appearance.transparency.layers + } + + component M3TPalette: QtObject { + readonly property color m3primary_paletteKeyColor: root.layer(root.palette.m3primary_paletteKeyColor) + readonly property color m3secondary_paletteKeyColor: root.layer(root.palette.m3secondary_paletteKeyColor) + readonly property color m3tertiary_paletteKeyColor: root.layer(root.palette.m3tertiary_paletteKeyColor) + readonly property color m3neutral_paletteKeyColor: root.layer(root.palette.m3neutral_paletteKeyColor) + readonly property color m3neutral_variant_paletteKeyColor: root.layer(root.palette.m3neutral_variant_paletteKeyColor) + readonly property color m3background: root.layer(root.palette.m3background, 0) + readonly property color m3onBackground: root.layer(root.palette.m3onBackground) + readonly property color m3surface: root.layer(root.palette.m3surface, 0) + readonly property color m3surfaceDim: root.layer(root.palette.m3surfaceDim, 0) + readonly property color m3surfaceBright: root.layer(root.palette.m3surfaceBright, 0) + readonly property color m3surfaceContainerLowest: root.layer(root.palette.m3surfaceContainerLowest) + readonly property color m3surfaceContainerLow: root.layer(root.palette.m3surfaceContainerLow) + readonly property color m3surfaceContainer: root.layer(root.palette.m3surfaceContainer) + readonly property color m3surfaceContainerHigh: root.layer(root.palette.m3surfaceContainerHigh) + readonly property color m3surfaceContainerHighest: root.layer(root.palette.m3surfaceContainerHighest) + readonly property color m3onSurface: root.layer(root.palette.m3onSurface) + readonly property color m3surfaceVariant: root.layer(root.palette.m3surfaceVariant, 0) + readonly property color m3onSurfaceVariant: root.layer(root.palette.m3onSurfaceVariant) + readonly property color m3inverseSurface: root.layer(root.palette.m3inverseSurface, 0) + readonly property color m3inverseOnSurface: root.layer(root.palette.m3inverseOnSurface) + readonly property color m3outline: root.layer(root.palette.m3outline) + readonly property color m3outlineVariant: root.layer(root.palette.m3outlineVariant) + readonly property color m3shadow: root.layer(root.palette.m3shadow) + readonly property color m3scrim: root.layer(root.palette.m3scrim) + readonly property color m3surfaceTint: root.layer(root.palette.m3surfaceTint) + readonly property color m3primary: root.layer(root.palette.m3primary) + readonly property color m3onPrimary: root.layer(root.palette.m3onPrimary) + readonly property color m3primaryContainer: root.layer(root.palette.m3primaryContainer) + readonly property color m3onPrimaryContainer: root.layer(root.palette.m3onPrimaryContainer) + readonly property color m3inversePrimary: root.layer(root.palette.m3inversePrimary) + readonly property color m3secondary: root.layer(root.palette.m3secondary) + readonly property color m3onSecondary: root.layer(root.palette.m3onSecondary) + readonly property color m3secondaryContainer: root.layer(root.palette.m3secondaryContainer) + readonly property color m3onSecondaryContainer: root.layer(root.palette.m3onSecondaryContainer) + readonly property color m3tertiary: root.layer(root.palette.m3tertiary) + readonly property color m3onTertiary: root.layer(root.palette.m3onTertiary) + readonly property color m3tertiaryContainer: root.layer(root.palette.m3tertiaryContainer) + readonly property color m3onTertiaryContainer: root.layer(root.palette.m3onTertiaryContainer) + readonly property color m3error: root.layer(root.palette.m3error) + readonly property color m3onError: root.layer(root.palette.m3onError) + readonly property color m3errorContainer: root.layer(root.palette.m3errorContainer) + readonly property color m3onErrorContainer: root.layer(root.palette.m3onErrorContainer) + readonly property color m3success: root.layer(root.palette.m3success) + readonly property color m3onSuccess: root.layer(root.palette.m3onSuccess) + readonly property color m3successContainer: root.layer(root.palette.m3successContainer) + readonly property color m3onSuccessContainer: root.layer(root.palette.m3onSuccessContainer) + readonly property color m3primaryFixed: root.layer(root.palette.m3primaryFixed) + readonly property color m3primaryFixedDim: root.layer(root.palette.m3primaryFixedDim) + readonly property color m3onPrimaryFixed: root.layer(root.palette.m3onPrimaryFixed) + readonly property color m3onPrimaryFixedVariant: root.layer(root.palette.m3onPrimaryFixedVariant) + readonly property color m3secondaryFixed: root.layer(root.palette.m3secondaryFixed) + readonly property color m3secondaryFixedDim: root.layer(root.palette.m3secondaryFixedDim) + readonly property color m3onSecondaryFixed: root.layer(root.palette.m3onSecondaryFixed) + readonly property color m3onSecondaryFixedVariant: root.layer(root.palette.m3onSecondaryFixedVariant) + readonly property color m3tertiaryFixed: root.layer(root.palette.m3tertiaryFixed) + readonly property color m3tertiaryFixedDim: root.layer(root.palette.m3tertiaryFixedDim) + readonly property color m3onTertiaryFixed: root.layer(root.palette.m3onTertiaryFixed) + readonly property color m3onTertiaryFixedVariant: root.layer(root.palette.m3onTertiaryFixedVariant) + } + + component M3Palette: QtObject { + property color m3primary_paletteKeyColor: "#a8627b" + property color m3secondary_paletteKeyColor: "#8e6f78" + property color m3tertiary_paletteKeyColor: "#986e4c" + property color m3neutral_paletteKeyColor: "#807477" + property color m3neutral_variant_paletteKeyColor: "#837377" + property color m3background: "#191114" + property color m3onBackground: "#efdfe2" + property color m3surface: "#191114" + property color m3surfaceDim: "#191114" + property color m3surfaceBright: "#403739" + property color m3surfaceContainerLowest: "#130c0e" + property color m3surfaceContainerLow: "#22191c" + property color m3surfaceContainer: "#261d20" + property color m3surfaceContainerHigh: "#31282a" + property color m3surfaceContainerHighest: "#3c3235" + property color m3onSurface: "#efdfe2" + property color m3surfaceVariant: "#514347" + property color m3onSurfaceVariant: "#d5c2c6" + property color m3inverseSurface: "#efdfe2" + property color m3inverseOnSurface: "#372e30" + property color m3outline: "#9e8c91" + property color m3outlineVariant: "#514347" + property color m3shadow: "#000000" + property color m3scrim: "#000000" + property color m3surfaceTint: "#ffb0ca" + property color m3primary: "#ffb0ca" + property color m3onPrimary: "#541d34" + property color m3primaryContainer: "#6f334a" + property color m3onPrimaryContainer: "#ffd9e3" + property color m3inversePrimary: "#8b4a62" + property color m3secondary: "#e2bdc7" + property color m3onSecondary: "#422932" + property color m3secondaryContainer: "#5a3f48" + property color m3onSecondaryContainer: "#ffd9e3" + property color m3tertiary: "#f0bc95" + property color m3onTertiary: "#48290c" + property color m3tertiaryContainer: "#b58763" + property color m3onTertiaryContainer: "#000000" + property color m3error: "#ffb4ab" + property color m3onError: "#690005" + property color m3errorContainer: "#93000a" + property color m3onErrorContainer: "#ffdad6" + property color m3success: "#B5CCBA" + property color m3onSuccess: "#213528" + property color m3successContainer: "#374B3E" + property color m3onSuccessContainer: "#D1E9D6" + property color m3primaryFixed: "#ffd9e3" + property color m3primaryFixedDim: "#ffb0ca" + property color m3onPrimaryFixed: "#39071f" + property color m3onPrimaryFixedVariant: "#6f334a" + property color m3secondaryFixed: "#ffd9e3" + property color m3secondaryFixedDim: "#e2bdc7" + property color m3onSecondaryFixed: "#2b151d" + property color m3onSecondaryFixedVariant: "#5a3f48" + property color m3tertiaryFixed: "#ffdcc3" + property color m3tertiaryFixedDim: "#f0bc95" + property color m3onTertiaryFixed: "#2f1500" + property color m3onTertiaryFixedVariant: "#623f21" + property color term0: "#353434" + property color term1: "#ff4c8a" + property color term2: "#ffbbb7" + property color term3: "#ffdedf" + property color term4: "#b3a2d5" + property color term5: "#e98fb0" + property color term6: "#ffba93" + property color term7: "#eed1d2" + property color term8: "#b39e9e" + property color term9: "#ff80a3" + property color term10: "#ffd3d0" + property color term11: "#fff1f0" + property color term12: "#dcbc93" + property color term13: "#f9a8c2" + property color term14: "#ffd1c0" + property color term15: "#ffffff" + } +} diff --git a/services/GameMode.qml b/services/GameMode.qml new file mode 100644 index 0000000..83770b7 --- /dev/null +++ b/services/GameMode.qml @@ -0,0 +1,76 @@ +pragma Singleton + +import qs.services +import qs.config +import Caelestia +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property alias enabled: props.enabled + + function setDynamicConfs(): void { + Hypr.extras.applyOptions({ + "animations:enabled": 0, + "decoration:shadow:enabled": 0, + "decoration:blur:enabled": 0, + "general:gaps_in": 0, + "general:gaps_out": 0, + "general:border_size": 1, + "decoration:rounding": 0, + "general:allow_tearing": 1 + }); + } + + onEnabledChanged: { + if (enabled) { + setDynamicConfs(); + if (Config.utilities.toasts.gameModeChanged) + Toaster.toast(qsTr("Game mode enabled"), qsTr("Disabled Hyprland animations, blur, gaps and shadows"), "gamepad"); + } else { + Hypr.extras.message("reload"); + if (Config.utilities.toasts.gameModeChanged) + Toaster.toast(qsTr("Game mode disabled"), qsTr("Hyprland settings restored"), "gamepad"); + } + } + + PersistentProperties { + id: props + + property bool enabled: Hypr.options["animations:enabled"] === 0 + + reloadableId: "gameMode" + } + + Connections { + target: Hypr + + function onConfigReloaded(): void { + if (props.enabled) + root.setDynamicConfs(); + } + } + + IpcHandler { + target: "gameMode" + + function isEnabled(): bool { + return props.enabled; + } + + function toggle(): void { + props.enabled = !props.enabled; + } + + function enable(): void { + props.enabled = true; + } + + function disable(): void { + props.enabled = false; + } + } +} diff --git a/services/Hypr.qml b/services/Hypr.qml new file mode 100644 index 0000000..f537792 --- /dev/null +++ b/services/Hypr.qml @@ -0,0 +1,143 @@ +pragma Singleton + +import qs.components.misc +import qs.config +import Caelestia +import Caelestia.Internal +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property var toplevels: Hyprland.toplevels + readonly property var workspaces: Hyprland.workspaces + readonly property var monitors: Hyprland.monitors + + readonly property HyprlandToplevel activeToplevel: Hyprland.activeToplevel?.wayland?.activated ? Hyprland.activeToplevel : null + readonly property HyprlandWorkspace focusedWorkspace: Hyprland.focusedWorkspace + readonly property HyprlandMonitor focusedMonitor: Hyprland.focusedMonitor + readonly property int activeWsId: focusedWorkspace?.id ?? 1 + + readonly property HyprKeyboard keyboard: extras.devices.keyboards.find(kb => kb.main) ?? null + readonly property bool capsLock: keyboard?.capsLock ?? false + readonly property bool numLock: keyboard?.numLock ?? false + readonly property string defaultKbLayout: keyboard?.layout.split(",")[0] ?? "??" + readonly property string kbLayoutFull: keyboard?.activeKeymap ?? "Unknown" + readonly property string kbLayout: kbMap.get(kbLayoutFull) ?? "??" + readonly property var kbMap: new Map() + + readonly property alias extras: extras + readonly property alias options: extras.options + readonly property alias devices: extras.devices + + property bool hadKeyboard + + signal configReloaded + + function dispatch(request: string): void { + Hyprland.dispatch(request); + } + + function monitorFor(screen: ShellScreen): HyprlandMonitor { + return Hyprland.monitorFor(screen); + } + + function reloadDynamicConfs(): void { + extras.batchMessage(["keyword bindlni ,Caps_Lock,global,caelestia:refreshDevices", "keyword bindlni ,Num_Lock,global,caelestia:refreshDevices"]); + } + + Component.onCompleted: reloadDynamicConfs() + + onCapsLockChanged: { + if (!Config.utilities.toasts.capsLockChanged) + return; + + if (capsLock) + Toaster.toast(qsTr("Caps lock enabled"), qsTr("Caps lock is currently enabled"), "keyboard_capslock_badge"); + else + Toaster.toast(qsTr("Caps lock disabled"), qsTr("Caps lock is currently disabled"), "keyboard_capslock"); + } + + onNumLockChanged: { + if (!Config.utilities.toasts.numLockChanged) + return; + + if (numLock) + Toaster.toast(qsTr("Num lock enabled"), qsTr("Num lock is currently enabled"), "looks_one"); + else + Toaster.toast(qsTr("Num lock disabled"), qsTr("Num lock is currently disabled"), "timer_1"); + } + + onKbLayoutFullChanged: { + if (hadKeyboard && Config.utilities.toasts.kbLayoutChanged) + Toaster.toast(qsTr("Keyboard layout changed"), qsTr("Layout changed to: %1").arg(kbLayoutFull), "keyboard"); + + hadKeyboard = !!keyboard; + } + + Connections { + target: Hyprland + + function onRawEvent(event: HyprlandEvent): void { + const n = event.name; + if (n.endsWith("v2")) + return; + + if (n === "configreloaded") { + root.configReloaded(); + root.reloadDynamicConfs(); + } else if (["workspace", "moveworkspace", "activespecial", "focusedmon"].includes(n)) { + Hyprland.refreshWorkspaces(); + Hyprland.refreshMonitors(); + } else if (["openwindow", "closewindow", "movewindow"].includes(n)) { + Hyprland.refreshToplevels(); + Hyprland.refreshWorkspaces(); + } else if (n.includes("mon")) { + Hyprland.refreshMonitors(); + } else if (n.includes("workspace")) { + Hyprland.refreshWorkspaces(); + } else if (n.includes("window") || n.includes("group") || ["pin", "fullscreen", "changefloatingmode", "minimize"].includes(n)) { + Hyprland.refreshToplevels(); + } + } + } + + FileView { + id: kbLayoutFile + + path: Quickshell.env("CAELESTIA_XKB_RULES_PATH") || "/usr/share/X11/xkb/rules/base.lst" + onLoaded: { + const lines = text().match(/! layout\n([\s\S]*?)\n\n/)[1].split("\n"); + for (const line of lines) { + if (!line.trim() || line.trim().startsWith("!")) + continue; + + const match = line.match(/^\s*([a-z]{2,})\s+([a-zA-Z() ]+)$/); + if (match) + root.kbMap.set(match[2], match[1]); + } + } + } + + IpcHandler { + target: "hypr" + + function refreshDevices(): void { + extras.refreshDevices(); + } + } + + CustomShortcut { + name: "refreshDevices" + description: "Reload devices" + onPressed: extras.refreshDevices() + onReleased: extras.refreshDevices() + } + + HyprExtras { + id: extras + } +} diff --git a/services/IdleInhibitor.qml b/services/IdleInhibitor.qml new file mode 100644 index 0000000..29409ab --- /dev/null +++ b/services/IdleInhibitor.qml @@ -0,0 +1,56 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import Quickshell.Wayland + +Singleton { + id: root + + property alias enabled: props.enabled + readonly property alias enabledSince: props.enabledSince + + onEnabledChanged: { + if (enabled) + props.enabledSince = new Date(); + } + + PersistentProperties { + id: props + + property bool enabled + property date enabledSince + + reloadableId: "idleInhibitor" + } + + IdleInhibitor { + enabled: props.enabled + window: PanelWindow { + implicitWidth: 0 + implicitHeight: 0 + color: "transparent" + mask: Region {} + } + } + + IpcHandler { + target: "idleInhibitor" + + function isEnabled(): bool { + return props.enabled; + } + + function toggle(): void { + props.enabled = !props.enabled; + } + + function enable(): void { + props.enabled = true; + } + + function disable(): void { + props.enabled = false; + } + } +} diff --git a/services/Network.qml b/services/Network.qml new file mode 100644 index 0000000..2c31065 --- /dev/null +++ b/services/Network.qml @@ -0,0 +1,190 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property list networks: [] + readonly property AccessPoint active: networks.find(n => n.active) ?? null + property bool wifiEnabled: true + readonly property bool scanning: rescanProc.running + + function enableWifi(enabled: bool): void { + const cmd = enabled ? "on" : "off"; + enableWifiProc.exec(["nmcli", "radio", "wifi", cmd]); + } + + function toggleWifi(): void { + const cmd = wifiEnabled ? "off" : "on"; + enableWifiProc.exec(["nmcli", "radio", "wifi", cmd]); + } + + function rescanWifi(): void { + rescanProc.running = true; + } + + function connectToNetwork(ssid: string, password: string): void { + // TODO: Implement password + connectProc.exec(["nmcli", "conn", "up", ssid]); + } + + function disconnectFromNetwork(): void { + if (active) { + disconnectProc.exec(["nmcli", "connection", "down", active.ssid]); + } + } + + function getWifiStatus(): void { + wifiStatusProc.running = true; + } + + Process { + running: true + command: ["nmcli", "m"] + stdout: SplitParser { + onRead: getNetworks.running = true + } + } + + Process { + id: wifiStatusProc + + running: true + command: ["nmcli", "radio", "wifi"] + environment: ({ + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8" + }) + stdout: StdioCollector { + onStreamFinished: { + root.wifiEnabled = text.trim() === "enabled"; + } + } + } + + Process { + id: enableWifiProc + + onExited: { + root.getWifiStatus(); + getNetworks.running = true; + } + } + + Process { + id: rescanProc + + command: ["nmcli", "dev", "wifi", "list", "--rescan", "yes"] + onExited: { + getNetworks.running = true; + } + } + + Process { + id: connectProc + + stdout: SplitParser { + onRead: getNetworks.running = true + } + stderr: StdioCollector { + onStreamFinished: console.warn("Network connection error:", text) + } + } + + Process { + id: disconnectProc + + stdout: SplitParser { + onRead: getNetworks.running = true + } + } + + Process { + id: getNetworks + + running: true + command: ["nmcli", "-g", "ACTIVE,SIGNAL,FREQ,SSID,BSSID,SECURITY", "d", "w"] + environment: ({ + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8" + }) + stdout: StdioCollector { + onStreamFinished: { + const PLACEHOLDER = "STRINGWHICHHOPEFULLYWONTBEUSED"; + const rep = new RegExp("\\\\:", "g"); + const rep2 = new RegExp(PLACEHOLDER, "g"); + + const allNetworks = text.trim().split("\n").map(n => { + const net = n.replace(rep, PLACEHOLDER).split(":"); + return { + active: net[0] === "yes", + strength: parseInt(net[1]), + frequency: parseInt(net[2]), + ssid: net[3]?.replace(rep2, ":") ?? "", + bssid: net[4]?.replace(rep2, ":") ?? "", + security: net[5] ?? "" + }; + }).filter(n => n.ssid && n.ssid.length > 0); + + // Group networks by SSID and prioritize connected ones + const networkMap = new Map(); + for (const network of allNetworks) { + const existing = networkMap.get(network.ssid); + if (!existing) { + networkMap.set(network.ssid, network); + } else { + // Prioritize active/connected networks + if (network.active && !existing.active) { + networkMap.set(network.ssid, network); + } else if (!network.active && !existing.active) { + // If both are inactive, keep the one with better signal + if (network.strength > existing.strength) { + networkMap.set(network.ssid, network); + } + } + // If existing is active and new is not, keep existing + } + } + + const networks = Array.from(networkMap.values()); + + const rNetworks = root.networks; + + const destroyed = rNetworks.filter(rn => !networks.find(n => n.frequency === rn.frequency && n.ssid === rn.ssid && n.bssid === rn.bssid)); + for (const network of destroyed) + rNetworks.splice(rNetworks.indexOf(network), 1).forEach(n => n.destroy()); + + for (const network of networks) { + const match = rNetworks.find(n => n.frequency === network.frequency && n.ssid === network.ssid && n.bssid === network.bssid); + if (match) { + match.lastIpcObject = network; + } else { + rNetworks.push(apComp.createObject(root, { + lastIpcObject: network + })); + } + } + } + } + } + + component AccessPoint: QtObject { + required property var lastIpcObject + readonly property string ssid: lastIpcObject.ssid + readonly property string bssid: lastIpcObject.bssid + readonly property int strength: lastIpcObject.strength + readonly property int frequency: lastIpcObject.frequency + readonly property bool active: lastIpcObject.active + readonly property string security: lastIpcObject.security + readonly property bool isSecure: security.length > 0 + } + + Component { + id: apComp + + AccessPoint {} + } +} diff --git a/services/Notifs.qml b/services/Notifs.qml new file mode 100644 index 0000000..4a89c7f --- /dev/null +++ b/services/Notifs.qml @@ -0,0 +1,334 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs.components.misc +import qs.config +import qs.utils +import Caelestia +import Quickshell +import Quickshell.Io +import Quickshell.Services.Notifications +import QtQuick + +Singleton { + id: root + + property list list: [] + readonly property list notClosed: list.filter(n => !n.closed) + readonly property list popups: list.filter(n => n.popup) + property alias dnd: props.dnd + + property bool loaded + + onDndChanged: { + if (!Config.utilities.toasts.dndChanged) + return; + + if (dnd) + Toaster.toast(qsTr("Do not disturb enabled"), qsTr("Popup notifications are now disabled"), "do_not_disturb_on"); + else + Toaster.toast(qsTr("Do not disturb disabled"), qsTr("Popup notifications are now enabled"), "do_not_disturb_off"); + } + + onListChanged: { + if (loaded) + saveTimer.restart(); + } + + Timer { + id: saveTimer + + interval: 1000 + onTriggered: storage.setText(JSON.stringify(root.notClosed.map(n => ({ + time: n.time, + id: n.id, + summary: n.summary, + body: n.body, + appIcon: n.appIcon, + appName: n.appName, + image: n.image, + expireTimeout: n.expireTimeout, + urgency: n.urgency, + resident: n.resident, + hasActionIcons: n.hasActionIcons, + actions: n.actions + })))) + } + + PersistentProperties { + id: props + + property bool dnd + + reloadableId: "notifs" + } + + NotificationServer { + id: server + + keepOnReload: false + actionsSupported: true + bodyHyperlinksSupported: true + bodyImagesSupported: true + bodyMarkupSupported: true + imageSupported: true + persistenceSupported: true + + onNotification: notif => { + notif.tracked = true; + + const comp = notifComp.createObject(root, { + popup: !props.dnd && ![...Visibilities.screens.values()].some(v => v.sidebar), + notification: notif + }); + root.list = [comp, ...root.list]; + } + } + + FileView { + id: storage + + path: `${Paths.state}/notifs.json` + onLoaded: { + const data = JSON.parse(text()); + for (const notif of data) + root.list.push(notifComp.createObject(root, notif)); + root.list.sort((a, b) => b.time - a.time); + root.loaded = true; + } + onLoadFailed: err => { + if (err === FileViewError.FileNotFound) { + root.loaded = true; + setText("[]"); + } + } + } + + CustomShortcut { + name: "clearNotifs" + description: "Clear all notifications" + onPressed: { + for (const notif of root.list.slice()) + notif.close(); + } + } + + IpcHandler { + target: "notifs" + + function clear(): void { + for (const notif of root.list.slice()) + notif.close(); + } + + function isDndEnabled(): bool { + return props.dnd; + } + + function toggleDnd(): void { + props.dnd = !props.dnd; + } + + function enableDnd(): void { + props.dnd = true; + } + + function disableDnd(): void { + props.dnd = false; + } + } + + component Notif: QtObject { + id: notif + + property bool popup + property bool closed + property var locks: new Set() + + property date time: new Date() + readonly property string timeStr: { + const diff = Time.date.getTime() - time.getTime(); + const m = Math.floor(diff / 60000); + + if (m < 1) + return qsTr("now"); + + const h = Math.floor(m / 60); + const d = Math.floor(h / 24); + + if (d > 0) + return `${d}d`; + if (h > 0) + return `${h}h`; + return `${m}m`; + } + + property Notification notification + property string id + property string summary + property string body + property string appIcon + property string appName + property string image + property real expireTimeout: Config.notifs.defaultExpireTimeout + property int urgency: NotificationUrgency.Normal + property bool resident + property bool hasActionIcons + property list actions + + readonly property Timer timer: Timer { + running: true + interval: notif.expireTimeout > 0 ? notif.expireTimeout : Config.notifs.defaultExpireTimeout + onTriggered: { + if (Config.notifs.expire) + notif.popup = false; + } + } + + readonly property LazyLoader dummyImageLoader: LazyLoader { + active: false + + PanelWindow { + implicitWidth: Config.notifs.sizes.image + implicitHeight: Config.notifs.sizes.image + color: "transparent" + mask: Region {} + + Image { + anchors.fill: parent + source: Qt.resolvedUrl(notif.image) + fillMode: Image.PreserveAspectCrop + cache: false + asynchronous: true + opacity: 0 + + onStatusChanged: { + if (status !== Image.Ready) + return; + + const cacheKey = notif.appName + notif.summary + notif.id; + let h1 = 0xdeadbeef, h2 = 0x41c6ce57, ch; + for (let i = 0; i < cacheKey.length; i++) { + ch = cacheKey.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + const hash = (h2 >>> 0).toString(16).padStart(8, 0) + (h1 >>> 0).toString(16).padStart(8, 0); + + const cache = `${Paths.notifimagecache}/${hash}.png`; + CUtils.saveItem(this, Qt.resolvedUrl(cache), () => { + notif.image = cache; + notif.dummyImageLoader.active = false; + }); + } + } + } + } + + readonly property Connections conn: Connections { + target: notif.notification + + function onClosed(): void { + notif.close(); + } + + function onSummaryChanged(): void { + notif.summary = notif.notification.summary; + } + + function onBodyChanged(): void { + notif.body = notif.notification.body; + } + + function onAppIconChanged(): void { + notif.appIcon = notif.notification.appIcon; + } + + function onAppNameChanged(): void { + notif.appName = notif.notification.appName; + } + + function onImageChanged(): void { + notif.image = notif.notification.image; + if (notif.notification?.image) + notif.dummyImageLoader.active = true; + } + + function onExpireTimeoutChanged(): void { + notif.expireTimeout = notif.notification.expireTimeout; + } + + function onUrgencyChanged(): void { + notif.urgency = notif.notification.urgency; + } + + function onResidentChanged(): void { + notif.resident = notif.notification.resident; + } + + function onHasActionIconsChanged(): void { + notif.hasActionIcons = notif.notification.hasActionIcons; + } + + function onActionsChanged(): void { + notif.actions = notif.notification.actions.map(a => ({ + identifier: a.identifier, + text: a.text, + invoke: () => a.invoke() + })); + } + } + + function lock(item: Item): void { + locks.add(item); + } + + function unlock(item: Item): void { + locks.delete(item); + if (closed) + close(); + } + + function close(): void { + closed = true; + if (locks.size === 0 && root.list.includes(this)) { + root.list = root.list.filter(n => n !== this); + notification?.dismiss(); + destroy(); + } + } + + Component.onCompleted: { + if (!notification) + return; + + id = notification.id; + summary = notification.summary; + body = notification.body; + appIcon = notification.appIcon; + appName = notification.appName; + image = notification.image; + if (notification?.image) + dummyImageLoader.active = true; + expireTimeout = notification.expireTimeout; + urgency = notification.urgency; + resident = notification.resident; + hasActionIcons = notification.hasActionIcons; + actions = notification.actions.map(a => ({ + identifier: a.identifier, + text: a.text, + invoke: () => a.invoke() + })); + } + } + + Component { + id: notifComp + + Notif {} + } +} diff --git a/services/Players.qml b/services/Players.qml new file mode 100644 index 0000000..1191696 --- /dev/null +++ b/services/Players.qml @@ -0,0 +1,126 @@ +pragma Singleton + +import qs.components.misc +import qs.config +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris +import QtQml +import Caelestia + +Singleton { + id: root + + readonly property list list: Mpris.players.values + readonly property MprisPlayer active: props.manualActive ?? list.find(p => getIdentity(p) === Config.services.defaultPlayer) ?? list[0] ?? null + property alias manualActive: props.manualActive + + function getIdentity(player: MprisPlayer): string { + const alias = Config.services.playerAliases.find(a => a.from === player.identity); + return alias?.to ?? player.identity; + } + + Connections { + target: active + + function onPostTrackChanged() { + if (!Config.utilities.toasts.nowPlaying) { + return; + } + if (active.trackArtist != "" && active.trackTitle != "") { + Toaster.toast(qsTr("Now Playing"), qsTr("%1 - %2").arg(active.trackArtist).arg(active.trackTitle), "music_note"); + } + } + } + + PersistentProperties { + id: props + + property MprisPlayer manualActive + + reloadableId: "players" + } + + CustomShortcut { + name: "mediaToggle" + description: "Toggle media playback" + onPressed: { + const active = root.active; + if (active && active.canTogglePlaying) + active.togglePlaying(); + } + } + + CustomShortcut { + name: "mediaPrev" + description: "Previous track" + onPressed: { + const active = root.active; + if (active && active.canGoPrevious) + active.previous(); + } + } + + CustomShortcut { + name: "mediaNext" + description: "Next track" + onPressed: { + const active = root.active; + if (active && active.canGoNext) + active.next(); + } + } + + CustomShortcut { + name: "mediaStop" + description: "Stop media playback" + onPressed: root.active?.stop() + } + + IpcHandler { + target: "mpris" + + function getActive(prop: string): string { + const active = root.active; + return active ? active[prop] ?? "Invalid property" : "No active player"; + } + + function list(): string { + return root.list.map(p => root.getIdentity(p)).join("\n"); + } + + function play(): void { + const active = root.active; + if (active?.canPlay) + active.play(); + } + + function pause(): void { + const active = root.active; + if (active?.canPause) + active.pause(); + } + + function playPause(): void { + const active = root.active; + if (active?.canTogglePlaying) + active.togglePlaying(); + } + + function previous(): void { + const active = root.active; + if (active?.canGoPrevious) + active.previous(); + } + + function next(): void { + const active = root.active; + if (active?.canGoNext) + active.next(); + } + + function stop(): void { + root.active?.stop(); + } + } +} diff --git a/services/Recorder.qml b/services/Recorder.qml new file mode 100644 index 0000000..e4ce6a8 --- /dev/null +++ b/services/Recorder.qml @@ -0,0 +1,82 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property alias running: props.running + readonly property alias paused: props.paused + readonly property alias elapsed: props.elapsed + property bool needsStart + property list startArgs + property bool needsStop + property bool needsPause + + function start(extraArgs: list): void { + needsStart = true; + startArgs = extraArgs; + checkProc.running = true; + } + + function stop(): void { + needsStop = true; + checkProc.running = true; + } + + function togglePause(): void { + needsPause = true; + checkProc.running = true; + } + + PersistentProperties { + id: props + + property bool running: false + property bool paused: false + property real elapsed: 0 // Might get too large for int + + reloadableId: "recorder" + } + + Process { + id: checkProc + + running: true + command: ["pidof", "gpu-screen-recorder"] + onExited: code => { + props.running = code === 0; + + if (code === 0) { + if (root.needsStop) { + Quickshell.execDetached(["caelestia", "record"]); + props.running = false; + props.paused = false; + } else if (root.needsPause) { + Quickshell.execDetached(["caelestia", "record", "-p"]); + props.paused = !props.paused; + } + } else if (root.needsStart) { + Quickshell.execDetached(["caelestia", "record", ...root.startArgs]); + props.running = true; + props.paused = false; + props.elapsed = 0; + } + + root.needsStart = false; + root.needsStop = false; + root.needsPause = false; + } + } + + Connections { + target: Time + // enabled: props.running && !props.paused + + function onSecondsChanged(): void { + props.elapsed++; + } + } +} diff --git a/services/SystemUsage.qml b/services/SystemUsage.qml new file mode 100644 index 0000000..bd02da3 --- /dev/null +++ b/services/SystemUsage.qml @@ -0,0 +1,222 @@ +pragma Singleton + +import qs.config +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property real cpuPerc + property real cpuTemp + readonly property string gpuType: Config.services.gpuType.toUpperCase() || autoGpuType + property string autoGpuType: "NONE" + property real gpuPerc + property real gpuTemp + property real memUsed + property real memTotal + readonly property real memPerc: memTotal > 0 ? memUsed / memTotal : 0 + property real storageUsed + property real storageTotal + property real storagePerc: storageTotal > 0 ? storageUsed / storageTotal : 0 + + property real lastCpuIdle + property real lastCpuTotal + + property int refCount + + function formatKib(kib: real): var { + const mib = 1024; + const gib = 1024 ** 2; + const tib = 1024 ** 3; + + if (kib >= tib) + return { + value: kib / tib, + unit: "TiB" + }; + if (kib >= gib) + return { + value: kib / gib, + unit: "GiB" + }; + if (kib >= mib) + return { + value: kib / mib, + unit: "MiB" + }; + return { + value: kib, + unit: "KiB" + }; + } + + Timer { + running: root.refCount > 0 + interval: 3000 + repeat: true + triggeredOnStart: true + onTriggered: { + stat.reload(); + meminfo.reload(); + storage.running = true; + gpuUsage.running = true; + sensors.running = true; + } + } + + FileView { + id: stat + + path: "/proc/stat" + onLoaded: { + const data = text().match(/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/); + if (data) { + const stats = data.slice(1).map(n => parseInt(n, 10)); + const total = stats.reduce((a, b) => a + b, 0); + const idle = stats[3] + (stats[4] ?? 0); + + const totalDiff = total - root.lastCpuTotal; + const idleDiff = idle - root.lastCpuIdle; + root.cpuPerc = totalDiff > 0 ? (1 - idleDiff / totalDiff) : 0; + + root.lastCpuTotal = total; + root.lastCpuIdle = idle; + } + } + } + + FileView { + id: meminfo + + path: "/proc/meminfo" + onLoaded: { + const data = text(); + root.memTotal = parseInt(data.match(/MemTotal: *(\d+)/)[1], 10) || 1; + root.memUsed = (root.memTotal - parseInt(data.match(/MemAvailable: *(\d+)/)[1], 10)) || 0; + } + } + + Process { + id: storage + + command: ["sh", "-c", "df | grep '^/dev/' | awk '{print $1, $3, $4}'"] + stdout: StdioCollector { + onStreamFinished: { + const deviceMap = new Map(); + + for (const line of text.trim().split("\n")) { + if (line.trim() === "") + continue; + + const parts = line.trim().split(/\s+/); + if (parts.length >= 3) { + const device = parts[0]; + const used = parseInt(parts[1], 10) || 0; + const avail = parseInt(parts[2], 10) || 0; + + // Only keep the entry with the largest total space for each device + if (!deviceMap.has(device) || (used + avail) > (deviceMap.get(device).used + deviceMap.get(device).avail)) { + deviceMap.set(device, { + used: used, + avail: avail + }); + } + } + } + + let totalUsed = 0; + let totalAvail = 0; + + for (const [device, stats] of deviceMap) { + totalUsed += stats.used; + totalAvail += stats.avail; + } + + root.storageUsed = totalUsed; + root.storageTotal = totalUsed + totalAvail; + } + } + } + + Process { + id: gpuTypeCheck + + running: !Config.services.gpuType + command: ["sh", "-c", "if command -v nvidia-smi &>/dev/null && nvidia-smi -L &>/dev/null; then echo NVIDIA; elif ls /sys/class/drm/card*/device/gpu_busy_percent 2>/dev/null | grep -q .; then echo GENERIC; else echo NONE; fi"] + stdout: StdioCollector { + onStreamFinished: root.autoGpuType = text.trim() + } + } + + Process { + id: gpuUsage + + command: root.gpuType === "GENERIC" ? ["sh", "-c", "cat /sys/class/drm/card*/device/gpu_busy_percent"] : root.gpuType === "NVIDIA" ? ["nvidia-smi", "--query-gpu=utilization.gpu,temperature.gpu", "--format=csv,noheader,nounits"] : ["echo"] + stdout: StdioCollector { + onStreamFinished: { + if (root.gpuType === "GENERIC") { + const percs = text.trim().split("\n"); + const sum = percs.reduce((acc, d) => acc + parseInt(d, 10), 0); + root.gpuPerc = sum / percs.length / 100; + } else if (root.gpuType === "NVIDIA") { + const [usage, temp] = text.trim().split(","); + root.gpuPerc = parseInt(usage, 10) / 100; + root.gpuTemp = parseInt(temp, 10); + } else { + root.gpuPerc = 0; + root.gpuTemp = 0; + } + } + } + } + + Process { + id: sensors + + command: ["sensors"] + environment: ({ + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8" + }) + stdout: StdioCollector { + onStreamFinished: { + let cpuTemp = text.match(/(?:Package id [0-9]+|Tdie):\s+((\+|-)[0-9.]+)(°| )C/); + if (!cpuTemp) + // If AMD Tdie pattern failed, try fallback on Tctl + cpuTemp = text.match(/Tctl:\s+((\+|-)[0-9.]+)(°| )C/); + + if (cpuTemp) + root.cpuTemp = parseFloat(cpuTemp[1]); + + if (root.gpuType !== "GENERIC") + return; + + let eligible = false; + let sum = 0; + let count = 0; + + for (const line of text.trim().split("\n")) { + if (line === "Adapter: PCI adapter") + eligible = true; + else if (line === "") + eligible = false; + else if (eligible) { + let match = line.match(/^(temp[0-9]+|GPU core|edge)+:\s+\+([0-9]+\.[0-9]+)(°| )C/); + if (!match) + // Fall back to junction/mem if GPU doesn't have edge temp (for AMD GPUs) + match = line.match(/^(junction|mem)+:\s+\+([0-9]+\.[0-9]+)(°| )C/); + + if (match) { + sum += parseFloat(match[2]); + count++; + } + } + } + + root.gpuTemp = count > 0 ? sum / count : 0; + } + } + } +} diff --git a/services/Time.qml b/services/Time.qml new file mode 100644 index 0000000..c4b3913 --- /dev/null +++ b/services/Time.qml @@ -0,0 +1,20 @@ +pragma Singleton + +import Quickshell + +Singleton { + property alias enabled: clock.enabled + readonly property date date: clock.date + readonly property int hours: clock.hours + readonly property int minutes: clock.minutes + readonly property int seconds: clock.seconds + + function format(fmt: string): string { + return Qt.formatDateTime(clock.date, fmt); + } + + SystemClock { + id: clock + precision: SystemClock.Seconds + } +} diff --git a/services/VPN.qml b/services/VPN.qml new file mode 100644 index 0000000..10e5e7e --- /dev/null +++ b/services/VPN.qml @@ -0,0 +1,176 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.config +import Caelestia + +Singleton { + id: root + + property bool connected: false + + readonly property bool connecting: connectProc.running || disconnectProc.running + readonly property bool enabled: Config.utilities.vpn.enabled + readonly property var providerInput: (Config.utilities.vpn.provider && Config.utilities.vpn.provider.length > 0) ? Config.utilities.vpn.provider[0] : "wireguard" + readonly property bool isCustomProvider: typeof providerInput === "object" + readonly property string providerName: isCustomProvider ? (providerInput.name || "custom") : String(providerInput) + readonly property string interfaceName: isCustomProvider ? (providerInput.interface || "") : "" + readonly property var currentConfig: { + const name = providerName; + const iface = interfaceName; + const defaults = getBuiltinDefaults(name, iface); + + if (isCustomProvider) { + const custom = providerInput; + return { + connectCmd: custom.connectCmd || defaults.connectCmd, + disconnectCmd: custom.disconnectCmd || defaults.disconnectCmd, + interface: custom.interface || defaults.interface, + displayName: custom.displayName || defaults.displayName + }; + } + + return defaults; + } + + function getBuiltinDefaults(name, iface) { + const builtins = { + "wireguard": { + connectCmd: ["pkexec", "wg-quick", "up", iface], + disconnectCmd: ["pkexec", "wg-quick", "down", iface], + interface: iface, + displayName: iface + }, + "warp": { + connectCmd: ["warp-cli", "connect"], + disconnectCmd: ["warp-cli", "disconnect"], + interface: "CloudflareWARP", + displayName: "Warp" + }, + "netbird": { + connectCmd: ["netbird", "up"], + disconnectCmd: ["netbird", "down"], + interface: "wt0", + displayName: "NetBird" + }, + "tailscale": { + connectCmd: ["tailscale", "up"], + disconnectCmd: ["tailscale", "down"], + interface: "tailscale0", + displayName: "Tailscale" + } + }; + + return builtins[name] || { + connectCmd: [name, "up"], + disconnectCmd: [name, "down"], + interface: iface || name, + displayName: name + }; + } + + function connect(): void { + if (!connected && !connecting && root.currentConfig && root.currentConfig.connectCmd) { + connectProc.exec(root.currentConfig.connectCmd); + } + } + + function disconnect(): void { + if (connected && !connecting && root.currentConfig && root.currentConfig.disconnectCmd) { + disconnectProc.exec(root.currentConfig.disconnectCmd); + } + } + + function toggle(): void { + if (connected) { + disconnect(); + } else { + connect(); + } + } + + function checkStatus(): void { + if (root.enabled) { + statusProc.running = true; + } + } + + onConnectedChanged: { + if (!Config.utilities.toasts.vpnChanged) + return; + + const displayName = root.currentConfig ? (root.currentConfig.displayName || "VPN") : "VPN"; + if (connected) { + Toaster.toast(qsTr("VPN connected"), qsTr("Connected to %1").arg(displayName), "vpn_key"); + } else { + Toaster.toast(qsTr("VPN disconnected"), qsTr("Disconnected from %1").arg(displayName), "vpn_key_off"); + } + } + + Component.onCompleted: root.enabled && statusCheckTimer.start() + + Process { + id: nmMonitor + + running: root.enabled + command: ["nmcli", "monitor"] + stdout: SplitParser { + onRead: statusCheckTimer.restart() + } + } + + Process { + id: statusProc + + command: ["ip", "link", "show"] + environment: ({ + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8" + }) + stdout: StdioCollector { + onStreamFinished: { + const iface = root.currentConfig ? root.currentConfig.interface : ""; + root.connected = iface && text.includes(iface + ":"); + } + } + } + + Process { + id: connectProc + + onExited: statusCheckTimer.start() + stderr: StdioCollector { + onStreamFinished: { + const error = text.trim(); + if (error && !error.includes("[#]") && !error.includes("already exists")) { + console.warn("VPN connection error:", error); + } else if (error.includes("already exists")) { + root.connected = true; + } + } + } + } + + Process { + id: disconnectProc + + onExited: statusCheckTimer.start() + stderr: StdioCollector { + onStreamFinished: { + const error = text.trim(); + if (error && !error.includes("[#]")) { + console.warn("VPN disconnection error:", error); + } + } + } + } + + Timer { + id: statusCheckTimer + + interval: 500 + onTriggered: root.checkStatus() + } +} diff --git a/services/Visibilities.qml b/services/Visibilities.qml new file mode 100644 index 0000000..5ddde0c --- /dev/null +++ b/services/Visibilities.qml @@ -0,0 +1,16 @@ +pragma Singleton + +import Quickshell + +Singleton { + property var screens: new Map() + property var bars: new Map() + + function load(screen: ShellScreen, visibilities: var): void { + screens.set(Hypr.monitorFor(screen), visibilities); + } + + function getForActive(): PersistentProperties { + return screens.get(Hypr.focusedMonitor); + } +} diff --git a/services/Wallpapers.qml b/services/Wallpapers.qml new file mode 100644 index 0000000..cb96bc5 --- /dev/null +++ b/services/Wallpapers.qml @@ -0,0 +1,93 @@ +pragma Singleton + +import qs.config +import qs.utils +import Caelestia.Models +import Quickshell +import Quickshell.Io +import QtQuick + +Searcher { + id: root + + readonly property string currentNamePath: `${Paths.state}/wallpaper/path.txt` + readonly property list smartArg: Config.services.smartScheme ? [] : ["--no-smart"] + + property bool showPreview: false + readonly property string current: showPreview ? previewPath : actualCurrent + property string previewPath + property string actualCurrent + property bool previewColourLock + + function setWallpaper(path: string): void { + actualCurrent = path; + Quickshell.execDetached(["caelestia", "wallpaper", "-f", path, ...smartArg]); + } + + function preview(path: string): void { + previewPath = path; + showPreview = true; + + if (Colours.scheme === "dynamic") + getPreviewColoursProc.running = true; + } + + function stopPreview(): void { + showPreview = false; + if (!previewColourLock) + Colours.showPreview = false; + } + + list: wallpapers.entries + key: "relativePath" + useFuzzy: Config.launcher.useFuzzy.wallpapers + extraOpts: useFuzzy ? ({}) : ({ + forward: false + }) + + IpcHandler { + target: "wallpaper" + + function get(): string { + return root.actualCurrent; + } + + function set(path: string): void { + root.setWallpaper(path); + } + + function list(): string { + return root.list.map(w => w.path).join("\n"); + } + } + + FileView { + path: root.currentNamePath + watchChanges: true + onFileChanged: reload() + onLoaded: { + root.actualCurrent = text().trim(); + root.previewColourLock = false; + } + } + + FileSystemModel { + id: wallpapers + + recursive: true + path: Paths.wallsdir + filter: FileSystemModel.Images + } + + Process { + id: getPreviewColoursProc + + command: ["caelestia", "wallpaper", "-p", root.previewPath, ...root.smartArg] + stdout: StdioCollector { + onStreamFinished: { + Colours.load(text, true); + Colours.showPreview = true; + } + } + } +} diff --git a/services/Weather.qml b/services/Weather.qml new file mode 100644 index 0000000..73e0b77 --- /dev/null +++ b/services/Weather.qml @@ -0,0 +1,40 @@ +pragma Singleton + +import qs.config +import qs.utils +import Caelestia +import Quickshell +import QtQuick + +Singleton { + id: root + + property string city + property var cc + property var forecast + readonly property string icon: cc ? Icons.getWeatherIcon(cc.weatherCode) : "cloud_alert" + readonly property string description: cc?.weatherDesc[0].value ?? qsTr("No weather") + readonly property string temp: Config.services.useFahrenheit ? `${cc?.temp_F ?? 0}°F` : `${cc?.temp_C ?? 0}°C` + readonly property string feelsLike: Config.services.useFahrenheit ? `${cc?.FeelsLikeF ?? 0}°F` : `${cc?.FeelsLikeC ?? 0}°C` + readonly property int humidity: cc?.humidity ?? 0 + + function reload(): void { + if (Config.services.weatherLocation) + city = Config.services.weatherLocation; + else if (!city || timer.elapsed() > 900) + Requests.get("https://ipinfo.io/json", text => { + city = JSON.parse(text).city ?? ""; + timer.restart(); + }); + } + + onCityChanged: Requests.get(`https://wttr.in/${city}?format=j1`, text => { + const json = JSON.parse(text); + cc = json.current_condition[0]; + forecast = json.weather; + }) + + ElapsedTimer { + id: timer + } +} diff --git a/shell.qml b/shell.qml index f652407..1ebc771 100755 --- a/shell.qml +++ b/shell.qml @@ -58,6 +58,4 @@ PanelWindow { PetMarch{ id:petMarch } - - Areapicker{} } diff --git a/utils/Icons.qml b/utils/Icons.qml new file mode 100644 index 0000000..e946c4f --- /dev/null +++ b/utils/Icons.qml @@ -0,0 +1,228 @@ +pragma Singleton + +import qs.config +import Quickshell +import Quickshell.Services.Notifications + +Singleton { + id: root + + readonly property var weatherIcons: ({ + "113": "clear_day", + "116": "partly_cloudy_day", + "119": "cloud", + "122": "cloud", + "143": "foggy", + "176": "rainy", + "179": "rainy", + "182": "rainy", + "185": "rainy", + "200": "thunderstorm", + "227": "cloudy_snowing", + "230": "snowing_heavy", + "248": "foggy", + "260": "foggy", + "263": "rainy", + "266": "rainy", + "281": "rainy", + "284": "rainy", + "293": "rainy", + "296": "rainy", + "299": "rainy", + "302": "weather_hail", + "305": "rainy", + "308": "weather_hail", + "311": "rainy", + "314": "rainy", + "317": "rainy", + "320": "cloudy_snowing", + "323": "cloudy_snowing", + "326": "cloudy_snowing", + "329": "snowing_heavy", + "332": "snowing_heavy", + "335": "snowing", + "338": "snowing_heavy", + "350": "rainy", + "353": "rainy", + "356": "rainy", + "359": "weather_hail", + "362": "rainy", + "365": "rainy", + "368": "cloudy_snowing", + "371": "snowing", + "374": "rainy", + "377": "rainy", + "386": "thunderstorm", + "389": "thunderstorm", + "392": "thunderstorm", + "395": "snowing" + }) + + readonly property var categoryIcons: ({ + WebBrowser: "web", + Printing: "print", + Security: "security", + Network: "chat", + Archiving: "archive", + Compression: "archive", + Development: "code", + IDE: "code", + TextEditor: "edit_note", + Audio: "music_note", + Music: "music_note", + Player: "music_note", + Recorder: "mic", + Game: "sports_esports", + FileTools: "files", + FileManager: "files", + Filesystem: "files", + FileTransfer: "files", + Settings: "settings", + DesktopSettings: "settings", + HardwareSettings: "settings", + TerminalEmulator: "terminal", + ConsoleOnly: "terminal", + Utility: "build", + Monitor: "monitor_heart", + Midi: "graphic_eq", + Mixer: "graphic_eq", + AudioVideoEditing: "video_settings", + AudioVideo: "music_video", + Video: "videocam", + Building: "construction", + Graphics: "photo_library", + "2DGraphics": "photo_library", + RasterGraphics: "photo_library", + TV: "tv", + System: "host", + Office: "content_paste" + }) + + function getAppIcon(name: string, fallback: string): string { + const icon = DesktopEntries.heuristicLookup(name)?.icon; + if (fallback !== "undefined") + return Quickshell.iconPath(icon, fallback); + return Quickshell.iconPath(icon); + } + + function getAppCategoryIcon(name: string, fallback: string): string { + const categories = DesktopEntries.heuristicLookup(name)?.categories; + + if (categories) + for (const [key, value] of Object.entries(categoryIcons)) + if (categories.includes(key)) + return value; + return fallback; + } + + function getNetworkIcon(strength: int): string { + if (strength >= 80) + return "signal_wifi_4_bar"; + if (strength >= 60) + return "network_wifi_3_bar"; + if (strength >= 40) + return "network_wifi_2_bar"; + if (strength >= 20) + return "network_wifi_1_bar"; + return "signal_wifi_0_bar"; + } + + function getBluetoothIcon(icon: string): string { + if (icon.includes("headset") || icon.includes("headphones")) + return "headphones"; + if (icon.includes("audio")) + return "speaker"; + if (icon.includes("phone")) + return "smartphone"; + if (icon.includes("mouse")) + return "mouse"; + if (icon.includes("keyboard")) + return "keyboard"; + return "bluetooth"; + } + + function getWeatherIcon(code: string): string { + if (weatherIcons.hasOwnProperty(code)) + return weatherIcons[code]; + return "air"; + } + + function getNotifIcon(summary: string, urgency: int): string { + summary = summary.toLowerCase(); + if (summary.includes("reboot")) + return "restart_alt"; + if (summary.includes("recording")) + return "screen_record"; + if (summary.includes("battery")) + return "power"; + if (summary.includes("screenshot")) + return "screenshot_monitor"; + if (summary.includes("welcome")) + return "waving_hand"; + if (summary.includes("time") || summary.includes("a break")) + return "schedule"; + if (summary.includes("installed")) + return "download"; + if (summary.includes("update")) + return "update"; + if (summary.includes("unable to")) + return "deployed_code_alert"; + if (summary.includes("profile")) + return "person"; + if (summary.includes("file")) + return "folder_copy"; + if (urgency === NotificationUrgency.Critical) + return "release_alert"; + return "chat"; + } + + function getVolumeIcon(volume: real, isMuted: bool): string { + if (isMuted) + return "no_sound"; + if (volume >= 0.5) + return "volume_up"; + if (volume > 0) + return "volume_down"; + return "volume_mute"; + } + + function getMicVolumeIcon(volume: real, isMuted: bool): string { + if (!isMuted && volume > 0) + return "mic"; + return "mic_off"; + } + + function getSpecialWsIcon(name: string): string { + name = name.toLowerCase().slice("special:".length); + + for (const iconConfig of Config.bar.workspaces.specialWorkspaceIcons) { + if (iconConfig.name === name) { + return iconConfig.icon; + } + } + + if (name === "special") + return "star"; + if (name === "communication") + return "forum"; + if (name === "music") + return "music_cast"; + if (name === "todo") + return "checklist"; + if (name === "sysmon") + return "monitor_heart"; + return name[0].toUpperCase(); + } + + function getTrayIcon(id: string, icon: string): string { + for (const sub of Config.bar.tray.iconSubs) + if (sub.id === id) + return sub.image ? Qt.resolvedUrl(sub.image) : Quickshell.iconPath(sub.icon); + + if (icon.includes("?path=")) { + const [name, path] = icon.split("?path="); + icon = Qt.resolvedUrl(`${path}/${name.slice(name.lastIndexOf("/") + 1)}`); + } + return icon; + } +} diff --git a/utils/Images.qml b/utils/Images.qml new file mode 100644 index 0000000..ac76f51 --- /dev/null +++ b/utils/Images.qml @@ -0,0 +1,12 @@ +pragma Singleton + +import Quickshell + +Singleton { + readonly property list validImageTypes: ["jpeg", "png", "webp", "tiff", "svg"] + readonly property list validImageExtensions: ["jpg", "jpeg", "png", "webp", "tif", "tiff", "svg"] + + function isValidImageByName(name: string): bool { + return validImageExtensions.some(t => name.endsWith(`.${t}`)); + } +} diff --git a/utils/Paths.qml b/utils/Paths.qml new file mode 100644 index 0000000..71cdd23 --- /dev/null +++ b/utils/Paths.qml @@ -0,0 +1,36 @@ +pragma Singleton + +import qs.config +import Quickshell + +Singleton { + id: root + + readonly property string home: Quickshell.env("HOME") + readonly property string pictures: Quickshell.env("XDG_PICTURES_DIR") || `${home}/Pictures` + readonly property string videos: Quickshell.env("XDG_VIDEOS_DIR") || `${home}/Videos` + + readonly property string data: `${Quickshell.env("XDG_DATA_HOME") || `${home}/.local/share`}/caelestia` + readonly property string state: `${Quickshell.env("XDG_STATE_HOME") || `${home}/.local/state`}/caelestia` + readonly property string cache: `${Quickshell.env("XDG_CACHE_HOME") || `${home}/.cache`}/caelestia` + readonly property string config: `${Quickshell.env("XDG_CONFIG_HOME") || `${home}/.config`}/caelestia` + + readonly property string imagecache: `${cache}/imagecache` + readonly property string notifimagecache: `${imagecache}/notifs` + readonly property string wallsdir: Quickshell.env("CAELESTIA_WALLPAPERS_DIR") || absolutePath(Config.paths.wallpaperDir) + readonly property string recsdir: Quickshell.env("CAELESTIA_RECORDINGS_DIR") || `${videos}/Recordings` + readonly property string libdir: Quickshell.env("CAELESTIA_LIB_DIR") || "/usr/lib/caelestia" + + function toLocalFile(path: url): string { + path = Qt.resolvedUrl(path); + return path.toString() ? CUtils.toLocalFile(path) : ""; + } + + function absolutePath(path: string): string { + return toLocalFile(path.replace("~", home)); + } + + function shortenHome(path: string): string { + return path.replace(home, "~"); + } +} diff --git a/utils/Searcher.qml b/utils/Searcher.qml new file mode 100644 index 0000000..053b73b --- /dev/null +++ b/utils/Searcher.qml @@ -0,0 +1,56 @@ +import Quickshell + +import "scripts/fzf.js" as Fzf +import "scripts/fuzzysort.js" as Fuzzy +import QtQuick + +Singleton { + required property list list + property string key: "name" + property bool useFuzzy: false + property var extraOpts: ({}) + + // Extra stuff for fuzzy + property list keys: [key] + property list weights: [1] + + readonly property var fzf: useFuzzy ? [] : new Fzf.Finder(list, Object.assign({ + selector + }, extraOpts)) + readonly property list fuzzyPrepped: useFuzzy ? list.map(e => { + const obj = { + _item: e + }; + for (const k of keys) + obj[k] = Fuzzy.prepare(e[k]); + return obj; + }) : [] + + function transformSearch(search: string): string { + return search; + } + + function selector(item: var): string { + // Only for fzf + return item[key]; + } + + function query(search: string): list { + search = transformSearch(search); + if (!search) + return [...list]; + + if (useFuzzy) + return Fuzzy.go(search, fuzzyPrepped, Object.assign({ + all: true, + keys, + scoreFn: r => weights.reduce((a, w, i) => a + r[i].score * w, 0) + }, extraOpts)).map(r => r.obj._item); + + return fzf.find(search).sort((a, b) => { + if (a.score === b.score) + return selector(a.item).trim().length - selector(b.item).trim().length; + return b.score - a.score; + }).map(r => r.item); + } +} diff --git a/utils/SysInfo.qml b/utils/SysInfo.qml new file mode 100644 index 0000000..ab2699d --- /dev/null +++ b/utils/SysInfo.qml @@ -0,0 +1,72 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property string osName + property string osPrettyName + property string osId + property list osIdLike + property string osLogo: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/logo.svg`) + property bool isDefaultLogo: true + + property string uptime + readonly property string user: Quickshell.env("USER") + readonly property string wm: Quickshell.env("XDG_CURRENT_DESKTOP") || Quickshell.env("XDG_SESSION_DESKTOP") + readonly property string shell: Quickshell.env("SHELL").split("/").pop() + + FileView { + id: osRelease + + path: "/etc/os-release" + onLoaded: { + const lines = text().split("\n"); + + const fd = key => lines.find(l => l.startsWith(`${key}=`))?.split("=")[1].replace(/"/g, "") ?? ""; + + root.osName = fd("NAME"); + root.osPrettyName = fd("PRETTY_NAME"); + root.osId = fd("ID"); + root.osIdLike = fd("ID_LIKE").split(" "); + + const logo = Quickshell.iconPath(fd("LOGO"), true); + if (logo) { + root.osLogo = logo; + root.isDefaultLogo = false; + } + } + } + + Timer { + running: true + repeat: true + interval: 15000 + onTriggered: fileUptime.reload() + } + + FileView { + id: fileUptime + + path: "/proc/uptime" + onLoaded: { + const up = parseInt(text().split(" ")[0] ?? 0); + + const days = Math.floor(up / 86400); + const hours = Math.floor((up % 86400) / 3600); + const minutes = Math.floor((up % 3600) / 60); + + let str = ""; + if (days > 0) + str += `${days} day${days === 1 ? "" : "s"}`; + if (hours > 0) + str += `${str ? ", " : ""}${hours} hour${hours === 1 ? "" : "s"}`; + if (minutes > 0 || !str) + str += `${str ? ", " : ""}${minutes} minute${minutes === 1 ? "" : "s"}`; + root.uptime = str; + } + } +} diff --git a/utils/scripts/fuzzysort.js b/utils/scripts/fuzzysort.js new file mode 100644 index 0000000..94308ff --- /dev/null +++ b/utils/scripts/fuzzysort.js @@ -0,0 +1,705 @@ +.pragma library + +/* +https://github.com/farzher/fuzzysort + +MIT License + +Copyright (c) 2018 Stephen Kamenar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +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/utils/scripts/fzf.js b/utils/scripts/fzf.js new file mode 100644 index 0000000..995a093 --- /dev/null +++ b/utils/scripts/fzf.js @@ -0,0 +1,1307 @@ +.pragma library + +/* +https://github.com/ajitid/fzf-for-js + +BSD 3-Clause License + +Copyright (c) 2021, Ajit +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +const normalized = { + 216: "O", + 223: "s", + 248: "o", + 273: "d", + 295: "h", + 305: "i", + 320: "l", + 322: "l", + 359: "t", + 383: "s", + 384: "b", + 385: "B", + 387: "b", + 390: "O", + 392: "c", + 393: "D", + 394: "D", + 396: "d", + 398: "E", + 400: "E", + 402: "f", + 403: "G", + 407: "I", + 409: "k", + 410: "l", + 412: "M", + 413: "N", + 414: "n", + 415: "O", + 421: "p", + 427: "t", + 429: "t", + 430: "T", + 434: "V", + 436: "y", + 438: "z", + 477: "e", + 485: "g", + 544: "N", + 545: "d", + 549: "z", + 564: "l", + 565: "n", + 566: "t", + 567: "j", + 570: "A", + 571: "C", + 572: "c", + 573: "L", + 574: "T", + 575: "s", + 576: "z", + 579: "B", + 580: "U", + 581: "V", + 582: "E", + 583: "e", + 584: "J", + 585: "j", + 586: "Q", + 587: "q", + 588: "R", + 589: "r", + 590: "Y", + 591: "y", + 592: "a", + 593: "a", + 595: "b", + 596: "o", + 597: "c", + 598: "d", + 599: "d", + 600: "e", + 603: "e", + 604: "e", + 605: "e", + 606: "e", + 607: "j", + 608: "g", + 609: "g", + 610: "G", + 613: "h", + 614: "h", + 616: "i", + 618: "I", + 619: "l", + 620: "l", + 621: "l", + 623: "m", + 624: "m", + 625: "m", + 626: "n", + 627: "n", + 628: "N", + 629: "o", + 633: "r", + 634: "r", + 635: "r", + 636: "r", + 637: "r", + 638: "r", + 639: "r", + 640: "R", + 641: "R", + 642: "s", + 647: "t", + 648: "t", + 649: "u", + 651: "v", + 652: "v", + 653: "w", + 654: "y", + 655: "Y", + 656: "z", + 657: "z", + 663: "c", + 665: "B", + 666: "e", + 667: "G", + 668: "H", + 669: "j", + 670: "k", + 671: "L", + 672: "q", + 686: "h", + 867: "a", + 868: "e", + 869: "i", + 870: "o", + 871: "u", + 872: "c", + 873: "d", + 874: "h", + 875: "m", + 876: "r", + 877: "t", + 878: "v", + 879: "x", + 7424: "A", + 7427: "B", + 7428: "C", + 7429: "D", + 7431: "E", + 7432: "e", + 7433: "i", + 7434: "J", + 7435: "K", + 7436: "L", + 7437: "M", + 7438: "N", + 7439: "O", + 7440: "O", + 7441: "o", + 7442: "o", + 7443: "o", + 7446: "o", + 7447: "o", + 7448: "P", + 7449: "R", + 7450: "R", + 7451: "T", + 7452: "U", + 7453: "u", + 7454: "u", + 7455: "m", + 7456: "V", + 7457: "W", + 7458: "Z", + 7522: "i", + 7523: "r", + 7524: "u", + 7525: "v", + 7834: "a", + 7835: "s", + 8305: "i", + 8341: "h", + 8342: "k", + 8343: "l", + 8344: "m", + 8345: "n", + 8346: "p", + 8347: "s", + 8348: "t", + 8580: "c" +}; +for (let i = "\u0300".codePointAt(0); i <= "\u036F".codePointAt(0); ++i) { + const diacritic = String.fromCodePoint(i); + for (const asciiChar of "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") { + const withDiacritic = (asciiChar + diacritic).normalize(); + const withDiacriticCodePoint = withDiacritic.codePointAt(0); + if (withDiacriticCodePoint > 126) { + normalized[withDiacriticCodePoint] = asciiChar; + } + } +} +const ranges = { + a: [7844, 7863], + e: [7870, 7879], + o: [7888, 7907], + u: [7912, 7921] +}; +for (const lowerChar of Object.keys(ranges)) { + const upperChar = lowerChar.toUpperCase(); + for (let i = ranges[lowerChar][0]; i <= ranges[lowerChar][1]; ++i) { + normalized[i] = i % 2 === 0 ? upperChar : lowerChar; + } +} +function normalizeRune(rune) { + if (rune < 192 || rune > 8580) { + return rune; + } + const normalizedChar = normalized[rune]; + if (normalizedChar !== void 0) + return normalizedChar.codePointAt(0); + return rune; +} +function toShort(number) { + return number; +} +function toInt(number) { + return number; +} +function maxInt16(num1, num2) { + return num1 > num2 ? num1 : num2; +} +const strToRunes = (str) => str.split("").map((s) => s.codePointAt(0)); +const runesToStr = (runes) => runes.map((r) => String.fromCodePoint(r)).join(""); +const whitespaceRunes = new Set( + " \f\n\r \v\xA0\u1680\u2028\u2029\u202F\u205F\u3000\uFEFF".split("").map((v) => v.codePointAt(0)) +); +for (let codePoint = "\u2000".codePointAt(0); codePoint <= "\u200A".codePointAt(0); codePoint++) { + whitespaceRunes.add(codePoint); +} +const isWhitespace = (rune) => whitespaceRunes.has(rune); +const whitespacesAtStart = (runes) => { + let whitespaces = 0; + for (const rune of runes) { + if (isWhitespace(rune)) + whitespaces++; + else + break; + } + return whitespaces; +}; +const whitespacesAtEnd = (runes) => { + let whitespaces = 0; + for (let i = runes.length - 1; i >= 0; i--) { + if (isWhitespace(runes[i])) + whitespaces++; + else + break; + } + return whitespaces; +}; +const MAX_ASCII = "\x7F".codePointAt(0); +const CAPITAL_A_RUNE = "A".codePointAt(0); +const CAPITAL_Z_RUNE = "Z".codePointAt(0); +const SMALL_A_RUNE = "a".codePointAt(0); +const SMALL_Z_RUNE = "z".codePointAt(0); +const NUMERAL_ZERO_RUNE = "0".codePointAt(0); +const NUMERAL_NINE_RUNE = "9".codePointAt(0); +function indexAt(index, max, forward) { + if (forward) { + return index; + } + return max - index - 1; +} +const SCORE_MATCH = 16, SCORE_GAP_START = -3, SCORE_GAP_EXTENTION = -1, BONUS_BOUNDARY = SCORE_MATCH / 2, BONUS_NON_WORD = SCORE_MATCH / 2, BONUS_CAMEL_123 = BONUS_BOUNDARY + SCORE_GAP_EXTENTION, BONUS_CONSECUTIVE = -(SCORE_GAP_START + SCORE_GAP_EXTENTION), BONUS_FIRST_CHAR_MULTIPLIER = 2; +function createPosSet(withPos) { + if (withPos) { + return /* @__PURE__ */ new Set(); + } + return null; +} +function alloc16(offset, slab2, size) { + if (slab2 !== null && slab2.i16.length > offset + size) { + const subarray = slab2.i16.subarray(offset, offset + size); + return [offset + size, subarray]; + } + return [offset, new Int16Array(size)]; +} +function alloc32(offset, slab2, size) { + if (slab2 !== null && slab2.i32.length > offset + size) { + const subarray = slab2.i32.subarray(offset, offset + size); + return [offset + size, subarray]; + } + return [offset, new Int32Array(size)]; +} +function charClassOfAscii(rune) { + if (rune >= SMALL_A_RUNE && rune <= SMALL_Z_RUNE) { + return 1; + } else if (rune >= CAPITAL_A_RUNE && rune <= CAPITAL_Z_RUNE) { + return 2; + } else if (rune >= NUMERAL_ZERO_RUNE && rune <= NUMERAL_NINE_RUNE) { + return 4; + } else { + return 0; + } +} +function charClassOfNonAscii(rune) { + const char = String.fromCodePoint(rune); + if (char !== char.toUpperCase()) { + return 1; + } else if (char !== char.toLowerCase()) { + return 2; + } else if (char.match(/\p{Number}/gu) !== null) { + return 4; + } else if (char.match(/\p{Letter}/gu) !== null) { + return 3; + } + return 0; +} +function charClassOf(rune) { + if (rune <= MAX_ASCII) { + return charClassOfAscii(rune); + } + return charClassOfNonAscii(rune); +} +function bonusFor(prevClass, currClass) { + if (prevClass === 0 && currClass !== 0) { + return BONUS_BOUNDARY; + } else if (prevClass === 1 && currClass === 2 || prevClass !== 4 && currClass === 4) { + return BONUS_CAMEL_123; + } else if (currClass === 0) { + return BONUS_NON_WORD; + } + return 0; +} +function bonusAt(input, idx) { + if (idx === 0) { + return BONUS_BOUNDARY; + } + return bonusFor(charClassOf(input[idx - 1]), charClassOf(input[idx])); +} +function trySkip(input, caseSensitive, char, from) { + let rest = input.slice(from); + let idx = rest.indexOf(char); + if (idx === 0) { + return from; + } + if (!caseSensitive && char >= SMALL_A_RUNE && char <= SMALL_Z_RUNE) { + if (idx > 0) { + rest = rest.slice(0, idx); + } + const uidx = rest.indexOf(char - 32); + if (uidx >= 0) { + idx = uidx; + } + } + if (idx < 0) { + return -1; + } + return from + idx; +} +function isAscii(runes) { + for (const rune of runes) { + if (rune >= 128) { + return false; + } + } + return true; +} +function asciiFuzzyIndex(input, pattern, caseSensitive) { + if (!isAscii(input)) { + return 0; + } + if (!isAscii(pattern)) { + return -1; + } + let firstIdx = 0, idx = 0; + for (let pidx = 0; pidx < pattern.length; pidx++) { + idx = trySkip(input, caseSensitive, pattern[pidx], idx); + if (idx < 0) { + return -1; + } + if (pidx === 0 && idx > 0) { + firstIdx = idx - 1; + } + idx++; + } + return firstIdx; +} +const fuzzyMatchV2 = (caseSensitive, normalize, forward, input, pattern, withPos, slab2) => { + const M = pattern.length; + if (M === 0) { + return [{ start: 0, end: 0, score: 0 }, createPosSet(withPos)]; + } + const N = input.length; + if (slab2 !== null && N * M > slab2.i16.length) { + return fuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos); + } + const idx = asciiFuzzyIndex(input, pattern, caseSensitive); + if (idx < 0) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + let offset16 = 0, offset32 = 0, H0 = null, C0 = null, B = null, F = null; + [offset16, H0] = alloc16(offset16, slab2, N); + [offset16, C0] = alloc16(offset16, slab2, N); + [offset16, B] = alloc16(offset16, slab2, N); + [offset32, F] = alloc32(offset32, slab2, M); + const [, T] = alloc32(offset32, slab2, N); + for (let i = 0; i < T.length; i++) { + T[i] = input[i]; + } + let maxScore = toShort(0), maxScorePos = 0; + let pidx = 0, lastIdx = 0; + const pchar0 = pattern[0]; + let pchar = pattern[0], prevH0 = toShort(0), prevCharClass = 0, inGap = false; + let Tsub = T.subarray(idx); + let H0sub = H0.subarray(idx).subarray(0, Tsub.length), C0sub = C0.subarray(idx).subarray(0, Tsub.length), Bsub = B.subarray(idx).subarray(0, Tsub.length); + for (let [off, char] of Tsub.entries()) { + let charClass = null; + if (char <= MAX_ASCII) { + charClass = charClassOfAscii(char); + if (!caseSensitive && charClass === 2) { + char += 32; + } + } else { + charClass = charClassOfNonAscii(char); + if (!caseSensitive && charClass === 2) { + char = String.fromCodePoint(char).toLowerCase().codePointAt(0); + } + if (normalize) { + char = normalizeRune(char); + } + } + Tsub[off] = char; + const bonus = bonusFor(prevCharClass, charClass); + Bsub[off] = bonus; + prevCharClass = charClass; + if (char === pchar) { + if (pidx < M) { + F[pidx] = toInt(idx + off); + pidx++; + pchar = pattern[Math.min(pidx, M - 1)]; + } + lastIdx = idx + off; + } + if (char === pchar0) { + const score = SCORE_MATCH + bonus * BONUS_FIRST_CHAR_MULTIPLIER; + H0sub[off] = score; + C0sub[off] = 1; + if (M === 1 && (forward && score > maxScore || !forward && score >= maxScore)) { + maxScore = score; + maxScorePos = idx + off; + if (forward && bonus === BONUS_BOUNDARY) { + break; + } + } + inGap = false; + } else { + if (inGap) { + H0sub[off] = maxInt16(prevH0 + SCORE_GAP_EXTENTION, 0); + } else { + H0sub[off] = maxInt16(prevH0 + SCORE_GAP_START, 0); + } + C0sub[off] = 0; + inGap = true; + } + prevH0 = H0sub[off]; + } + if (pidx !== M) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + if (M === 1) { + const result = { + start: maxScorePos, + end: maxScorePos + 1, + score: maxScore + }; + if (!withPos) { + return [result, null]; + } + const pos2 = /* @__PURE__ */ new Set(); + pos2.add(maxScorePos); + return [result, pos2]; + } + const f0 = F[0]; + const width = lastIdx - f0 + 1; + let H = null; + [offset16, H] = alloc16(offset16, slab2, width * M); + { + const toCopy = H0.subarray(f0, lastIdx + 1); + for (const [i, v] of toCopy.entries()) { + H[i] = v; + } + } + let [, C] = alloc16(offset16, slab2, width * M); + { + const toCopy = C0.subarray(f0, lastIdx + 1); + for (const [i, v] of toCopy.entries()) { + C[i] = v; + } + } + const Fsub = F.subarray(1); + const Psub = pattern.slice(1).slice(0, Fsub.length); + for (const [off, f] of Fsub.entries()) { + let inGap2 = false; + const pchar2 = Psub[off], pidx2 = off + 1, row = pidx2 * width, Tsub2 = T.subarray(f, lastIdx + 1), Bsub2 = B.subarray(f).subarray(0, Tsub2.length), Csub = C.subarray(row + f - f0).subarray(0, Tsub2.length), Cdiag = C.subarray(row + f - f0 - 1 - width).subarray(0, Tsub2.length), Hsub = H.subarray(row + f - f0).subarray(0, Tsub2.length), Hdiag = H.subarray(row + f - f0 - 1 - width).subarray(0, Tsub2.length), Hleft = H.subarray(row + f - f0 - 1).subarray(0, Tsub2.length); + Hleft[0] = 0; + for (const [off2, char] of Tsub2.entries()) { + const col = off2 + f; + let s1 = 0, s2 = 0, consecutive = 0; + if (inGap2) { + s2 = Hleft[off2] + SCORE_GAP_EXTENTION; + } else { + s2 = Hleft[off2] + SCORE_GAP_START; + } + if (pchar2 === char) { + s1 = Hdiag[off2] + SCORE_MATCH; + let b = Bsub2[off2]; + consecutive = Cdiag[off2] + 1; + if (b === BONUS_BOUNDARY) { + consecutive = 1; + } else if (consecutive > 1) { + b = maxInt16(b, maxInt16(BONUS_CONSECUTIVE, B[col - consecutive + 1])); + } + if (s1 + b < s2) { + s1 += Bsub2[off2]; + consecutive = 0; + } else { + s1 += b; + } + } + Csub[off2] = consecutive; + inGap2 = s1 < s2; + const score = maxInt16(maxInt16(s1, s2), 0); + if (pidx2 === M - 1 && (forward && score > maxScore || !forward && score >= maxScore)) { + maxScore = score; + maxScorePos = col; + } + Hsub[off2] = score; + } + } + const pos = createPosSet(withPos); + let j = f0; + if (withPos && pos !== null) { + let i = M - 1; + j = maxScorePos; + let preferMatch = true; + while (true) { + const I = i * width, j0 = j - f0, s = H[I + j0]; + let s1 = 0, s2 = 0; + if (i > 0 && j >= F[i]) { + s1 = H[I - width + j0 - 1]; + } + if (j > F[i]) { + s2 = H[I + j0 - 1]; + } + if (s > s1 && (s > s2 || s === s2 && preferMatch)) { + pos.add(j); + if (i === 0) { + break; + } + i--; + } + preferMatch = C[I + j0] > 1 || I + width + j0 + 1 < C.length && C[I + width + j0 + 1] > 0; + j--; + } + } + return [{ start: j, end: maxScorePos + 1, score: maxScore }, pos]; +}; +function calculateScore(caseSensitive, normalize, text, pattern, sidx, eidx, withPos) { + let pidx = 0, score = 0, inGap = false, consecutive = 0, firstBonus = toShort(0); + const pos = createPosSet(withPos); + let prevCharClass = 0; + if (sidx > 0) { + prevCharClass = charClassOf(text[sidx - 1]); + } + for (let idx = sidx; idx < eidx; idx++) { + let rune = text[idx]; + const charClass = charClassOf(rune); + if (!caseSensitive) { + if (rune >= CAPITAL_A_RUNE && rune <= CAPITAL_Z_RUNE) { + rune += 32; + } else if (rune > MAX_ASCII) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + } + if (normalize) { + rune = normalizeRune(rune); + } + if (rune === pattern[pidx]) { + if (withPos && pos !== null) { + pos.add(idx); + } + score += SCORE_MATCH; + let bonus = bonusFor(prevCharClass, charClass); + if (consecutive === 0) { + firstBonus = bonus; + } else { + if (bonus === BONUS_BOUNDARY) { + firstBonus = bonus; + } + bonus = maxInt16(maxInt16(bonus, firstBonus), BONUS_CONSECUTIVE); + } + if (pidx === 0) { + score += bonus * BONUS_FIRST_CHAR_MULTIPLIER; + } else { + score += bonus; + } + inGap = false; + consecutive++; + pidx++; + } else { + if (inGap) { + score += SCORE_GAP_EXTENTION; + } else { + score += SCORE_GAP_START; + } + inGap = true; + consecutive = 0; + firstBonus = 0; + } + prevCharClass = charClass; + } + return [score, pos]; +} +function fuzzyMatchV1(caseSensitive, normalize, forward, text, pattern, withPos, slab2) { + if (pattern.length === 0) { + return [{ start: 0, end: 0, score: 0 }, null]; + } + if (asciiFuzzyIndex(text, pattern, caseSensitive) < 0) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + let pidx = 0, sidx = -1, eidx = -1; + const lenRunes = text.length; + const lenPattern = pattern.length; + for (let index = 0; index < lenRunes; index++) { + let rune = text[indexAt(index, lenRunes, forward)]; + if (!caseSensitive) { + if (rune >= CAPITAL_A_RUNE && rune <= CAPITAL_Z_RUNE) { + rune += 32; + } else if (rune > MAX_ASCII) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + } + if (normalize) { + rune = normalizeRune(rune); + } + const pchar = pattern[indexAt(pidx, lenPattern, forward)]; + if (rune === pchar) { + if (sidx < 0) { + sidx = index; + } + pidx++; + if (pidx === lenPattern) { + eidx = index + 1; + break; + } + } + } + if (sidx >= 0 && eidx >= 0) { + pidx--; + for (let index = eidx - 1; index >= sidx; index--) { + const tidx = indexAt(index, lenRunes, forward); + let rune = text[tidx]; + if (!caseSensitive) { + if (rune >= CAPITAL_A_RUNE && rune <= CAPITAL_Z_RUNE) { + rune += 32; + } else if (rune > MAX_ASCII) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + } + const pidx_ = indexAt(pidx, lenPattern, forward); + const pchar = pattern[pidx_]; + if (rune === pchar) { + pidx--; + if (pidx < 0) { + sidx = index; + break; + } + } + } + if (!forward) { + const sidxTemp = sidx; + sidx = lenRunes - eidx; + eidx = lenRunes - sidxTemp; + } + const [score, pos] = calculateScore( + caseSensitive, + normalize, + text, + pattern, + sidx, + eidx, + withPos + ); + return [{ start: sidx, end: eidx, score }, pos]; + } + return [{ start: -1, end: -1, score: 0 }, null]; +}; +const exactMatchNaive = (caseSensitive, normalize, forward, text, pattern, withPos, slab2) => { + if (pattern.length === 0) { + return [{ start: 0, end: 0, score: 0 }, null]; + } + const lenRunes = text.length; + const lenPattern = pattern.length; + if (lenRunes < lenPattern) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + if (asciiFuzzyIndex(text, pattern, caseSensitive) < 0) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + let pidx = 0; + let bestPos = -1, bonus = toShort(0), bestBonus = toShort(-1); + for (let index = 0; index < lenRunes; index++) { + const index_ = indexAt(index, lenRunes, forward); + let rune = text[index_]; + if (!caseSensitive) { + if (rune >= CAPITAL_A_RUNE && rune <= CAPITAL_Z_RUNE) { + rune += 32; + } else if (rune > MAX_ASCII) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + } + if (normalize) { + rune = normalizeRune(rune); + } + const pidx_ = indexAt(pidx, lenPattern, forward); + const pchar = pattern[pidx_]; + if (pchar === rune) { + if (pidx_ === 0) { + bonus = bonusAt(text, index_); + } + pidx++; + if (pidx === lenPattern) { + if (bonus > bestBonus) { + bestPos = index; + bestBonus = bonus; + } + if (bonus === BONUS_BOUNDARY) { + break; + } + index -= pidx - 1; + pidx = 0; + bonus = 0; + } + } else { + index -= pidx; + pidx = 0; + bonus = 0; + } + } + if (bestPos >= 0) { + let sidx = 0, eidx = 0; + if (forward) { + sidx = bestPos - lenPattern + 1; + eidx = bestPos + 1; + } else { + sidx = lenRunes - (bestPos + 1); + eidx = lenRunes - (bestPos - lenPattern + 1); + } + const [score] = calculateScore(caseSensitive, normalize, text, pattern, sidx, eidx, false); + return [{ start: sidx, end: eidx, score }, null]; + } + return [{ start: -1, end: -1, score: 0 }, null]; +}; +const prefixMatch = (caseSensitive, normalize, forward, text, pattern, withPos, slab2) => { + if (pattern.length === 0) { + return [{ start: 0, end: 0, score: 0 }, null]; + } + let trimmedLen = 0; + if (!isWhitespace(pattern[0])) { + trimmedLen = whitespacesAtStart(text); + } + if (text.length - trimmedLen < pattern.length) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + for (const [index, r] of pattern.entries()) { + let rune = text[trimmedLen + index]; + if (!caseSensitive) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + if (normalize) { + rune = normalizeRune(rune); + } + if (rune !== r) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + } + const lenPattern = pattern.length; + const [score] = calculateScore( + caseSensitive, + normalize, + text, + pattern, + trimmedLen, + trimmedLen + lenPattern, + false + ); + return [{ start: trimmedLen, end: trimmedLen + lenPattern, score }, null]; +}; +const suffixMatch = (caseSensitive, normalize, forward, text, pattern, withPos, slab2) => { + const lenRunes = text.length; + let trimmedLen = lenRunes; + if (pattern.length === 0 || !isWhitespace(pattern[pattern.length - 1])) { + trimmedLen -= whitespacesAtEnd(text); + } + if (pattern.length === 0) { + return [{ start: trimmedLen, end: trimmedLen, score: 0 }, null]; + } + const diff = trimmedLen - pattern.length; + if (diff < 0) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + for (const [index, r] of pattern.entries()) { + let rune = text[index + diff]; + if (!caseSensitive) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + if (normalize) { + rune = normalizeRune(rune); + } + if (rune !== r) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + } + const lenPattern = pattern.length; + const sidx = trimmedLen - lenPattern; + const eidx = trimmedLen; + const [score] = calculateScore(caseSensitive, normalize, text, pattern, sidx, eidx, false); + return [{ start: sidx, end: eidx, score }, null]; +}; +const equalMatch = (caseSensitive, normalize, forward, text, pattern, withPos, slab2) => { + const lenPattern = pattern.length; + if (lenPattern === 0) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + let trimmedLen = 0; + if (!isWhitespace(pattern[0])) { + trimmedLen = whitespacesAtStart(text); + } + let trimmedEndLen = 0; + if (!isWhitespace(pattern[lenPattern - 1])) { + trimmedEndLen = whitespacesAtEnd(text); + } + if (text.length - trimmedLen - trimmedEndLen != lenPattern) { + return [{ start: -1, end: -1, score: 0 }, null]; + } + let match = true; + if (normalize) { + const runes = text; + for (const [idx, pchar] of pattern.entries()) { + let rune = runes[trimmedLen + idx]; + if (!caseSensitive) { + rune = String.fromCodePoint(rune).toLowerCase().codePointAt(0); + } + if (normalizeRune(pchar) !== normalizeRune(rune)) { + match = false; + break; + } + } + } else { + let runesStr = runesToStr(text).substring(trimmedLen, text.length - trimmedEndLen); + if (!caseSensitive) { + runesStr = runesStr.toLowerCase(); + } + match = runesStr === runesToStr(pattern); + } + if (match) { + return [ + { + start: trimmedLen, + end: trimmedLen + lenPattern, + score: (SCORE_MATCH + BONUS_BOUNDARY) * lenPattern + (BONUS_FIRST_CHAR_MULTIPLIER - 1) * BONUS_BOUNDARY + }, + null + ]; + } + return [{ start: -1, end: -1, score: 0 }, null]; +}; +const SLAB_16_SIZE = 100 * 1024; +const SLAB_32_SIZE = 2048; +function makeSlab(size16, size32) { + return { + i16: new Int16Array(size16), + i32: new Int32Array(size32) + }; +} +const slab = makeSlab(SLAB_16_SIZE, SLAB_32_SIZE); +var TermType = /* @__PURE__ */ ((TermType2) => { + TermType2[TermType2["Fuzzy"] = 0] = "Fuzzy"; + TermType2[TermType2["Exact"] = 1] = "Exact"; + TermType2[TermType2["Prefix"] = 2] = "Prefix"; + TermType2[TermType2["Suffix"] = 3] = "Suffix"; + TermType2[TermType2["Equal"] = 4] = "Equal"; + return TermType2; +})(TermType || {}); +const termTypeMap = { + [0]: fuzzyMatchV2, + [1]: exactMatchNaive, + [2]: prefixMatch, + [3]: suffixMatch, + [4]: equalMatch +}; +function buildPatternForExtendedMatch(fuzzy, caseMode, normalize, str) { + let cacheable = true; + str = str.trimLeft(); + { + const trimmedAtRightStr = str.trimRight(); + if (trimmedAtRightStr.endsWith("\\") && str[trimmedAtRightStr.length] === " ") { + str = trimmedAtRightStr + " "; + } else { + str = trimmedAtRightStr; + } + } + let sortable = false; + let termSets = []; + termSets = parseTerms(fuzzy, caseMode, normalize, str); + Loop: + for (const termSet of termSets) { + for (const [idx, term] of termSet.entries()) { + if (!term.inv) { + sortable = true; + } + if (!cacheable || idx > 0 || term.inv || fuzzy && term.typ !== 0 || !fuzzy && term.typ !== 1) { + cacheable = false; + if (sortable) { + break Loop; + } + } + } + } + return { + str, + termSets, + sortable, + cacheable, + fuzzy + }; +} +function parseTerms(fuzzy, caseMode, normalize, str) { + str = str.replace(/\\ /g, " "); + const tokens = str.split(/ +/); + const sets = []; + let set = []; + let switchSet = false; + let afterBar = false; + for (const token of tokens) { + let typ = 0, inv = false, text = token.replace(/\t/g, " "); + const lowerText = text.toLowerCase(); + const caseSensitive = caseMode === "case-sensitive" || caseMode === "smart-case" && text !== lowerText; + const normalizeTerm = normalize && lowerText === runesToStr(strToRunes(lowerText).map(normalizeRune)); + if (!caseSensitive) { + text = lowerText; + } + if (!fuzzy) { + typ = 1; + } + if (set.length > 0 && !afterBar && text === "|") { + switchSet = false; + afterBar = true; + continue; + } + afterBar = false; + if (text.startsWith("!")) { + inv = true; + typ = 1; + text = text.substring(1); + } + if (text !== "$" && text.endsWith("$")) { + typ = 3; + text = text.substring(0, text.length - 1); + } + if (text.startsWith("'")) { + if (fuzzy && !inv) { + typ = 1; + } else { + typ = 0; + } + text = text.substring(1); + } else if (text.startsWith("^")) { + if (typ === 3) { + typ = 4; + } else { + typ = 2; + } + text = text.substring(1); + } + if (text.length > 0) { + if (switchSet) { + sets.push(set); + set = []; + } + let textRunes = strToRunes(text); + if (normalizeTerm) { + textRunes = textRunes.map(normalizeRune); + } + set.push({ + typ, + inv, + text: textRunes, + caseSensitive, + normalize: normalizeTerm + }); + switchSet = true; + } + } + if (set.length > 0) { + sets.push(set); + } + return sets; +} +const buildPatternForBasicMatch = (query, casing, normalize) => { + let caseSensitive = false; + switch (casing) { + case "smart-case": + if (query.toLowerCase() !== query) { + caseSensitive = true; + } + break; + case "case-sensitive": + caseSensitive = true; + break; + case "case-insensitive": + query = query.toLowerCase(); + caseSensitive = false; + break; + } + let queryRunes = strToRunes(query); + if (normalize) { + queryRunes = queryRunes.map(normalizeRune); + } + return { + queryRunes, + caseSensitive + }; +}; +function iter(algoFn, tokens, caseSensitive, normalize, forward, pattern, slab2) { + for (const part of tokens) { + const [res, pos] = algoFn(caseSensitive, normalize, forward, part.text, pattern, true, slab2); + if (res.start >= 0) { + const sidx = res.start + part.prefixLength; + const eidx = res.end + part.prefixLength; + if (pos !== null) { + const newPos = /* @__PURE__ */ new Set(); + pos.forEach((v) => newPos.add(part.prefixLength + v)); + return [[sidx, eidx], res.score, newPos]; + } + return [[sidx, eidx], res.score, pos]; + } + } + return [[-1, -1], 0, null]; +} +function computeExtendedMatch(text, pattern, fuzzyAlgo, forward) { + const input = [ + { + text, + prefixLength: 0 + } + ]; + const offsets = []; + let totalScore = 0; + const allPos = /* @__PURE__ */ new Set(); + for (const termSet of pattern.termSets) { + let offset = [0, 0]; + let currentScore = 0; + let matched = false; + for (const term of termSet) { + let algoFn = termTypeMap[term.typ]; + if (term.typ === TermType.Fuzzy) { + algoFn = fuzzyAlgo; + } + const [off, score, pos] = iter( + algoFn, + input, + term.caseSensitive, + term.normalize, + forward, + term.text, + slab + ); + const sidx = off[0]; + if (sidx >= 0) { + if (term.inv) { + continue; + } + offset = off; + currentScore = score; + matched = true; + if (pos !== null) { + pos.forEach((v) => allPos.add(v)); + } else { + for (let idx = off[0]; idx < off[1]; ++idx) { + allPos.add(idx); + } + } + break; + } else if (term.inv) { + offset = [0, 0]; + currentScore = 0; + matched = true; + continue; + } + } + if (matched) { + offsets.push(offset); + totalScore += currentScore; + } + } + return { offsets, totalScore, allPos }; +} +function getResultFromScoreMap(scoreMap, limit) { + const scoresInDesc = Object.keys(scoreMap).map((v) => parseInt(v, 10)).sort((a, b) => b - a); + let result = []; + for (const score of scoresInDesc) { + result = result.concat(scoreMap[score]); + if (result.length >= limit) { + break; + } + } + return result; +} +function getBasicMatchIter(scoreMap, queryRunes, caseSensitive) { + return (idx) => { + const itemRunes = this.runesList[idx]; + if (queryRunes.length > itemRunes.length) + return; + let [match, positions] = this.algoFn( + caseSensitive, + this.opts.normalize, + this.opts.forward, + itemRunes, + queryRunes, + true, + slab + ); + if (match.start === -1) + return; + if (this.opts.fuzzy === false) { + positions = /* @__PURE__ */ new Set(); + for (let position = match.start; position < match.end; ++position) { + positions.add(position); + } + } + const scoreKey = this.opts.sort ? match.score : 0; + if (scoreMap[scoreKey] === void 0) { + scoreMap[scoreKey] = []; + } + scoreMap[scoreKey].push(Object.assign({ + item: this.items[idx], + positions: positions != null ? positions : /* @__PURE__ */ new Set() + }, match)); + }; +} +function getExtendedMatchIter(scoreMap, pattern) { + return (idx) => { + const runes = this.runesList[idx]; + const match = computeExtendedMatch(runes, pattern, this.algoFn, this.opts.forward); + if (match.offsets.length !== pattern.termSets.length) + return; + let sidx = -1, eidx = -1; + if (match.allPos.size > 0) { + sidx = Math.min(...match.allPos); + eidx = Math.max(...match.allPos) + 1; + } + const scoreKey = this.opts.sort ? match.totalScore : 0; + if (scoreMap[scoreKey] === void 0) { + scoreMap[scoreKey] = []; + } + scoreMap[scoreKey].push({ + score: match.totalScore, + item: this.items[idx], + positions: match.allPos, + start: sidx, + end: eidx + }); + }; +} +function basicMatch(query) { + const { queryRunes, caseSensitive } = buildPatternForBasicMatch( + query, + this.opts.casing, + this.opts.normalize + ); + const scoreMap = {}; + const iter2 = getBasicMatchIter.bind(this)( + scoreMap, + queryRunes, + caseSensitive + ); + for (let i = 0, len = this.runesList.length; i < len; ++i) { + iter2(i); + } + return getResultFromScoreMap(scoreMap, this.opts.limit); +} +function extendedMatch(query) { + const pattern = buildPatternForExtendedMatch( + Boolean(this.opts.fuzzy), + this.opts.casing, + this.opts.normalize, + query + ); + const scoreMap = {}; + const iter2 = getExtendedMatchIter.bind(this)(scoreMap, pattern); + for (let i = 0, len = this.runesList.length; i < len; ++i) { + iter2(i); + } + return getResultFromScoreMap(scoreMap, this.opts.limit); +} +const defaultOpts = { + limit: Infinity, + selector: (v) => v, + casing: "smart-case", + normalize: true, + fuzzy: "v2", + tiebreakers: [], + sort: true, + forward: true, + match: basicMatch +}; +class Finder { + constructor(list, ...optionsTuple) { + this.opts = Object.assign(defaultOpts, optionsTuple[0]); + this.items = list; + this.runesList = list.map((item) => strToRunes(this.opts.selector(item).normalize())); + this.algoFn = exactMatchNaive; + switch (this.opts.fuzzy) { + case "v2": + this.algoFn = fuzzyMatchV2; + break; + case "v1": + this.algoFn = fuzzyMatchV1; + break; + } + } + find(query) { + if (query.length === 0 || this.items.length === 0) + return this.items.slice(0, this.opts.limit).map(createResultItemWithEmptyPos); + query = query.normalize(); + let result = this.opts.match.bind(this)(query); + return postProcessResultItems(result, this.opts); + } +} +function createResultItemWithEmptyPos(item) { + return ({ + item, + start: -1, + end: -1, + score: 0, + positions: /* @__PURE__ */ new Set() + }) +}; +function postProcessResultItems(result, opts) { + if (opts.sort) { + const { selector } = opts; + result.sort((a, b) => { + if (a.score === b.score) { + for (const tiebreaker of opts.tiebreakers) { + const diff = tiebreaker(a, b, selector); + if (diff !== 0) { + return diff; + } + } + } + return 0; + }); + } + if (Number.isFinite(opts.limit)) { + result.splice(opts.limit); + } + return result; +} +function byLengthAsc(a, b, selector) { + return selector(a.item).length - selector(b.item).length; +} +function byStartAsc(a, b) { + return a.start - b.start; +}