Merged #build into #main

This commit is contained in:
quadbyte 2025-08-17 11:46:06 -04:00
commit c5c06449bd
195 changed files with 15841 additions and 17212 deletions

View file

@ -1,96 +0,0 @@
name: Build and Release
on:
push:
tags:
- 'v*'
release:
types: [created]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Zig
uses: mlugg/setup-zig@v2
with:
version: 0.14.0
- name: Clone pikabar source
run: |
git clone https://git.pika-os.com/wm-packages/pikabar.git
- name: Create Programs directory
run: mkdir -p ${{ github.workspace }}/Programs
- name: Build zigstat
run: |
cd pikabar/src/zigstat
zig build-exe src/main.zig -O ReleaseSmall -mcpu x86_64_v2 --name zigstat
mv zigstat ${{ github.workspace }}/Programs/
- name: Build zigbrightness
run: |
cd pikabar/src/zigbrightness
zig build-exe src/main.zig -O ReleaseSmall -mcpu x86_64_v2 --name zigbrightness
mv zigbrightness ${{ github.workspace }}/Programs/
- name: Create release archive
run: |
# Create a clean output directory
mkdir -p ../noctalia-release
# Copy all files except .git, .github, and pikabar
cp -r . ../noctalia-release/
rm -rf ../noctalia-release/.git
rm -rf ../noctalia-release/.github
rm -rf ../noctalia-release/pikabar
# Create the archives
cd ..
tar -czf noctalia-${{ github.ref_name }}.tar.gz noctalia-release/
cp noctalia-${{ github.ref_name }}.tar.gz noctalia-latest.tar.gz
# Move archives to workspace
mv *.tar.gz ${{ github.workspace }}/
- name: Upload Release Asset
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: ./noctalia-${{ github.ref_name }}.tar.gz
asset_name: noctalia-${{ github.ref_name }}.tar.gz
asset_content_type: application/gzip
- name: Upload Latest Release Asset
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: ./noctalia-latest.tar.gz
asset_name: noctalia-latest.tar.gz
asset_content_type: application/gzip
- name: Create Release
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v1
with:
files: |
noctalia-${{ github.ref_name }}.tar.gz
noctalia-latest.tar.gz
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

63
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,63 @@
name: Build and Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Create release archive
run: |
mkdir -p ../noctalia-release
rsync -av --exclude='.git' --exclude='.github' ./ ../noctalia-release/
cd ..
tar -czf noctalia-${{ github.ref_name }}.tar.gz noctalia-release/
cp noctalia-${{ github.ref_name }}.tar.gz noctalia-latest.tar.gz
mv *.tar.gz ${{ github.workspace }}/
- name: Generate release notes
id: release_notes
run: |
PREV_TAG=$(git describe --tags --abbrev=0 @^ 2>/dev/null || echo "")
RANGE="${PREV_TAG}..HEAD"
PR_NOTES=""
COMMIT_NOTES=""
git log $RANGE --pretty=format:"%H|%s|%an" | while IFS='|' read -r SHA MSG AUTHOR; do
SHORT_MSG=$(echo "$MSG" | cut -c1-80)
if [[ "$MSG" =~ Merge\ pull\ request\ \#([0-9]+) ]]; then
PR_NUM="${BASH_REMATCH[1]}"
PR_TITLE=$(echo "$SHORT_MSG" | sed -E "s/Merge pull request #$PR_NUM //")
PR_NOTES+="- [PR #$PR_NUM](https://github.com/${GITHUB_REPOSITORY}/pull/$PR_NUM): $PR_TITLE by $AUTHOR\n"
else
COMMIT_NOTES+="- [$SHA](https://github.com/${GITHUB_REPOSITORY}/commit/$SHA): $SHORT_MSG by $AUTHOR\n"
fi
done
NOTES="### Merged PRs\n$PR_NOTES\n### Direct commits\n$COMMIT_NOTES"
echo "RELEASE_NOTES<<EOF" >> $GITHUB_ENV
echo -e "$NOTES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
files: |
noctalia-${{ github.ref_name }}.tar.gz
noctalia-latest.tar.gz
body: ${{ env.RELEASE_NOTES }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -0,0 +1,19 @@
{
"mPrimary": "#cba6f7",
"mOnPrimary": "#11111b",
"mSecondary": "#fab387",
"mOnSecondary": "#11111b",
"mTertiary": "#a6e3a1",
"mOnTertiary": "#11111b",
"mError": "#f38ba8",
"mOnError": "#11111b",
"mSurface": "#1e1e2e",
"mOnSurface": "#cdd6f4",
"mSurfaceVariant": "#313244",
"mOnSurfaceVariant": "#a3b4eb",
"mOutline": "#45475a",
"mOutlineVariant": "#2f303d",
"mShadow": "#11111b"
}

View file

@ -0,0 +1,19 @@
{
"mPrimary": "#bd93f9",
"mOnPrimary": "#282A36",
"mSecondary": "#ff79c6",
"mOnSecondary": "#4e1d32",
"mTertiary": "#8be9fd",
"mOnTertiary": "#003543",
"mError": "#FF5555",
"mOnError": "#282A36",
"mSurface": "#282A36",
"mOnSurface": "#F8F8F2",
"mSurfaceVariant": "#44475A",
"mOnSurfaceVariant": "#d6d8e0",
"mOutline": "#4d5c86",
"mOutlineVariant": "#3a4666",
"mShadow": "#282A36"
}

View file

@ -0,0 +1,19 @@
{
"mPrimary": "#b8bb26",
"mOnPrimary": "#282828",
"mSecondary": "#fabd2f",
"mOnSecondary": "#282828",
"mTertiary": "#83a598",
"mOnTertiary": "#282828",
"mError": "#fb4934",
"mOnError": "#282828",
"mSurface": "#282828",
"mOnSurface": "#fbf1c7",
"mSurfaceVariant": "#3c3836",
"mOnSurfaceVariant": "#ebdbb2",
"mOutline": "#665c54",
"mOutlineVariant": "#3c3836",
"mShadow": "#282828"
}

View file

@ -0,0 +1,20 @@
{
"mPrimary": "#c7a1d8",
"mOnPrimary": "#1a151f",
"mSecondary": "#a984c4",
"mOnSecondary": "#f3edf7",
"mTertiary": "#e0b7c9",
"mOnTertiary": "#20161f",
"mError": "#e9899d",
"mOnError": "#1e1418",
"mSurface": "#1c1822",
"mOnSurface": "#e9e4f0",
"mSurfaceVariant": "#262130",
"mOnSurfaceVariant": "#a79ab0",
"mOutline": "#4d445a",
"mOutlineVariant": "#342c42",
"mShadow": "#120f18"
}

View file

@ -0,0 +1,19 @@
{
"mPrimary": "#8fbcbb",
"mOnPrimary": "#2e3440",
"mSecondary": "#88c0d0",
"mOnSecondary": "#2e3440",
"mTertiary": "#5e81ac",
"mOnTertiary": "#2e3440",
"mError": "#bf616a",
"mOnError": "#2e3440",
"mSurface": "#2e3440",
"mOnSurface": "#d8dee9",
"mSurfaceVariant": "#3b4252",
"mOnSurfaceVariant": "#e5e9f0",
"mOutline": "#434c5e",
"mOutlineVariant": "#2e3440",
"mShadow": "#2e3440"
}

View file

@ -0,0 +1,19 @@
{
"mPrimary": "#ebbcba",
"mOnPrimary": "#1f1d2e",
"mSecondary": "#31748f",
"mOnSecondary": "#e0def4",
"mTertiary": "#9ccfd8",
"mOnTertiary": "#191724",
"mError": "#eb6f92",
"mOnError": "#1f1d2e",
"mSurface": "#1f1d2e",
"mOnSurface": "#e0def4",
"mSurfaceVariant": "#26233a",
"mOnSurfaceVariant": "#908caa",
"mOutline": "#44415a",
"mOutlineVariant": "#2e2c3c",
"mShadow": "#191724"
}

View file

@ -0,0 +1,19 @@
{
"mPrimary": "#b58900",
"mOnPrimary": "#002b36",
"mSecondary": "#d33682",
"mOnSecondary": "#002b36",
"mTertiary": "#cb4b16",
"mOnTertiary": "#002b36",
"mError": "#dc322f",
"mOnError": "#002b36",
"mSurface": "#002b36",
"mOnSurface": "#839496",
"mSurfaceVariant": "#073642",
"mOnSurfaceVariant": "#657b83",
"mOutline": "#006883",
"mOutlineVariant": "#004050",
"mShadow": "#002b36"
}

View file

@ -0,0 +1,19 @@
{
"mPrimary": "#ff9e64",
"mOnPrimary": "#1a1b26",
"mSecondary": "#ff4499",
"mOnSecondary": "#1a1b26",
"mTertiary": "#7aa2f7",
"mOnTertiary": "#1a1b26",
"mError": "#f7768e",
"mOnError": "#1f1d2e",
"mSurface": "#1a1b26",
"mOnSurface": "#a9b1d6",
"mSurfaceVariant": "#292e42",
"mOnSurfaceVariant": "#787c99",
"mOutline": "#3b4261",
"mOutlineVariant": "#282c41",
"mShadow": "#1a1b26"
}

View file

@ -0,0 +1,7 @@
# This file configures how matugen generates colors from wallpapers for Noctalia
[config]
[templates.noctalia]
input_path = "templates/noctalia.json"
output_path = "~/.config/noctalia/colors.json"

View file

@ -0,0 +1,21 @@
{
"mPrimary": "{{colors.primary.default.hex}}",
"mOnPrimary": "{{colors.on_primary.default.hex}}",
"mSecondary": "{{colors.secondary.default.hex}}",
"mOnSecondary": "{{colors.on_secondary.default.hex}}",
"mTertiary": "{{colors.tertiary.default.hex}}",
"mOnTertiary": "{{colors.on_tertiary.default.hex}}",
"mError": "{{colors.error.default.hex}}",
"mOnError": "{{colors.on_error.default.hex}}",
"mSurface": "{{colors.surface.default.hex}}",
"mOnSurface": "{{colors.on_surface.default.hex}}",
"mSurfaceVariant": "{{colors.surface_variant.default.hex}}",
"mOnSurfaceVariant": "{{colors.on_surface_variant.default.hex}}",
"mOutline": "{{colors.outline.default.hex}}",
"mOutlineVariant": "{{colors.outline_variant.default.hex}}",
"mShadow": "{{colors.shadow.default.hex}}"
}

BIN
Assets/Tests/wallpaper.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

View file

@ -1,287 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.Bar.Modules
import qs.Components
import qs.Helpers
import qs.Services
import qs.Settings
import qs.Widgets
import qs.Widgets.Notification
import qs.Widgets.SidePanel
// Main bar component - creates panels on selected monitors with widgets and corners
Scope {
id: rootScope
property var shell
property alias visible: barRootItem.visible
Item {
id: barRootItem
anchors.fill: parent
Variants {
model: Quickshell.screens
Item {
property var modelData
PanelWindow {
id: panel
screen: modelData
color: "transparent"
implicitHeight: barBackground.height
anchors.top: true
anchors.left: true
anchors.right: true
visible: Settings.settings.barMonitors.includes(modelData.name) || (Settings.settings.barMonitors.length === 0)
Rectangle {
id: barBackground
width: parent.width
height: 36 * Theme.scale(panel.screen)
color: Theme.backgroundPrimary
anchors.top: parent.top
anchors.left: parent.left
}
Row {
id: leftWidgetsRow
anchors.verticalCenter: barBackground.verticalCenter
anchors.left: barBackground.left
anchors.leftMargin: 18 * Theme.scale(panel.screen)
spacing: 12 * Theme.scale(panel.screen)
SystemInfo {
anchors.verticalCenter: parent.verticalCenter
}
Media {
anchors.verticalCenter: parent.verticalCenter
}
Taskbar {
anchors.verticalCenter: parent.verticalCenter
}
}
ActiveWindow {
screen: modelData
}
Workspace {
id: workspace
screen: modelData
anchors.horizontalCenter: barBackground.horizontalCenter
anchors.verticalCenter: barBackground.verticalCenter
}
Row {
id: rightWidgetsRow
anchors.verticalCenter: barBackground.verticalCenter
anchors.right: barBackground.right
anchors.rightMargin: 18 * Theme.scale(panel.screen)
spacing: 12 * Theme.scale(panel.screen)
SystemTray {
id: systemTrayModule
shell: rootScope.shell
anchors.verticalCenter: parent.verticalCenter
bar: panel
trayMenu: externalTrayMenu
}
CustomTrayMenu {
id: externalTrayMenu
}
NotificationIcon {
shell: rootScope.shell
anchors.verticalCenter: parent.verticalCenter
}
Wifi {
anchors.verticalCenter: parent.verticalCenter
}
Bluetooth {
anchors.verticalCenter: parent.verticalCenter
}
Battery {
id: widgetsBattery
anchors.verticalCenter: parent.verticalCenter
}
Brightness {
id: widgetsBrightness
screen: modelData
anchors.verticalCenter: parent.verticalCenter
}
Volume {
id: widgetsVolume
shell: rootScope.shell
anchors.verticalCenter: parent.verticalCenter
}
ClockWidget {
screen: modelData
anchors.verticalCenter: parent.verticalCenter
}
PanelPopup {
id: sidebarPopup
shell: rootScope.shell
}
Button {
barBackground: barBackground
anchors.verticalCenter: parent.verticalCenter
screen: modelData
sidebarPopup: sidebarPopup
}
}
}
Loader {
active: Settings.settings.showCorners && (Settings.settings.barMonitors.includes(modelData.name) || (Settings.settings.barMonitors.length === 0))
sourceComponent: Item {
PanelWindow {
id: topLeftPanel
anchors.top: true
anchors.left: true
color: "transparent"
screen: modelData
margins.top: 36 * Theme.scale(screen) - 1
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Top
WlrLayershell.namespace: "swww-daemon"
aboveWindows: false
implicitHeight: 24
Corner {
id: topLeftCorner
position: "bottomleft"
size: 1.3
fillColor: (Theme.backgroundPrimary !== undefined && Theme.backgroundPrimary !== null) ? Theme.backgroundPrimary : "#222"
offsetX: -39
offsetY: 0
anchors.top: parent.top
}
}
PanelWindow {
id: topRightPanel
anchors.top: true
anchors.right: true
color: "transparent"
screen: modelData
margins.top: 36 * Theme.scale(screen) - 1
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Top
WlrLayershell.namespace: "swww-daemon"
aboveWindows: false
implicitHeight: 24
Corner {
id: topRightCorner
position: "bottomright"
size: 1.3
fillColor: (Theme.backgroundPrimary !== undefined && Theme.backgroundPrimary !== null) ? Theme.backgroundPrimary : "#222"
offsetX: 39
offsetY: 0
anchors.top: parent.top
}
}
PanelWindow {
id: bottomLeftPanel
anchors.bottom: true
anchors.left: true
color: "transparent"
screen: modelData
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Top
WlrLayershell.namespace: "swww-daemon"
aboveWindows: false
implicitHeight: 24
Corner {
id: bottomLeftCorner
position: "topleft"
size: 1.3
fillColor: Theme.backgroundPrimary
offsetX: -39
offsetY: 0
anchors.top: parent.top
}
}
PanelWindow {
id: bottomRightPanel
anchors.bottom: true
anchors.right: true
color: "transparent"
screen: modelData
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Top
WlrLayershell.namespace: "swww-daemon"
aboveWindows: false
implicitHeight: 24
Corner {
id: bottomRightCorner
position: "topright"
size: 1.3
fillColor: Theme.backgroundPrimary
offsetX: 39
offsetY: 0
anchors.top: parent.top
}
}
}
}
}
}
}
}

View file

@ -1,146 +0,0 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Components
import qs.Settings
PanelWindow {
id: activeWindowPanel
// Lower case "screen" from modelData
property int barHeight: 36 * Theme.scale(screen)
screen: (typeof modelData !== 'undefined' ? modelData : null)
WlrLayershell.exclusionMode: ExclusionMode.Ignore
anchors.top: true
anchors.left: true
anchors.right: true
focusable: false
margins.top: barHeight
visible: Settings.settings.showActiveWindow && !activeWindowWrapper.finallyHidden
implicitHeight: activeWindowTitleContainer.height
implicitWidth: 0
color: "transparent"
function getIcon() {
var icon = Quickshell.iconPath(ToplevelManager.activeToplevel.appId.toLowerCase(), true);
if (!icon) {
icon = Quickshell.iconPath(ToplevelManager.activeToplevel.appId, true);
}
if (!icon) {
icon = Quickshell.iconPath(ToplevelManager.activeToplevel.title, true);
}
if (!icon) {
icon = Quickshell.iconPath(ToplevelManager.activeToplevel.title.toLowerCase(), "application-x-executable");
}
return icon;
}
Item {
id: activeWindowWrapper
width: parent.width
property int fullHeight: activeWindowTitleContainer.height
property bool shouldShow: false
property bool finallyHidden: false
Timer {
id: visibilityTimer
interval: 1500
running: false
onTriggered: {
activeWindowWrapper.shouldShow = false;
hideTimer.restart();
}
}
Timer {
id: hideTimer
interval: 300
running: false
onTriggered: {
activeWindowWrapper.finallyHidden = true;
}
}
Connections {
target: ToplevelManager
function onActiveToplevelChanged() {
if (ToplevelManager.activeToplevel?.appId) {
activeWindowWrapper.shouldShow = true;
activeWindowWrapper.finallyHidden = false;
visibilityTimer.restart();
} else {
activeWindowWrapper.shouldShow = false;
hideTimer.restart();
visibilityTimer.stop();
}
}
}
y: shouldShow ? 0 : -activeWindowPanel.barHeight
height: shouldShow ? fullHeight : 1
opacity: shouldShow ? 1 : 0
clip: true
Behavior on height {
NumberAnimation {
duration: 300
easing.type: Easing.OutQuad
}
}
Behavior on y {
NumberAnimation {
duration: 300
easing.type: Easing.OutQuad
}
}
Behavior on opacity {
NumberAnimation {
duration: 250
}
}
Rectangle {
id: activeWindowTitleContainer
color: Theme.backgroundPrimary
width: Math.min(barBackground.width - 200, activeWindowTitle.implicitWidth + (Settings.settings.showActiveWindowIcon ? 28 : 22)) + 16
height: activeWindowTitle.implicitHeight + 12
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
bottomLeftRadius: Math.max(0, width / 2)
bottomRightRadius: Math.max(0, width / 2)
IconImage {
id: icon
width: 12
height: 12
anchors.left: parent.left
anchors.leftMargin: 14
anchors.verticalCenter: parent.verticalCenter
source: ToplevelManager?.activeToplevel ? getIcon() : ""
visible: Settings.settings.showActiveWindowIcon
anchors.verticalCenterOffset: -3
}
Text {
id: activeWindowTitle
text: ToplevelManager?.activeToplevel?.title && ToplevelManager?.activeToplevel?.title.length > 60 ? ToplevelManager?.activeToplevel?.title.substring(0, 60) + "..." : ToplevelManager?.activeToplevel?.title || ""
font.pixelSize: 12 * Theme.scale(screen)
color: Theme.textSecondary
anchors.left: icon.right
anchors.leftMargin: Settings.settings.showActiveWindowIcon ? 4 : 6
anchors.right: parent.right
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: -3
horizontalAlignment: Settings.settings.showActiveWindowIcon ? Text.AlignRight : Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
maximumLineCount: 1
}
}
}
}

View file

@ -1,938 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Components
import qs.Settings
import "../../Helpers/Fuzzysort.js" as Fuzzysort
PanelWithOverlay {
Timer {
id: clipboardTimer
interval: 1000
repeat: true
running: appLauncherPanel.visible
onTriggered: {
updateClipboardHistory();
}
}
property var clipboardHistory: []
property bool clipboardInitialized: false
Process {
id: clipboardTypeProcess
property bool isLoading: false
property var currentTypes: []
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
currentTypes = String(stdout.text).trim().split('\n').filter(t => t);
const imageType = currentTypes.find(t => t.startsWith('image/'));
if (imageType) {
clipboardImageProcess.mimeType = imageType;
clipboardImageProcess.command = ["sh", "-c", `wl-paste -n -t "${imageType}" | base64 -w 0`];
clipboardImageProcess.running = true;
} else {
clipboardHistoryProcess.command = ["wl-paste", "-n", "--type", "text/plain"];
clipboardHistoryProcess.running = true;
}
} else {
clipboardTypeProcess.isLoading = false;
}
}
stdout: StdioCollector {}
}
Process {
id: clipboardImageProcess
property string mimeType: ""
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
const base64 = stdout.text.trim();
if (base64) {
const entry = {
type: 'image',
mimeType: mimeType,
data: `data:${mimeType};base64,${base64}`,
timestamp: new Date().getTime()
};
const exists = clipboardHistory.find(item =>
item.type === 'image' && item.data === entry.data
);
if (!exists) {
clipboardHistory = [entry, ...clipboardHistory].slice(0, 20);
root.updateFilter();
}
}
}
if (!clipboardHistoryProcess.isLoading) {
clipboardInitialized = true;
}
clipboardTypeProcess.isLoading = false;
}
stdout: StdioCollector {}
}
Process {
id: clipboardHistoryProcess
property bool isLoading: false
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
const content = String(stdout.text).trim();
// Only filter out self path to avoid capturing this file
const isSelfPath = content.indexOf("/home/lysec/.config/quickshell/Bar/Modules/Applauncher.qml") !== -1;
if (content && !isSelfPath) {
const entry = {
type: 'text',
content: content,
timestamp: new Date().getTime()
};
const exists = clipboardHistory.find(item => {
if (item.type === 'text') {
return item.content === content;
}
return item === content;
});
if (!exists) {
const newHistory = clipboardHistory.map(item => {
if (typeof item === 'string') {
return {
type: 'text',
content: item,
timestamp: new Date().getTime()
};
}
return item;
});
clipboardHistory = [entry, ...newHistory].slice(0, 20);
}
}
} else {
clipboardHistoryProcess.isLoading = false;
}
clipboardInitialized = true;
clipboardTypeProcess.isLoading = false;
root.updateFilter();
}
stdout: StdioCollector {}
}
Process {
id: clipboardImageCopyProcess
property string mimeType: ""
property string pendingBase64: ""
// Simple FIFO queue for repeated copy requests
property var queue: []
stdinEnabled: true
command: [
"sh",
"-c",
"base64 -d | wl-copy -t '" + mimeType + "'"
]
function copyBase64(mime, base64) {
// If a copy is in progress or pending, queue the next request
if (running || (pendingBase64 && pendingBase64.length > 0)) {
var q = queue.slice();
q.push({ mime: mime, base64: base64 });
queue = q;
return;
}
mimeType = mime;
pendingBase64 = base64;
running = true;
}
onStarted: {
// ensure stdin is open for each new run
stdinEnabled = true;
if (pendingBase64 && pendingBase64.length > 0) {
write(pendingBase64);
}
// Close stdin to signal EOF so base64 exits
stdinEnabled = false;
}
onExited: (exitCode, exitStatus) => {
pendingBase64 = "";
// re-open stdin for the next run so we can copy repeatedly
stdinEnabled = true;
if (queue.length > 0) {
var next = queue[0];
// pop front
var q2 = queue.slice(1);
queue = q2;
mimeType = next.mime;
pendingBase64 = next.base64;
running = true;
}
}
stdout: StdioCollector {}
}
// Process to copy arbitrary text via stdin to avoid quoting/ARG_MAX issues
Process {
id: clipboardTextCopyProcess
property string pendingText: ""
property var queue: []
stdinEnabled: true
command: [
"sh",
"-c",
"cat | wl-copy -t text/plain;charset=utf-8"
]
function copyText(text) {
if (running || (pendingText && pendingText.length > 0)) {
var q = queue.slice();
q.push(text);
queue = q;
return;
}
pendingText = text;
running = true;
}
onStarted: {
stdinEnabled = true;
if (pendingText && pendingText.length > 0) {
write(pendingText);
}
stdinEnabled = false;
}
onExited: (exitCode, exitStatus) => {
pendingText = "";
stdinEnabled = true;
if (queue.length > 0) {
var next = queue[0];
queue = queue.slice(1);
pendingText = next;
running = true;
}
}
stdout: StdioCollector {}
}
function updateClipboardHistory() {
if (!clipboardTypeProcess.isLoading && !clipboardHistoryProcess.isLoading) {
clipboardTypeProcess.isLoading = true;
clipboardTypeProcess.command = ["wl-paste", "-l"];
clipboardTypeProcess.running = true;
}
}
id: appLauncherPanel
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
function isPinned(app) {
return app && app.execString && Settings.settings.pinnedExecs.indexOf(app.execString) !== -1;
}
function togglePin(app) {
if (!app || !app.execString) return;
var arr = Settings.settings.pinnedExecs ? Settings.settings.pinnedExecs.slice() : [];
var idx = arr.indexOf(app.execString);
if (idx === -1) {
arr.push(app.execString);
} else {
arr.splice(idx, 1);
}
Settings.settings.pinnedExecs = arr;
root.updateFilter();
}
function showAt() {
appLauncherPanelRect.showAt();
}
function hidePanel() {
appLauncherPanelRect.hidePanel();
}
function show() {
appLauncherPanelRect.showAt();
}
function dismiss() {
appLauncherPanelRect.hidePanel();
}
Rectangle {
id: appLauncherPanelRect
implicitWidth: 460
implicitHeight: 640
color: "transparent"
visible: parent.visible
property bool shouldBeVisible: false
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
function showAt() {
appLauncherPanel.visible = true;
shouldBeVisible = true;
searchField.forceActiveFocus();
root.selectedIndex = 0;
root.appModel = DesktopEntries.applications.values;
root.updateFilter();
// Start clipboard refresh immediately on open so >clip is ready
updateClipboardHistory();
}
function hidePanel() {
shouldBeVisible = false;
searchField.text = "";
root.selectedIndex = 0;
}
// Prevent closing when clicking in the panel bg
MouseArea {
anchors.fill: parent
}
Rectangle {
id: root
width: 460
height: 640
x: (parent.width - width) / 2
color: Theme.backgroundPrimary
bottomLeftRadius: 28
bottomRightRadius: 28
property var appModel: DesktopEntries.applications.values
property var filteredApps: []
property int selectedIndex: 0
property int targetY: (parent.height - height) / 2
y: appLauncherPanelRect.shouldBeVisible ? targetY : -height
Behavior on y {
NumberAnimation {
duration: 300
easing.type: Easing.OutCubic
}
}
scale: appLauncherPanelRect.shouldBeVisible ? 1 : 0
Behavior on scale {
NumberAnimation {
duration: 200
easing.type: Easing.InOutCubic
}
}
onScaleChanged: {
if (scale === 0 && !appLauncherPanelRect.shouldBeVisible) {
appLauncherPanel.visible = false;
}
}
function isMathExpression(str) {
return /^[-+*/().0-9\s]+$/.test(str);
}
function safeEval(expr) {
try {
return Function('return (' + expr + ')')();
} catch (e) {
return undefined;
}
}
function updateFilter() {
var query = searchField.text ? searchField.text.toLowerCase() : "";
var apps = root.appModel.slice();
var results = [];
if (query === ">") {
results.push({
isCommand: true,
name: ">calc",
content: "Calculator - evaluate mathematical expressions",
icon: "calculate",
execute: function() {
searchField.text = ">calc ";
searchField.cursorPosition = searchField.text.length;
}
});
results.push({
isCommand: true,
name: ">clip",
content: "Clipboard history - browse and restore clipboard items",
icon: "content_paste",
execute: function() {
searchField.text = ">clip ";
searchField.cursorPosition = searchField.text.length;
}
});
root.filteredApps = results;
return;
}
if (query.startsWith(">clip")) {
if (!clipboardInitialized) {
updateClipboardHistory();
}
const searchTerm = query.slice(5).trim();
clipboardHistory.forEach(function(clip, index) {
let searchContent = clip.type === 'image' ?
clip.mimeType :
clip.content || clip; // Support both new object format and old string format
if (!searchTerm || searchContent.toLowerCase().includes(searchTerm)) {
let entry;
if (clip.type === 'image') {
entry = {
isClipboard: true,
name: "Image from " + new Date(clip.timestamp).toLocaleTimeString(),
content: "Image: " + clip.mimeType,
icon: "image",
type: 'image',
data: clip.data,
execute: function() {
// Restore image via stdin to avoid command-length limits
const base64Data = clip.data.split(',')[1];
clipboardImageCopyProcess.copyBase64(clip.mimeType, base64Data);
Quickshell.execDetached(["notify-send", "Clipboard", "Image copied: " + clip.mimeType]);
}
};
} else {
const textContent = clip.content || clip; // Support both new object format and old string format
let displayContent = textContent;
let previewContent = "";
// Clean up whitespace for display
displayContent = displayContent.replace(/\s+/g, ' ').trim();
// Truncate long content and show preview
if (displayContent.length > 50) {
previewContent = displayContent;
// Show first line or first 50 characters as title
displayContent = displayContent.split('\n')[0].substring(0, 50) + "...";
}
entry = {
isClipboard: true,
name: displayContent,
content: previewContent || textContent,
icon: "content_paste",
execute: function() {
// Set Quickshell clipboard as primary path; also stream to wl-copy for system clipboard
Quickshell.clipboardText = String(textContent);
clipboardTextCopyProcess.copyText(String(textContent));
var preview = (textContent.length > 50) ? textContent.slice(0,50) + "…" : textContent;
Quickshell.execDetached(["notify-send", "Clipboard", "Text copied: " + preview]);
}
};
}
results.push(entry);
}
});
if (results.length === 0) {
results.push({
isClipboard: true,
name: "No clipboard history",
content: "No matching clipboard entries found",
icon: "content_paste_off"
});
}
root.filteredApps = results;
return;
}
if (query.startsWith(">calc")) {
var expr = searchField.text.slice(5).trim();
if (expr && isMathExpression(expr)) {
var value = safeEval(expr);
if (value !== undefined && value !== null && value !== "") {
results.push({
isCalculator: true,
name: `Calculator: ${expr} = ${value}`,
result: value,
expr: expr,
icon: "calculate"
});
}
}
var pinned = [];
var unpinned = [];
for (var i = 0; i < results.length; ++i) {
var app = results[i];
if (app.execString && Settings.settings.pinnedExecs.indexOf(app.execString) !== -1) {
pinned.push(app);
} else {
unpinned.push(app);
}
}
// Sort pinned apps alphabetically for consistent display
pinned.sort(function(a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
root.filteredApps = pinned.concat(unpinned);
root.selectedIndex = 0;
return;
}
if (!query) {
results = results.concat(apps.sort(function (a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
}));
} else {
var fuzzyResults = Fuzzysort.go(query, apps, {
keys: ["name", "comment", "genericName"]
});
results = results.concat(fuzzyResults.map(function (r) {
return r.obj;
}));
}
var pinned = [];
var unpinned = [];
for (var i = 0; i < results.length; ++i) {
var app = results[i];
if (app.execString && Settings.settings.pinnedExecs.indexOf(app.execString) !== -1) {
pinned.push(app);
} else {
unpinned.push(app);
}
}
// Sort pinned alphabetically
pinned.sort(function(a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
root.filteredApps = pinned.concat(unpinned);
root.selectedIndex = 0;
}
function selectNext() {
if (filteredApps.length > 0)
selectedIndex = Math.min(selectedIndex + 1, filteredApps.length - 1);
}
function selectPrev() {
if (filteredApps.length > 0)
selectedIndex = Math.max(selectedIndex - 1, 0);
}
function activateSelected() {
if (filteredApps.length === 0)
return;
var modelData = filteredApps[selectedIndex];
const termEmu = Quickshell.env("TERMINAL") || Quickshell.env("TERM_PROGRAM") || "";
if (modelData.isCalculator) {
Qt.callLater(function () {
Quickshell.clipboardText = String(modelData.result);
Quickshell.execDetached(["notify-send", "Calculator Result", `${modelData.expr} = ${modelData.result} (copied to clipboard)`]);
});
} else if (modelData.isCommand) {
modelData.execute();
return;
} else if (modelData.runInTerminal && termEmu){
Quickshell.execDetached([termEmu, "-e", modelData.execString.trim()]);
} else if (modelData.execute) {
modelData.execute();
} else {
var execCmd = modelData.execString || modelData.exec || "";
if (execCmd) {
execCmd = execCmd.replace(/\s?%[fFuUdDnNiCkvm]/g, '');
Quickshell.execDetached(["sh", "-c", execCmd.trim()]);
}
}
appLauncherPanel.hidePanel();
searchField.text = "";
}
Component.onCompleted: updateFilter()
RowLayout {
anchors.fill: parent
anchors.margins: 32
spacing: 18
Rectangle {
id: previewPanel
Layout.preferredWidth: 200
Layout.fillHeight: true
color: Theme.surface
radius: 20
visible: false
Rectangle {
anchors.fill: parent
anchors.margins: 16
color: "transparent"
clip: true
Image {
id: previewImage
anchors.fill: parent
fillMode: Image.PreserveAspectFit
asynchronous: true
cache: true
smooth: true
}
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 18
Rectangle {
id: searchBar
color: Theme.surfaceVariant
radius: 20
height: 48
Layout.fillWidth: true
border.color: searchField.activeFocus ? Theme.accentPrimary : Theme.outline
border.width: searchField.activeFocus ? 2 : 1
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 14
anchors.rightMargin: 14
spacing: 10
Text {
text: "search"
font.family: "Material Symbols Outlined"
font.pixelSize: Theme.fontSizeHeader * Theme.scale(screen)
color: searchField.activeFocus ? Theme.accentPrimary : Theme.textSecondary
verticalAlignment: Text.AlignVCenter
Layout.alignment: Qt.AlignVCenter
}
TextField {
id: searchField
placeholderText: "Search apps..."
color: Theme.textPrimary
placeholderTextColor: Theme.textSecondary
background: null
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeBody * Theme.scale(screen)
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
onTextChanged: root.updateFilter()
selectedTextColor: Theme.onAccent
selectionColor: Theme.accentPrimary
padding: 0
verticalAlignment: TextInput.AlignVCenter
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
font.bold: true
Component.onCompleted: contentItem.cursorColor = Theme.textPrimary
onActiveFocusChanged: contentItem.cursorColor = Theme.textPrimary
Keys.onDownPressed: root.selectNext()
Keys.onUpPressed: root.selectPrev()
Keys.onEnterPressed: root.activateSelected()
Keys.onReturnPressed: root.activateSelected()
Keys.onEscapePressed: appLauncherPanel.hidePanel()
}
}
Behavior on border.color {
ColorAnimation {
duration: 120
}
}
Behavior on border.width {
NumberAnimation {
duration: 120
}
}
}
Rectangle {
color: Theme.surface
radius: 20
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
property int innerPadding: 16
ListView {
id: appList
anchors.fill: parent
anchors.margins: parent.innerPadding
spacing: 2
model: root.filteredApps
currentIndex: root.selectedIndex
delegate: Item {
id: appDelegate
width: appList.width
height: (modelData.isClipboard || modelData.isCommand) ? 64 : 48
property bool hovered: mouseArea.containsMouse
property bool isSelected: index === root.selectedIndex
Rectangle {
anchors.fill: parent
color: (hovered || isSelected)
? Theme.accentPrimary
: (appLauncherPanel.isPinned(modelData) ? Theme.surfaceVariant : "transparent")
radius: 12
border.color: appLauncherPanel.isPinned(modelData)
? "transparent"
: (hovered || isSelected ? Theme.accentPrimary : "transparent")
border.width: appLauncherPanel.isPinned(modelData) ? 0 : (hovered || isSelected ? 2 : 0)
Behavior on color {
ColorAnimation {
duration: 120
}
}
Behavior on border.color {
ColorAnimation {
duration: 120
}
}
Behavior on border.width {
NumberAnimation {
duration: 120
}
}
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 10
Item {
width: 28
height: 28
property bool iconLoaded: !modelData.isCalculator && !modelData.isClipboard && !modelData.isCommand && iconImg.status === Image.Ready && iconImg.source !== "" && iconImg.status !== Image.Error
Image {
id: clipboardImage
anchors.fill: parent
visible: modelData.type === 'image'
source: modelData.data || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
MouseArea {
anchors.fill: parent
hoverEnabled: true
propagateComposedEvents: true
onContainsMouseChanged: {
if (containsMouse && modelData.type === 'image') {
previewImage.source = modelData.data;
previewPanel.visible = true;
} else {
previewPanel.visible = false;
}
}
onMouseXChanged: mouse.accepted = false
onMouseYChanged: mouse.accepted = false
onClicked: mouse.accepted = false
}
}
IconImage {
id: iconImg
anchors.fill: parent
asynchronous: true
source: modelData.isCalculator ? "qrc:/icons/calculate.svg" :
modelData.isClipboard ? "qrc:/icons/" + modelData.icon + ".svg" :
modelData.isCommand ? "qrc:/icons/" + modelData.icon + ".svg" :
Quickshell.iconPath(modelData.icon, "application-x-executable")
visible: (modelData.isCalculator || modelData.isClipboard || modelData.isCommand || parent.iconLoaded) && modelData.type !== 'image'
}
Text {
anchors.centerIn: parent
visible: !modelData.isCalculator && !modelData.isClipboard && !modelData.isCommand && !parent.iconLoaded && modelData.type !== 'image'
text: "broken_image"
font.family: "Material Symbols Outlined"
font.pixelSize: Theme.fontSizeHeader * Theme.scale(screen)
color: Theme.accentPrimary
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 1
Text {
text: modelData.name
color: (hovered || isSelected) ? Theme.onAccent : (appLauncherPanel.isPinned(modelData) ? Theme.textPrimary : Theme.textPrimary)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall * Theme.scale(screen)
font.bold: hovered || isSelected
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
Layout.fillWidth: true
}
Text {
text: modelData.isCalculator ? (modelData.expr + " = " + modelData.result) :
modelData.isClipboard ? modelData.content :
modelData.isCommand ? modelData.content :
(modelData.comment || modelData.genericName || "No description available")
color: (hovered || isSelected) ? Theme.onAccent : (appLauncherPanel.isPinned(modelData) ? Theme.textSecondary : Theme.textSecondary)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeCaption * Theme.scale(screen)
font.italic: !(modelData.comment || modelData.genericName)
opacity: modelData.isClipboard ? 0.8 : modelData.isCommand ? 0.9 : ((modelData.comment || modelData.genericName) ? 1.0 : 0.6)
elide: Text.ElideRight
maximumLineCount: (modelData.isClipboard || modelData.isCommand) ? 2 : 1
wrapMode: (modelData.isClipboard || modelData.isCommand) ? Text.WordWrap : Text.NoWrap
Layout.fillWidth: true
Layout.preferredHeight: (modelData.isClipboard || modelData.isCommand) ? implicitHeight : contentHeight
}
}
Item {
Layout.fillWidth: true
}
Text {
text: modelData.isCalculator ? "content_copy" : "chevron_right"
font.family: "Material Symbols Outlined"
font.pixelSize: Theme.fontSizeBody * Theme.scale(screen)
color: (hovered || isSelected)
? Theme.onAccent
: (appLauncherPanel.isPinned(modelData) ? Theme.textPrimary : Theme.textSecondary)
verticalAlignment: Text.AlignVCenter
Layout.rightMargin: 8
}
Item { width: 8; height: 1 }
}
Rectangle {
id: ripple
anchors.fill: parent
color: Theme.onAccent
opacity: 0.0
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: {
if (pinArea.containsMouse) return;
if (mouse.button === Qt.RightButton) {
appLauncherPanel.togglePin(modelData);
return;
}
ripple.opacity = 0.18;
rippleNumberAnimation.start();
root.selectedIndex = index;
root.activateSelected();
}
cursorShape: Qt.PointingHandCursor
onPressed: ripple.opacity = 0.18
onReleased: ripple.opacity = 0.0
}
NumberAnimation {
id: rippleNumberAnimation
target: ripple
property: "opacity"
to: 0.0
duration: 320
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: Math.max(1, 1 * Theme.scale(screen))
color: Theme.outline
opacity: index === appList.count - 1 ? 0 : 0.10
}
Item {
id: pinArea
width: 28; height: 28
z: 100
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
preventStealing: true
z: 100
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
propagateComposedEvents: false
onClicked: {
appLauncherPanel.togglePin(modelData);
event.accepted = true;
}
}
Text {
anchors.centerIn: parent
text: "star"
font.family: "Material Symbols Outlined"
font.pixelSize: Theme.fontSizeSmall * Theme.scale(screen)
color: (parent.MouseArea.containsMouse || hovered || isSelected)
? Theme.onAccent
: (appLauncherPanel.isPinned(modelData) ? Theme.textPrimary : Theme.textDisabled)
verticalAlignment: Text.AlignVCenter
}
}
}
}
}
}
}
}
}
}

View file

@ -1,370 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Services.Pipewire
import qs.Components
import qs.Settings
PanelWithOverlay {
id: ioSelector
property int tabIndex: 0
property Item anchorItem: null
signal panelClosed()
function sinkNodes() {
let nodes = Pipewire.nodes && Pipewire.nodes.values ? Pipewire.nodes.values.filter(function(n) {
return n.isSink && n.audio && n.isStream === false;
}) : [];
if (Pipewire.defaultAudioSink)
nodes = nodes.slice().sort(function(a, b) {
if (a.id === Pipewire.defaultAudioSink.id)
return -1;
if (b.id === Pipewire.defaultAudioSink.id)
return 1;
return 0;
});
return nodes;
}
function sourceNodes() {
let nodes = Pipewire.nodes && Pipewire.nodes.values ? Pipewire.nodes.values.filter(function(n) {
return !n.isSink && n.audio && n.isStream === false;
}) : [];
if (Pipewire.defaultAudioSource)
nodes = nodes.slice().sort(function(a, b) {
if (a.id === Pipewire.defaultAudioSource.id)
return -1;
if (b.id === Pipewire.defaultAudioSource.id)
return 1;
return 0;
});
return nodes;
}
Component.onCompleted: {
if (Pipewire.nodes && Pipewire.nodes.values) {
for (var i = 0; i < Pipewire.nodes.values.length; ++i) {
var n = Pipewire.nodes.values[i];
}
}
}
Component.onDestruction: {
}
onVisibleChanged: {
if (!visible)
panelClosed();
}
// Bind all Pipewire nodes so their properties are valid
PwObjectTracker {
id: nodeTracker
objects: Pipewire.nodes
}
Rectangle {
color: Theme.backgroundPrimary
radius: 20
width: 340
height: 340
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: 4
anchors.rightMargin: 4
// Prevent closing when clicking in the panel bg
MouseArea {
anchors.fill: parent
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 10
// Tabs centered inside the window
RowLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
spacing: 0
Tabs {
id: ioTabs
tabsModel: [{
"label": "Output",
"icon": "volume_up"
}, {
"label": "Input",
"icon": "mic"
}]
currentIndex: tabIndex
onTabChanged: {
tabIndex = currentIndex;
}
}
}
// Add vertical space between tabs and entries
Item {
height: 36
Layout.fillWidth: true
}
// Output Devices
Flickable {
id: sinkList
visible: tabIndex === 0
contentHeight: sinkColumn.height
clip: true
interactive: contentHeight > height
width: parent.width
height: 220
ColumnLayout {
id: sinkColumn
width: sinkList.width
spacing: 6
Repeater {
model: ioSelector.sinkNodes()
Rectangle {
width: parent.width
height: 36
color: "transparent"
radius: 6
RowLayout {
anchors.fill: parent
anchors.margins: 6
spacing: 8
Text {
text: "volume_up"
font.family: "Material Symbols Outlined"
font.pixelSize: 16 * Theme.scale(screen)
color: (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.id === modelData.id) ? Theme.accentPrimary : Theme.textPrimary
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
Layout.fillWidth: true
spacing: 1
Layout.maximumWidth: sinkList.width - 120 // Reserve space for the Set button
Text {
text: modelData.nickname || modelData.description || modelData.name
font.bold: true
font.pixelSize: 12 * Theme.scale(screen)
color: (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.id === modelData.id) ? Theme.accentPrimary : Theme.textPrimary
elide: Text.ElideRight
maximumLineCount: 1
Layout.fillWidth: true
}
Text {
text: modelData.description !== modelData.nickname ? modelData.description : ""
font.pixelSize: 10 * Theme.scale(screen)
color: Theme.textSecondary
elide: Text.ElideRight
maximumLineCount: 1
Layout.fillWidth: true
}
}
Rectangle {
visible: Pipewire.preferredDefaultAudioSink !== modelData
width: 60
height: 20
radius: 4
color: Theme.accentPrimary
border.color: Theme.accentPrimary
border.width: 1
Layout.alignment: Qt.AlignVCenter
Text {
anchors.centerIn: parent
text: "Set"
color: Theme.onAccent
font.pixelSize: 10 * Theme.scale(screen)
font.bold: true
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: Pipewire.preferredDefaultAudioSink = modelData
}
}
Text {
text: "(Current)"
visible: Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.id === modelData.id
color: Theme.accentPrimary
font.pixelSize: 10 * Theme.scale(screen)
Layout.alignment: Qt.AlignVCenter
}
}
}
}
}
ScrollBar.vertical: ScrollBar {
}
}
// Input Devices
Flickable {
id: sourceList
visible: tabIndex === 1
contentHeight: sourceColumn.height
clip: true
interactive: contentHeight > height
width: parent.width
height: 220
ColumnLayout {
id: sourceColumn
width: sourceList.width
spacing: 6
Repeater {
model: ioSelector.sourceNodes()
Rectangle {
width: parent.width
height: 36
color: "transparent"
radius: 6
RowLayout {
anchors.fill: parent
anchors.margins: 6
spacing: 8
Text {
text: "mic"
font.family: "Material Symbols Outlined"
font.pixelSize: 16 * Theme.scale(screen)
color: (Pipewire.defaultAudioSource && Pipewire.defaultAudioSource.id === modelData.id) ? Theme.accentPrimary : Theme.textPrimary
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
Layout.fillWidth: true
spacing: 1
Layout.maximumWidth: sourceList.width - 120 // Reserve space for the Set button
Text {
text: modelData.nickname || modelData.description || modelData.name
font.bold: true
font.pixelSize: 12 * Theme.scale(screen)
color: (Pipewire.defaultAudioSource && Pipewire.defaultAudioSource.id === modelData.id) ? Theme.accentPrimary : Theme.textPrimary
elide: Text.ElideRight
maximumLineCount: 1
Layout.fillWidth: true
}
Text {
text: modelData.description !== modelData.nickname ? modelData.description : ""
font.pixelSize: 10 * Theme.scale(screen)
color: Theme.textSecondary
elide: Text.ElideRight
maximumLineCount: 1
Layout.fillWidth: true
}
}
Rectangle {
visible: Pipewire.preferredDefaultAudioSource !== modelData
width: 60
height: 20
radius: 4
color: Theme.accentPrimary
border.color: Theme.accentPrimary
border.width: 1
Layout.alignment: Qt.AlignVCenter
Text {
anchors.centerIn: parent
text: "Set"
color: Theme.onAccent
font.pixelSize: 10 * Theme.scale(screen)
font.bold: true
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: Pipewire.preferredDefaultAudioSource = modelData
}
}
Text {
text: "(Current)"
visible: Pipewire.defaultAudioSource && Pipewire.defaultAudioSource.id === modelData.id
color: Theme.accentPrimary
font.pixelSize: 10 * Theme.scale(screen)
Layout.alignment: Qt.AlignVCenter
}
}
}
}
}
ScrollBar.vertical: ScrollBar {
}
}
}
}
Connections {
function onReadyChanged() {
if (Pipewire.ready && Pipewire.nodes && Pipewire.nodes.values) {
for (var i = 0; i < Pipewire.nodes.values.length; ++i) {
var n = Pipewire.nodes.values[i];
}
}
}
function onDefaultAudioSinkChanged() {
}
function onDefaultAudioSourceChanged() {
}
target: Pipewire
}
}

View file

@ -1,119 +0,0 @@
import QtQuick
import Quickshell
import Quickshell.Services.UPower
import QtQuick.Layouts
import qs.Components
import qs.Settings
import "../../Helpers/Time.js" as Time
Item {
id: batteryWidget
// Test mode
property bool testMode: false
property int testPercent: 49
property bool testCharging: true
property var battery: UPower.displayDevice
property bool isReady: testMode ? true : (battery && battery.ready && battery.isLaptopBattery && battery.isPresent)
property real percent: testMode ? testPercent : (isReady ? (battery.percentage * 100) : 0)
property bool charging: testMode ? testCharging : (isReady ? battery.state === UPowerDeviceState.Charging : false)
property bool show: isReady && percent > 0
// Choose icon based on charge and charging state
function batteryIcon() {
if (!show)
return "";
if (charging)
return "battery_android_bolt";
if (percent >= 95)
return "battery_android_full";
// Hardcoded battery symbols
if (percent >= 85)
return "battery_android_6";
if (percent >= 70)
return "battery_android_5";
if (percent >= 55)
return "battery_android_4";
if (percent >= 40)
return "battery_android_3";
if (percent >= 25)
return "battery_android_2";
if (percent >= 10)
return "battery_android_1";
if (percent >= 0)
return "battery_android_0";
}
visible: testMode || (isReady && battery.isLaptopBattery)
width: pill.width
height: pill.height
PillIndicator {
id: pill
icon: batteryWidget.batteryIcon()
text: Math.round(batteryWidget.percent) + "%"
pillColor: Theme.surfaceVariant
iconCircleColor: Theme.accentPrimary
iconTextColor: Theme.backgroundPrimary
textColor: charging ? Theme.accentPrimary : Theme.textPrimary
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: {
pill.showDelayed();
batteryTooltip.tooltipVisible = true;
}
onExited: {
pill.hide();
batteryTooltip.tooltipVisible = false;
}
}
StyledTooltip {
id: batteryTooltip
positionAbove: false
text: {
let lines = [];
if (!batteryWidget.isReady) {
return "";
}
if (batteryWidget.battery.timeToEmpty > 0) {
lines.push("Time left: " + Time.formatVagueHumanReadableTime(batteryWidget.battery.timeToEmpty));
}
if (batteryWidget.battery.timeToFull > 0) {
lines.push("Time until full: " + Time.formatVagueHumanReadableTime(batteryWidget.battery.timeToFull));
}
if (batteryWidget.battery.changeRate !== undefined) {
const rate = batteryWidget.battery.changeRate;
if (rate > 0) {
lines.push(batteryWidget.charging ? "Charging rate: " + rate.toFixed(2) + " W" : "Discharging rate: " + rate.toFixed(2) + " W");
}
else if (rate < 0) {
lines.push("Discharging rate: " + Math.abs(rate).toFixed(2) + " W");
}
else {
lines.push("Estimating...");
}
}
else {
lines.push(batteryWidget.charging ? "Charging" : "Discharging");
}
if (batteryWidget.battery.healthPercentage !== undefined && batteryWidget.battery.healthPercentage > 0) {
lines.push("Health: " + Math.round(batteryWidget.battery.healthPercentage) + "%");
}
return lines.join("\n");
}
tooltipVisible: false
targetItem: pill
delay: 1500
}
}
}

View file

@ -1,282 +0,0 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import Quickshell.Bluetooth
import qs.Settings
import qs.Components
Item {
id: root
width: Settings.settings.bluetoothEnabled ? 22 : 0
height: Settings.settings.bluetoothEnabled ? 22 : 0
property bool menuVisible: false
// Bluetooth icon/button
Item {
id: bluetoothIcon
width: 22; height: 22
visible: Settings.settings.bluetoothEnabled
// Check if any devices are currently connected
property bool hasConnectedDevices: {
if (!Bluetooth.defaultAdapter) return false;
for (let i = 0; i < Bluetooth.defaultAdapter.devices.count; i++) {
if (Bluetooth.defaultAdapter.devices.valueAt(i).connected) {
return true;
}
}
return false;
}
Text {
id: bluetoothText
anchors.centerIn: parent
text: {
if (!Bluetooth.defaultAdapter || !Bluetooth.defaultAdapter.enabled) {
return "bluetooth_disabled"
} else if (parent.hasConnectedDevices) {
return "bluetooth_connected"
} else {
return "bluetooth"
}
}
font.family: mouseAreaBluetooth.containsMouse ? "Material Symbols Rounded" : "Material Symbols Outlined"
font.pixelSize: 16 * Theme.scale(Screen)
color: mouseAreaBluetooth.containsMouse ? Theme.accentPrimary : Theme.textPrimary
}
MouseArea {
id: mouseAreaBluetooth
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (!bluetoothMenuLoader.active) {
bluetoothMenuLoader.loading = true;
}
if (bluetoothMenuLoader.item) {
bluetoothMenuLoader.item.visible = !bluetoothMenuLoader.item.visible;
// Enable adapter and start discovery when menu opens
if (bluetoothMenuLoader.item.visible && Bluetooth.defaultAdapter) {
if (!Bluetooth.defaultAdapter.enabled) {
Bluetooth.defaultAdapter.enabled = true;
}
if (!Bluetooth.defaultAdapter.discovering) {
Bluetooth.defaultAdapter.discovering = true;
}
}
}
}
onEntered: bluetoothTooltip.tooltipVisible = true
onExited: bluetoothTooltip.tooltipVisible = false
}
}
StyledTooltip {
id: bluetoothTooltip
text: "Bluetooth Devices"
positionAbove: false
tooltipVisible: false
targetItem: bluetoothIcon
delay: 200
}
// LazyLoader for Bluetooth menu
LazyLoader {
id: bluetoothMenuLoader
loading: false
component: PanelWindow {
id: bluetoothMenu
implicitWidth: 320
implicitHeight: 480
visible: false
color: "transparent"
anchors.top: true
anchors.right: true
margins.right: 0
margins.top: 0
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
onVisibleChanged: {
// Stop discovery when menu closes to save battery
if (!visible && Bluetooth.defaultAdapter && Bluetooth.defaultAdapter.discovering) {
Bluetooth.defaultAdapter.discovering = false;
}
}
Rectangle {
anchors.fill: parent
color: Theme.backgroundPrimary
radius: 12
ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 16
RowLayout {
Layout.fillWidth: true
spacing: 12
Text {
text: "bluetooth"
font.family: "Material Symbols Outlined"
font.pixelSize: 24 * Theme.scale(Screen)
color: Theme.accentPrimary
}
Text {
text: "Bluetooth Devices"
font.pixelSize: 18 * Theme.scale(Screen)
font.bold: true
color: Theme.textPrimary
Layout.fillWidth: true
}
IconButton {
icon: "close"
onClicked: {
bluetoothMenu.visible = false;
if (Bluetooth.defaultAdapter && Bluetooth.defaultAdapter.discovering) {
Bluetooth.defaultAdapter.discovering = false;
}
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.outline
opacity: 0.12
}
ListView {
id: deviceList
Layout.fillWidth: true
Layout.fillHeight: true
model: Bluetooth.defaultAdapter ? Bluetooth.defaultAdapter.devices : []
spacing: 8
clip: true
delegate: Item {
width: parent.width
height: 48
Rectangle {
anchors.fill: parent
radius: 8
color: modelData.connected ? Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.44) : (deviceMouseArea.containsMouse ? Theme.highlight : "transparent")
RowLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 8
Text {
text: modelData.connected ? "bluetooth" : "bluetooth_disabled"
font.family: "Material Symbols Outlined"
font.pixelSize: 18 * Theme.scale(Screen)
color: deviceMouseArea.containsMouse ? Theme.backgroundPrimary : (modelData.connected ? Theme.accentPrimary : Theme.textSecondary)
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
text: {
let deviceName = modelData.name || modelData.deviceName || "Unknown Device";
// Hide MAC addresses and show "Unknown Device" instead
let macPattern = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/;
if (macPattern.test(deviceName)) {
return "Unknown Device";
}
return deviceName;
}
color: deviceMouseArea.containsMouse ? Theme.backgroundPrimary : (modelData.connected ? Theme.accentPrimary : Theme.textPrimary)
font.pixelSize: 14 * Theme.scale(Screen)
elide: Text.ElideRight
Layout.fillWidth: true
}
Text {
text: {
let deviceName = modelData.name || modelData.deviceName || "";
let macPattern = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/;
if (macPattern.test(deviceName)) {
// Show MAC address in subtitle for unnamed devices
return modelData.address + " • " + (modelData.paired ? "Paired" : "Available");
} else {
// Show only status for named devices
return modelData.paired ? "Paired" : "Available";
}
}
color: deviceMouseArea.containsMouse ? Theme.backgroundPrimary : (modelData.connected ? Theme.accentPrimary : Theme.textSecondary)
font.pixelSize: 11 * Theme.scale(Screen)
elide: Text.ElideRight
Layout.fillWidth: true
}
}
Item {
Layout.preferredWidth: 22
Layout.preferredHeight: 22
visible: modelData.pairing || modelData.state === BluetoothDeviceState.Connecting || modelData.state === BluetoothDeviceState.Disconnecting
Spinner {
visible: parent.visible
running: parent.visible
color: Theme.accentPrimary
anchors.centerIn: parent
size: 22
}
}
}
MouseArea {
id: deviceMouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
// Handle device actions: disconnect, pair, or connect
if (modelData.connected) {
modelData.disconnect();
} else if (!modelData.paired) {
modelData.pair();
} else {
modelData.connect();
}
}
}
}
}
}
// Discovering indicator
RowLayout {
Layout.fillWidth: true
spacing: 8
visible: Bluetooth.defaultAdapter && Bluetooth.defaultAdapter.discovering
Text {
text: "Scanning for devices..."
font.pixelSize: 12 * Theme.scale(Screen)
color: Theme.textSecondary
}
Spinner {
running: true
color: Theme.accentPrimary
size: 16
}
}
}
}
}
}
}

View file

@ -1,141 +0,0 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Components
import qs.Settings
Item {
id: brightnessDisplay
property int brightness: -1
property int previousBrightness: -1
property var screen: (typeof modelData !== 'undefined' ? modelData : null)
property string monitorName: screen ? screen.name : "DP-1"
property bool isSettingBrightness: false
property bool hasPendingSet: false
property int pendingSetValue: -1
property bool firstChange: true
width: pill.width
height: pill.height
Process {
id: getBrightnessProcess
command: [Quickshell.shellDir + "/Programs/zigbrightness", "get", monitorName]
stdout: StdioCollector {
onStreamFinished: {
const output = this.text.trim()
const val = parseInt(output)
if (isNaN(val)) return
if (val < 0) {
brightnessDisplay.visible = false
}
else if (val >= 0 && val !== previousBrightness) {
brightnessDisplay.visible = true
previousBrightness = brightness
brightness = val
pill.text = brightness + "%"
if (firstChange) {
firstChange = false
}
else {
pill.show()
}
}
}
}
}
function getBrightness() {
if (isSettingBrightness) {
return
}
getBrightnessProcess.running = true
}
Process {
id: setBrightnessProcess
property int targetValue: -1
command: [Quickshell.shellDir + "/Programs/zigbrightness", "set", monitorName, targetValue.toString()]
stdout: StdioCollector {
onStreamFinished: {
const output = this.text.trim()
const val = parseInt(output)
if (!isNaN(val) && val >= 0) {
brightness = val
pill.text = brightness + "%"
pill.show()
}
isSettingBrightness = false
if (hasPendingSet) {
hasPendingSet = false
const pendingValue = pendingSetValue
pendingSetValue = -1
setBrightness(pendingValue)
}
}
}
}
function setBrightness(newValue) {
newValue = Math.max(0, Math.min(100, newValue))
if (isSettingBrightness) {
hasPendingSet = true
pendingSetValue = newValue
return
}
isSettingBrightness = true
setBrightnessProcess.targetValue = newValue
setBrightnessProcess.running = true
}
PillIndicator {
id: pill
icon: "brightness_high"
text: brightness >= 0 ? brightness + "%" : "--"
pillColor: Theme.surfaceVariant
iconCircleColor: Theme.accentPrimary
iconTextColor: Theme.backgroundPrimary
textColor: Theme.textPrimary
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: {
getBrightness()
brightnessTooltip.tooltipVisible = true
pill.showDelayed()
}
onExited: {
brightnessTooltip.tooltipVisible = false
pill.hide()
}
onWheel: function(wheel) {
const delta = wheel.angleDelta.y > 0 ? 5 : -5
const newBrightness = brightness + delta
setBrightness(newBrightness)
}
}
StyledTooltip {
id: brightnessTooltip
text: "Brightness: " + brightness + "%"
positionAbove: false
tooltipVisible: false
targetItem: pill
delay: 1500
}
}
Component.onCompleted: {
getBrightness()
}
}

View file

@ -1,210 +0,0 @@
import "../../Helpers/Holidays.js" as Holidays
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs.Components
import qs.Settings
PanelWithOverlay {
id: calendarOverlay
Rectangle {
color: Theme.backgroundPrimary
radius: 12
border.color: Theme.backgroundTertiary
border.width: 1
width: 340
height: 380
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: 4
anchors.rightMargin: 4
// Prevent closing when clicking in the panel bg
MouseArea {
anchors.fill: parent
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 12
// Month/Year header with navigation
RowLayout {
Layout.fillWidth: true
spacing: 8
IconButton {
icon: "chevron_left"
onClicked: {
let newDate = new Date(calendar.year, calendar.month - 1, 1);
calendar.year = newDate.getFullYear();
calendar.month = newDate.getMonth();
}
}
Text {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
text: calendar.title
color: Theme.textPrimary
opacity: 0.7
font.pixelSize: 13 * Theme.scale(screen)
font.family: Theme.fontFamily
font.bold: true
}
IconButton {
icon: "chevron_right"
onClicked: {
let newDate = new Date(calendar.year, calendar.month + 1, 1);
calendar.year = newDate.getFullYear();
calendar.month = newDate.getMonth();
}
}
}
DayOfWeekRow {
Layout.fillWidth: true
spacing: 0
Layout.leftMargin: 8 // Align with grid
Layout.rightMargin: 8
delegate: Text {
text: shortName
color: Theme.textPrimary
opacity: 0.8
font.pixelSize: 13 * Theme.scale(screen)
font.family: Theme.fontFamily
font.bold: true
horizontalAlignment: Text.AlignHCenter
width: 32
}
}
MonthGrid {
id: calendar
property var holidays: []
// Fetch holidays when calendar is opened or month/year changes
function updateHolidays() {
Holidays.getHolidaysForMonth(calendar.year, calendar.month, function(holidays) {
calendar.holidays = holidays;
});
}
Layout.fillWidth: true
Layout.leftMargin: 8
Layout.rightMargin: 8
spacing: 0
month: Time.date.getMonth()
year: Time.date.getFullYear()
onMonthChanged: updateHolidays()
onYearChanged: updateHolidays()
Component.onCompleted: updateHolidays()
// Optionally, update when the panel becomes visible
Connections {
function onVisibleChanged() {
if (calendarOverlay.visible) {
calendar.month = Time.date.getMonth();
calendar.year = Time.date.getFullYear();
calendar.updateHolidays();
}
}
target: calendarOverlay
}
delegate: Rectangle {
property var holidayInfo: calendar.holidays.filter(function(h) {
var d = new Date(h.date);
return d.getDate() === model.day && d.getMonth() === model.month && d.getFullYear() === model.year;
})
property bool isHoliday: holidayInfo.length > 0
width: 32
height: 32
radius: 8
color: {
if (model.today)
return Theme.accentPrimary;
if (mouseArea2.containsMouse)
return Theme.backgroundTertiary;
return "transparent";
}
// Holiday dot indicator
Rectangle {
visible: isHoliday
width: 4
height: 4
radius: 4
color: Theme.accentTertiary
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: 4
anchors.rightMargin: 4
z: 2
}
Text {
anchors.centerIn: parent
text: model.day
color: model.today ? Theme.onAccent : Theme.textPrimary
opacity: model.month === calendar.month ? (mouseArea2.containsMouse ? 1 : 0.7) : 0.3
font.pixelSize: 13 * Theme.scale(screen)
font.family: Theme.fontFamily
font.bold: model.today ? true : false
}
MouseArea {
id: mouseArea2
anchors.fill: parent
hoverEnabled: true
onEntered: {
if (isHoliday) {
holidayTooltip.text = holidayInfo.map(function(h) {
return h.localName + (h.name !== h.localName ? " (" + h.name + ")" : "") + (h.global ? " [Global]" : "");
}).join(", ");
holidayTooltip.targetItem = parent;
holidayTooltip.tooltipVisible = true;
}
}
onExited: holidayTooltip.tooltipVisible = false
}
StyledTooltip {
id: holidayTooltip
text: ""
tooltipVisible: false
targetItem: null
delay: 100
}
Behavior on color {
ColorAnimation {
duration: 150
}
}
}
}
}
}
}

View file

@ -1,49 +0,0 @@
import QtQuick
import qs.Settings
import qs.Components
Rectangle {
id: clockWidget
property var screen: (typeof modelData !== 'undefined' ? modelData : null)
property var showTooltip: false
width: textItem.paintedWidth
height: textItem.paintedHeight
color: "transparent"
Text {
id: textItem
text: Time.time
font.family: Theme.fontFamily
font.weight: Font.Bold
font.pixelSize: Theme.fontSizeSmall * Theme.scale(screen)
color: Theme.textPrimary
anchors.centerIn: parent
}
MouseArea {
id: clockMouseArea
anchors.fill: parent
hoverEnabled: true
onEntered: showTooltip = true
onExited: showTooltip = false
cursorShape: Qt.PointingHandCursor
onClicked: function() {
calendar.visible = !calendar.visible
}
}
Calendar {
id: calendar
screen: clockWidget.screen
visible: false
}
StyledTooltip {
id: dateTooltip
text: Time.dateString
positionAbove: false
tooltipVisible: showTooltip && !calendar.visible
targetItem: clockWidget
delay: 200
}
}

View file

@ -1,476 +0,0 @@
pragma ComponentBehavior: Bound
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import Quickshell
import qs.Settings
PopupWindow {
id: trayMenu
implicitWidth: 180
implicitHeight: Math.max(40, listView.contentHeight + 12)
visible: false
color: "transparent"
property QsMenuHandle menu
property var anchorItem: null
property real anchorX
property real anchorY
anchor.item: anchorItem ? anchorItem : null
anchor.rect.x: anchorX
anchor.rect.y: anchorY - 4
// Recursive function to destroy all open submenus in delegate tree, safely avoiding infinite recursion
function destroySubmenusRecursively(item) {
if (!item || !item.contentItem) return;
var children = item.contentItem.children;
for (var i = 0; i < children.length; ++i) {
var child = children[i];
if (child.subMenu) {
child.subMenu.hideMenu();
child.subMenu.destroy();
child.subMenu = null;
}
// Recursively destroy submenus only if the child has contentItem to prevent issues
if (child.contentItem) {
destroySubmenusRecursively(child);
}
}
}
function showAt(item, x, y) {
if (!item) {
console.warn("CustomTrayMenu: anchorItem is undefined, won't show menu.");
return;
}
anchorItem = item;
anchorX = x;
anchorY = y;
visible = true;
forceActiveFocus();
Qt.callLater(() => trayMenu.anchor.updateAnchor());
}
function hideMenu() {
visible = false;
destroySubmenusRecursively(listView);
}
Item {
anchors.fill: parent;
Keys.onEscapePressed: trayMenu.hideMenu();
}
QsMenuOpener {
id: opener;
menu: trayMenu.menu;
}
Rectangle {
id: bg;
anchors.fill: parent;
color: Theme.backgroundPrimary || "#222";
border.color: Theme.outline || "#444";
border.width: 1;
radius: 12;
z: 0;
}
ListView {
id: listView;
anchors.fill: parent;
anchors.margins: 6;
spacing: 2;
interactive: false;
enabled: trayMenu.visible;
clip: true;
model: ScriptModel {
values: opener.children ? [...opener.children.values] : []
}
delegate: Rectangle {
id: entry;
required property var modelData;
width: listView.width;
height: (modelData?.isSeparator) ? 8 : 32;
color: "transparent";
radius: 12;
property var subMenu: null;
Rectangle {
anchors.centerIn: parent;
width: parent.width - 20;
height: 1;
color: Qt.darker(Theme.backgroundPrimary || "#222", 1.4);
visible: modelData?.isSeparator ?? false;
}
Rectangle {
id: bg;
anchors.fill: parent;
color: mouseArea.containsMouse ? Theme.highlight : "transparent";
radius: 8;
visible: !(modelData?.isSeparator ?? false);
property color hoverTextColor: mouseArea.containsMouse ? Theme.onAccent : Theme.textPrimary;
RowLayout {
anchors.fill: parent;
anchors.leftMargin: 12;
anchors.rightMargin: 12;
spacing: 8;
Text {
Layout.fillWidth: true;
color: (modelData?.enabled ?? true) ? bg.hoverTextColor : Theme.textDisabled;
text: modelData?.text ?? "";
font.family: Theme.fontFamily;
font.pixelSize: Theme.fontSizeSmall * Theme.scale(screen);
verticalAlignment: Text.AlignVCenter;
elide: Text.ElideRight;
}
Image {
Layout.preferredWidth: 16;
Layout.preferredHeight: 16;
source: modelData?.icon ?? "";
visible: (modelData?.icon ?? "") !== "";
fillMode: Image.PreserveAspectFit;
}
Text {
// Material Symbols Outlined chevron right for submenu
text: modelData?.hasChildren ? "menu" : "";
font.family: "Material Symbols Outlined";
font.pixelSize: 18 * Theme.scale(screen);
verticalAlignment: Text.AlignVCenter;
visible: modelData?.hasChildren ?? false;
color: Theme.textPrimary;
}
}
MouseArea {
id: mouseArea;
anchors.fill: parent;
hoverEnabled: true;
enabled: (modelData?.enabled ?? true) && !(modelData?.isSeparator ?? false) && trayMenu.visible;
onClicked: {
if (modelData && !modelData.isSeparator) {
if (modelData.hasChildren) {
// Submenus open on hover; ignore click here
return;
}
modelData.triggered();
trayMenu.hideMenu();
}
}
onEntered: {
if (!trayMenu.visible) return;
if (modelData?.hasChildren) {
// Close sibling submenus immediately
for (let i = 0; i < listView.contentItem.children.length; i++) {
const sibling = listView.contentItem.children[i];
if (sibling !== entry && sibling.subMenu) {
sibling.subMenu.hideMenu();
sibling.subMenu.destroy();
sibling.subMenu = null;
}
}
if (entry.subMenu) {
entry.subMenu.hideMenu();
entry.subMenu.destroy();
entry.subMenu = null;
}
var globalPos = entry.mapToGlobal(0, 0);
var submenuWidth = 180;
var gap = 12;
var openLeft = (globalPos.x + entry.width + submenuWidth > Screen.width);
var anchorX = openLeft ? -submenuWidth - gap : entry.width + gap;
entry.subMenu = subMenuComponent.createObject(trayMenu, {
menu: modelData,
anchorItem: entry,
anchorX: anchorX,
anchorY: 0
});
entry.subMenu.showAt(entry, anchorX, 0);
} else {
// Hovered item without submenu; close siblings
for (let i = 0; i < listView.contentItem.children.length; i++) {
const sibling = listView.contentItem.children[i];
if (sibling.subMenu) {
sibling.subMenu.hideMenu();
sibling.subMenu.destroy();
sibling.subMenu = null;
}
}
if (entry.subMenu) {
entry.subMenu.hideMenu();
entry.subMenu.destroy();
entry.subMenu = null;
}
}
}
onExited: {
if (entry.subMenu && !entry.subMenu.containsMouse()) {
entry.subMenu.hideMenu();
entry.subMenu.destroy();
entry.subMenu = null;
}
}
}
}
// Simplified containsMouse without recursive calls to avoid stack overflow
function containsMouse() {
return mouseArea.containsMouse;
}
Component.onDestruction: {
if (subMenu) {
subMenu.destroy();
subMenu = null;
}
}
}
}
Component {
id: subMenuComponent;
PopupWindow {
id: subMenu;
implicitWidth: 180;
implicitHeight: Math.max(40, listView.contentHeight + 12);
visible: false;
color: "transparent";
property QsMenuHandle menu;
property var anchorItem: null;
property real anchorX;
property real anchorY;
anchor.item: anchorItem ? anchorItem : null;
anchor.rect.x: anchorX;
anchor.rect.y: anchorY;
function showAt(item, x, y) {
if (!item) {
console.warn("subMenuComponent: anchorItem is undefined, not showing menu.");
return;
}
anchorItem = item;
anchorX = x;
anchorY = y;
visible = true;
Qt.callLater(() => subMenu.anchor.updateAnchor());
}
function hideMenu() {
visible = false;
// Close all submenus recursively in this submenu
for (let i = 0; i < listView.contentItem.children.length; i++) {
const child = listView.contentItem.children[i];
if (child.subMenu) {
child.subMenu.hideMenu();
child.subMenu.destroy();
child.subMenu = null;
}
}
}
// Simplified containsMouse avoiding recursive calls
function containsMouse() {
return subMenu.containsMouse;
}
Item {
anchors.fill: parent;
Keys.onEscapePressed: subMenu.hideMenu();
}
QsMenuOpener {
id: opener;
menu: subMenu.menu;
}
Rectangle {
id: bg;
anchors.fill: parent;
color: Theme.backgroundPrimary || "#222";
border.color: Theme.outline || "#444";
border.width: 1;
radius: 12;
z: 0;
}
ListView {
id: listView;
anchors.fill: parent;
anchors.margins: 6;
spacing: 2;
interactive: false;
enabled: subMenu.visible;
clip: true;
model: ScriptModel {
values: opener.children ? [...opener.children.values] : [];
}
delegate: Rectangle {
id: entry;
required property var modelData;
width: listView.width;
height: (modelData?.isSeparator) ? 8 : 32;
color: "transparent";
radius: 12;
property var subMenu: null;
Rectangle {
anchors.centerIn: parent;
width: parent.width - 20;
height: 1;
color: Qt.darker(Theme.surfaceVariant || "#222", 1.4);
visible: modelData?.isSeparator ?? false;
}
Rectangle {
id: bg;
anchors.fill: parent;
color: mouseArea.containsMouse ? Theme.highlight : "transparent";
radius: 8;
visible: !(modelData?.isSeparator ?? false);
property color hoverTextColor: mouseArea.containsMouse ? Theme.onAccent : Theme.textPrimary;
RowLayout {
anchors.fill: parent;
anchors.leftMargin: 12;
anchors.rightMargin: 12;
spacing: 8;
Text {
Layout.fillWidth: true;
color: (modelData?.enabled ?? true) ? bg.hoverTextColor : Theme.textDisabled;
text: modelData?.text ?? "";
font.family: Theme.fontFamily;
font.pixelSize: Theme.fontSizeSmall * Theme.scale(screen);
verticalAlignment: Text.AlignVCenter;
elide: Text.ElideRight;
}
Image {
Layout.preferredWidth: 16;
Layout.preferredHeight: 16;
source: modelData?.icon ?? "";
visible: (modelData?.icon ?? "") !== "";
fillMode: Image.PreserveAspectFit;
}
Text {
text: modelData?.hasChildren ? "\uE5CC" : "";
font.family: "Material Symbols Outlined";
font.pixelSize: 18 * Theme.scale(screen);
verticalAlignment: Text.AlignVCenter;
visible: modelData?.hasChildren ?? false;
color: Theme.textPrimary;
}
}
MouseArea {
id: mouseArea;
anchors.fill: parent;
hoverEnabled: true;
enabled: (modelData?.enabled ?? true) && !(modelData?.isSeparator ?? false) && subMenu.visible;
onClicked: {
if (modelData && !modelData.isSeparator) {
if (modelData.hasChildren) {
return;
}
modelData.triggered();
trayMenu.hideMenu();
}
}
onEntered: {
if (!subMenu.visible) return;
if (modelData?.hasChildren) {
for (let i = 0; i < listView.contentItem.children.length; i++) {
const sibling = listView.contentItem.children[i];
if (sibling !== entry && sibling.subMenu) {
sibling.subMenu.hideMenu();
sibling.subMenu.destroy();
sibling.subMenu = null;
}
}
if (entry.subMenu) {
entry.subMenu.hideMenu();
entry.subMenu.destroy();
entry.subMenu = null;
}
var globalPos = entry.mapToGlobal(0, 0);
var submenuWidth = 180;
var gap = 12;
var openLeft = (globalPos.x + entry.width + submenuWidth > Screen.width);
var anchorX = openLeft ? -submenuWidth - gap : entry.width + gap;
entry.subMenu = subMenuComponent.createObject(subMenu, {
menu: modelData,
anchorItem: entry,
anchorX: anchorX,
anchorY: 0
});
entry.subMenu.showAt(entry, anchorX, 0);
} else {
for (let i = 0; i < listView.contentItem.children.length; i++) {
const sibling = listView.contentItem.children[i];
if (sibling.subMenu) {
sibling.subMenu.hideMenu();
sibling.subMenu.destroy();
sibling.subMenu = null;
}
}
if (entry.subMenu) {
entry.subMenu.hideMenu();
entry.subMenu.destroy();
entry.subMenu = null;
}
}
}
onExited: {
if (entry.subMenu && !entry.subMenu.containsMouse()) {
entry.subMenu.hideMenu();
entry.subMenu.destroy();
entry.subMenu = null;
}
}
}
}
// Simplified & safe containsMouse avoiding recursion
function containsMouse() {
return mouseArea.containsMouse;
}
Component.onDestruction: {
if (subMenu) {
subMenu.destroy();
subMenu = null;
}
}
}
}
}
}
}

View file

@ -1,114 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Widgets
import QtQuick.Effects
import qs.Settings
import qs.Services
import qs.Components
Item {
id: mediaControl
width: visible ? mediaRow.width : 0
height: 36 * Theme.scale(Screen)
visible: Settings.settings.showMediaInBar && MusicManager.currentPlayer
RowLayout {
id: mediaRow
height: parent.height
spacing: 8
Item {
id: albumArtContainer
width: 24 * Theme.scale(Screen)
height: 24 * Theme.scale(Screen)
Layout.alignment: Qt.AlignVCenter
// Circular spectrum visualizer
CircularSpectrum {
id: spectrum
values: MusicManager.cavaValues
anchors.centerIn: parent
innerRadius: 10 * Theme.scale(Screen)
outerRadius: 18 * Theme.scale(Screen)
fillColor: Theme.accentPrimary
strokeColor: Theme.accentPrimary
strokeWidth: 0
z: 0
}
// Album art image
ClippingRectangle {
id: albumArtwork
width: 20 * Theme.scale(Screen)
height: 20 * Theme.scale(Screen)
anchors.centerIn: parent
radius: 12 // circle
color: Qt.darker(Theme.surface, 1.1)
border.color: Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.3)
border.width: 1
z: 1
Image {
id: albumArt
anchors.fill: parent
anchors.margins: 1
fillMode: Image.PreserveAspectCrop
smooth: true
mipmap: true
cache: false
asynchronous: true
source: MusicManager.trackArtUrl
visible: source.toString() !== ""
}
// Fallback icon
Text {
anchors.centerIn: parent
text: "music_note"
font.family: "Material Symbols Outlined"
font.pixelSize: 14 * Theme.scale(Screen)
color: Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.4)
visible: !albumArt.visible
}
// Play/Pause overlay (only visible on hover)
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Qt.rgba(0, 0, 0, 0.5)
visible: playButton.containsMouse
z: 2
Text {
anchors.centerIn: parent
text: MusicManager.isPlaying ? "pause" : "play_arrow"
font.family: "Material Symbols Outlined"
font.pixelSize: 14 * Theme.scale(Screen)
color: "white"
}
}
MouseArea {
id: playButton
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
enabled: MusicManager.canPlay || MusicManager.canPause
onClicked: MusicManager.playPause()
}
}
}
// Track info
Text {
text: MusicManager.trackTitle + " - " + MusicManager.trackArtist
color: Theme.textPrimary
font.family: Theme.fontFamily
font.pixelSize: 12 * Theme.scale(Screen)
elide: Text.ElideRight
Layout.maximumWidth: 300
Layout.alignment: Qt.AlignVCenter
}
}
}

View file

@ -1,71 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Settings
import qs.Components
import qs.Widgets.SettingsWindow
Item {
id: root
width: 22
height: 22
Rectangle {
id: button
anchors.fill: parent
color: "transparent"
radius: width / 2
Text {
anchors.centerIn: parent
text: "settings"
font.family: "Material Symbols Outlined"
font.pixelSize: 16 * Theme.scale(screen)
color: mouseArea.containsMouse ? Theme.accentPrimary : Theme.textPrimary
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (!settingsWindowLoader.active) {
// Start loading the settings window
settingsWindowLoader.loading = true;
}
if (settingsWindowLoader.item) {
// Toggle visibility
if (settingsWindowLoader.item.visible) {
settingsWindowLoader.item.visible = false;
} else {
settingsWindowLoader.item.visible = true;
}
}
}
}
StyledTooltip {
text: "Settings"
targetItem: mouseArea
tooltipVisible: mouseArea.containsMouse
}
}
// LazyLoader for SettingsWindow
LazyLoader {
id: settingsWindowLoader
loading: false
component: SettingsWindow {
// Handle window closure - just hide it, don't destroy
onVisibleChanged: {
if (!visible) {
// Window is hidden, but keep it loaded for reuse
}
}
}
}
}

View file

@ -1,83 +0,0 @@
import QtQuick
import Quickshell
import qs.Settings
import qs.Services
Row {
id: layout
spacing: 10
visible: Settings.settings.showSystemInfoInBar
width: Math.floor(cpuUsageLayout.width + cpuTempLayout.width + memoryUsageLayout.width + (2 * 10))
Row {
id: cpuUsageLayout
spacing: 6
Text {
id: cpuUsageIcon
font.family: "Material Symbols Outlined"
font.pixelSize: Theme.fontSizeBody * Theme.scale(Screen)
text: "speed"
verticalAlignment: Text.AlignVCenter
anchors.verticalCenter: parent.verticalCenter
color: Theme.accentPrimary
}
Text {
id: cpuUsageText
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall * Theme.scale(Screen)
color: Theme.textPrimary
text: Sysinfo.cpuUsageStr
anchors.verticalCenter: parent.verticalCenter
verticalAlignment: Text.AlignVCenter
}
}
// CPU Temperature Component
Row {
id: cpuTempLayout
spacing: 3
Text {
font.family: "Material Symbols Outlined"
font.pixelSize: Theme.fontSizeBody * Theme.scale(Screen)
text: "thermometer"
verticalAlignment: Text.AlignVCenter
anchors.verticalCenter: parent.verticalCenter
color: Theme.accentPrimary
}
Text {
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall * Theme.scale(Screen)
color: Theme.textPrimary
text: Sysinfo.cpuTempStr
anchors.verticalCenter: parent.verticalCenter
verticalAlignment: Text.AlignVCenter
}
}
// Memory Usage Component
Row {
id: memoryUsageLayout
spacing: 3
Text {
font.family: "Material Symbols Outlined"
font.pixelSize: Theme.fontSizeBody * Theme.scale(Screen)
text: "memory"
color: Theme.accentPrimary
verticalAlignment: Text.AlignVCenter
anchors.verticalCenter: parent.verticalCenter
}
Text {
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall * Theme.scale(Screen)
color: Theme.textPrimary
text: Sysinfo.memoryUsageStr
anchors.verticalCenter: parent.verticalCenter
verticalAlignment: Text.AlignVCenter
}
}
}

View file

@ -1,128 +0,0 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import QtQuick.Effects
import Quickshell.Services.SystemTray
import Quickshell.Widgets
import qs.Settings
import qs.Components
Row {
property var bar
property var shell
property var trayMenu
spacing: 8
Layout.alignment: Qt.AlignVCenter
property bool containsMouse: false
property var systemTray: SystemTray
Repeater {
model: systemTray.items
delegate: Item {
width: 24 * Theme.scale(Screen)
height: 24 * Theme.scale(Screen)
visible: modelData
property bool isHovered: trayMouseArea.containsMouse
// No animations - static display
Rectangle {
anchors.centerIn: parent
width: 16 * Theme.scale(Screen)
height: 16 * Theme.scale(Screen)
radius: 6
color: "transparent"
clip: true
IconImage {
id: trayIcon
anchors.centerIn: parent
width: 16 * Theme.scale(Screen)
height: 16 * Theme.scale(Screen)
smooth: false
asynchronous: true
backer.fillMode: Image.PreserveAspectFit
source: {
let icon = modelData?.icon || "";
if (!icon)
return "";
// Process icon path
if (icon.includes("?path=")) {
const [name, path] = icon.split("?path=");
const fileName = name.substring(name.lastIndexOf("/") + 1);
return `file://${path}/${fileName}`;
}
return icon;
}
opacity: status === Image.Ready ? 1 : 0
Component.onCompleted: {}
}
}
MouseArea {
id: trayMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: mouse => {
if (!modelData)
return;
if (mouse.button === Qt.LeftButton) {
// Close any open menu first
if (trayMenu && trayMenu.visible) {
trayMenu.hideMenu();
}
if (!modelData.onlyMenu) {
modelData.activate();
}
} else if (mouse.button === Qt.MiddleButton) {
// Close any open menu first
if (trayMenu && trayMenu.visible) {
trayMenu.hideMenu();
}
modelData.secondaryActivate && modelData.secondaryActivate();
} else if (mouse.button === Qt.RightButton) {
trayTooltip.tooltipVisible = false;
// If menu is already visible, close it
if (trayMenu && trayMenu.visible) {
trayMenu.hideMenu();
return;
}
if (modelData.hasMenu && modelData.menu && trayMenu) {
// Anchor the menu to the tray icon item (parent) and position it below the icon
const menuX = (width / 2) - (trayMenu.width / 2);
const menuY = height + 20 * Theme.scale(Screen);
trayMenu.menu = modelData.menu;
trayMenu.showAt(parent, menuX, menuY);
} else
// console.log("No menu available for", modelData.id, "or trayMenu not set")
{}
}
}
onEntered: trayTooltip.tooltipVisible = true
onExited: trayTooltip.tooltipVisible = false
}
StyledTooltip {
id: trayTooltip
text: modelData.tooltipTitle || modelData.name || modelData.id || "Tray Item"
positionAbove: false
tooltipVisible: false
targetItem: trayIcon
delay: 200
}
Component.onDestruction:
// No cache cleanup needed
{}
}
}
}

View file

@ -1,142 +0,0 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Settings
import qs.Components
Item {
id: taskbar
width: runningAppsRow.width
height: Settings.settings.taskbarIconSize
visible: Settings.settings.showTaskbar
// Attach custom tooltip
StyledTooltip {
id: styledTooltip
positionAbove: false
}
function getAppIcon(toplevel: Toplevel): string {
if (!toplevel)
return "";
let icon = Quickshell.iconPath(toplevel.appId?.toLowerCase(), true);
if (!icon) {
icon = Quickshell.iconPath(toplevel.appId, true);
}
if (!icon) {
icon = Quickshell.iconPath(toplevel.title?.toLowerCase(), true);
}
if (!icon) {
icon = Quickshell.iconPath(toplevel.title, true);
}
if (!icon) {
icon = Quickshell.iconPath("application-x-executable", true);
}
return icon || "";
}
Row {
id: runningAppsRow
spacing: 8
height: parent.height
Repeater {
model: ToplevelManager ? ToplevelManager.toplevels : null
delegate: Rectangle {
id: appButton
width: Settings.settings.taskbarIconSize
height: Settings.settings.taskbarIconSize
radius: Math.max(4, Settings.settings.taskbarIconSize * 0.25)
color: isActive ? Theme.accentPrimary : (hovered ? Theme.surfaceVariant : "transparent")
border.color: isActive ? Qt.darker(Theme.accentPrimary, 1.2) : "transparent"
border.width: 1
property bool isActive: ToplevelManager.activeToplevel && ToplevelManager.activeToplevel === modelData
property bool hovered: mouseArea.containsMouse
property string appId: modelData ? modelData.appId : ""
property string appTitle: modelData ? modelData.title : ""
Behavior on color {
ColorAnimation { duration: 150 }
}
Behavior on border.color {
ColorAnimation { duration: 150 }
}
IconImage {
id: appIcon
width: Math.max(12, Settings.settings.taskbarIconSize * 0.625)
height: Math.max(12, Settings.settings.taskbarIconSize * 0.625)
anchors.centerIn: parent
source: getAppIcon(modelData)
visible: source.toString() !== ""
}
Text {
anchors.centerIn: parent
visible: !appIcon.visible
text: appButton.appId ? appButton.appId.charAt(0).toUpperCase() : "?"
font.family: Theme.fontFamily
font.pixelSize: Math.max(10, Settings.settings.taskbarIconSize * 0.4375 * Theme.scale(Screen))
font.bold: true
color: appButton.isActive ? Theme.onAccent : Theme.textPrimary
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: {
var text = appTitle || appId;
styledTooltip.text = text.length > 60 ? text.substring(0, 60) + "..." : text;
styledTooltip.targetItem = appButton;
styledTooltip.tooltipVisible = true;
}
onExited: {
styledTooltip.tooltipVisible = false;
}
onClicked: function(mouse) {
if (mouse.button === Qt.MiddleButton) {
if (modelData && modelData.close) {
modelData.close();
}
}
if (mouse.button === Qt.LeftButton) {
if (modelData && modelData.activate) {
modelData.activate();
}
}
}
onPressed: mouse => {
if (mouse.button === Qt.RightButton) {
// context menu logic (optional)
}
}
}
Rectangle {
visible: isActive
width: 4
height: 4
radius: 2
color: Theme.onAccent
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottomMargin: -6
}
}
}
}
}

View file

@ -1,46 +0,0 @@
pragma Singleton
import Quickshell
import QtQuick
import qs.Settings
Singleton {
id: root
property var date: new Date()
property string time: Settings.settings.use12HourClock ? Qt.formatDateTime(date, "h:mm AP") : Qt.formatDateTime(date, "HH:mm")
property string dateString: {
let now = date;
let dayName = now.toLocaleDateString(Qt.locale(), "ddd");
dayName = dayName.charAt(0).toUpperCase() + dayName.slice(1);
let day = now.getDate();
let suffix;
if (day > 3 && day < 21)
suffix = 'th';
else
switch (day % 10) {
case 1:
suffix = "st";
break;
case 2:
suffix = "nd";
break;
case 3:
suffix = "rd";
break;
default:
suffix = "th";
}
let month = now.toLocaleDateString(Qt.locale(), "MMMM");
let year = now.toLocaleDateString(Qt.locale(), "yyyy");
return `${dayName}, ` + (Settings.settings.reverseDayMonth ? `${month} ${day}${suffix} ${year}` : `${day}${suffix} ${month} ${year}`);
}
Timer {
interval: 1000
repeat: true
running: true
onTriggered: root.date = new Date()
}
}

View file

@ -1,133 +0,0 @@
import QtQuick
import Quickshell
import qs.Settings
import qs.Components
import qs.Bar.Modules
Item {
id: volumeDisplay
property var shell
property int volume: 0
property bool firstChange: true
width: pillIndicator.width
height: pillIndicator.height
function getVolumeColor() {
if (volume <= 100) return Theme.accentPrimary;
// Calculate interpolation factor (0 at 100%, 1 at 200%)
var factor = (volume - 100) / 100;
// Blend between accent and warning colors
return Qt.rgba(
Theme.accentPrimary.r + (Theme.warning.r - Theme.accentPrimary.r) * factor,
Theme.accentPrimary.g + (Theme.warning.g - Theme.accentPrimary.g) * factor,
Theme.accentPrimary.b + (Theme.warning.b - Theme.accentPrimary.b) * factor,
1
);
}
function getIconColor() {
if (volume <= 100) return Theme.textPrimary;
return getVolumeColor(); // Only use warning blend when >100%
}
PillIndicator {
id: pillIndicator
icon: shell && shell.defaultAudioSink && shell.defaultAudioSink.audio && shell.defaultAudioSink.audio.muted
? "volume_off"
: (volume === 0 ? "volume_off" : (volume < 30 ? "volume_down" : "volume_up"))
text: volume + "%"
pillColor: Theme.surfaceVariant
iconCircleColor: getVolumeColor()
iconTextColor: Theme.backgroundPrimary
textColor: Theme.textPrimary
collapsedIconColor: getIconColor()
autoHide: true
StyledTooltip {
id: volumeTooltip
text: "Volume: " + volume + "%\nLeft click for advanced settings.\nScroll up/down to change volume."
positionAbove: false
tooltipVisible: !ioSelector.visible && volumeDisplay.containsMouse
targetItem: pillIndicator
delay: 1500
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (ioSelector.visible) {
ioSelector.dismiss();
} else {
ioSelector.show();
}
}
}
}
Connections {
target: shell ?? null
function onVolumeChanged() {
if (shell) {
const clampedVolume = Math.max(0, Math.min(200, shell.volume));
if (clampedVolume !== volume) {
volume = clampedVolume;
pillIndicator.text = volume + "%";
pillIndicator.icon = shell.defaultAudioSink && shell.defaultAudioSink.audio && shell.defaultAudioSink.audio.muted
? "volume_off"
: (volume === 0 ? "volume_off" : (volume < 30 ? "volume_down" : "volume_up"));
if (firstChange) {
firstChange = false
}
else {
pillIndicator.show();
}
}
}
}
}
Component.onCompleted: {
if (shell && shell.volume !== undefined) {
volume = Math.max(0, Math.min(200, shell.volume));
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
propagateComposedEvents: true
onEntered: {
volumeDisplay.containsMouse = true
pillIndicator.autoHide = false;
pillIndicator.showDelayed()
}
onExited: {
volumeDisplay.containsMouse = false
pillIndicator.autoHide = true;
pillIndicator.hide()
}
cursorShape: Qt.PointingHandCursor
onWheel: (wheel) => {
if (!shell) return;
let step = 5;
if (wheel.angleDelta.y > 0) {
shell.updateVolume(Math.min(200, shell.volume + step));
} else if (wheel.angleDelta.y < 0) {
shell.updateVolume(Math.max(0, shell.volume - step));
}
}
}
AudioDeviceSelector {
id: ioSelector
onPanelClosed: ioSelector.dismiss()
}
property bool containsMouse: false
}

View file

@ -1,380 +0,0 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import qs.Settings
import qs.Components
import qs.Services
Item {
id: root
width: Settings.settings.wifiEnabled ? 22 : 0
height: Settings.settings.wifiEnabled ? 22 : 0
property bool menuVisible: false
property string passwordPromptSsid: ""
property string passwordInput: ""
property bool showPasswordPrompt: false
Network {
id: network
}
// WiFi icon/button
Item {
id: wifiIcon
width: 22; height: 22
visible: Settings.settings.wifiEnabled
property int currentSignal: {
let maxSignal = 0;
for (const net in network.networks) {
if (network.networks[net].connected && network.networks[net].signal > maxSignal) {
maxSignal = network.networks[net].signal;
}
}
return maxSignal;
}
Text {
id: wifiText
anchors.centerIn: parent
text: {
let connected = false;
for (const net in network.networks) {
if (network.networks[net].connected) {
connected = true;
break;
}
}
return connected ? network.signalIcon(parent.currentSignal) : "wifi_off"
}
font.family: mouseAreaWifi.containsMouse ? "Material Symbols Rounded" : "Material Symbols Outlined"
font.pixelSize: 16 * Theme.scale(Screen)
color: mouseAreaWifi.containsMouse ? Theme.accentPrimary : Theme.textPrimary
}
MouseArea {
id: mouseAreaWifi
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (!wifiMenuLoader.active) {
wifiMenuLoader.loading = true;
}
if (wifiMenuLoader.item) {
wifiMenuLoader.item.visible = !wifiMenuLoader.item.visible;
if (wifiMenuLoader.item.visible) {
network.onMenuOpened();
} else {
network.onMenuClosed();
}
}
}
onEntered: wifiTooltip.tooltipVisible = true
onExited: wifiTooltip.tooltipVisible = false
}
}
StyledTooltip {
id: wifiTooltip
text: "WiFi Networks"
positionAbove: false
tooltipVisible: false
targetItem: wifiIcon
delay: 200
}
// LazyLoader for WiFi menu
LazyLoader {
id: wifiMenuLoader
loading: false
component: PanelWindow {
id: wifiMenu
implicitWidth: 320
implicitHeight: 480
visible: false
color: "transparent"
anchors.top: true
anchors.right: true
margins.right: 0
margins.top: 0
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
Rectangle {
anchors.fill: parent
color: Theme.backgroundPrimary
radius: 12
ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 16
RowLayout {
Layout.fillWidth: true
spacing: 12
Text {
text: "wifi"
font.family: "Material Symbols Outlined"
font.pixelSize: 24 * Theme.scale(Screen)
color: Theme.accentPrimary
}
Text {
text: "WiFi Networks"
font.pixelSize: 18 * Theme.scale(Screen)
font.bold: true
color: Theme.textPrimary
Layout.fillWidth: true
}
IconButton {
icon: "refresh"
onClicked: network.refreshNetworks()
}
IconButton {
icon: "close"
onClicked: {
wifiMenu.visible = false;
network.onMenuClosed();
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.outline
opacity: 0.12
}
ListView {
id: networkList
Layout.fillWidth: true
Layout.fillHeight: true
model: Object.values(network.networks)
spacing: 8
clip: true
delegate: Item {
width: parent.width
height: modelData.ssid === passwordPromptSsid && showPasswordPrompt ? 108 : 48 // 48 for network + 60 for password prompt
ColumnLayout {
anchors.fill: parent
spacing: 0
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 48
radius: 8
color: modelData.connected ? Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.44) : (networkMouseArea.containsMouse ? Theme.highlight : "transparent")
RowLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 8
Text {
text: network.signalIcon(modelData.signal)
font.family: "Material Symbols Outlined"
font.pixelSize: 18 * Theme.scale(Screen)
color: networkMouseArea.containsMouse ? Theme.backgroundPrimary : (modelData.connected ? Theme.accentPrimary : Theme.textSecondary)
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
text: modelData.ssid || "Unknown Network"
color: networkMouseArea.containsMouse ? Theme.backgroundPrimary : (modelData.connected ? Theme.accentPrimary : Theme.textPrimary)
font.pixelSize: 14 * Theme.scale(Screen)
elide: Text.ElideRight
Layout.fillWidth: true
}
Text {
text: modelData.security && modelData.security !== "--" ? modelData.security : "Open"
color: networkMouseArea.containsMouse ? Theme.backgroundPrimary : (modelData.connected ? Theme.accentPrimary : Theme.textSecondary)
font.pixelSize: 11 * Theme.scale(Screen)
elide: Text.ElideRight
Layout.fillWidth: true
}
Text {
visible: network.connectStatusSsid === modelData.ssid && network.connectStatus === "error" && network.connectError.length > 0
text: network.connectError
color: Theme.error
font.pixelSize: 11 * Theme.scale(Screen)
elide: Text.ElideRight
Layout.fillWidth: true
}
}
Item {
Layout.preferredWidth: 22
Layout.preferredHeight: 22
visible: network.connectStatusSsid === modelData.ssid && (network.connectStatus !== "" || network.connectingSsid === modelData.ssid)
Spinner {
visible: network.connectingSsid === modelData.ssid
running: network.connectingSsid === modelData.ssid
color: Theme.accentPrimary
anchors.centerIn: parent
size: 22
}
Text {
visible: network.connectStatus === "success" && !network.connectingSsid
text: "check_circle"
font.family: "Material Symbols Outlined"
font.pixelSize: 18 * Theme.scale(Screen)
color: "#43a047"
anchors.centerIn: parent
}
Text {
visible: network.connectStatus === "error" && !network.connectingSsid
text: "error"
font.family: "Material Symbols Outlined"
font.pixelSize: 18 * Theme.scale(Screen)
color: Theme.error
anchors.centerIn: parent
}
}
Text {
visible: modelData.connected
text: "connected"
color: networkMouseArea.containsMouse ? Theme.backgroundPrimary : Theme.accentPrimary
font.pixelSize: 11 * Theme.scale(Screen)
}
}
MouseArea {
id: networkMouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (modelData.connected) {
network.disconnectNetwork(modelData.ssid);
} else if (network.isSecured(modelData.security) && !modelData.existing) {
passwordPromptSsid = modelData.ssid;
showPasswordPrompt = true;
passwordInput = ""; // Clear previous input
Qt.callLater(function() {
passwordInputField.forceActiveFocus();
});
} else {
network.connectNetwork(modelData.ssid, modelData.security);
}
}
}
}
// Password prompt section
Rectangle {
id: passwordPromptSection
Layout.fillWidth: true
Layout.preferredHeight: modelData.ssid === passwordPromptSsid && showPasswordPrompt ? 60 : 0
Layout.margins: 8
visible: modelData.ssid === passwordPromptSsid && showPasswordPrompt
color: Theme.surfaceVariant
radius: 8
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 10
Item {
Layout.fillWidth: true
Layout.preferredHeight: 36
Rectangle {
anchors.fill: parent
radius: 8
color: "transparent"
border.color: passwordInputField.activeFocus ? Theme.accentPrimary : Theme.outline
border.width: 1
TextInput {
id: passwordInputField
anchors.fill: parent
anchors.margins: 12
text: passwordInput
font.pixelSize: 13 * Theme.scale(Screen)
color: Theme.textPrimary
verticalAlignment: TextInput.AlignVCenter
clip: true
focus: true
selectByMouse: true
activeFocusOnTab: true
inputMethodHints: Qt.ImhNone
echoMode: TextInput.Password
onTextChanged: passwordInput = text
onAccepted: {
network.submitPassword(passwordPromptSsid, passwordInput);
showPasswordPrompt = false;
}
MouseArea {
id: passwordInputMouseArea
anchors.fill: parent
onClicked: passwordInputField.forceActiveFocus()
}
}
}
}
Rectangle {
Layout.preferredWidth: 80
Layout.preferredHeight: 36
radius: 18
color: Theme.accentPrimary
border.color: Theme.accentPrimary
border.width: 0
opacity: 1.0
Behavior on color {
ColorAnimation {
duration: 100
}
}
MouseArea {
anchors.fill: parent
onClicked: {
network.submitPassword(passwordPromptSsid, passwordInput);
showPasswordPrompt = false;
}
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: parent.color = Qt.darker(Theme.accentPrimary, 1.1)
onExited: parent.color = Theme.accentPrimary
}
Text {
anchors.centerIn: parent
text: "Connect"
color: Theme.backgroundPrimary
font.pixelSize: 14 * Theme.scale(Screen)
font.bold: true
}
}
}
}
}
}
}
}
}
}
}
}

View file

@ -1,265 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import QtQuick.Effects
import Quickshell
import Quickshell.Io
import qs.Settings
import qs.Services
Item {
id: root
required property ShellScreen screen
property bool isDestroying: false
property bool hovered: false
signal workspaceChanged(int workspaceId, color accentColor)
property ListModel localWorkspaces: ListModel {}
property real masterProgress: 0.0
property bool effectsActive: false
property color effectColor: Theme.accentPrimary
property int horizontalPadding: 16
property int spacingBetweenPills: 8
width: {
let total = 0;
for (let i = 0; i < localWorkspaces.count; i++) {
const ws = localWorkspaces.get(i);
if (ws.isFocused)
total += 44;
else if (ws.isActive)
total += 28;
else
total += 16;
}
total += Math.max(localWorkspaces.count - 1, 0) * spacingBetweenPills;
total += horizontalPadding * 2;
return total;
}
height: 36 * Theme.scale(screen)
Component.onCompleted: {
localWorkspaces.clear();
for (let i = 0; i < WorkspaceManager.workspaces.count; i++) {
const ws = WorkspaceManager.workspaces.get(i);
if (ws.output.toLowerCase() === screen.name.toLowerCase()) {
localWorkspaces.append(ws);
}
}
workspaceRepeater.model = localWorkspaces;
updateWorkspaceFocus();
}
Connections {
target: WorkspaceManager
function onWorkspacesChanged() {
localWorkspaces.clear();
for (let i = 0; i < WorkspaceManager.workspaces.count; i++) {
const ws = WorkspaceManager.workspaces.get(i);
if (ws.output.toLowerCase() === screen.name.toLowerCase()) {
localWorkspaces.append(ws);
}
}
workspaceRepeater.model = localWorkspaces;
updateWorkspaceFocus();
}
}
function triggerUnifiedWave() {
effectColor = Theme.accentPrimary;
masterAnimation.restart();
}
SequentialAnimation {
id: masterAnimation
PropertyAction {
target: root
property: "effectsActive"
value: true
}
NumberAnimation {
target: root
property: "masterProgress"
from: 0.0
to: 1.0
duration: 1000
easing.type: Easing.OutQuint
}
PropertyAction {
target: root
property: "effectsActive"
value: false
}
PropertyAction {
target: root
property: "masterProgress"
value: 0.0
}
}
function updateWorkspaceFocus() {
for (let i = 0; i < localWorkspaces.count; i++) {
const ws = localWorkspaces.get(i);
if (ws.isFocused === true) {
root.triggerUnifiedWave();
root.workspaceChanged(ws.id, Theme.accentPrimary);
break;
}
}
}
Rectangle {
id: workspaceBackground
width: parent.width - 15 * Theme.scale(screen)
height: 26 * Theme.scale(screen)
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
radius: 12
color: Theme.surfaceVariant
border.color: Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.1)
border.width: 1 * Theme.scale(screen)
layer.enabled: true
layer.effect: MultiEffect {
shadowColor: "black"
// radius: 12
shadowVerticalOffset: 0
shadowHorizontalOffset: 0
shadowOpacity: 0.10
}
}
Row {
id: pillRow
spacing: spacingBetweenPills
anchors.verticalCenter: workspaceBackground.verticalCenter
width: root.width - horizontalPadding * 2
x: horizontalPadding
Repeater {
id: workspaceRepeater
model: localWorkspaces
Item {
id: workspacePillContainer
height: 12 * Theme.scale(screen)
width: {
if (model.isFocused)
return 44;
else if (model.isActive)
return 28;
else
return 16;
}
Rectangle {
id: workspacePill
anchors.fill: parent
radius: {
if (model.isFocused)
return 12;
else
// half of focused height (if you want to animate this too)
return 6;
}
color: {
if (model.isFocused)
return Theme.accentPrimary;
if (model.isUrgent)
return Theme.error;
if (model.isActive || model.isOccupied)
return Theme.accentTertiary;
if (model.isUrgent)
return Theme.error;
return Theme.outline;
}
scale: model.isFocused ? 1.0 : 0.9
z: 0
MouseArea {
id: pillMouseArea
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
WorkspaceManager.switchToWorkspace(model.idx);
}
z: 20
hoverEnabled: true
}
// Material 3-inspired smooth animation for width, height, scale, color, opacity, and radius
Behavior on width {
NumberAnimation {
duration: 350
easing.type: Easing.OutBack
}
}
Behavior on height {
NumberAnimation {
duration: 350
easing.type: Easing.OutBack
}
}
Behavior on scale {
NumberAnimation {
duration: 300
easing.type: Easing.OutBack
}
}
Behavior on color {
ColorAnimation {
duration: 200
easing.type: Easing.InOutCubic
}
}
Behavior on opacity {
NumberAnimation {
duration: 200
easing.type: Easing.InOutCubic
}
}
Behavior on radius {
NumberAnimation {
duration: 350
easing.type: Easing.OutBack
}
}
}
Behavior on width {
NumberAnimation {
duration: 350
easing.type: Easing.OutBack
}
}
Behavior on height {
NumberAnimation {
duration: 350
easing.type: Easing.OutBack
}
}
// Burst effect overlay for focused pill (smaller outline)
Rectangle {
id: pillBurst
anchors.centerIn: workspacePillContainer
width: workspacePillContainer.width + 18 * root.masterProgress * Theme.scale(screen)
height: workspacePillContainer.height + 18 * root.masterProgress * Theme.scale(screen)
radius: width / 2
color: "transparent"
border.color: root.effectColor
border.width: (2 + 6 * (1.0 - root.masterProgress)) * Theme.scale(screen)
opacity: root.effectsActive && model.isFocused ? (1.0 - root.masterProgress) * 0.7 : 0
visible: root.effectsActive && model.isFocused
z: 1
}
}
}
}
Component.onDestruction: {
root.isDestroying = true;
}
}

7
Bin/run-qmlfmt.sh Executable file
View file

@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Uses: https://github.com/jesperhh/qmlfmt
# Can be installed from AUR "qmlfmt-git"
# Requires qt6-5compat
find . -name "*.qml" -print -exec qmlfmt -e -b 120 -t 2 -i 2 -w {} \;

202
Bin/system-stats.sh Executable file
View file

@ -0,0 +1,202 @@
#!/bin/bash
# A Bash script to monitor system stats and output them in JSON format.
# This script is a conversion of ZigStat
# --- Configuration ---
# Default sleep duration in seconds. Can be overridden by the first argument.
SLEEP_DURATION=3
# --- Argument Parsing ---
# Check if a command-line argument is provided for the sleep duration.
if [[ -n "$1" ]]; then
# Basic validation to ensure the argument is a number (integer or float).
if [[ "$1" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
SLEEP_DURATION=$1
else
# Output to stderr if the format is invalid.
echo "Warning: Invalid duration format '$1'. Using default of ${SLEEP_DURATION}s." >&2
fi
fi
# --- Global Cache Variables ---
# These variables will store the discovered CPU temperature sensor path and type
# to avoid searching for it on every loop iteration.
TEMP_SENSOR_PATH=""
TEMP_SENSOR_TYPE=""
# --- Data Collection Functions ---
#
# Gets memory usage in GB, MB, and as a percentage.
#
get_memory_info() {
awk '
/MemTotal/ {total=$2}
/MemAvailable/ {available=$2}
END {
if (total > 0) {
usage_kb = total - available
usage_gb = usage_kb / 1000000
usage_percent = (usage_kb / total) * 100
printf "%.1f %.0f\n", usage_gb, usage_percent
} else {
# Fallback if /proc/meminfo is unreadable or empty.
print "0.0 0 0"
}
}
' /proc/meminfo
}
#
# Gets the usage percentage of the root filesystem ("/").
#
get_disk_usage() {
# df gets disk usage. --output=pcent shows only the percentage for the root path.
# tail -1 gets the data line, and tr removes the '%' sign and whitespace.
df --output=pcent / | tail -1 | tr -d ' %'
}
#
# Calculates current CPU usage over a short interval.
#
get_cpu_usage() {
# Read all 10 CPU time fields to prevent errors on newer kernels.
read -r cpu prev_user prev_nice prev_system prev_idle prev_iowait prev_irq prev_softirq prev_steal prev_guest prev_guest_nice < /proc/stat
# Calculate previous total and idle times.
local prev_total_idle=$((prev_idle + prev_iowait))
local prev_total=$((prev_user + prev_nice + prev_system + prev_idle + prev_iowait + prev_irq + prev_softirq + prev_steal + prev_guest + prev_guest_nice))
# Wait for a short period.
sleep 0.05
# Read all 10 CPU time fields again for the second measurement.
read -r cpu user nice system idle iowait irq softirq steal guest guest_nice < /proc/stat
# Calculate new total and idle times.
local total_idle=$((idle + iowait))
local total=$((user + nice + system + idle + iowait + irq + softirq + steal + guest + guest_nice))
# Add a check to prevent division by zero if total hasn't changed.
if (( total <= prev_total )); then
echo "0.0"
return
fi
# Calculate the difference over the interval.
local diff_total=$((total - prev_total))
local diff_idle=$((total_idle - prev_total_idle))
# Use awk for floating-point calculation and print the percentage.
awk -v total="$diff_total" -v idle="$diff_idle" '
BEGIN {
if (total > 0) {
# Formula: 100 * (Total - Idle) / Total
usage = 100 * (total - idle) / total
printf "%.1f\n", usage
} else {
print "0.0"
}
}'
}
#
# Finds and returns the CPU temperature in degrees Celsius.
# Caches the sensor path for efficiency.
#
get_cpu_temp() {
# If the sensor path hasn't been found yet, search for it.
if [[ -z "$TEMP_SENSOR_PATH" ]]; then
for dir in /sys/class/hwmon/hwmon*; do
# Check if the 'name' file exists and read it.
if [[ -f "$dir/name" ]]; then
local name
name=$(<"$dir/name")
# Check for supported sensor types.
if [[ "$name" == "coretemp" || "$name" == "k10temp" ]]; then
TEMP_SENSOR_PATH=$dir
TEMP_SENSOR_TYPE=$name
break # Found it, no need to keep searching.
fi
fi
done
fi
# If after searching no sensor was found, return 0.
if [[ -z "$TEMP_SENSOR_PATH" ]]; then
echo 0
return
fi
# --- Get temp based on sensor type ---
if [[ "$TEMP_SENSOR_TYPE" == "coretemp" ]]; then
# For Intel 'coretemp', average all available temperature sensors.
local total_temp=0
local sensor_count=0
# Use a for loop with a glob to iterate over all temp input files.
# This is more efficient than 'find' for this simple case.
for temp_file in "$TEMP_SENSOR_PATH"/temp*_input; do
# The glob returns the pattern itself if no files match,
# so we must check if the file actually exists.
if [[ -f "$temp_file" ]]; then
total_temp=$((total_temp + $(<"$temp_file")))
sensor_count=$((sensor_count + 1))
fi
done
if (( sensor_count > 0 )); then
# Use awk for the final division to handle potential floating point numbers
# and convert from millidegrees to integer degrees Celsius.
awk -v total="$total_temp" -v count="$sensor_count" 'BEGIN { print int(total / count / 1000) }'
else
# If no sensor files were found, return 0.
echo 0
fi
elif [[ "$TEMP_SENSOR_TYPE" == "k10temp" ]]; then
# For AMD 'k10temp', find the 'Tctl' sensor, which is the control temperature.
local tctl_input=""
for label_file in "$TEMP_SENSOR_PATH"/temp*_label; do
if [[ -f "$label_file" ]] && [[ $(<"$label_file") == "Tctl" ]]; then
# The input file has the same name but with '_input' instead of '_label'.
tctl_input="${label_file%_label}_input"
break
fi
done
if [[ -f "$tctl_input" ]]; then
# Read the temperature and convert from millidegrees to degrees.
echo "$(( $(<"$tctl_input") / 1000 ))"
else
echo 0 # Fallback
fi
else
echo 0 # Should not happen if cache logic is correct.
fi
}
# --- Main Loop ---
# This loop runs indefinitely, gathering and printing stats.
while true; do
# Call the functions to gather all the data.
# get_memory_info
read -r mem_gb mem_per <<< "$(get_memory_info)"
# Command substitution captures the single output from the other functions.
disk_per=$(get_disk_usage)
cpu_usage=$(get_cpu_usage)
cpu_temp=$(get_cpu_temp)
# Use printf to format the final JSON output string, adding the mem_mb key.
printf '{"cpu": "%s", "cputemp": "%s", "memgb":"%s", "memper": "%s", "diskper": "%s"}\n' \
"$cpu_usage" \
"$cpu_temp" \
"$mem_gb" \
"$mem_per" \
"$disk_per"
# Wait for the specified duration before the next update.
sleep "$SLEEP_DURATION"
done

11
Bin/test-notifications.sh Executable file
View file

@ -0,0 +1,11 @@
#!/bin/bash
echo "Sending 8 test notifications..."
# Send 8 notifications with numbers
for i in {1..8}; do
notify-send "Notification $i" "This is test notification number $i of 8"
sleep 1
done
echo "All notifications sent!"

135
Commons/Color.qml Normal file
View file

@ -0,0 +1,135 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services
// --------------------------------
// Noctalia Colors - Material Design 3
// We only use a very small subset of all available m3 colors to avoid complexity
// All color names start with a 'm' to avoid QML assuming some of them are signals (ex: onPrimary)
Singleton {
id: root
// --- Key Colors: These are the main accent colors that define your app's style
property color mPrimary: customColors.mPrimary
property color mOnPrimary: customColors.mOnPrimary
property color mSecondary: customColors.mSecondary
property color mOnSecondary: customColors.mOnSecondary
property color mTertiary: customColors.mTertiary
property color mOnTertiary: customColors.mOnTertiary
// --- Utility Colors: These colors serve specific, universal purposes like indicating errors
property color mError: customColors.mError
property color mOnError: customColors.mOnError
// --- Surface and Variant Colors: These provide additional options for surfaces and their contents, creating visual hierarchy
property color mSurface: customColors.mSurface
property color mOnSurface: customColors.mOnSurface
property color mSurfaceVariant: customColors.mSurfaceVariant
property color mOnSurfaceVariant: customColors.mOnSurfaceVariant
property color mOutline: customColors.mOutline
property color mOutlineVariant: customColors.mOutlineVariant
property color mShadow: customColors.mShadow
property color transparent: "transparent"
// -----------
function applyOpacity(color, opacity) {
// Convert color to string and apply opacity
return color.toString().replace("#", "#" + opacity)
}
// --------------------------------
// Default colors: RosePine
QtObject {
id: defaultColors
property color mPrimary: "#c7a1d8"
property color mOnPrimary: "#1a151f"
property color mSecondary: "#a984c4"
property color mOnSecondary: "#f3edf7"
property color mTertiary: "#e0b7c9"
property color mOnTertiary: "#20161f"
property color mError: "#e9899d"
property color mOnError: "#1e1418"
property color mSurface: "#1c1822"
property color mOnSurface: "#e9e4f0"
property color mSurfaceVariant: "#262130"
property color mOnSurfaceVariant: "#a79ab0"
property color mOutline: "#4d445a"
property color mOutlineVariant: "#342c42"
property color mShadow: "#120f18"
}
// ----------------------------------------------------------------
// Custom colors loaded from colors.json
// These can be generated by matugen or simply come from a well know color scheme (Dracula, Gruvbox, Nord, ...)
QtObject {
id: customColors
property color mPrimary: customColorsData.mPrimary
property color mOnPrimary: customColorsData.mOnPrimary
property color mSecondary: customColorsData.mSecondary
property color mOnSecondary: customColorsData.mOnSecondary
property color mTertiary: customColorsData.mTertiary
property color mOnTertiary: customColorsData.mOnTertiary
property color mError: customColorsData.mError
property color mOnError: customColorsData.mOnError
property color mSurface: customColorsData.mSurface
property color mOnSurface: customColorsData.mOnSurface
property color mSurfaceVariant: customColorsData.mSurfaceVariant
property color mOnSurfaceVariant: customColorsData.mOnSurfaceVariant
property color mOutline: customColorsData.mOutline
property color mOutlineVariant: customColorsData.mOutlineVariant
property color mShadow: customColorsData.mShadow
}
// FileView to load custom colors data from colors.json
FileView {
id: customColorsFile
path: Settings.configDir + "colors.json"
watchChanges: true
onFileChanged: {
Logger.log("Color", "Reloading colors from disk")
reload()
}
onAdapterUpdated: {
Logger.log("Color", "Writing colors to disk")
writeAdapter()
}
onLoadFailed: function (error) {
if (error.toString().includes("No such file") || error === 2) {
// File doesn't exist, create it with default values
writeAdapter()
}
}
JsonAdapter {
id: customColorsData
property color mPrimary: defaultColors.mPrimary
property color mOnPrimary: defaultColors.mOnPrimary
property color mSecondary: defaultColors.mSecondary
property color mOnSecondary: defaultColors.mOnSecondary
property color mTertiary: defaultColors.mTertiary
property color mOnTertiary: defaultColors.mOnTertiary
property color mError: defaultColors.mError
property color mOnError: defaultColors.mOnError
property color mSurface: defaultColors.mSurface
property color mOnSurface: defaultColors.mOnSurface
property color mSurfaceVariant: defaultColors.mSurfaceVariant
property color mOnSurfaceVariant: defaultColors.mOnSurfaceVariant
property color mOutline: defaultColors.mOutline
property color mOutlineVariant: defaultColors.mOutlineVariant
property color mShadow: defaultColors.mShadow
}
}
}

34
Commons/Logger.qml Normal file
View file

@ -0,0 +1,34 @@
pragma Singleton
import Quickshell
import qs.Commons
Singleton {
id: root
function _formatMessage(...args) {
var t = Time.getFormattedTimestamp()
if (args.length > 1) {
const maxLength = 14
var module = args.shift().substring(0, maxLength).padStart(maxLength, " ")
return `\x1b[36m[${t}]\x1b[0m \x1b[35m${module}\x1b[0m ` + args.join(" ")
} else {
return `[\x1b[36m[${t}]\x1b[0m ` + args.join(" ")
}
}
function log(...args) {
var msg = _formatMessage(...args)
console.log(msg)
}
function warn(...args) {
var msg = _formatMessage(...args)
console.warn(msg)
}
function error(...args) {
var msg = _formatMessage(...args)
console.error(msg)
}
}

203
Commons/Settings.qml Normal file
View file

@ -0,0 +1,203 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services
pragma Singleton
Singleton {
id: root
// Define our app directories
// Default config directory: ~/.config/noctalia
// Default cache directory: ~/.cache/noctalia
property string shellName: "noctalia"
property string configDir: Quickshell.env("NOCTALIA_CONFIG_DIR") || (Quickshell.env("XDG_CONFIG_HOME")
|| Quickshell.env(
"HOME") + "/.config") + "/" + shellName + "/"
property string cacheDir: Quickshell.env("NOCTALIA_CACHE_DIR") || (Quickshell.env("XDG_CACHE_HOME") || Quickshell.env(
"HOME") + "/.cache") + "/" + shellName + "/"
property string settingsFile: Quickshell.env("NOCTALIA_SETTINGS_FILE") || (configDir + "settings.json")
property string defaultWallpaper: Qt.resolvedUrl("../Assets/Tests/wallpaper.png")
property string defaultAvatar: Quickshell.env("HOME") + "/.face"
// Used to access via Settings.data.xxx.yyy
property alias data: adapter
// Flag to prevent unnecessary wallpaper calls during reloads
property bool isInitialLoad: true
// Needed to only have one NPanel loaded at a time. <--- VERY BROKEN
//property var openPanel: null
Item {
Component.onCompleted: {
// ensure settings dir exists
Quickshell.execDetached(["mkdir", "-p", configDir])
Quickshell.execDetached(["mkdir", "-p", cacheDir])
}
}
FileView {
path: settingsFile
watchChanges: true
onFileChanged: reload()
onAdapterUpdated: writeAdapter()
Component.onCompleted: function () {
reload()
}
onLoaded: function () {
Logger.log("Settings", "OnLoaded")
Qt.callLater(function () {
// Only set wallpaper on initial load, not on reloads
if (isInitialLoad && adapter.wallpaper.current !== "") {
Logger.log("Settings", "Set current wallpaper", adapter.wallpaper.current)
WallpaperService.setCurrentWallpaper(adapter.wallpaper.current, true)
}
isInitialLoad = false
})
}
onLoadFailed: function (error) {
if (error.toString().includes("No such file") || error === 2)
// File doesn't exist, create it with default values
writeAdapter()
}
JsonAdapter {
id: adapter
// bar
property JsonObject bar
bar: JsonObject {
property bool showActiveWindow: true
property bool showSystemInfo: false
property bool showMedia: false
property bool showBrightness: true
property bool showNotificationsHistory: true
property bool showTray: true
property list<string> monitors: []
}
// general
property JsonObject general
general: JsonObject {
property string avatarImage: defaultAvatar
property bool dimDesktop: true
property bool showScreenCorners: false
}
// location
property JsonObject location
location: JsonObject {
property string name: "Tokyo"
property bool useFahrenheit: false
property bool reverseDayMonth: false
property bool use12HourClock: false
property bool showDateWithClock: false
}
// screen recorder
property JsonObject screenRecorder
screenRecorder: JsonObject {
property string directory: "~/Videos"
property int frameRate: 60
property string audioCodec: "opus"
property string videoCodec: "h264"
property string quality: "very_high"
property string colorRange: "limited"
property bool showCursor: true
property string audioSource: "default_output"
}
// wallpaper
property JsonObject wallpaper
wallpaper: JsonObject {
property string directory: "/usr/share/wallpapers"
property string current: ""
property bool isRandom: false
property int randomInterval: 300
property JsonObject swww
onDirectoryChanged: WallpaperService.listWallpapers()
onIsRandomChanged: WallpaperService.toggleRandomWallpaper()
onRandomIntervalChanged: WallpaperService.restartRandomWallpaperTimer()
swww: JsonObject {
property bool enabled: false
property string resizeMethod: "crop"
property int transitionFps: 60
property string transitionType: "random"
property real transitionDuration: 1.1
}
}
// applauncher
property JsonObject appLauncher
appLauncher: JsonObject {
property list<string> pinnedExecs: []
}
// dock
property JsonObject dock
dock: JsonObject {
property bool autoHide: false
property bool exclusive: false
property list<string> monitors: []
}
// network
property JsonObject network
network: JsonObject {
property bool wifiEnabled: true
property bool bluetoothEnabled: true
}
// notifications
property JsonObject notifications
notifications: JsonObject {
property list<string> monitors: []
}
// audio
property JsonObject audio
audio: JsonObject {
property string visualizerType: "linear"
}
// ui
property JsonObject ui
ui: JsonObject {
property string fontFamily: "Roboto" // Family for all text
property list<string> monitorsScale: []
}
// brightness
property JsonObject brightness
brightness: JsonObject {
property int brightnessStep: 5
}
property JsonObject colorSchemes
colorSchemes: JsonObject {
property bool useWallpaperColors: false
property string predefinedScheme: ""
property bool darkMode: true
}
}
}
}

72
Commons/Style.qml Normal file
View file

@ -0,0 +1,72 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
/*
Preset sizes for font, radii, ?
*/
// Font size
property real fontSizeTiny: 7
property real fontSizeSmall: 9
property real fontSizeReduced: 10
property real fontSizeMedium: 11
property real fontSizeInter: 12
property real fontSizeLarge: 13
property real fontSizeLarger: 16
property real fontSizeXL: 18
property real fontSizeXXL: 24
// Font weight / Unsure if we keep em?
property int fontWeightRegular: 400
property int fontWeightMedium: 500
property int fontWeightBold: 700
// Radii
property int radiusTiny: 8
property int radiusSmall: 12
property int radiusMedium: 16
property int radiusLarge: 20
// Border
property int borderThin: 1
property int borderMedium: 2
property int borderThick: 3
// Animation duration (ms)
property int animationFast: 150
property int animationNormal: 300
property int animationSlow: 500
// Dimensions
property int barHeight: 36
property int baseWidgetSize: 32
property int sliderWidth: 200
// Delays
property int tooltipDelay: 300
property int tooltipDelayLong: 1200
property int pillDelay: 500
// Margins (for margins and spacing)
property int marginTiniest: 2
property int marginTiny: 4
property int marginSmall: 8
property int marginMedium: 12
property int marginLarge: 16
property int marginXL: 24
// Opacity
property real opacityNone: 0.0
property real opacityLight: 0.25
property real opacityMedium: 0.5
property real opacityHeavy: 0.75
property real opacityAlmost: 0.95
property real opacityFull: 1.0
}

106
Commons/Time.qml Normal file
View file

@ -0,0 +1,106 @@
pragma Singleton
import Quickshell
import QtQuick
import qs.Commons
import qs.Services
Singleton {
id: root
property var date: new Date()
property string time: {
let timeFormat = Settings.data.location.use12HourClock ? "h:mm AP" : "HH:mm"
let timeString = Qt.formatDateTime(date, timeFormat)
if (Settings.data.location.showDateWithClock) {
let dayName = date.toLocaleDateString(Qt.locale(), "ddd")
dayName = dayName.charAt(0).toUpperCase() + dayName.slice(1)
let day = date.getDate()
let month = date.toLocaleDateString(Qt.locale(), "MMM")
return timeString + " - " + dayName + ", " + day + " " + month
}
return timeString
}
readonly property string dateString: {
let now = date
let dayName = now.toLocaleDateString(Qt.locale(), "ddd")
dayName = dayName.charAt(0).toUpperCase() + dayName.slice(1)
let day = now.getDate()
let suffix
if (day > 3 && day < 21)
suffix = 'th'
else
switch (day % 10) {
case 1:
suffix = "st"
break
case 2:
suffix = "nd"
break
case 3:
suffix = "rd"
break
default:
suffix = "th"
}
let month = now.toLocaleDateString(Qt.locale(), "MMMM")
let year = now.toLocaleDateString(Qt.locale(), "yyyy")
return `${dayName}, `
+ (Settings.data.location.reverseDayMonth ? `${month} ${day}${suffix} ${year}` : `${day}${suffix} ${month} ${year}`)
}
// Returns a Unix Timestamp (in seconds)
readonly property int timestamp: {
return Math.floor(date / 1000)
}
/**
* Formats a Date object into a YYYYMMDD-HHMMSS string.
* @param {Date} [date=new Date()] - The date to format. Defaults to the current date and time.
* @returns {string} The formatted date string.
*/
function getFormattedTimestamp(date = new Date()) {
const year = date.getFullYear()
// getMonth() is zero-based, so we add 1
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}${month}${day}-${hours}${minutes}${seconds}`
}
// Format an easy to read approximate duration ex: 4h32m
// Used to display the time remaining on the Battery widget
function formatVagueHumanReadableDuration(totalSeconds) {
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds - (hours * 3600)) / 60)
const seconds = totalSeconds - (hours * 3600) - (minutes * 60)
var str = ""
if (hours) {
str += hours.toString() + "h"
}
if (minutes) {
str += minutes.toString() + "m"
}
if (!hours && !minutes) {
str += seconds.toString() + "s"
}
return str
}
Timer {
interval: 1000
repeat: true
running: true
onTriggered: root.date = new Date()
}
}

View file

@ -1,51 +0,0 @@
import QtQuick
import Quickshell
import Quickshell.Widgets
import qs.Settings
import QtQuick.Effects
Item {
anchors.fill: parent
anchors.margins: 2
Image {
id: avatarImage
anchors.fill: parent
source: "file://" + Settings.settings.profileImage
visible: false
mipmap: true
smooth: true
asynchronous: true
fillMode: Image.PreserveAspectCrop
}
MultiEffect {
anchors.fill: parent
source: avatarImage
maskEnabled: true
maskSource: mask
visible: Settings.settings.profileImage !== ""
}
Item {
id: mask
anchors.fill: parent
layer.enabled: true
visible: false
Rectangle {
anchors.fill: parent
radius: avatarImage.width / 2
}
}
// Fallback icon
Text {
anchors.centerIn: parent
text: "person"
font.family: "Material Symbols Outlined"
font.pixelSize: 24 * Theme.scale(screen)
color: Theme.onAccent
visible: Settings.settings.profileImage === undefined || Settings.settings.profileImage === ""
z: 0
}
}

View file

@ -1,77 +0,0 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
Scope {
id: root
property int count: 32
property int noiseReduction: 60
property string channels: "mono"
property string monoOption: "average"
property var config: ({
general: {
bars: count,
framerate: 30,
autosens: 1
},
smoothing: {
monstercat: 1,
gravity: 1000000,
noise_reduction: noiseReduction
},
output: {
method: "raw",
bit_format: 8,
channels: channels,
mono_option: monoOption
}
})
property var values: Array(count).fill(0)
Process {
id: process
property int index: 0
stdinEnabled: true
running: MusicManager.isPlaying
command: ["cava", "-p", "/dev/stdin"]
onExited: {
stdinEnabled = true;
index = 0;
values = Array(count).fill(0);
}
onStarted: {
for (const k in config) {
if (typeof config[k] !== "object") {
write(k + "=" + config[k] + "\n");
continue;
}
write("[" + k + "]\n");
const obj = config[k];
for (const k2 in obj) {
write(k2 + "=" + obj[k2] + "\n");
}
}
stdinEnabled = false;
}
stdout: SplitParser {
splitMarker: ""
onRead: data => {
const newValues = Array(count).fill(0);
for (let i = 0; i < values.length; i++) {
newValues[i] = values[i];
}
if (process.index + data.length > count) {
process.index = 0;
}
for (let i = 0; i < data.length; i += 1) {
newValues[process.index] = Math.min(data.charCodeAt(i), 128) / 128;
process.index = (process.index+1) % count;
}
values = newValues;
}
}
}
}

View file

@ -1,130 +0,0 @@
import QtQuick
import qs.Settings
Rectangle {
id: circularProgressBar
color: "transparent"
property real progress: 0.0
property int size: 80
property color backgroundColor: Theme.surfaceVariant
property color progressColor: Theme.accentPrimary
property int strokeWidth: 6 * Theme.scale(screen)
property bool showText: true
property string units: "%"
property string text: Math.round(progress * 100) + units
property int textSize: 10 * Theme.scale(screen)
property color textColor: Theme.textPrimary
// Notch properties
property bool hasNotch: false
property real notchSize: 0.25
property string notchIcon: ""
property int notchIconSize: 12
property color notchIconColor: Theme.accentPrimary
width: size
height: size
Canvas {
id: canvas
anchors.fill: parent
onPaint: {
// Setup canvas context and calculate dimensions
var ctx = getContext("2d")
var centerX = width / 2
var centerY = height / 2
var radius = Math.min(width, height) / 2 - strokeWidth / 2
var startAngle = -Math.PI / 2 // Start from top
var notchAngle = notchSize * 2 * Math.PI
var notchStartAngle = -notchAngle / 2
var notchEndAngle = notchAngle / 2
ctx.reset()
ctx.strokeStyle = backgroundColor
ctx.lineWidth = strokeWidth
ctx.lineCap = "round"
ctx.beginPath()
if (hasNotch) {
// Draw background arc with notch gap
ctx.arc(centerX, centerY, radius, notchEndAngle, 2 * Math.PI + notchStartAngle)
} else {
// Draw full background circle
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI)
}
ctx.stroke()
// Draw progress arc
if (progress > 0) {
ctx.strokeStyle = progressColor
ctx.lineWidth = strokeWidth
ctx.lineCap = "round"
ctx.beginPath()
if (hasNotch) {
// Calculate progress arc with notch gap
var availableAngle = 2 * Math.PI - notchAngle
var progressAngle = availableAngle * progress
var adjustedStartAngle = notchEndAngle
var adjustedEndAngle = adjustedStartAngle + progressAngle
if (adjustedEndAngle > 2 * Math.PI + notchStartAngle) {
adjustedEndAngle = 2 * Math.PI + notchStartAngle
}
if (adjustedEndAngle > adjustedStartAngle) {
ctx.arc(centerX, centerY, radius, adjustedStartAngle, adjustedEndAngle)
}
} else {
// Draw full progress arc
ctx.arc(centerX, centerY, radius, startAngle, startAngle + (2 * Math.PI * progress))
}
ctx.stroke()
}
}
}
// Center text - always show the percentage
Text {
id: centerText
anchors.centerIn: parent
text: circularProgressBar.text
font.pixelSize: textSize
font.family: Theme.fontFamily
font.bold: true
color: textColor
visible: showText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
// Notch icon - positioned further to the right
Text {
id: notchIconText
anchors.right: parent.right
anchors.rightMargin: -4
anchors.verticalCenter: parent.verticalCenter
text: notchIcon
font.family: "Material Symbols Outlined"
font.pixelSize: notchIconSize
color: notchIconColor
visible: hasNotch && notchIcon !== ""
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
// Animate progress changes
Behavior on progress {
NumberAnimation { duration: 300; easing.type: Easing.OutCubic }
}
// Redraw canvas when properties change
onProgressChanged: canvas.requestPaint()
onSizeChanged: canvas.requestPaint()
onBackgroundColorChanged: canvas.requestPaint()
onProgressColorChanged: canvas.requestPaint()
onStrokeWidthChanged: canvas.requestPaint()
onHasNotchChanged: canvas.requestPaint()
onNotchSizeChanged: canvas.requestPaint()
}

View file

@ -1,57 +0,0 @@
import QtQuick
import qs.Components
import qs.Settings
Item {
id: root
property int innerRadius: 34 * Theme.scale(Screen)
property int outerRadius: 48 * Theme.scale(Screen)
property color fillColor: "#fff"
property color strokeColor: "#fff"
property int strokeWidth: 0 * Theme.scale(Screen)
property var values: []
property int usableOuter: 48
width: usableOuter * 2
height: usableOuter * 2
onOuterRadiusChanged: () => {
usableOuter = Settings.settings.visualizerType === "fire" ? outerRadius * 0.85 : outerRadius;
}
Repeater {
model: root.values.length
Rectangle {
property real value: root.values[index]
property real angle: (index / root.values.length) * 360
width: Math.max(2 * Theme.scale(Screen), (root.innerRadius * 2 * Math.PI) / root.values.length - 4 * Theme.scale(Screen))
height: Settings.settings.visualizerType === "diamond" ? value * 2 * (usableOuter - root.innerRadius) : value * (usableOuter - root.innerRadius)
radius: width / 2
color: root.fillColor
border.color: root.strokeColor
border.width: root.strokeWidth
antialiasing: true
x: Settings.settings.visualizerType === "radial" ? root.width / 2 - width / 2 : root.width / 2 + root.innerRadius * Math.cos(Math.PI / 2 + 2 * Math.PI * index / root.values.length) - width / 2
y: Settings.settings.visualizerType === "radial" ? root.height / 2 - height : Settings.settings.visualizerType === "diamond" ? root.height / 2 - root.innerRadius * Math.sin(Math.PI / 2 + 2 * Math.PI * index / root.values.length) - height / 2 : root.height / 2 - root.innerRadius * Math.sin(Math.PI / 2 + 2 * Math.PI * index / root.values.length) - height
transform: [
Rotation {
origin.x: width / 2
origin.y: Settings.settings.visualizerType === "diamond" ? height / 2 : height
angle: Settings.settings.visualizerType === "radial" ? (index / root.values.length) * 360 : Settings.settings.visualizerType === "fire" ? 0 : (index / root.values.length) * 360 - 90
},
Translate {
x: Settings.settings.visualizerType === "radial" ? root.innerRadius * Math.cos(2 * Math.PI * index / root.values.length) : 0
y: Settings.settings.visualizerType === "radial" ? root.innerRadius * Math.sin(2 * Math.PI * index / root.values.length) : 0
}
]
Behavior on height {
SmoothedAnimation {
duration: 120
}
}
}
}
}

View file

@ -1,86 +0,0 @@
import QtQuick
import QtQuick.Shapes
import qs.Settings
Shape {
id: root
property string position: "topleft" // Corner position: topleft/topright/bottomleft/bottomright
property real size: 1.0 * Theme.scale(screen) // Scale multiplier for entire corner
property int concaveWidth: 100 * size
property int concaveHeight: 60 * size
property int offsetX: -20 * Theme.scale(screen)
property int offsetY: -20 * Theme.scale(screen)
property color fillColor: Theme.accentPrimary
property int arcRadius: 20 * size
property var modelData: null
// Position flags derived from position string - calculated once
readonly property bool _isTop: position.includes("top")
readonly property bool _isLeft: position.includes("left")
readonly property bool _isRight: position.includes("right")
readonly property bool _isBottom: position.includes("bottom")
// Shift the path vertically if offsetY is negative to pull shape up
readonly property real pathOffsetY: Math.min(offsetY, 0)
// Base coordinates for left corner shape, shifted by pathOffsetY vertically
readonly property real _baseStartX: 30 * size
readonly property real _baseStartY: (_isTop ? 20 * size : 0) + pathOffsetY
readonly property real _baseLineX: 30 * size
readonly property real _baseLineY: (_isTop ? 0 : 20 * size) + pathOffsetY
readonly property real _baseArcX: 50 * size
readonly property real _baseArcY: (_isTop ? 20 * size : 0) + pathOffsetY
// Mirror coordinates for right corners
readonly property real _startX: _isRight ? (concaveWidth - _baseStartX) : _baseStartX
readonly property real _startY: _baseStartY
readonly property real _lineX: _isRight ? (concaveWidth - _baseLineX) : _baseLineX
readonly property real _lineY: _baseLineY
readonly property real _arcX: _isRight ? (concaveWidth - _baseArcX) : _baseArcX
readonly property real _arcY: _baseArcY
// Arc direction varies by corner to maintain proper concave shape
readonly property int _arcDirection: {
if (_isTop && _isLeft) return PathArc.Counterclockwise
if (_isTop && _isRight) return PathArc.Clockwise
if (_isBottom && _isLeft) return PathArc.Clockwise
if (_isBottom && _isRight) return PathArc.Counterclockwise
return PathArc.Counterclockwise
}
width: concaveWidth
height: concaveHeight
// Position relative to parent based on corner type
x: _isLeft ? offsetX : (parent ? parent.width - width + offsetX : 0)
y: _isTop ? offsetY : (parent ? parent.height - height + offsetY : 0)
preferredRendererType: Shape.CurveRenderer // Use GPU-based renderer
layer.enabled: false // Disable layer rendering to save memory
antialiasing: true // Use standard antialiasing instead of MSAA
ShapePath {
strokeWidth: 0
fillColor: root.fillColor
strokeColor: root.fillColor
startX: root._startX
startY: root._startY
PathLine {
x: root._lineX
y: root._lineY
}
PathArc {
x: root._arcX
y: root._arcY
radiusX: root.arcRadius
radiusY: root.arcRadius
useLargeArc: false
direction: root._arcDirection
}
}
}

View file

@ -1,36 +0,0 @@
import QtQuick
import Quickshell
import Quickshell.Widgets
import qs.Settings
MouseArea {
id: root
property string icon
property bool enabled: true
property bool hovering: false
property real size: 32
cursorShape: Qt.PointingHandCursor
implicitWidth: size
implicitHeight: size
hoverEnabled: true
onEntered: hovering = true
onExited: hovering = false
Rectangle {
anchors.fill: parent
radius: 8
color: root.hovering ? Theme.accentPrimary : "transparent"
}
Text {
id: iconText
anchors.centerIn: parent
text: root.icon
font.family: "Material Symbols Outlined"
font.pixelSize: 24 * Theme.scale(screen)
color: root.hovering ? Theme.onAccent : Theme.textPrimary
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
opacity: root.enabled ? 1.0 : 0.5
}
}

View file

@ -1,43 +0,0 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Settings
PanelWindow {
id: outerPanel
property bool showOverlay: Settings.settings.dimPanels
property int topMargin: 36 * Theme.scale(screen)
property color overlayColor: showOverlay ? Theme.overlay : "transparent"
function dismiss() {
visible = false;
}
function show() {
visible = true;
}
implicitWidth: screen.width
implicitHeight: screen.height
color: visible ? overlayColor : "transparent"
visible: false
WlrLayershell.exclusionMode: ExclusionMode.Ignore
screen: (typeof modelData !== 'undefined' ? modelData : null)
anchors.top: true
anchors.left: true
anchors.right: true
anchors.bottom: true
margins.top: topMargin
MouseArea {
anchors.fill: parent
onClicked: outerPanel.dismiss()
}
Behavior on color {
ColorAnimation {
duration: 350
easing.type: Easing.InOutCubic
}
}
}

View file

@ -1,200 +0,0 @@
import QtQuick
import QtQuick.Controls
import qs.Settings
Item {
id: revealPill
// External properties
property string icon: ""
property string text: ""
property color pillColor: Theme.surfaceVariant
property color textColor: Theme.textPrimary
property color iconCircleColor: Theme.accentPrimary
property color iconTextColor: Theme.backgroundPrimary
property color collapsedIconColor: Theme.textPrimary
property int pillHeight: 22 * Theme.scale(Screen)
property int iconSize: 22 * Theme.scale(Screen)
property int pillPaddingHorizontal: 14
property bool autoHide: false
// Internal state
property bool showPill: false
property bool shouldAnimateHide: false
// Exposed width logic
readonly property int pillOverlap: iconSize / 2
readonly property int maxPillWidth: Math.max(1, textItem.implicitWidth + pillPaddingHorizontal * 2 + pillOverlap)
signal shown
signal hidden
width: iconSize + (showPill ? maxPillWidth - pillOverlap : 0)
height: pillHeight
Rectangle {
id: pill
width: showPill ? maxPillWidth : 1
height: pillHeight
x: (iconCircle.x + iconCircle.width / 2) - width
opacity: showPill ? 1 : 0
color: pillColor
topLeftRadius: pillHeight / 2
bottomLeftRadius: pillHeight / 2
anchors.verticalCenter: parent.verticalCenter
Text {
id: textItem
anchors.centerIn: parent
text: revealPill.text
font.pixelSize: Theme.fontSizeSmall * Theme.scale(Screen)
font.family: Theme.fontFamily
font.weight: Font.Bold
color: textColor
visible: showPill
}
Behavior on width {
enabled: showAnim.running || hideAnim.running
NumberAnimation {
duration: 250
easing.type: Easing.OutCubic
}
}
Behavior on opacity {
enabled: showAnim.running || hideAnim.running
NumberAnimation {
duration: 250
easing.type: Easing.OutCubic
}
}
}
Rectangle {
id: iconCircle
width: iconSize
height: iconSize
radius: width / 2
color: showPill ? iconCircleColor : "transparent"
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
Behavior on color {
ColorAnimation {
duration: 200
easing.type: Easing.InOutQuad
}
}
Text {
anchors.centerIn: parent
font.family: showPill ? "Material Symbols Rounded" : "Material Symbols Outlined"
font.pixelSize: Theme.fontSizeSmall * Theme.scale(Screen)
text: revealPill.icon
color: showPill ? iconTextColor : collapsedIconColor
}
}
ParallelAnimation {
id: showAnim
running: false
NumberAnimation {
target: pill
property: "width"
from: 1
to: maxPillWidth
duration: 250
easing.type: Easing.OutCubic
}
NumberAnimation {
target: pill
property: "opacity"
from: 0
to: 1
duration: 250
easing.type: Easing.OutCubic
}
onStarted: {
showPill = true;
}
onStopped: {
delayedHideAnim.start();
shown();
}
}
SequentialAnimation {
id: delayedHideAnim
running: false
PauseAnimation {
duration: 2500
}
ScriptAction {
script: if (shouldAnimateHide)
hideAnim.start()
}
}
ParallelAnimation {
id: hideAnim
running: false
NumberAnimation {
target: pill
property: "width"
from: maxPillWidth
to: 1
duration: 250
easing.type: Easing.InCubic
}
NumberAnimation {
target: pill
property: "opacity"
from: 1
to: 0
duration: 250
easing.type: Easing.InCubic
}
onStopped: {
showPill = false;
shouldAnimateHide = false;
hidden();
}
}
function show() {
if (!showPill) {
shouldAnimateHide = autoHide;
showAnim.start();
} else {
hideAnim.stop();
delayedHideAnim.restart();
}
}
function hide() {
if (showPill) {
hideAnim.start();
}
showTimer.stop();
}
function showDelayed() {
if (!showPill) {
shouldAnimateHide = autoHide;
showTimer.start();
} else {
hideAnim.stop();
delayedHideAnim.restart();
}
}
Timer {
id: showTimer
interval: 500
onTriggered: {
if (!showPill) {
showAnim.start();
}
}
}
}

View file

@ -1,54 +0,0 @@
import QtQuick
import qs.Settings
Item {
id: root
property bool running: false
property color color: "white"
property int size: 16
property int strokeWidth: 2 * Theme.scale(screen)
property int duration: 1000
implicitWidth: size
implicitHeight: size
Canvas {
id: spinnerCanvas
anchors.fill: parent
onPaint: {
var ctx = getContext("2d")
ctx.reset()
var centerX = width / 2
var centerY = height / 2
var radius = Math.min(width, height) / 2 - strokeWidth / 2
ctx.strokeStyle = root.color
ctx.lineWidth = root.strokeWidth
ctx.lineCap = "round"
// Draw arc with gap (270 degrees with 90 degree gap)
ctx.beginPath()
ctx.arc(centerX, centerY, radius, -Math.PI/2 + rotationAngle, -Math.PI/2 + rotationAngle + Math.PI * 1.5)
ctx.stroke()
}
property real rotationAngle: 0
onRotationAngleChanged: {
requestPaint()
}
NumberAnimation {
target: spinnerCanvas
property: "rotationAngle"
running: root.running
from: 0
to: 2 * Math.PI
duration: root.duration
loops: Animation.Infinite
}
}
}

View file

@ -1,111 +0,0 @@
import QtQuick
import QtQuick.Window 2.15
import qs.Settings
Window {
id: tooltipWindow
property string text: ""
property bool tooltipVisible: false
property Item targetItem: null
property int delay: 300
property bool positionAbove: true
flags: Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint
color: "transparent"
visible: false
property var _timerObj: null
onTooltipVisibleChanged: {
if (tooltipVisible) {
if (delay > 0) {
if (_timerObj) { _timerObj.destroy(); _timerObj = null; }
_timerObj = Qt.createQmlObject(
'import QtQuick 2.0; Timer { interval: ' + delay + '; running: true; repeat: false; onTriggered: tooltipWindow._showNow() }',
tooltipWindow);
} else {
_showNow();
}
} else {
_hideNow();
}
}
function _showNow() {
width = Math.max(50 * Theme.scale(screen), tooltipText.implicitWidth + 24 * Theme.scale(screen))
height = Math.max(50 * Theme.scale(screen), tooltipText.implicitHeight + 16 * Theme.scale(screen))
if (!targetItem) return;
if (positionAbove) {
// Position tooltip above the target item
var pos = targetItem.mapToGlobal(0, 0);
x = pos.x - width / 2 + targetItem.width / 2;
y = pos.y - height - 12; // 12 px margin above
} else {
// Position tooltip below the target item
var pos = targetItem.mapToGlobal(0, targetItem.height);
x = pos.x - width / 2 + targetItem.width / 2;
y = pos.y + 12; // 12 px margin below
}
visible = true;
}
function _hideNow() {
visible = false;
if (_timerObj) { _timerObj.destroy(); _timerObj = null; }
}
Connections {
target: tooltipWindow.targetItem
function onXChanged() {
if (tooltipWindow.visible) tooltipWindow._showNow();
}
function onYChanged() {
if (tooltipWindow.visible) tooltipWindow._showNow();
}
function onWidthChanged() {
if (tooltipWindow.visible) tooltipWindow._showNow();
}
function onHeightChanged() {
if (tooltipWindow.visible) tooltipWindow._showNow();
}
}
Rectangle {
anchors.fill: parent
radius: 18
color: Theme.backgroundTertiary || "#222"
border.color: Theme.outline || "#444"
border.width: 1 * Theme.scale(screen)
opacity: 0.97
z: 1
}
Text {
id: tooltipText
text: tooltipWindow.text
color: Theme.textPrimary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall * Theme.scale(screen)
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
wrapMode: Text.Wrap
padding: 8
z: 2
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onExited: tooltipWindow.tooltipVisible = false
cursorShape: Qt.ArrowCursor
}
onTextChanged: {
width = Math.max(minimumWidth * Theme.scale(screen), tooltipText.implicitWidth + 24 * Theme.scale(screen));
height = Math.max(minimumHeight * Theme.scale(screen), tooltipText.implicitHeight + 16 * Theme.scale(screen));
}
}

View file

@ -1,77 +0,0 @@
import QtQuick
import QtQuick.Layouts
import qs.Settings
Item {
id: root
property var tabsModel: []
property int currentIndex: 0
signal tabChanged(int index)
RowLayout {
id: tabBar
anchors.horizontalCenter: parent.horizontalCenter
spacing: 16
Repeater {
model: root.tabsModel
delegate: Rectangle {
id: tabWrapper
implicitHeight: tab.height * Theme.scale(screen)
implicitWidth: 56 * Theme.scale(screen)
color: "transparent"
property bool hovered: false
MouseArea {
anchors.fill: parent
onClicked: {
if (currentIndex !== index) {
currentIndex = index;
tabChanged(index);
}
}
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: parent.hovered = true
onExited: parent.hovered = false
}
ColumnLayout {
id: tab
spacing: 2
anchors.centerIn: parent
Layout.fillWidth: true
Layout.fillHeight: true
// Icon
Text {
text: modelData.icon
font.family: "Material Symbols Outlined"
font.pixelSize: 22 * Theme.scale(screen)
color: index === root.currentIndex ? (Theme ? Theme.accentPrimary : "#7C3AED") : tabWrapper.hovered ? (Theme ? Theme.accentPrimary : "#7C3AED") : (Theme ? Theme.textSecondary : "#444")
Layout.alignment: Qt.AlignCenter
}
// Label
Text {
text: modelData.label
font.pixelSize: 12 * Theme.scale(screen)
font.bold: index === root.currentIndex
color: index === root.currentIndex ? (Theme ? Theme.accentPrimary : "#7C3AED") : tabWrapper.hovered ? (Theme ? Theme.accentPrimary : "#7C3AED") : (Theme ? Theme.textSecondary : "#444")
Layout.alignment: Qt.AlignCenter
}
// Underline for active tab
Rectangle {
width: 24 * Theme.scale(screen)
height: 2 * Theme.scale(screen)
radius: 1
color: index === root.currentIndex ? (Theme ? Theme.accentPrimary : "#7C3AED") : "transparent"
Layout.alignment: Qt.AlignCenter
}
}
}
}
}
}

View file

@ -1,96 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import qs.Settings
Slider {
id: slider
property var screen
property bool snapAlways: true
readonly property real trackHeight: 12 * Theme.scale(screen)
readonly property real knobDiameter: 28 * Theme.scale(screen)
// Optional color to cut the track beneath the knob (should match surrounding background)
property var cutoutColor
readonly property real cutoutExtra: 8 * Theme.scale(screen)
snapMode: snapAlways ? Slider.SnapAlways : Slider.SnapOnRelease
implicitHeight: Math.max(trackHeight, knobDiameter)
background: Rectangle {
x: slider.leftPadding
y: slider.topPadding + slider.availableHeight / 2 - height / 2
implicitWidth: 200
implicitHeight: trackHeight
width: slider.availableWidth
height: implicitHeight
radius: height / 2
color: Theme.surfaceVariant
Rectangle {
id: activeTrack
width: slider.visualPosition * parent.width
height: parent.height
color: Theme.accentPrimary
radius: parent.radius
Behavior on width {
NumberAnimation { duration: 120; easing.type: Easing.OutQuad }
}
}
// Circular cutout
Rectangle {
id: knobCutout
width: knobDiameter + cutoutExtra
height: knobDiameter + cutoutExtra
radius: width / 2
color: slider.cutoutColor !== undefined ? slider.cutoutColor : Theme.backgroundPrimary
x: Math.max(0, Math.min(parent.width - width, slider.visualPosition * (parent.width - slider.knobDiameter) - cutoutExtra / 2))
y: (parent.height - height) / 2
}
}
handle: Item {
id: handleRoot
width: knob.implicitWidth
height: knob.implicitHeight
x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width)
y: slider.topPadding + slider.availableHeight / 2 - height / 2
// Subtle shadow for a more polished look (keeps theme colors)
MultiEffect {
anchors.fill: knob
source: knob
shadowEnabled: true
shadowColor: Theme.shadow
shadowOpacity: 0.25
shadowHorizontalOffset: 0
shadowVerticalOffset: 1
shadowBlur: 8
}
Rectangle {
id: knob
implicitWidth: knobDiameter
implicitHeight: knobDiameter
radius: width / 2
color: slider.pressed ? Theme.surfaceVariant : Theme.surface
border.color: Theme.accentPrimary
border.width: 2 * Theme.scale(screen)
// Press feedback halo (using accent color, low opacity)
Rectangle {
anchors.centerIn: parent
width: parent.width + 10 * Theme.scale(screen)
height: parent.height + 10 * Theme.scale(screen)
radius: width / 2
color: Theme.accentPrimary
opacity: slider.pressed ? 0.16 : 0.0
}
}
}
}

View file

@ -1,90 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Components
import qs.Settings
ColumnLayout {
id: root
property var screen
property string label: ""
property string description: ""
property bool value: false
property var onToggled: function() {
}
Layout.fillWidth: true
RowLayout {
Layout.fillWidth: true
ColumnLayout {
spacing: 4 * Theme.scale(screen)
Layout.fillWidth: true
Text {
text: label
font.pixelSize: 13 * Theme.scale(screen)
font.bold: true
color: Theme.textPrimary
}
Text {
text: description
font.pixelSize: 12 * Theme.scale(screen)
color: Theme.textSecondary
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
Rectangle {
id: switcher
width: 52 * Theme.scale(screen)
height: 32 * Theme.scale(screen)
radius: 16
color: value ? Theme.accentPrimary : Theme.surfaceVariant
border.color: value ? Theme.accentPrimary : Theme.outline
border.width: 2 * Theme.scale(screen)
Rectangle {
width: 28 * Theme.scale(screen)
height: 28 * Theme.scale(screen)
radius: 14
color: Theme.surface
border.color: Theme.outline
border.width: 1 * Theme.scale(screen)
y: 2
x: value ? switcher.width - width - 2 : 2
Behavior on x {
NumberAnimation {
duration: 200
easing.type: Easing.OutCubic
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
root.onToggled();
}
}
}
}
Rectangle {
height: 8 * Theme.scale(screen)
}
}

View file

@ -1,81 +0,0 @@
var _countryCode = null;
var _regionCode = null;
var _regionName = null;
var _holidaysCache = {};
function getCountryCode(callback) {
if (_countryCode) {
callback(_countryCode);
return;
}
var xhr = new XMLHttpRequest();
xhr.open("GET", "https://nominatim.openstreetmap.org/search?city="+ Settings.settings.weatherCity+"&country=&format=json&addressdetails=1&extratags=1", true);
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
var response = JSON.parse(xhr.responseText);
_countryCode = response?.[0]?.address?.country_code ?? "US";
_regionCode = response?.[0]?.address?.["ISO3166-2-lvl4"] ?? "";
_regionName = response?.[0]?.address?.state ?? "";
callback(_countryCode);
}
}
xhr.send();
}
function getHolidays(year, countryCode, callback) {
var cacheKey = year + "-" + countryCode;
if (_holidaysCache[cacheKey]) {
callback(_holidaysCache[cacheKey]);
return;
}
var url = "https://date.nager.at/api/v3/PublicHolidays/" + year + "/" + countryCode;
var xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
var holidays = JSON.parse(xhr.responseText);
var augmentedHolidays = filterHolidaysByRegion(holidays);
_holidaysCache[cacheKey] = augmentedHolidays;
callback(augmentedHolidays);
}
}
xhr.send();
}
function filterHolidaysByRegion(holidays) {
if (!_regionCode) {
return holidays;
}
const retHolidays = [];
holidays.forEach(function(holiday) {
if (holiday.counties?.length > 0) {
let found = false;
holiday.counties.forEach(function(county) {
if (county.toLowerCase() === _regionCode.toLowerCase()) {
found = true;
}
});
if (found) {
var regionText = " (" + _regionName + ")";
holiday.name = holiday.name + regionText;
holiday.localName = holiday.localName + regionText;
retHolidays.push(holiday);
}
} else {
retHolidays.push(holiday);
}
});
return retHolidays;
}
function getHolidaysForMonth(year, month, callback) {
getCountryCode(function(countryCode) {
getHolidays(year, countryCode, function(holidays) {
var filtered = holidays.filter(function(h) {
var date = new Date(h.date);
return date.getFullYear() === year && date.getMonth() === month;
});
callback(filtered);
});
});
}

View file

@ -1,54 +0,0 @@
import QtQuick
import Quickshell.Io
import qs.Bar.Modules
import qs.Helpers
import qs.Widgets.LockScreen
import qs.Widgets.Notification
Item {
id: root
property Applauncher appLauncherPanel
property LockScreen lockScreen
property IdleInhibitor idleInhibitor
property NotificationPopup notificationPopup
IpcHandler {
target: "globalIPC"
function toggleIdleInhibitor(): void {
root.idleInhibitor.toggle()
}
function toggleNotificationPopup(): void {
console.log("[IPC] NotificationPopup toggle() called")
// Use the global toggle function from the notification manager
root.notificationPopup.togglePopup();
}
// Toggle Applauncher visibility
function toggleLauncher(): void {
if (!root.appLauncherPanel) {
console.warn("AppLauncherIpcHandler: appLauncherPanel not set!");
return;
}
if (root.appLauncherPanel.visible) {
root.appLauncherPanel.hidePanel();
} else {
console.log("[IPC] Applauncher show() called");
root.appLauncherPanel.showAt();
}
}
// Toggle LockScreen
function toggleLock(): void {
if (!root.lockScreen) {
console.warn("LockScreenIpcHandler: lockScreen not set!");
return;
}
console.log("[IPC] LockScreen show() called");
root.lockScreen.locked = true;
}
}
}

View file

@ -1,43 +0,0 @@
import Quickshell.Io
Process {
id: idleRoot
// Uses systemd-inhibit to prevent idle/sleep
command: ["systemd-inhibit", "--what=idle:sleep", "--who=noctalia", "--why=User requested", "sleep", "infinity"]
// Track background process state
property bool isRunning: running
onStarted: {
console.log("[IdleInhibitor] Process started - idle inhibited")
}
onExited: function(exitCode, exitStatus) {
console.log("[IdleInhibitor] Process finished:", exitCode)
}
function start() {
if (!running) {
console.log("[IdleInhibitor] Starting idle inhibitor...")
running = true
}
}
function stop() {
if (running) {
// Force stop the process by setting running to false
running = false
console.log("[IdleInhibitor] Stopping idle inhibitor...")
}
}
function toggle() {
if (running) {
stop()
} else {
start()
}
}
}

120
Helpers/MathHelper.js Normal file
View file

@ -0,0 +1,120 @@
// Math helper functions for calculator functionality
var MathHelper = {
// Basic arithmetic operations
add: (a, b) => a + b,
subtract: (a, b) => a - b,
multiply: (a, b) => a * b,
divide: (a, b) => b !== 0 ? a / b : NaN,
// Power and roots
pow: (base, exponent) => Math.pow(base, exponent),
sqrt: (x) => x >= 0 ? Math.sqrt(x) : NaN,
cbrt: (x) => Math.cbrt(x),
// Trigonometric functions (in radians)
sin: (x) => Math.sin(x),
cos: (x) => Math.cos(x),
tan: (x) => Math.tan(x),
asin: (x) => Math.asin(x),
acos: (x) => Math.acos(x),
atan: (x) => Math.atan(x),
// Logarithmic functions
log: (x) => x > 0 ? Math.log(x) : NaN,
log10: (x) => x > 0 ? Math.log10(x) : NaN,
log2: (x) => x > 0 ? Math.log2(x) : NaN,
// Other mathematical functions
abs: (x) => Math.abs(x),
floor: (x) => Math.floor(x),
ceil: (x) => Math.ceil(x),
round: (x) => Math.round(x),
min: (...args) => Math.min(...args),
max: (...args) => Math.max(...args),
// Constants
PI: Math.PI,
E: Math.E,
// Factorial
factorial: (n) => {
if (n < 0 || n !== Math.floor(n)) return NaN;
if (n === 0 || n === 1) return 1;
let result = 1;
for (let i = 2; i <= n; i++) {
result *= i;
}
return result;
},
// Percentage
percent: (value, total) => (value / total) * 100,
// Degrees to radians and vice versa
toRadians: (degrees) => degrees * (Math.PI / 180),
toDegrees: (radians) => radians * (180 / Math.PI),
// Safe evaluation with math functions
evaluate: (expression) => {
try {
// Replace common math functions with MathHelper equivalents
let processedExpr = expression
.replace(/\bpi\b/gi, 'MathHelper.PI')
.replace(/\be\b/gi, 'MathHelper.E')
.replace(/\bsin\b/gi, 'MathHelper.sin')
.replace(/\bcos\b/gi, 'MathHelper.cos')
.replace(/\btan\b/gi, 'MathHelper.tan')
.replace(/\basin\b/gi, 'MathHelper.asin')
.replace(/\bacos\b/gi, 'MathHelper.acos')
.replace(/\batan\b/gi, 'MathHelper.atan')
.replace(/\blog\b/gi, 'MathHelper.log')
.replace(/\blog10\b/gi, 'MathHelper.log10')
.replace(/\blog2\b/gi, 'MathHelper.log2')
.replace(/\bsqrt\b/gi, 'MathHelper.sqrt')
.replace(/\bcbrt\b/gi, 'MathHelper.cbrt')
.replace(/\bpow\b/gi, 'MathHelper.pow')
.replace(/\babs\b/gi, 'MathHelper.abs')
.replace(/\bfloor\b/gi, 'MathHelper.floor')
.replace(/\bceil\b/gi, 'MathHelper.ceil')
.replace(/\bround\b/gi, 'MathHelper.round')
.replace(/\bmin\b/gi, 'MathHelper.min')
.replace(/\bmax\b/gi, 'MathHelper.max')
.replace(/\bfactorial\b/gi, 'MathHelper.factorial')
.replace(/\bpercent\b/gi, 'MathHelper.percent')
.replace(/\btoRadians\b/gi, 'MathHelper.toRadians')
.replace(/\btoDegrees\b/gi, 'MathHelper.toDegrees');
// Evaluate the expression
const result = Function('MathHelper', 'return ' + processedExpr)(MathHelper);
// Check if result is valid
if (isNaN(result) || !isFinite(result)) {
return null;
}
return result;
} catch (error) {
return null;
}
},
// Format result for display
formatResult: (result) => {
if (result === null || isNaN(result) || !isFinite(result)) {
return "Error";
}
// For very large or small numbers, use scientific notation
if (Math.abs(result) >= 1e10 || (Math.abs(result) < 1e-10 && result !== 0)) {
return result.toExponential(6);
}
// For integers, don't show decimal places
if (Number.isInteger(result)) {
return result.toString();
}
// For decimals, limit to 8 significant digits
return parseFloat(result.toPrecision(8)).toString();
}
};

View file

@ -1,18 +0,0 @@
function formatVagueHumanReadableTime(totalSeconds) {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds - (hours * 3600)) / 60);
const seconds = totalSeconds - (hours * 3600) - (minutes * 60);
var str = "";
if (hours) {
str += hours.toString() + "h";
}
if (minutes) {
str += minutes.toString() + "m";
}
if (!hours && !minutes) {
str += seconds.toString() + "s";
}
return str;
}

View file

@ -1,58 +0,0 @@
function fetchCoordinates(city, callback, errorCallback) {
var geoUrl = "https://geocoding-api.open-meteo.com/v1/search?name=" + encodeURIComponent(city) + "&language=en&format=json";
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var geoData = JSON.parse(xhr.responseText);
if (geoData.results && geoData.results.length > 0) {
callback(geoData.results[0].latitude, geoData.results[0].longitude);
} else {
errorCallback("City not found.");
}
} catch (e) {
errorCallback("Failed to parse geocoding data.");
}
} else {
errorCallback("Geocoding error: " + xhr.status);
}
}
}
xhr.open("GET", geoUrl);
xhr.send();
}
function fetchWeather(latitude, longitude, callback, errorCallback) {
var url = "https://api.open-meteo.com/v1/forecast?latitude=" + latitude + "&longitude=" + longitude + "&current_weather=true&current=relativehumidity_2m,surface_pressure&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto";
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var weatherData = JSON.parse(xhr.responseText);
callback(weatherData);
} catch (e) {
errorCallback("Failed to parse weather data.");
}
} else {
errorCallback("Weather fetch error: " + xhr.status);
}
}
}
xhr.open("GET", url);
xhr.send();
}
function fetchCityWeather(city, callback, errorCallback) {
fetchCoordinates(city, function(lat, lon) {
fetchWeather(lat, lon, function(weatherData) {
callback({
city: city,
latitude: lat,
longitude: lon,
weather: weatherData
});
}, errorCallback);
}, errorCallback);
}

21
LICENSE
View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Ly-sec
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.

View file

@ -0,0 +1,540 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Effects
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Commons
import qs.Services
import qs.Widgets
import "../../Helpers/FuzzySort.js" as Fuzzysort
import "../../Helpers/MathHelper.js" as MathHelper
NLoader {
id: appLauncher
isLoaded: false
// Clipboard state is persisted in Services/ClipboardService.qml
content: Component {
NPanel {
id: appLauncherPanel
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
// No local timer/processes; use persistent Clipboard service
// Removed local clipboard processes; handled by Clipboard service
// Copy helpers via simple exec; avoid keeping processes alive locally
function copyImageBase64(mime, base64) {
Quickshell.execDetached(["sh", "-lc", `printf %s ${base64} | base64 -d | wl-copy -t '${mime}'`])
}
function copyText(text) {
Quickshell.execDetached(["sh", "-lc", `printf %s ${text} | wl-copy -t text/plain;charset=utf-8`])
}
function updateClipboardHistory() {
ClipboardService.refresh()
}
function selectNext() {
if (filteredEntries.length > 0) {
selectedIndex = Math.min(selectedIndex + 1, filteredEntries.length - 1)
}
}
function selectPrev() {
if (filteredEntries.length > 0) {
selectedIndex = Math.max(selectedIndex - 1, 0)
}
}
function activateSelected() {
if (filteredEntries.length === 0)
return
var modelData = filteredEntries[selectedIndex]
if (modelData && modelData.execute) {
if (modelData.isCommand) {
modelData.execute()
return
} else {
modelData.execute()
}
appLauncherPanel.hide()
}
}
property var desktopEntries: DesktopEntries.applications.values
property string searchText: ""
property int selectedIndex: 0
property var filteredEntries: {
Logger.log("AppLauncher", "Total desktop entries:", desktopEntries ? desktopEntries.length : 0)
if (!desktopEntries || desktopEntries.length === 0) {
Logger.log("AppLauncher", "No desktop entries available")
return []
}
// Filter out entries that shouldn't be displayed
var visibleEntries = desktopEntries.filter(entry => {
if (!entry || entry.noDisplay) {
return false
}
return true
})
Logger.log("AppLauncher", "Visible entries:", visibleEntries.length)
var query = searchText ? searchText.toLowerCase() : ""
var results = []
// Handle special commands
if (query === ">") {
results.push({
"isCommand": true,
"name": ">calc",
"content": "Calculator - evaluate mathematical expressions",
"icon": "calculate",
"execute": function () {
searchText = ">calc "
searchInput.cursorPosition = searchText.length
}
})
results.push({
"isCommand": true,
"name": ">clip",
"content": "Clipboard history - browse and restore clipboard items",
"icon": "content_paste",
"execute": function () {
searchText = ">clip "
searchInput.cursorPosition = searchText.length
}
})
return results
}
// Handle clipboard history
if (query.startsWith(">clip")) {
if (!ClipboardService.initialized) {
ClipboardService.refresh()
}
const searchTerm = query.slice(5).trim()
ClipboardService.history.forEach(function (clip, index) {
let searchContent = clip.type === 'image' ? clip.mimeType : clip.content || clip
if (!searchTerm || searchContent.toLowerCase().includes(searchTerm)) {
let entry
if (clip.type === 'image') {
entry = {
"isClipboard": true,
"name": "Image from " + new Date(clip.timestamp).toLocaleTimeString(),
"content": "Image: " + clip.mimeType,
"icon": "image",
"type": 'image',
"data": clip.data,
"execute": function () {
const base64Data = clip.data.split(',')[1]
copyImageBase64(clip.mimeType, base64Data)
Quickshell.execDetached(["notify-send", "Clipboard", "Image copied: " + clip.mimeType])
}
}
} else {
const textContent = clip.content || clip
let displayContent = textContent
let previewContent = ""
displayContent = displayContent.replace(/\s+/g, ' ').trim()
if (displayContent.length > 50) {
previewContent = displayContent
displayContent = displayContent.split('\n')[0].substring(0, 50) + "..."
}
entry = {
"isClipboard": true,
"name": displayContent,
"content": previewContent || textContent,
"icon": "content_paste",
"execute": function () {
Quickshell.clipboardText = String(textContent)
copyText(String(textContent))
var preview = (textContent.length > 50) ? textContent.slice(0, 50) + "…" : textContent
Quickshell.execDetached(["notify-send", "Clipboard", "Text copied: " + preview])
}
}
}
results.push(entry)
}
})
if (results.length === 0) {
results.push({
"isClipboard": true,
"name": "No clipboard history",
"content": "No matching clipboard entries found",
"icon": "content_paste_off"
})
}
return results
}
// Handle calculator
if (query.startsWith(">calc")) {
var expr = searchText.slice(5).trim()
if (expr && isMathExpression(expr)) {
var value = safeEval(expr)
if (value !== null && value !== undefined && value !== "") {
var formattedResult = MathHelper.MathHelper.formatResult(value)
results.push({
"isCalculator": true,
"name": `Calculator: ${expr} = ${formattedResult}`,
"result": value,
"expr": expr,
"icon": "calculate",
"execute": function () {
Quickshell.clipboardText = String(formattedResult)
clipboardTextCopyProcess.copyText(String(formattedResult))
Quickshell.execDetached(
["notify-send", "Calculator Result", `${expr} = ${formattedResult} (copied to clipboard)`])
}
})
}
}
return results
}
// Regular app search
if (!query) {
results = results.concat(visibleEntries.sort(function (a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase())
}))
} else {
var fuzzyResults = Fuzzysort.go(query, visibleEntries, {
"keys": ["name", "comment", "genericName"]
})
results = results.concat(fuzzyResults.map(function (r) {
return r.obj
}))
}
Logger.log("AppLauncher", "Filtered entries:", results.length)
return results
}
Component.onCompleted: {
Logger.log("AppLauncher", "Component completed")
Logger.log("AppLauncher", "DesktopEntries available:", typeof DesktopEntries !== 'undefined')
if (typeof DesktopEntries !== 'undefined') {
Logger.log("AppLauncher", "DesktopEntries.entries:",
DesktopEntries.entries ? DesktopEntries.entries.length : 'undefined')
}
// Start clipboard refresh immediately on open
updateClipboardHistory()
}
function isMathExpression(str) {
// Allow more characters for enhanced math functions
return /^[-+*/().0-9\s\w]+$/.test(str)
}
function safeEval(expr) {
return MathHelper.MathHelper.evaluate(expr)
}
// Main content container
Rectangle {
anchors.centerIn: parent
width: Math.min(700 * scaling, parent.width * 0.75)
height: Math.min(550 * scaling, parent.height * 0.8)
radius: Style.radiusLarge * scaling
color: Color.mSurface
border.color: Color.mOutline
border.width: Style.borderThin * scaling
// Subtle gradient background
gradient: Gradient {
GradientStop {
position: 0.0
color: Qt.lighter(Color.mSurface, 1.02)
}
GradientStop {
position: 1.0
color: Qt.darker(Color.mSurface, 1.1)
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginLarge * scaling
spacing: Style.marginMedium * scaling
// Search bar
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Style.barHeight * scaling
Layout.bottomMargin: Style.marginMedium * scaling
radius: Style.radiusMedium * scaling
color: Color.mSurface
border.color: searchInput.activeFocus ? Color.mPrimary : Color.mOutline
border.width: Math.max(
1,
searchInput.activeFocus ? Style.borderMedium * scaling : Style.borderThin * scaling)
Item {
anchors.fill: parent
anchors.margins: Style.marginMedium * scaling
Text {
id: searchIcon
text: "search"
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeLarger * scaling
color: searchInput.activeFocus ? Color.mPrimary : Color.mOnSurface
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
TextField {
id: searchInput
placeholderText: searchText
=== "" ? "Search applications... (use > to view commands)" : "Search applications..."
color: Color.mOnSurface
placeholderTextColor: Color.mOnSurfaceVariant
background: null
font.pointSize: Style.fontSizeLarge * scaling
anchors.left: searchIcon.right
anchors.leftMargin: Style.marginSmall * scaling
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
onTextChanged: {
searchText = text
selectedIndex = 0 // Reset selection when search changes
}
selectedTextColor: Color.mOnSurface
selectionColor: Color.mPrimary
padding: 0
verticalAlignment: TextInput.AlignVCenter
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
font.bold: true
Component.onCompleted: {
contentItem.cursorColor = Color.mOnSurface
contentItem.verticalAlignment = TextInput.AlignVCenter
// Focus the search bar by default
Qt.callLater(() => {
searchInput.forceActiveFocus()
})
}
onActiveFocusChanged: contentItem.cursorColor = Color.mOnSurface
Keys.onDownPressed: selectNext()
Keys.onUpPressed: selectPrev()
Keys.onEnterPressed: activateSelected()
Keys.onReturnPressed: activateSelected()
Keys.onEscapePressed: appLauncherPanel.hide()
}
}
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
}
}
Behavior on border.width {
NumberAnimation {
duration: Style.animationFast
}
}
}
// Applications list
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ListView {
id: appsList
anchors.fill: parent
spacing: Style.marginTiniest * scaling
model: filteredEntries
currentIndex: selectedIndex
delegate: Rectangle {
width: appsList.width - Style.marginSmall * scaling
height: 65 * scaling
radius: Style.radiusMedium * scaling
property bool isSelected: index === selectedIndex
color: (appCardArea.containsMouse || isSelected) ? Qt.darker(Color.mPrimary,
1.1) : Color.mSurface
border.color: (appCardArea.containsMouse || isSelected) ? Color.mPrimary : Color.transparent
border.width: Math.max(1, (appCardArea.containsMouse
|| isSelected) ? Style.borderMedium * scaling : 0)
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
}
}
Behavior on border.width {
NumberAnimation {
duration: Style.animationFast
}
}
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginMedium * scaling
spacing: Style.marginMedium * scaling
// App icon with background
Rectangle {
Layout.preferredWidth: Style.baseWidgetSize * 1.25 * scaling
Layout.preferredHeight: Style.baseWidgetSize * 1.25 * scaling
radius: Style.radiusSmall * scaling
color: appCardArea.containsMouse ? Qt.darker(Color.mPrimary,
1.1) : Color.mSurfaceVariant
property bool iconLoaded: (modelData.isCalculator || modelData.isClipboard
|| modelData.isCommand) || (iconImg.status === Image.Ready
&& iconImg.source !== ""
&& iconImg.status !== Image.Error
&& iconImg.source !== "")
visible: !searchText.startsWith(">calc") // Hide icon when in calculator mode
// Clipboard image display
Image {
id: clipboardImage
anchors.fill: parent
anchors.margins: Style.marginTiny * scaling
visible: modelData.type === 'image'
source: modelData.data || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
}
IconImage {
id: iconImg
anchors.fill: parent
anchors.margins: Style.marginTiny * scaling
asynchronous: true
source: modelData.isCalculator ? "calculate" : modelData.isClipboard ? (modelData.type === 'image' ? "" : "content_paste") : modelData.isCommand ? modelData.icon : (modelData.icon ? Quickshell.iconPath(modelData.icon, "application-x-executable") : "")
visible: (modelData.isCalculator || modelData.isClipboard || modelData.isCommand
|| parent.iconLoaded) && modelData.type !== 'image'
}
// Fallback icon container
Rectangle {
anchors.fill: parent
anchors.margins: Style.marginTiny * scaling
radius: Style.radiusTiny * scaling
color: Color.mPrimary
opacity: Style.opacityMedium
visible: !parent.iconLoaded
}
Text {
anchors.centerIn: parent
visible: !parent.iconLoaded && !(modelData.isCalculator || modelData.isClipboard
|| modelData.isCommand)
text: modelData.name ? modelData.name.charAt(0).toUpperCase() : "?"
font.pointSize: Style.fontSizeXL * scaling
font.weight: Font.Bold
color: Color.mPrimary
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
// App info
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginTiniest * scaling
NText {
text: modelData.name || "Unknown"
font.pointSize: Style.fontSizeLarge * scaling
font.weight: Font.Bold
color: (appCardArea.containsMouse || isSelected) ? Color.mOnPrimary : Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
}
NText {
text: modelData.isCalculator ? (modelData.expr + " = " + modelData.result) : modelData.isClipboard ? modelData.content : modelData.isCommand ? modelData.content : (modelData.genericName || modelData.comment || "")
font.pointSize: Style.fontSizeMedium * scaling
color: (appCardArea.containsMouse || isSelected) ? Color.mOnPrimary : Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
visible: text !== ""
}
}
}
MouseArea {
id: appCardArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
selectedIndex = index
activateSelected()
}
}
}
}
}
// No results message
NText {
text: searchText.trim() !== "" ? "No applications found" : "No applications available"
font.pointSize: Style.fontSizeLarge * scaling
color: Color.mOnSurface
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
visible: filteredEntries.length === 0
}
// Results count
NText {
text: searchText.startsWith(
">clip") ? `${filteredEntries.length} clipboard item${filteredEntries.length
!== 1 ? 's' : ''}` : searchText.startsWith(
">calc") ? `${filteredEntries.length} result${filteredEntries.length
!== 1 ? 's' : ''}` : `${filteredEntries.length} application${filteredEntries.length !== 1 ? 's' : ''}`
font.pointSize: Style.fontSizeSmall * scaling
color: Color.mOnSurface
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
visible: searchText.trim() !== ""
}
}
}
}
}
}

View file

@ -0,0 +1,54 @@
import QtQuick
import qs.Commons
import qs.Services
// Not used ATM and need rework
Item {
id: root
property int innerRadius: 32 * scaling
property int outerRadius: 64 * scaling
property color fillColor: Color.mPrimary
property color strokeColor: Color.mOnSurface
property int strokeWidth: 0 * scaling
property var values: []
property int usableOuter: 64
width: usableOuter * 2
height: usableOuter * 2
Repeater {
model: root.values.length
Rectangle {
property real value: root.values[index]
property real angle: (index / root.values.length) * 360
width: Math.max(2 * scaling, (root.innerRadius * 2 * Math.PI) / root.values.length - 4 * scaling)
height: value * (usableOuter - root.innerRadius)
radius: width / 2
color: root.fillColor
border.color: root.strokeColor
border.width: root.strokeWidth
antialiasing: true
x: root.width / 2 - width / 2 * Math.cos(Math.PI / 2 + 2 * Math.PI * index / root.values.length) - width / 2
y: root.height / 2 - height
transform: [
Rotation {
origin.x: width / 2
origin.y: height
//angle: (index / root.values.length) * 360
},
Translate {
x: root.innerRadius * Math.cos(2 * Math.PI * index / root.values.length)
y: root.innerRadius * Math.sin(2 * Math.PI * index / root.values.length)
}
]
Behavior on height {
SmoothedAnimation {
duration: Style.animationFast
}
}
}
}
}

View file

@ -0,0 +1,47 @@
import QtQuick
import qs.Commons
import qs.Services
Item {
id: root
property color fillColor: Color.mPrimary
property color strokeColor: Color.mOnSurface
property int strokeWidth: 0
property var values: []
readonly property real xScale: width / (values.length * 2)
Repeater {
model: values.length
Rectangle {
property real amp: values[values.length - 1 - index]
color: fillColor
border.color: strokeColor
border.width: strokeWidth
antialiasing: true
width: xScale * 0.5
height: Math.max(1, root.height * amp)
x: index * xScale
y: root.height - height
}
}
Repeater {
model: values.length
Rectangle {
property real amp: values[index]
color: fillColor
border.color: strokeColor
border.width: strokeWidth
antialiasing: true
width: xScale * 0.5
height: Math.max(1, root.height * amp)
x: (values.length + index) * xScale
y: root.height - height
}
}
}

View file

@ -0,0 +1,52 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services
Variants {
model: Quickshell.screens
delegate: PanelWindow {
required property ShellScreen modelData
property string wallpaperSource: WallpaperService.currentWallpaper !== ""
&& !Settings.data.wallpaper.swww.enabled ? WallpaperService.currentWallpaper : ""
visible: wallpaperSource !== "" && !Settings.data.wallpaper.swww.enabled
// Force update when SWWW setting changes
onVisibleChanged: {
if (visible) {
} else {
}
}
color: Color.transparent
screen: modelData
WlrLayershell.layer: WlrLayer.Background
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "quickshell-wallpaper"
anchors {
bottom: true
top: true
right: true
left: true
}
margins {
top: 0
}
Image {
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
source: wallpaperSource
visible: wallpaperSource !== ""
cache: true
smooth: true
mipmap: false
}
}
}

View file

@ -0,0 +1,70 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services
import qs.Widgets
NLoader {
active: CompositorService.isNiri
Component.onCompleted: {
if (CompositorService.isNiri) {
Logger.log("Overview", "Loading Overview component (Niri detected)")
} else {
Logger.log("Overview", "Skipping Overview component (Niri not detected)")
}
}
sourceComponent: Variants {
model: Quickshell.screens
delegate: PanelWindow {
required property ShellScreen modelData
property string wallpaperSource: WallpaperService.currentWallpaper !== ""
&& !Settings.data.wallpaper.swww.enabled ? WallpaperService.currentWallpaper : ""
visible: wallpaperSource !== "" && !Settings.data.wallpaper.swww.enabled
color: Color.transparent
screen: modelData
WlrLayershell.layer: WlrLayer.Background
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "quickshell-overview"
anchors {
top: true
bottom: true
right: true
left: true
}
Image {
id: bgImage
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
source: wallpaperSource
cache: true
smooth: true
mipmap: false
visible: wallpaperSource !== ""
}
MultiEffect {
id: overviewBgBlur
anchors.fill: parent
source: bgImage
blurEnabled: true
blur: 0.48
blurMax: 128
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(Color.mSurface.r, Color.mSurface.g, Color.mSurface.b, 0.5)
}
}
}
}

View file

@ -0,0 +1,147 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services
import qs.Widgets
NLoader {
isLoaded: Settings.data.general.showScreenCorners
content: Variants {
model: Quickshell.screens
PanelWindow {
id: root
required property ShellScreen modelData
readonly property real scaling: ScalingService.scale(screen)
screen: modelData
// Visible ring color
property color ringColor: Color.mSurface
// The amount subtracted from full size for the inner cutout
// Inner size = full size - borderWidth (per axis)
property int borderWidth: Style.borderMedium
// Rounded radius for the inner cutout
property int innerRadius: 20
color: Color.transparent
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "quickshell-corner"
// Do not take keyboard focus and make the surface click-through
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
anchors {
top: true
bottom: true
left: true
right: true
}
margins {
top: (Settings.data.bar.monitors.includes(modelData.name)
|| (Settings.data.bar.monitors.length === 0)) ? Math.floor(Style.barHeight * scaling) : 0
}
// Source we want to show only as a ring
Rectangle {
id: overlaySource
anchors.fill: parent
color: root.ringColor
}
// Texture for overlaySource
ShaderEffectSource {
id: overlayTexture
anchors.fill: parent
sourceItem: overlaySource
hideSource: true
live: true
visible: false
}
// Mask via Canvas: paint opaque white, then punch rounded inner hole
Canvas {
id: maskSource
anchors.fill: parent
antialiasing: true
renderTarget: Canvas.FramebufferObject
onPaint: {
const ctx = getContext("2d")
ctx.reset()
ctx.clearRect(0, 0, width, height)
// Solid white base (alpha=1)
ctx.globalCompositeOperation = "source-over"
ctx.fillStyle = "#ffffffff"
ctx.fillRect(0, 0, width, height)
// Punch hole using destination-out with rounded rect path
const x = Math.round(root.borderWidth / 2)
const y = Math.round(root.borderWidth / 2)
const w = Math.max(0, width - root.borderWidth)
const h = Math.max(0, height - root.borderWidth)
const r = Math.max(0, Math.min(root.innerRadius, Math.min(w, h) / 2))
ctx.globalCompositeOperation = "destination-out"
ctx.fillStyle = "#ffffffff"
ctx.beginPath()
// rounded rectangle path using arcTo
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.arcTo(x + w, y, x + w, y + r, r)
ctx.lineTo(x + w, y + h - r)
ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
ctx.lineTo(x + r, y + h)
ctx.arcTo(x, y + h, x, y + h - r, r)
ctx.lineTo(x, y + r)
ctx.arcTo(x, y, x + r, y, r)
ctx.closePath()
ctx.fill()
}
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
}
// Repaint mask when properties change
Connections {
function onBorderWidthChanged() {
maskSource.requestPaint()
}
function onRingColorChanged() {}
function onInnerRadiusChanged() {
maskSource.requestPaint()
}
target: root
}
// Texture for maskSource; hides the original
ShaderEffectSource {
id: maskTexture
anchors.fill: parent
sourceItem: maskSource
hideSource: true
live: true
visible: false
}
// Apply mask to show only the ring area
MultiEffect {
anchors.fill: parent
source: overlayTexture
maskEnabled: true
maskSource: maskTexture
maskInverted: false
}
mask: Region {}
}
}
}

View file

@ -0,0 +1,122 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
Row {
id: root
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginSmall * scaling
visible: (Settings.data.bar.showActiveWindow && getTitle() !== "")
property bool showingFullTitle: false
property int lastWindowIndex: -1
// Timer to hide full title after window switch
Timer {
id: fullTitleTimer
interval: Style.animationSlow * 4 // Show full title for 2 seconds
repeat: false
onTriggered: {
showingFullTitle = false
}
}
// Update text when window changes
Connections {
target: CompositorService
function onActiveWindowChanged() {
// Check if window actually changed
if (CompositorService.focusedWindowIndex !== lastWindowIndex) {
lastWindowIndex = CompositorService.focusedWindowIndex
showingFullTitle = true
fullTitleTimer.restart()
}
}
}
function getFocusedWindow() {
return CompositorService.getFocusedWindow()
}
function getTitle() {
const focusedWindow = getFocusedWindow()
return focusedWindow ? (focusedWindow.title || focusedWindow.appId || "") : ""
}
// A hidden text element to safely measure the full title width
NText {
id: fullTitleMetrics
visible: false
text: titleText.text
font: titleText.font
}
Rectangle {
// Let the Rectangle size itself based on its content (the Row)
width: row.width + Style.marginMedium * scaling * 2
height: row.height + Style.marginSmall * scaling
color: Color.mSurfaceVariant
radius: Style.radiusSmall * scaling
anchors.verticalCenter: parent.verticalCenter
Item {
id: mainContainer
anchors.fill: parent
anchors.leftMargin: Style.marginSmall * scaling
anchors.rightMargin: Style.marginSmall * scaling
Row {
id: row
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginTiny * scaling
// Window icon
NText {
id: windowIcon
text: "dialogs"
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeLarge * scaling
verticalAlignment: Text.AlignVCenter
anchors.verticalCenter: parent.verticalCenter
visible: getTitle() !== ""
}
NText {
id: titleText
// If hovered or just switched window, show up to 300 pixels
// If not hovered show up to 150 pixels
width: (showingFullTitle || mouseArea.containsMouse) ? Math.min(fullTitleMetrics.contentWidth,
300 * scaling) : Math.min(
fullTitleMetrics.contentWidth, 150 * scaling)
text: getTitle()
font.pointSize: Style.fontSizeReduced * scaling
font.weight: Style.fontWeightBold
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
verticalAlignment: Text.AlignVCenter
color: Color.mSecondary
Behavior on width {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.InOutCubic
}
}
}
}
// Mouse area for hover detection
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
}
}
}
}

172
Modules/Bar/Bar.qml Normal file
View file

@ -0,0 +1,172 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
import qs.Modules.Notification
Variants {
model: Quickshell.screens
delegate: PanelWindow {
id: root
required property ShellScreen modelData
readonly property real scaling: ScalingService.scale(screen)
screen: modelData
implicitHeight: Style.barHeight * scaling
color: Color.transparent
// If no bar activated in settings, then show them all
visible: modelData ? (Settings.data.bar.monitors.includes(modelData.name)
|| (Settings.data.bar.monitors.length === 0)) : false
anchors {
top: true
left: true
right: true
}
Item {
anchors.fill: parent
clip: true
// Background fill
Rectangle {
id: bar
anchors.fill: parent
color: Color.mSurface
layer.enabled: true
}
// Left
Row {
id: leftSection
height: parent.height
anchors.left: parent.left
anchors.leftMargin: Style.marginSmall * scaling
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginSmall * scaling
SystemMonitor {}
ActiveWindow {}
MediaMini {}
}
// Center
Row {
id: centerSection
height: parent.height
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginSmall * scaling
Workspace {}
}
// Right
Row {
id: rightSection
height: parent.height
anchors.right: bar.right
anchors.rightMargin: Style.marginSmall * scaling
anchors.verticalCenter: bar.verticalCenter
spacing: Style.marginSmall * scaling
// Screen Recording Indicator
NIconButton {
id: screenRecordingIndicator
icon: "videocam"
tooltipText: "Screen Recording Active"
sizeMultiplier: 0.8
showBorder: false
showFilled: ScreenRecorderService.isRecording
visible: ScreenRecorderService.isRecording
anchors.verticalCenter: parent.verticalCenter
onClicked: {
ScreenRecorderService.toggleRecording()
}
}
Tray {
anchors.verticalCenter: parent.verticalCenter
}
NotificationHistory {
anchors.verticalCenter: parent.verticalCenter
}
WiFi {
anchors.verticalCenter: parent.verticalCenter
}
Bluetooth {
anchors.verticalCenter: parent.verticalCenter
}
Battery {
anchors.verticalCenter: parent.verticalCenter
}
Volume {
anchors.verticalCenter: parent.verticalCenter
}
Brightness {
anchors.verticalCenter: parent.verticalCenter
}
Clock {
anchors.verticalCenter: parent.verticalCenter
}
// NIconButton {
// id: demoPanelToggle
// icon: "experiment"
// tooltipText: "Open Demo Panel"
// sizeMultiplier: 0.8
// showBorder: false
// anchors.verticalCenter: parent.verticalCenter
// onClicked: {
// demoPanel.isLoaded = !demoPanel.isLoaded
// }
// }
NIconButton {
id: sidePanelToggle
icon: "widgets"
tooltipText: "Open Side Panel"
sizeMultiplier: 0.8
showBorder: false
anchors.verticalCenter: parent.verticalCenter
onClicked: {
// Map this button's center to the screen and open the side panel below it
const localCenterX = width / 2
const localCenterY = height / 2
const globalPoint = mapToItem(null, localCenterX, localCenterY)
if (sidePanel.isLoaded) {
// Call hide() instead of directly setting isLoaded to false
if (sidePanel.item && sidePanel.item.hide) {
sidePanel.item.hide()
} else {
sidePanel.isLoaded = false
}
} else if (sidePanel.openAt) {
sidePanel.openAt(globalPoint.x, screen)
} else {
// Fallback: toggle if API unavailable
sidePanel.isLoaded = true
}
}
}
}
}
}
}

98
Modules/Bar/Battery.qml Normal file
View file

@ -0,0 +1,98 @@
import QtQuick
import Quickshell
import Quickshell.Services.UPower
import QtQuick.Layouts
import qs.Commons
import qs.Services
import qs.Widgets
NPill {
id: root
// Test mode
property bool testMode: false
property int testPercent: 49
property bool testCharging: false
property var battery: UPower.displayDevice
property bool isReady: testMode ? true : (battery && battery.ready && battery.isLaptopBattery && battery.isPresent)
property real percent: testMode ? testPercent : (isReady ? (battery.percentage * 100) : 0)
property bool charging: testMode ? testCharging : (isReady ? battery.state === UPowerDeviceState.Charging : false)
property bool show: isReady && percent > 0
// Choose icon based on charge and charging state
function batteryIcon() {
if (!show)
return ""
if (charging)
return "battery_android_bolt"
if (percent >= 95)
return "battery_android_full"
// Hardcoded battery symbols
if (percent >= 85)
return "battery_android_6"
if (percent >= 70)
return "battery_android_5"
if (percent >= 55)
return "battery_android_4"
if (percent >= 40)
return "battery_android_3"
if (percent >= 25)
return "battery_android_2"
if (percent >= 10)
return "battery_android_1"
if (percent >= 0)
return "battery_android_0"
}
visible: testMode || (isReady && battery.isLaptopBattery)
icon: root.batteryIcon()
text: Math.round(root.percent) + "%"
pillColor: Color.mSurfaceVariant
iconCircleColor: Color.mPrimary
iconTextColor: Color.mSurface
textColor: charging ? Color.mPrimary : Color.mOnSurface
tooltipText: {
let lines = []
if (testMode) {
lines.push("Time left: " + Time.formatVagueHumanReadableDuration(12345))
return lines.join("\n")
}
if (!root.isReady) {
return ""
}
if (root.battery.timeToEmpty > 0) {
lines.push("Time left: " + Time.formatVagueHumanReadableDuration(root.battery.timeToEmpty))
}
if (root.battery.timeToFull > 0) {
lines.push("Time until full: " + Time.formatVagueHumanReadableDuration(root.battery.timeToFull))
}
if (root.battery.changeRate !== undefined) {
const rate = root.battery.changeRate
if (rate > 0) {
lines.push(root.charging ? "Charging rate: " + rate.toFixed(2) + " W" : "Discharging rate: " + rate.toFixed(
2) + " W")
} else if (rate < 0) {
lines.push("Discharging rate: " + Math.abs(rate).toFixed(2) + " W")
} else {
lines.push("Estimating...")
}
} else {
lines.push(root.charging ? "Charging" : "Discharging")
}
if (root.battery.healthPercentage !== undefined && root.battery.healthPercentage > 0) {
lines.push("Health: " + Math.round(root.battery.healthPercentage) + "%")
}
return lines.join("\n")
}
}

51
Modules/Bar/Bluetooth.qml Normal file
View file

@ -0,0 +1,51 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services
import qs.Widgets
NIconButton {
id: root
readonly property bool bluetoothEnabled: Settings.data.network.bluetoothEnabled
sizeMultiplier: 0.8
showBorder: false
visible: bluetoothEnabled
icon: {
// Show different icons based on connection status
if (BluetoothService.pairedDevices.length > 0) {
return "bluetooth_connected"
} else if (BluetoothService.discovering) {
return "bluetooth_searching"
} else {
return "bluetooth"
}
}
tooltipText: "Bluetooth Devices"
onClicked: {
if (!bluetoothMenuLoader.active) {
bluetoothMenuLoader.isLoaded = true
}
if (bluetoothMenuLoader.item) {
if (bluetoothMenuLoader.item.visible) {
// Panel is visible, hide it with animation
if (bluetoothMenuLoader.item.hide) {
bluetoothMenuLoader.item.hide()
} else {
bluetoothMenuLoader.item.visible = false
}
} else {
// Panel is hidden, show it
bluetoothMenuLoader.item.visible = true
}
}
}
BluetoothMenu {
id: bluetoothMenuLoader
}
}

View file

@ -0,0 +1,496 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Wayland
import qs.Commons
import qs.Services
import qs.Widgets
// Loader for Bluetooth menu
NLoader {
id: root
content: Component {
NPanel {
id: bluetoothPanel
function hide() {
bluetoothMenuRect.scaleValue = 0.8
bluetoothMenuRect.opacityValue = 0.0
hideTimer.start()
}
// Connect to NPanel's dismissed signal to handle external close events
Connections {
target: bluetoothPanel
ignoreUnknownSignals: true
function onDismissed() {
// Start hide animation
bluetoothMenuRect.scaleValue = 0.8
bluetoothMenuRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Also handle visibility changes from external sources
onVisibleChanged: {
if (visible && Settings.data.network.bluetoothEnabled) {
// Always refresh devices when menu opens to get fresh device objects
BluetoothService.adapter.discovering = true
} else if (bluetoothMenuRect.opacityValue > 0) {
// Start hide animation
bluetoothMenuRect.scaleValue = 0.8
bluetoothMenuRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Timer to hide panel after animation
Timer {
id: hideTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
bluetoothPanel.visible = false
bluetoothPanel.dismissed()
}
}
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
Rectangle {
id: bluetoothMenuRect
property var deviceData: null
color: Color.mSurface
radius: Style.radiusLarge * scaling
border.color: Color.mOutlineVariant
border.width: Math.max(1, Style.borderThin * scaling)
width: 380 * scaling
height: 500 * scaling
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: Style.marginTiny * scaling
anchors.rightMargin: Style.marginTiny * scaling
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0.0
scale: scaleValue
opacity: opacityValue
// Prevent closing the window if clicking inside it
MouseArea {
anchors.fill: parent
}
// Animate in when component is completed
Component.onCompleted: {
scaleValue = 1.0
opacityValue = 1.0
}
// Animation behaviors
Behavior on scale {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.OutExpo
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginLarge * scaling
spacing: Style.marginMedium * scaling
// HEADER
RowLayout {
Layout.fillWidth: true
spacing: Style.marginMedium * scaling
NText {
text: "bluetooth"
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeXL * scaling
color: Color.mPrimary
}
NText {
text: "Bluetooth"
font.pointSize: Style.fontSizeLarge * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.fillWidth: true
}
NIconButton {
icon: BluetoothService.adapter && BluetoothService.adapter.discovering ? "stop_circle" : "refresh"
tooltipText: "Refresh Devices"
sizeMultiplier: 0.8
onClicked: {
if (BluetoothService.adapter) {
BluetoothService.adapter.discovering = !BluetoothService.adapter.discovering
}
}
}
NIconButton {
icon: "close"
tooltipText: "Close"
sizeMultiplier: 0.8
onClicked: {
bluetoothPanel.hide()
}
}
}
NDivider {}
ScrollView {
id: scrollView
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
// Available devices
Column {
id: column
width: parent.width
spacing: Style.marginMedium * scaling
visible: BluetoothService.adapter && BluetoothService.adapter.enabled
RowLayout {
width: parent.width
spacing: Style.marginMedium * scaling
NText {
text: "Available Devices"
font.pointSize: Style.fontSizeLarge * scaling
color: Color.mOnSurface
font.weight: Style.fontWeightMedium
}
}
Repeater {
model: {
if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices)
return []
var filtered = Bluetooth.devices.values.filter(dev => {
return dev && !dev.paired && !dev.pairing
&& !dev.blocked && (dev.signalStrength === undefined
|| dev.signalStrength > 0)
})
return BluetoothService.sortDevices(filtered)
}
Rectangle {
property bool canConnect: BluetoothService.canConnect(modelData)
property bool isBusy: BluetoothService.isDeviceBusy(modelData)
width: parent.width
height: 70
radius: Style.radiusMedium * scaling
color: {
if (availableDeviceArea.containsMouse && !isBusy)
return Color.mTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mPrimary
if (modelData.blocked)
return Color.mError
return Color.mSurfaceVariant
}
border.color: Color.mOutline
border.width: Math.max(1, Style.borderThin * scaling)
Row {
anchors.left: parent.left
anchors.leftMargin: Style.marginMedium * scaling
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginSmall * scaling
// One device BT icon
NText {
text: BluetoothService.getDeviceIcon(modelData)
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeXL * scaling
color: {
if (availableDeviceArea.containsMouse)
return Color.mOnTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mOnPrimary
if (modelData.blocked)
return Color.mOnError
return Color.mOnSurface
}
anchors.verticalCenter: parent.verticalCenter
}
Column {
spacing: Style.marginTiniest * scaling
anchors.verticalCenter: parent.verticalCenter
// One device name
NText {
text: modelData.name || modelData.deviceName
font.pointSize: Style.fonttSizeMedium * scaling
elide: Text.ElideRight
color: {
if (availableDeviceArea.containsMouse)
return Color.mOnTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mOnPrimary
if (modelData.blocked)
return Color.mOnError
return Color.mOnSurface
}
font.weight: Style.fontWeightMedium
}
Row {
spacing: Style.marginTiny * scaling
Row {
spacing: Style.marginSmall * spacing
// One device signal strength - "Unknown" when not connected
NText {
text: {
if (modelData.pairing)
return "Pairing..."
if (modelData.blocked)
return "Blocked"
return BluetoothService.getSignalStrength(modelData)
}
font.pointSize: Style.fontSizeSmall * scaling
color: {
if (availableDeviceArea.containsMouse)
return Color.mOnTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mOnPrimary
if (modelData.blocked)
return Color.mOnError
return Color.mOnSurface
}
}
NText {
text: BluetoothService.getSignalIcon(modelData)
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeSmall * scaling
color: {
if (availableDeviceArea.containsMouse)
return Color.mOnTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mOnPrimary
if (modelData.blocked)
return Color.mOnError
return Color.mOnSurface
}
visible: modelData.signalStrength !== undefined && modelData.signalStrength > 0
&& !modelData.pairing && !modelData.blocked
}
NText {
text: (modelData.signalStrength !== undefined
&& modelData.signalStrength > 0) ? modelData.signalStrength + "%" : ""
font.pointSize: Style.fontSizeSmall * scaling
color: {
if (availableDeviceArea.containsMouse)
return Color.mOnTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mOnPrimary
if (modelData.blocked)
return Color.mOnError
return Color.mOnSurface
}
visible: modelData.signalStrength !== undefined && modelData.signalStrength > 0
&& !modelData.pairing && !modelData.blocked
}
}
}
}
}
Rectangle {
width: 80 * scaling
height: 28 * scaling
radius: Style.radiusMedium * scaling
anchors.right: parent.right
anchors.rightMargin: Style.marginMedium * scaling
anchors.verticalCenter: parent.verticalCenter
visible: modelData.state !== BluetoothDeviceState.Connecting
color: Color.transparent
border.color: {
if (availableDeviceArea.containsMouse) {
return Color.mOnTertiary
} else {
return Color.mPrimary
}
}
border.width: Math.max(1, Style.borderThin * scaling)
opacity: canConnect || isBusy ? 1 : 0.5
// On device connect button
NText {
anchors.centerIn: parent
text: {
if (modelData.pairing)
return "Pairing..."
if (modelData.blocked)
return "Blocked"
return "Connect"
}
font.pointSize: Style.fontSizeSmall * scaling
font.weight: Style.fontWeightMedium
color: {
if (availableDeviceArea.containsMouse) {
return Color.mOnTertiary
} else {
return Color.mPrimary
}
}
}
}
MouseArea {
id: availableDeviceArea
anchors.fill: parent
hoverEnabled: true
cursorShape: canConnect
&& !isBusy ? Qt.PointingHandCursor : (isBusy ? Qt.BusyCursor : Qt.ArrowCursor)
enabled: canConnect && !isBusy
onClicked: {
if (modelData)
BluetoothService.connectDeviceWithTrust(modelData)
}
}
}
}
// Fallback if nothing available
Column {
width: parent.width
spacing: Style.marginMedium * scaling
visible: {
if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices)
return false
var availableCount = Bluetooth.devices.values.filter(dev => {
return dev && !dev.paired && !dev.pairing
&& !dev.blocked
&& (dev.signalStrength === undefined
|| dev.signalStrength > 0)
}).length
return availableCount === 0
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.marginMedium * scaling
NText {
text: "sync"
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeXLL * 1.5 * scaling
color: Color.mPrimary
anchors.verticalCenter: parent.verticalCenter
RotationAnimation on rotation {
running: true
loops: Animation.Infinite
from: 0
to: 360
duration: 2000
}
}
NText {
text: "Scanning for devices..."
font.pointSize: Style.fontSizeLarge * scaling
color: Color.mOnSurface
font.weight: Style.fontWeightMedium
anchors.verticalCenter: parent.verticalCenter
}
}
NText {
text: "Make sure your device is in pairing mode"
font.pointSize: Style.fontSizeMedium * scaling
color: Color.mOnSurfaceVariant
anchors.horizontalCenter: parent.horizontalCenter
}
}
NText {
text: "No devices found. Put your device in pairing mode and click Start Scanning."
font.pointSize: Style.fontSizeMedium * scaling
color: Color.mOnSurfaceVariant
visible: {
if (!BluetoothService.adapter || !Bluetooth.devices)
return true
var availableCount = Bluetooth.devices.values.filter(dev => {
return dev && !dev.paired && !dev.pairing
&& !dev.blocked
&& (dev.signalStrength === undefined
|| dev.signalStrength > 0)
}).length
return availableCount === 0 && !BluetoothService.adapter.discovering
}
wrapMode: Text.WordWrap
width: parent.width
horizontalAlignment: Text.AlignHCenter
}
}
}
// This item takes up all the remaining vertical space.
Item {
Layout.fillHeight: true
}
}
}
}
}
}

View file

@ -0,0 +1,76 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Modules.SettingsPanel
import qs.Services
import qs.Widgets
Item {
id: root
width: pill.width
height: pill.height
visible: Settings.data.bar.showBrightness && firstBrightnessReceived
// Used to avoid opening the pill on Quickshell startup
property bool firstBrightnessReceived: false
function getIcon() {
var brightness = BrightnessService.getMonitorForScreen(screen).brightness
return brightness <= 0 ? "brightness_1" : brightness < 0.33 ? "brightness_low" : brightness
< 0.66 ? "brightness_medium" : "brightness_high"
}
// Connection used to open the pill when brightness changes
Connections {
target: BrightnessService.getMonitorForScreen(screen)
function onBrightnessUpdated() {
Logger.log("Bar-Brightness", "OnBrightnessUpdated")
var monitor = BrightnessService.getMonitorForScreen(screen)
var currentBrightness = monitor.brightness
// Ignore if this is the first time or if brightness hasn't actually changed
if (!firstBrightnessReceived) {
firstBrightnessReceived = true
monitor.lastBrightness = currentBrightness
return
}
// Only show pill if brightness actually changed (not just loaded from settings)
if (Math.abs(currentBrightness - monitor.lastBrightness) > 0.1) {
pill.show()
}
monitor.lastBrightness = currentBrightness
}
}
NPill {
id: pill
icon: getIcon()
iconCircleColor: Color.mPrimary
collapsedIconColor: Color.mOnSurface
autoHide: false // Important to be false so we can hover as long as we want
text: Math.round(BrightnessService.getMonitorForScreen(screen).brightness * 100) + "%"
tooltipText: {
var monitor = BrightnessService.getMonitorForScreen(screen)
return "Brightness: " + Math.round(monitor.brightness * 100) + "%\nMethod: " + monitor.method
+ "\nLeft click for advanced settings.\nScroll up/down to change brightness."
}
onWheel: function (angle) {
var monitor = BrightnessService.getMonitorForScreen(screen)
if (angle > 0) {
monitor.increaseBrightness()
} else if (angle < 0) {
monitor.decreaseBrightness()
}
}
onClicked: {
settingsPanel.requestedTab = SettingsPanel.Tab.Brightness
settingsPanel.isLoaded = true
}
}
}

28
Modules/Bar/Clock.qml Normal file
View file

@ -0,0 +1,28 @@
import QtQuick
import qs.Commons
import qs.Services
import qs.Widgets
// Clock Icon with attached calendar
NClock {
id: root
NTooltip {
id: tooltip
text: Time.dateString
target: root
}
onEntered: {
if (!calendarPanel.isLoaded) {
tooltip.show()
}
}
onExited: {
tooltip.hide()
}
onClicked: {
tooltip.hide()
calendarPanel.isLoaded = !calendarPanel.isLoaded
}
}

92
Modules/Bar/MediaMini.qml Normal file
View file

@ -0,0 +1,92 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
Row {
id: root
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginSmall * scaling
visible: Settings.data.bar.showMedia && (MediaService.canPlay || MediaService.canPause)
function getTitle() {
return MediaService.trackTitle + (MediaService.trackArtist !== "" ? ` - ${MediaService.trackArtist}` : "")
}
// A hidden text element to safely measure the full title width
NText {
id: fullTitleMetrics
visible: false
text: titleText.text
font: titleText.font
}
Rectangle {
// Let the Rectangle size itself based on its content (the Row)
width: row.width + Style.marginMedium * scaling * 2
height: row.height + Style.marginSmall * scaling
color: Color.mSurfaceVariant
radius: Style.radiusSmall * scaling
anchors.verticalCenter: parent.verticalCenter
Item {
id: mainContainer
anchors.fill: parent
anchors.leftMargin: Style.marginSmall * scaling
anchors.rightMargin: Style.marginSmall * scaling
Row {
id: row
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginTiny * scaling
// Window icon
NText {
id: windowIcon
text: MediaService.isPlaying ? "pause" : "play_arrow"
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeLarge * scaling
verticalAlignment: Text.AlignVCenter
anchors.verticalCenter: parent.verticalCenter
visible: getTitle() !== ""
}
NText {
id: titleText
// If hovered or just switched window, show up to 300 pixels
// If not hovered show up to 150 pixels
width: (mouseArea.containsMouse) ? Math.min(fullTitleMetrics.contentWidth,
400 * scaling) : Math.min(fullTitleMetrics.contentWidth,
150 * scaling)
text: getTitle()
font.pointSize: Style.fontSizeReduced * scaling
font.weight: Style.fontWeightBold
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
verticalAlignment: Text.AlignVCenter
color: Color.mSecondary
Behavior on width {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.InOutCubic
}
}
}
}
// Mouse area for hover detection
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: MediaService.playPause()
}
}
}
}

View file

@ -0,0 +1,36 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services
import qs.Widgets
NIconButton {
id: root
visible: Settings.data.bar.showNotificationsHistory
sizeMultiplier: 0.8
showBorder: false
icon: "notifications"
tooltipText: "Notification History"
onClicked: {
if (!notificationHistoryPanel.active) {
notificationHistoryPanel.isLoaded = true
}
if (notificationHistoryPanel.item) {
if (notificationHistoryPanel.item.visible) {
// Panel is visible, hide it with animation
if (notificationHistoryPanel.item.hide) {
notificationHistoryPanel.item.hide()
} else {
notificationHistoryPanel.item.visible = false
}
} else {
// Panel is hidden, show it
notificationHistoryPanel.item.visible = true
}
}
}
}

View file

@ -0,0 +1,86 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
Row {
id: layout
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginSmall * scaling
visible: Settings.data.bar.showSystemInfo
// Ensure our width is an integer
width: Math.floor(cpuUsageLayout.width + cpuTempLayout.width + memoryUsageLayout.width + (2 * 10))
Row {
id: cpuUsageLayout
spacing: Style.marginTiny * scaling
NText {
id: cpuUsageIcon
text: "speed"
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeLarge * scaling
verticalAlignment: Text.AlignVCenter
anchors.verticalCenter: parent.verticalCenter
}
NText {
id: cpuUsageText
text: `${SystemStatService.cpuUsage}%`
font.pointSize: Style.fontSizeReduced * scaling
font.weight: Style.fontWeightBold
anchors.verticalCenter: parent.verticalCenter
verticalAlignment: Text.AlignVCenter
color: Color.mPrimary
}
}
// CPU Temperature Component
Row {
id: cpuTempLayout
spacing: Style.marginTiny * scaling
NText {
text: "thermometer"
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeLarge * scaling
verticalAlignment: Text.AlignVCenter
anchors.verticalCenter: parent.verticalCenter
}
NText {
text: `${SystemStatService.cpuTemp}°C`
font.pointSize: Style.fontSizeReduced * scaling
font.weight: Style.fontWeightBold
anchors.verticalCenter: parent.verticalCenter
verticalAlignment: Text.AlignVCenter
color: Color.mPrimary
}
}
// Memory Usage Component
Row {
id: memoryUsageLayout
spacing: Style.marginTiny * scaling
NText {
text: "memory"
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeLarge * scaling
verticalAlignment: Text.AlignVCenter
anchors.verticalCenter: parent.verticalCenter
}
NText {
text: `${SystemStatService.memoryUsageGb}G`
font.pointSize: Style.fontSizeReduced * scaling
font.weight: Style.fontWeightBold
anchors.verticalCenter: parent.verticalCenter
verticalAlignment: Text.AlignVCenter
color: Color.mPrimary
}
}
}

211
Modules/Bar/Tray.qml Normal file
View file

@ -0,0 +1,211 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Services.SystemTray
import Quickshell.Widgets
import qs.Commons
import qs.Services
import qs.Widgets
Item {
readonly property real itemSize: 24 * scaling
visible: Settings.data.bar.showTray
width: tray.width
height: itemSize
Row {
id: tray
spacing: Style.marginSmall * scaling
Layout.alignment: Qt.AlignVCenter
Repeater {
id: repeater
model: SystemTray.items
delegate: Item {
width: itemSize
height: itemSize
visible: modelData
IconImage {
id: trayIcon
anchors.centerIn: parent
width: Style.marginLarge * scaling
height: Style.marginLarge * scaling
smooth: false
asynchronous: true
backer.fillMode: Image.PreserveAspectFit
source: {
let icon = modelData?.icon || ""
if (!icon) {
return ""
}
// Process icon path
if (icon.includes("?path=")) {
// Seems qmlfmt does not support the following ES6 syntax: const[name, path] = icon.split
const chunks = icon.split("?path=")
const name = chunks[0]
const path = chunks[1]
const fileName = name.substring(name.lastIndexOf("/") + 1)
return `file://${path}/${fileName}`
}
return icon
}
opacity: status === Image.Ready ? 1 : 0
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: mouse => {
if (!modelData) {
return
}
if (mouse.button === Qt.LeftButton) {
// Close any open menu first
if (trayMenu && trayMenu.visible) {
trayMenu.hideMenu()
}
if (!modelData.onlyMenu) {
modelData.activate()
}
} else if (mouse.button === Qt.MiddleButton) {
// Close any open menu first
if (trayMenu && trayMenu.visible) {
trayMenu.hideMenu()
}
modelData.secondaryActivate && modelData.secondaryActivate()
} else if (mouse.button === Qt.RightButton) {
trayTooltip.hide()
// If menu is already visible, close it
if (trayMenu && trayMenu.visible) {
trayMenu.hideMenu()
return
}
if (modelData.hasMenu && modelData.menu && trayMenu) {
// Anchor the menu to the tray icon item (parent) and position it below the icon
const menuX = (width / 2) - (trayMenu.width / 2)
const menuY = (Style.barHeight * scaling)
trayMenu.menu = modelData.menu
trayMenu.showAt(parent, menuX, menuY)
trayPanel.show()
} else {
Logger.log("Tray", "No menu available for", modelData.id, "or trayMenu not set")
}
}
}
onEntered: trayTooltip.show()
onExited: trayTooltip.hide()
}
NTooltip {
id: trayTooltip
target: trayIcon
text: modelData.tooltipTitle || modelData.name || modelData.id || "Tray Item"
}
}
}
}
// Attached TrayMenu drop down
// Wrapped in NPanel so we can detect click outside of the menu to close the TrayMenu
NPanel {
id: trayPanel
showOverlay: false // no colors overlay even if activated in settings
// Override hide function to animate first
function hide() {
// Start hide animation
trayMenuRect.scaleValue = 0.8
trayMenuRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
Connections {
target: trayPanel
ignoreUnknownSignals: true
function onDismissed() {
// Start hide animation
trayMenuRect.scaleValue = 0.8
trayMenuRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Also handle visibility changes from external sources
onVisibleChanged: {
if (!visible && trayMenuRect.opacityValue > 0) {
// Start hide animation
trayMenuRect.scaleValue = 0.8
trayMenuRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Timer to hide panel after animation
Timer {
id: hideTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
trayPanel.visible = false
trayMenu.hideMenu()
}
}
Rectangle {
id: trayMenuRect
color: Color.transparent
anchors.fill: parent
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0.0
scale: scaleValue
opacity: opacityValue
// Animate in when component is completed
Component.onCompleted: {
scaleValue = 1.0
opacityValue = 1.0
}
// Animation behaviors
Behavior on scale {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.OutExpo
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
TrayMenu {
id: trayMenu
}
}
}
}

479
Modules/Bar/TrayMenu.qml Normal file
View file

@ -0,0 +1,479 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
PopupWindow {
id: trayMenu
property QsMenuHandle menu
property var anchorItem: null
property real anchorX
property real anchorY
implicitWidth: Style.baseWidgetSize * 5.625 * scaling
implicitHeight: Math.max(60 * scaling, listView.contentHeight + (Style.marginMedium * 2 * scaling))
visible: false
color: Color.transparent
anchor.item: anchorItem ? anchorItem : null
anchor.rect.x: anchorX
anchor.rect.y: anchorY - 4
// Recursive function to destroy all open submenus in delegate tree, safely avoiding infinite recursion
function destroySubmenusRecursively(item) {
if (!item || !item.contentItem)
return
var children = item.contentItem.children
for (var i = 0; i < children.length; ++i) {
var child = children[i]
if (child.subMenu) {
child.subMenu.hideMenu()
child.subMenu.destroy()
child.subMenu = null
}
// Recursively destroy submenus only if the child has contentItem to prevent issues
if (child.contentItem) {
destroySubmenusRecursively(child)
}
}
}
function showAt(item, x, y) {
if (!item) {
Logger.warn("TrayMenu", "anchorItem is undefined, won't show menu.")
return
}
anchorItem = item
anchorX = x
anchorY = y
visible = true
forceActiveFocus()
Qt.callLater(() => trayMenu.anchor.updateAnchor())
}
function hideMenu() {
visible = false
destroySubmenusRecursively(listView)
}
Item {
anchors.fill: parent
Keys.onEscapePressed: trayMenu.hideMenu()
}
QsMenuOpener {
id: opener
menu: trayMenu.menu
}
Rectangle {
id: bg
anchors.fill: parent
color: Color.mSurface
border.color: Color.mOutline
border.width: Math.max(1, Style.borderThin * scaling)
radius: Style.radiusMedium * scaling
z: 0
}
ListView {
id: listView
anchors.fill: parent
anchors.margins: Style.marginMedium * scaling
spacing: 0
interactive: false
enabled: trayMenu.visible
clip: true
model: ScriptModel {
values: opener.children ? [...opener.children.values] : []
}
delegate: Rectangle {
id: entry
required property var modelData
width: listView.width
height: (modelData?.isSeparator) ? 8 * scaling : Math.max(32 * scaling, text.height + 8)
color: Color.transparent
property var subMenu: null
NDivider {
anchors.centerIn: parent
width: parent.width - (Style.marginMedium * scaling * 2)
visible: modelData?.isSeparator ?? false
}
Rectangle {
id: bg
anchors.fill: parent
color: mouseArea.containsMouse ? Color.mTertiary : Color.transparent
radius: Style.radiusSmall * scaling
visible: !(modelData?.isSeparator ?? false)
RowLayout {
anchors.fill: parent
anchors.leftMargin: Style.marginMedium * scaling
anchors.rightMargin: Style.marginMedium * scaling
spacing: Style.marginSmall * scaling
NText {
id: text
Layout.fillWidth: true
color: (modelData?.enabled
?? true) ? (mouseArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface) : Color.textDisabled
text: modelData?.text ?? ""
font.pointSize: Style.fontSizeSmall * scaling
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Image {
Layout.preferredWidth: Style.marginLarge * scaling
Layout.preferredHeight: Style.marginLarge * scaling
source: modelData?.icon ?? ""
visible: (modelData?.icon ?? "") !== ""
fillMode: Image.PreserveAspectFit
}
// Chevron right for optional submenu
Text {
text: modelData?.hasChildren ? "menu" : ""
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeSmall * scaling
verticalAlignment: Text.AlignVCenter
visible: modelData?.hasChildren ?? false
color: Color.mOnSurface
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
enabled: (modelData?.enabled ?? true) && !(modelData?.isSeparator ?? false) && trayMenu.visible
onClicked: {
if (modelData && !modelData.isSeparator) {
if (modelData.hasChildren) {
// Submenus open on hover; ignore click here
return
}
modelData.triggered()
trayMenu.hideMenu()
}
}
onEntered: {
if (!trayMenu.visible)
return
if (modelData?.hasChildren) {
// Close sibling submenus immediately
for (var i = 0; i < listView.contentItem.children.length; i++) {
const sibling = listView.contentItem.children[i]
if (sibling !== entry && sibling.subMenu) {
sibling.subMenu.hideMenu()
sibling.subMenu.destroy()
sibling.subMenu = null
}
}
if (entry.subMenu) {
entry.subMenu.hideMenu()
entry.subMenu.destroy()
entry.subMenu = null
}
var globalPos = entry.mapToGlobal(0, 0)
var submenuWidth = 180 * scaling
var gap = 12 * scaling
var openLeft = (globalPos.x + entry.width + submenuWidth > Screen.width)
var anchorX = openLeft ? -submenuWidth - gap : entry.width + gap
entry.subMenu = subMenuComponent.createObject(trayMenu, {
"menu": modelData,
"anchorItem": entry,
"anchorX": anchorX,
"anchorY": 0
})
entry.subMenu.showAt(entry, anchorX, 0)
} else {
// Hovered item without submenu; close siblings
for (var i = 0; i < listView.contentItem.children.length; i++) {
const sibling = listView.contentItem.children[i]
if (sibling.subMenu) {
sibling.subMenu.hideMenu()
sibling.subMenu.destroy()
sibling.subMenu = null
}
}
if (entry.subMenu) {
entry.subMenu.hideMenu()
entry.subMenu.destroy()
entry.subMenu = null
}
}
}
onExited: {
if (entry.subMenu && !entry.subMenu.containsMouse()) {
entry.subMenu.hideMenu()
entry.subMenu.destroy()
entry.subMenu = null
}
}
}
}
// Simplified containsMouse without recursive calls to avoid stack overflow
function containsMouse() {
return mouseArea.containsMouse
}
Component.onDestruction: {
if (subMenu) {
subMenu.destroy()
subMenu = null
}
}
}
}
// -----------------------------------------
// Sub Component
// -----------------------------------------
Component {
id: subMenuComponent
PopupWindow {
id: subMenu
implicitWidth: Style.baseWidgetSize * 5.625 * scaling
implicitHeight: Math.max(40, listView.contentHeight + 12)
visible: false
color: Color.transparent
property QsMenuHandle menu
property var anchorItem: null
property real anchorX
property real anchorY
anchor.item: anchorItem ? anchorItem : null
anchor.rect.x: anchorX
anchor.rect.y: anchorY
function showAt(item, x, y) {
if (!item) {
Logger.warn("TrayMenu", "SubComponent anchorItem is undefined, not showing menu.")
return
}
anchorItem = item
anchorX = x
anchorY = y
visible = true
Qt.callLater(() => subMenu.anchor.updateAnchor())
}
function hideMenu() {
visible = false
// Close all submenus recursively in this submenu
for (var i = 0; i < listView.contentItem.children.length; i++) {
const child = listView.contentItem.children[i]
if (child.subMenu) {
child.subMenu.hideMenu()
child.subMenu.destroy()
child.subMenu = null
}
}
}
// Simplified containsMouse avoiding recursive calls
function containsMouse() {
return subMenu.containsMouse
}
Item {
anchors.fill: parent
Keys.onEscapePressed: subMenu.hideMenu()
}
QsMenuOpener {
id: opener
menu: subMenu.menu
}
Rectangle {
id: bg
anchors.fill: parent
color: Color.mSurface
border.color: Color.mOutline
border.width: Math.max(1, Style.borderThin * scaling)
radius: Style.radiusMedium * scaling
z: 0
}
ListView {
id: listView
anchors.fill: parent
anchors.margins: Style.marginSmall * scaling
spacing: Style.marginTiny * scaling
interactive: false
enabled: subMenu.visible
clip: true
model: ScriptModel {
values: opener.children ? [...opener.children.values] : []
}
delegate: Rectangle {
id: entry
required property var modelData
width: listView.width
height: (modelData?.isSeparator) ? 8 * scaling : Math.max(32 * scaling, subText.height + 8)
color: Color.transparent
property var subMenu: null
NDivider {
anchors.centerIn: parent
width: parent.width - (Style.marginMedium * scaling * 2)
visible: modelData?.isSeparator ?? false
}
Rectangle {
id: bg
anchors.fill: parent
color: mouseArea.containsMouse ? Color.mTertiary : Color.transparent
radius: Style.radiusSmall * scaling
visible: !(modelData?.isSeparator ?? false)
property color hoverTextColor: mouseArea.containsMouse ? Color.mOnSurface : Color.mOnSurface
RowLayout {
anchors.fill: parent
anchors.leftMargin: Style.marginMedium * scaling
anchors.rightMargin: Style.marginMedium * scaling
spacing: Style.marginSmall * scaling
NText {
id: subText
Layout.fillWidth: true
color: (modelData?.enabled ?? true) ? bg.hoverTextColor : Color.textDisabled
text: modelData?.text ?? ""
font.pointSize: Style.fontSizeSmall * scaling
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Image {
Layout.preferredWidth: Style.marginLarge * scaling
Layout.preferredHeight: Style.marginLarge * scaling
source: modelData?.icon ?? ""
visible: (modelData?.icon ?? "") !== ""
fillMode: Image.PreserveAspectFit
}
// TBC a Square UTF-8?
NText {
text: modelData?.hasChildren ? "\uE5CC" : ""
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeSmall * scaling
verticalAlignment: Text.AlignVCenter
visible: modelData?.hasChildren ?? false
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
enabled: (modelData?.enabled ?? true) && !(modelData?.isSeparator ?? false)
onClicked: {
if (modelData && !modelData.isSeparator) {
if (modelData.hasChildren) {
return
}
modelData.triggered()
trayMenu.hideMenu()
}
}
onEntered: {
if (subMenu && !subMenu.visible) {
return
}
if (modelData?.hasChildren) {
for (var i = 0; i < listView.contentItem.children.length; i++) {
const sibling = listView.contentItem.children[i]
if (sibling !== entry && sibling.subMenu) {
sibling.subMenu.hideMenu()
sibling.subMenu.destroy()
sibling.subMenu = null
}
}
if (entry.subMenu) {
entry.subMenu.hideMenu()
entry.subMenu.destroy()
entry.subMenu = null
}
var globalPos = entry.mapToGlobal(0, 0)
var submenuWidth = 180 * scaling
var gap = 12 * scaling
var openLeft = (globalPos.x + entry.width + submenuWidth > Screen.width)
var anchorX = openLeft ? -submenuWidth - gap : entry.width + gap
entry.subMenu = subMenuComponent.createObject(subMenu, {
"menu": modelData,
"anchorItem": entry,
"anchorX": anchorX,
"anchorY": 0
})
entry.subMenu.showAt(entry, anchorX, 0)
} else {
for (var i = 0; i < listView.contentItem.children.length; i++) {
const sibling = listView.contentItem.children[i]
if (sibling.subMenu) {
sibling.subMenu.hideMenu()
sibling.subMenu.destroy()
sibling.subMenu = null
}
}
if (entry.subMenu) {
entry.subMenu.hideMenu()
entry.subMenu.destroy()
entry.subMenu = null
}
}
}
onExited: {
if (entry.subMenu && !entry.subMenu.containsMouse()) {
entry.subMenu.hideMenu()
entry.subMenu.destroy()
entry.subMenu = null
}
}
}
}
// Simplified & safe containsMouse avoiding recursion
function containsMouse() {
return mouseArea.containsMouse
}
Component.onDestruction: {
if (subMenu) {
subMenu.destroy()
subMenu = null
}
}
}
}
}
}
}

71
Modules/Bar/Volume.qml Normal file
View file

@ -0,0 +1,71 @@
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import qs.Commons
import qs.Modules.SettingsPanel
import qs.Services
import qs.Widgets
Item {
id: root
width: pill.width
height: pill.height
// Used to avoid opening the pill on Quickshell startup
property bool firstVolumeReceived: false
function getIcon() {
if (AudioService.muted) {
return "volume_off"
}
return AudioService.volume <= Number.EPSILON ? "volume_off" : (AudioService.volume < 0.33 ? "volume_down" : "volume_up")
}
// Connection used to open the pill when volume changes
Connections {
target: AudioService.sink?.audio ? AudioService.sink?.audio : null
function onVolumeChanged() {
// Logger.log("Bar:Volume", "onVolumeChanged")
if (!firstVolumeReceived) {
// Ignore the first volume change
firstVolumeReceived = true
} else {
pill.show()
externalHideTimer.restart()
}
}
}
Timer {
id: externalHideTimer
running: false
interval: 1500
onTriggered: {
pill.hide()
}
}
NPill {
id: pill
icon: getIcon()
iconCircleColor: Color.mPrimary
collapsedIconColor: Color.mOnSurface
autoHide: false // Important to be false so we can hover as long as we want
text: Math.floor(AudioService.volume * 100) + "%"
tooltipText: "Volume: " + Math.round(
AudioService.volume * 100) + "%\nLeft click for advanced settings.\nScroll up/down to change volume."
onWheel: function (angle) {
if (angle > 0) {
AudioService.increaseVolume()
} else if (angle < 0) {
AudioService.decreaseVolume()
}
}
onClicked: {
settingsPanel.requestedTab = SettingsPanel.Tab.AudioService
settingsPanel.isLoaded = true
}
}
}

54
Modules/Bar/WiFi.qml Normal file
View file

@ -0,0 +1,54 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services
import qs.Widgets
NIconButton {
id: root
readonly property bool wifiEnabled: Settings.data.network.wifiEnabled
sizeMultiplier: 0.8
showBorder: false
visible: wifiEnabled
icon: {
let connected = false
let signalStrength = 0
for (const net in NetworkService.networks) {
if (NetworkService.networks[net].connected) {
connected = true
signalStrength = NetworkService.networks[net].signal
break
}
}
return connected ? NetworkService.signalIcon(signalStrength) : "wifi"
}
tooltipText: "WiFi Networks"
onClicked: {
if (!wifiMenuLoader.active) {
wifiMenuLoader.isLoaded = true
}
if (wifiMenuLoader.item) {
if (wifiMenuLoader.item.visible) {
// Panel is visible, hide it with animation
if (wifiMenuLoader.item.hide) {
wifiMenuLoader.item.hide()
} else {
wifiMenuLoader.item.visible = false
NetworkService.onMenuClosed()
}
} else {
// Panel is hidden, show it
wifiMenuLoader.item.visible = true
NetworkService.onMenuOpened()
}
}
}
WiFiMenu {
id: wifiMenuLoader
}
}

434
Modules/Bar/WiFiMenu.qml Normal file
View file

@ -0,0 +1,434 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services
import qs.Widgets
// Loader for WiFi menu
NLoader {
id: root
content: Component {
NPanel {
id: wifiPanel
property string passwordPromptSsid: ""
property string passwordInput: ""
property bool showPasswordPrompt: false
function hide() {
wifiMenuRect.scaleValue = 0.8
wifiMenuRect.opacityValue = 0.0
hideTimer.start()
}
// Connect to NPanel's dismissed signal to handle external close events
Connections {
target: wifiPanel
ignoreUnknownSignals: true
function onDismissed() {
// Start hide animation
wifiMenuRect.scaleValue = 0.8
wifiMenuRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Also handle visibility changes from external sources
onVisibleChanged: {
if (visible && Settings.data.network.wifiEnabled) {
NetworkService.refreshNetworks()
} else if (wifiMenuRect.opacityValue > 0) {
// Start hide animation
wifiMenuRect.scaleValue = 0.8
wifiMenuRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Timer to hide panel after animation
Timer {
id: hideTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
wifiPanel.visible = false
wifiPanel.dismissed()
// NetworkService.onMenuClosed()
}
}
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
// Timer to refresh networks when WiFi is enabled while menu is open
Timer {
id: wifiEnableRefreshTimer
interval: 3000 // Wait 3 seconds for WiFi to be fully ready
repeat: false
onTriggered: {
if (Settings.data.network.wifiEnabled && wifiPanel.visible) {
NetworkService.refreshNetworks()
}
}
}
Rectangle {
id: wifiMenuRect
color: Color.mSurface
radius: Style.radiusLarge * scaling
border.color: Color.mOutlineVariant
border.width: Math.max(1, Style.borderThin * scaling)
width: 340 * scaling
height: 500 * scaling
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: Style.marginTiny * scaling
anchors.rightMargin: Style.marginTiny * scaling
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0.0
scale: scaleValue
opacity: opacityValue
// Animate in when component is completed
Component.onCompleted: {
scaleValue = 1.0
opacityValue = 1.0
}
// Animation behaviors
Behavior on scale {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.OutExpo
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginLarge * scaling
spacing: Style.marginMedium * scaling
RowLayout {
Layout.fillWidth: true
spacing: Style.marginMedium * scaling
NText {
text: "wifi"
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeXL * scaling
color: Color.mPrimary
}
NText {
text: "WiFi"
font.pointSize: Style.fontSizeLarge * scaling
font.bold: true
color: Color.mOnSurface
Layout.fillWidth: true
}
NIconButton {
icon: "refresh"
tooltipText: "Refresh Networks"
sizeMultiplier: 0.8
enabled: Settings.data.network.wifiEnabled && !NetworkService.isLoading
onClicked: {
NetworkService.refreshNetworks()
}
}
NIconButton {
icon: "close"
tooltipText: "Close"
sizeMultiplier: 0.8
onClicked: {
wifiPanel.hide()
}
}
}
NDivider {}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
// Loading indicator
ColumnLayout {
anchors.centerIn: parent
visible: Settings.data.network.wifiEnabled && NetworkService.isLoading
spacing: Style.marginMedium * scaling
NBusyIndicator {
running: NetworkService.isLoading
color: Color.mPrimary
size: Style.baseWidgetSize * scaling
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "Scanning for networks..."
font.pointSize: Style.fontSizeNormal * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
}
// WiFi disabled message
ColumnLayout {
anchors.centerIn: parent
visible: !Settings.data.network.wifiEnabled
spacing: Style.marginMedium * scaling
NText {
text: "wifi_off"
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeXXL * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "WiFi is disabled"
font.pointSize: Style.fontSizeLarge * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "Enable WiFi to see available networks"
font.pointSize: Style.fontSizeNormal * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
}
// Network list
ListView {
id: networkList
anchors.fill: parent
visible: Settings.data.network.wifiEnabled && !NetworkService.isLoading
model: Object.values(NetworkService.networks)
spacing: Style.marginMedium * scaling
clip: true
delegate: Item {
width: parent ? parent.width : 0
height: modelData.ssid === passwordPromptSsid
&& showPasswordPrompt ? 108 * scaling : Style.baseWidgetSize * 1.5 * scaling
ColumnLayout {
anchors.fill: parent
spacing: 0
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Style.baseWidgetSize * 1.5 * scaling
radius: Style.radiusMedium * scaling
color: modelData.connected ? Color.mPrimary : (networkMouseArea.containsMouse ? Color.mTertiary : Color.transparent)
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginSmall * scaling
spacing: Style.marginSmall * scaling
NText {
text: NetworkService.signalIcon(modelData.signal)
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeXL * scaling
color: modelData.connected ? Color.mSurface : (networkMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface)
}
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginTiny * scaling
// SSID
NText {
text: modelData.ssid || "Unknown Network"
font.pointSize: Style.fontSizeNormal * scaling
elide: Text.ElideRight
Layout.fillWidth: true
color: modelData.connected ? Color.mSurface : (networkMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface)
}
// Security Protocol
NText {
text: modelData.security && modelData.security !== "--" ? modelData.security : "Open"
font.pointSize: Style.fontSizeTiny * scaling
elide: Text.ElideRight
Layout.fillWidth: true
color: modelData.connected ? Color.mSurface : (networkMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface)
}
NText {
visible: NetworkService.connectStatusSsid === modelData.ssid
&& NetworkService.connectStatus === "error" && NetworkService.connectError.length > 0
text: NetworkService.connectError
color: Color.mError
font.pointSize: Style.fontSizeSmall * scaling
elide: Text.ElideRight
Layout.fillWidth: true
}
}
Item {
Layout.preferredWidth: Style.baseWidgetSize * 0.7 * scaling
Layout.preferredHeight: Style.baseWidgetSize * 0.7 * scaling
visible: NetworkService.connectStatusSsid === modelData.ssid
&& (NetworkService.connectStatus !== ""
|| NetworkService.connectingSsid === modelData.ssid)
NBusyIndicator {
visible: NetworkService.connectingSsid === modelData.ssid
running: NetworkService.connectingSsid === modelData.ssid
color: Color.mPrimary
anchors.centerIn: parent
size: Style.baseWidgetSize * 0.7 * scaling
}
}
NText {
visible: modelData.connected
text: "connected"
font.pointSize: Style.fontSizeSmall * scaling
color: modelData.connected ? Color.mSurface : (networkMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface)
}
}
MouseArea {
id: networkMouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (modelData.connected) {
NetworkService.disconnectNetwork(modelData.ssid)
} else if (NetworkService.isSecured(modelData.security) && !modelData.existing) {
passwordPromptSsid = modelData.ssid
showPasswordPrompt = true
passwordInput = "" // Clear previous input
Qt.callLater(function () {
passwordInputField.forceActiveFocus()
})
} else {
NetworkService.connectNetwork(modelData.ssid, modelData.security)
}
}
}
}
// Password prompt section
Rectangle {
id: passwordPromptSection
Layout.fillWidth: true
Layout.preferredHeight: modelData.ssid === passwordPromptSsid && showPasswordPrompt ? 60 : 0
Layout.margins: Style.marginSmall * scaling
visible: modelData.ssid === passwordPromptSsid && showPasswordPrompt
color: Color.mSurfaceVariant
radius: Style.radiusSmall * scaling
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginSmall * scaling
spacing: Style.marginSmall * scaling
Item {
Layout.fillWidth: true
Layout.preferredHeight: Style.barHeight * scaling
Rectangle {
anchors.fill: parent
radius: Style.radiusTiny * scaling
color: Color.transparent
border.color: passwordInputField.activeFocus ? Color.mPrimary : Color.mOutline
border.width: Math.max(1, Style.borderThin * scaling)
TextInput {
id: passwordInputField
anchors.fill: parent
anchors.margins: Style.marginMedium * scaling
text: passwordInput
font.pointSize: Style.fontSizeMedium * scaling
color: Color.mOnSurface
verticalAlignment: TextInput.AlignVCenter
clip: true
focus: true
selectByMouse: true
activeFocusOnTab: true
inputMethodHints: Qt.ImhNone
echoMode: TextInput.Password
onTextChanged: passwordInput = text
onAccepted: {
NetworkService.submitPassword(passwordPromptSsid, passwordInput)
showPasswordPrompt = false
}
MouseArea {
id: passwordInputMouseArea
anchors.fill: parent
onClicked: passwordInputField.forceActiveFocus()
}
}
}
}
Rectangle {
Layout.preferredWidth: Style.baseWidgetSize * 2.5 * scaling
Layout.preferredHeight: Style.barHeight * scaling
radius: Style.radiusMedium * scaling
color: Color.mPrimary
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
NText {
anchors.centerIn: parent
text: "Connect"
color: Color.mSurface
font.pointSize: Style.fontSizeSmall * scaling
}
MouseArea {
anchors.fill: parent
onClicked: {
NetworkService.submitPassword(passwordPromptSsid, passwordInput)
showPasswordPrompt = false
}
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: parent.color = Qt.darker(Color.mPrimary, 1.1)
onExited: parent.color = Color.mPrimary
}
}
}
}
}
}
}
}
}
}
}
}
}

261
Modules/Bar/Workspace.qml Normal file
View file

@ -0,0 +1,261 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import QtQuick.Effects
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services
Item {
id: root
property bool isDestroying: false
property bool hovered: false
property ListModel localWorkspaces: ListModel {}
property real masterProgress: 0.0
property bool effectsActive: false
property color effectColor: Color.mPrimary
property int horizontalPadding: Math.round(16 * scaling)
property int spacingBetweenPills: Math.round(8 * scaling)
signal workspaceChanged(int workspaceId, color accentColor)
width: {
let total = 0
for (var i = 0; i < localWorkspaces.count; i++) {
const ws = localWorkspaces.get(i)
if (ws.isFocused)
total += Math.round(44 * scaling)
else if (ws.isActive)
total += Math.round(28 * scaling)
else
total += Math.round(16 * scaling)
}
total += Math.max(localWorkspaces.count - 1, 0) * spacingBetweenPills
total += horizontalPadding * 2
return total
}
height: Math.round(36 * scaling)
Component.onCompleted: {
localWorkspaces.clear()
for (var i = 0; i < WorkspaceService.workspaces.count; i++) {
const ws = WorkspaceService.workspaces.get(i)
if (ws.output.toLowerCase() === screen.name.toLowerCase()) {
localWorkspaces.append(ws)
}
}
workspaceRepeater.model = localWorkspaces
updateWorkspaceFocus()
}
Connections {
target: WorkspaceService
function onWorkspacesChanged() {
localWorkspaces.clear()
for (var i = 0; i < WorkspaceService.workspaces.count; i++) {
const ws = WorkspaceService.workspaces.get(i)
if (ws.output.toLowerCase() === screen.name.toLowerCase()) {
localWorkspaces.append(ws)
}
}
workspaceRepeater.model = localWorkspaces
updateWorkspaceFocus()
}
}
function triggerUnifiedWave() {
effectColor = Color.mPrimary
masterAnimation.restart()
}
SequentialAnimation {
id: masterAnimation
PropertyAction {
target: root
property: "effectsActive"
value: true
}
NumberAnimation {
target: root
property: "masterProgress"
from: 0.0
to: 1.0
duration: 1000
easing.type: Easing.OutQuint
}
PropertyAction {
target: root
property: "effectsActive"
value: false
}
PropertyAction {
target: root
property: "masterProgress"
value: 0.0
}
}
function updateWorkspaceFocus() {
for (var i = 0; i < localWorkspaces.count; i++) {
const ws = localWorkspaces.get(i)
if (ws.isFocused === true) {
root.triggerUnifiedWave()
root.workspaceChanged(ws.id, Color.mPrimary)
break
}
}
}
Rectangle {
id: workspaceBackground
width: parent.width - Math.round(15 * scaling)
height: Math.round(26 * scaling)
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
radius: Math.round(12 * scaling)
color: Color.mSurfaceVariant
border.color: Color.mOutlineVariant
border.width: Math.max(1, Math.round(1 * scaling))
layer.enabled: true
layer.effect: MultiEffect {
shadowColor: Color.mShadow
shadowVerticalOffset: 0
shadowHorizontalOffset: 0
shadowOpacity: 0.10
}
}
Row {
id: pillRow
spacing: spacingBetweenPills
anchors.verticalCenter: workspaceBackground.verticalCenter
width: root.width - horizontalPadding * 2
x: horizontalPadding
Repeater {
id: workspaceRepeater
model: localWorkspaces
Item {
id: workspacePillContainer
height: Math.round(12 * scaling)
width: {
if (model.isFocused)
return Math.round(44 * scaling)
else if (model.isActive)
return Math.round(28 * scaling)
else
return Math.round(16 * scaling)
}
Rectangle {
id: workspacePill
anchors.fill: parent
radius: {
if (model.isFocused)
return Math.round(12 * scaling)
else
// half of focused height (if you want to animate this too)
return Math.round(6 * scaling)
}
color: {
if (model.isFocused)
return Color.mPrimary
if (model.isUrgent)
return Color.mError
if (model.isActive || model.isOccupied)
return Color.mSecondary
if (model.isUrgent)
return Color.mError
return Color.mOutline
}
scale: model.isFocused ? 1.0 : 0.9
z: 0
MouseArea {
id: pillMouseArea
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
WorkspaceService.switchToWorkspace(model.idx)
}
hoverEnabled: true
}
// Material 3-inspired smooth animation for width, height, scale, color, opacity, and radius
Behavior on width {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutBack
}
}
Behavior on height {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutBack
}
}
Behavior on scale {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutBack
}
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.InOutCubic
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.InOutCubic
}
}
Behavior on radius {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutBack
}
}
}
Behavior on width {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutBack
}
}
Behavior on height {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutBack
}
}
// Burst effect overlay for focused pill (smaller outline)
Rectangle {
id: pillBurst
anchors.centerIn: workspacePillContainer
width: workspacePillContainer.width + 18 * root.masterProgress * scale
height: workspacePillContainer.height + 18 * root.masterProgress * scale
radius: width / 2
color: Color.transparent
border.color: root.effectColor
border.width: Math.max(1, Math.round((2 + 6 * (1.0 - root.masterProgress)) * scaling))
opacity: root.effectsActive && model.isFocused ? (1.0 - root.masterProgress) * 0.7 : 0
visible: root.effectsActive && model.isFocused
z: 1
}
}
}
}
Component.onDestruction: {
root.isDestroying = true
}
}

View file

@ -0,0 +1,236 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services
import qs.Widgets
NLoader {
id: root
content: Component {
NPanel {
id: calendarPanel
// Override hide function to animate first
function hide() {
// Start hide animation
calendarRect.scaleValue = 0.8
calendarRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
// Connect to NPanel's dismissed signal to handle external close events
Connections {
target: calendarPanel
function onDismissed() {
// Start hide animation
calendarRect.scaleValue = 0.8
calendarRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Also handle visibility changes from external sources
onVisibleChanged: {
if (!visible && calendarRect.opacityValue > 0) {
// Start hide animation
calendarRect.scaleValue = 0.8
calendarRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Timer to hide panel after animation
Timer {
id: hideTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
calendarPanel.visible = false
calendarPanel.dismissed()
}
}
Rectangle {
id: calendarRect
color: Color.mSurface
radius: Style.radiusMedium * scaling
border.color: Color.mOutline
border.width: Math.max(1, Style.borderMedium * scaling)
width: 340 * scaling
height: 320 * scaling // Reduced height to eliminate bottom space
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: Style.marginTiny * scaling
anchors.rightMargin: Style.marginTiny * scaling
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0.0
scale: scaleValue
opacity: opacityValue
// Animate in when component is completed
Component.onCompleted: {
scaleValue = 1.0
opacityValue = 1.0
}
// Prevent closing when clicking in the panel bg
MouseArea {
anchors.fill: parent
}
// Animation behaviors
Behavior on scale {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.OutExpo
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
// Main Column
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginMedium * scaling
spacing: Style.marginTiny * scaling
// Header: Month/Year with navigation
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: Style.marginMedium * scaling
Layout.rightMargin: Style.marginMedium * scaling
spacing: Style.marginSmall * scaling
NIconButton {
icon: "chevron_left"
tooltipText: "Previous Month"
onClicked: {
let newDate = new Date(grid.year, grid.month - 1, 1)
grid.year = newDate.getFullYear()
grid.month = newDate.getMonth()
}
}
NText {
text: grid.title
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
font.pointSize: Style.fontSizeMedium * scaling
font.weight: Style.fontWeightBold
color: Color.mPrimary
}
NIconButton {
icon: "chevron_right"
tooltipText: "Next Month"
onClicked: {
let newDate = new Date(grid.year, grid.month + 1, 1)
grid.year = newDate.getFullYear()
grid.month = newDate.getMonth()
}
}
}
// Divider between header and weekdays
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginSmall * scaling
Layout.bottomMargin: Style.marginMedium * scaling
}
// Columns label (respects locale's first day of week)
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: Style.marginSmall * scaling // Align with grid
Layout.rightMargin: Style.marginSmall * scaling
spacing: 0
Repeater {
model: 7
NText {
text: {
// Use the locale's first day of week setting
let firstDay = Qt.locale().firstDayOfWeek
let dayIndex = (firstDay + index) % 7
return Qt.locale().dayName(dayIndex, Locale.ShortFormat)
}
color: Color.mSecondary
font.pointSize: Style.fontSizeMedium * scaling
font.weight: Style.fontWeightBold
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
Layout.preferredWidth: Style.baseWidgetSize * scaling
}
}
}
// Grids: days
MonthGrid {
id: grid
Layout.fillWidth: true
Layout.fillHeight: true // Take remaining space
Layout.leftMargin: Style.marginSmall * scaling
Layout.rightMargin: Style.marginSmall * scaling
spacing: 0
month: Time.date.getMonth()
year: Time.date.getFullYear()
locale: Qt.locale() // Use system locale
// Optionally, update when the panel becomes visible
Connections {
target: calendarPanel
function onVisibleChanged() {
if (calendarPanel.visible) {
grid.month = Time.date.getMonth()
grid.year = Time.date.getFullYear()
}
}
}
delegate: Rectangle {
width: (Style.baseWidgetSize * scaling)
height: (Style.baseWidgetSize * scaling)
radius: Style.radiusSmall * scaling
color: model.today ? Color.mPrimary : Color.transparent
NText {
anchors.centerIn: parent
text: model.day
color: model.today ? Color.onAccent : Color.mOnSurface
opacity: model.month === grid.month ? Style.opacityHeavy : Style.opacityLight
font.pointSize: (Style.fontSizeMedium * scaling)
font.weight: model.today ? Style.fontWeightBold : Style.fontWeightRegular
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,310 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services
import qs.Widgets
NLoader {
id: root
content: Component {
NPanel {
id: demoPanel
// Override hide function to animate first
function hide() {
// Start hide animation
bgRect.scaleValue = 0.8
bgRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
// Connect to NPanel's dismissed signal to handle external close events
Connections {
target: demoPanel
function onDismissed() {
// Start hide animation
bgRect.scaleValue = 0.8
bgRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Also handle visibility changes from external sources
onVisibleChanged: {
if (!visible && bgRect.opacityValue > 0) {
// Start hide animation
bgRect.scaleValue = 0.8
bgRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Timer to hide panel after animation
Timer {
id: hideTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
demoPanel.visible = false
demoPanel.dismissed()
}
}
// Ensure panel shows itself once created
Component.onCompleted: {
show()
}
Rectangle {
id: bgRect
color: Color.mSurfaceVariant
radius: Style.radiusMedium * scaling
border.color: Color.mOutlineVariant
border.width: Math.max(1, Style.borderThin * scaling)
width: 500 * scaling
height: 900 * scaling
anchors.centerIn: parent
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0.0
scale: scaleValue
opacity: opacityValue
// Animate in when component is completed
Component.onCompleted: {
scaleValue = 1.0
opacityValue = 1.0
}
// Prevent closing when clicking in the panel bg
MouseArea {
anchors.fill: parent
}
// Animation behaviors
Behavior on scale {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.OutExpo
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginXL * scaling
NText {
text: "DemoPanel"
color: Color.mPrimary
font.pointSize: Style.fontSizeXL * scaling
font.weight: Style.fontWeightBold
Layout.alignment: Qt.AlignHCenter
}
ColumnLayout {
spacing: Style.marginMedium * scaling
// NSlider
ColumnLayout {
spacing: Style.marginLarge * scaling
NText {
text: "Scaling"
color: Color.mSecondary
font.weight: Style.fontWeightBold
}
NText {
text: `${Math.round(ScalingService.overrideScale * 100)}%`
Layout.alignment: Qt.AlignVCenter
}
RowLayout {
spacing: Style.marginSmall * scaling
NSlider {
id: scaleSlider
from: 0.6
to: 1.8
stepSize: 0.01
value: ScalingService.overrideScale
implicitWidth: bgRect.width * 0.75
onMoved: {
}
onPressedChanged: {
ScalingService.overrideScale = value
ScalingService.overrideEnabled = true
}
}
NIconButton {
icon: "refresh"
tooltipText: "Reset Scaling"
fontPointSize: Style.fontSizeLarge * scaling
onClicked: {
ScalingService.overrideEnabled = false
ScalingService.overrideScale = 1.0
}
}
}
NDivider {
Layout.fillWidth: true
}
}
// NIconButton
ColumnLayout {
spacing: Style.marginLarge * scaling
NText {
text: "NIconButton"
color: Color.mSecondary
font.weight: Style.fontWeightBold
}
NIconButton {
id: myIconButton
icon: "celebration"
tooltipText: "A nice tooltip"
fontPointSize: Style.fontSizeLarge * scaling
}
NDivider {
Layout.fillWidth: true
}
}
// NToggle
ColumnLayout {
spacing: Style.marginMedium * scaling
NText {
text: "NToggle"
color: Color.mSecondary
font.weight: Style.fontWeightBold
}
NToggle {
label: "Label"
description: "Description"
onToggled: checked => {
Logger.log("DemoPanel", "NToggle:", checked)
}
}
NDivider {
Layout.fillWidth: true
}
}
// NComboBox
ColumnLayout {
spacing: Style.marginMedium * scaling
NText {
text: "NComboBox"
color: Color.mSecondary
font.weight: Style.fontWeightBold
}
NComboBox {
label: "Animal"
description: "What's your favorite?"
model: ListModel {
ListElement {
key: "cat"
name: "Cat"
}
ListElement {
key: "dog"
name: "Dog"
}
ListElement {
key: "bird"
name: "Bird"
}
ListElement {
key: "fish"
name: "Fish"
}
ListElement {
key: "turtle"
name: "Turtle"
}
ListElement {
key: "elephant"
name: "Elephant"
}
ListElement {
key: "tiger"
name: "Tiger"
}
}
currentKey: "dog"
onSelected: function (key) {
Logger.log("DemoPanel", "NComboBox: selected ", key)
}
}
NDivider {
Layout.fillWidth: true
}
}
// NTextInput
ColumnLayout {
spacing: Style.marginMedium * scaling
NText {
text: "NTextInput"
color: Color.mSecondary
font.weight: Style.fontWeightBold
}
NTextInput {
label: "Input label"
description: "A cool description"
text: "Type anything"
Layout.fillWidth: true
onEditingFinished: {
}
}
NDivider {
Layout.fillWidth: true
}
}
// NBusyIndicator
ColumnLayout {
spacing: Style.marginMedium * scaling
NText {
text: "NBusyIndicator"
color: Color.mSecondary
font.weight: Style.fontWeightBold
}
NBusyIndicator {}
NDivider {
Layout.fillWidth: true
}
}
}
}
}
}
}
}

308
Modules/Dock/Dock.qml Normal file
View file

@ -0,0 +1,308 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Commons
import qs.Services
import qs.Widgets
NLoader {
isLoaded: (Settings.data.dock.monitors.length > 0)
content: Component {
Variants {
model: Quickshell.screens
PanelWindow {
id: dockWindow
required property ShellScreen modelData
readonly property real scaling: ScalingService.scale(screen)
screen: modelData
// Auto-hide properties - make reactive to settings changes
property bool autoHide: Settings.data.dock.autoHide
property bool hidden: autoHide
property int hideDelay: 500
property int showDelay: 100
property int hideAnimationDuration: Style.animationFast
property int showAnimationDuration: Style.animationFast
property int peekHeight: 2
property int fullHeight: dockContainer.height
property int iconSize: 36
// Track hover state
property bool dockHovered: false
property bool anyAppHovered: false
// Dock is only shown if explicitely toggled
visible: modelData ? Settings.data.dock.monitors.includes(modelData.name) : false
exclusionMode: ExclusionMode.Ignore
anchors.bottom: true
anchors.left: true
anchors.right: true
focusable: false
color: Color.transparent
implicitHeight: iconSize * 1.4 * scaling
// Watch for autoHide setting changes
onAutoHideChanged: {
if (!autoHide) {
// If auto-hide is disabled, show the dock
hidden = false
hideTimer.stop()
showTimer.stop()
} else {
// If auto-hide is enabled, start hidden
hidden = true
}
}
// Timer for auto-hide delay
Timer {
id: hideTimer
interval: hideDelay
onTriggered: {
if (autoHide && !dockHovered && !anyAppHovered) {
hidden = true
}
}
}
// Timer for show delay
Timer {
id: showTimer
interval: showDelay
onTriggered: hidden = false
}
// Behavior for smooth hide/show animations
Behavior on margins.bottom {
NumberAnimation {
duration: hidden ? hideAnimationDuration : showAnimationDuration
easing.type: Easing.InOutQuad
}
}
MouseArea {
id: screenEdgeMouseArea
x: 0
y: modelData && modelData.geometry ? modelData.geometry.height - (fullHeight + 10 * scaling) : 0
width: screen.width
height: fullHeight + 10 * scaling
hoverEnabled: true
propagateComposedEvents: true
onEntered: {
if (autoHide && hidden) {
showTimer.start()
}
}
onExited: {
if (autoHide && !hidden && !dockHovered && !anyAppHovered) {
hideTimer.start()
}
}
}
margins.bottom: hidden ? -(fullHeight - peekHeight) : 0
Rectangle {
id: dockContainer
width: dock.width + 48 * scaling
height: iconSize * 1.4 * scaling
color: Color.mSurface
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
topLeftRadius: Style.radiusLarge * scaling
topRightRadius: Style.radiusLarge * scaling
MouseArea {
id: dockMouseArea
anchors.fill: parent
hoverEnabled: true
propagateComposedEvents: true
onEntered: {
dockHovered = true
if (autoHide) {
showTimer.stop()
hideTimer.stop()
hidden = false
}
}
onExited: {
dockHovered = false
// Only start hide timer if we're not hovering over any app
if (autoHide && !anyAppHovered) {
hideTimer.start()
}
}
}
Item {
id: dock
width: runningAppsRow.width
height: parent.height - (20 * scaling)
anchors.centerIn: parent
NTooltip {
id: appTooltip
visible: false
positionAbove: true
}
function getAppIcon(toplevel: Toplevel): string {
if (!toplevel)
return ""
let icon = Quickshell.iconPath(toplevel.appId?.toLowerCase(), true)
if (!icon)
icon = Quickshell.iconPath(toplevel.appId, true)
if (!icon)
icon = Quickshell.iconPath(toplevel.title?.toLowerCase(), true)
if (!icon)
icon = Quickshell.iconPath(toplevel.title, true)
return icon || Quickshell.iconPath("application-x-executable", true)
}
Row {
id: runningAppsRow
spacing: Style.marginLarge * scaling
height: parent.height
anchors.centerIn: parent
Repeater {
model: ToplevelManager ? ToplevelManager.toplevels : null
delegate: Rectangle {
id: appButton
width: iconSize * scaling
height: iconSize * scaling
color: Color.transparent
radius: Style.radiusMedium * scaling
property bool isActive: ToplevelManager.activeToplevel && ToplevelManager.activeToplevel === modelData
property bool hovered: appMouseArea.containsMouse
property string appId: modelData ? modelData.appId : ""
property string appTitle: modelData ? modelData.title : ""
// Hover background
Rectangle {
id: hoverBackground
anchors.fill: parent
color: appButton.hovered ? Color.mSurfaceVariant : Color.transparent
radius: parent.radius
opacity: appButton.hovered ? 0.8 : 0
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutQuad
}
}
}
// The icon
Image {
id: appIcon
width: iconSize * scaling
height: iconSize * scaling
anchors.centerIn: parent
source: dock.getAppIcon(modelData)
visible: source.toString() !== ""
smooth: true
mipmap: false
antialiasing: false
fillMode: Image.PreserveAspectFit
scale: appButton.hovered ? 1.1 : 1.0
Behavior on scale {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutBack
}
}
}
// Fall back if no icon
NText {
anchors.centerIn: parent
visible: !appIcon.visible
text: "question_mark"
font.family: "Material Symbols Rounded"
font.pointSize: iconSize * 0.7 * scaling
color: appButton.isActive ? Color.mPrimary : Color.mOnSurfaceVariant
scale: appButton.hovered ? 1.1 : 1.0
Behavior on scale {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutBack
}
}
}
MouseArea {
id: appMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
onEntered: {
anyAppHovered = true
const appName = appButton.appTitle || appButton.appId || "Unknown"
appTooltip.text = appName.length > 40 ? appName.substring(0, 37) + "..." : appName
appTooltip.target = appButton
appTooltip.isVisible = true
if (autoHide) {
showTimer.stop()
hideTimer.stop()
hidden = false
}
}
onExited: {
anyAppHovered = false
appTooltip.hide()
// Only start hide timer if we're not hovering over the dock
if (autoHide && !dockHovered) {
hideTimer.start()
}
}
onClicked: function (mouse) {
if (mouse.button === Qt.MiddleButton && modelData?.close) {
modelData.close()
}
if (mouse.button === Qt.LeftButton && modelData?.activate) {
modelData.activate()
}
}
}
Rectangle {
visible: isActive
width: iconSize * 0.75
height: 4 * scaling
color: Color.mPrimary
radius: Style.radiusTiny
anchors.top: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
anchors.topMargin: Style.marginTiniest * scaling
}
}
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,60 @@
import QtQuick
import Quickshell.Io
Item {
id: root
IpcHandler {
target: "settings"
function toggle() {
settingsPanel.isLoaded = !settingsPanel.isLoaded
}
}
IpcHandler {
target: "notifications"
function toggleHistory() {
notificationHistoryPanel.isLoaded = !notificationHistoryPanel.isLoaded
}
function toggleDoNotDisturb() {// TODO
}
}
IpcHandler {
target: "idleInhibitor"
function toggle() {// TODO
}
}
IpcHandler {
target: "appLauncher"
function toggle() {
appLauncherPanel.isLoaded = !appLauncherPanel.isLoaded
}
}
IpcHandler {
target: "lockScreen"
function toggle() {
lockScreen.locked = !lockScreen.locked
}
}
IpcHandler {
target: "brightness"
function increase() {
BrightnessService.increaseBrightness()
}
function decrease() {
BrightnessService.decreaseBrightness()
}
}
}

View file

@ -0,0 +1,877 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import Quickshell.Services.Pam
import Quickshell.Services.UPower
import Quickshell.Io
import Quickshell.Widgets
import qs.Commons
import qs.Services
import qs.Widgets
WlSessionLock {
id: lock
// Lockscreen is a different beast, needs a capital 'S' in 'Screen' to get the current screen
readonly property real scaling: ScalingService.scale(Screen)
property string errorMessage: ""
property bool authenticating: false
property string password: ""
property bool pamAvailable: typeof PamContext !== "undefined"
locked: false
function unlockAttempt() {
Logger.log("LockScreen", "Unlock attempt started")
// Real PAM authentication
if (!pamAvailable) {
lock.errorMessage = "PAM authentication not available."
Logger.log("LockScreen", "PAM not available")
return
}
if (!lock.password) {
lock.errorMessage = "Password required."
Logger.log("LockScreen", "No password entered")
return
}
Logger.log("LockScreen", "Starting PAM authentication")
lock.authenticating = true
lock.errorMessage = ""
Logger.log("LockScreen", "About to create PAM context with userName:", Quickshell.env("USER"))
var pam = Qt.createQmlObject(
'import Quickshell.Services.Pam; PamContext { config: "login"; user: "' + Quickshell.env("USER") + '" }',
lock)
Logger.log("LockScreen", "PamContext created", pam)
pam.onCompleted.connect(function (result) {
Logger.log("LockScreen", "PAM completed with result:", result)
lock.authenticating = false
if (result === PamResult.Success) {
Logger.log("LockScreen", "Authentication successful, unlocking")
lock.locked = false
lock.password = ""
lock.errorMessage = ""
} else {
Logger.log("LockScreen", "Authentication failed")
lock.errorMessage = "Authentication failed."
lock.password = ""
}
pam.destroy()
})
pam.onError.connect(function (error) {
Logger.log("LockScreen", "PAM error:", error)
lock.authenticating = false
lock.errorMessage = pam.message || "Authentication error."
lock.password = ""
pam.destroy()
})
pam.onPamMessage.connect(function () {
Logger.log("LockScreen", "PAM message:", pam.message, "isError:", pam.messageIsError)
if (pam.messageIsError) {
lock.errorMessage = pam.message
}
})
pam.onResponseRequiredChanged.connect(function () {
Logger.log("LockScreen", "PAM response required:", pam.responseRequired)
if (pam.responseRequired && lock.authenticating) {
Logger.log("LockScreen", "Responding to PAM with password")
pam.respond(lock.password)
}
})
var started = pam.start()
Logger.log("LockScreen", "PAM start result:", started)
}
WlSessionLockSurface {
// Battery indicator component
Item {
id: batteryIndicator
// Import UPower for battery data
property var battery: UPower.displayDevice
property bool isReady: battery && battery.ready && battery.isLaptopBattery && battery.isPresent
property real percent: isReady ? (battery.percentage * 100) : 0
property bool charging: isReady ? battery.state === UPowerDeviceState.Charging : false
property bool batteryVisible: isReady && percent > 0
// Choose icon based on charge and charging state
function getIcon() {
if (!batteryVisible)
return ""
if (charging)
return "battery_android_bolt"
if (percent >= 95)
return "battery_android_full"
// Hardcoded battery symbols
if (percent >= 85)
return "battery_android_6"
if (percent >= 70)
return "battery_android_5"
if (percent >= 55)
return "battery_android_4"
if (percent >= 40)
return "battery_android_3"
if (percent >= 25)
return "battery_android_2"
if (percent >= 10)
return "battery_android_1"
if (percent >= 0)
return "battery_android_0"
}
}
// Wallpaper image
Image {
id: lockBgImage
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
source: WallpaperService.currentWallpaper !== "" ? WallpaperService.currentWallpaper : ""
cache: true
smooth: true
mipmap: false
}
// Blurred background
Rectangle {
anchors.fill: parent
color: Color.transparent
// Simple blur effect
layer.enabled: true
layer.smooth: true
layer.samples: 4
}
// Animated gradient overlay
Rectangle {
anchors.fill: parent
gradient: Gradient {
GradientStop {
position: 0.0
color: Qt.rgba(0, 0, 0, 0.6)
}
GradientStop {
position: 0.3
color: Qt.rgba(0, 0, 0, 0.3)
}
GradientStop {
position: 0.7
color: Qt.rgba(0, 0, 0, 0.4)
}
GradientStop {
position: 1.0
color: Qt.rgba(0, 0, 0, 0.7)
}
}
// Subtle animated particles
Repeater {
model: 20
Rectangle {
width: Math.random() * 4 + 2
height: width
radius: width * 0.5
color: Qt.rgba(Color.mPrimary.r, Color.mPrimary.g, Color.mPrimary.b, 0.3)
x: Math.random() * parent.width
y: Math.random() * parent.height
SequentialAnimation on opacity {
loops: Animation.Infinite
NumberAnimation {
to: 0.8
duration: 2000 + Math.random() * 3000
}
NumberAnimation {
to: 0.1
duration: 2000 + Math.random() * 3000
}
}
}
}
}
// Main content - Centered design
Item {
anchors.fill: parent
// Top section - Time, date, and user info
ColumnLayout {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: 80 * scaling
spacing: 40 * scaling
// Time display - Large and prominent with pulse animation
Column {
spacing: Style.marginSmall * scaling
Layout.alignment: Qt.AlignHCenter
Text {
id: timeText
text: Qt.formatDateTime(new Date(), "HH:mm")
font.family: "Inter"
font.pointSize: Style.fontSizeXXL * 6
font.weight: Font.Bold
font.letterSpacing: -2
color: Color.mOnSurface
horizontalAlignment: Text.AlignHCenter
SequentialAnimation on scale {
loops: Animation.Infinite
NumberAnimation {
to: 1.02
duration: 2000
easing.type: Easing.InOutQuad
}
NumberAnimation {
to: 1.0
duration: 2000
easing.type: Easing.InOutQuad
}
}
}
Text {
id: dateText
text: Qt.formatDateTime(new Date(), "dddd, MMMM d")
font.family: "Inter"
font.pointSize: Style.fontSizeXL
font.weight: Font.Light
color: Color.mOnSurface
horizontalAlignment: Text.AlignHCenter
width: timeText.width
}
}
// User section with animated avatar
Column {
spacing: Style.marginMedium * scaling
Layout.alignment: Qt.AlignHCenter
// Animated avatar with glow effect
Rectangle {
width: 120 * scaling
height: 120 * scaling
radius: width * 0.5
color: Color.transparent
border.color: Color.mPrimary
border.width: Math.max(1, Style.borderThick * scaling)
anchors.horizontalCenter: parent.horizontalCenter
// Glow effect
Rectangle {
anchors.centerIn: parent
width: parent.width + 24 * scaling
height: parent.height + 24 * scaling
radius: width * 0.5
color: Color.transparent
border.color: Qt.rgba(Color.mPrimary.r, Color.mPrimary.g, Color.mPrimary.b, 0.3)
border.width: Math.max(1, Style.borderMedium * scaling)
z: -1
SequentialAnimation on scale {
loops: Animation.Infinite
NumberAnimation {
to: 1.1
duration: 1500
easing.type: Easing.InOutQuad
}
NumberAnimation {
to: 1.0
duration: 1500
easing.type: Easing.InOutQuad
}
}
}
NImageRounded {
anchors.centerIn: parent
width: 100 * scaling
height: 100 * scaling
imagePath: Quickshell.env("HOME") + "/.face"
fallbackIcon: "person"
imageRadius: width * 0.5
}
// Hover animation
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: parent.scale = 1.05
onExited: parent.scale = 1.0
}
Behavior on scale {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutBack
}
}
}
}
}
// Centered terminal section
Item {
width: 720 * scaling
height: 280 * scaling
anchors.centerIn: parent
ColumnLayout {
anchors.centerIn: parent
spacing: 20 * scaling
width: parent.width
// Futuristic Terminal-Style Input
Item {
width: parent.width
height: 280 * scaling
Layout.fillWidth: true
// Terminal background with scanlines
Rectangle {
id: terminalBackground
anchors.fill: parent
radius: Style.radiusMedium * scaling
color: Color.applyOpacity(Color.mSurface, "E6")
border.color: Color.mPrimary
border.width: Math.max(1, Style.borderMedium * scaling)
// Scanline effect
Repeater {
model: 20
Rectangle {
width: parent.width
height: 1
color: Color.applyOpacity(Color.mPrimary, "1A")
y: index * 10
opacity: Style.opacityMedium
SequentialAnimation on opacity {
loops: Animation.Infinite
NumberAnimation {
to: 0.6
duration: 2000 + Math.random() * 1000
}
NumberAnimation {
to: 0.1
duration: 2000 + Math.random() * 1000
}
}
}
}
// Terminal header
Rectangle {
width: parent.width
height: 40 * scaling
color: Color.applyOpacity(Color.mPrimary, "33")
topLeftRadius: Style.radiusSmall * scaling
topRightRadius: Style.radiusSmall * scaling
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginMedium * scaling
spacing: Style.marginMedium * scaling
Text {
text: "SECURE TERMINAL"
color: Color.mOnSurface
font.family: "DejaVu Sans Mono"
font.pointSize: Style.fontSizeLarge
font.weight: Font.Bold
Layout.fillWidth: true
}
// Battery indicator
Row {
spacing: Style.marginSmall * scaling
visible: batteryIndicator.batteryVisible
Text {
text: batteryIndicator.getIcon()
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeMedium
color: batteryIndicator.charging ? Color.mPrimary : Color.mOnSurface
}
Text {
text: Math.round(batteryIndicator.percent) + "%"
color: Color.mOnSurface
font.family: "DejaVu Sans Mono"
font.pointSize: Style.fontSizeMedium
font.weight: Font.Bold
}
}
}
}
// Terminal content area
ColumnLayout {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.topMargin: 70 * scaling
anchors.margins: Style.marginMedium * scaling
spacing: Style.marginMedium * scaling
// Welcome back typing effect
RowLayout {
Layout.fillWidth: true
spacing: Style.marginMedium * scaling
Text {
text: "root@noctalia:~$"
color: Color.mPrimary
font.family: "DejaVu Sans Mono"
font.pointSize: Style.fontSizeLarge
font.weight: Font.Bold
}
Text {
id: welcomeText
text: ""
color: Color.mOnSurface
font.family: "DejaVu Sans Mono"
font.pointSize: Style.fontSizeLarge
property int currentIndex: 0
property string fullText: "Welcome back, " + Quickshell.env("USER") + "!"
Timer {
interval: Style.animationFast
running: true
repeat: true
onTriggered: {
if (parent.currentIndex < parent.fullText.length) {
parent.text = parent.fullText.substring(0, parent.currentIndex + 1)
parent.currentIndex++
} else {
running = false
}
}
}
}
}
// Command line with integrated password input
RowLayout {
Layout.fillWidth: true
spacing: Style.marginMedium * scaling
Text {
text: "root@noctalia:~$"
color: Color.mPrimary
font.family: "DejaVu Sans Mono"
font.pointSize: Style.fontSizeLarge
font.weight: Font.Bold
}
Text {
text: "sudo unlock-session"
color: Color.mOnSurface
font.family: "DejaVu Sans Mono"
font.pointSize: Style.fontSizeLarge
}
// Integrated password input (invisible, just for functionality)
TextInput {
id: passwordInput
width: 0
height: 0
visible: false
font.family: "DejaVu Sans Mono"
font.pointSize: Style.fontSizeLarge
color: Color.mOnSurface
echoMode: TextInput.Password
passwordCharacter: "*"
passwordMaskDelay: 0
text: lock.password
onTextChanged: {
lock.password = text
// Terminal typing sound effect (visual)
typingEffect.start()
}
Keys.onPressed: function (event) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
lock.unlockAttempt()
}
}
Component.onCompleted: {
forceActiveFocus()
}
}
// Visual password display with integrated cursor
Text {
id: asterisksText
text: "*".repeat(passwordInput.text.length)
color: Color.mOnSurface
font.family: "DejaVu Sans Mono"
font.pointSize: Style.fontSizeLarge
visible: passwordInput.activeFocus
// Typing effect animation
SequentialAnimation {
id: typingEffect
NumberAnimation {
target: passwordInput
property: "scale"
to: 1.01
duration: 50
}
NumberAnimation {
target: passwordInput
property: "scale"
to: 1.0
duration: 50
}
}
}
// Blinking cursor positioned right after the asterisks
Rectangle {
width: 8 * scaling
height: 20 * scaling
color: Color.mPrimary
visible: passwordInput.activeFocus
anchors.left: asterisksText.right
anchors.leftMargin: Style.marginTiniest * scaling
anchors.verticalCenter: asterisksText.verticalCenter
SequentialAnimation on opacity {
loops: Animation.Infinite
NumberAnimation {
to: 1.0
duration: 500
}
NumberAnimation {
to: 0.0
duration: 500
}
}
}
}
// Status messages
Text {
text: lock.authenticating ? "Authenticating..." : (lock.errorMessage !== "" ? "Authentication failed." : "")
color: lock.authenticating ? Color.mPrimary : (lock.errorMessage !== "" ? Color.mError : Color.transparent)
font.family: "DejaVu Sans Mono"
font.pointSize: Style.fontSizeLarge
Layout.fillWidth: true
SequentialAnimation on opacity {
running: lock.authenticating
loops: Animation.Infinite
NumberAnimation {
to: 1.0
duration: 800
}
NumberAnimation {
to: 0.5
duration: 800
}
}
}
// Execute button
Rectangle {
width: 120 * scaling
height: 40 * scaling
radius: Style.radiusSmall * scaling
color: executeButtonArea.containsMouse ? Color.mPrimary : Color.applyOpacity(Color.mPrimary, "33")
border.color: Color.mPrimary
border.width: Math.max(1, Style.borderThin * scaling)
enabled: !lock.authenticating
Layout.alignment: Qt.AlignRight
Layout.bottomMargin: -12 * scaling
Text {
anchors.centerIn: parent
text: lock.authenticating ? "EXECUTING" : "EXECUTE"
color: executeButtonArea.containsMouse ? Color.onAccent : Color.mPrimary
font.family: "DejaVu Sans Mono"
font.pointSize: Style.fontSizeMedium
font.weight: Font.Bold
}
MouseArea {
id: executeButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: lock.unlockAttempt()
SequentialAnimation on scale {
running: containsMouse
NumberAnimation {
to: 1.05
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
SequentialAnimation on scale {
running: !containsMouse
NumberAnimation {
to: 1.0
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
}
// Processing animation
SequentialAnimation on scale {
loops: Animation.Infinite
running: lock.authenticating
NumberAnimation {
to: 1.02
duration: 600
easing.type: Easing.InOutQuad
}
NumberAnimation {
to: 1.0
duration: 600
easing.type: Easing.InOutQuad
}
}
}
}
// Terminal glow effect
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Color.transparent
border.color: Color.applyOpacity(Color.mPrimary, "4D")
border.width: Math.max(1, Style.borderThin * scaling)
z: -1
SequentialAnimation on opacity {
loops: Animation.Infinite
NumberAnimation {
to: 0.6
duration: 2000
easing.type: Easing.InOutQuad
}
NumberAnimation {
to: 0.2
duration: 2000
easing.type: Easing.InOutQuad
}
}
}
}
}
}
}
}
// Enhanced power buttons with hover effects
Row {
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 50 * scaling
spacing: 20 * scaling
// Shutdown with enhanced styling
Rectangle {
width: 64 * scaling
height: 64 * scaling
radius: Style.radiusLarge * scaling
color: Qt.rgba(Color.mError.r, Color.mError.g, Color.mError.b, shutdownArea.containsMouse ? 0.9 : 0.2)
border.color: Color.mError
border.width: Math.max(1, Style.borderMedium * scaling)
// Glow effect
Rectangle {
anchors.centerIn: parent
width: parent.width + 10 * scaling
height: parent.height + 10 * scaling
radius: width * 0.5
color: Color.transparent
border.color: Qt.rgba(Color.mError.r, Color.mError.g, Color.mError.b, 0.3)
border.width: Math.max(1, Style.borderMedium * scaling)
opacity: shutdownArea.containsMouse ? 1 : 0
z: -1
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
}
MouseArea {
id: shutdownArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
Qt.createQmlObject('import Quickshell.Io; Process { command: ["shutdown", "-h", "now"]; running: true }',
lock)
}
}
Text {
anchors.centerIn: parent
text: "power_settings_new"
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeXXL * scaling
color: shutdownArea.containsMouse ? Color.onAccent : Color.mError
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
scale: shutdownArea.containsMouse ? 1.1 : 1.0
}
// Reboot with enhanced styling
Rectangle {
width: 64 * scaling
height: 64 * scaling
radius: Style.radiusLarge * scaling
color: Qt.rgba(Color.mPrimary.r, Color.mPrimary.g, Color.mPrimary.b, rebootArea.containsMouse ? 0.9 : 0.2)
border.color: Color.mPrimary
border.width: Math.max(1, Style.borderMedium * scaling)
// Glow effect
Rectangle {
anchors.centerIn: parent
width: parent.width + 10 * scaling
height: parent.height + 10 * scaling
radius: width * 0.5
color: Color.transparent
border.color: Qt.rgba(Color.mPrimary.r, Color.mPrimary.g, Color.mPrimary.b, 0.3)
border.width: Math.max(1, Style.borderMedium * scaling)
opacity: rebootArea.containsMouse ? 1 : 0
z: -1
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
}
MouseArea {
id: rebootArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
Qt.createQmlObject('import Quickshell.Io; Process { command: ["reboot"]; running: true }', lock)
}
}
Text {
anchors.centerIn: parent
text: "refresh"
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeXXL * scaling
color: rebootArea.containsMouse ? Color.onAccent : Color.mPrimary
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
scale: rebootArea.containsMouse ? 1.1 : 1.0
}
// Logout with enhanced styling
Rectangle {
width: 64 * scaling
height: 64 * scaling
radius: Style.radiusLarge * scaling
color: Qt.rgba(Color.mSecondary.r, Color.mSecondary.g, Color.mSecondary.b, logoutArea.containsMouse ? 0.9 : 0.2)
border.color: Color.mSecondary
border.width: Math.max(1, Style.borderMedium * scaling)
// Glow effect
Rectangle {
anchors.centerIn: parent
width: parent.width + 10 * scaling
height: parent.height + 10 * scaling
radius: width * 0.5
color: Color.transparent
border.color: Qt.rgba(Color.mSecondary.r, Color.mSecondary.g, Color.mSecondary.b, 0.3)
border.width: Math.max(1, Style.borderMedium * scaling)
opacity: logoutArea.containsMouse ? 1 : 0
z: -1
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
}
MouseArea {
id: logoutArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
Qt.createQmlObject(
'import Quickshell.Io; Process { command: ["loginctl", "terminate-user", "' + Quickshell.env(
"USER") + '"]; running: true }', lock)
}
}
Text {
anchors.centerIn: parent
text: "exit_to_app"
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeXXL * scaling
color: logoutArea.containsMouse ? Color.onAccent : Color.mSecondary
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
scale: logoutArea.containsMouse ? 1.1 : 1.0
}
}
// Timer for updating time
Timer {
interval: 1000
running: true
repeat: true
onTriggered: {
timeText.text = Qt.formatDateTime(new Date(), "HH:mm")
dateText.text = Qt.formatDateTime(new Date(), "dddd, MMMM d")
}
}
}
}

View file

@ -0,0 +1,203 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Services.Notifications
import qs.Commons
import qs.Services
import qs.Widgets
// Simple notification popup - displays multiple notifications
Variants {
model: Quickshell.screens
PanelWindow {
id: root
required property ShellScreen modelData
readonly property real scaling: ScalingService.scale(screen)
screen: modelData
// Access the notification model from the service
property ListModel notificationModel: NotificationService.notificationModel
// Track notifications being removed for animation
property var removingNotifications: ({})
color: Color.transparent
// If no notification display activated in settings, then show them all
visible: modelData ? (Settings.data.notifications.monitors.includes(modelData.name)
|| (Settings.data.notifications.monitors.length === 0))
&& (NotificationService.notificationModel.count > 0) : false
anchors.top: true
anchors.right: true
margins.top: (Style.barHeight + Style.marginMedium) * scaling
margins.right: Style.marginMedium * scaling
implicitWidth: 360 * scaling
implicitHeight: Math.min(notificationStack.implicitHeight, (NotificationService.maxVisible * 120) * scaling)
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.exclusionMode: ExclusionMode.Ignore
// Connect to animation signal from service
Component.onCompleted: {
NotificationService.animateAndRemove.connect(function (notification, index) {
// Find the delegate and trigger its animation
if (notificationStack.children && notificationStack.children[index]) {
let delegate = notificationStack.children[index]
if (delegate && delegate.animateOut) {
delegate.animateOut()
}
}
})
}
// Main notification container
Column {
id: notificationStack
anchors.top: parent.top
anchors.right: parent.right
spacing: Style.marginSmall * scaling
width: 360 * scaling
visible: true
// Multiple notifications display
Repeater {
model: notificationModel
delegate: Rectangle {
width: 360 * scaling
height: Math.max(80 * scaling, contentColumn.implicitHeight + (Style.marginMedium * 2 * scaling))
clip: true
radius: Style.radiusMedium * scaling
border.color: Color.mPrimary
border.width: Math.max(1, Style.borderThin * scaling)
color: Color.mSurface
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0.0
property bool isRemoving: false
// Scale and fade-in animation
scale: scaleValue
opacity: opacityValue
// Animate in when the item is created
Component.onCompleted: {
scaleValue = 1.0
opacityValue = 1.0
}
// Animate out when being removed
function animateOut() {
isRemoving = true
scaleValue = 0.8
opacityValue = 0.0
}
// Timer for delayed removal after animation
Timer {
id: removalTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
NotificationService.forceRemoveNotification(model.rawNotification)
}
}
// Check if this notification is being removed
onIsRemovingChanged: {
if (isRemoving) {
// Remove from model after animation completes
removalTimer.start()
}
}
// Animation behaviors
Behavior on scale {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.OutExpo
//easing.type: Easing.OutBack looks better but notification get clipped on all sides
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
Column {
id: contentColumn
anchors.fill: parent
anchors.margins: Style.marginMedium * scaling
spacing: Style.marginSmall * scaling
RowLayout {
spacing: Style.marginSmall * scaling
NText {
text: (model.appName || model.desktopEntry) || "Unknown App"
color: Color.mSecondary
font.pointSize: Style.fontSizeSmall * scaling
}
Rectangle {
width: 6 * scaling
height: 6 * scaling
radius: Style.radiusTiny * scaling
color: (model.urgency === NotificationUrgency.Critical) ? Color.mError : (model.urgency === NotificationUrgency.Low) ? Color.mOnSurface : Color.mPrimary
Layout.alignment: Qt.AlignVCenter
}
Item {
Layout.fillWidth: true
}
NText {
text: NotificationService.formatTimestamp(model.timestamp)
color: Color.mOnSurface
font.pointSize: Style.fontSizeSmall * scaling
}
}
NText {
text: model.summary || "No summary"
font.pointSize: Style.fontSizeLarge * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
wrapMode: Text.Wrap
width: 300 * scaling
maximumLineCount: 3
elide: Text.ElideRight
}
NText {
text: model.body || ""
font.pointSize: Style.fontSizeSmall * scaling
color: Color.mOnSurface
wrapMode: Text.Wrap
width: 300 * scaling
maximumLineCount: 5
elide: Text.ElideRight
}
}
NIconButton {
icon: "close"
tooltipText: "Close"
sizeMultiplier: 0.8
showBorder: false
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: Style.marginSmall * scaling
onClicked: {
animateOut()
}
}
}
}
}
}
}

View file

@ -0,0 +1,278 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import Quickshell.Services.Notifications
import qs.Commons
import qs.Services
import qs.Widgets
// Loader for Notification History panel
NLoader {
id: root
content: Component {
NPanel {
id: notificationPanel
// Override hide function to animate first
function hide() {
// Start hide animation
notificationRect.scaleValue = 0.8
notificationRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
Connections {
target: notificationPanel
ignoreUnknownSignals: true
function onDismissed() {
// Start hide animation
notificationRect.scaleValue = 0.8
notificationRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Also handle visibility changes from external sources
onVisibleChanged: {
if (!visible && notificationRect.opacityValue > 0) {
// Start hide animation
notificationRect.scaleValue = 0.8
notificationRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Timer to hide panel after animation
Timer {
id: hideTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
notificationPanel.visible = false
notificationPanel.dismissed()
}
}
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
Rectangle {
id: notificationRect
color: Color.mSurface
radius: Style.radiusLarge * scaling
border.color: Color.mOutlineVariant
border.width: Math.max(1, Style.borderThin * scaling)
width: 400 * scaling
height: 500 * scaling
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: Style.marginTiny * scaling
anchors.rightMargin: Style.marginTiny * scaling
clip: true
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0.0
scale: scaleValue
opacity: opacityValue
// Animate in when component is completed
Component.onCompleted: {
scaleValue = 1.0
opacityValue = 1.0
}
// Animation behaviors
Behavior on scale {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.OutExpo
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginLarge * scaling
spacing: Style.marginMedium * scaling
RowLayout {
Layout.fillWidth: true
spacing: Style.marginMedium * scaling
NText {
text: "notifications"
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeXL * scaling
color: Color.mPrimary
}
NText {
text: "Notification History"
font.pointSize: Style.fontSizeLarge * scaling
font.bold: true
color: Color.mOnSurface
Layout.fillWidth: true
}
NIconButton {
icon: "delete"
tooltipText: "Clear History"
sizeMultiplier: 0.8
onClicked: NotificationService.clearHistory()
}
NIconButton {
icon: "close"
tooltipText: "Close"
sizeMultiplier: 0.8
onClicked: {
notificationPanel.hide()
}
}
}
NDivider {}
// Empty state when no notifications
Item {
Layout.fillWidth: true
Layout.fillHeight: true
visible: NotificationService.historyModel.count === 0
ColumnLayout {
anchors.centerIn: parent
spacing: Style.marginMedium * scaling
NText {
text: "notifications_off"
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeXXL * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "No notifications"
font.pointSize: Style.fontSizeLarge * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "Notifications will appear here when you receive them"
font.pointSize: Style.fontSizeNormal * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
}
}
ListView {
id: notificationList
Layout.fillWidth: true
Layout.fillHeight: true
model: NotificationService.historyModel
spacing: Style.marginMedium * scaling
clip: true
boundsBehavior: Flickable.StopAtBounds
visible: NotificationService.historyModel.count > 0
delegate: Rectangle {
width: notificationList ? (notificationList.width - 20) : 380 * scaling
height: Math.max(80, notificationContent.height + 30)
radius: Style.radiusMedium * scaling
color: notificationMouseArea.containsMouse ? Color.mPrimary : Color.mSurfaceVariant
RowLayout {
anchors {
fill: parent
margins: Style.marginMedium * scaling
}
spacing: Style.marginMedium * scaling
// Notification content
Column {
id: notificationContent
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
spacing: Style.marginTiniest * scaling
NText {
text: (summary || "No summary").substring(0, 100)
font.pointSize: Style.fontSizeMedium * scaling
font.weight: Font.Medium
color: notificationMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface
wrapMode: Text.Wrap
width: parent.width - 60
maximumLineCount: 2
elide: Text.ElideRight
}
NText {
text: (body || "").substring(0, 150)
font.pointSize: Style.fontSizeSmall * scaling
color: notificationMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface
wrapMode: Text.Wrap
width: parent.width - 60
maximumLineCount: 3
elide: Text.ElideRight
}
NText {
text: NotificationService.formatTimestamp(timestamp)
font.pointSize: Style.fontSizeSmall * scaling
color: notificationMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface
}
}
// Trash icon button
NIconButton {
icon: "delete"
tooltipText: "Delete Notification"
sizeMultiplier: 0.7
onClicked: {
Logger.log("NotificationHistory", "Removing notification:", summary)
NotificationService.historyModel.remove(index)
NotificationService.saveHistory()
}
}
}
MouseArea {
id: notificationMouseArea
anchors.fill: parent
anchors.rightMargin: Style.marginLarge * 3 * scaling
hoverEnabled: true
// Remove the onClicked handler since we now have a dedicated delete button
}
}
ScrollBar.vertical: ScrollBar {
active: true
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
}
}
}
}

View file

@ -0,0 +1,380 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs.Modules.SettingsPanel.Tabs as Tabs
import qs.Commons
import qs.Services
import qs.Widgets
NLoader {
id: root
// Tabs enumeration, order is NOT relevant
enum Tab {
About,
AudioService,
Bar,
Brightness,
ColorScheme,
Display,
General,
Network,
ScreenRecorder,
TimeWeather,
Wallpaper,
WallpaperSelector
}
property int requestedTab: SettingsPanel.Tab.General
content: Component {
NPanel {
id: panel
property int currentTabIndex: 0
// Override hide function to animate first
function hide() {
// Start hide animation
bgRect.scaleValue = 0.8
bgRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
// Connect to NPanel's dismissed signal to handle external close events
Connections {
target: panel
function onDismissed() {
hide()
}
}
// Timer to hide panel after animation
Timer {
id: hideTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
panel.visible = false
panel.dismissed()
}
}
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
Component {
id: generalTab
Tabs.GeneralTab {}
}
Component {
id: barTab
Tabs.BarTab {}
}
Component {
id: audioTab
Tabs.AudioTab {}
}
Component {
id: brightnessTab
Tabs.BrightnessTab {}
}
Component {
id: displayTab
Tabs.DisplayTab {}
}
Component {
id: networkTab
Tabs.NetworkTab {}
}
Component {
id: timeWeatherTab
Tabs.TimeWeatherTab {}
}
Component {
id: colorSchemeTab
Tabs.ColorSchemeTab {}
}
Component {
id: wallpaperTab
Tabs.WallpaperTab {}
}
Component {
id: wallpaperSelectorTab
Tabs.WallpaperSelectorTab {}
}
Component {
id: screenRecorderTab
Tabs.ScreenRecorderTab {}
}
Component {
id: aboutTab
Tabs.AboutTab {}
}
// Order *DOES* matter
property var tabsModel: [{
"id": SettingsPanel.Tab.General,
"label": "General",
"icon": "tune",
"source": generalTab
}, {
"id": SettingsPanel.Tab.Bar,
"label": "Bar",
"icon": "web_asset",
"source": barTab
}, {
"id": SettingsPanel.Tab.AudioService,
"label": "Audio",
"icon": "volume_up",
"source": audioTab
}, {
"id": SettingsPanel.Tab.Display,
"label": "Display",
"icon": "monitor",
"source": displayTab
}, {
"id": SettingsPanel.Tab.Network,
"label": "Network",
"icon": "lan",
"source": networkTab
}, {
"id": SettingsPanel.Tab.Brightness,
"label": "Brightness",
"icon": "brightness_6",
"source": brightnessTab
}, {
"id": SettingsPanel.Tab.TimeWeather,
"label": "Time & Weather",
"icon": "schedule",
"source": timeWeatherTab
}, {
"id": SettingsPanel.Tab.ColorScheme,
"label": "Color Scheme",
"icon": "palette",
"source": colorSchemeTab
}, {
"id": SettingsPanel.Tab.Wallpaper,
"label": "Wallpaper",
"icon": "image",
"source": wallpaperTab
}, {
"id": SettingsPanel.Tab.WallpaperSelector,
"label": "Wallpaper Selector",
"icon": "wallpaper_slideshow",
"source": wallpaperSelectorTab
}, {
"id": SettingsPanel.Tab.ScreenRecorder,
"label": "Screen Recorder",
"icon": "videocam",
"source": screenRecorderTab
}, {
"id": SettingsPanel.Tab.About,
"label": "About",
"icon": "info",
"source": aboutTab
}]
Component.onCompleted: {
var initialIndex = 0
if (root.requestedTab !== null) {
for (var i = 0; i < panel.tabsModel.length; i++) {
if (panel.tabsModel[i].id === root.requestedTab) {
initialIndex = i
break
}
}
}
// Now that the UI is settled, set the current tab index.
panel.currentTabIndex = initialIndex
show()
}
onVisibleChanged: {
if (!visible && (bgRect.opacityValue > 0)) {
hide()
}
}
Rectangle {
id: bgRect
color: Color.mSurface
radius: Style.radiusLarge * scaling
border.color: Color.mOutlineVariant
border.width: Math.max(1, Style.borderThin * scaling)
layer.enabled: true
width: Math.max(screen.width * 0.5, 1280) * scaling
height: Math.max(screen.height * 0.5, 720) * scaling
anchors.centerIn: parent
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0.0
scale: scaleValue
opacity: opacityValue
// Animate in when component is completed
Component.onCompleted: {
scaleValue = 1.0
opacityValue = 1.0
}
MouseArea {
anchors.fill: parent
}
Behavior on scale {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.OutExpo
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginLarge * scaling
spacing: Style.marginLarge * scaling
Rectangle {
id: sidebar
Layout.preferredWidth: Style.sliderWidth * 1.3 * scaling
Layout.fillHeight: true
color: Color.mSurfaceVariant
border.color: Color.mOutlineVariant
border.width: Math.max(1, Style.borderThin * scaling)
radius: Style.radiusMedium * scaling
Column {
anchors.fill: parent
anchors.margins: Style.marginSmall * scaling
spacing: Style.marginTiny * 1.5 * scaling
Repeater {
id: sections
model: panel.tabsModel
delegate: Rectangle {
id: tabItem
width: parent.width
height: 32 * scaling
radius: Style.radiusSmall * scaling
color: selected ? Color.mPrimary : (tabItem.hovering ? Color.mTertiary : Color.transparent)
readonly property bool selected: index === currentTabIndex
property bool hovering: false
property color tabTextColor: selected ? Color.mOnPrimary : (tabItem.hovering ? Color.mOnTertiary : Color.mOnSurface)
RowLayout {
anchors.fill: parent
anchors.leftMargin: Style.marginSmall * scaling
anchors.rightMargin: Style.marginSmall * scaling
spacing: Style.marginSmall * scaling
// Tab icon on the left side
NText {
text: modelData.icon
color: tabTextColor
font.family: "Material Symbols Outlined"
font.variableAxes: {
"wght": (Font.Normal + Font.Bold) / 2.0
}
font.pointSize: Style.fontSizeLarge * scaling
}
// Tab label on the left side
NText {
text: modelData.label
color: tabTextColor
font.pointSize: Style.fontSizeMedium * scaling
font.weight: Style.fontWeightBold
Layout.fillWidth: true
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton
onEntered: tabItem.hovering = true
onExited: tabItem.hovering = false
onCanceled: tabItem.hovering = false
onClicked: currentTabIndex = index
}
}
}
}
}
// Content
Rectangle {
id: contentPane
Layout.fillWidth: true
Layout.fillHeight: true
radius: Style.radiusMedium * scaling
color: Color.mSurfaceVariant
border.color: Color.mOutlineVariant
border.width: Math.max(1, Style.borderThin * scaling)
clip: true
ColumnLayout {
id: contentLayout
anchors.fill: parent
anchors.margins: Style.marginLarge * scaling
spacing: Style.marginSmall * scaling
RowLayout {
id: headerRow
Layout.fillWidth: true
spacing: Style.marginSmall * scaling
// Tab label on the main right side
NText {
text: panel.tabsModel[currentTabIndex].label
font.pointSize: Style.fontSizeLarge * scaling
font.weight: Style.fontWeightBold
color: Color.mPrimary
Layout.fillWidth: true
}
NIconButton {
icon: "close"
tooltipText: "Close"
Layout.alignment: Qt.AlignVCenter
onClicked: panel.hide()
}
}
NDivider {
Layout.fillWidth: true
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
Repeater {
model: panel.tabsModel
onItemAdded: function (index, item) {
item.sourceComponent = panel.tabsModel[index].source
}
delegate: Loader {
// All loaders will occupy the same space, stacked on top of each other.
anchors.fill: parent
visible: index === panel.currentTabIndex
// The loader is only active (and uses memory) when its page is visible.
active: visible
}
}
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,264 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services
import qs.Widgets
ColumnLayout {
id: root
property string latestVersion: GitHubService.latestVersion
property string currentVersion: "Unknown" // Fallback version
property var contributors: GitHubService.contributors
spacing: 0
Layout.fillWidth: true
Layout.fillHeight: true
Process {
id: currentVersionProcess
command: ["sh", "-c", "cd " + Quickshell.shellDir + " && git describe --tags --abbrev=0 2>/dev/null || echo 'Unknown'"]
Component.onCompleted: {
running = true
}
stdout: StdioCollector {
onStreamFinished: {
const version = text.trim()
if (version && version !== "Unknown") {
root.currentVersion = version
} else {
currentVersionProcess.command = ["sh", "-c", "cd " + Quickshell.shellDir
+ " && cat package.json 2>/dev/null | grep '\"version\"' | cut -d'\"' -f4 || echo 'Unknown'"]
currentVersionProcess.running = true
}
}
}
}
ScrollView {
id: scrollView
Layout.fillWidth: true
Layout.fillHeight: true
padding: Style.marginLarge * scaling
rightPadding: Style.marginMedium * scaling
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ColumnLayout {
width: scrollView.availableWidth
spacing: 0
NText {
text: "Noctalia: quiet by design"
font.pointSize: Style.fontSizeXXL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.alignment: Qt.AlignCenter
Layout.bottomMargin: Style.marginSmall * scaling
}
NText {
text: "It may just be another quickshell setup but it won't get in your way."
font.pointSize: Style.fontSizeMedium * scaling
color: Color.mOnSurface
Layout.alignment: Qt.AlignCenter
Layout.bottomMargin: Style.marginLarge * scaling
}
GridLayout {
Layout.alignment: Qt.AlignCenter
columns: 2
rowSpacing: Style.marginTiny * scaling
columnSpacing: Style.marginSmall * scaling
NText {
text: "Latest Version:"
color: Color.mOnSurface
Layout.alignment: Qt.AlignRight
}
NText {
text: root.latestVersion
color: Color.mOnSurface
font.weight: Style.fontWeightBold
}
NText {
text: "Installed Version:"
color: Color.mOnSurface
Layout.alignment: Qt.AlignRight
}
NText {
text: root.currentVersion
color: Color.mOnSurface
font.weight: Style.fontWeightBold
}
}
Rectangle {
Layout.alignment: Qt.AlignCenter
Layout.topMargin: Style.marginSmall * scaling
Layout.preferredWidth: updateText.implicitWidth + 46 * scaling
Layout.preferredHeight: Style.barHeight * scaling
radius: Style.radiusLarge * scaling
color: updateArea.containsMouse ? Color.mPrimary : Color.transparent
border.color: Color.mPrimary
border.width: Math.max(1, Style.borderThin * scaling)
visible: {
if (root.currentVersion === "Unknown" || root.latestVersion === "Unknown")
return false
const latest = root.latestVersion.replace("v", "").split(".")
const current = root.currentVersion.replace("v", "").split(".")
for (var i = 0; i < Math.max(latest.length, current.length); i++) {
const l = parseInt(latest[i] || "0")
const c = parseInt(current[i] || "0")
if (l > c)
return true
if (l < c)
return false
}
return false
}
RowLayout {
anchors.centerIn: parent
spacing: Style.marginSmall * scaling
NText {
text: "system_update"
font.family: "Material Symbols Outlined"
font.pointSize: Style.fontSizeXL * scaling
color: updateArea.containsMouse ? Color.mSurface : Color.mPrimary
}
NText {
id: updateText
text: "Download latest release"
font.pointSize: Style.fontSizeLarge * scaling
color: updateArea.containsMouse ? Color.mSurface : Color.mPrimary
}
}
MouseArea {
id: updateArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
Quickshell.execDetached(["xdg-open", "https://github.com/Ly-sec/Noctalia/releases/latest"])
}
}
}
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginLarge * 2 * scaling
Layout.bottomMargin: Style.marginLarge * scaling
}
NText {
text: `Shout-out to our ${root.contributors.length} awesome contributors!`
font.pointSize: Style.fontSizeLarge * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.alignment: Qt.AlignCenter
Layout.topMargin: Style.marginLarge * 2
}
ScrollView {
Layout.alignment: Qt.AlignCenter
Layout.preferredWidth: 200 * Style.marginTiny * scaling
Layout.fillHeight: true
Layout.topMargin: Style.marginLarge * scaling
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
GridView {
id: contributorsGrid
anchors.fill: parent
width: 200 * 4 * scaling
height: Math.ceil(root.contributors.length / 4) * 100
cellWidth: Style.baseWidgetSize * 6.25 * scaling
cellHeight: Style.baseWidgetSize * 3.125 * scaling
model: root.contributors
delegate: Rectangle {
width: contributorsGrid.cellWidth - Style.marginLarge * scaling
height: contributorsGrid.cellHeight - Style.marginTiny * scaling
radius: Style.radiusLarge * scaling
color: contributorArea.containsMouse ? Color.mTertiary : Color.transparent
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginSmall * scaling
spacing: Style.marginMedium * scaling
Item {
Layout.alignment: Qt.AlignVCenter
Layout.preferredWidth: Style.baseWidgetSize * 2 * scaling
Layout.preferredHeight: Style.baseWidgetSize * 2 * scaling
NImageRounded {
imagePath: modelData.avatar_url || ""
anchors.fill: parent
anchors.margins: Style.marginTiny * scaling
fallbackIcon: "person"
borderColor: Color.mPrimary
borderWidth: Math.max(1, Style.borderMedium * scaling)
imageRadius: width * 0.5
}
}
ColumnLayout {
spacing: Style.marginTiny * scaling
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
NText {
text: modelData.login || "Unknown"
font.weight: Style.fontWeightBold
color: contributorArea.containsMouse ? Color.mSurface : Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
}
NText {
text: (modelData.contributions || 0) + " " + ((modelData.contributions
|| 0) === 1 ? "commit" : "commits")
font.pointSize: Style.fontSizeSmall * scaling
color: contributorArea.containsMouse ? Color.mSurface : Color.mOnSurface
}
}
}
MouseArea {
id: contributorArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (modelData.html_url)
Quickshell.execDetached(["xdg-open", modelData.html_url])
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,276 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Services.Pipewire
import qs.Widgets
import qs.Commons
import qs.Services
ColumnLayout {
id: root
property real localVolume: AudioService.volume
// Connection used to open the pill when volume changes
Connections {
target: AudioService.sink?.audio ? AudioService.sink?.audio : null
function onVolumeChanged() {
localVolume = AudioService.volume
}
}
spacing: 0
ScrollView {
id: scrollView
Layout.fillWidth: true
Layout.fillHeight: true
padding: Style.marginMedium * scaling
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ColumnLayout {
width: scrollView.availableWidth
spacing: 0
Item {
Layout.fillWidth: true
Layout.preferredHeight: 0
}
ColumnLayout {
spacing: Style.marginTiny * scaling
Layout.fillWidth: true
NText {
text: "Audio"
font.pointSize: Style.fontSizeXL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.bottomMargin: Style.marginSmall * scaling
}
// Volume Controls
ColumnLayout {
spacing: Style.marginSmall * scaling
Layout.fillWidth: true
Layout.topMargin: Style.marginSmall * scaling
// Master Volume
ColumnLayout {
spacing: Style.marginSmall * scaling
Layout.fillWidth: true
ColumnLayout {
spacing: Style.marginTiniest * scaling
NText {
text: "Master Volume"
font.weight: Style.fontWeightBold
color: Color.mOnSurface
}
NText {
text: "System-wide volume level"
font.pointSize: Style.fontSizeSmall * scaling
color: Color.mOnSurface
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
RowLayout {
// Pipewire seems a bit finicky, if we spam too many volume changes it breaks easily
// Probably because they have some quick fades in and out to avoid clipping
// We use a timer to space out the updates, to avoid lock up
Timer {
interval: Style.animationFast
running: true
repeat: true
onTriggered: {
if (Math.abs(localVolume - AudioService.volume) >= 0.01) {
AudioService.setVolume(localVolume)
}
}
}
NSlider {
Layout.fillWidth: true
from: 0
to: Settings.data.audio.volumeOverdrive ? 2.0 : 1.0
value: localVolume
stepSize: 0.01
onMoved: {
localVolume = value
}
}
NText {
text: Math.floor(AudioService.volume * 100) + "%"
Layout.alignment: Qt.AlignVCenter
color: Color.mOnSurface
}
}
}
// Mute Toggle
ColumnLayout {
spacing: Style.marginSmall * scaling
Layout.fillWidth: true
Layout.topMargin: Style.marginMedium * scaling
NToggle {
label: "Mute AudioService"
description: "Mute or unmute the default audio output"
checked: AudioService.muted
onToggled: checked => {
if (AudioService.sink && AudioService.sink.audio) {
AudioService.sink.audio.muted = checked
}
}
}
}
}
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginLarge * 2 * scaling
Layout.bottomMargin: Style.marginLarge * scaling
}
// AudioService Devices
ColumnLayout {
spacing: Style.marginLarge * scaling
Layout.fillWidth: true
NText {
text: "Audio Devices"
font.pointSize: Style.fontSizeXL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.bottomMargin: Style.marginSmall * scaling
}
// -------------------------------
// Output Devices
ButtonGroup {
id: sinks
}
ColumnLayout {
spacing: Style.marginTiniest * scaling
Layout.fillWidth: true
Layout.bottomMargin: Style.marginLarge * scaling
NText {
text: "Output Device"
font.pointSize: Style.fontSizeMedium * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
}
NText {
text: "Select the desired audio output device"
font.pointSize: Style.fontSizeSmall * scaling
color: Color.mOnSurface
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
Repeater {
model: AudioService.sinks
NRadioButton {
required property PwNode modelData
ButtonGroup.group: sinks
checked: AudioService.sink?.id === modelData.id
onClicked: AudioService.setAudioSink(modelData)
text: modelData.description
}
}
}
}
// -------------------------------
// Input Devices
ButtonGroup {
id: sources
}
ColumnLayout {
spacing: Style.marginTiniest * scaling
Layout.fillWidth: true
Layout.bottomMargin: Style.marginLarge * scaling
NText {
text: "Input Device"
font.pointSize: Style.fontSizeMedium * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
}
NText {
text: "Select desired audio input device"
font.pointSize: Style.fontSizeSmall * scaling
color: Color.mOnSurface
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
Repeater {
model: AudioService.sources
NRadioButton {
required property PwNode modelData
ButtonGroup.group: sources
checked: AudioService.source?.id === modelData.id
onClicked: AudioService.setAudioSource(modelData)
text: modelData.description
}
}
}
}
// Divider
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginLarge * scaling
Layout.bottomMargin: Style.marginMedium * scaling
}
// AudioService Visualizer Category
ColumnLayout {
spacing: Style.marginSmall * scaling
Layout.fillWidth: true
NText {
text: "Audio Visualizer"
font.pointSize: Style.fontSizeXL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.bottomMargin: Style.marginSmall * scaling
}
// AudioService Visualizer section
NComboBox {
id: audioVisualizerCombo
label: "Visualization Type"
description: "Choose a visualization type for media playback"
model: ListModel {
ListElement {
key: "none"
name: "None"
}
ListElement {
key: "linear"
name: "Linear"
}
}
currentKey: Settings.data.audio.visualizerType
onSelected: function (key) {
Settings.data.audio.visualizerType = key
}
}
}
}
}
}

View file

@ -0,0 +1,90 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Services
import qs.Widgets
ColumnLayout {
id: root
spacing: 0
ScrollView {
id: scrollView
Layout.fillWidth: true
Layout.fillHeight: true
padding: Style.marginMedium * scaling
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ColumnLayout {
width: scrollView.availableWidth
spacing: 0
Item {
Layout.fillWidth: true
Layout.preferredHeight: 0
}
ColumnLayout {
spacing: Style.marginLarge * scaling
Layout.fillWidth: true
NText {
text: "Components"
font.pointSize: Style.fontSizeXL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
}
NToggle {
label: "Show Active Window"
description: "Display the title of the currently focused window on the left side of the bar"
checked: Settings.data.bar.showActiveWindow
onToggled: checked => {
Settings.data.bar.showActiveWindow = checked
}
}
NToggle {
label: "Show System Info"
description: "Display system statistics (CPU, RAM, Temperature)"
checked: Settings.data.bar.showSystemInfo
onToggled: checked => {
Settings.data.bar.showSystemInfo = checked
}
}
NToggle {
label: "Show Media"
description: "Display media controls and information"
checked: Settings.data.bar.showMedia
onToggled: checked => {
Settings.data.bar.showMedia = checked
}
}
NToggle {
label: "Show Notifications History"
description: "Display a shortcut to the notifications history"
checked: Settings.data.bar.showNotificationsHistory
onToggled: checked => {
Settings.data.bar.showNotificationsHistory = checked
}
}
NToggle {
label: "Show Applications Tray"
description: "Display the applications tray"
checked: Settings.data.bar.showTray
onToggled: checked => {
Settings.data.bar.showTray = checked
}
}
}
}
}
}

View file

@ -0,0 +1,228 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
Item {
property real scaling: 1
readonly property string tabIcon: "brightness_6"
readonly property string tabLabel: "Brightness"
Layout.fillWidth: true
Layout.fillHeight: true
ScrollView {
anchors.fill: parent
clip: true
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ScrollBar.horizontal.policy: ScrollBar.AsNeeded
contentWidth: parent.width
ColumnLayout {
width: parent.width
ColumnLayout {
spacing: Style.marginLarge * scaling
Layout.margins: Style.marginLarge * scaling
Layout.fillWidth: true
NText {
text: "Brightness Settings"
font.pointSize: Style.fontSizeXL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
}
NText {
text: "Configure brightness controls and monitor settings."
font.pointSize: Style.fontSize * scaling
color: Color.mOnSurfaceVariant
}
// Bar Visibility Section
ColumnLayout {
spacing: Style.marginSmall * scaling
Layout.fillWidth: true
Layout.topMargin: Style.marginLarge * scaling
NText {
text: "Bar Integration"
font.pointSize: Style.fontSizeLarge * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
}
NToggle {
label: "Show Brightness Icon"
description: "Display the brightness control icon in the top bar"
checked: Settings.data.bar.showBrightness
onToggled: checked => {
Settings.data.bar.showBrightness = checked
}
}
}
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginLarge * scaling
Layout.bottomMargin: Style.marginLarge * scaling
}
// Brightness Step Section
ColumnLayout {
spacing: Style.marginSmall * scaling
Layout.fillWidth: true
NText {
text: "Brightness Step Size"
font.pointSize: Style.fontSizeLarge * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
}
NText {
text: "Adjust the step size for brightness changes (scroll wheel, keyboard shortcuts)"
font.pointSize: Style.fontSizeSmall * scaling
color: Color.mOnSurfaceVariant
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
RowLayout {
Layout.fillWidth: true
spacing: Style.marginMedium * scaling
NSlider {
Layout.fillWidth: true
from: 1
to: 50
value: Settings.data.brightness.brightnessStep
stepSize: 1
onPressedChanged: {
if (!pressed) {
Settings.data.brightness.brightnessStep = value
}
}
}
NText {
text: Settings.data.brightness.brightnessStep + "%"
Layout.alignment: Qt.AlignVCenter
color: Color.mOnSurface
font.pointSize: Style.fontSizeMedium * scaling
font.weight: Style.fontWeightBold
}
}
}
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginLarge * scaling
Layout.bottomMargin: Style.marginLarge * scaling
}
// Monitor Overview Section
ColumnLayout {
spacing: Style.marginSmall * scaling
Layout.fillWidth: true
NText {
text: "Monitor Brightness Overview"
font.pointSize: Style.fontSizeLarge * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
}
NText {
text: "Current brightness levels for all detected monitors"
font.pointSize: Style.fontSizeSmall * scaling
color: Color.mOnSurfaceVariant
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
// Single monitor display using the same data source as the bar icon
Repeater {
model: BrightnessService.monitors
Rectangle {
Layout.fillWidth: true
radius: Style.radiusMedium * scaling
color: Color.mSurface
border.color: Color.mOutline
border.width: Math.max(1, Style.borderThin * scaling)
implicitHeight: contentCol.implicitHeight + Style.marginXL * 2 * scaling
ColumnLayout {
id: contentCol
anchors.fill: parent
anchors.margins: Style.marginLarge * scaling
spacing: Style.marginMedium * scaling
RowLayout {
Layout.fillWidth: true
spacing: Style.marginMedium * scaling
NText {
text: `${model.modelData.name} [${model.modelData.model}]`
font.pointSize: Style.fontSizeLarge * scaling
font.weight: Style.fontWeightBold
color: Color.mSecondary
}
Item {
Layout.fillWidth: true
}
NText {
text: model.method
font.pointSize: Style.fontSizeSmall * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignRight
}
}
RowLayout {
Layout.fillWidth: true
spacing: Style.marginMedium * scaling
NText {
text: "Brightness:"
font.pointSize: Style.fontSizeMedium * scaling
color: Color.mOnSurface
}
NSlider {
Layout.fillWidth: true
from: 0
to: 1
value: model.brightness
stepSize: 0.05
onPressedChanged: {
if (!pressed) {
var monitor = BrightnessService.getMonitorForScreen(model.modelData)
monitor.setBrightness(value)
}
}
}
NText {
text: Math.round(model.brightness * 100) + "%"
font.pointSize: Style.fontSizeMedium * scaling
font.weight: Style.fontWeightBold
color: Color.mPrimary
Layout.alignment: Qt.AlignRight
}
}
}
}
}
}
Item {
Layout.fillHeight: true
}
}
}
}
}

View file

@ -0,0 +1,338 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Services
import qs.Widgets
import Quickshell.Io
ColumnLayout {
id: root
spacing: 0
// Helper function to get color from scheme file
function getSchemeColor(schemePath, colorKey) {
// Extract scheme name from path
var schemeName = schemePath.split("/").pop().replace(".json", "")
// Try to get from cached data first
if (schemeColorsCache[schemeName] && schemeColorsCache[schemeName][colorKey]) {
return schemeColorsCache[schemeName][colorKey]
}
// Return a default color if not cached yet
return "#000000"
}
// Cache for scheme colors
property var schemeColorsCache: ({})
// Scale properties for card animations
property real cardScaleLow: 0.95
property real cardScaleHigh: 1.0
// This function is called by the FileView Repeater when a scheme file is loaded
function schemeLoaded(schemeName, jsonData) {
var colors = {}
// Extract colors from JSON data
if (jsonData && typeof jsonData === 'object') {
colors.mPrimary = jsonData.mPrimary || jsonData.primary || "#000000"
colors.mSecondary = jsonData.mSecondary || jsonData.secondary || "#000000"
colors.mTertiary = jsonData.mTertiary || jsonData.tertiary || "#000000"
colors.mError = jsonData.mError || jsonData.error || "#ff0000"
colors.mSurface = jsonData.mSurface || jsonData.surface || "#ffffff"
colors.mOnSurface = jsonData.mOnSurface || jsonData.onSurface || "#000000"
colors.mOutline = jsonData.mOutline || jsonData.outline || "#666666"
} else {
// Default colors on failure
colors = {
"mPrimary": "#000000",
"mSecondary": "#000000",
"mTertiary": "#000000",
"mError": "#ff0000",
"mSurface": "#ffffff",
"mOnSurface": "#000000",
"mOutline": "#666666"
}
}
// Update the cache. This must be done by re-assigning the whole object to trigger updates.
var newCache = schemeColorsCache
newCache[schemeName] = colors
schemeColorsCache = newCache
}
// When the list of available schemes changes, clear the cache.
// The Repeater below will automatically re-create the FileViews.
Connections {
target: ColorSchemeService
function onSchemesChanged() {
schemeColorsCache = {}
}
}
// A non-visual Item to host the Repeater that loads the color scheme files.
Item {
visible: false
id: fileLoaders
Repeater {
model: ColorSchemeService.schemes
// The delegate is a Component, which correctly wraps the non-visual FileView
delegate: Item {
FileView {
path: modelData
blockLoading: true
onLoaded: {
var schemeName = path.split("/").pop().replace(".json", "")
try {
var jsonData = JSON.parse(text())
root.schemeLoaded(schemeName, jsonData)
} catch (e) {
Logger.warn("ColorSchemeTab", "Failed to parse JSON for scheme:", schemeName, e)
root.schemeLoaded(schemeName, null) // Load defaults on parse error
}
}
}
}
}
}
// UI Code
ScrollView {
id: scrollView
Layout.fillWidth: true
Layout.fillHeight: true
padding: Style.marginMedium * scaling
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ColumnLayout {
width: scrollView.availableWidth
spacing: 0
Item {
Layout.fillWidth: true
Layout.preferredHeight: 0
}
ColumnLayout {
spacing: Style.marginLarge * scaling
Layout.fillWidth: true
// Use Matugen
NToggle {
label: "Use Matugen"
description: "Automatically generate colors based on your active wallpaper using Matugen"
checked: Settings.data.colorSchemes.useWallpaperColors
onToggled: checked => {
Settings.data.colorSchemes.useWallpaperColors = checked
if (Settings.data.colorSchemes.useWallpaperColors) {
ColorSchemeService.changedWallpaper()
}
}
}
// Dark Mode Toggle
NToggle {
label: "Dark Mode"
description: "Generate dark theme colors when using Matugen. Disable for light theme."
checked: Settings.data.colorSchemes.darkMode
enabled: Settings.data.colorSchemes.useWallpaperColors
onToggled: checked => {
Settings.data.colorSchemes.darkMode = checked
if (Settings.data.colorSchemes.useWallpaperColors) {
ColorSchemeService.changedWallpaper()
}
}
}
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginLarge * scaling
Layout.bottomMargin: Style.marginLarge * scaling
}
ColumnLayout {
spacing: Style.marginTiniest * scaling
Layout.fillWidth: true
NText {
text: "Predefined Color Schemes"
font.pointSize: Style.fontSizeLarge * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.fillWidth: true
}
NText {
text: "These color schemes only apply when 'Use Matugen' is disabled. When enabled, Matugen will generate colors based on your wallpaper instead. You can toggle between light and dark themes when using Matugen."
font.pointSize: Style.fontSizeSmall * scaling
color: Color.mOnSurface
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
ColumnLayout {
spacing: Style.marginTiny * scaling
Layout.fillWidth: true
Layout.topMargin: Style.marginLarge * scaling
// Color Schemes Grid
GridLayout {
columns: 4
rowSpacing: Style.marginLarge * scaling
columnSpacing: Style.marginLarge * scaling
Layout.fillWidth: true
Repeater {
model: ColorSchemeService.schemes
Rectangle {
id: schemeCard
property string schemePath: modelData
Layout.fillWidth: true
Layout.preferredHeight: 120 * scaling
radius: Style.radiusMedium * scaling
color: getSchemeColor(modelData, "mSurface")
border.width: Math.max(1, Style.borderThick * scaling)
border.color: Settings.data.colorSchemes.predefinedScheme === modelData ? Color.mPrimary : Color.mOutline
scale: root.cardScaleLow
// Mouse area for selection
MouseArea {
anchors.fill: parent
onClicked: {
// Disable useWallpaperColors when picking a predefined color scheme
Settings.data.colorSchemes.useWallpaperColors = false
Settings.data.colorSchemes.predefinedScheme = schemePath
ColorSchemeService.applyScheme(schemePath)
}
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: {
schemeCard.scale = root.cardScaleHigh
}
onExited: {
schemeCard.scale = root.cardScaleLow
}
}
// Card content
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginXL * scaling
spacing: Style.marginSmall * scaling
// Scheme name
NText {
text: {
// Remove json and the full path
var chunks = schemePath.replace(".json", "").split("/")
return chunks[chunks.length - 1]
}
font.pointSize: Style.fontSizeMedium * scaling
font.weight: Style.fontWeightBold
color: getSchemeColor(modelData, "mOnSurface")
Layout.fillWidth: true
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
}
// Color swatches
RowLayout {
spacing: Style.marginSmall * scaling
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
// Primary color swatch
Rectangle {
width: 28 * scaling
height: 28 * scaling
radius: width * 0.5
color: getSchemeColor(modelData, "mPrimary")
}
// Secondary color swatch
Rectangle {
width: 28 * scaling
height: 28 * scaling
radius: width * 0.5
color: getSchemeColor(modelData, "mSecondary")
}
// Tertiary color swatch
Rectangle {
width: 28 * scaling
height: 28 * scaling
radius: width * 0.5
color: getSchemeColor(modelData, "mTertiary")
}
// Error color swatch
Rectangle {
width: 28 * scaling
height: 28 * scaling
radius: width * 0.5
color: getSchemeColor(modelData, "mError")
}
}
}
// Selection indicator
Rectangle {
visible: Settings.data.colorSchemes.predefinedScheme === schemePath
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.marginSmall * scaling
width: 24 * scaling
height: 24 * scaling
radius: width * 0.5
color: Color.mPrimary
NText {
anchors.centerIn: parent
text: "✓"
font.pointSize: Style.fontSizeSmall * scaling
font.weight: Style.fontWeightBold
color: Color.mOnPrimary
}
}
// Smooth animations
Behavior on scale {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutCubic
}
}
Behavior on border.color {
ColorAnimation {
duration: Style.animationNormal
}
}
Behavior on border.width {
NumberAnimation {
duration: Style.animationFast
}
}
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,141 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
Item {
property real scaling: 1
readonly property string tabIcon: "monitor"
readonly property string tabLabel: "Display"
readonly property int tabIndex: 5
Layout.fillWidth: true
Layout.fillHeight: true
// Helper functions to update arrays immutably
function addMonitor(list, name) {
const arr = (list || []).slice()
if (!arr.includes(name))
arr.push(name)
return arr
}
function removeMonitor(list, name) {
return (list || []).filter(function (n) {
return n !== name
})
}
ScrollView {
anchors.fill: parent
clip: true
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ScrollBar.horizontal.policy: ScrollBar.AsNeeded
contentWidth: parent.width
ColumnLayout {
width: parent.width
ColumnLayout {
spacing: Style.marginLarge * scaling
Layout.margins: Style.marginLarge * scaling
Layout.fillWidth: true
NText {
text: "Permonitor configuration"
font.pointSize: Style.fontSizeXL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
}
NText {
text: "By default, bars and notifications are shown on all displays. Select one or more below to narrow your view."
font.pointSize: Style.fontSize * scaling
color: Color.mOnSurfaceVariant
}
Repeater {
model: Quickshell.screens || []
delegate: Rectangle {
Layout.fillWidth: true
radius: Style.radiusMedium * scaling
color: Color.mSurface
border.color: Color.mOutline
border.width: Math.max(1, Style.borderThin * scaling)
implicitHeight: contentCol.implicitHeight + Style.marginXL * 2 * scaling
ColumnLayout {
id: contentCol
anchors.fill: parent
anchors.margins: Style.marginLarge * scaling
spacing: Style.marginTiniest * scaling
NText {
text: (modelData.name || "Unknown")
font.pointSize: Style.fontSizeLarge * scaling
font.weight: Style.fontWeightBold
color: Color.mSecondary
}
NText {
text: `Resolution: ${modelData.width}x${modelData.height} - Position: (${modelData.x}, ${modelData.y})`
font.pointSize: Style.fontSizeSmall * scaling
color: Color.mOnSurface
}
ColumnLayout {
spacing: Style.marginLarge * scaling
NToggle {
label: "Bar"
description: "Enable the top bar on this monitor"
checked: (Settings.data.bar.monitors || []).indexOf(modelData.name) !== -1
onToggled: checked => {
if (checked) {
Settings.data.bar.monitors = addMonitor(Settings.data.bar.monitors, modelData.name)
} else {
Settings.data.bar.monitors = removeMonitor(Settings.data.bar.monitors, modelData.name)
}
}
}
NToggle {
label: "Notifications"
description: "Enable notifications on this monitor"
checked: (Settings.data.notifications.monitors || []).indexOf(modelData.name) !== -1
onToggled: checked => {
if (checked) {
Settings.data.notifications.monitors = addMonitor(
Settings.data.notifications.monitors, modelData.name)
} else {
Settings.data.notifications.monitors = removeMonitor(
Settings.data.notifications.monitors, modelData.name)
}
}
}
NToggle {
label: "Dock"
description: "Enable the dock on this monitor"
checked: (Settings.data.dock.monitors || []).indexOf(modelData.name) !== -1
onToggled: checked => {
if (checked) {
Settings.data.dock.monitors = addMonitor(Settings.data.dock.monitors, modelData.name)
} else {
Settings.data.dock.monitors = removeMonitor(Settings.data.dock.monitors,
modelData.name)
}
}
}
}
}
}
}
Item {
Layout.fillHeight: true
}
}
}
}
}

View file

@ -0,0 +1,124 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Services
import qs.Widgets
ColumnLayout {
id: root
spacing: 0
ScrollView {
id: scrollView
Layout.fillWidth: true
Layout.fillHeight: true
padding: Style.marginMedium * scaling
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ColumnLayout {
width: scrollView.availableWidth
spacing: 0
Item {
Layout.fillWidth: true
Layout.preferredHeight: 0
}
ColumnLayout {
spacing: Style.marginLarge * scaling
Layout.fillWidth: true
NText {
text: "General Settings"
font.pointSize: Style.fontSizeXL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
}
// Profile section
ColumnLayout {
spacing: Style.marginSmall * scaling
Layout.fillWidth: true
Layout.topMargin: Style.marginSmall * scaling
RowLayout {
Layout.fillWidth: true
spacing: Style.marginLarge * scaling
// Avatar preview
NImageRounded {
width: 64 * scaling
height: 64 * scaling
imagePath: Settings.data.general.avatarImage
fallbackIcon: "person"
borderColor: Color.mPrimary
borderWidth: Math.max(1, Style.borderMedium)
}
NTextInput {
label: "Profile Picture"
description: "Your profile picture displayed in various places throughout the shell"
text: Settings.data.general.avatarImage
placeholderText: "/home/user/.face"
Layout.fillWidth: true
onEditingFinished: {
Settings.data.general.avatarImage = text
}
}
}
}
}
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginLarge * 2 * scaling
Layout.bottomMargin: Style.marginLarge * scaling
}
ColumnLayout {
spacing: Style.marginLarge * scaling
Layout.fillWidth: true
NText {
text: "User Interface"
font.pointSize: Style.fontSizeXL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.bottomMargin: Style.marginSmall * scaling
}
NToggle {
label: "Show Corners"
description: "Display rounded corners on the edge of the screen"
checked: Settings.data.general.showScreenCorners
onToggled: checked => {
Settings.data.general.showScreenCorners = checked
}
}
NToggle {
label: "Dim Desktop"
description: "Dim the desktop when panels or menus are open"
checked: Settings.data.general.dimDesktop
onToggled: checked => {
Settings.data.general.dimDesktop = checked
}
}
NToggle {
label: "Auto-hide Dock"
description: "Automatically hide the dock when not in use"
checked: Settings.data.dock.autoHide
onToggled: checked => {
Settings.data.dock.autoHide = checked
}
}
}
}
}
}

View file

@ -0,0 +1,67 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Bluetooth
import qs.Commons
import qs.Services
import qs.Widgets
ColumnLayout {
id: root
spacing: 0
ScrollView {
id: scrollView
Layout.fillWidth: true
Layout.fillHeight: true
padding: Style.marginMedium * scaling
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ColumnLayout {
width: scrollView.availableWidth
spacing: 0
Item {
Layout.fillWidth: true
Layout.preferredHeight: 0
}
ColumnLayout {
spacing: Style.marginLarge * scaling
Layout.fillWidth: true
NText {
text: "Interfaces"
font.pointSize: Style.fontSizeXL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
}
NToggle {
label: "WiFi Enabled"
description: "Enable WiFi connectivity"
checked: Settings.data.network.wifiEnabled
onToggled: checked => {
Settings.data.network.wifiEnabled = checked
NetworkService.setWifiEnabled(checked)
}
}
NToggle {
label: "Bluetooth Enabled"
description: "Enable Bluetooth connectivity"
checked: Settings.data.network.bluetoothEnabled
onToggled: checked => {
Settings.data.network.bluetoothEnabled = checked
BluetoothService.setBluetoothEnabled(checked)
}
}
}
}
}
}

View file

@ -0,0 +1,270 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Services
import qs.Widgets
ColumnLayout {
id: root
spacing: 0
ScrollView {
id: scrollView
Layout.fillWidth: true
Layout.fillHeight: true
padding: Style.marginMedium * scaling
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ColumnLayout {
width: scrollView.availableWidth
spacing: 0
Item {
Layout.fillWidth: true
Layout.preferredHeight: 0
}
ColumnLayout {
spacing: Style.marginTiny * scaling
Layout.fillWidth: true
NText {
text: "Recording"
font.pointSize: Style.fontSizeXL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.bottomMargin: Style.marginSmall * scaling
}
// Output Directory
ColumnLayout {
spacing: Style.marginSmall * scaling
Layout.fillWidth: true
Layout.topMargin: Style.marginSmall * scaling
NTextInput {
label: "Output Directory"
description: "Directory where screen recordings will be saved"
placeholderText: "/home/xxx/Videos"
text: Settings.data.screenRecorder.directory
onEditingFinished: {
Settings.data.screenRecorder.directory = text
}
}
ColumnLayout {
spacing: Style.marginSmall * scaling
Layout.fillWidth: true
Layout.topMargin: Style.marginMedium * scaling
// Show Cursor
NToggle {
label: "Show Cursor"
description: "Record mouse cursor in the video"
checked: Settings.data.screenRecorder.showCursor
onToggled: checked => {
Settings.data.screenRecorder.showCursor = checked
}
}
}
}
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginLarge * 2 * scaling
Layout.bottomMargin: Style.marginLarge * scaling
}
// Video Settings
ColumnLayout {
spacing: Style.marginLarge * scaling
Layout.fillWidth: true
NText {
text: "Video Settings"
font.pointSize: Style.fontSizeXL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.bottomMargin: Style.marginSmall * scaling
}
// Frame Rate
NComboBox {
label: "Frame Rate"
description: "Target frame rate for screen recordings (default: 60)"
model: ListModel {
ListElement {
key: "30"
name: "30 FPS"
}
ListElement {
key: "60"
name: "60 FPS"
}
ListElement {
key: "120"
name: "120 FPS"
}
ListElement {
key: "240"
name: "240 FPS"
}
}
currentKey: Settings.data.screenRecorder.frameRate
onSelected: function (key) {
Settings.data.screenRecorder.frameRate = key
}
}
// Video Quality
NComboBox {
label: "Video Quality"
description: "Higher quality results in larger file sizes"
model: ListModel {
ListElement {
key: "medium"
name: "Medium"
}
ListElement {
key: "high"
name: "High"
}
ListElement {
key: "very_high"
name: "Very High"
}
ListElement {
key: "ultra"
name: "Ultra"
}
}
currentKey: Settings.data.screenRecorder.quality
onSelected: function (key) {
Settings.data.screenRecorder.quality = key
}
}
// Video Codec
NComboBox {
label: "Video Codec"
description: "Different codecs offer different compression and compatibility"
model: ListModel {
ListElement {
key: "h264"
name: "H264"
}
ListElement {
key: "hevc"
name: "HEVC"
}
ListElement {
key: "av1"
name: "AV1"
}
ListElement {
key: "vp8"
name: "VP8"
}
ListElement {
key: "vp9"
name: "VP9"
}
}
currentKey: Settings.data.screenRecorder.videoCodec
onSelected: function (key) {
Settings.data.screenRecorder.videoCodec = key
}
}
// Color Range
NComboBox {
label: "Color Range"
description: "Limited is recommended for better compatibility"
model: ListModel {
ListElement {
key: "limited"
name: "Limited"
}
ListElement {
key: "full"
name: "Full"
}
}
currentKey: Settings.data.screenRecorder.colorRange
onSelected: function (key) {
Settings.data.screenRecorder.colorRange = key
}
}
}
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginLarge * 2 * scaling
Layout.bottomMargin: Style.marginLarge * scaling
}
// Audio Settings
ColumnLayout {
spacing: Style.marginLarge * scaling
Layout.fillWidth: true
NText {
text: "Audio Settings"
font.pointSize: Style.fontSizeXL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.bottomMargin: Style.marginSmall * scaling
}
// Audio Source
NComboBox {
label: "Audio Source"
description: "Audio source to capture during recording"
model: ListModel {
ListElement {
key: "default_output"
name: "System Output"
}
ListElement {
key: "default_input"
name: "Microphone Input"
}
ListElement {
key: "both"
name: "System Output + Microphone Input"
}
}
currentKey: Settings.data.screenRecorder.audioSource
onSelected: function (key) {
Settings.data.screenRecorder.audioSource = key
}
}
// Audio Codec
NComboBox {
label: "Audio Codec"
description: "Opus is recommended for best performance and smallest audio size"
model: ListModel {
ListElement {
key: "opus"
name: "Opus"
}
ListElement {
key: "aac"
name: "AAC"
}
}
currentKey: Settings.data.screenRecorder.audioCodec
onSelected: function (key) {
Settings.data.screenRecorder.audioCodec = key
}
}
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show more