From 2d5299c8fa30fb78544a028741cb1d47232b0650 Mon Sep 17 00:00:00 2001 From: Niklas Kapelle Date: Tue, 9 Sep 2025 12:03:05 +0200 Subject: [PATCH] initial commit --- ActivateLinux.qml | 57 ++++++++++++++++ README.md | 13 ++++ bar/Music.qml | 30 ++++++++ bar/Pill.qml | 24 +++++++ bar/StatusBar.qml | 54 +++++++++++++++ bar/Volume.qml | 16 +++++ bar/Workspaces.qml | 51 ++++++++++++++ flake.lock | 61 +++++++++++++++++ flake.nix | 24 +++++++ launcher/Launcher.qml | 136 +++++++++++++++++++++++++++++++++++++ shell.qml | 21 ++++++ singeltons/DesktopApps.qml | 18 +++++ singeltons/Pipewire.qml | 30 ++++++++ singeltons/Time.qml | 17 +++++ utils/fuzzySearch.js | 70 +++++++++++++++++++ 15 files changed, 622 insertions(+) create mode 100644 ActivateLinux.qml create mode 100644 README.md create mode 100644 bar/Music.qml create mode 100644 bar/Pill.qml create mode 100644 bar/StatusBar.qml create mode 100644 bar/Volume.qml create mode 100644 bar/Workspaces.qml create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 launcher/Launcher.qml create mode 100644 shell.qml create mode 100644 singeltons/DesktopApps.qml create mode 100644 singeltons/Pipewire.qml create mode 100644 singeltons/Time.qml create mode 100644 utils/fuzzySearch.js diff --git a/ActivateLinux.qml b/ActivateLinux.qml new file mode 100644 index 0000000..20e865f --- /dev/null +++ b/ActivateLinux.qml @@ -0,0 +1,57 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import "root:/bar" + +PanelWindow { + id: activateLinux + + required property ShellScreen modelData + + screen: modelData + + anchors { + right: true + bottom: true + } + + margins { + right: 50 + bottom: 50 + } + + implicitWidth: content.width + implicitHeight: content.height + + color: "transparent" + + // Give the window an empty click mask so all clicks pass through it. + mask: Region {} + + // Use the wlroots specific layer property to ensure it displays over + // fullscreen windows. + WlrLayershell.layer: WlrLayer.Overlay + + Pill { + Text { + text: "Ahhh" + } + } + + ColumnLayout { + id: content + + Text { + text: "Activate Linux" + color: "#50ffffff" + font.pointSize: 22 + } + + Text { + text: "Go to Settings to activate Linux" + color: "#50ffffff" + font.pointSize: 14 + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..9413e5b --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ + + +# Usefull links + +## Docs +https://doc.qt.io/qt-6/qml-qtquick-repeater.html +https://quickshell.outfoxxed.me/ + +## Example dotfiles +https://github.com/caelestia-dots/shell +https://github.com/end-4/dots-hyprland +https://github.com/Ly-sec/nixos/tree/main/home/quickshell + diff --git a/bar/Music.qml b/bar/Music.qml new file mode 100644 index 0000000..3069108 --- /dev/null +++ b/bar/Music.qml @@ -0,0 +1,30 @@ +import QtQuick +import Quickshell.Services.Mpris + +Text { + required property MprisPlayer player + text: `${player.trackTitle} - ${player.trackArtist || "Unknown Artist"}` + + color: "white" + + MouseArea { + anchors.fill: parent + + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: { + if (mouse.button === Qt.RightButton) { + parent.player.canGoNext && parent.player.next() + } else if (mouse.button === Qt.LeftButton) { + parent.player.canTogglePlaying && parent.player.togglePlaying() + } + } + + onWheel: (event) => { + if (!parent.player.volumeSupported || !parent.player.canControl){ + return + } + + parent.player.volume = parent.player.volume + (event.angleDelta.y / 120 * 0.05) + } + } +} diff --git a/bar/Pill.qml b/bar/Pill.qml new file mode 100644 index 0000000..f3e8ac9 --- /dev/null +++ b/bar/Pill.qml @@ -0,0 +1,24 @@ +import QtQuick +import QtQuick.Shapes +import QtQuick.Layouts + +Rectangle { + id: pill + default property alias content: container.data + + // Center in the middle of the bar + anchors.verticalCenter: parent.verticalCenter + + implicitWidth: container.childrenRect.width + 15 + implicitHeight: container.childrenRect.height + 12 + + color: "#939ab7" + radius: 18 + + Loader { + id: container + width: childrenRect.width + height: childrenRect.height + anchors.centerIn: parent + } +} diff --git a/bar/StatusBar.qml b/bar/StatusBar.qml new file mode 100644 index 0000000..18d3dea --- /dev/null +++ b/bar/StatusBar.qml @@ -0,0 +1,54 @@ +import Quickshell +import QtQuick +import Quickshell.Io +import Quickshell.Hyprland +import QtQuick.Layouts +import Quickshell.Services.Mpris +import "root:/singeltons" + +PanelWindow { + required property ShellScreen modelData + screen: modelData + + anchors { + top: true + left: true + right: true + } + + height: 30 + + color: "transparent" + + Pill { + Workspaces {} + } + + Pill { + anchors.centerIn: parent + + Text { + text: Time.time + + color: "white" + font.pixelSize: 14 + } + } + + Pill { + anchors.verticalCenter: parent.verticalCenter + anchors.right: music.left + anchors.rightMargin: 10 + Volume {} + } + + Pill { + id: music + + anchors.right: parent.right + anchors.rightMargin: 10 + Music { + player: Mpris.players.values[0] + } + } +} diff --git a/bar/Volume.qml b/bar/Volume.qml new file mode 100644 index 0000000..ab69742 --- /dev/null +++ b/bar/Volume.qml @@ -0,0 +1,16 @@ +import QtQuick +import "root:singeltons" + +Text { + text: `${Pipewire.volumePercent}% ${Pipewire.micMuted}` + + color: "white" + + MouseArea { + anchors.fill: parent + + onWheel: (event) => { + Pipewire.setVolume(Pipewire.volume + (event.angleDelta.y / 120 * 0.05)) + } + } +} diff --git a/bar/Workspaces.qml b/bar/Workspaces.qml new file mode 100644 index 0000000..07323f1 --- /dev/null +++ b/bar/Workspaces.qml @@ -0,0 +1,51 @@ +import QtQuick +import Quickshell +import Quickshell.Hyprland +import QtQuick.Layouts + +Rectangle { + + implicitWidth: content.implicitWidth + implicitHeight: content.implicitHeight + color: "transparent" + + Row { + id: content + anchors.fill: parent + + + Repeater { + model: ScriptModel { + values: Hyprland.workspaces.values.filter(e => e.id >= 0) + } + + Rectangle { + required property HyprlandWorkspace modelData + property bool hovered: false + + width: 25 + height: 20 + color: "transparent" + + Text { + text: modelData.name + color: modelData.active ? "#09608c" : "#cccccc" + anchors.centerIn: parent + font.pixelSize: 14 + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: { + modelData.activate() + parent.hovered = false + } + + onEntered: parent.hovered = !modelData.active + onExited: parent.hovered = false + } + } + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..da9e388 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1751741127, + "narHash": "sha256-t75Shs76NgxjZSgvvZZ9qOmz5zuBE8buUaYD28BMTxg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "29e290002bfff26af1db6f64d070698019460302", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1749285348, + "narHash": "sha256-frdhQvPbmDYaScPFiCnfdh3B/Vh81Uuoo0w5TkWmmjU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "3e3afe5174c561dee0df6f2c2b2236990146329f", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "ref": "nixos-unstable", + "type": "indirect" + } + }, + "quickshell": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1751880110, + "narHash": "sha256-5fQ2cetL3rTHqXe2VM3puawL/8u5j6ujBr6Gdt7Iues=", + "owner": "quickshell-mirror", + "repo": "quickshell", + "rev": "5d7e07508ae3e5487edb1ac5a152120434f091d5", + "type": "github" + }, + "original": { + "owner": "quickshell-mirror", + "repo": "quickshell", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "quickshell": "quickshell" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..6e818ca --- /dev/null +++ b/flake.nix @@ -0,0 +1,24 @@ +{ + description = "Dev shell with quickshell"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + quickshell.url = "github:quickshell-mirror/quickshell"; + }; + + outputs = { self, nixpkgs, quickshell, ... }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { inherit system; }; + in { + devShells.${system}.default = pkgs.mkShell { + packages = [ + quickshell.packages.${system}.default + ]; + + shellHook = '' + exec zsh + ''; + }; + }; +} diff --git a/launcher/Launcher.qml b/launcher/Launcher.qml new file mode 100644 index 0000000..d6e63f3 --- /dev/null +++ b/launcher/Launcher.qml @@ -0,0 +1,136 @@ +import Quickshell +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell.Wayland +import Quickshell.Hyprland +import "root:/singeltons" + +PanelWindow { + id: root + + property bool show: true + + implicitWidth: 600 + implicitHeight: 400 + visible: show + focusable: true + color: "transparent" + + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + WlrLayershell.layer: WlrLayer.Top + + HyprlandWindow.opacity: 0.9 + + // Main window + Rectangle { + anchors.fill: parent + anchors.centerIn: parent + + color: Qt.hsla(0, 0, 1, 0.5) + border.color: Qt.hsla(0, 0, 1, 0.2) + border.width: 2 + radius: 12 + + // Main window layout + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 20 + + // Search bar + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + spacing: 8 + + TextField { + id: searchInput + + Layout.fillWidth: true + focus: true + placeholderText: "Search..." + font.pixelSize: 20 + background: null + color: "white" + selectByMouse: true + + Keys.onPressed: (event) => { + switch (event.key){ + case (Qt.Key_Escape): + root.show = false + event.accepted = true + break + case (Qt.Key_Return): + case (Qt.Key_Enter): + if (resultsList.currentIndex >= 0) { + const item = resultsList.model[resultsList.currentIndex] + DesktopApps.launch(item) + } + root.show = false + event.accepted = true + break + case (Qt.Key_Down): + case (Qt.Key_Tab): + resultsList.currentIndex = Math.min(resultsList.count - 1, resultsList.currentIndex + 1) + event.accepted = true + break + case (Qt.Key_Up): + case (Qt.Key_Backtab): + resultsList.currentIndex = Math.max(0, resultsList.currentIndex - 1) + break + + } + } + + } + } + + // Results + ListView { + id: resultsList + model: DesktopApps.fuzzySearch(searchInput.text) + + Layout.fillWidth: true + Layout.fillHeight: true + + spacing: 4 + keyNavigationWraps: true + + // Result item + delegate: Rectangle { + required property DesktopEntry modelData + + width: parent.width + height: 50 + radius: 6 + color: ListView.isCurrentItem ? "#3a3a3c" : "transparent" + + RowLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + Image { + source: parent.parent.modelData.icon !== "" ? Quickshell.iconPath(parent.parent.modelData.icon,true) : "" + visible: parent.parent.modelData.icon !== "" + fillMode: Image.PreserveAspectFit + sourceSize.width: 30 + sourceSize.height: 30 + } + + Text { + text: parent.parent.modelData.name + + Layout.fillWidth: true + + font.pixelSize: 18 + color: "white" + } + } + } + } + } + } +} + diff --git a/shell.qml b/shell.qml new file mode 100644 index 0000000..38f8291 --- /dev/null +++ b/shell.qml @@ -0,0 +1,21 @@ +import Quickshell +import QtQuick +import Quickshell.Io +import "./bar" +import "./launcher/" + +ShellRoot{ + // Variants{ + // model: Quickshell.screens + // + // ActivateLinux {} + // } + // + Variants { + model: Quickshell.screens + + StatusBar {} + } + + // Launcher {} +} diff --git a/singeltons/DesktopApps.qml b/singeltons/DesktopApps.qml new file mode 100644 index 0000000..ef6d30d --- /dev/null +++ b/singeltons/DesktopApps.qml @@ -0,0 +1,18 @@ +pragma Singleton + +import Quickshell +import "root:/utils/fuzzySearch.js" as FuzzySearch + +Singleton { + id: root + + readonly property list all: DesktopEntries.applications.values + + function launch(entry: DesktopEntry): void { + entry.execute() + } + + function fuzzySearch(query: string): var { + return FuzzySearch.fuzzySearch(query, all); + } +} diff --git a/singeltons/Pipewire.qml b/singeltons/Pipewire.qml new file mode 100644 index 0000000..f3d2b1f --- /dev/null +++ b/singeltons/Pipewire.qml @@ -0,0 +1,30 @@ +pragma Singleton + +import Quickshell +import Quickshell.Services.Pipewire + +Singleton { + id: root + + readonly property PwNode sink: Pipewire.defaultAudioSink + readonly property PwNode source: Pipewire.defaultAudioSource + + readonly property bool muted: sink?.audio?.muted ?? false + readonly property real volume: sink?.audio?.volume ?? 0 + + readonly property int volumePercent: Math.round(volume * 100) + + + readonly property bool micMuted: source?.audio?.muted ?? false + + function setVolume(volume: real): void { + if (sink?.ready && sink?.audio) { + sink.audio.muted = false; + sink.audio.volume = volume; + } + } + + PwObjectTracker { + objects: [Pipewire.defaultAudioSink, Pipewire.defaultAudioSource] + } +} diff --git a/singeltons/Time.qml b/singeltons/Time.qml new file mode 100644 index 0000000..0ddf5e2 --- /dev/null +++ b/singeltons/Time.qml @@ -0,0 +1,17 @@ +// Time.qml +pragma Singleton + +import Quickshell +import QtQuick + +Singleton { + id: root + readonly property string time: { + Qt.formatDateTime(clock.date, "hh:mm") + } + + SystemClock { + id: clock + precision: SystemClock.Seconds + } +} diff --git a/utils/fuzzySearch.js b/utils/fuzzySearch.js new file mode 100644 index 0000000..e5df828 --- /dev/null +++ b/utils/fuzzySearch.js @@ -0,0 +1,70 @@ +/** + * Fuzzy search for list of objects. + * - Each object in `items` must have a `value`. Or set the search key in options. + * - Returns an array of original objects ordered by their relevance. + * - Implements a cutoff for non-matching strings based on minimum relevance. Can be set in the options. + * + * @param {string} query - The search string. + * @param {Object[]} items - The array of objects to search. Each must have a 'value' property (string). + * @param {object} [options] - Optional settings. + * @param {number} [options.cutoff=0.2] - Minimum relevance threshold (0 to 1). + * @param {string} [options.key='value'] - The property name to match against (default: 'value'). + * @param {boolean} [options.allOnEmpty=true] - Return all items on empty query. + * @param {Object} [options.boostMap] - An object mapping item keys (by 'value') to normalized boost values (0 to 1). + * @param {number} [options.boostWeight=0.1] - The maximum boost to apply for boostMap value 1.0. + * @returns {Object[]} - Array of matching objects sorted by relevance. + */ +function fuzzySearch(query, items, options = {}) { + const cutoff = options.cutoff ?? 0.2; + const key = options.key ?? 'name'; + const boostMap = options.boostMap || {}; + const boostWeight = options.boostWeight ?? 0.1; + const allOnEmpty = options.allOnEmpty == undefined || options.allOnEmpty == true; + + if (!query) return allOnEmpty ? items : [] + + const q = query.toLowerCase(); + + return items + .map(item => { + const text = (item[key] || '').toLowerCase(); + let score = 0; + + // Perfect match + if (text === q) score = 1.0; + // Starts with + else if (text.startsWith(q)) score = 0.9 + 0.01 * (1 - q.length / (text.length || 1)); + // Substring match + else { + const idx = text.indexOf(q); + if (idx !== -1) { + const startScore = 0.7 - 0.1 * (idx / (text.length || 1)); + const lengthScore = 0.2 * (q.length / (text.length || 1)); + score = startScore + lengthScore; + } else { + // Fuzzy: all query chars in order + let lastIdx = -1; + let found = true; + for (let c of q) { + lastIdx = text.indexOf(c, lastIdx + 1); + if (lastIdx === -1) { + found = false; + break; + } + } + if (found) { + score = 0.5 * (q.length / (text.length || 1)); + } + } + } + + // Boost: expects normalized boostMap (values from 0 to 1) + const boost = Math.max(0, Math.min(1, boostMap[item[key]] || 0)) * boostWeight; + + return { item, score: score + boost }; + }) + .filter(result => result.score >= cutoff) + .sort((a, b) => b.score - a.score) + .map(result => result.item); +} +