mirror of
https://github.com/belsabbagh/dotfiles.git
synced 2026-04-11 09:36:46 +00:00
quickshell and hyprland additions
This commit is contained in:
168
.config/quickshell/nucleus-shell/services/AppRegistry.qml
Normal file
168
.config/quickshell/nucleus-shell/services/AppRegistry.qml
Normal file
@@ -0,0 +1,168 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.modules.functions
|
||||
import qs.config
|
||||
|
||||
/*
|
||||
This registry is only used to get app details for wm classes.
|
||||
*/
|
||||
|
||||
Singleton {
|
||||
id: registry
|
||||
|
||||
property var apps: []
|
||||
property var classToIcon: ({})
|
||||
property var desktopIdToIcon: ({})
|
||||
property var nameToIcon: ({})
|
||||
|
||||
signal ready()
|
||||
|
||||
function iconForDesktopIcon(icon) {
|
||||
if (!icon) return ""
|
||||
|
||||
// If it's already a URL, keep it
|
||||
if (icon.startsWith("file://") || icon.startsWith("qrc:/"))
|
||||
return icon
|
||||
|
||||
// Absolute filesystem path → convert to file URL
|
||||
if (icon.startsWith("/"))
|
||||
return "file://" + icon
|
||||
|
||||
// Otherwise treat as theme icon name
|
||||
return Quickshell.iconPath(icon)
|
||||
}
|
||||
|
||||
// Try very aggressive matching so the running app always gets the same icon as launcher
|
||||
function iconForClass(id) {
|
||||
if (!id) return ""
|
||||
|
||||
const lower = id.toLowerCase()
|
||||
|
||||
// direct hits first
|
||||
if (classToIcon[lower])
|
||||
return iconForDesktopIcon(classToIcon[lower])
|
||||
|
||||
if (desktopIdToIcon[lower])
|
||||
return iconForDesktopIcon(desktopIdToIcon[lower])
|
||||
|
||||
if (nameToIcon[lower])
|
||||
return iconForDesktopIcon(nameToIcon[lower])
|
||||
|
||||
// fuzzy contains match against wmClass map
|
||||
for (let key in classToIcon) {
|
||||
if (lower.includes(key) || key.includes(lower))
|
||||
return iconForDesktopIcon(classToIcon[key])
|
||||
}
|
||||
|
||||
// fuzzy against desktop ids
|
||||
for (let key in desktopIdToIcon) {
|
||||
if (lower.includes(key) || key.includes(lower))
|
||||
return iconForDesktopIcon(desktopIdToIcon[key])
|
||||
}
|
||||
|
||||
// fuzzy against names
|
||||
for (let key in nameToIcon) {
|
||||
if (lower.includes(key) || key.includes(lower))
|
||||
return iconForDesktopIcon(nameToIcon[key])
|
||||
}
|
||||
|
||||
// final fallback to theme resolution
|
||||
const resolved = FileUtils.resolveIcon(id)
|
||||
return iconForDesktopIcon(resolved)
|
||||
}
|
||||
|
||||
// Extra helper: resolve icon using any metadata we might have (Hyprland, Niri, etc.)
|
||||
function iconForAppMeta(meta) {
|
||||
if (!meta) return Quickshell.iconPath("application-x-executable")
|
||||
|
||||
const candidates = [
|
||||
meta.appId,
|
||||
meta.class,
|
||||
meta.initialClass,
|
||||
meta.desktopId,
|
||||
meta.title,
|
||||
meta.name
|
||||
]
|
||||
|
||||
for (let c of candidates) {
|
||||
const icon = iconForClass(c)
|
||||
if (icon !== "")
|
||||
return icon
|
||||
}
|
||||
|
||||
// fallback: try compositor provided icon name
|
||||
if (meta.icon)
|
||||
return iconForDesktopIcon(meta.icon)
|
||||
|
||||
// hard fallback icons (guaranteed to exist in most themes)
|
||||
const fallbacks = [
|
||||
"application-x-executable",
|
||||
"application-default-icon",
|
||||
"window"
|
||||
]
|
||||
|
||||
for (let f of fallbacks) {
|
||||
const resolved = Quickshell.iconPath(f)
|
||||
if (resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
function registerApp(displayName, comment, icon, exec, wmClass, desktopId) {
|
||||
const entry = {
|
||||
name: displayName,
|
||||
comment: comment,
|
||||
icon: icon,
|
||||
exec: exec,
|
||||
wmClass: wmClass,
|
||||
desktopId: desktopId
|
||||
}
|
||||
|
||||
apps.push(entry)
|
||||
|
||||
if (wmClass)
|
||||
classToIcon[wmClass.toLowerCase()] = icon
|
||||
|
||||
if (desktopId)
|
||||
desktopIdToIcon[desktopId.toLowerCase()] = icon
|
||||
|
||||
if (displayName)
|
||||
nameToIcon[displayName.toLowerCase()] = icon
|
||||
|
||||
// Hard aliases for apps with messy WM_CLASS values
|
||||
if (displayName.toLowerCase().includes("visual studio code") ||
|
||||
icon.toLowerCase().includes("code")) {
|
||||
|
||||
classToIcon["code"] = icon
|
||||
classToIcon["code-oss"] = icon
|
||||
classToIcon["code-url-handler"] = icon
|
||||
desktopIdToIcon["code.desktop"] = icon
|
||||
desktopIdToIcon["code-oss.desktop"] = icon
|
||||
}
|
||||
}
|
||||
|
||||
function buildRegistry() {
|
||||
const entries = DesktopEntries.applications.values
|
||||
|
||||
for (let entry of entries) {
|
||||
if (entry.noDisplay)
|
||||
continue
|
||||
|
||||
registry.registerApp(
|
||||
entry.name || "",
|
||||
entry.comment || "",
|
||||
entry.icon || "",
|
||||
entry.execString || "",
|
||||
entry.startupWMClass || "",
|
||||
entry.id || ""
|
||||
)
|
||||
}
|
||||
|
||||
registry.ready()
|
||||
}
|
||||
|
||||
Component.onCompleted: buildRegistry()
|
||||
}
|
||||
30
.config/quickshell/nucleus-shell/services/Apps.qml
Normal file
30
.config/quickshell/nucleus-shell/services/Apps.qml
Normal file
@@ -0,0 +1,30 @@
|
||||
pragma Singleton
|
||||
|
||||
import "../modules/functions/fuzzy/fuzzysort.js" as Fuzzy
|
||||
import Quickshell
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property list<DesktopEntry> list: DesktopEntries.applications.values.filter(a => !a.noDisplay).sort((a, b) => a.name.localeCompare(b.name))
|
||||
readonly property list<var> preppedApps: list.map(a => ({
|
||||
name: Fuzzy.prepare(a.name),
|
||||
comment: Fuzzy.prepare(a.comment),
|
||||
entry: a
|
||||
}))
|
||||
|
||||
function fuzzyQuery(search: string): var { // idk why list<DesktopEntry> doesn't work
|
||||
return Fuzzy.go(search, preppedApps, {
|
||||
all: true,
|
||||
keys: ["name", "comment"],
|
||||
scoreFn: r => r[0].score > 0 ? r[0].score * 0.9 + r[1].score * 0.1 : 0
|
||||
}).map(r => r.obj.entry);
|
||||
}
|
||||
|
||||
function launch(entry: DesktopEntry): void {
|
||||
if (entry.execString.startsWith("sh -c"))
|
||||
Quickshell.execDetached(["sh", "-c", `app2unit -- ${entry.execString}`]);
|
||||
else
|
||||
Quickshell.execDetached(["sh", "-c", `app2unit -- '${entry.id}.desktop' || app2unit -- ${entry.execString}`]);
|
||||
}
|
||||
}
|
||||
24
.config/quickshell/nucleus-shell/services/Bluetooth.qml
Normal file
24
.config/quickshell/nucleus-shell/services/Bluetooth.qml
Normal file
@@ -0,0 +1,24 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Bluetooth
|
||||
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
readonly property BluetoothAdapter defaultAdapter: Bluetooth.defaultAdapter
|
||||
readonly property list<BluetoothDevice> devices: defaultAdapter?.devices?.values ?? []
|
||||
readonly property BluetoothDevice activeDevice: devices.find(d => d.connected) ?? null
|
||||
readonly property string icon: {
|
||||
if (!defaultAdapter?.enabled)
|
||||
return "bluetooth_disabled"
|
||||
|
||||
if (activeDevice)
|
||||
return "bluetooth_connected"
|
||||
|
||||
return defaultAdapter.discovering
|
||||
? "bluetooth_searching"
|
||||
: "bluetooth"
|
||||
}
|
||||
|
||||
}
|
||||
142
.config/quickshell/nucleus-shell/services/Brightness.qml
Executable file
142
.config/quickshell/nucleus-shell/services/Brightness.qml
Executable file
@@ -0,0 +1,142 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Hyprland
|
||||
import QtQuick
|
||||
|
||||
// from github.com/end-4/dots-hyprland
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
signal brightnessChanged()
|
||||
|
||||
property var ddcMonitors: []
|
||||
readonly property list<BrightnessMonitor> monitors: Quickshell.screens.map(screen => monitorComp.createObject(root, { screen }))
|
||||
|
||||
function getMonitorForScreen(screen: ShellScreen): var {
|
||||
return monitors.find(m => m.screen === screen)
|
||||
}
|
||||
|
||||
function increaseBrightness(): void {
|
||||
const focusedName = Hyprland.focusedMonitor.name
|
||||
const monitor = monitors.find(m => focusedName === m.screen.name)
|
||||
if (monitor)
|
||||
monitor.setBrightness(monitor.brightness + 0.05)
|
||||
}
|
||||
|
||||
function decreaseBrightness(): void {
|
||||
const focusedName = Hyprland.focusedMonitor.name
|
||||
const monitor = monitors.find(m => focusedName === m.screen.name)
|
||||
if (monitor)
|
||||
monitor.setBrightness(monitor.brightness - 0.05)
|
||||
}
|
||||
|
||||
reloadableId: "brightness"
|
||||
|
||||
onMonitorsChanged: {
|
||||
ddcMonitors = []
|
||||
ddcProc.running = true
|
||||
}
|
||||
|
||||
Process {
|
||||
id: ddcProc
|
||||
command: ["ddcutil", "detect", "--brief"]
|
||||
stdout: SplitParser {
|
||||
splitMarker: "\n\n"
|
||||
onRead: data => {
|
||||
if (data.startsWith("Display ")) {
|
||||
const lines = data.split("\n").map(l => l.trim())
|
||||
root.ddcMonitors.push({
|
||||
model: lines.find(l => l.startsWith("Monitor:")).split(":")[2],
|
||||
busNum: lines.find(l => l.startsWith("I2C bus:")).split("/dev/i2c-")[1]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: root.ddcMonitorsChanged()
|
||||
}
|
||||
|
||||
Process { id: setProc }
|
||||
|
||||
component BrightnessMonitor: QtObject {
|
||||
id: monitor
|
||||
|
||||
required property ShellScreen screen
|
||||
|
||||
readonly property bool isDdc: {
|
||||
const match = root.ddcMonitors.find(m => m.model === screen.model &&
|
||||
!root.monitors.slice(0, root.monitors.indexOf(this))
|
||||
.some(mon => mon.busNum === m.busNum))
|
||||
return !!match
|
||||
}
|
||||
|
||||
readonly property string busNum: {
|
||||
const match = root.ddcMonitors.find(m => m.model === screen.model &&
|
||||
!root.monitors.slice(0, root.monitors.indexOf(this))
|
||||
.some(mon => mon.busNum === m.busNum))
|
||||
return match?.busNum ?? ""
|
||||
}
|
||||
|
||||
property int rawMaxBrightness: 100
|
||||
property real brightness
|
||||
property real brightnessMultiplier: 1.0
|
||||
property real multipliedBrightness: Math.max(0, Math.min(1, brightness * brightnessMultiplier))
|
||||
property bool ready: false
|
||||
property bool animateChanges: !monitor.isDdc
|
||||
|
||||
onBrightnessChanged: {
|
||||
if (!monitor.ready) return
|
||||
root.brightnessChanged()
|
||||
if (monitor.animateChanges)
|
||||
syncBrightness()
|
||||
else
|
||||
setTimer.restart()
|
||||
}
|
||||
|
||||
property var setTimer: Timer {
|
||||
id: setTimer
|
||||
interval: monitor.isDdc ? 300 : 0
|
||||
onTriggered: syncBrightness()
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
monitor.ready = false
|
||||
initProc.command = isDdc
|
||||
? ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"]
|
||||
: ["sh", "-c", `echo "a b c $(brightnessctl g) $(brightnessctl m)"`]
|
||||
initProc.running = true
|
||||
}
|
||||
|
||||
readonly property Process initProc: Process {
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
const [, , , current, max] = data.split(" ")
|
||||
monitor.rawMaxBrightness = parseInt(max)
|
||||
monitor.brightness = parseInt(current) / monitor.rawMaxBrightness
|
||||
monitor.ready = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncBrightness() {
|
||||
const brightnessValue = Math.max(Math.min(monitor.multipliedBrightness, 1), 0)
|
||||
const rawValueRounded = Math.max(Math.floor(brightnessValue * monitor.rawMaxBrightness), 1)
|
||||
setProc.command = isDdc
|
||||
? ["ddcutil", "-b", busNum, "setvcp", "10", rawValueRounded]
|
||||
: ["brightnessctl", "set", rawValueRounded.toString()]
|
||||
setProc.startDetached()
|
||||
}
|
||||
|
||||
function setBrightness(value: real): void {
|
||||
value = Math.max(0, Math.min(1, value))
|
||||
monitor.brightness = value
|
||||
}
|
||||
|
||||
Component.onCompleted: initialize()
|
||||
onBusNumChanged: initialize()
|
||||
}
|
||||
|
||||
Component { id: monitorComp; BrightnessMonitor {} }
|
||||
}
|
||||
89
.config/quickshell/nucleus-shell/services/Compositor.qml
Normal file
89
.config/quickshell/nucleus-shell/services/Compositor.qml
Normal file
@@ -0,0 +1,89 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Io
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// compositor stuff
|
||||
property string detectedCompositor: ""
|
||||
|
||||
readonly property var backend: {
|
||||
if (detectedCompositor === "niri")
|
||||
return Niri
|
||||
if (detectedCompositor === "hyprland")
|
||||
return Hyprland
|
||||
return null
|
||||
}
|
||||
|
||||
function require(compositors) { // This function can be effectively used to detect check requirements for a feature (also supports multiple compositors)
|
||||
if (Array.isArray(compositors)) {
|
||||
return compositors.includes(detectedCompositor);
|
||||
}
|
||||
return compositors === detectedCompositor;
|
||||
}
|
||||
|
||||
// Unified api
|
||||
property string title: backend?.title ?? ""
|
||||
property bool isFullscreen: backend?.isFullscreen ?? false
|
||||
property string layout: backend?.layout ?? "Tiled"
|
||||
property int focusedWorkspaceId: backend?.focusedWorkspaceId ?? 1
|
||||
property var workspaces: backend?.workspaces ?? []
|
||||
property var windowList: backend?.windowList ?? []
|
||||
property bool initialized: backend?.initialized ?? true
|
||||
property int workspaceCount: backend?.workspaceCount ?? 0
|
||||
property real screenW: backend?.screenW ?? 0
|
||||
property real screenH: backend?.screenH ?? 0
|
||||
property real screenScale: backend?.screenScale ?? 1
|
||||
readonly property Toplevel activeToplevel: ToplevelManager.activeToplevel
|
||||
|
||||
function changeWorkspace(id) {
|
||||
backend?.changeWorkspace?.(id)
|
||||
}
|
||||
|
||||
function changeWorkspaceRelative(delta) {
|
||||
backend?.changeWorkspaceRelative?.(delta)
|
||||
}
|
||||
|
||||
function isWorkspaceOccupied(id) {
|
||||
return backend?.isWorkspaceOccupied?.(id) ?? false
|
||||
}
|
||||
|
||||
function focusedWindowForWorkspace(id) {
|
||||
return backend?.focusedWindowForWorkspace?.(id) ?? null
|
||||
}
|
||||
|
||||
// process to detect compositor
|
||||
Process {
|
||||
command: ["sh", "-c", "echo \"$XDG_CURRENT_DESKTOP $XDG_SESSION_DESKTOP\""]
|
||||
running: true
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
if (!data)
|
||||
return
|
||||
|
||||
const val = data.trim().toLowerCase()
|
||||
|
||||
if (val.includes("hyprland")) {
|
||||
root.detectedCompositor = "hyprland"
|
||||
} else if (val.includes("niri")) {
|
||||
root.detectedCompositor = "niri"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
signal stateChanged()
|
||||
|
||||
Connections {
|
||||
target: backend
|
||||
function onStateChanged() {
|
||||
root.stateChanged()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
34
.config/quickshell/nucleus-shell/services/ConfigResolver.qml
Normal file
34
.config/quickshell/nucleus-shell/services/ConfigResolver.qml
Normal file
@@ -0,0 +1,34 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
pragma Singleton
|
||||
|
||||
|
||||
/*
|
||||
|
||||
This service primarily resolves configs for widgets that are customizable per monitor.
|
||||
|
||||
*/
|
||||
|
||||
|
||||
Singleton {
|
||||
|
||||
function bar(displayName) {
|
||||
const displays = Config.runtime.monitors;
|
||||
const fallback = Config.runtime.bar;
|
||||
if (!displays || !displays[displayName] || !displays[displayName].bar || displayName === "")
|
||||
return fallback;
|
||||
|
||||
return displays[displayName].bar;
|
||||
}
|
||||
|
||||
function getBarConfigurableHandle(displayName) { // returns prefField string
|
||||
const displays = Config.runtime.monitors;
|
||||
|
||||
if (!displays || !displays[displayName] || !displays[displayName].bar || displayName === "")
|
||||
return "bar";
|
||||
|
||||
return "monitors." + displayName + ".bar";
|
||||
}
|
||||
|
||||
}
|
||||
69
.config/quickshell/nucleus-shell/services/Contracts.qml
Normal file
69
.config/quickshell/nucleus-shell/services/Contracts.qml
Normal file
@@ -0,0 +1,69 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
import qs.config
|
||||
|
||||
QtObject {
|
||||
// Power menu
|
||||
property url powerMenu: Qt.resolvedUrl("../modules/interface/powermenu/Powermenu.qml")
|
||||
property bool overriddenPowerMenu: false
|
||||
function overridePowerMenu() {
|
||||
overriddenPowerMenu = true
|
||||
}
|
||||
|
||||
// Bar
|
||||
property url bar: Qt.resolvedUrl("../modules/interface/bar/Bar.qml")
|
||||
property bool overriddenBar: false
|
||||
function overrideBar() {
|
||||
overriddenBar = true
|
||||
}
|
||||
|
||||
// App launcher
|
||||
property url launcher: Qt.resolvedUrl("../modules/interface/launcher/Launcher.qml")
|
||||
property bool overriddenLauncher: false
|
||||
function overrideLauncher() {
|
||||
overriddenLauncher = true
|
||||
}
|
||||
|
||||
// Lock screen
|
||||
property url lockScreen: Qt.resolvedUrl("../modules/interface/lockscreen/LockScreen.qml")
|
||||
property bool overriddenLockScreen: false
|
||||
function overrideLockScreen() {
|
||||
overriddenLockScreen = true
|
||||
}
|
||||
|
||||
// Desktop background / wallpaper handler
|
||||
property url background: Qt.resolvedUrl("../modules/interface/background/Background.qml")
|
||||
property bool overriddenBackground: false
|
||||
function overrideBackground() {
|
||||
overriddenBackground = true
|
||||
}
|
||||
|
||||
// Notifications UI
|
||||
property url notifications: Qt.resolvedUrl("../modules/interface/notifications/Notifications.qml")
|
||||
property bool overriddenNotifications: false
|
||||
function overrideNotifications() {
|
||||
overriddenNotifications = true
|
||||
}
|
||||
|
||||
// Global overlays (OSD, volume, brightness, etc.)
|
||||
property url overlays: Qt.resolvedUrl("../modules/interface/overlays/Overlays.qml")
|
||||
property bool overriddenOverlays: false
|
||||
function overrideOverlays() {
|
||||
overriddenOverlays = true
|
||||
}
|
||||
|
||||
// Right sidebar
|
||||
property url sidebarRight: !overriddenSidebarRight ? Qt.resolvedUrl("../modules/interface/sidebarRight/SidebarRight.qml") : "" // Force override
|
||||
property bool overriddenSidebarRight: false
|
||||
function overrideSidebarRight() {
|
||||
overriddenSidebarRight = true
|
||||
}
|
||||
|
||||
// Left sidebar
|
||||
property url sidebarLeft: !overriddenSidebarLeft ? Qt.resolvedUrl("../modules/interface/sidebarLeft/SidebarLeft.qml") : "" // Force override
|
||||
property bool overriddenSidebarLeft: false
|
||||
function overrideSidebarLeft() {
|
||||
overriddenSidebarLeft = true
|
||||
}
|
||||
}
|
||||
216
.config/quickshell/nucleus-shell/services/Hyprland.qml
Executable file
216
.config/quickshell/nucleus-shell/services/Hyprland.qml
Executable file
@@ -0,0 +1,216 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Wayland
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// true if Hyprland is running, false otherwise
|
||||
readonly property bool isHyprland: Compositor.require("hyprland")
|
||||
|
||||
// reactive Hyprland data, only valid if Hyprland is running
|
||||
signal stateChanged()
|
||||
readonly property var toplevels: isHyprland ? Hyprland.toplevels : []
|
||||
readonly property var workspaces: isHyprland ? Hyprland.workspaces : []
|
||||
readonly property var monitors: isHyprland ? Hyprland.monitors : []
|
||||
readonly property Toplevel activeToplevel: isHyprland ? ToplevelManager.activeToplevel : null
|
||||
readonly property HyprlandWorkspace focusedWorkspace: isHyprland ? Hyprland.focusedWorkspace : null
|
||||
readonly property HyprlandMonitor focusedMonitor: isHyprland ? Hyprland.focusedMonitor : null
|
||||
readonly property int focusedWorkspaceId: focusedWorkspace?.id ?? 1
|
||||
property real screenW: focusedMonitor ? focusedMonitor.width : 0
|
||||
property real screenH: focusedMonitor ? focusedMonitor.height : 0
|
||||
property real screenScale: focusedMonitor ? focusedMonitor.scale : 1
|
||||
|
||||
// parsed hyprctl data, defaults are empty
|
||||
property var windowList: []
|
||||
property var windowByAddress: ({})
|
||||
property var addresses: []
|
||||
property var layers: ({})
|
||||
property var monitorsInfo: []
|
||||
property var workspacesInfo: []
|
||||
property var workspaceById: ({})
|
||||
property var workspaceIds: []
|
||||
property var activeWorkspaceInfo: null
|
||||
property string keyboardLayout: "?"
|
||||
|
||||
// dispatch a command to Hyprland, no-op if not running
|
||||
function dispatch(request: string): void {
|
||||
if (!isHyprland) return
|
||||
Hyprland.dispatch(request)
|
||||
}
|
||||
|
||||
// switch workspace safely
|
||||
function changeWorkspace(targetWorkspaceId) {
|
||||
if (!isHyprland || !targetWorkspaceId) return
|
||||
root.dispatch("workspace " + targetWorkspaceId)
|
||||
}
|
||||
|
||||
// find most recently focused window in a workspace
|
||||
function focusedWindowForWorkspace(workspaceId) {
|
||||
if (!isHyprland) return null
|
||||
const wsWindows = root.windowList.filter(w => w.workspace.id === workspaceId)
|
||||
if (wsWindows.length === 0) return null
|
||||
return wsWindows.reduce((best, win) => {
|
||||
const bestFocus = best?.focusHistoryID ?? Infinity
|
||||
const winFocus = win?.focusHistoryID ?? Infinity
|
||||
return winFocus < bestFocus ? win : best
|
||||
}, null)
|
||||
}
|
||||
|
||||
// check if a workspace has any windows
|
||||
function isWorkspaceOccupied(id: int): bool {
|
||||
if (!isHyprland) return false
|
||||
return Hyprland.workspaces.values.find(w => w?.id === id)?.lastIpcObject.windows > 0 || false
|
||||
}
|
||||
|
||||
// update all hyprctl processes
|
||||
function updateAll() {
|
||||
if (!isHyprland) return
|
||||
getClients.running = true
|
||||
getLayers.running = true
|
||||
getMonitors.running = true
|
||||
getWorkspaces.running = true
|
||||
getActiveWorkspace.running = true
|
||||
}
|
||||
|
||||
// largest window in a workspace
|
||||
function biggestWindowForWorkspace(workspaceId) {
|
||||
if (!isHyprland) return null
|
||||
const windowsInThisWorkspace = root.windowList.filter(w => w.workspace.id === workspaceId)
|
||||
return windowsInThisWorkspace.reduce((maxWin, win) => {
|
||||
const maxArea = (maxWin?.size?.[0] ?? 0) * (maxWin?.size?.[1] ?? 0)
|
||||
const winArea = (win?.size?.[0] ?? 0) * (win?.size?.[1] ?? 0)
|
||||
return winArea > maxArea ? win : maxWin
|
||||
}, null)
|
||||
}
|
||||
|
||||
// refresh keyboard layout
|
||||
function refreshKeyboardLayout() {
|
||||
if (!isHyprland) return
|
||||
hyprctlDevices.running = true
|
||||
}
|
||||
|
||||
// only create hyprctl processes if Hyprland is running
|
||||
Component.onCompleted: {
|
||||
if (isHyprland) {
|
||||
updateAll()
|
||||
refreshKeyboardLayout()
|
||||
}
|
||||
}
|
||||
|
||||
// process to get keyboard layout
|
||||
Process {
|
||||
id: hyprctlDevices
|
||||
running: false
|
||||
command: ["hyprctl", "devices", "-j"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const devices = JSON.parse(this.text)
|
||||
const keyboard = devices.keyboards.find(k => k.main) || devices.keyboards[0]
|
||||
root.keyboardLayout = keyboard?.active_keymap?.toUpperCase()?.slice(0, 2) ?? "?"
|
||||
} catch (err) {
|
||||
console.error("Failed to parse keyboard layout:", err)
|
||||
root.keyboardLayout = "?"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getClients
|
||||
running: false
|
||||
command: ["hyprctl", "clients", "-j"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
root.windowList = JSON.parse(this.text)
|
||||
let tempWinByAddress = {}
|
||||
for (let win of root.windowList) tempWinByAddress[win.address] = win
|
||||
root.windowByAddress = tempWinByAddress
|
||||
root.addresses = root.windowList.map(w => w.address)
|
||||
} catch (e) {
|
||||
console.error("Failed to parse clients:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getMonitors
|
||||
running: false
|
||||
command: ["hyprctl", "monitors", "-j"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try { root.monitorsInfo = JSON.parse(this.text) }
|
||||
catch (e) { console.error("Failed to parse monitors:", e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getLayers
|
||||
running: false
|
||||
command: ["hyprctl", "layers", "-j"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try { root.layers = JSON.parse(this.text) }
|
||||
catch (e) { console.error("Failed to parse layers:", e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getWorkspaces
|
||||
running: false
|
||||
command: ["hyprctl", "workspaces", "-j"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
root.workspacesInfo = JSON.parse(this.text)
|
||||
let map = {}
|
||||
for (let ws of root.workspacesInfo) map[ws.id] = ws
|
||||
root.workspaceById = map
|
||||
root.workspaceIds = root.workspacesInfo.map(ws => ws.id)
|
||||
} catch (e) { console.error("Failed to parse workspaces:", e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getActiveWorkspace
|
||||
running: false
|
||||
command: ["hyprctl", "activeworkspace", "-j"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try { root.activeWorkspaceInfo = JSON.parse(this.text) }
|
||||
catch (e) { console.error("Failed to parse active workspace:", e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// only connect to Hyprland events if running
|
||||
Connections {
|
||||
target: isHyprland ? Hyprland : null
|
||||
function onRawEvent(event) {
|
||||
if (!isHyprland || event.name.endsWith("v2")) return
|
||||
|
||||
if (event.name.includes("activelayout"))
|
||||
refreshKeyboardLayout()
|
||||
else if (event.name.includes("mon"))
|
||||
Hyprland.refreshMonitors()
|
||||
else if (event.name.includes("workspace") || event.name.includes("window"))
|
||||
Hyprland.refreshWorkspaces()
|
||||
else
|
||||
Hyprland.refreshToplevels()
|
||||
|
||||
updateAll()
|
||||
root.stateChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
136
.config/quickshell/nucleus-shell/services/Mpris.qml
Executable file
136
.config/quickshell/nucleus-shell/services/Mpris.qml
Executable file
@@ -0,0 +1,136 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Mpris
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property alias activePlayer: instance.activePlayer
|
||||
property bool isPlaying: activePlayer ? activePlayer.playbackState === MprisPlaybackState.Playing : false
|
||||
property string title: activePlayer ? activePlayer.trackTitle : "No Media"
|
||||
property string artist: activePlayer ? activePlayer.trackArtist : ""
|
||||
property string album: activePlayer ? activePlayer.trackAlbum : ""
|
||||
property string artUrl: activePlayer ? activePlayer.trackArtUrl : ""
|
||||
property double position: 0
|
||||
property double length: activePlayer ? activePlayer.length : 0
|
||||
property var _players: Mpris.players.values
|
||||
property int playerCount: _players.length
|
||||
property var playerList: {
|
||||
let list = [];
|
||||
for (let p of _players) {
|
||||
list.push({
|
||||
"identity": p.identity || p.desktopEntry || "Unknown",
|
||||
"desktopEntry": p.desktopEntry || "",
|
||||
"player": p
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
property string currentPlayerName: activePlayer ? (activePlayer.identity || activePlayer.desktopEntry || "Unknown") : ""
|
||||
property bool manualSelection: false
|
||||
|
||||
function setPosition(pos) {
|
||||
if (activePlayer)
|
||||
activePlayer.position = pos;
|
||||
|
||||
}
|
||||
|
||||
function selectPlayer(player) {
|
||||
if (player) {
|
||||
instance.activePlayer = player;
|
||||
manualSelection = true;
|
||||
}
|
||||
}
|
||||
|
||||
function selectNextPlayer() {
|
||||
const players = Mpris.players.values;
|
||||
if (players.length <= 1)
|
||||
return ;
|
||||
|
||||
const currentIndex = players.indexOf(activePlayer);
|
||||
const nextIndex = (currentIndex + 1) % players.length;
|
||||
selectPlayer(players[nextIndex]);
|
||||
}
|
||||
|
||||
function selectPreviousPlayer() {
|
||||
const players = Mpris.players.values;
|
||||
if (players.length <= 1)
|
||||
return ;
|
||||
|
||||
const currentIndex = players.indexOf(activePlayer);
|
||||
const prevIndex = (currentIndex - 1 + players.length) % players.length;
|
||||
selectPlayer(players[prevIndex]);
|
||||
}
|
||||
|
||||
function updateActivePlayer() {
|
||||
const players = Mpris.players.values;
|
||||
if (manualSelection && instance.activePlayer && players.includes(instance.activePlayer))
|
||||
return ;
|
||||
|
||||
if (manualSelection && instance.activePlayer && !players.includes(instance.activePlayer))
|
||||
manualSelection = false;
|
||||
|
||||
const playing = players.find((p) => {
|
||||
return p.playbackState === MprisPlaybackState.Playing;
|
||||
});
|
||||
if (playing) {
|
||||
instance.activePlayer = playing;
|
||||
} else if (players.length > 0) {
|
||||
if (!instance.activePlayer || !players.includes(instance.activePlayer))
|
||||
instance.activePlayer = players[0];
|
||||
|
||||
} else {
|
||||
instance.activePlayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function playPause() {
|
||||
if (activePlayer && activePlayer.canTogglePlaying)
|
||||
activePlayer.togglePlaying();
|
||||
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (activePlayer && activePlayer.canGoNext)
|
||||
activePlayer.next();
|
||||
|
||||
}
|
||||
|
||||
function previous() {
|
||||
if (activePlayer && activePlayer.canGoPrevious)
|
||||
activePlayer.previous();
|
||||
|
||||
}
|
||||
|
||||
Component.onCompleted: updateActivePlayer()
|
||||
|
||||
QtObject {
|
||||
id: instance
|
||||
|
||||
property var players: Mpris.players.values
|
||||
property var activePlayer: null
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
updateActivePlayer();
|
||||
if (activePlayer)
|
||||
root.position = activePlayer.position;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onValuesChanged() {
|
||||
root._players = Mpris.players.values;
|
||||
updateActivePlayer();
|
||||
}
|
||||
|
||||
target: Mpris.players
|
||||
}
|
||||
|
||||
}
|
||||
309
.config/quickshell/nucleus-shell/services/Network.qml
Executable file
309
.config/quickshell/nucleus-shell/services/Network.qml
Executable file
@@ -0,0 +1,309 @@
|
||||
pragma Singleton
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property list<Connection> connections: []
|
||||
readonly property list<string> savedNetworks: []
|
||||
readonly property Connection active: connections.find(c => c.active) ?? null
|
||||
|
||||
property bool wifiEnabled: true
|
||||
readonly property bool scanning: rescanProc.running
|
||||
|
||||
property string lastNetworkAttempt: ""
|
||||
property string lastErrorMessage: ""
|
||||
property string message: ""
|
||||
|
||||
readonly property string icon: {
|
||||
if (!active) return "signal_wifi_off";
|
||||
if (active.type === "ethernet") return "settings_ethernet";
|
||||
|
||||
if (active.strength >= 75) return "network_wifi";
|
||||
else if (active.strength >= 50) return "network_wifi_3_bar";
|
||||
else if (active.strength >= 25) return "network_wifi_2_bar";
|
||||
else return "network_wifi_1_bar";
|
||||
}
|
||||
|
||||
readonly property string wifiLabel: {
|
||||
const activeWifi = connections.find(c => c.active && c.type === "wifi");
|
||||
if (activeWifi) return activeWifi.name;
|
||||
return "Wi-Fi";
|
||||
}
|
||||
|
||||
readonly property string wifiStatus: {
|
||||
const activeWifi = connections.find(c => c.active && c.type === "wifi");
|
||||
if (activeWifi) return "Connected";
|
||||
if (wifiEnabled) return "On";
|
||||
return "Off";
|
||||
}
|
||||
|
||||
readonly property string label: {
|
||||
if (active) return active.name;
|
||||
if (wifiEnabled) return "Wi-Fi";
|
||||
return "Wi-Fi";
|
||||
}
|
||||
|
||||
readonly property string status: {
|
||||
if (active) return "Connected";
|
||||
if (wifiEnabled) return "On";
|
||||
return "Off";
|
||||
}
|
||||
|
||||
function enableWifi(enabled: bool): void {
|
||||
enableWifiProc.exec(["nmcli", "radio", "wifi", enabled ? "on" : "off"]);
|
||||
}
|
||||
|
||||
function toggleWifi(): void {
|
||||
enableWifi(!wifiEnabled);
|
||||
}
|
||||
|
||||
function rescan(): void {
|
||||
rescanProc.running = true;
|
||||
}
|
||||
|
||||
function connect(connection: Connection, password: string): void {
|
||||
if (connection.type === "wifi") {
|
||||
root.lastNetworkAttempt = connection.name;
|
||||
root.lastErrorMessage = "";
|
||||
root.message = "";
|
||||
|
||||
if (password && password.length > 0) {
|
||||
connectProc.exec(["nmcli", "dev", "wifi", "connect", connection.name, "password", password]);
|
||||
} else {
|
||||
connectProc.exec(["nmcli", "dev", "wifi", "connect", connection.name]);
|
||||
}
|
||||
} else if (connection.type === "ethernet") {
|
||||
ethConnectProc.exec(["nmcli", "connection", "up", connection.uuid]);
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect(): void {
|
||||
if (!active) return;
|
||||
|
||||
if (active.type === "wifi") {
|
||||
disconnectProc.exec(["nmcli", "connection", "down", active.name]);
|
||||
} else if (active.type === "ethernet") {
|
||||
ethDisconnectProc.exec(["nmcli", "connection", "down", active.uuid]);
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
running: true
|
||||
command: ["nmcli", "m"]
|
||||
stdout: SplitParser {
|
||||
onRead: {
|
||||
getWifiStatus();
|
||||
updateConnections();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getWifiStatus(): void {
|
||||
wifiStatusProc.running = true;
|
||||
}
|
||||
|
||||
function updateConnections(): void {
|
||||
getWifiNetworks.running = true;
|
||||
getEthConnections.running = true;
|
||||
getSavedNetworks.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: wifiStatusProc
|
||||
running: true
|
||||
command: ["nmcli", "radio", "wifi"]
|
||||
environment: ({ LANG: "C.UTF-8", LC_ALL: "C.UTF-8" })
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.wifiEnabled = text.trim() === "enabled";
|
||||
if (!root.wifiEnabled) {
|
||||
root.lastErrorMessage = "";
|
||||
root.message = "";
|
||||
root.lastNetworkAttempt = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: enableWifiProc
|
||||
onExited: updateConnections()
|
||||
}
|
||||
|
||||
Process {
|
||||
id: rescanProc
|
||||
command: ["nmcli", "dev", "wifi", "list", "--rescan", "yes"]
|
||||
onExited: updateConnections()
|
||||
}
|
||||
|
||||
Process {
|
||||
id: connectProc
|
||||
stdout: StdioCollector { }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (text.includes("Error") || text.includes("incorrect")) {
|
||||
root.lastErrorMessage = "Incorrect password";
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
if (exitCode === 0) {
|
||||
root.message = "ok";
|
||||
root.lastErrorMessage = "";
|
||||
} else {
|
||||
root.message = root.lastErrorMessage !== "" ? root.lastErrorMessage : "Connection failed";
|
||||
}
|
||||
updateConnections();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: disconnectProc
|
||||
onExited: updateConnections()
|
||||
}
|
||||
|
||||
Process {
|
||||
id: ethConnectProc
|
||||
onExited: updateConnections()
|
||||
}
|
||||
|
||||
Process {
|
||||
id: ethDisconnectProc
|
||||
onExited: updateConnections()
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getSavedNetworks
|
||||
running: true
|
||||
command: ["nmcli", "-g", "NAME,TYPE", "connection", "show"]
|
||||
environment: ({ LANG: "C.UTF-8", LC_ALL: "C.UTF-8" })
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const lines = text.trim().split("\n");
|
||||
const wifiConnections = lines
|
||||
.map(line => line.split(":"))
|
||||
.filter(parts => parts[1] === "802-11-wireless")
|
||||
.map(parts => parts[0]);
|
||||
root.savedNetworks = wifiConnections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getWifiNetworks
|
||||
running: true
|
||||
command: ["nmcli", "-g", "ACTIVE,SIGNAL,FREQ,SSID,BSSID,SECURITY", "d", "w"]
|
||||
environment: ({ LANG: "C.UTF-8", LC_ALL: "C.UTF-8" })
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const PLACEHOLDER = "STRINGWHICHHOPEFULLYWONTBEUSED";
|
||||
const rep = new RegExp("\\\\:", "g");
|
||||
const rep2 = new RegExp(PLACEHOLDER, "g");
|
||||
|
||||
const allNetworks = text.trim().split("\n").map(n => {
|
||||
const net = n.replace(rep, PLACEHOLDER).split(":");
|
||||
return {
|
||||
type: "wifi",
|
||||
active: net[0] === "yes",
|
||||
strength: parseInt(net[1]),
|
||||
frequency: parseInt(net[2]),
|
||||
name: net[3]?.replace(rep2, ":") ?? "",
|
||||
bssid: net[4]?.replace(rep2, ":") ?? "",
|
||||
security: net[5] ?? "",
|
||||
saved: root.savedNetworks.includes(net[3] ?? ""),
|
||||
uuid: "",
|
||||
device: ""
|
||||
};
|
||||
}).filter(n => n.name && n.name.length > 0);
|
||||
|
||||
const networkMap = new Map();
|
||||
for (const network of allNetworks) {
|
||||
const existing = networkMap.get(network.name);
|
||||
if (!existing) {
|
||||
networkMap.set(network.name, network);
|
||||
} else {
|
||||
if (network.active && !existing.active) {
|
||||
networkMap.set(network.name, network);
|
||||
} else if (!network.active && !existing.active && network.strength > existing.strength) {
|
||||
networkMap.set(network.name, network);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mergeConnections(Array.from(networkMap.values()), "wifi");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getEthConnections
|
||||
running: true
|
||||
command: ["nmcli", "-g", "NAME,UUID,TYPE,DEVICE,STATE", "connection", "show"]
|
||||
environment: ({ LANG: "C.UTF-8", LC_ALL: "C.UTF-8" })
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const lines = text.trim().split("\n");
|
||||
const ethConns = lines
|
||||
.map(line => line.split(":"))
|
||||
.filter(parts => parts[2] === "802-3-ethernet" || parts[2] === "gsm" || parts[2] === "bluetooth")
|
||||
.map(parts => ({
|
||||
type: "ethernet",
|
||||
name: parts[0],
|
||||
uuid: parts[1],
|
||||
device: parts[3],
|
||||
active: parts[4] === "activated",
|
||||
strength: 100,
|
||||
frequency: 0,
|
||||
bssid: "",
|
||||
security: "",
|
||||
saved: true
|
||||
}));
|
||||
|
||||
mergeConnections(ethConns, "ethernet");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeConnections(newConns: var, connType: string): void {
|
||||
const rConns = root.connections;
|
||||
const destroyed = rConns.filter(rc => rc.type === connType && !newConns.find(nc =>
|
||||
connType === "wifi" ? (nc.frequency === rc.frequency && nc.name === rc.name && nc.bssid === rc.bssid)
|
||||
: nc.uuid === rc.uuid
|
||||
));
|
||||
|
||||
for (const conn of destroyed)
|
||||
rConns.splice(rConns.indexOf(conn), 1).forEach(c => c.destroy());
|
||||
|
||||
for (const conn of newConns) {
|
||||
const match = rConns.find(c =>
|
||||
connType === "wifi" ? (c.frequency === conn.frequency && c.name === conn.name && c.bssid === conn.bssid)
|
||||
: c.uuid === conn.uuid
|
||||
);
|
||||
if (match) {
|
||||
match.lastIpcObject = conn;
|
||||
} else {
|
||||
rConns.push(connComp.createObject(root, { lastIpcObject: conn }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component Connection: QtObject {
|
||||
required property var lastIpcObject
|
||||
readonly property string type: lastIpcObject.type
|
||||
readonly property string name: lastIpcObject.name
|
||||
readonly property string uuid: lastIpcObject.uuid
|
||||
readonly property string device: lastIpcObject.device
|
||||
readonly property bool active: lastIpcObject.active
|
||||
readonly property int strength: lastIpcObject.strength
|
||||
readonly property int frequency: lastIpcObject.frequency
|
||||
readonly property string bssid: lastIpcObject.bssid
|
||||
readonly property string security: lastIpcObject.security
|
||||
readonly property bool isSecure: security.length > 0
|
||||
readonly property bool saved: lastIpcObject.saved
|
||||
}
|
||||
|
||||
Component { id: connComp; Connection { } }
|
||||
}
|
||||
242
.config/quickshell/nucleus-shell/services/Niri.qml
Normal file
242
.config/quickshell/nucleus-shell/services/Niri.qml
Normal file
@@ -0,0 +1,242 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Io
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: niriItem
|
||||
|
||||
signal stateChanged()
|
||||
|
||||
property string title: ""
|
||||
property bool isFullscreen: false
|
||||
property string layout: "Tiled"
|
||||
property int focusedWorkspaceId: 1
|
||||
|
||||
property var workspaces: []
|
||||
property var workspaceCache: ({})
|
||||
property var windows: [] // tracked windows
|
||||
|
||||
property bool initialized: false
|
||||
property int screenW: 0
|
||||
property int screenH: 0
|
||||
property real screenScale: 1
|
||||
|
||||
function changeWorkspace(id) {
|
||||
sendSocketCommand(niriCommandSocket, {
|
||||
"Action": {
|
||||
"focus_workspace": {
|
||||
"reference": { "Id": id }
|
||||
}
|
||||
}
|
||||
})
|
||||
dispatchProc.command = ["niri", "msg", "action", "focus-workspace", id.toString()]
|
||||
dispatchProc.running = true
|
||||
}
|
||||
|
||||
function changeWorkspaceRelative(delta) {
|
||||
const cmd = delta > 0 ? "focus-workspace-down" : "focus-workspace-up"
|
||||
dispatchProc.command = ["niri", "msg", "action", cmd]
|
||||
dispatchProc.running = true
|
||||
}
|
||||
|
||||
function isWorkspaceOccupied(id) {
|
||||
for (const ws of workspaces)
|
||||
if (ws.id === id) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function focusedWindowForWorkspace(id) {
|
||||
// focused window in workspace
|
||||
for (const win of windows) {
|
||||
if (win.workspaceId === id && win.isFocused) {
|
||||
return { class: win.appId, title: win.title }
|
||||
}
|
||||
}
|
||||
|
||||
// fallback: any window in workspace
|
||||
for (const win of windows) {
|
||||
if (win.workspaceId === id) {
|
||||
return { class: win.appId, title: win.title }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function sendSocketCommand(sock, command) {
|
||||
if (sock.connected)
|
||||
sock.write(JSON.stringify(command) + "\n")
|
||||
}
|
||||
|
||||
function startEventStream() {
|
||||
sendSocketCommand(niriEventStream, "EventStream")
|
||||
}
|
||||
|
||||
function updateWorkspaces() {
|
||||
sendSocketCommand(niriCommandSocket, "Workspaces")
|
||||
}
|
||||
|
||||
function updateWindows() {
|
||||
sendSocketCommand(niriCommandSocket, "Windows")
|
||||
}
|
||||
|
||||
function updateFocusedWindow() {
|
||||
sendSocketCommand(niriCommandSocket, "FocusedWindow")
|
||||
}
|
||||
|
||||
function recollectWorkspaces(workspacesData) {
|
||||
const list = []
|
||||
workspaceCache = {}
|
||||
|
||||
for (const ws of workspacesData) {
|
||||
const data = {
|
||||
id: ws.idx !== undefined ? ws.idx + 1 : ws.id,
|
||||
internalId: ws.id,
|
||||
idx: ws.idx,
|
||||
name: ws.name || "",
|
||||
output: ws.output || "",
|
||||
isFocused: ws.is_focused === true,
|
||||
isActive: ws.is_active === true
|
||||
}
|
||||
|
||||
list.push(data)
|
||||
workspaceCache[ws.id] = data
|
||||
if (data.isFocused)
|
||||
focusedWorkspaceId = data.id
|
||||
}
|
||||
|
||||
list.sort((a, b) => a.id - b.id)
|
||||
workspaces = list
|
||||
stateChanged()
|
||||
}
|
||||
|
||||
function recollectWindows(windowsData) {
|
||||
const list = []
|
||||
|
||||
for (const win of windowsData) {
|
||||
list.push({
|
||||
appId: win.app_id || "",
|
||||
title: win.title || "",
|
||||
workspaceId: win.workspace_id,
|
||||
isFocused: win.is_focused === true
|
||||
})
|
||||
}
|
||||
|
||||
windows = list
|
||||
stateChanged()
|
||||
}
|
||||
|
||||
function recollectFocusedWindow(win) {
|
||||
if (win && win.title) {
|
||||
title = win.title
|
||||
isFullscreen = win.is_fullscreen || false
|
||||
layout = "Tiled"
|
||||
} else {
|
||||
title = "~"
|
||||
isFullscreen = false
|
||||
layout = "Tiled"
|
||||
}
|
||||
stateChanged()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (Quickshell.env("NIRI_SOCKET")) {
|
||||
niriCommandSocket.connected = true
|
||||
niriEventStream.connected = true
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: niriOutputsProc
|
||||
command: ["niri", "msg", "outputs"]
|
||||
running: true
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const lines = this.text.split("\n")
|
||||
let sizeRe = /Logical size:\s*(\d+)x(\d+)/
|
||||
let scaleRe = /Scale:\s*([\d.]+)/
|
||||
|
||||
for (const line of lines) {
|
||||
let m
|
||||
if ((m = sizeRe.exec(line))) {
|
||||
screenW = parseInt(m[1], 10)
|
||||
screenH = parseInt(m[2], 10)
|
||||
} else if ((m = scaleRe.exec(line))) {
|
||||
screenScale = parseFloat(m[1])
|
||||
}
|
||||
}
|
||||
stateChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Socket {
|
||||
id: niriCommandSocket
|
||||
path: Quickshell.env("NIRI_SOCKET") || ""
|
||||
connected: false
|
||||
|
||||
onConnectedChanged: {
|
||||
if (connected) {
|
||||
updateWorkspaces()
|
||||
updateWindows()
|
||||
updateFocusedWindow()
|
||||
}
|
||||
}
|
||||
|
||||
parser: SplitParser {
|
||||
onRead: (line) => {
|
||||
if (!line.trim()) return
|
||||
try {
|
||||
const data = JSON.parse(line)
|
||||
if (data?.Ok) {
|
||||
const res = data.Ok
|
||||
if (res.Workspaces) recollectWorkspaces(res.Workspaces)
|
||||
else if (res.Windows) recollectWindows(res.Windows)
|
||||
else if (res.FocusedWindow) recollectFocusedWindow(res.FocusedWindow)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Niri socket parse error:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Socket {
|
||||
id: niriEventStream
|
||||
path: Quickshell.env("NIRI_SOCKET") || ""
|
||||
connected: false
|
||||
|
||||
onConnectedChanged: {
|
||||
if (connected)
|
||||
startEventStream()
|
||||
}
|
||||
|
||||
parser: SplitParser {
|
||||
onRead: (data) => {
|
||||
if (!data.trim()) return
|
||||
try {
|
||||
const event = JSON.parse(data.trim())
|
||||
|
||||
if (event.WorkspacesChanged)
|
||||
recollectWorkspaces(event.WorkspacesChanged.workspaces)
|
||||
else if (event.WorkspaceActivated)
|
||||
updateWorkspaces()
|
||||
else if (
|
||||
event.WindowFocusChanged ||
|
||||
event.WindowOpenedOrChanged ||
|
||||
event.WindowClosed
|
||||
)
|
||||
updateWindows()
|
||||
} catch (e) {
|
||||
console.warn("Niri event stream parse error:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process { id: dispatchProc }
|
||||
}
|
||||
110
.config/quickshell/nucleus-shell/services/NotifServer.qml
Executable file
110
.config/quickshell/nucleus-shell/services/NotifServer.qml
Executable file
@@ -0,0 +1,110 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Notifications
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
// from github.com/end-4/dots-hyprland with modifications
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property list<Notif> data: []
|
||||
property list<Notif> popups: data.filter(n => n.popup && !n.tracked)
|
||||
property list<Notif> history: data
|
||||
|
||||
Loader {
|
||||
active: Config.initialized && Config.runtime.notifications.enabled
|
||||
sourceComponent: NotificationServer {
|
||||
keepOnReload: false
|
||||
actionsSupported: true
|
||||
bodyHyperlinksSupported: true
|
||||
bodyImagesSupported: true
|
||||
bodyMarkupSupported: true
|
||||
imageSupported: true
|
||||
|
||||
onNotification: notif => {
|
||||
notif.tracked = true;
|
||||
|
||||
root.data.push(notifComp.createObject(root, {
|
||||
popup: true,
|
||||
notification: notif,
|
||||
shown: false
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
function removeById(id) {
|
||||
const i = data.findIndex(n => n.notification.id === id);
|
||||
if (i >= 0) {
|
||||
data.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
component Notif: QtObject {
|
||||
id: notif
|
||||
|
||||
property bool popup
|
||||
readonly property date time: new Date()
|
||||
readonly property string timeStr: {
|
||||
const diff = Time.date.getTime() - time.getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
const h = Math.floor(m / 60);
|
||||
|
||||
if (h < 1 && m < 1)
|
||||
return "now";
|
||||
if (h < 1)
|
||||
return `${m}m`;
|
||||
return `${h}h`;
|
||||
}
|
||||
|
||||
property bool shown: false
|
||||
required property Notification notification
|
||||
readonly property string summary: notification.summary
|
||||
readonly property string body: notification.body
|
||||
readonly property string appIcon: notification.appIcon
|
||||
readonly property string appName: notification.appName
|
||||
readonly property string image: notification.image
|
||||
readonly property int urgency: notification.urgency
|
||||
readonly property list<NotificationAction> actions: notification.actions
|
||||
|
||||
readonly property Timer timer: Timer {
|
||||
running: notif.actions.length >= 0
|
||||
interval: notif.notification.expireTimeout > 0 ? notif.notification.expireTimeout : 5000
|
||||
onTriggered: {
|
||||
if (true)
|
||||
notif.popup = false;
|
||||
}
|
||||
}
|
||||
|
||||
readonly property Connections conn: Connections {
|
||||
target: notif.notification.Retainable
|
||||
|
||||
function onDropped(): void {
|
||||
root.data.splice(root.data.indexOf(notif), 1);
|
||||
}
|
||||
|
||||
function onAboutToDestroy(): void {
|
||||
notif.destroy();
|
||||
}
|
||||
}
|
||||
readonly property Connections conn2: Connections {
|
||||
target: notif.notification
|
||||
|
||||
function onClosed(reason) {
|
||||
root.data.splice(root.data.indexOf(notif), 1)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Component {
|
||||
id: notifComp
|
||||
|
||||
Notif {}
|
||||
}
|
||||
}
|
||||
22
.config/quickshell/nucleus-shell/services/Polkit.qml
Normal file
22
.config/quickshell/nucleus-shell/services/Polkit.qml
Normal file
@@ -0,0 +1,22 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Polkit
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property alias isActive: polkit.isActive
|
||||
property alias isRegistered: polkit.isRegistered
|
||||
property alias flow: polkit.flow
|
||||
property alias path: polkit.path
|
||||
|
||||
Component.onCompleted: {
|
||||
}
|
||||
PolkitAgent {
|
||||
id: polkit
|
||||
}
|
||||
}
|
||||
394
.config/quickshell/nucleus-shell/services/SystemDetails.qml
Normal file
394
.config/quickshell/nucleus-shell/services/SystemDetails.qml
Normal file
@@ -0,0 +1,394 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.config
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string hostname: ""
|
||||
property string username: ""
|
||||
property string osIcon: ""
|
||||
property string osName: ""
|
||||
property string kernelVersion: ""
|
||||
property string architecture: ""
|
||||
property string uptime: ""
|
||||
property string qsVersion: ""
|
||||
property string swapUsage: "—"
|
||||
property real swapPercent: 0
|
||||
property string ipAddress: "—"
|
||||
property int runningProcesses: 0
|
||||
property int loggedInUsers: 0
|
||||
property string ramUsage: "—"
|
||||
property real ramPercent: 0
|
||||
property string cpuLoad: "—"
|
||||
property real cpuPercent: 0
|
||||
property string diskUsage: "—"
|
||||
property real diskPercent: 0
|
||||
property string cpuTemp: "—"
|
||||
property string keyboardLayout: "none"
|
||||
property real cpuTempPercent: 0
|
||||
property int prevIdle: -1
|
||||
property int prevTotal: -1
|
||||
|
||||
|
||||
readonly property var osIcons: ({
|
||||
"almalinux": "",
|
||||
"alpine": "",
|
||||
"arch": "",
|
||||
"archcraft": "",
|
||||
"arcolinux": "",
|
||||
"artix": "",
|
||||
"centos": "",
|
||||
"debian": "",
|
||||
"devuan": "",
|
||||
"elementary": "",
|
||||
"endeavouros": "",
|
||||
"fedora": "",
|
||||
"freebsd": "",
|
||||
"garuda": "",
|
||||
"gentoo": "",
|
||||
"hyperbola": "",
|
||||
"kali": "",
|
||||
"linuxmint": "",
|
||||
"mageia": "",
|
||||
"openmandriva": "",
|
||||
"manjaro": "",
|
||||
"neon": "",
|
||||
"nixos": "",
|
||||
"opensuse": "",
|
||||
"suse": "",
|
||||
"sles": "",
|
||||
"sles_sap": "",
|
||||
"opensuse-tumbleweed": "",
|
||||
"parrot": "",
|
||||
"pop": "",
|
||||
"raspbian": "",
|
||||
"rhel": "",
|
||||
"rocky": "",
|
||||
"slackware": "",
|
||||
"solus": "",
|
||||
"steamos": "",
|
||||
"tails": "",
|
||||
"trisquel": "",
|
||||
"ubuntu": "",
|
||||
"vanilla": "",
|
||||
"void": "",
|
||||
"zorin": "",
|
||||
"opensuse": "",
|
||||
|
||||
})
|
||||
|
||||
|
||||
FileView { id: cpuStat; path: "/proc/stat" }
|
||||
FileView { id: memInfo; path: "/proc/meminfo" }
|
||||
FileView { id: uptimeFile; path: "/proc/uptime" }
|
||||
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
running: true
|
||||
repeat: true
|
||||
|
||||
onTriggered: {
|
||||
|
||||
cpuStat.reload()
|
||||
memInfo.reload()
|
||||
uptimeFile.reload()
|
||||
|
||||
/* CPU */
|
||||
|
||||
const cpuLine = cpuStat.text().split("\n")[0].trim().split(/\s+/)
|
||||
|
||||
const cpuUser = parseInt(cpuLine[1])
|
||||
const cpuNice = parseInt(cpuLine[2])
|
||||
const cpuSystem = parseInt(cpuLine[3])
|
||||
const cpuIdle = parseInt(cpuLine[4])
|
||||
const cpuIowait = parseInt(cpuLine[5])
|
||||
const cpuIrq = parseInt(cpuLine[6])
|
||||
const cpuSoftirq = parseInt(cpuLine[7])
|
||||
|
||||
const cpuIdleAll = cpuIdle + cpuIowait
|
||||
const cpuTotal =
|
||||
cpuUser + cpuNice + cpuSystem + cpuIrq + cpuSoftirq + cpuIdleAll
|
||||
|
||||
if (root.prevTotal >= 0) {
|
||||
|
||||
const totalDiff = cpuTotal - root.prevTotal
|
||||
const idleDiff = cpuIdleAll - root.prevIdle
|
||||
|
||||
if (totalDiff > 0)
|
||||
root.cpuPercent = (totalDiff - idleDiff) / totalDiff
|
||||
}
|
||||
|
||||
root.prevTotal = cpuTotal
|
||||
root.prevIdle = cpuIdleAll
|
||||
|
||||
root.cpuLoad = Math.round(root.cpuPercent * 100) + "%"
|
||||
|
||||
|
||||
/* RAM */
|
||||
|
||||
const memLines = memInfo.text().split("\n")
|
||||
|
||||
let memTotal = 0
|
||||
let memAvailable = 0
|
||||
|
||||
for (let line of memLines) {
|
||||
|
||||
if (line.startsWith("MemTotal"))
|
||||
memTotal = parseInt(line.match(/\d+/)[0])
|
||||
|
||||
if (line.startsWith("MemAvailable"))
|
||||
memAvailable = parseInt(line.match(/\d+/)[0])
|
||||
}
|
||||
|
||||
if (memTotal > 0) {
|
||||
|
||||
const memUsed = memTotal - memAvailable
|
||||
|
||||
root.ramPercent = memUsed / memTotal
|
||||
root.ramUsage =
|
||||
`${Math.round(memUsed/1024)}/${Math.round(memTotal/1024)} MB`
|
||||
}
|
||||
|
||||
|
||||
/* Uptime */
|
||||
|
||||
const uptimeSeconds =
|
||||
parseFloat(uptimeFile.text().split(" ")[0])
|
||||
|
||||
const d = Math.floor(uptimeSeconds / 86400)
|
||||
const h = Math.floor((uptimeSeconds % 86400) / 3600)
|
||||
const m = Math.floor((uptimeSeconds % 3600) / 60)
|
||||
|
||||
let upString = "Up "
|
||||
|
||||
if (d > 0) upString += d + "d "
|
||||
if (h > 0) upString += h + "h "
|
||||
|
||||
upString += m + "m"
|
||||
|
||||
root.uptime = upString
|
||||
|
||||
|
||||
cpuTempProc.running = true
|
||||
diskProc.running = true
|
||||
ipProc.running = true
|
||||
procCountProc.running = true
|
||||
swapProc.running = true
|
||||
keyboardLayoutProc.running = true
|
||||
loggedUsersProc.running = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* CPU Temperature */
|
||||
|
||||
Process {
|
||||
id: cpuTempProc
|
||||
command: [
|
||||
"sh","-c",
|
||||
"for f in /sys/class/hwmon/hwmon*/temp*_input; do cat $f && exit; done"
|
||||
]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const raw = parseInt(text.trim())
|
||||
if (isNaN(raw)) return
|
||||
|
||||
const c = raw / 1000
|
||||
root.cpuTemp = `${Math.round(c)}°C`
|
||||
|
||||
const min = 30
|
||||
const max = 95
|
||||
|
||||
root.cpuTempPercent =
|
||||
Math.max(0, Math.min(1,(c-min)/(max-min)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Disk */
|
||||
|
||||
Process {
|
||||
id: diskProc
|
||||
command: ["df","-h","/"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
|
||||
const lines = text.trim().split("\n")
|
||||
if (lines.length < 2) return
|
||||
|
||||
const parts = lines[1].split(/\s+/)
|
||||
|
||||
const used = parts[2]
|
||||
const total = parts[1]
|
||||
const percent = parseInt(parts[4]) / 100
|
||||
|
||||
root.diskPercent = percent
|
||||
root.diskUsage = `${used}/${total}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Swap */
|
||||
|
||||
Process {
|
||||
id: swapProc
|
||||
command: ["sh","-c","free -m | grep Swap"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
|
||||
const parts = text.trim().split(/\s+/)
|
||||
if (parts.length < 3) return
|
||||
|
||||
const total = parseInt(parts[1])
|
||||
const used = parseInt(parts[2])
|
||||
|
||||
root.swapPercent = used / total
|
||||
root.swapUsage = `${used}/${total} MB`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* IP */
|
||||
|
||||
Process {
|
||||
id: ipProc
|
||||
command: ["sh","-c","hostname -I | awk '{print $1}'"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.ipAddress = text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Process Count */
|
||||
|
||||
Process {
|
||||
id: procCountProc
|
||||
command: ["sh","-c","ps -e --no-headers | wc -l"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.runningProcesses = parseInt(text.trim())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Logged Users */
|
||||
|
||||
Process {
|
||||
id: loggedUsersProc
|
||||
command: ["who","-q"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const lines = text.trim().split("\n")
|
||||
if (lines.length > 0)
|
||||
root.loggedInUsers =
|
||||
parseInt(lines[lines.length-1].replace("# users=",""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Keyboard Layout */
|
||||
|
||||
Process {
|
||||
id: keyboardLayoutProc
|
||||
command: [
|
||||
"sh","-c",
|
||||
"hyprctl devices -j | jq -r '.keyboards[] | .layout' | head -n1"
|
||||
]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const layout = text.trim()
|
||||
if (layout)
|
||||
root.keyboardLayout = layout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* OS Info */
|
||||
|
||||
Process {
|
||||
running: true
|
||||
command: [
|
||||
"sh","-c",
|
||||
"source /etc/os-release && echo \"$PRETTY_NAME|$ID\""
|
||||
]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const parts = text.trim().split("|")
|
||||
root.osName = parts[0]
|
||||
root.osIcon = root.osIcons[parts[1]] || ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Quickshell Version */
|
||||
|
||||
Process {
|
||||
running: true
|
||||
command: ["qs","--version"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.qsVersion =
|
||||
text.trim().split(',')[0]
|
||||
.replace("quickshell ","")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Static system info */
|
||||
|
||||
Process {
|
||||
running: true
|
||||
command: ["whoami"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.username = text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
running: true
|
||||
command: ["hostname"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.hostname = text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
running: true
|
||||
command: ["uname","-r"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.kernelVersion = text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
running: true
|
||||
command: ["uname","-m"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.architecture = text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
54
.config/quickshell/nucleus-shell/services/Theme.qml
Normal file
54
.config/quickshell/nucleus-shell/services/Theme.qml
Normal file
@@ -0,0 +1,54 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.config
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
property var map: ({
|
||||
})
|
||||
|
||||
function notifyMissingVariant(theme, variant) {
|
||||
Quickshell.execDetached(["notify-send", "Nucleus Shell", `Theme '${theme}' does not have a ${variant} variant.`, "--urgency=normal", "--expire-time=5000"]);
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 5000
|
||||
repeat: true
|
||||
running: true
|
||||
onTriggered: loadThemes.running = true
|
||||
}
|
||||
|
||||
Process {
|
||||
id: loadThemes
|
||||
|
||||
command: ["ls", Directories.shellConfig + "/colorschemes"]
|
||||
running: true
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const map = {
|
||||
};
|
||||
text.split("\n").map((t) => {
|
||||
return t.trim();
|
||||
}).filter((t) => {
|
||||
return t.endsWith(".json");
|
||||
}).forEach((t) => {
|
||||
const name = t.replace(/\.json$/, "");
|
||||
const parts = name.split("-");
|
||||
const variant = parts.pop();
|
||||
const base = parts.join("-");
|
||||
if (!map[base])
|
||||
map[base] = {
|
||||
};
|
||||
|
||||
map[base][variant] = name;
|
||||
});
|
||||
root.map = map;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
20
.config/quickshell/nucleus-shell/services/Time.qml
Executable file
20
.config/quickshell/nucleus-shell/services/Time.qml
Executable file
@@ -0,0 +1,20 @@
|
||||
pragma Singleton
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property alias date: clock.date // expose raw date/time
|
||||
readonly property SystemClock clock: clock
|
||||
|
||||
SystemClock {
|
||||
id: clock
|
||||
precision: SystemClock.Seconds
|
||||
}
|
||||
|
||||
// Helper function if you still want formatting ability:
|
||||
function format(fmt) {
|
||||
return Qt.formatDateTime(clock.date, fmt)
|
||||
}
|
||||
}
|
||||
136
.config/quickshell/nucleus-shell/services/UPower.qml
Normal file
136
.config/quickshell/nucleus-shell/services/UPower.qml
Normal file
@@ -0,0 +1,136 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
pragma Singleton
|
||||
|
||||
Item {
|
||||
// I have to make such services because quickshell services like Quickshell.Services.UPower don't work and are messy.
|
||||
|
||||
id: root
|
||||
|
||||
// Battery
|
||||
property int percentage: 0
|
||||
property string state: "unknown"
|
||||
property string iconName: ""
|
||||
property bool onBattery: false
|
||||
property bool charging: false
|
||||
property bool batteryPresent: false
|
||||
property bool rechargeable: false
|
||||
// Energy metrics
|
||||
property real energyWh: 0
|
||||
property real energyFullWh: 0
|
||||
property real energyRateW: 0
|
||||
property real capacityPercent: 0
|
||||
// AC / system
|
||||
property bool acOnline: false
|
||||
property bool lidClosed: false
|
||||
property string battIcon: {
|
||||
const b = percentage;
|
||||
if (b > 80)
|
||||
return "battery_6_bar";
|
||||
|
||||
if (b > 60)
|
||||
return "battery_5_bar";
|
||||
|
||||
if (b > 50)
|
||||
return "battery_4_bar";
|
||||
|
||||
if (b > 40)
|
||||
return "battery_3_bar";
|
||||
|
||||
if (b > 30)
|
||||
return "battery_2_bar";
|
||||
|
||||
if (b > 20)
|
||||
return "battery_1_bar";
|
||||
|
||||
if (b > 10)
|
||||
return "battery_alert";
|
||||
|
||||
return "battery_0_bar";
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 2000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: upowerProc.running = true
|
||||
}
|
||||
|
||||
Process {
|
||||
id: upowerProc
|
||||
|
||||
command: ["upower", "-d"]
|
||||
running: true
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
// ---------- DisplayDevice (preferred) ----------
|
||||
// ---------- Rechargeable ----------
|
||||
// ---------- Physical battery (extra info) ----------
|
||||
// ---------- Daemon / system ----------
|
||||
// ---------- AC adapter ----------
|
||||
|
||||
const t = text;
|
||||
let m;
|
||||
m = t.match(/DisplayDevice[\s\S]*?present:\s+(yes|no)/);
|
||||
if (m) {
|
||||
root.batteryPresent = (m[1] === "yes");
|
||||
} else {
|
||||
// fallback: physical battery
|
||||
m = t.match(/battery_BAT\d+[\s\S]*?present:\s+(yes|no)/);
|
||||
if (m)
|
||||
root.batteryPresent = (m[1] === "yes");
|
||||
|
||||
}
|
||||
m = t.match(/DisplayDevice[\s\S]*?rechargeable:\s+(yes|no)/);
|
||||
if (m)
|
||||
root.rechargeable = (m[1] === "yes");
|
||||
|
||||
m = t.match(/DisplayDevice[\s\S]*?percentage:\s+(\d+)%/);
|
||||
if (m)
|
||||
root.percentage = parseInt(m[1]);
|
||||
|
||||
m = t.match(/DisplayDevice[\s\S]*?state:\s+([a-z\-]+)/);
|
||||
if (m) {
|
||||
root.state = m[1];
|
||||
root.charging = (m[1].includes("charge"));
|
||||
}
|
||||
m = t.match(/DisplayDevice[\s\S]*?icon-name:\s+'([^']+)'/);
|
||||
if (m)
|
||||
root.iconName = m[1];
|
||||
|
||||
m = t.match(/DisplayDevice[\s\S]*?energy:\s+([\d.]+)\s+Wh/);
|
||||
if (m)
|
||||
root.energyWh = parseFloat(m[1]);
|
||||
|
||||
m = t.match(/DisplayDevice[\s\S]*?energy-full:\s+([\d.]+)\s+Wh/);
|
||||
if (m)
|
||||
root.energyFullWh = parseFloat(m[1]);
|
||||
|
||||
m = t.match(/DisplayDevice[\s\S]*?energy-rate:\s+([\d.]+)\s+W/);
|
||||
if (m)
|
||||
root.energyRateW = parseFloat(m[1]);
|
||||
|
||||
m = t.match(/capacity:\s+([\d.]+)%/);
|
||||
if (m)
|
||||
root.capacityPercent = parseFloat(m[1]);
|
||||
|
||||
m = t.match(/on-battery:\s+(yes|no)/);
|
||||
if (m)
|
||||
root.onBattery = (m[1] === "yes");
|
||||
|
||||
m = t.match(/lid-is-closed:\s+(yes|no)/);
|
||||
if (m)
|
||||
root.lidClosed = (m[1] === "yes");
|
||||
|
||||
m = t.match(/line-power[\s\S]*?online:\s+(yes|no)/);
|
||||
if (m)
|
||||
root.acOnline = (m[1] === "yes");
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
100
.config/quickshell/nucleus-shell/services/UpdateNotifier.qml
Normal file
100
.config/quickshell/nucleus-shell/services/UpdateNotifier.qml
Normal file
@@ -0,0 +1,100 @@
|
||||
import Qt.labs.platform
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: updater
|
||||
// Add 'v' arg to default local version because it is not stored
|
||||
// as vX.Y.Z but X.Y.Z while on github its published as vX.Y.Z
|
||||
|
||||
property string currentVersion: ""
|
||||
property string latestVersion: ""
|
||||
property bool notified: false
|
||||
property string channel: Config.runtime.shell.releaseChannel || "stable"
|
||||
|
||||
function readLocalVersion() {
|
||||
currentVersion = "v" + (Config.runtime.shell.version || "");
|
||||
}
|
||||
|
||||
function fetchLatestVersion() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText);
|
||||
|
||||
if (channel === "stable") {
|
||||
// /releases/latest returns a single object
|
||||
if (json.tag_name) {
|
||||
latestVersion = json.tag_name;
|
||||
compareVersions();
|
||||
} else {
|
||||
console.warn("Stable update check returned unexpected response:", json);
|
||||
}
|
||||
} else if (channel === "edge") {
|
||||
// /releases returns an array, newest first
|
||||
for (var i = 0; i < json.length; i++) {
|
||||
if (json[i].prerelease === true) {
|
||||
latestVersion = json[i].tag_name;
|
||||
compareVersions();
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.warn("Edge channel: no pre-release found.");
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Update check JSON parse failed:", xhr.responseText);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (channel === "stable") {
|
||||
xhr.open(
|
||||
"GET",
|
||||
"https://api.github.com/repos/xzepyx/nucleus-shell/releases/latest"
|
||||
);
|
||||
} else {
|
||||
xhr.open(
|
||||
"GET",
|
||||
"https://api.github.com/repos/xzepyx/nucleus-shell/releases"
|
||||
);
|
||||
}
|
||||
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function compareVersions() {
|
||||
if (!currentVersion || !latestVersion)
|
||||
return;
|
||||
|
||||
if (currentVersion !== latestVersion && !notified) {
|
||||
notifyUpdate();
|
||||
notified = true;
|
||||
}
|
||||
}
|
||||
|
||||
function notifyUpdate() {
|
||||
Quickshell.execDetached([
|
||||
"notify-send",
|
||||
"-a", "Nucleus Shell",
|
||||
"Update Available",
|
||||
"Installed: " + currentVersion +
|
||||
"\nLatest (" + channel + "): " + latestVersion
|
||||
]);
|
||||
}
|
||||
|
||||
visible: false
|
||||
|
||||
Timer {
|
||||
interval: 24 * 60 * 60 * 1000 // 24 hours
|
||||
running: true
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: {
|
||||
readLocalVersion();
|
||||
fetchLatestVersion();
|
||||
}
|
||||
}
|
||||
}
|
||||
50
.config/quickshell/nucleus-shell/services/Volume.qml
Executable file
50
.config/quickshell/nucleus-shell/services/Volume.qml
Executable file
@@ -0,0 +1,50 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
|
||||
|
||||
Singleton {
|
||||
PwObjectTracker {
|
||||
objects: [
|
||||
Pipewire.defaultAudioSource,
|
||||
Pipewire.defaultAudioSink,
|
||||
Pipewire.nodes,
|
||||
Pipewire.links
|
||||
]
|
||||
}
|
||||
|
||||
property var sinks: Pipewire.nodes.values.filter(node => node.isSink && !node.isStream && node.audio)
|
||||
property PwNode defaultSink: Pipewire.defaultAudioSink
|
||||
|
||||
property var sources: Pipewire.nodes.values.filter(node => !node.isSink && !node.isStream && node.audio)
|
||||
property PwNode defaultSource: Pipewire.defaultAudioSource
|
||||
|
||||
property real volume: defaultSink?.audio?.volume ?? 0
|
||||
property bool muted: defaultSink?.audio?.muted ?? false
|
||||
|
||||
function setVolume(to: real): void {
|
||||
if (defaultSink?.ready && defaultSink?.audio) {
|
||||
defaultSink.audio.muted = false;
|
||||
defaultSink.audio.volume = Math.max(0, Math.min(1, to));
|
||||
}
|
||||
}
|
||||
|
||||
function setSourceVolume(to: real): void {
|
||||
if (defaultSource?.ready && defaultSource?.audio) {
|
||||
defaultSource.audio.muted = false;
|
||||
defaultSource.audio.volume = Math.max(0, Math.min(1, to));
|
||||
}
|
||||
}
|
||||
|
||||
function setDefaultSink(sink: PwNode): void {
|
||||
Pipewire.preferredDefaultAudioSink = sink;
|
||||
}
|
||||
|
||||
function setDefaultSource(source: PwNode): void {
|
||||
Pipewire.preferredDefaultAudioSource = source;
|
||||
}
|
||||
|
||||
function init() {
|
||||
}
|
||||
}
|
||||
140
.config/quickshell/nucleus-shell/services/WallpaperSlideshow.qml
Normal file
140
.config/quickshell/nucleus-shell/services/WallpaperSlideshow.qml
Normal file
@@ -0,0 +1,140 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var wallpapers: []
|
||||
property bool scanning: false
|
||||
property string currentFolder: Config.initialized ? Config.runtime.appearance.background.slideshow.folder : "";
|
||||
property int intervalMinutes: Config.initialized ? Config.runtime.appearance.background.slideshow.interval : 5
|
||||
property bool enabled: Config.initialized ? Config.runtime.appearance.background.slideshow.enabled : false
|
||||
property bool includeSubfolders: Config.initialized ? Config.runtime.appearance.background.slideshow.includeSubfolders : true
|
||||
property bool initializedOnce: false
|
||||
property bool hydratingFromConfig: true
|
||||
|
||||
signal wallpaperChanged(string path)
|
||||
|
||||
function nextWallpaper() {
|
||||
if (wallpapers.length === 0) {
|
||||
console.warn("WallpaperSlideshow: No wallpapers found in folder");
|
||||
return;
|
||||
}
|
||||
const randomIndex = Math.floor(Math.random() * wallpapers.length);
|
||||
const selectedPath = "file://" + wallpapers[randomIndex];
|
||||
Config.updateKey("appearance.background.path", selectedPath);
|
||||
wallpaperChanged(selectedPath);
|
||||
|
||||
// Regenerate colors
|
||||
if (Config.runtime.appearance.colors.autogenerated) {
|
||||
Quickshell.execDetached([
|
||||
"nucleus", "ipc", "call", "global", "regenColors"
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function scanFolder() {
|
||||
if (!currentFolder || currentFolder === "") {
|
||||
wallpapers = [];
|
||||
return;
|
||||
}
|
||||
scanning = true;
|
||||
scanProcess.running = true;
|
||||
}
|
||||
|
||||
// Timer for automatic wallpaper rotation
|
||||
Timer {
|
||||
id: slideshowTimer
|
||||
interval: root.intervalMinutes * 60 * 1000 // Convert minutes into miliseconds
|
||||
repeat: true
|
||||
running: root.enabled && root.wallpapers.length > 0 && root.initializedOnce
|
||||
onTriggered: root.nextWallpaper()
|
||||
}
|
||||
|
||||
// Process to scan folder for images
|
||||
Process {
|
||||
id: scanProcess
|
||||
command: root.includeSubfolders
|
||||
? ["find", root.currentFolder, "-type", "f", "-iregex", ".*\\.\\(jpg\\|jpeg\\|png\\|webp\\|bmp\\|svg\\)$"]
|
||||
: ["find", root.currentFolder, "-maxdepth", "1", "-type", "f", "-iregex", ".*\\.\\(jpg\\|jpeg\\|png\\|webp\\|bmp\\|svg\\)$"]
|
||||
|
||||
stdout: SplitParser {
|
||||
splitMarker: ""
|
||||
onRead: data => {
|
||||
const lines = data.trim().split("\n").filter(line => line.length > 0);
|
||||
root.wallpapers = lines;
|
||||
root.scanning = false;
|
||||
}
|
||||
}
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.scanning = false;
|
||||
if (exitCode !== 0) {
|
||||
console.warn("WallpaperSlideshow: Failed to scan folder");
|
||||
root.wallpapers = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for folder changes - rescan and immediately change wallpaper
|
||||
onCurrentFolderChanged: {
|
||||
if (root.hydratingFromConfig)
|
||||
return;
|
||||
if (!currentFolder || currentFolder === "")
|
||||
return;
|
||||
scanning = true;
|
||||
folderChangeScanProcess.running = true;
|
||||
}
|
||||
|
||||
// Separate process for folder change scan (triggers immediate wallpaper change)
|
||||
Process {
|
||||
id: folderChangeScanProcess
|
||||
command: root.includeSubfolders
|
||||
? ["find", root.currentFolder, "-type", "f", "-iregex", ".*\\.\\(jpg\\|jpeg\\|png\\|webp\\|bmp\\|svg\\)$"]
|
||||
: ["find", root.currentFolder, "-maxdepth", "1", "-type", "f", "-iregex", ".*\\.\\(jpg\\|jpeg\\|png\\|webp\\|bmp\\|svg\\)$"]
|
||||
|
||||
stdout: SplitParser {
|
||||
splitMarker: ""
|
||||
onRead: data => {
|
||||
const lines = data.trim().split("\n").filter(line => line.length > 0);
|
||||
root.wallpapers = lines;
|
||||
root.scanning = false;
|
||||
|
||||
// Immediately change wallpaper when folder is changed
|
||||
if (root.wallpapers.length > 0) {
|
||||
root.nextWallpaper();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.scanning = false;
|
||||
if (exitCode !== 0) {
|
||||
console.warn("WallpaperSlideshow: Failed to scan folder");
|
||||
root.wallpapers = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for includeSubfolders changes
|
||||
onIncludeSubfoldersChanged: {
|
||||
if (currentFolder && currentFolder !== "") {
|
||||
scanFolder();
|
||||
}
|
||||
}
|
||||
|
||||
// Initial scan when config is loaded
|
||||
Connections {
|
||||
target: Config
|
||||
function onInitializedChanged() {
|
||||
if (Config.initialized && root.currentFolder && root.currentFolder !== "") {
|
||||
root.scanFolder();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
55
.config/quickshell/nucleus-shell/services/Xrandr.qml
Normal file
55
.config/quickshell/nucleus-shell/services/Xrandr.qml
Normal file
@@ -0,0 +1,55 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
pragma Singleton
|
||||
|
||||
/*
|
||||
|
||||
Why use this service? Good question.
|
||||
:- Cause xrandr works with all compositors to fetch monitor data.
|
||||
|
||||
*/
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// Array of monitor objects: { name, width, height, x, y, physWidth, physHeight }
|
||||
property var monitors: []
|
||||
|
||||
// Refresh monitors every 5 seconds
|
||||
Timer {
|
||||
interval: 5000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: xrandrProc.running = true
|
||||
}
|
||||
|
||||
Process {
|
||||
id: xrandrProc
|
||||
command: ["bash", "-c", "xrandr --query | grep ' connected '"]
|
||||
running: true
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: { // I don't even know wtf is this I can't explain shit
|
||||
const lines = text.trim().split("\n")
|
||||
root.monitors = lines.map(line => {
|
||||
const m = line.match(/^(\S+)\sconnected\s(\d+)x(\d+)\+(\d+)\+(\d+).*?(\d+)mm\sx\s(\d+)mm$/)
|
||||
if (!m) return null
|
||||
return {
|
||||
name: m[1],
|
||||
width: parseInt(m[2]),
|
||||
height: parseInt(m[3]),
|
||||
x: parseInt(m[4]),
|
||||
y: parseInt(m[5]),
|
||||
physWidth: parseInt(m[6]),
|
||||
physHeight: parseInt(m[7])
|
||||
}
|
||||
}).filter(m => m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getMonitor(name) {
|
||||
return monitors.find(m => m.name === name) || null
|
||||
}
|
||||
}
|
||||
82
.config/quickshell/nucleus-shell/services/Zenith.qml
Normal file
82
.config/quickshell/nucleus-shell/services/Zenith.qml
Normal file
@@ -0,0 +1,82 @@
|
||||
// Zenith.properties
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.config
|
||||
import qs.modules.functions
|
||||
|
||||
Singleton {
|
||||
// current state and shit
|
||||
property string currentChat: "default"
|
||||
property string currentModel: "gpt-4o-mini"
|
||||
property string pendingInput: ""
|
||||
property bool loading: zenithProcess.running
|
||||
|
||||
// signals (needed for ui loading)
|
||||
signal chatsListed(string text)
|
||||
signal chatLoaded(string text)
|
||||
signal aiReply(string text)
|
||||
|
||||
// process to load data and talk to zenith
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
repeat: true
|
||||
running: true
|
||||
onTriggered: listChatsProcess.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: listChatsProcess
|
||||
command: ["ls", FileUtils.trimFileProtocol(Directories.config) + "/zenith/chats"]
|
||||
running: true
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: chatsListed(text)
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: chatLoadProcess
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: chatLoaded(text)
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: zenithProcess
|
||||
|
||||
command: [
|
||||
"zenith",
|
||||
"--api", Config.runtime.misc.intelligence.apiKey,
|
||||
"--chat", currentChat,
|
||||
"-a",
|
||||
"--model", currentModel,
|
||||
pendingInput
|
||||
]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (text.trim() !== "")
|
||||
aiReply(text.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// api shit
|
||||
|
||||
function loadChat(chatName) {
|
||||
chatLoadProcess.command = [
|
||||
"cat",
|
||||
FileUtils.trimFileProtocol(Directories.config)
|
||||
+ "/zenith/chats/" + chatName + ".txt"
|
||||
]
|
||||
chatLoadProcess.running = true
|
||||
}
|
||||
|
||||
function send() {
|
||||
zenithProcess.running = true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user