Promote shell to its own top-level directory

This commit is contained in:
David Heinemeier Hansson
2026-05-18 14:56:59 +02:00
parent d782705878
commit 0fe985b45d
83 changed files with 50 additions and 28 deletions
+99
View File
@@ -0,0 +1,99 @@
# First-party plugins
These plugins ship with Omarchy and are loaded by the shell at startup.
They use the same `manifest.json` contract as third-party plugins; the
only difference is that the shell flags them with `__isFirstParty: true`
so they cannot be disabled.
User-installed plugins live alongside these conceptually but on disk under
`~/.config/omarchy/plugins/<plugin-id>/` rather than in this directory.
| Plugin | id | kinds | activation | entry point |
|---------------|-------------------------|-----------|------------|-------------------------------------|
| Bar | `omarchy.bar` | `bar` | persistent | `bar/Bar.qml` |
| Bar settings | `omarchy.settings` | `panel` | on-demand | `settings/SettingsPanel.qml` |
| Image picker | `omarchy.image-picker` | `overlay` | on-demand | `image-picker/ImagePicker.qml` |
| Emoji picker | `omarchy.emoji-picker` | `overlay` | on-demand | `emoji-picker/EmojiPicker.qml` |
| Clipboard mgr | `omarchy.clipboard-picker`| `overlay` | on-demand | `clipboard-picker/ClipboardPicker.qml`|
| Omarchy menu | `omarchy.menu` | `menu` | on-demand | `menu/Menu.qml` |
| Notifications | `omarchy.notifications` | `service` | persistent | `notifications/Service.qml` |
| OSD | `omarchy.osd` | `panel` | persistent | `osd/Osd.qml` |
| Polkit agent | `omarchy.polkit` | `service` | persistent | `polkit/PolkitAgent.qml` |
## Bar
The status bar. Mounted at startup, lives forever. Layout lives in the
top-level `bar:` subtree of `~/.config/omarchy/shell.json` (with the shell
providing [`shell-defaults.json`](../shell-defaults.json) when the user has
no file). Owns the `bar` IPC target for refresh hooks fired by indicator
scripts. See [`bar/README.md`](bar/README.md) for the widget catalogue
and customization schema.
## Bar settings
Visual editor for the bar layout. Summoned by
`omarchy-shell shell summon omarchy.settings "{}"` (which is what
`omarchy launch bar settings` ultimately calls). Provides:
- bar position and center-anchor controls
- per-section add/move/remove/edit of bar widget entries
- dynamic per-widget settings forms that write inline back to the
corresponding shell.json entry
## Image picker
Fullscreen image-grid selector overlay. Used by `omarchy-menu-images`
(wallpaper picker) and `omarchy-theme-switcher` (theme picker) and any
other caller that wants to present a directory of images with previews.
Two ways to drive it:
- Shell-level summon: `omarchy-shell shell summon omarchy.image-picker '<jsonPayload>'`.
The payload can carry `imageDirs`, `imageRows`, `selectedImage`,
`selectionFile`, `doneFile`, `showLabels`, `filterable`. Best for
in-shell callers that already speak JSON.
- Direct IPC target: `omarchy-shell image-selector open <imageDirs> <imageRowsB64> <selectedImage> <selectionFile> <doneFile> <showLabels> <filterable>`.
Positional args; `imageRowsB64` is base64-encoded so embedded newlines /
tabs survive the bash argv handoff. This is what `omarchy-menu-images`
uses. Colors come from the central shell theme singleton; there is no
per-call override surface.
The selection round-trip remains file-based: callers create a
`selection_file` and `done_file` (both `mktemp`), pass the paths, and
poll `done_file` for existence. The plugin writes the chosen path into
`selection_file` and touches `done_file` when it's done. `cancel` IPC
clears it without writing a selection.
The plugin has `keepLoaded: true` so the layer-shell window survives
between summons within a single shell session.
## Polkit agent
Theme-aware authentication dialog for privileged actions. It uses
Quickshell's native `Quickshell.Services.Polkit.PolkitAgent` backend and
runs inside the long-lived `omarchy-shell` process, replacing the old
`polkit-gnome-authentication-agent-1` autostart.
## Omarchy menu
Quickshell-powered replacement for the legacy Walker-driven `omarchy-menu`.
The menu UI lives in `menu/Menu.qml` as a first-party `menu` plugin and is
summoned through the shell (`omarchy-shell shell summon omarchy.menu ...`),
so it shares the long-running `omarchy-shell` process instead of starting a
second Quickshell instance.
The menu definition lives outside the shell host code:
- defaults: `default/omarchy/omarchy-menu.jsonc`
- user extensions: `~/.config/omarchy/extensions/omarchy-menu.jsonc`
The shell parses both JSONC files at startup (with `watchChanges: true`
so edits take effect without a restart), evaluates `when:` / `checked:`
bash expressions in a single batched subprocess, and executes the
selected `action:` string directly via `Quickshell.execDetached`. The
long-running shell process keeps the parsed menu in memory, so the
keybind → IPC → visible path costs ~30ms cold.
## Coming soon
- `omarchy.theme-switcher` — folds theme switching into the shell.
+312
View File
@@ -0,0 +1,312 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import QtQuick.Effects
import QtQuick.Shapes
import qs.Commons as NoctaliaCommons
Item {
id: root
property string omarchyPath: ""
property var shell: null
property var manifest: null
readonly property string home: Quickshell.env("HOME")
readonly property string currentBackgroundLink: home + "/.config/omarchy/current/background"
property string currentBackground: ""
property string displayedBackground: ""
property string incomingBackground: ""
property string oldBackground: ""
property bool finishingTransition: false
property int backgroundVersion: 0
property int revealStartedVersion: -1
property int pendingThemeVersion: -1
property string pendingColorsRaw: ""
property string pendingShellRaw: ""
property real revealProgress: 1
function imageUrl(path) {
if (!path) return ""
return "file://" + path
}
function refreshBackground() {
if (!readlinkProc.running) readlinkProc.running = true
}
function setBackground(path, instant) {
transitionBackground("", path, path, instant, false)
}
function transitionBackground(fromPath, path, finalPath, instant, force) {
path = String(path || "").trim()
finalPath = String(finalPath || path).trim()
fromPath = String(fromPath || "").trim()
if (!path || (!force && finalPath === currentBackground)) return
currentBackground = finalPath
backgroundVersion += 1
revealStartedVersion = -1
revealAnimation.stop()
finishingTransition = false
if (instant || !displayedBackground) {
oldBackground = ""
incomingBackground = ""
displayedBackground = path
revealProgress = 1
return
}
oldBackground = fromPath || displayedBackground
incomingBackground = path
revealProgress = 0
}
function decodePayload(payload) {
try { return Qt.atob(String(payload || "")) } catch (e) { return "" }
}
function setPendingTheme(colorsB64, shellB64) {
pendingColorsRaw = decodePayload(colorsB64)
pendingShellRaw = decodePayload(shellB64)
pendingThemeVersion = backgroundVersion
}
function applyPendingTheme() {
if (pendingThemeVersion !== backgroundVersion) return
NoctaliaCommons.Color.resumeThemeReloads()
NoctaliaCommons.Color.loadColors(pendingColorsRaw)
NoctaliaCommons.Color.loadShell(pendingShellRaw)
pendingThemeVersion = -1
pendingColorsRaw = ""
pendingShellRaw = ""
}
function transitionBackgroundWithTheme(fromPath, path, finalPath, colorsB64, shellB64) {
transitionBackground(fromPath, path, finalPath, false, true)
setPendingTheme(colorsB64, shellB64)
if (!incomingBackground || revealProgress >= 1) applyPendingTheme()
}
function startReveal(panel) {
if (!incomingBackground) return
panel.maskReady = true
if (revealStartedVersion === backgroundVersion) return
revealStartedVersion = backgroundVersion
applyPendingTheme()
revealAnimation.restart()
}
function openSelector() {
if (!bgSwitchProc.running) bgSwitchProc.running = true
}
function openThemeSwitcher() {
if (!themeSwitchProc.running) themeSwitchProc.running = true
}
Process {
id: bgSwitchProc
command: ["bash", "-lc", "background=$(omarchy-theme-bg-switcher); [[ -n $background ]] && omarchy-theme-bg-set \"$background\""]
onExited: root.refreshBackground()
}
Process {
id: themeSwitchProc
command: ["bash", "-lc", "theme=$(omarchy-theme-switcher); [[ -n $theme ]] && omarchy-theme-set \"$theme\""]
onExited: root.refreshBackground()
}
Process {
id: readlinkProc
command: ["readlink", "-f", root.currentBackgroundLink]
stdout: StdioCollector {
onStreamFinished: root.setBackground(String(text || "").trim(), false)
}
}
IpcHandler {
target: "background"
function refresh(): void {
root.refreshBackground()
}
function set(path: string): void {
root.setBackground(path, false)
}
function setInstant(path: string): void {
root.setBackground(path, true)
}
function transition(fromPath: string, path: string): void {
root.transitionBackground(fromPath, path, path, false, false)
}
function themeTransition(fromPath: string, path: string, finalPath: string, colorsB64: string, shellB64: string): void {
root.transitionBackgroundWithTheme(fromPath, path, finalPath, colorsB64, shellB64)
}
}
Timer {
interval: 100
running: true
repeat: true
onTriggered: root.refreshBackground()
}
NumberAnimation {
id: revealAnimation
target: root
property: "revealProgress"
from: 0
to: 1
duration: 420
easing.type: Easing.InOutCubic
onFinished: {
if (root.incomingBackground) {
root.displayedBackground = root.currentBackground || root.incomingBackground
root.finishingTransition = true
}
root.revealProgress = 1
}
}
Component.onCompleted: refreshBackground()
Variants {
model: Quickshell.screens
PanelWindow {
id: panel
required property var modelData
screen: modelData
visible: true
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
property bool maskReady: false
function maybeStartReveal() {
if (!root.incomingBackground || root.revealProgress !== 0 || maskReady) return
if (incomingFrame.status !== Image.Ready) return
Qt.callLater(function() {
if (!root.incomingBackground || root.revealProgress !== 0 || maskReady) return
if (incomingFrame.status !== Image.Ready) return
root.startReveal(panel)
})
}
WlrLayershell.namespace: "omarchy-background"
WlrLayershell.layer: WlrLayer.Background
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
Image {
id: base
anchors.fill: parent
source: root.imageUrl(root.displayedBackground)
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
onStatusChanged: {
if (status === Image.Ready && root.finishingTransition) {
root.incomingBackground = ""
root.oldBackground = ""
root.finishingTransition = false
}
}
}
Image {
id: oldFrame
anchors.fill: parent
source: root.imageUrl(root.oldBackground)
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
smooth: true
mipmap: true
visible: root.oldBackground !== "" && root.revealProgress < 1
onStatusChanged: panel.maybeStartReveal()
}
Item {
id: incomingLayer
anchors.fill: parent
visible: root.incomingBackground !== "" && incomingFrame.status === Image.Ready && (root.revealProgress >= 1 || panel.maskReady)
layer.enabled: root.incomingBackground !== "" && root.revealProgress < 1
layer.smooth: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: revealMask
maskThresholdMin: 0.5
maskSpreadAtMin: 0.02
}
Image {
id: incomingFrame
anchors.fill: parent
source: root.imageUrl(root.incomingBackground)
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
smooth: true
mipmap: true
onStatusChanged: panel.maybeStartReveal()
}
}
Item {
id: revealMask
anchors.fill: parent
visible: false
layer.enabled: true
readonly property real slant: -0.18
readonly property real centerTop: width / 2 - slant * height / 2
readonly property real centerBottom: width / 2 + slant * height / 2
readonly property real reach: width / 2 + Math.abs(slant) * height / 2 + 4
readonly property real spread: reach * root.revealProgress
Shape {
anchors.fill: parent
antialiasing: true
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: "white"
strokeColor: "transparent"
startX: revealMask.centerTop - revealMask.spread; startY: 0
PathLine { x: revealMask.centerTop + revealMask.spread; y: 0 }
PathLine { x: revealMask.centerBottom + revealMask.spread; y: revealMask.height }
PathLine { x: revealMask.centerBottom - revealMask.spread; y: revealMask.height }
PathLine { x: revealMask.centerTop - revealMask.spread; y: 0 }
}
}
}
Connections {
target: root
function onIncomingBackgroundChanged() {
panel.maskReady = false
panel.maybeStartReveal()
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function(mouse) {
if (mouse.button === Qt.RightButton) root.openThemeSwitcher()
else root.openSelector()
mouse.accepted = true
}
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"schemaVersion": 1,
"id": "omarchy.background",
"name": "Background",
"version": "1.0.0",
"author": "Omarchy",
"description": "Desktop background renderer with click handling and transitions",
"kinds": ["service"],
"activation": "startup",
"entryPoints": { "service": "Background.qml" }
}
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
# Omarchy bar
This is the Quickshell implementation of the Omarchy status bar. It is
shipped as a first-party plugin of [`omarchy-shell`](../../README.md), the
long-running shell host. The bar is mounted at startup and lives inside
the shell for its whole session.
- `manifest.json` declares the plugin (`id: omarchy.bar`, `kind: bar`, `activation: persistent`) and points at `Bar.qml` as the entry point.
- `Bar.qml` is Omarchy-owned bar engine code, loaded by the omarchy-shell host. Users should not edit it directly.
- `widgets/` holds first-party widgets — modular, interactive components shipped with Omarchy.
- `common/` holds shared QML helpers (buttons, sliders, popup cards).
- The bar receives its config from the host shell as a `barConfig` property; the host loads it from `~/.config/omarchy/shell.json` (or `shell-defaults.json` when the user has no file).
- `omarchy-style-bar-position` updates only the user shell.json file.
## Customizing
The bar config lives under the `bar:` key of [`~/.config/omarchy/shell.json`](../../README.md#shelljson-shape). Out of the box the shell uses [`shell-defaults.json`](../../shell-defaults.json). Once you customize anything via `omarchy launch bar settings` or by editing shell.json directly, your file is canonical — there is no deep-merge.
Launch the visual editor with `omarchy launch bar settings` (or run `omarchy-launch-bar-settings`) to reorder widgets, add/remove them, and tweak per-widget options without editing JSON by hand. You can also right-click empty space to the left or right of the centered clock to open it; double-left-click the same empty space to toggle bar transparency.
Example `shell.json` (bar subtree only shown):
```json
{
"version": 1,
"bar": {
"position": "top",
"transparent": false,
"centerAnchor": "calendar",
"layout": {
"left": [
{ "id": "omarchy" },
{ "id": "spacer", "size": 12 },
{ "id": "workspaces" }
],
"center": [
{ "id": "media" },
{ "id": "calendar", "format": "HH:mm" }
],
"right": [
{ "id": "audioPanel" },
{ "id": "battery" }
]
}
}
}
```
`centerAnchor` pins one center module to the exact horizontal/vertical center and flanks others around it. Set to an empty string to disable anchoring (the center list is centered as a group).
## Module catalogue
### First-party interactive widgets (in `widgets/`)
| Name | What it does | Interactions |
|---|---|---|
| `media` | MPRIS now-playing — scrolling track + artist, cover-art popup | left = play/pause · middle = next · scroll = prev/next · right = popup |
| `audioPanel` | Volume icon + popup with master slider, output-device picker, per-app mixer | left = popup · right = mute · middle = audio TUI · scroll = volume |
| `networkPanel` | Wi-Fi/Ethernet icon + popup with Wi-Fi scan, signal, connect, DNS provider selection | left = popup · right = nmtui |
| `bluetoothPanel` | Bluetooth icon + popup with device list, connect/disconnect, battery | left = popup · right = toggle radio · middle = bluetoothctl TUI |
| `calendar` | Clock + popup with month-grid calendar | left = popup · right = tz selector |
| `notificationCenter` | Bell with badge + popup with recent notifications, DND toggle | left = popup · right = toggle DND |
| `systemStats` | Inline CPU + memory sparklines, popup with detail | left = popup · right = terminal |
| `weatherFlyout` | Weather icon + popup with forecast | left = popup · right = full notification |
| `idleInhibitor` | Coffee-cup that toggles `omarchy-toggle-idle` | left = toggle |
| `microphone` | Mic icon + scroll volume | left = mute toggle · middle = audio TUI · scroll = source volume |
### Built-in legacy modules (in `shell.qml`)
`omarchy`, `workspaces`, `clock`, `weather`, `update`, `voxtype`, `screenRecording`, `idle`, `notifications`, `tray`, `bluetooth`, `network`, `audio`, `cpu`, `battery`.
These remain available — set them in `layout` to use them instead of the richer widget versions.
## Orientation
All widgets work in `top`, `bottom`, `left`, and `right` positions. Popups anchor on the side opposite the bar edge, sliding into the workspace. Vertical bars use 28px width; widgets that show text fall back to compact icon-only forms (e.g. `media` hides its scrolling label).
## Custom user modules
The schema accepts arbitrary module ids that you provide. Set `type` to `command` for shell-driven output or `qml` for a custom QML widget. Both still go under `bar.layout.<section>` in `shell.json`.
Command module:
```json
{
"version": 1,
"bar": {
"layout": {
"right": [
{ "id": "tray" },
{ "id": "vpn", "type": "command", "exec": "~/.config/omarchy/bar/scripts/vpn-status", "interval": 5, "tooltip": "VPN", "onClick": "nm-connection-editor" },
{ "id": "audioPanel" }
]
}
}
}
```
The command may print plain text or Waybar-style JSON, for example:
```json
{"text":"󰌆","tooltip":"Work VPN","class":"active"}
```
QML module:
```json
{
"version": 1,
"bar": {
"layout": {
"right": [
{ "id": "gpu", "type": "qml" },
{ "id": "audioPanel" }
]
}
}
}
```
Then create `~/.config/omarchy/bar/modules/gpu.qml`. If you want to store it elsewhere, add a `source` path.
Custom QML modules should be an `Item` with `implicitWidth` and `implicitHeight`. They may optionally define these properties, which the bar fills after loading:
```qml
import QtQuick
Item {
property var bar
property string moduleName
property var settings
implicitWidth: 28
implicitHeight: bar ? bar.barSize : 26
Text {
anchors.centerIn: parent
text: "GPU"
color: bar ? bar.foreground : "white"
font.family: bar ? bar.fontFamily : "monospace"
font.pixelSize: 12
}
MouseArea {
anchors.fill: parent
onClicked: if (bar) bar.run("omarchy-launch-or-focus-tui btop")
}
}
```
## Bar properties available to widgets
Widgets receive `bar` (the shell root), `moduleName` (string), and `settings` (object) injected at load time. The bar exposes:
- `bar.foreground`, `bar.background`, `bar.urgent` — theme colors (live-updated)
- `bar.fontFamily` — current monospace family
- `bar.position``"top" | "bottom" | "left" | "right"`
- `bar.vertical` — boolean shortcut
- `bar.barSize` — 26 horizontal / 28 vertical
- `bar.run(command)` — fire-and-forget bash exec
- `bar.shellQuote(value)` — safe shell-quote a string
- `bar.showTooltip(target, text)` / `bar.hideTooltip(target)` — shared tooltip popup
- `bar.requestPopout(owner)` / `bar.releasePopout(owner)` — one-popup-at-a-time coordinator
First-party widgets live in `widgets/<name>.qml` and are picked up by the
shell's `BarWidgetRegistry` at startup; reference one by `id` in any
layout list.
Third-party widgets ship as separate plugins under
`~/.config/omarchy/plugins/<plugin-id>/` with their own `manifest.json`
declaring `kinds: ["bar-widget"]` and a `barWidget` entry point. See
[../../README.md](../../README.md) for the manifest schema. Enable or
rescan third-party plugins with `omarchy-shell shell setPluginEnabled`
and `omarchy-shell shell rescanPlugins`.
+11
View File
@@ -0,0 +1,11 @@
{
"schemaVersion": 1,
"id": "omarchy.bar",
"name": "Bar",
"version": "1.0.0",
"author": "Omarchy",
"description": "Status bar with widgets",
"kinds": ["bar"],
"activation": "persistent",
"entryPoints": { "bar": "Bar.qml" }
}
@@ -0,0 +1,70 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
Item {
id: root
property QtObject bar: null
property string moduleName: "activeWindow"
property var settings: ({})
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null ? fallback : value
}
readonly property var toplevel: ToplevelManager.activeToplevel
readonly property string title: toplevel ? (toplevel.title || toplevel.appId || "") : ""
readonly property int maxLabelWidth: Number(setting("maxWidth", 280))
readonly property bool vertical: bar ? bar.vertical : false
visible: title !== "" && !vertical
implicitWidth: visible ? Math.min(maxLabelWidth, labelText.implicitWidth) + 16 : 0
implicitHeight: bar ? bar.barSize : 26
Behavior on implicitWidth {
NumberAnimation { duration: 180; easing.type: Easing.OutCubic }
}
Item {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
clip: true
Text {
id: labelText
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: parent.width
text: root.title
color: root.bar ? root.bar.foreground : "#cacccc"
font.family: root.bar ? root.bar.fontFamily : "JetBrainsMono Nerd Font"
font.pixelSize: 12
elide: Text.ElideRight
opacity: 0.85
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: function(mouse) {
if (!root.toplevel) return
if (mouse.button === Qt.MiddleButton) {
root.toplevel.close()
} else if (mouse.button === Qt.RightButton) {
root.toplevel.close()
} else {
root.toplevel.activate()
}
}
onEntered: if (root.bar) root.bar.showTooltip(root, root.title)
onExited: if (root.bar) root.bar.hideTooltip(root)
}
}
+916
View File
@@ -0,0 +1,916 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import qs.Ui
Item {
id: root
property QtObject bar: null
property string moduleName: "audioPanel"
property var settings: ({})
property bool popupOpen: false
function closePopout() { popupOpen = false }
readonly property var sink: Pipewire.defaultAudioSink
readonly property var source: Pipewire.defaultAudioSource
readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : []
readonly property var candidateSinks: {
var list = []
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i]
if (n && n.isSink && !n.isStream) list.push(n)
}
return list
}
readonly property var candidateSources: {
var list = []
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i]
if (n && !n.isSink && !n.isStream && n.audio) {
var name = n.name || ""
if (name === "quickshell") continue
list.push(n)
}
}
return list
}
readonly property var candidateStreams: {
var list = []
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i]
if (n && n.isStream && isPlaybackStream(n)) list.push(n)
}
return list
}
// Identify true playback streams without reading node.properties here:
// PwNode.properties is invalid until the node is bound, and reading it while
// capture streams are appearing (for example, when Voxtype starts recording)
// can destabilize Quickshell's Pipewire service. `type` mirrors media.class
// and is safe enough for pre-bind filtering.
function isPlaybackStream(node) {
if (!node) return false
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Output") !== -1
}
readonly property var audioSinks: {
var list = []
for (var i = 0; i < candidateSinks.length; i++)
if (candidateSinks[i].audio) list.push(candidateSinks[i])
return list
}
readonly property var audioSources: candidateSources
readonly property var audioStreams: {
var list = []
for (var i = 0; i < candidateStreams.length; i++)
if (candidateStreams[i].audio) list.push(candidateStreams[i])
return list
}
readonly property real outputVolume: sink && sink.audio ? sink.audio.volume : 0
readonly property bool outputMuted: sink && sink.audio ? sink.audio.muted : false
readonly property real inputVolume: source && source.audio ? source.audio.volume : 0
readonly property bool inputMuted: source && source.audio ? source.audio.muted : false
// Single cursor model shared by keyboard and mouse. Sections:
// "output" — output slider + sink device list
// "input" — input slider + source device list
// "streams" — per-app playback streams
// selectedIndex semantics within a section:
// -1 → on the slider row (h/l adjusts volume, m/Enter mute)
// 0..N-1 → on the Nth device/stream row
// Visuals derive from hasCursor/current via CursorSurface, never
// from containsMouse — that's what keeps the highlight unique across
// keyboard + mouse like wifi does.
property string focusSection: "output"
property int selectedIndex: -1
readonly property color activeFill: bar
? Qt.rgba(bar.foreground.r, bar.foreground.g, bar.foreground.b, 0.18)
: "transparent"
function sectionCount(section) {
if (section === "output") return audioSinks.length
if (section === "input") return audioSources.length
if (section === "streams") return audioStreams.length
return 0
}
function sectionVisible(section) {
if (section === "output") return true
if (section === "input") return audioSources.length > 0 || !!source
if (section === "streams") return audioStreams.length > 0
return false
}
function sectionHasSlider(section) {
if (section === "output") return true
if (section === "input") return !!source
return false // stream rows carry their own sliders inline; not a section-level slider
}
// Order of visible sections, recomputed reactively so dropping a section
// (e.g. no input devices) doesn't leave the cursor pointing at it.
readonly property var visibleSections: {
var list = []
if (sectionVisible("output")) list.push("output")
if (sectionVisible("input")) list.push("input")
if (sectionVisible("streams")) list.push("streams")
return list
}
function moveCursor(delta) {
var sections = visibleSections
if (sections.length === 0) return
var sIdx = sections.indexOf(focusSection)
if (sIdx < 0) { focusSection = sections[0]; selectedIndex = sectionHasSlider(focusSection) ? -1 : 0; return }
var idx = selectedIndex
var max = sectionCount(focusSection) - 1 // last device index
var hasSlider = sectionHasSlider(focusSection)
var floor = hasSlider ? -1 : 0 // -1 = slider row
if (delta > 0) {
if (idx < max) { selectedIndex = idx + 1; return }
// Fall through to next section.
if (sIdx < sections.length - 1) {
focusSection = sections[sIdx + 1]
selectedIndex = sectionHasSlider(focusSection) ? -1 : 0
}
} else {
if (idx > floor) { selectedIndex = idx - 1; return }
// Escape upward.
if (sIdx > 0) {
focusSection = sections[sIdx - 1]
var prevMax = sectionCount(focusSection) - 1
selectedIndex = prevMax >= 0 ? prevMax : (sectionHasSlider(focusSection) ? -1 : 0)
}
}
}
// Adjust the slider associated with the focused section. Output and
// input sliders are real volume controls; on stream rows h/l adjusts
// that stream's volume (so keyboard parity with the inline slider).
// For device rows (selectedIndex >= 0 in output/input) h/l is a no-op
// — the cursor is on a discrete row, not on the slider, and silently
// moving the global slider would surprise the user.
function adjustVolume(delta) {
if (focusSection === "output" && selectedIndex === -1) {
setOutputVolume(outputVolume + delta)
return
}
if (focusSection === "input" && selectedIndex === -1) {
setInputVolume(inputVolume + delta)
return
}
if (focusSection === "streams" && selectedIndex >= 0 && selectedIndex < audioStreams.length) {
var s = audioStreams[selectedIndex]
if (s && s.audio) s.audio.volume = Math.max(0, Math.min(1.5, s.audio.volume + delta))
}
}
// Enter/Space: activate whatever the cursor is on.
function activateCursor() {
if (focusSection === "output") {
if (selectedIndex === -1) { toggleOutputMute(); return }
var sink = audioSinks[selectedIndex]
if (sink) setDefaultSink(sink)
return
}
if (focusSection === "input") {
if (selectedIndex === -1) { toggleInputMute(); return }
var src = audioSources[selectedIndex]
if (src) setDefaultSource(src)
return
}
if (focusSection === "streams" && selectedIndex >= 0) {
var st = audioStreams[selectedIndex]
if (st && st.audio) st.audio.muted = !st.audio.muted
}
}
onPopupOpenChanged: {
if (popupOpen) {
focusSection = "output"
selectedIndex = -1 // start on the output slider
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
}
}
// Clamp / repair the cursor whenever any list refreshes underneath us.
onAudioSinksChanged: clampCursor()
onAudioSourcesChanged: clampCursor()
onAudioStreamsChanged: clampCursor()
// Keep the keyboard-focused row inside the visible viewport of the
// ScrollView. Each cursor target (slider rows, SinkRow, SourceRow,
// StreamRow) calls this when it gains hasCursor. Without it, j/k can
// walk the selection off-screen — wifi uses ListView.positionViewAtIndex
// for this; we don't have that affordance with a multi-section Column.
function ensureCursorVisible(item) {
if (!item || !scrollArea) return
var flick = scrollArea.contentItem
if (!flick || flick.contentY === undefined) return
var pt = item.mapToItem(flick.contentItem || flick, 0, 0)
var top = pt.y
var bottom = top + (item.height || 0)
var viewTop = flick.contentY
var viewBottom = viewTop + flick.height
var margin = 6
if (top < viewTop + margin) flick.contentY = Math.max(0, top - margin)
else if (bottom > viewBottom - margin)
flick.contentY = bottom + margin - flick.height
}
function clampCursor() {
var sections = visibleSections
if (!sections || !sections.length) return
if (sections.indexOf(focusSection) < 0) {
focusSection = visibleSections[0]
selectedIndex = sectionHasSlider(focusSection) ? -1 : 0
return
}
var count = sectionCount(focusSection)
var hasSlider = sectionHasSlider(focusSection)
var floor = hasSlider ? -1 : 0
if (selectedIndex > count - 1) selectedIndex = Math.max(floor, count - 1)
if (selectedIndex < floor) selectedIndex = floor
}
function outputIcon() {
// Match the old Waybar pulseaudio glyph set. The Material Design speaker
// icons render visually smaller in JetBrainsMono Nerd Font.
if (!sink || !sink.audio) return ""
if (outputMuted) return ""
var v = outputVolume
if (v >= 0.67) return ""
if (v >= 0.34) return ""
if (v > 0) return ""
return ""
}
function inputIcon() {
if (!source || !source.audio) return "󰍭"
return inputMuted ? "󰍭" : "󰍬"
}
function setOutputVolume(v) {
if (!sink || !sink.audio) return
sink.audio.volume = Math.max(0, Math.min(1, v))
}
function setInputVolume(v) {
if (!source || !source.audio) return
source.audio.volume = Math.max(0, Math.min(1, v))
}
function toggleOutputMute() {
if (sink && sink.audio) sink.audio.muted = !sink.audio.muted
}
function toggleInputMute() {
if (source && source.audio) source.audio.muted = !source.audio.muted
}
function setDefaultSink(node) { Pipewire.preferredDefaultAudioSink = node }
function setDefaultSource(node) { Pipewire.preferredDefaultAudioSource = node }
function nodeLabel(node) {
if (!node) return "Unknown"
return node.description || node.nickname || node.name || "Unknown"
}
function nodeProps(node) {
return node && node.ready && node.properties ? node.properties : {}
}
function sinkGlyph(node) {
if (!node) return "󰓃"
var p = nodeProps(node)
var blob = String([
node.name, node.description, node.nickname,
p["device.icon-name"] || "",
p["device.product.name"] || ""
].join(" ")).toLowerCase()
if (blob.indexOf("headphone") !== -1 || blob.indexOf("headset") !== -1) return "󰋋"
if (blob.indexOf("bluetooth") !== -1) return "󰂯"
if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "󰍹"
return "󰓃"
}
function sourceGlyph(node) {
if (!node) return "󰍬"
var p = nodeProps(node)
var blob = String([
node.name, node.description, node.nickname,
p["device.icon-name"] || ""
].join(" ")).toLowerCase()
if (blob.indexOf("headset") !== -1) return "󰋋"
if (blob.indexOf("bluetooth") !== -1) return "󰂯"
if (blob.indexOf("webcam") !== -1 || blob.indexOf("camera") !== -1) return "󰄀"
return "󰍬"
}
function streamLabel(node) {
if (!node) return "Stream"
var p = nodeProps(node)
return p["application.name"] || node.description || p["media.name"] || p["node.name"] || node.name || "Stream"
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
PwObjectTracker { objects: root.candidateSinks }
PwObjectTracker { objects: root.candidateSources }
PwObjectTracker { objects: root.audioStreams }
// Lets a Hyprland keybind summon the panel without a click. Mirrors the
// networkPanel IpcHandler pattern; KeyboardPanel grants Exclusive focus
// at map-time so j/k/h/l work the moment the panel appears.
IpcHandler {
target: "audioPanel"
function toggle(): void {
if (root.popupOpen) root.closePopout()
else root.popupOpen = true
}
function show(): void { if (!root.popupOpen) root.popupOpen = true }
function hide(): void { root.closePopout() }
}
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.outputIcon()
fontSize: 12
onPressed: function(b) {
if (b === Qt.RightButton) root.toggleOutputMute()
else if (b === Qt.MiddleButton) root.bar.run("omarchy-launch-audio")
else root.popupOpen = !root.popupOpen
}
onWheelMoved: function(delta) {
var step = 0.05
root.setOutputVolume(root.outputVolume + (delta > 0 ? step : -step))
}
}
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.popupOpen
contentWidth: 370
contentHeight: Math.min(560, panelColumn.implicitHeight + 28)
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onMoveRequested: function(dx, dy) {
if (dy !== 0) root.moveCursor(dy)
else if (dx !== 0) root.adjustVolume(dx * 0.05)
}
onActivateRequested: root.activateCursor()
onCloseRequested: root.closePopout()
onTextKey: function(t) {
// 'm' mutes whatever the cursor is on: focused section's slider
// for output/input, the focused stream for streams.
if (t === "m" || t === "M") {
if (root.focusSection === "streams" && root.selectedIndex >= 0
&& root.selectedIndex < root.audioStreams.length) {
var s = root.audioStreams[root.selectedIndex]
if (s && s.audio) s.audio.muted = !s.audio.muted
} else if (root.focusSection === "input") {
root.toggleInputMute()
} else {
root.toggleOutputMute()
}
}
}
ScrollView {
id: scrollArea
anchors.fill: parent
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
Column {
id: panelColumn
width: scrollArea.availableWidth
spacing: 14
// ---- Output ----
Column {
width: parent.width
spacing: 6
Row {
width: parent.width
spacing: 8
PanelSectionHeader {
text: "Output"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
fontSize: 11
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: root.sink ? "· " + root.nodeLabel(root.sink) : ""
color: Qt.darker(root.bar.foreground, 1.8)
font.family: root.bar.fontFamily
font.pixelSize: 11
elide: Text.ElideRight
width: parent.width - 70
anchors.verticalCenter: parent.verticalCenter
}
}
// Output slider row — itself a cursor target (selectedIndex === -1
// when focusSection === "output"). h/l adjust the value via
// root.adjustVolume; m / Enter toggle mute.
CursorSurface {
id: outputSliderRow
width: parent.width
height: outputSliderInner.implicitHeight + 8
hasCursor: root.focusSection === "output" && root.selectedIndex === -1
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(outputSliderRow)
foreground: root.bar.foreground
fill: root.activeFill
Row {
id: outputSliderInner
anchors.fill: parent
anchors.leftMargin: 6
anchors.rightMargin: 6
spacing: 8
Text {
id: outputIconText
text: root.outputIcon()
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 16
width: 22
horizontalAlignment: Text.AlignHCenter
anchors.verticalCenter: parent.verticalCenter
opacity: root.outputMuted ? 0.5 : 1.0
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.toggleOutputMute()
}
}
PanelSlider {
id: outputSlider
bar: root.bar
width: parent.width - outputIconText.width - outputPercent.width - 16
anchors.verticalCenter: parent.verticalCenter
minimum: 0
maximum: 1
step: 0.05
value: root.outputVolume
opacity: root.outputMuted ? 0.5 : 1.0
enabled: !!root.sink
onMoved: function(v) { root.setOutputVolume(v) }
}
Text {
id: outputPercent
text: Math.round((outputSlider.dragging ? outputSlider.liveValue : root.outputVolume) * 100) + "%"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 11
width: 36
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
opacity: root.outputMuted ? 0.5 : 1.0
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
propagateComposedEvents: true
onContainsMouseChanged: if (containsMouse) {
root.focusSection = "output"
root.selectedIndex = -1
}
}
}
Repeater {
model: root.audioSinks
SinkRow {
required property var modelData
required property int index
width: panelColumn.width
node: modelData
rowIndex: index
}
}
}
// ---- Input ----
Column {
width: parent.width
spacing: 6
visible: root.audioSources.length > 0 || !!root.source
Row {
width: parent.width
spacing: 8
PanelSectionHeader {
text: "Input"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
fontSize: 11
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: root.source ? "· " + root.nodeLabel(root.source) : ""
color: Qt.darker(root.bar.foreground, 1.8)
font.family: root.bar.fontFamily
font.pixelSize: 11
elide: Text.ElideRight
width: parent.width - 56
anchors.verticalCenter: parent.verticalCenter
}
}
CursorSurface {
id: inputSliderRow
visible: !!root.source
width: parent.width
height: inputSliderInner.implicitHeight + 8
hasCursor: root.focusSection === "input" && root.selectedIndex === -1
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(inputSliderRow)
foreground: root.bar.foreground
fill: root.activeFill
Row {
id: inputSliderInner
anchors.fill: parent
anchors.leftMargin: 6
anchors.rightMargin: 6
spacing: 8
Text {
id: inputIconText
text: root.inputIcon()
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 16
width: 22
horizontalAlignment: Text.AlignHCenter
anchors.verticalCenter: parent.verticalCenter
opacity: root.inputMuted ? 0.5 : 1.0
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.toggleInputMute()
}
}
PanelSlider {
id: inputSlider
bar: root.bar
width: parent.width - inputIconText.width - inputPercent.width - 16
anchors.verticalCenter: parent.verticalCenter
minimum: 0
maximum: 1
step: 0.05
value: root.inputVolume
opacity: root.inputMuted ? 0.5 : 1.0
enabled: !!root.source
onMoved: function(v) { root.setInputVolume(v) }
}
Text {
id: inputPercent
text: Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 11
width: 36
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
opacity: root.inputMuted ? 0.5 : 1.0
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
propagateComposedEvents: true
onContainsMouseChanged: if (containsMouse) {
root.focusSection = "input"
root.selectedIndex = -1
}
}
}
Repeater {
model: root.audioSources
SourceRow {
required property var modelData
required property int index
width: panelColumn.width
node: modelData
rowIndex: index
}
}
}
// ---- Per-app streams ----
Column {
width: parent.width
spacing: 6
visible: root.audioStreams.length > 0
PanelSectionHeader {
text: "Playing"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
fontSize: 11
}
Repeater {
model: root.audioStreams
StreamRow {
required property var modelData
required property int index
width: panelColumn.width
node: modelData
rowIndex: index
}
}
}
}
}
}
}
// ---- Reusable inline components ----
// Output device row — cursor target inside the "output" section. Mouse
// hover updates the panel cursor at the root; visuals come entirely
// from hasCursor/current via CursorSurface, never from containsMouse.
component SinkRow: CursorSurface {
id: sinkRow
required property var node
required property int rowIndex
readonly property bool isActive: root.sink && node && root.sink.id === node.id
hasCursor: root.focusSection === "output" && root.selectedIndex === rowIndex
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(sinkRow)
current: isActive
foreground: root.bar.foreground
fill: root.activeFill
implicitHeight: sinkInner.implicitHeight + 10
Row {
id: sinkInner
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 8
Text {
text: root.sinkGlyph(sinkRow.node)
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 14
width: 18
horizontalAlignment: Text.AlignHCenter
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: root.nodeLabel(sinkRow.node)
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 12
elide: Text.ElideRight
width: parent.width - 18 - 14 - 16
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: sinkRow.isActive ? "󰄬" : ""
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 13
width: 14
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: if (containsMouse) {
root.focusSection = "output"
root.selectedIndex = sinkRow.rowIndex
}
onClicked: root.setDefaultSink(sinkRow.node)
}
}
// Input device row — sibling of SinkRow for the "input" section.
component SourceRow: CursorSurface {
id: sourceRow
required property var node
required property int rowIndex
readonly property bool isActive: root.source && node && root.source.id === node.id
hasCursor: root.focusSection === "input" && root.selectedIndex === rowIndex
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(sourceRow)
current: isActive
foreground: root.bar.foreground
fill: root.activeFill
implicitHeight: sourceInner.implicitHeight + 10
Row {
id: sourceInner
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 8
Text {
text: root.sourceGlyph(sourceRow.node)
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 14
width: 18
horizontalAlignment: Text.AlignHCenter
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: root.nodeLabel(sourceRow.node)
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 12
elide: Text.ElideRight
width: parent.width - 18 - 14 - 16
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: sourceRow.isActive ? "󰄬" : ""
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 13
width: 14
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: if (containsMouse) {
root.focusSection = "input"
root.selectedIndex = sourceRow.rowIndex
}
onClicked: root.setDefaultSource(sourceRow.node)
}
}
// Per-app stream row — cursor target inside the "streams" section.
// The stream has its own slider inline, so h/l from the keyboard
// adjusts THIS stream's volume (not the global output) when the cursor
// sits on this row. Enter/Space mutes the stream.
component StreamRow: CursorSurface {
id: streamRow
required property var node
required property int rowIndex
readonly property real streamVolume: node && node.audio ? node.audio.volume : 0
readonly property bool streamMuted: node && node.audio ? node.audio.muted : false
hasCursor: root.focusSection === "streams" && root.selectedIndex === rowIndex
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(streamRow)
foreground: root.bar.foreground
fill: root.activeFill
implicitHeight: streamColumn.implicitHeight + 8
Column {
id: streamColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 6
anchors.rightMargin: 6
spacing: 2
Row {
width: parent.width
spacing: 6
Text {
id: streamMuteIcon
text: streamRow.streamMuted ? "󰝟" : "󰕾"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 12
width: 14
horizontalAlignment: Text.AlignHCenter
anchors.verticalCenter: parent.verticalCenter
opacity: streamRow.streamMuted ? 0.5 : 1.0
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
if (streamRow.node && streamRow.node.audio)
streamRow.node.audio.muted = !streamRow.node.audio.muted
}
}
}
Text {
text: root.streamLabel(streamRow.node)
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 11
elide: Text.ElideRight
width: parent.width - streamMuteIcon.width - streamPct.width - 12
anchors.verticalCenter: parent.verticalCenter
}
Text {
id: streamPct
text: Math.round(streamRow.streamVolume * 100) + "%"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: 11
width: 36
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
}
PanelSlider {
bar: root.bar
width: parent.width
minimum: 0
maximum: 1.5
step: 0.05
value: streamRow.streamVolume
opacity: streamRow.streamMuted ? 0.5 : 1.0
onMoved: function(v) {
if (streamRow.node && streamRow.node.audio) streamRow.node.audio.volume = v
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
propagateComposedEvents: true
onContainsMouseChanged: if (containsMouse) {
root.focusSection = "streams"
root.selectedIndex = streamRow.rowIndex
}
}
}
}
@@ -0,0 +1,687 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Io
import Quickshell.Bluetooth
import qs.Ui
Item {
id: root
property QtObject bar: null
property string moduleName: "bluetoothPanel"
property var settings: ({})
property bool popupOpen: false
// Address -> true while we are waiting for a click-initiated pair to land
// so we can chain trust + connect at root scope. Doing this in the row's
// Connections is racy: the discovered Repeater destroys the delegate the
// moment `paired` flips, before the row's handler reliably fires.
property var pendingPairAddresses: ({})
function closePopout() { popupOpen = false }
readonly property var adapter: Bluetooth.defaultAdapter
readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : []
readonly property var connectedDevices: {
var list = []
for (var i = 0; i < devices.length; i++)
if (devices[i] && devices[i].connected) list.push(devices[i])
return list
}
readonly property var knownDevices: {
var list = []
for (var i = 0; i < devices.length; i++) {
var d = devices[i]
if (d && (d.paired || d.connected || d.bonded || d.trusted)) list.push(d)
}
list.sort(function(a, b) {
if (a.connected !== b.connected) return a.connected ? -1 : 1
return (a.name || a.deviceName || "").localeCompare(b.name || b.deviceName || "")
})
return list
}
readonly property var discoveredDevices: {
var list = []
for (var i = 0; i < devices.length; i++) {
var d = devices[i]
if (!d) continue
if (d.paired || d.connected || d.bonded || d.trusted) continue
list.push(d)
}
list.sort(function(a, b) {
return (a.name || a.deviceName || a.address || "").localeCompare(b.name || b.deviceName || b.address || "")
})
return list
}
readonly property string icon: {
if (!adapter) return ""
if (!adapter.enabled) return "󰂲"
if (connectedDevices.length > 0) return "󰂱"
return "󰂯"
}
// Single cursor model shared by keyboard and mouse. Sections:
// "header" — 3 action pills (scan, tui, toggle); h/l moves between
// them, Enter activates.
// "known" — paired/known device rows; Enter toggles connect.
// "discovered" — unpaired devices visible while scanning; Enter pairs.
// Visuals always come from CursorSurface (hasCursor / current),
// never from containsMouse. Mouse hover updates root cursor state too,
// guaranteeing one highlight on screen.
property string focusSection: "header"
property int selectedIndex: 2 // default = toggle pill
readonly property int headerPillCount: 3
// Stable identity for the focused known device. The known list is sorted
// (connected-first, then alphabetical) so activating a device can shift
// its index. We track the BlueZ address here so the cursor follows the
// same device across reorders rather than the slot it used to occupy.
property string focusedKnownAddress: ""
readonly property color activeFill: bar
? Qt.rgba(bar.foreground.r, bar.foreground.g, bar.foreground.b, 0.18)
: "transparent"
function sectionCount(section) {
if (section === "header") return headerPillCount
if (section === "known") return knownDevices.length
if (section === "discovered") return discoveredDevices.length
return 0
}
function sectionVisible(section) {
if (section === "header") return true
if (section === "known") return knownDevices.length > 0
if (section === "discovered") return adapter && adapter.discovering && discoveredDevices.length > 0
return false
}
readonly property var visibleSections: {
var list = ["header"]
if (sectionVisible("known")) list.push("known")
if (sectionVisible("discovered")) list.push("discovered")
return list
}
// j/k navigates between sections row-by-row. The header is treated as a
// SINGLE row (its pills sit on one horizontal line), so j/k from devices
// jumps to/from the header as a unit, and h/l moves between the three
// pills inside it. This matches wifi's DNS-pill behaviour.
function moveCursor(delta) {
var sections = visibleSections
if (!sections || sections.length === 0) return
var sIdx = sections.indexOf(focusSection)
if (sIdx < 0) { focusSection = sections[0]; selectedIndex = 0; return }
var idx = selectedIndex
var inHeader = focusSection === "header"
var max = inHeader ? 0 : sectionCount(focusSection) - 1
if (delta > 0) {
if (!inHeader && idx < max) { selectedIndex = idx + 1; return }
if (sIdx < sections.length - 1) {
focusSection = sections[sIdx + 1]
// Entering the header from below shouldn't happen (header is first),
// but other entries start at 0.
selectedIndex = 0
}
} else {
if (!inHeader && idx > 0) { selectedIndex = idx - 1; return }
if (sIdx > 0) {
focusSection = sections[sIdx - 1]
// Entering the header always lands on the toggle pill — the most
// common action and consistent with the on-open default. h/l from
// there moves to scan/TUI.
selectedIndex = focusSection === "header" ? 2 : sectionCount(focusSection) - 1
}
}
}
// h/l: only meaningful in the header. In device sections it's a no-op
// — j/k is the canonical row navigator there.
function moveCursorH(delta) {
if (focusSection !== "header") return
var next = selectedIndex + delta
if (next < 0) next = 0
if (next > headerPillCount - 1) next = headerPillCount - 1
selectedIndex = next
}
function activateCursor() {
if (focusSection === "header") {
if (selectedIndex === 0) {
if (adapter && adapter.enabled) adapter.discovering = !adapter.discovering
} else if (selectedIndex === 1) {
if (bar) bar.run("omarchy-launch-bluetooth")
closePopout()
} else if (selectedIndex === 2) {
if (adapter) adapter.enabled = !adapter.enabled
}
return
}
if (focusSection === "known") {
var dev = knownDevices[selectedIndex]
if (!dev) return
if (!dev.trusted) dev.trusted = true
if (dev.connected) dev.disconnect()
else dev.connect()
return
}
if (focusSection === "discovered") {
var d = discoveredDevices[selectedIndex]
if (!d) return
pendingPairAddresses[d.address] = true
d.pair()
}
}
// 'x' on a known row mirrors the row's X button: connected device
// disconnects, everything else forgets the pairing. Mismatching this
// (e.g. forgetting a connected device) is destructive — the X button
// tooltip says "Disconnect" for connected rows, and the keybind has
// to agree.
function deleteSelected() {
if (focusSection !== "known") return
var dev = knownDevices[selectedIndex]
if (!dev) return
if (dev.connected) dev.disconnect()
else if (dev.forget) dev.forget()
}
onPopupOpenChanged: {
if (popupOpen) {
if (knownDevices.length > 0) { focusSection = "known"; selectedIndex = 0 }
else { focusSection = "header"; selectedIndex = 2 }
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
}
}
// When `selectedIndex` changes inside the known section, remember which
// address it points at. Updates from re-resolution (below) are idempotent
// because we end up setting the same address.
onSelectedIndexChanged: {
if (focusSection !== "known") return
if (selectedIndex < 0 || selectedIndex >= knownDevices.length) return
var d = knownDevices[selectedIndex]
focusedKnownAddress = d ? (d.address || "") : ""
}
onFocusSectionChanged: {
if (focusSection !== "known") focusedKnownAddress = ""
}
onKnownDevicesChanged: {
// Try to follow the device by address before clamping. If we can't find
// the address (e.g. it was forgotten), fall through to clampCursor()
// which will pull selectedIndex back into range.
if (focusSection === "known" && focusedKnownAddress !== "") {
for (var i = 0; i < knownDevices.length; i++) {
if (knownDevices[i] && knownDevices[i].address === focusedKnownAddress) {
if (selectedIndex !== i) selectedIndex = i
clampCursor()
return
}
}
}
clampCursor()
}
onDiscoveredDevicesChanged: clampCursor()
onVisibleSectionsChanged: clampCursor()
// Keep the keyboard-focused row inside the visible viewport of the device
// Flickable. Each DeviceRow calls this when it gains hasCursor. Without
// it, j/k can walk the selection off-screen in a long device list.
function ensureCursorVisible(item) {
if (!item || !deviceFlick) return
var pt = item.mapToItem(deviceFlick.contentItem, 0, 0)
var top = pt.y
var bottom = top + (item.height || 0)
var viewTop = deviceFlick.contentY
var viewBottom = viewTop + deviceFlick.height
var margin = 6
if (top < viewTop + margin) deviceFlick.contentY = Math.max(0, top - margin)
else if (bottom > viewBottom - margin)
deviceFlick.contentY = bottom + margin - deviceFlick.height
}
function clampCursor() {
var sections = visibleSections
if (!sections || !sections.length) return
if (sections.indexOf(focusSection) < 0) {
focusSection = sections[0]
selectedIndex = 0
return
}
var count = sectionCount(focusSection)
if (count === 0) {
// Section emptied out — bounce to the previous visible one.
var sIdx = sections.indexOf(focusSection)
focusSection = sIdx > 0 ? sections[sIdx - 1] : sections[0]
selectedIndex = Math.max(0, sectionCount(focusSection) - 1)
return
}
if (selectedIndex > count - 1) selectedIndex = count - 1
if (selectedIndex < 0) selectedIndex = 0
}
visible: adapter !== null
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
// Non-visual lifecycle watchers, one per device. Survives popup open/close
// and the discovered-known transition that destroys row delegates.
Repeater {
model: root.devices
Item {
required property var modelData
visible: false
Connections {
target: modelData || null
function onPairedChanged() {
var d = modelData
if (!d || !d.paired) return
if (!root.pendingPairAddresses[d.address]) return
delete root.pendingPairAddresses[d.address]
// BlueZ pair() does not auto-trust or auto-connect. Without
// trusted, the daemon may drop the entry shortly after pairing,
// which makes a freshly-paired device flash "Connected" and then
// vanish from the model.
d.trusted = true
if (!d.connected) d.connect()
}
}
}
}
// Lets a Hyprland keybind summon the panel without a click.
IpcHandler {
target: "bluetoothPanel"
function toggle(): void {
if (root.popupOpen) root.closePopout()
else root.popupOpen = true
}
function show(): void { if (!root.popupOpen) root.popupOpen = true }
function hide(): void { root.closePopout() }
}
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.icon
onPressed: function(b) {
if (b === Qt.RightButton && root.adapter) root.adapter.enabled = !root.adapter.enabled
else if (b === Qt.MiddleButton) root.bar.run("omarchy-launch-bluetooth")
else root.popupOpen = !root.popupOpen
}
}
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.popupOpen
contentWidth: 320
contentHeight: column.implicitHeight + 28
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onMoveRequested: function(dx, dy) {
if (dy !== 0) root.moveCursor(dy)
else if (dx !== 0) root.moveCursorH(dx)
}
onActivateRequested: root.activateCursor()
onCloseRequested: root.closePopout()
onDeleteRequested: root.deleteSelected()
Column {
id: column
anchors.fill: parent
spacing: 10
// Header: title left, on/off toggle + actions right.
Item {
width: parent.width
height: titleText.implicitHeight
Text {
id: titleText
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "Bluetooth"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 13
font.bold: true
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 4
HeaderPill {
pillIndex: 0
iconText: "󰂳"
tooltipText: !root.adapter ? "" : !root.adapter.enabled ? "Bluetooth is off"
: root.adapter.discovering ? "Stop scanning" : "Scan for devices"
pillEnabled: root.adapter !== null && root.adapter.enabled
active: root.adapter && root.adapter.discovering
onActivated: if (root.adapter) root.adapter.discovering = !root.adapter.discovering
}
HeaderPill {
pillIndex: 1
iconText: "󱁤"
tooltipText: "Open Impala (TUI)"
onActivated: { root.bar.run("omarchy-launch-bluetooth"); root.popupOpen = false }
}
HeaderPill {
pillIndex: 2
iconText: root.adapter && root.adapter.enabled ? "󰂯" : "󰂲"
tooltipText: root.adapter && root.adapter.enabled ? "Turn Bluetooth off" : "Turn Bluetooth on"
active: root.adapter && root.adapter.enabled
onActivated: if (root.adapter) root.adapter.enabled = !root.adapter.enabled
}
}
}
// Scrollable device list — capped so a noisy neighborhood doesn't
// grow the popup past the screen.
Flickable {
id: deviceFlick
width: parent.width
height: Math.min(deviceList.implicitHeight, 400)
contentWidth: width
contentHeight: deviceList.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
Column {
id: deviceList
width: parent.width
spacing: 10
// Paired / known devices.
Repeater {
model: root.knownDevices
DeviceRow {
required property var modelData
required property int index
width: deviceList.width
dev: modelData
rowIndex: index
isDiscovered: false
}
}
// Discovered (unpaired) devices, only shown while scanning.
PanelSectionHeader {
visible: root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0
text: "Discovered"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
}
Repeater {
model: root.adapter && root.adapter.discovering ? root.discoveredDevices : []
DeviceRow {
required property var modelData
required property int index
width: deviceList.width
dev: modelData
rowIndex: index
isDiscovered: true
}
}
Text {
visible: root.knownDevices.length === 0
&& (!root.adapter || !root.adapter.discovering || root.discoveredDevices.length === 0)
text: !root.adapter ? "No Bluetooth adapter"
: !root.adapter.enabled ? "Turn Bluetooth on to scan"
: root.adapter.discovering ? "Scanning for devices…"
: "No paired devices. Tap the scan icon to find new ones."
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: 11
wrapMode: Text.WordWrap
width: deviceList.width
}
}
}
}
}
}
// Header pill: a CursorPill bound into the panel's "header" cursor
// section. CursorPill collapses what used to be a PillButton subclass +
// overlay MouseArea into one component; we keep the pillIndex / activated
// shim here so the three header pill instantiations stay readable.
component HeaderPill: CursorPill {
id: pill
required property int pillIndex
property bool pillEnabled: true
signal activated()
tooltipBackground: root.bar.background
tooltipForeground: root.bar.foreground
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
horizontalPadding: 6
verticalPadding: 4
iconSize: 14
enabled: pillEnabled
opacity: pillEnabled ? 1 : 0.4
hasCursor: root.focusSection === "header" && root.selectedIndex === pillIndex
onClicked: pill.activated()
onHovered: function(isHovered) {
if (!isHovered) return
root.focusSection = "header"
root.selectedIndex = pill.pillIndex
}
}
// Two-line device row showing name + live status (Connected, Connecting,
// Pairing, Failed). Tracks pending click attempts with a Timer so a
// connect that drops back to Disconnected within 10s surfaces as "Failed".
// Now a cursor target: hasCursor binds to root state, mouse hover updates
// root state. The X button on the right is a PanelActionButton.
component DeviceRow: CursorSurface {
id: row
required property var dev
required property int rowIndex
required property bool isDiscovered
readonly property bool isConnected: dev && dev.connected
readonly property int devState: dev && dev.state !== undefined ? dev.state : -1
readonly property string sectionName: isDiscovered ? "discovered" : "known"
hasCursor: root.focusSection === sectionName && root.selectedIndex === rowIndex
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(row)
current: isConnected
foreground: root.bar.foreground
fill: root.activeFill
// 0 idle, 1 connecting, 2 disconnecting, 3 pairing, 4 failed.
property int pendingAction: 0
property string failureReason: ""
// Heuristic: while pendingAction is set, the connect/pair attempt is
// expected to land within ~10s. If state stays Disconnected past that, we
// declare failure. Cleared as soon as state reaches Connected.
Timer {
id: failureTimer
interval: 10000
repeat: false
onTriggered: {
if (row.pendingAction === 1 && !row.isConnected) {
row.pendingAction = 4
row.failureReason = "Could not connect"
} else if (row.pendingAction === 3 && row.dev && !row.dev.paired) {
row.pendingAction = 4
row.failureReason = "Pairing failed"
} else {
row.pendingAction = 0
row.failureReason = ""
}
}
}
Connections {
target: row.dev || null
function onConnectedChanged() {
if (row.isConnected) { row.pendingAction = 0; row.failureReason = "" }
}
function onPairedChanged() {
if (row.dev && row.dev.paired && row.pendingAction === 3) {
row.pendingAction = 0
}
}
}
readonly property string statusText: {
if (!dev) return ""
if (pendingAction === 4) return failureReason || "Failed"
if (pendingAction === 1 || devState === 3) return "Connecting…"
if (pendingAction === 2 || devState === 2) return "Disconnecting…"
if (pendingAction === 3 || (dev.pairing === true)) return "Pairing…"
if (isConnected) {
if (dev.batteryAvailable) return "Connected · " + Math.round(dev.battery * 100) + "%"
return "Connected"
}
if (isDiscovered) return "Available · click to pair"
return "Paired"
}
readonly property color statusColor: {
if (pendingAction === 4) return root.bar.urgent
if (isConnected) return root.bar.foreground
if (pendingAction === 1 || devState === 3 || pendingAction === 3) return root.bar.foreground
return Qt.darker(root.bar.foreground, 1.5)
}
implicitHeight: rowContent.implicitHeight + 12
MouseArea {
id: rowMouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: row.dev ? Qt.PointingHandCursor : Qt.ArrowCursor
onContainsMouseChanged: if (containsMouse) {
root.focusSection = row.sectionName
root.selectedIndex = row.rowIndex
}
onClicked: function(mouse) {
if (!row.dev) return
if (mouse.button === Qt.RightButton) {
if (row.dev.forget) row.dev.forget()
return
}
if (row.isDiscovered) {
row.pendingAction = 3
row.failureReason = ""
failureTimer.restart()
root.pendingPairAddresses[row.dev.address] = true
row.dev.pair()
return
}
if (!row.dev.trusted) row.dev.trusted = true
if (row.isConnected) return // use the X button to disconnect
row.pendingAction = 1
row.failureReason = ""
failureTimer.restart()
row.dev.connect()
}
}
Item {
id: rowContent
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 10
anchors.rightMargin: 10
implicitHeight: Math.max(deviceIcon.implicitHeight, info.implicitHeight, disconnectBtn.implicitHeight)
Text {
id: deviceIcon
text: row.isConnected ? "󰂱" : "󰂯"
color: row.statusColor
font.family: root.bar.fontFamily
font.pixelSize: 16
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
// Explicit close button on any known device. Action depends on state:
// connected -> disconnect, otherwise -> forget the pairing entirely.
PanelActionButton {
id: disconnectBtn
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: !row.isDiscovered
iconText: "󰅙"
tooltipText: row.isConnected ? "Disconnect" : "Forget"
foreground: root.bar.foreground
hoverColor: root.bar.urgent
panelBackground: root.bar.background
fontFamily: root.bar.fontFamily
onClicked: {
if (!row.dev) return
if (row.isConnected) {
row.pendingAction = 2
failureTimer.stop()
row.dev.disconnect()
} else if (row.dev.forget) {
row.dev.forget()
}
}
}
Column {
id: info
spacing: 1
anchors.left: deviceIcon.right
anchors.leftMargin: 10
anchors.right: disconnectBtn.visible ? disconnectBtn.left : parent.right
anchors.rightMargin: disconnectBtn.visible ? 8 : 0
anchors.verticalCenter: parent.verticalCenter
Text {
text: row.dev ? (row.dev.deviceName || row.dev.name || row.dev.address || "Device") : ""
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 12
elide: Text.ElideRight
width: parent.width
}
Text {
visible: row.statusText !== ""
text: row.statusText
color: row.statusColor
font.family: root.bar.fontFamily
font.pixelSize: 10
elide: Text.ElideRight
width: parent.width
}
}
}
}
}
+199
View File
@@ -0,0 +1,199 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Ui
Item {
id: root
property QtObject bar: null
property string moduleName: "calendar"
property var settings: ({})
property date now: new Date()
property date viewMonth: new Date()
property bool popupOpen: false
function closePopout() { popupOpen = false }
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null ? fallback : value
}
function formatLabel() {
if (!bar) return ""
var fmt = bar.vertical
? String(setting("verticalFormat", "HH\n—\nmm"))
: String(setting("format", "dddd HH:mm"))
return Qt.formatDateTime(now, fmt)
}
function shiftMonth(delta) {
var date = new Date(viewMonth)
date.setDate(1)
date.setMonth(date.getMonth() + delta)
viewMonth = date
}
function isoWeek(date) {
var d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
var day = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() + 4 - day)
var yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7)
}
function tooltipLabel() {
return Qt.formatDateTime(root.now, "dd MMMM yyyy") + " Week " + root.isoWeek(root.now)
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
SystemClock {
id: clockTimer
precision: SystemClock.Minutes
onDateChanged: root.now = clockTimer.date
}
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.formatLabel()
horizontalMargin: 8.75
verticalPadding: 8.75
tooltipText: root.tooltipLabel()
onPressed: function(b) {
if (b === Qt.RightButton) {
root.bar.run("omarchy-launch-floating-terminal-with-presentation omarchy-tz-select")
} else {
root.viewMonth = new Date(root.now)
root.popupOpen = !root.popupOpen
}
}
}
PopupCard {
id: popup
anchorItem: button
bar: root.bar
owner: root
open: root.popupOpen
contentWidth: 300
contentHeight: header.implicitHeight + grid.implicitHeight + 36
Column {
anchors.fill: parent
spacing: 8
Item {
id: header
width: parent.width
implicitHeight: 28
PillButton {
id: prevButton
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
iconText: "󰅁"
foreground: root.bar.foreground
horizontalPadding: 8
verticalPadding: 4
onClicked: root.shiftMonth(-1)
}
Text {
anchors.centerIn: parent
text: Qt.formatDate(root.viewMonth, "MMMM yyyy")
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 14
font.bold: true
}
PillButton {
id: nextButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
iconText: "󰅂"
foreground: root.bar.foreground
horizontalPadding: 8
verticalPadding: 4
onClicked: root.shiftMonth(1)
}
}
Grid {
id: grid
columns: 7
rowSpacing: 4
columnSpacing: 4
width: parent.width
Repeater {
model: ["S", "M", "T", "W", "T", "F", "S"]
Item {
required property string modelData
width: (grid.width - grid.columnSpacing * 6) / 7
height: 18
Text {
anchors.centerIn: parent
text: modelData
color: Qt.darker(root.bar.foreground, 1.6)
font.family: root.bar.fontFamily
font.pixelSize: 11
font.bold: true
}
}
}
Repeater {
model: 42
Rectangle {
required property int index
readonly property var startOfMonth: {
var d = new Date(root.viewMonth)
d.setDate(1)
return d
}
readonly property int firstDayOffset: startOfMonth.getDay()
readonly property var dayDate: {
var d = new Date(startOfMonth)
d.setDate(d.getDate() + index - firstDayOffset)
return d
}
readonly property bool inMonth: dayDate.getMonth() === root.viewMonth.getMonth()
readonly property bool isToday: {
var n = root.now
return dayDate.getDate() === n.getDate() && dayDate.getMonth() === n.getMonth() && dayDate.getFullYear() === n.getFullYear()
}
width: (grid.width - grid.columnSpacing * 6) / 7
height: 28
radius: 4
color: isToday ? root.bar.foreground : "transparent"
border.color: isToday ? root.bar.foreground : "transparent"
border.width: isToday ? 1 : 0
Text {
anchors.centerIn: parent
text: dayDate.getDate()
color: isToday ? root.bar.background : (inMonth ? root.bar.foreground : Qt.darker(root.bar.foreground, 2.2))
font.family: root.bar.fontFamily
font.pixelSize: 12
font.bold: isToday
}
}
}
}
}
}
}
@@ -0,0 +1,64 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Ui
Item {
id: root
property QtObject bar: null
property string moduleName: "idleInhibitor"
property var settings: ({})
property bool active: false
readonly property string icon: active ? "󰅶" : "󰛊"
function refresh() {
if (!statusProc.running) statusProc.running = true
}
function toggle() {
if (root.bar) root.bar.run("omarchy-toggle-idle")
refreshTimer.restart()
}
Component.onCompleted: refresh()
Process {
id: statusProc
command: ["bash", "-lc", "pgrep -x hypridle >/dev/null 2>&1 && echo running || echo stopped"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
root.active = String(text || "").trim() === "stopped"
}
}
}
Timer {
id: refreshTimer
interval: 1500
onTriggered: root.refresh()
}
Timer {
interval: 5000
running: true
repeat: true
onTriggered: root.refresh()
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.icon
active: root.active
tooltipText: root.active ? "Staying awake — click to allow idle" : "Can idle — click to stay awake"
onPressed: function() { root.toggle() }
}
}
@@ -0,0 +1,84 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import qs.Ui
Item {
id: root
property QtObject bar: null
property string moduleName: "keyboardLayout"
property var settings: ({})
property string layoutLabel: ""
property string layoutFull: ""
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null ? fallback : value
}
function refresh() {
if (!queryProc.running) queryProc.running = true
}
function cycleLayout() {
Hyprland.dispatch("switchxkblayout current next")
refreshTimer.restart()
}
Component.onCompleted: refresh()
Connections {
target: Hyprland
function onRawEvent(event) {
if (!event || !event.name) return
if (String(event.name).indexOf("activelayout") !== -1) root.refresh()
}
}
Process {
id: queryProc
command: ["bash", "-lc", "hyprctl -j devices 2>/dev/null | sed -n '/keyboards/,$p' | head -200"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var match = String(text || "").match(/"active_keymap":\s*"([^"]+)"/)
if (!match) return
var full = match[1]
root.layoutFull = full
var token = full.split(/\s+/)[0]
root.layoutLabel = token.substring(0, 3).toUpperCase()
}
}
}
Timer {
id: refreshTimer
interval: 600
onTriggered: root.refresh()
}
Timer {
interval: 10000
running: true
repeat: true
onTriggered: root.refresh()
}
visible: layoutLabel !== ""
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.layoutLabel
fontSize: 10
horizontalMargin: 6
tooltipText: root.layoutFull
onPressed: function() { root.cycleLayout() }
}
}
+101
View File
@@ -0,0 +1,101 @@
import QtQuick
import Quickshell
import Quickshell.Io
Item {
id: root
property QtObject bar: null
property string moduleName: "lockKeys"
property var settings: ({})
property bool capsOn: false
property bool numOn: false
property bool scrollOn: false
property bool hideWhenOff: true
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null ? fallback : value
}
Component.onCompleted: {
hideWhenOff = setting("hideWhenOff", true) === true
refresh()
}
function refresh() {
if (!stateProc.running) stateProc.running = true
}
property bool ledsAvailable: true
Process {
id: stateProc
command: ["bash", "-lc", "read_led() { for path in /sys/class/leds/input*::$1; do if [[ -r $path/brightness ]]; then cat $path/brightness; return; fi; done; echo missing; }; read_led capslock; read_led numlock; read_led scrolllock"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var lines = String(text || "").split("\n")
var caps = String(lines[0] || "").trim()
var num = String(lines[1] || "").trim()
var scroll = String(lines[2] || "").trim()
root.capsOn = caps !== "missing" && parseInt(caps, 10) > 0
root.numOn = num !== "missing" && parseInt(num, 10) > 0
root.scrollOn = scroll !== "missing" && parseInt(scroll, 10) > 0
root.ledsAvailable = caps !== "missing" || num !== "missing" || scroll !== "missing"
}
}
}
Timer {
interval: 2000
running: root.ledsAvailable
repeat: true
onTriggered: root.refresh()
}
readonly property bool anyOn: capsOn || numOn || scrollOn
visible: ledsAvailable && (hideWhenOff ? anyOn : true)
readonly property bool vertical: bar ? bar.vertical : false
implicitWidth: vertical ? (bar ? bar.barSize : 28) : (lay.item ? lay.item.implicitWidth + 8 : 0)
implicitHeight: vertical ? (lay.item ? lay.item.implicitHeight + 8 : 0) : (bar ? bar.barSize : 26)
Loader {
id: lay
anchors.centerIn: parent
sourceComponent: root.vertical ? colLayout : rowLayout
}
Component {
id: rowLayout
Row {
spacing: 4
LockGlyph { glyph: "A"; active: root.capsOn; visible: !root.hideWhenOff || root.capsOn }
LockGlyph { glyph: "1"; active: root.numOn; visible: !root.hideWhenOff || root.numOn }
LockGlyph { glyph: "S"; active: root.scrollOn; visible: !root.hideWhenOff || root.scrollOn }
}
}
Component {
id: colLayout
Column {
spacing: 2
LockGlyph { glyph: "A"; active: root.capsOn; visible: !root.hideWhenOff || root.capsOn }
LockGlyph { glyph: "1"; active: root.numOn; visible: !root.hideWhenOff || root.numOn }
LockGlyph { glyph: "S"; active: root.scrollOn; visible: !root.hideWhenOff || root.scrollOn }
}
}
component LockGlyph: Text {
property string glyph: ""
property bool active: false
text: glyph
color: active ? (root.bar ? root.bar.foreground : "#cacccc") : Qt.rgba(0.7, 0.7, 0.7, 0.3)
font.family: root.bar ? root.bar.fontFamily : "JetBrainsMono Nerd Font"
font.pixelSize: 11
}
}
+234
View File
@@ -0,0 +1,234 @@
import QtQuick
import Quickshell
import Quickshell.Services.Mpris
import qs.Ui
Item {
id: root
property QtObject bar: null
property string moduleName: "media"
property var settings: ({})
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null ? fallback : value
}
readonly property var players: Mpris.players ? Mpris.players.values : []
readonly property var activePlayer: {
var playing = null
for (var i = 0; i < players.length; i++) {
var p = players[i]
if (!p) continue
if (p.isPlaying) return p
if (!playing && p.trackTitle) playing = p
}
return playing
}
readonly property bool hasMedia: activePlayer !== null && (activePlayer.trackTitle || activePlayer.trackArtist)
readonly property string playIcon: activePlayer && activePlayer.isPlaying ? "󰏤" : "󰐊"
readonly property string title: activePlayer ? (activePlayer.trackTitle || "") : ""
readonly property string artist: activePlayer ? (activePlayer.trackArtist || "") : ""
property bool popupOpen: false
function closePopout() { popupOpen = false }
property real maxLabelWidth: 180
visible: hasMedia
implicitWidth: hasMedia ? row.implicitWidth + 14 : 0
implicitHeight: bar ? bar.barSize : 26
Row {
id: row
anchors.centerIn: parent
spacing: 6
Text {
id: glyph
anchors.verticalCenter: parent.verticalCenter
text: root.playIcon
color: activePlayer && activePlayer.isPlaying ? root.bar.foreground : Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: 12
Behavior on color { ColorAnimation { duration: 160 } }
}
Item {
id: scrollClip
width: Math.min(root.maxLabelWidth, labelText.implicitWidth)
height: glyph.height
clip: true
anchors.verticalCenter: parent.verticalCenter
visible: !root.bar.vertical && root.title !== ""
Text {
id: labelText
text: root.title + (root.artist ? " · " + root.artist : "")
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 12
anchors.verticalCenter: parent.verticalCenter
property bool needsScroll: implicitWidth > scrollClip.width
NumberAnimation on x {
id: scrollAnim
running: labelText.needsScroll && !root.popupOpen && !root.bar.vertical
loops: Animation.Infinite
duration: Math.max(6000, labelText.implicitWidth * 25)
from: scrollClip.width
to: -labelText.implicitWidth
easing.type: Easing.Linear
}
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: root.activePlayer ? Qt.PointingHandCursor : Qt.ArrowCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: function(mouse) {
if (!root.activePlayer) return
if (mouse.button === Qt.MiddleButton) {
if (root.activePlayer.canGoNext) root.activePlayer.next()
} else if (mouse.button === Qt.RightButton) {
root.popupOpen = !root.popupOpen
} else {
if (root.activePlayer.canTogglePlaying) root.activePlayer.togglePlaying()
}
}
onWheel: function(wheel) {
if (!root.activePlayer) return
if (wheel.angleDelta.y > 0 && root.activePlayer.canGoPrevious) root.activePlayer.previous()
else if (wheel.angleDelta.y < 0 && root.activePlayer.canGoNext) root.activePlayer.next()
}
onEntered: if (root.bar) root.bar.showTooltip(root, root.hasMedia ? (root.title + (root.artist ? " — " + root.artist : "")) : "")
onExited: if (root.bar) root.bar.hideTooltip(root)
}
PopupCard {
id: popup
anchorItem: root
bar: root.bar
owner: root
open: root.popupOpen
contentWidth: 320
contentHeight: column.implicitHeight + 28
Column {
id: column
anchors.fill: parent
spacing: 10
Row {
spacing: 10
width: parent.width
Rectangle {
width: 64
height: 64
radius: 4
color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.08)
border.color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.2)
border.width: 1
Image {
anchors.fill: parent
anchors.margins: 2
fillMode: Image.PreserveAspectCrop
asynchronous: true
source: root.activePlayer && root.activePlayer.trackArtUrl ? root.activePlayer.trackArtUrl : ""
visible: source !== ""
}
Text {
anchors.centerIn: parent
visible: !root.activePlayer || !root.activePlayer.trackArtUrl
text: "󰝚"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 28
}
}
Column {
spacing: 4
width: parent.width - 74
Text {
text: root.title || "Nothing playing"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 13
font.bold: true
elide: Text.ElideRight
width: parent.width
}
Text {
text: root.artist
color: Qt.darker(root.bar.foreground, 1.3)
font.family: root.bar.fontFamily
font.pixelSize: 11
elide: Text.ElideRight
width: parent.width
visible: text !== ""
}
Text {
text: root.activePlayer && root.activePlayer.trackAlbum ? root.activePlayer.trackAlbum : ""
color: Qt.darker(root.bar.foreground, 1.6)
font.family: root.bar.fontFamily
font.pixelSize: 10
elide: Text.ElideRight
width: parent.width
visible: text !== ""
}
}
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 6
PillButton {
iconText: "󰒮"
foreground: root.bar.foreground
horizontalPadding: 10
verticalPadding: 6
enabled: root.activePlayer && root.activePlayer.canGoPrevious
opacity: enabled ? 1.0 : 0.4
onClicked: if (root.activePlayer) root.activePlayer.previous()
}
PillButton {
iconText: root.activePlayer && root.activePlayer.isPlaying ? "󰏤" : "󰐊"
foreground: root.bar.foreground
horizontalPadding: 14
verticalPadding: 6
iconSize: 18
enabled: root.activePlayer && root.activePlayer.canTogglePlaying
opacity: enabled ? 1.0 : 0.4
onClicked: if (root.activePlayer) root.activePlayer.togglePlaying()
}
PillButton {
iconText: "󰒭"
foreground: root.bar.foreground
horizontalPadding: 10
verticalPadding: 6
enabled: root.activePlayer && root.activePlayer.canGoNext
opacity: enabled ? 1.0 : 0.4
onClicked: if (root.activePlayer) root.activePlayer.next()
}
}
}
}
}
+56
View File
@@ -0,0 +1,56 @@
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import qs.Ui
Item {
id: root
property QtObject bar: null
property string moduleName: "microphone"
property var settings: ({})
readonly property var source: Pipewire.defaultAudioSource
readonly property bool muted: source && source.audio ? source.audio.muted : true
readonly property real volume: source && source.audio ? source.audio.volume : 0
readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : []
readonly property var activeStreams: {
var list = []
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i]
if (node && node.isStream && node.isSink === false && !node.audio?.muted) list.push(node)
}
return list
}
readonly property bool inUse: activeStreams.length > 0 && !muted
visible: source !== null
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
function toggleMute() {
if (source && source.audio) source.audio.muted = !source.audio.muted
}
PwObjectTracker { objects: root.source ? [root.source] : [] }
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.muted ? "󰍭" : "󰍬"
active: root.inUse
tooltipText: root.muted ? "Microphone muted" : (root.inUse ? "Microphone in use" : "Microphone live")
onPressed: function(b) {
if (b === Qt.MiddleButton) root.bar.run("omarchy-launch-audio")
else root.toggleMute()
}
onWheelMoved: function(delta) {
if (!root.source || !root.source.audio) return
var step = 0.05
root.source.audio.volume = Math.max(0, Math.min(1, root.volume + (delta > 0 ? step : -step)))
}
}
}
+569
View File
@@ -0,0 +1,569 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Io
import qs.Ui
Item {
id: root
property QtObject bar: null
property string moduleName: "monitorPanel"
property var settings: ({})
property bool popupOpen: false
property int brightnessPercent: 0
property int pendingBrightnessPercent: 0
property bool brightnessSetQueued: false
property bool brightnessAvailable: false
property string internalMonitor: ""
property string externalMonitor: ""
property string focusedMonitor: ""
property bool internalEnabled: false
property bool mirrorEnabled: false
property string monitorScale: ""
property var displays: []
property int enabledDisplayCount: 0
// Cursor model shared by keyboard and mouse. Sections:
// "brightness" - single slider row, selectedIndex = -1 sentinel
// (mirrors audioPanel's slider rows). Only present if a
// controllable backlight was detected.
// "scale" - 6 ChoiceButton scale presets; treated as a single
// horizontal row from j/k's perspective. h/l moves
// between presets, identical to bluetooth's header.
// "monitors" - vertical Toggle list for enabling/disabling displays;
// j/k walks each row.
// Mouse hover on a target updates root state via the components' `hovered`
// signal so keyboard cursor and pointer share one highlight.
readonly property var scaleValues: ["1", "1.25", "1.6", "2", "3", "4"]
property string focusSection: "scale"
property int selectedIndex: 0
readonly property var visibleSections: {
var list = []
if (brightnessAvailable) list.push("brightness")
list.push("scale")
if (displays.length > 0) list.push("monitors")
return list
}
function sectionCount(section) {
if (section === "brightness") return 0 // only the slider sentinel at -1
if (section === "scale") return scaleValues.length
if (section === "monitors") return displays.length
return 0
}
function sectionIsSingleRow(section) {
// brightness has only the slider; scale presets sit horizontally.
return section === "brightness" || section === "scale"
}
function sectionFirstIndex(section) {
if (section === "brightness") return -1
return 0
}
function moveCursor(delta) {
var sections = visibleSections
if (!sections || sections.length === 0) return
var sIdx = sections.indexOf(focusSection)
if (sIdx < 0) {
focusSection = sections[0]
selectedIndex = sectionFirstIndex(focusSection)
return
}
var inSingleRow = sectionIsSingleRow(focusSection)
var max = inSingleRow ? 0 : sectionCount(focusSection) - 1
if (delta > 0) {
if (!inSingleRow && selectedIndex < max) { selectedIndex = selectedIndex + 1; return }
if (sIdx < sections.length - 1) {
focusSection = sections[sIdx + 1]
selectedIndex = sectionFirstIndex(focusSection)
}
} else {
if (!inSingleRow && selectedIndex > 0) { selectedIndex = selectedIndex - 1; return }
if (sIdx > 0) {
var prev = sections[sIdx - 1]
focusSection = prev
// Coming up from below — land on the last navigable row of the prev
// section, or its sentinel for single-row sections.
selectedIndex = sectionIsSingleRow(prev) ? sectionFirstIndex(prev) : sectionCount(prev) - 1
}
}
}
// h/l: in scale section, walks the preset row; everywhere else, no-op
// because adjustBrightness handles horizontal motion on the brightness
// slider.
function moveCursorH(delta) {
if (focusSection !== "scale") return
var next = selectedIndex + delta
if (next < 0) next = 0
if (next > scaleValues.length - 1) next = scaleValues.length - 1
selectedIndex = next
}
function adjustBrightness(delta) {
if (focusSection !== "brightness") return
if (!brightnessAvailable) return
setBrightness(root.brightnessPercent + delta)
}
function activateCursor() {
if (focusSection === "scale" && selectedIndex >= 0 && selectedIndex < scaleValues.length) {
setScale(scaleValues[selectedIndex])
return
}
if (focusSection === "monitors" && selectedIndex >= 0 && selectedIndex < displays.length) {
var d = displays[selectedIndex]
if (d) toggleDisplay(d.name, d.enabled)
}
// brightness: no semantic activation; the slider value is the action.
}
function clampCursor() {
var sections = visibleSections
if (!sections || !sections.length) return
if (sections.indexOf(focusSection) < 0) {
focusSection = sections[0]
selectedIndex = sectionFirstIndex(focusSection)
return
}
var count = sectionCount(focusSection)
if (sectionIsSingleRow(focusSection)) {
// brightness uses -1 sentinel; scale clamps into the preset range.
if (focusSection === "brightness") selectedIndex = -1
else if (selectedIndex < 0 || selectedIndex >= count) selectedIndex = 0
return
}
if (count === 0) {
var sIdx = sections.indexOf(focusSection)
focusSection = sIdx > 0 ? sections[sIdx - 1] : sections[0]
selectedIndex = sectionFirstIndex(focusSection)
return
}
if (selectedIndex > count - 1) selectedIndex = count - 1
if (selectedIndex < 0) selectedIndex = 0
}
// Keep the keyboard-focused row inside the viewport when the panel grows
// taller than its allotted height (lots of displays). Mirrors audio's
// ensureCursorVisible helper.
function ensureCursorVisible(item) {
if (!item || !scrollArea) return
var flick = scrollArea.contentItem
if (!flick || flick.contentY === undefined) return
var pt = item.mapToItem(flick.contentItem || flick, 0, 0)
var top = pt.y
var bottom = top + (item.height || 0)
var viewTop = flick.contentY
var viewBottom = viewTop + flick.height
var margin = 6
if (top < viewTop + margin) flick.contentY = Math.max(0, top - margin)
else if (bottom > viewBottom - margin)
flick.contentY = bottom + margin - flick.height
}
function closePopout() { popupOpen = false }
IpcHandler {
target: "monitorPanel"
function brightness(percent: string): string {
var value = Number(percent)
root.setBrightness(value)
return "got " + root.pendingBrightnessPercent
}
function state(): string {
return JSON.stringify({
brightness: root.brightnessPercent,
brightnessAvailable: root.brightnessAvailable,
focusedMonitor: root.focusedMonitor,
scale: root.monitorScale,
displays: root.displays
})
}
function toggle(): void {
if (root.popupOpen) root.closePopout()
else root.popupOpen = true
}
function show(): void { if (!root.popupOpen) root.popupOpen = true }
function hide(): void { root.closePopout() }
}
function refresh() {
if (!stateProc.running) stateProc.running = true
}
function setBrightness(value) {
var percent = Math.max(1, Math.min(100, Math.round(value)))
root.brightnessPercent = percent
root.pendingBrightnessPercent = percent
if (setBrightnessProc.running) {
root.brightnessSetQueued = true
return
}
root.brightnessSetQueued = false
setBrightnessProc.command = ["bash", "-lc", "omarchy-brightness-display " + percent + "%"]
setBrightnessProc.running = true
}
function previewBrightness(value) {
root.brightnessPercent = Math.max(1, Math.min(100, Math.round(value)))
brightnessDebounce.restart()
}
function toggleMirror() {
if (!internalMonitor || !externalMonitor) return
actionProc.command = ["bash", "-lc", "if hyprctl monitors -j | jq -e --arg i '" + internalMonitor + "' --arg e '" + externalMonitor + "' '.[] | select(.name == $i and .mirrorOf == $e)' >/dev/null; then hyprctl keyword monitor '" + internalMonitor + ",preferred,auto,auto'; else hyprctl keyword monitor '" + internalMonitor + ",preferred,auto,auto,mirror," + externalMonitor + "'; fi"]
if (!actionProc.running) actionProc.running = true
}
function toggleInternal() {
if (!internalMonitor || !externalMonitor) return
actionProc.command = ["bash", "-lc", "if hyprctl monitors -j | jq -e --arg i '" + internalMonitor + "' '.[] | select(.name == $i)' >/dev/null; then hyprctl keyword monitor '" + internalMonitor + ",disable'; else hyprctl keyword monitor '" + internalMonitor + ",preferred,auto,auto'; fi"]
if (!actionProc.running) actionProc.running = true
}
function normalizeScale(scale) {
var n = parseFloat(String(scale || ""))
if (!isFinite(n)) return ""
return String(Math.round(n * 100) / 100)
}
function updateDisplays(displaysJson) {
try {
root.displays = displaysJson ? JSON.parse(displaysJson) : []
} catch(e) {
root.displays = []
}
var count = 0
for (var i = 0; i < root.displays.length; i++)
if (root.displays[i] && root.displays[i].enabled) count++
root.enabledDisplayCount = count
}
function toggleDisplay(name, enabled) {
if (!name) return
if (enabled && root.enabledDisplayCount <= 1) return
actionProc.command = ["hyprctl", "keyword", "monitor", name + (enabled ? ",disable" : ",preferred,auto,auto")]
if (!actionProc.running) actionProc.running = true
}
function setScale(scale) {
actionProc.command = ["bash", "-lc", "omarchy-hyprland-monitor-scaling " + scale]
if (!actionProc.running) actionProc.running = true
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
Component.onCompleted: refresh()
// KeyboardPanel takes Exclusive focus at map-time, so SUPER-bound IPC
// summons land with j/k ready to navigate. Seed the cursor on each open.
onPopupOpenChanged: {
if (popupOpen) {
refresh()
if (brightnessAvailable) {
focusSection = "brightness"
selectedIndex = -1
} else {
focusSection = "scale"
selectedIndex = 0
}
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
}
}
onBrightnessAvailableChanged: clampCursor()
onDisplaysChanged: clampCursor()
onVisibleSectionsChanged: clampCursor()
Timer {
interval: 5000
running: true
repeat: true
onTriggered: root.refresh()
}
Process {
id: stateProc
command: ["bash", "-lc", "omarchy-brightness-display 2>/dev/null || true; monitors_json=$(hyprctl monitors all -j); printf '%s\\n' \"$monitors_json\" | jq -r 'def internal: test(\"^(eDP|LVDS|DSI)-\"); ([.[] | select(.name | internal)][0].name // \"\"), ([.[] | select((.name | internal) | not)][0].name // \"\"), ([.[] | select((.name | internal) and .disabled != true)][0].name // \"\"), ([.[] | select((.name | internal) and .mirrorOf != \"none\")][0].mirrorOf // \"\")'; omarchy-hyprland-monitor-focused 2>/dev/null || echo; omarchy-hyprland-monitor-scaling 2>/dev/null || echo; printf '%s\\n' \"$monitors_json\" | jq -c '[.[] | {name, enabled:(.disabled != true), focused:(.focused == true)}]'"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var lines = String(text || "").split("\n")
var brightness = String(lines[0] || "").trim()
root.brightnessAvailable = brightness !== "unavailable" && brightness !== ""
root.brightnessPercent = root.brightnessAvailable ? Math.max(0, Math.min(100, parseInt(brightness, 10))) : 0
root.internalMonitor = String(lines[1] || "").trim()
root.externalMonitor = String(lines[2] || "").trim()
root.internalEnabled = String(lines[3] || "").trim() !== ""
root.mirrorEnabled = String(lines[4] || "").trim() === root.externalMonitor && root.externalMonitor !== ""
root.focusedMonitor = String(lines[5] || "").trim()
root.monitorScale = root.normalizeScale(String(lines[6] || "").trim())
root.updateDisplays(String(lines[7] || "[]").trim())
}
}
}
Timer {
id: brightnessDebounce
interval: 180
repeat: false
onTriggered: root.setBrightness(root.brightnessPercent)
}
Process {
id: setBrightnessProc
stdout: StdioCollector { waitForEnd: true }
// Do NOT call refresh() after a brightness set completes. The local
// brightnessPercent we just wrote is authoritative; re-reading via
// `omarchy-brightness-display` races the hardware/driver and can
// return an empty string, which the parser then coerces to 0 —
// visible as a "bounce to zero" after h/l keypresses. External
// brightness changes are still picked up by the 5s periodic refresh,
// the open-time refresh, and Component.onCompleted.
onRunningChanged: {
if (running) return
if (root.brightnessSetQueued) {
root.setBrightness(root.pendingBrightnessPercent)
}
}
}
Process {
id: actionProc
stdout: StdioCollector { waitForEnd: true }
onRunningChanged: if (!running) root.refresh()
}
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: "󰍹"
fontSize: 13
onPressed: function(b) { root.popupOpen = !root.popupOpen }
onWheelMoved: function(delta) {
if (root.brightnessAvailable) root.setBrightness(root.brightnessPercent + (delta > 0 ? 5 : -5))
}
}
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.popupOpen
contentWidth: 320
contentHeight: Math.min(560, panelColumn.implicitHeight + 28)
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onMoveRequested: function(dx, dy) {
if (dy !== 0) root.moveCursor(dy)
else if (dx !== 0) {
if (root.focusSection === "brightness") root.adjustBrightness(dx * 5)
else if (root.focusSection === "scale") root.moveCursorH(dx)
}
}
onActivateRequested: root.activateCursor()
onCloseRequested: root.closePopout()
ScrollView {
id: scrollArea
anchors.fill: parent
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
Column {
id: panelColumn
width: scrollArea.availableWidth
spacing: 14
// ---- Brightness ----
Column {
width: parent.width
spacing: 6
PanelSectionHeader {
text: "Brightness"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
fontSize: 11
}
CursorSurface {
id: brightnessRow
visible: root.brightnessAvailable
width: parent.width
height: brightnessInner.implicitHeight + 8
hasCursor: root.focusSection === "brightness" && root.selectedIndex === -1
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(brightnessRow)
foreground: root.bar.foreground
fill: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.18)
Row {
id: brightnessInner
anchors.fill: parent
anchors.leftMargin: 6
anchors.rightMargin: 6
spacing: 8
Text {
text: "󰃠"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 16
width: 22
horizontalAlignment: Text.AlignHCenter
anchors.verticalCenter: parent.verticalCenter
}
PanelSlider {
id: brightnessSlider
bar: root.bar
width: parent.width - 22 - brightnessLabel.width - 16
anchors.verticalCenter: parent.verticalCenter
minimum: 1
maximum: 100
step: 1
value: root.brightnessPercent
integer: true
onMoved: function(v) { root.previewBrightness(v) }
onReleased: function(v) {
brightnessDebounce.stop()
root.setBrightness(v)
}
}
Text {
id: brightnessLabel
text: Math.round(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent) + "%"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 11
width: 36
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
}
HoverHandler {
onHoveredChanged: if (hovered) {
root.focusSection = "brightness"
root.selectedIndex = -1
}
}
}
Text {
visible: !root.brightnessAvailable
text: "No controllable backlight found"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: 11
}
}
// ---- Scale ----
Column {
width: parent.width
spacing: 6
PanelSectionHeader {
text: "Scale"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
fontSize: 11
}
Row {
width: parent.width
spacing: 6
Repeater {
model: root.scaleValues
ChoiceButton {
required property string modelData
required property int index
width: (panelColumn.width - 30) / 6
text: modelData + "x"
foreground: root.bar.foreground
background: root.bar.background
accent: root.bar.foreground
fontFamily: root.bar.fontFamily
fontSize: 11
selected: root.normalizeScale(root.monitorScale) === root.normalizeScale(modelData)
hasCursor: root.focusSection === "scale" && root.selectedIndex === index
onClicked: root.setScale(modelData)
onHovered: function(h) {
if (h) {
root.focusSection = "scale"
root.selectedIndex = index
}
}
}
}
}
}
// ---- Monitors ----
Column {
width: parent.width
spacing: 6
visible: root.displays.length > 0
PanelSectionHeader {
text: "Monitors"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
fontSize: 11
}
Repeater {
model: root.displays
Toggle {
required property var modelData
required property int index
width: panelColumn.width
label: modelData.name + (modelData.focused ? " · focused" : "")
checked: modelData.enabled
enabled: !modelData.enabled || root.enabledDisplayCount > 1
opacity: enabled ? 1.0 : 0.45
foreground: root.bar.foreground
accent: root.bar.foreground
fontFamily: root.bar.fontFamily
hasCursor: root.focusSection === "monitors" && root.selectedIndex === index
onClicked: root.toggleDisplay(modelData.name, modelData.enabled)
onHovered: function(h) {
if (h) {
root.focusSection = "monitors"
root.selectedIndex = index
}
}
}
}
}
}
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,430 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Ui
Item {
id: root
property QtObject bar: null
property string moduleName: "notificationCenter"
property var settings: ({})
property bool popupOpen: false
function closePopout() { popupOpen = false }
// Always default to the pending tab when there's anything unseen, no
// matter how the popup was opened (click, keybind/IPC, or the closePopout
// path). Keeps the spec from drifting based on the user's last manual
// tab selection.
onPopupOpenChanged: {
if (popupOpen) {
activeTab = pendingCount > 0 ? "pending" : "past"
}
}
// Look up the long-running notifications service through the shell host.
readonly property var hostShell: bar && bar.shell ? bar.shell : null
readonly property var notificationService: hostShell && typeof hostShell.firstPartyServiceFor === "function"
? hostShell.firstPartyServiceFor("omarchy.notifications")
: null
function isChromiumDerived(app, appIcon) {
var source = (String(app || "") + "\n" + String(appIcon || "")).toLowerCase()
return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 ||
source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 ||
source.indexOf("opera") >= 0
}
function sanitizeBody(s, app, appIcon) {
var text = String(s || "").replace(/<img[^>]*>/gi, "")
if (!isChromiumDerived(app, appIcon)) return text
return text
.replace(/^\s*<a\b[^>]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "")
.replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "")
}
readonly property int pendingCount: notificationService ? notificationService.pendingModel.count : 0
readonly property int pastCount: notificationService ? notificationService.pastModel.count : 0
readonly property bool dnd: notificationService ? notificationService.doNotDisturb : false
// Which tab is active in the popup. Auto-selects pending when there's
// something unseen; otherwise opens past.
property string activeTab: "pending"
readonly property string icon: {
if (dnd) return "󰂛"
if (pendingCount > 0) return "󱅫"
return "󰂚"
}
// Theme palette (mirrors HistoryPanel's tokens so the popup matches the
// rest of the notification stack).
readonly property color colForeground: Color.foreground
readonly property color colDim: Qt.darker(Color.foreground, 1.4)
readonly property color colBorder: Qt.rgba(Color.foreground.r, Color.foreground.g, Color.foreground.b, 0.18)
readonly property color colSurface: Qt.rgba(Color.foreground.r, Color.foreground.g, Color.foreground.b, 0.06)
readonly property color colAccent: Color.accent
readonly property int cardRadius: notificationService ? notificationService.cornerRadius : 0
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.icon
active: root.pendingCount > 0 && !root.dnd
tooltipText: root.dnd ? "Do Not Disturb"
: (root.pendingCount > 0 ? root.pendingCount + " pending" : "No notifications")
onPressed: function(b) {
if (b === Qt.RightButton) {
if (root.notificationService) {
root.notificationService.setDoNotDisturb(!root.notificationService.doNotDisturb)
}
} else {
root.popupOpen = !root.popupOpen
}
}
}
// Service-side IPC (omarchy-shell notifications showHistory) flips
// historyOpenRequested; we toggle our local popup state from here so the
// keybind path lands in the same PopupCard the click path uses.
Connections {
target: root.notificationService
ignoreUnknownSignals: true
function onHistoryOpenRequested() {
root.popupOpen = true
}
}
PopupCard {
id: popup
anchorItem: button
bar: root.bar
owner: root
open: root.popupOpen
contentWidth: 440
contentHeight: 540
ColumnLayout {
anchors.fill: parent
spacing: 10
// ----------------------------------------- header
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "Notifications"
font.family: root.bar ? root.bar.fontFamily : ""
color: root.colForeground
font.pixelSize: 14
font.bold: true
}
Item { Layout.fillWidth: true }
Rectangle {
id: dndPill
Layout.preferredHeight: 24
Layout.preferredWidth: dndLabel.implicitWidth + dndGlyph.implicitWidth + 18
radius: Math.min(12, root.cardRadius + 6)
color: dndOn ? root.colAccent : root.colSurface
border.color: dndOn ? root.colAccent : root.colBorder
border.width: 1
readonly property bool dndOn: !!root.notificationService && root.notificationService.doNotDisturb
Row {
anchors.centerIn: parent
spacing: 4
Text {
id: dndGlyph
text: dndPill.dndOn ? "󰂛" : "󰂚"
font.family: root.bar ? root.bar.fontFamily : ""
color: dndPill.dndOn ? Color.background : root.colDim
font.pixelSize: 12
anchors.verticalCenter: parent.verticalCenter
}
Text {
id: dndLabel
text: dndPill.dndOn ? "DND on" : "DND off"
font.family: root.bar ? root.bar.fontFamily : ""
color: dndPill.dndOn ? Color.background : root.colDim
font.pixelSize: 10
font.bold: true
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: if (root.notificationService) root.notificationService.setDoNotDisturb(!dndPill.dndOn)
}
}
}
// ----------------------------------------- tabs
RowLayout {
Layout.fillWidth: true
spacing: 0
Repeater {
model: [
{ key: "pending", label: "Pending", count: root.pendingCount },
{ key: "past", label: "Recently", count: root.pastCount }
]
delegate: Rectangle {
required property var modelData
readonly property bool isActive: root.activeTab === modelData.key
Layout.fillWidth: true
Layout.preferredHeight: 30
color: "transparent"
Text {
anchors.centerIn: parent
text: modelData.label + (modelData.count > 0 ? " " + modelData.count : "")
font.family: root.bar ? root.bar.fontFamily : ""
color: parent.isActive ? root.colForeground : root.colDim
font.pixelSize: 12
font.bold: parent.isActive
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 2
color: parent.isActive ? root.colAccent : root.colBorder
opacity: parent.isActive ? 1 : 0.4
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.activeTab = modelData.key
}
}
}
}
// ----------------------------------------- action row
RowLayout {
Layout.fillWidth: true
visible: (root.activeTab === "pending" && root.pendingCount > 0)
|| (root.activeTab === "past" && root.pastCount > 0)
spacing: 8
Item { Layout.fillWidth: true }
Rectangle {
Layout.preferredWidth: actionLabel.implicitWidth + 16
Layout.preferredHeight: 22
radius: Math.min(6, root.cardRadius)
color: actionArea.containsMouse ? root.colBorder : "transparent"
border.color: root.colBorder
border.width: 1
Text {
id: actionLabel
anchors.centerIn: parent
text: root.activeTab === "pending" ? "Mark all as seen" : "Clear recent"
font.family: root.bar ? root.bar.fontFamily : ""
color: root.colForeground
font.pixelSize: 10
}
MouseArea {
id: actionArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (!root.notificationService) return
if (root.activeTab === "pending") root.notificationService.markAllSeen()
else root.notificationService.clearPast()
}
}
}
}
// ----------------------------------------- list
ListView {
id: listView
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
spacing: 8
readonly property bool onPending: root.activeTab === "pending"
model: !root.notificationService ? null
: (onPending ? root.notificationService.pendingModel : root.notificationService.pastModel)
visible: count > 0
delegate: Rectangle {
id: rowCard
required property int index
required property string app
required property string appIcon
required property string summary
required property string body
required property string image
required property int urgency
required property double timestamp
readonly property bool hasMedia: image.length > 0 && (
image.indexOf("image://icon//") === 0 || image.indexOf("file://") === 0)
readonly property string smallIconSource: image.length > 0 ? image : appIcon
readonly property bool hasIcon: !hasMedia && smallIconSource.length > 0
readonly property string sanitizedBody: root.sanitizeBody(body, app, appIcon)
width: listView.width
implicitHeight: rowContent.implicitHeight + 20
radius: root.cardRadius
color: "transparent"
border.color: root.colBorder
border.width: 1
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: { /* no-op */ }
}
RowLayout {
id: rowContent
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 10
Item {
Layout.preferredWidth: 32
Layout.preferredHeight: 32
Layout.alignment: Qt.AlignVCenter
// Hide on icon load failure so unresolved themed-icon names
// don't render Qt's broken-image placeholder.
visible: (rowCard.hasIcon || rowCard.hasMedia) && rowIconImage.status !== Image.Error
Image {
id: rowIconImage
anchors.fill: parent
source: rowCard.hasMedia ? rowCard.image : rowCard.smallIconSource
fillMode: rowCard.hasMedia ? Image.PreserveAspectCrop : Image.PreserveAspectFit
sourceSize.width: 32 * Screen.devicePixelRatio
sourceSize.height: 32 * Screen.devicePixelRatio
asynchronous: true
smooth: true
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
Layout.fillWidth: true
visible: rowCard.summary.length > 0
text: rowCard.summary
font.family: root.bar ? root.bar.fontFamily : ""
color: root.colForeground
font.pixelSize: 13
font.bold: true
wrapMode: Text.WordWrap
elide: Text.ElideRight
maximumLineCount: 1
}
Text {
Layout.fillWidth: true
visible: rowCard.sanitizedBody.length > 0
text: rowCard.sanitizedBody
font.family: root.bar ? root.bar.fontFamily : ""
textFormat: Text.PlainText
color: root.colDim
font.pixelSize: 11
wrapMode: Text.WordWrap
elide: Text.ElideRight
maximumLineCount: 2
}
}
Rectangle {
Layout.preferredWidth: 18
Layout.preferredHeight: 18
Layout.alignment: Qt.AlignVCenter
radius: Math.min(4, root.cardRadius)
color: rowCloseArea.containsMouse ? root.colBorder : "transparent"
Text {
anchors.centerIn: parent
text: "✕"
font.family: root.bar ? root.bar.fontFamily : ""
color: root.colDim
font.pixelSize: 11
}
MouseArea {
id: rowCloseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (!root.notificationService) return
if (listView.onPending) root.notificationService.dismissPending(rowCard.index)
else root.notificationService.dismissPast(rowCard.index)
}
}
}
}
}
}
// ----------------------------------------- empty state
Item {
Layout.fillWidth: true
Layout.fillHeight: true
visible: listView.count === 0
ColumnLayout {
anchors.centerIn: parent
spacing: 6
Text {
Layout.alignment: Qt.AlignHCenter
text: "󰂚"
font.family: root.bar ? root.bar.fontFamily : ""
color: root.colBorder
font.pixelSize: 36
}
Text {
Layout.alignment: Qt.AlignHCenter
text: root.activeTab === "pending"
? "Nothing waiting for you"
: "Nothing recent"
font.family: root.bar ? root.bar.fontFamily : ""
? "Nothing waiting for you"
: "No past notifications"
color: root.colDim
font.pixelSize: 12
}
}
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import QtQuick
Item {
id: root
property QtObject bar: null
property string moduleName: "spacer"
property var settings: ({})
readonly property bool vertical: bar ? bar.vertical : false
readonly property int span: settings && settings.size !== undefined ? Number(settings.size) : 12
implicitWidth: vertical ? (bar ? bar.barSize : 28) : span
implicitHeight: vertical ? span : (bar ? bar.barSize : 26)
visible: span > 0
}
+292
View File
@@ -0,0 +1,292 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Ui
Item {
id: root
property QtObject bar: null
property string moduleName: "systemStats"
property var settings: ({})
property real cpuPercent: 0
property real memPercent: 0
property var cpuHistory: []
property var memHistory: []
property real loadAvg: 0
property var prevCpu: ({ idle: 0, total: 0 })
property bool popupOpen: false
function closePopout() { popupOpen = false }
readonly property int historyLimit: 30
function refresh() {
if (!cpuProc.running) cpuProc.running = true
if (!memProc.running) memProc.running = true
if (!loadProc.running) loadProc.running = true
}
function pushHistory(arr, value) {
var next = arr.slice()
next.push(value)
if (next.length > historyLimit) next.shift()
return next
}
function updateCpu(raw) {
var fields = String(raw || "").trim().split(/\s+/)
if (fields.length < 8) return
var user = parseInt(fields[1], 10) || 0
var nice = parseInt(fields[2], 10) || 0
var sys = parseInt(fields[3], 10) || 0
var idle = parseInt(fields[4], 10) || 0
var iowait = parseInt(fields[5], 10) || 0
var irq = parseInt(fields[6], 10) || 0
var softirq = parseInt(fields[7], 10) || 0
var total = user + nice + sys + idle + iowait + irq + softirq
var totalDiff = total - prevCpu.total
var idleDiff = idle - prevCpu.idle
if (prevCpu.total > 0 && totalDiff > 0) {
var usage = (1 - idleDiff / totalDiff) * 100
cpuPercent = Math.max(0, Math.min(100, usage))
cpuHistory = pushHistory(cpuHistory, cpuPercent)
}
prevCpu = { idle: idle, total: total }
}
function updateMem(raw) {
var lines = String(raw || "").split("\n")
var total = 0
var available = 0
for (var i = 0; i < lines.length; i++) {
var line = lines[i]
if (line.indexOf("MemTotal:") === 0) total = parseInt(line.replace(/[^0-9]/g, ""), 10) || 0
else if (line.indexOf("MemAvailable:") === 0) available = parseInt(line.replace(/[^0-9]/g, ""), 10) || 0
}
if (total > 0) {
memPercent = ((total - available) / total) * 100
memHistory = pushHistory(memHistory, memPercent)
}
}
function updateLoad(raw) {
var n = parseFloat(String(raw || "").trim().split(/\s+/)[0])
if (!isNaN(n)) loadAvg = n
}
Component.onCompleted: refresh()
Process {
id: cpuProc
command: ["bash", "-lc", "head -n1 /proc/stat"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.updateCpu(text)
}
}
Process {
id: memProc
command: ["bash", "-lc", "head -n3 /proc/meminfo"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.updateMem(text)
}
}
Process {
id: loadProc
command: ["bash", "-lc", "cat /proc/loadavg"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.updateLoad(text)
}
}
Timer {
interval: 2000
running: true
repeat: true
onTriggered: root.refresh()
}
readonly property bool vertical: bar ? bar.vertical : false
readonly property color statColor: bar ? bar.foreground : "#cacccc"
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
// Hover state across the trigger button and the popup.
property bool buttonHovered: false
property bool popupHovered: popup.containsMouse
function showPopup() {
hideTimer.stop()
popupOpen = true
}
function scheduleHide() {
hideTimer.restart()
}
Timer {
id: hideTimer
interval: 220
onTriggered: {
if (!root.buttonHovered && !root.popupHovered) root.popupOpen = false
}
}
onButtonHoveredChanged: buttonHovered ? showPopup() : scheduleHide()
onPopupHoveredChanged: popupHovered ? hideTimer.stop() : scheduleHide()
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: "󰍛"
horizontalMargin: 7.5
tooltipText: ""
onPressed: function(b) {
if (b === Qt.LeftButton) {
root.popupOpen = false
root.bar.run("omarchy-launch-or-focus-tui btop")
}
}
}
HoverHandler {
id: hoverHandler
target: button
onHoveredChanged: root.buttonHovered = hovered
}
PopupCard {
id: popup
anchorItem: button
owner: root
bar: root.bar
open: root.popupOpen
triggerMode: "hover"
contentWidth: 320
contentHeight: detailColumn.implicitHeight + 28
Column {
id: detailColumn
anchors.fill: parent
spacing: 10
Text {
text: "System"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 12
font.bold: true
}
DetailStat {
title: "CPU"
value: Math.round(root.cpuPercent) + "%"
history: root.cpuHistory
barFg: root.statColor
fontFamily: root.bar.fontFamily
width: parent.width
}
DetailStat {
title: "Memory"
value: Math.round(root.memPercent) + "%"
history: root.memHistory
barFg: root.statColor
fontFamily: root.bar.fontFamily
width: parent.width
}
Row {
width: parent.width
spacing: 6
Text {
text: "Load"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: 11
}
Text {
text: root.loadAvg.toFixed(2)
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 11
}
}
}
}
component DetailStat: Column {
id: detail
property string title: ""
property string value: ""
property var history: []
property color barFg: "#cacccc"
property string fontFamily: "JetBrainsMono Nerd Font"
spacing: 4
Row {
width: parent.width
Text {
text: detail.title
color: Qt.darker(detail.barFg, 1.4)
font.family: detail.fontFamily
font.pixelSize: 11
}
Item { width: detail.width - parent.children[0].implicitWidth - parent.children[2].implicitWidth; height: 1 }
Text {
text: detail.value
color: detail.barFg
font.family: detail.fontFamily
font.pixelSize: 11
}
}
Canvas {
id: detailCanvas
width: parent.width
height: 40
property var history: detail.history
onHistoryChanged: requestPaint()
onPaint: {
var ctx = getContext("2d")
ctx.clearRect(0, 0, width, height)
if (!detail.history || detail.history.length === 0) return
ctx.strokeStyle = detail.barFg
ctx.fillStyle = Qt.rgba(detail.barFg.r, detail.barFg.g, detail.barFg.b, 0.25)
ctx.lineWidth = 1.5
ctx.beginPath()
var step = width / Math.max(1, detail.history.length - 1)
for (var i = 0; i < detail.history.length; i++) {
var x = i * step
var y = height - (detail.history[i] / 100) * (height - 2) - 1
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
ctx.lineTo(width, height)
ctx.lineTo(0, height)
ctx.closePath()
ctx.fill()
}
}
}
}
+565
View File
@@ -0,0 +1,565 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
Item {
id: root
property QtObject bar: null
property string moduleName: "weatherFlyout"
property var settings: ({})
property bool popupOpen: false
function closePopout() { popupOpen = false }
IpcHandler {
target: "weatherFlyout"
function show(): void {
root.popupOpen = !root.popupOpen
if (root.popupOpen) root.refresh()
}
function toggle(): void {
show()
}
}
// Parsed wttr.in j1 response. Kept on failure so stale data stays visible.
property var report: null
property var dailyForecastReport: null
property string wttrLocation: ""
// Bar pill state. Polled locally; populated by weatherProc below.
property string label: ""
property string klass: ""
function updateWeather(raw) {
var data
try { data = JSON.parse(raw || "{}") } catch (e) { data = {} }
label = data.text || ""
klass = data.class || ""
}
readonly property var current: report && report.current_condition && report.current_condition[0] ? report.current_condition[0] : null
readonly property var areaInfo: report && report.nearest_area && report.nearest_area[0] ? report.nearest_area[0] : null
readonly property var forecastDays: buildForecastDays()
readonly property bool useImperial: {
var override = setting("unit", "")
if (override === "imperial") return true
if (override === "metric") return false
var name = String(Qt.locale().name || "")
return /^en_US/.test(name) || /^en_LR/.test(name) || /^my/.test(name)
}
// Auto-refresh interval in minutes; clamped to a sane minimum.
readonly property int refreshMinutes: Math.max(1, parseInt(setting("refreshMinutes", 15), 10) || 15)
readonly property string reportLocation: wttrLocation || (areaInfo && areaInfo.areaName && areaInfo.areaName[0] ? areaInfo.areaName[0].value : "")
readonly property string reportCondition: current && current.weatherDesc && current.weatherDesc[0] ? current.weatherDesc[0].value : ""
readonly property string reportTemp: current ? formatTemp(useImperial ? current.temp_F : current.temp_C) : ""
readonly property string reportTempNum: current ? String(useImperial ? current.temp_F : current.temp_C) : ""
readonly property string tempUnit: "°" + (useImperial ? "F" : "C")
readonly property string reportFeels: current ? formatTemp(useImperial ? current.FeelsLikeF : current.FeelsLikeC) : ""
readonly property string reportWind: current ? (useImperial ? (current.windspeedMiles + " mph") : (current.windspeedKmph + " km/h")) : ""
readonly property string reportHumidity: current ? (current.humidity + "%") : ""
visible: label !== ""
implicitWidth: button.implicitWidth + 8
implicitHeight: button.implicitHeight
function setting(name, fallback) {
var v = settings ? settings[name] : undefined
return v === undefined || v === null ? fallback : v
}
function refresh() {
if (!forecastProc.running) forecastProc.running = true
if (!locationProc.running) locationProc.running = true
}
function refreshDailyForecast(sourceReport) {
var area = sourceReport && sourceReport.nearest_area && sourceReport.nearest_area[0] ? sourceReport.nearest_area[0] : root.areaInfo
if (!area || dailyForecastProc.running) return
var lat = parseFloat(String(area.latitude || ""))
var lon = parseFloat(String(area.longitude || ""))
if (isNaN(lat) || isNaN(lon)) return
var url = "https://api.open-meteo.com/v1/forecast"
+ "?latitude=" + encodeURIComponent(String(lat))
+ "&longitude=" + encodeURIComponent(String(lon))
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min"
+ "&forecast_days=4"
+ "&timezone=auto"
dailyForecastProc.command = ["curl", "-fsS", "--max-time", "5", url]
dailyForecastProc.running = true
}
function buildForecastDays() {
var days = openMeteoForecastDays()
return days.length > 0 ? days : wttrNextForecastDays()
}
function openMeteoForecastDays() {
var daily = dailyForecastReport && dailyForecastReport.daily ? dailyForecastReport.daily : null
if (!daily || !daily.time) return []
var result = []
for (var i = 0; i < daily.time.length && result.length < 3; ++i) {
var date = daily.time[i]
if (!isFutureForecastDate(date)) continue
var maxC = daily.temperature_2m_max ? daily.temperature_2m_max[i] : ""
var minC = daily.temperature_2m_min ? daily.temperature_2m_min[i] : ""
result.push({
date: date,
maxtempC: roundedTemp(maxC),
mintempC: roundedTemp(minC),
maxtempF: roundedTemp(celsiusToFahrenheit(maxC)),
mintempF: roundedTemp(celsiusToFahrenheit(minC)),
openMeteoWeatherCode: daily.weather_code ? daily.weather_code[i] : null
})
}
return result
}
function wttrNextForecastDays() {
var days = report && report.weather ? report.weather : []
var result = []
for (var i = 0; i < days.length && result.length < 3; ++i) {
if (isFutureForecastDate(days[i].date)) result.push(days[i])
}
return result
}
function isFutureForecastDate(dateString) {
if (!dateString) return false
return String(dateString).slice(0, 10) > Qt.formatDate(new Date(), "yyyy-MM-dd")
}
function roundedTemp(value) {
if (value === undefined || value === null || value === "") return ""
var n = parseFloat(String(value))
return isNaN(n) ? "" : String(Math.round(n))
}
function celsiusToFahrenheit(value) {
if (value === undefined || value === null || value === "") return ""
var n = parseFloat(String(value))
return isNaN(n) ? "" : (n * 9 / 5) + 32
}
function formatTemp(value) {
if (value === undefined || value === null || value === "") return ""
return value + "°" + (useImperial ? "F" : "C")
}
function dayName(dateString) {
if (!dateString) return ""
var d = new Date(dateString + "T12:00:00")
if (isNaN(d.getTime())) return ""
return Qt.formatDate(d, "dddd")
}
function maxTempForDay(day) {
if (!day) return ""
return formatTemp(useImperial ? day.maxtempF : day.maxtempC)
}
function minTempForDay(day) {
if (!day) return ""
return formatTemp(useImperial ? day.mintempF : day.mintempC)
}
// Bare degree value (no unit letter), used in the forecast row.
function bareTempForDay(day, kind) {
if (!day) return ""
var v = useImperial
? (kind === "max" ? day.maxtempF : day.mintempF)
: (kind === "max" ? day.maxtempC : day.mintempC)
if (v === undefined || v === null || v === "") return ""
return v + "°"
}
// Representative icon for a forecast day: the hourly entry nearest noon.
function dayIcon(day) {
if (!day) return ""
if (day.openMeteoWeatherCode !== undefined && day.openMeteoWeatherCode !== null) return iconForOpenMeteoCode(day.openMeteoWeatherCode)
if (!day.hourly || day.hourly.length === 0) return ""
var best = day.hourly[0]
var bestDist = 9999
for (var i = 0; i < day.hourly.length; ++i) {
var t = parseInt(String(day.hourly[i].time || "0"), 10)
var dist = Math.abs(t - 1200)
if (dist < bestDist) { bestDist = dist; best = day.hourly[i] }
}
return iconForCode(best.weatherCode, false)
}
function iconForOpenMeteoCode(code) {
var c = parseInt(String(code || "0"), 10)
if (c === 0) return iconForCode(113, false)
if (c === 1 || c === 2) return iconForCode(116, false)
if (c === 3) return iconForCode(119, false)
if (c === 45 || c === 48) return iconForCode(143, false)
if (c === 51 || c === 53 || c === 55 || c === 56 || c === 57 || c === 61) return iconForCode(266, false)
if (c === 63 || c === 65 || c === 66 || c === 67 || c === 80 || c === 81 || c === 82) return iconForCode(308, false)
if (c === 71 || c === 73 || c === 75 || c === 77 || c === 85 || c === 86) return iconForCode(338, false)
if (c === 95 || c === 96 || c === 99) return iconForCode(389, false)
return iconForCode(119, false)
}
// Mirrors omarchy-weather-icon's wttr.in code → nerd-font glyph mapping.
function iconForCode(code, night) {
var c = parseInt(String(code || "0"), 10)
switch (c) {
case 113: return night ? "" : ""
case 116: return night ? "" : ""
case 119: case 122: return ""
case 143: case 248: case 260: return ""
case 176: case 263: case 353: return night ? "" : ""
case 179: case 227: case 230: case 323: case 326: case 368: return night ? "" : ""
case 182: case 185: case 281: case 284: case 311: case 314:
case 317: case 320: case 350: case 362: case 365: case 374: case 377: return ""
case 200: case 386: case 389: case 392: case 395: return ""
case 266: case 293: case 296: case 299: case 302: case 305: case 308: case 356: case 359: return ""
case 329: case 332: case 335: case 338: case 371: return ""
default: return ""
}
}
Process {
id: forecastProc
command: ["bash", "-lc", "curl -fsS --max-time 5 'https://wttr.in/?format=j1' 2>/dev/null"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var raw = String(text || "").trim()
if (!raw) return
try {
var parsed = JSON.parse(raw)
root.report = parsed
root.refreshDailyForecast(parsed)
} catch (e) {
// Keep last-good report on parse failure so the popup isn't blanked.
}
}
}
}
Process {
id: dailyForecastProc
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var raw = String(text || "").trim()
if (!raw) return
try {
root.dailyForecastReport = JSON.parse(raw)
} catch (e) {
// Keep last-good daily forecast on parse failure.
}
}
}
}
Process {
id: locationProc
command: ["bash", "-lc", "curl -fsS --max-time 4 'https://wttr.in?format=%l' 2>/dev/null"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var raw = String(text || "").trim()
if (!raw) return
root.wttrLocation = raw.split(",")[0]
}
}
}
Timer {
id: refreshTimer
interval: root.refreshMinutes * 60 * 1000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.refresh()
}
WidgetButton {
id: button
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: implicitWidth
height: implicitHeight
bar: root.bar
text: root.label
active: root.klass === "active"
horizontalMargin: 1
// Tooltip suppressed — the popup itself is the detail view.
tooltipText: ""
onPressed: function(b) {
if (b === Qt.RightButton) {
root.bar.run("omarchy-notification-send \"$(omarchy-weather-status)\"")
} else if (b === Qt.MiddleButton) {
root.refresh()
} else {
var willOpen = !root.popupOpen
root.popupOpen = willOpen
if (willOpen) root.refresh()
}
}
}
PopupCard {
id: popup
anchorItem: button
owner: root
bar: root.bar
open: root.popupOpen
centerOnBar: true
triggerMode: "click"
contentWidth: 480
contentHeight: card.implicitHeight + 28
margin: 24
borderColor: Color.notifications.border
Column {
id: card
anchors.fill: parent
spacing: 14
// ---- Hero row: big icon + temp on the left; location and stats stacked on the right.
Item {
width: parent.width
height: Math.max(heroLeft.height, heroRight.height)
Row {
id: heroLeft
anchors.left: parent.left
anchors.leftMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 16
Text {
id: heroIcon
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 5
text: root.label || "—"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 64
}
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
id: tempBig
text: root.reportTempNum || "—"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 56
font.bold: true
}
Text {
text: root.current ? root.tempUnit : ""
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 22
anchors.top: tempBig.top
anchors.topMargin: 10
}
}
}
Column {
id: heroRight
anchors.right: parent.right
anchors.rightMargin: 20
anchors.verticalCenter: parent.verticalCenter
spacing: 12
Row {
visible: root.reportLocation !== ""
spacing: 6
Text {
text: "" // nf-fa-map_marker
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: 12
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: (root.reportLocation || "").toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: 12
font.letterSpacing: 1
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
visible: !!root.current
spacing: 36
Column {
spacing: 5
Text {
text: "FEELS"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: 11
font.letterSpacing: 1
}
Text {
text: root.reportFeels
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 15
}
}
Column {
spacing: 5
Text {
text: "WIND"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: 11
font.letterSpacing: 1
}
Text {
text: root.reportWind
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 15
}
}
Column {
spacing: 5
Text {
text: "HUMID"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: 11
font.letterSpacing: 1
}
Text {
text: root.reportHumidity
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 15
}
}
}
}
}
Text {
visible: !root.current
text: "Fetching forecast…"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: 11
font.italic: true
}
// ---- Divider between current conditions and forecast.
Rectangle {
visible: root.forecastDays.length > 0
width: parent.width
height: 1
color: root.bar.foreground
opacity: 0.12
}
// ---- Forecast row: each cell has the day icon left of a day-name + hi/lo column.
// Wrapped in an Item so the block of cells can be centered within the popup.
Item {
visible: root.forecastDays.length > 0
width: parent.width
height: forecastRow.height
Row {
id: forecastRow
anchors.horizontalCenter: parent.horizontalCenter
spacing: 44
Repeater {
model: root.forecastDays
Row {
required property var modelData
required property int index
spacing: 10
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.dayIcon(modelData)
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 24
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
text: root.dayName(modelData.date).toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: 10
font.letterSpacing: 1
}
Row {
spacing: 6
Text {
text: root.bareTempForDay(modelData, "max")
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 12
}
Text {
text: root.bareTempForDay(modelData, "min")
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: 12
}
}
}
}
}
}
}
}
}
// Poll the weather pill text/class every minute. Local to this widget.
Process {
id: weatherProc
command: ["bash", "-lc", root.bar ? root.bar.commandWithOmarchyPath(root.bar.shellQuote(root.bar.omarchyPath + "/shell/scripts/weather.sh")) : ""]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.updateWeather(text)
}
}
Timer {
interval: 60000
running: true
repeat: true
triggeredOnStart: true
onTriggered: if (!weatherProc.running) weatherProc.running = true
}
}
@@ -0,0 +1,387 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import qs.Commons
Item {
id: root
property string omarchyPath: Quickshell.env("OMARCHY_PATH") || (Quickshell.env("HOME") + "/.local/share/omarchy")
property var shell: null
property var manifest: null
property var pluginRegistry: null
property bool opened: false
property string filterText: ""
property int selectedIndex: 0
property var items: []
property color accent: Color.menu.selected
property color background: Color.menu.background
property color foreground: Color.menu.text
property color border: foreground
readonly property int cornerRadius: Style.cornerRadius
property string fontFamily: Quickshell.env("OMARCHY_MENU_FONT") || "monospace"
property int contentMargin: 18
property int headerHeight: 34
property int contentSpacing: 6
property int cardWidth: 800
property int cardHeight: 600
property int rowHeight: 50
function open(payloadJson) {
root.opened = true
root.filterText = ""
root.selectedIndex = 0
// Trigger fetch
fetchProc.collected = ""
fetchProc.command = ["bash", "-lc", "elephant query --json 'clipboard;;100'"]
fetchProc.running = true
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function close() {
root.opened = false
}
function toggle() {
if (root.opened) root.close()
else root.open("{}")
}
function withAlpha(color, alpha) {
return Qt.rgba(color.r, color.g, color.b, alpha)
}
function rebuildDisplay() {
var query = root.filterText.trim().toLowerCase()
displayModel.clear()
var outCount = 0
for (var i = 0; i < root.items.length; i++) {
var entry = root.items[i]
var isPassword = (entry.meta === "password" || entry.preview_type === "password")
var textMatch = false
if (isPassword) {
textMatch = false // Passwords shouldn't match plain text search queries
} else {
textMatch = (entry.preview && entry.preview.toLowerCase().indexOf(query) >= 0)
}
if (!query || textMatch) {
displayModel.append({
identifier: entry.identifier,
previewText: entry.preview_type === "text" ? entry.preview.replace(/\n/g, " ") : "",
previewImage: entry.preview_type === "file" ? ("file://" + entry.preview) : "",
previewType: entry.preview_type || "text",
isPassword: isPassword,
index: outCount
})
outCount++
if (outCount >= 50) break
}
}
if (displayModel.count === 0) selectedIndex = 0
else if (selectedIndex >= displayModel.count) selectedIndex = displayModel.count - 1
else if (selectedIndex < 0) selectedIndex = 0
Qt.callLater(function() {
if (displayModel.count > 0) resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
})
}
function select(delta) {
if (displayModel.count === 0) return
selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count
resultList.positionViewAtIndex(selectedIndex, ListView.Contain)
}
function setFilter(nextFilter) {
root.filterText = nextFilter
root.selectedIndex = 0
root.rebuildDisplay()
}
function activateIndex(index) {
if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index)
root.applySelected(row.identifier)
}
function applySelected(identifier) {
if (!identifier) return
root.opened = false
var escId = identifier.replace(/'/g, "'\\''")
Quickshell.execDetached(["bash", "-lc", "elephant activate 'clipboard;" + escId + ";copy;;'; sleep 0.15; wtype -M shift -k Insert -m shift 2>/dev/null || true"])
}
ListModel { id: displayModel }
Process {
id: fetchProc
property string collected: ""
stdout: SplitParser {
onRead: function(data) { fetchProc.collected += data + "\n" }
}
onExited: {
var lines = fetchProc.collected.split("\n")
var newItems = []
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
try {
var parsed = JSON.parse(line)
if (parsed && parsed.item) {
newItems.push(parsed.item)
}
} catch(e) {}
}
root.items = newItems
root.rebuildDisplay()
}
}
IpcHandler {
target: "clipboard-picker"
function summon(): string { root.open("{}"); return "ok" }
function hide(): string { root.close(); return "ok" }
function toggle(): string { root.toggle(); return "ok" }
function ping(): string { return "ok" }
}
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "omarchy-clipboard-picker"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: root.withAlpha(root.background, 0.5)
}
MouseArea {
anchors.fill: parent
onClicked: root.close()
}
Rectangle {
id: card
width: root.cardWidth
height: root.cardHeight
radius: root.cornerRadius
anchors.centerIn: parent
color: root.background
border.color: root.border
border.width: 2
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
id: keyCatcher
anchors.fill: parent
focus: true
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
if (root.filterText) root.setFilter("")
else root.close()
event.accepted = true
} else if (event.key === Qt.Key_Backspace) {
if (root.filterText.length > 0) root.setFilter(root.filterText.slice(0, -1))
event.accepted = true
} else if (event.key === Qt.Key_Up) {
root.select(-1)
event.accepted = true
} else if (event.key === Qt.Key_Down) {
root.select(1)
event.accepted = true
} else if (event.key === Qt.Key_PageUp) {
root.select(-6)
event.accepted = true
} else if (event.key === Qt.Key_PageDown) {
root.select(6)
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.activateIndex(root.selectedIndex)
event.accepted = true
} else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127) {
root.setFilter(root.filterText + event.text)
event.accepted = true
}
}
}
Column {
anchors.fill: parent
anchors.margins: root.contentMargin
spacing: root.contentSpacing
Rectangle {
width: parent.width
height: root.headerHeight
radius: root.cornerRadius
color: "transparent"
border.width: 0
Text {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.filterText || "Search clipboard…"
color: root.foreground
opacity: root.filterText ? 1 : 0.58
font.family: root.fontFamily
font.pixelSize: 16
elide: Text.ElideRight
}
}
Item {
width: parent.width
height: parent.height - root.headerHeight - root.contentSpacing
Row {
anchors.fill: parent
spacing: root.contentSpacing
ListView {
id: resultList
width: parent.width / 2 - root.contentSpacing / 2
height: parent.height
model: displayModel
clip: true
spacing: 4
boundsBehavior: Flickable.StopAtBounds
delegate: Rectangle {
required property int index
required property string identifier
required property string previewText
required property string previewType
required property bool isPassword
width: ListView.view.width
height: root.rowHeight
radius: root.cornerRadius
color: index === root.selectedIndex ? root.withAlpha(root.foreground, 0.08) : root.withAlpha(root.foreground, mouseArea.containsMouse ? 0.045 : 0)
Rectangle {
visible: false
width: 4
height: parent.height - 18
radius: Math.min(root.cornerRadius, 4)
color: root.accent
anchors.left: parent.left
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
}
Item {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
anchors.topMargin: 8
anchors.bottomMargin: 8
Text {
width: parent.width
height: parent.height
text: parent.parent.isPassword ? "••••••••" : (parent.parent.previewType === "text" ? parent.parent.previewText : "Image")
color: index === root.selectedIndex ? root.accent : root.foreground
font.family: root.fontFamily
font.pixelSize: 14
font.italic: parent.parent.previewType === "file" || parent.parent.isPassword
opacity: (parent.parent.previewType === "file" || parent.parent.isPassword) ? 0.6 : 1.0
elide: Text.ElideRight
wrapMode: Text.NoWrap
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.selectedIndex = index
root.activateIndex(index)
}
}
}
}
Rectangle {
width: parent.width / 2 - root.contentSpacing / 2
height: parent.height
radius: root.cornerRadius
color: root.withAlpha(root.background, 0.5)
border.color: root.withAlpha(root.border, 0.1)
border.width: 1
clip: true
property var activeRow: displayModel.count > 0 && root.selectedIndex >= 0 && root.selectedIndex < displayModel.count ? displayModel.get(root.selectedIndex) : null
Text {
visible: parent.activeRow && parent.activeRow.previewType === "text"
anchors.fill: parent
anchors.margins: 16
text: parent.activeRow ? (parent.activeRow.isPassword ? "••••••••" : parent.activeRow.previewText) : ""
color: root.foreground
font.family: root.fontFamily
font.pixelSize: 14
wrapMode: Text.WrapAnywhere
elide: Text.ElideRight
verticalAlignment: Text.AlignTop
}
Image {
visible: parent.activeRow && parent.activeRow.previewType === "file"
anchors.fill: parent
anchors.margins: 16
source: parent.activeRow ? parent.activeRow.previewImage : ""
fillMode: Image.PreserveAspectFit
}
}
}
Column {
anchors.centerIn: parent
spacing: 8
visible: displayModel.count === 0
Text {
text: "󰅌"
color: root.accent
opacity: 0.8
font.family: root.fontFamily
font.pixelSize: 28
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
Text {
text: root.items.length === 0 ? "Clipboard is empty" : "No matches for “" + root.filterText + "”"
color: root.foreground
opacity: 0.7
font.family: root.fontFamily
font.pixelSize: 14
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
}
}
}
}
}
}
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "omarchy.clipboard-picker",
"name": "Clipboard picker",
"version": "1.0.0",
"author": "Omarchy",
"description": "A clipboard manager to view and paste history",
"kinds": ["overlay"],
"activation": "on-demand",
"keepLoaded": true,
"entryPoints": {
"overlay": "ClipboardPicker.qml"
}
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
{
"schemaVersion": 1,
"id": "omarchy.dev-gallery",
"name": "Dev gallery",
"version": "1.0.0",
"author": "Omarchy",
"description": "Visual reference for omarchy-shell common UI components. Summon with: omarchy-shell-ipc shell summon omarchy.dev-gallery '{}'",
"kinds": ["panel"],
"activation": "on-demand",
"entryPoints": { "panel": "GalleryPanel.qml" }
}
+330
View File
@@ -0,0 +1,330 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import qs.Commons
Item {
id: root
property string omarchyPath: Quickshell.env("OMARCHY_PATH") || (Quickshell.env("HOME") + "/.local/share/omarchy")
property var shell: null
property var manifest: null
property var pluginRegistry: null
property bool opened: false
property string filterText: ""
property int selectedIndex: 0
property var emojis: []
property var filteredEmojis: []
property color accent: Color.menu.selected
property color background: Color.menu.background
property color foreground: Color.menu.text
property color border: foreground
readonly property int cornerRadius: Style.cornerRadius
property string fontFamily: Quickshell.env("OMARCHY_MENU_FONT") || "monospace"
property int contentMargin: 18
property int headerHeight: 34
property int contentSpacing: 6
property int cardWidth: 400
property int cardHeight: 500
property int cellWidth: 44
property int cellHeight: 44
property int columns: Math.floor((cardWidth - contentMargin * 2) / cellWidth)
function open(payloadJson) {
root.opened = true
root.filterText = ""
root.selectedIndex = 0
root.rebuildDisplay()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function close() {
root.opened = false
}
function dismiss() {
root.opened = false
if (root.shell && typeof root.shell.hide === "function")
root.shell.hide((root.manifest && root.manifest.id) || "omarchy.emoji-picker")
}
function toggle() {
if (root.opened) root.dismiss()
else root.open("{}")
}
function withAlpha(color, alpha) {
return Qt.rgba(color.r, color.g, color.b, alpha)
}
function loadEmojis(raw) {
try {
var data = JSON.parse(raw)
root.emojis = data || []
} catch (e) {
console.warn("Failed to parse emojis.json:", e)
root.emojis = []
}
if (root.opened) root.rebuildDisplay()
}
function rebuildDisplay() {
var query = root.filterText.trim().toLowerCase()
var out = []
for (var i = 0; i < root.emojis.length; i++) {
var item = root.emojis[i]
if (!query || item.k.indexOf(query) >= 0) {
out.push(item)
if (out.length >= 1000) break // limit to keep it fast
}
}
root.filteredEmojis = out
displayModel.clear()
for (var j = 0; j < out.length; j++) {
displayModel.append({ emoji: out[j].e, index: j })
}
if (displayModel.count === 0) selectedIndex = 0
else if (selectedIndex >= displayModel.count) selectedIndex = displayModel.count - 1
else if (selectedIndex < 0) selectedIndex = 0
Qt.callLater(function() {
if (displayModel.count > 0) resultGrid.positionViewAtIndex(root.selectedIndex, GridView.Contain)
})
}
function select(delta) {
if (displayModel.count === 0) return
selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
}
function selectRow(delta) {
if (displayModel.count === 0) return
var newIndex = selectedIndex + delta * columns
if (newIndex < 0) newIndex = 0
if (newIndex >= displayModel.count) newIndex = displayModel.count - 1
selectedIndex = newIndex
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
}
function selectPage(delta) {
if (displayModel.count === 0) return
var visibleRows = Math.max(1, Math.floor(resultGrid.height / cellHeight))
var newIndex = selectedIndex + delta * columns * visibleRows
if (newIndex < 0) newIndex = 0
if (newIndex >= displayModel.count) newIndex = displayModel.count - 1
selectedIndex = newIndex
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
}
function setFilter(nextFilter) {
root.filterText = nextFilter
root.selectedIndex = 0
root.rebuildDisplay()
}
function activateIndex(index) {
if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index)
root.applySelected(row.emoji)
}
function applySelected(emoji) {
if (!emoji) return
root.dismiss()
var escEmoji = emoji.replace(/'/g, "'\\''")
Quickshell.execDetached(["bash", "-lc", "wl-copy '" + escEmoji + "'; sleep 0.15; wtype '" + escEmoji + "' 2>/dev/null || true"])
}
ListModel { id: displayModel }
IpcHandler {
target: "emoji-picker"
function summon(): string { root.open("{}"); return "ok" }
function hide(): string { root.close(); return "ok" }
function toggle(): string { root.toggle(); return "ok" }
function ping(): string { return "ok" }
}
FileView {
path: root.omarchyPath + "/shell/plugins/emoji-picker/emojis.json"
onLoaded: root.loadEmojis(text())
}
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "omarchy-emoji-picker"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: root.withAlpha(root.background, 0.5)
}
MouseArea {
anchors.fill: parent
onClicked: root.dismiss()
}
Rectangle {
id: card
width: root.cardWidth
height: root.cardHeight
radius: root.cornerRadius
anchors.centerIn: parent
color: root.background
border.color: root.border
border.width: 2
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
id: keyCatcher
anchors.fill: parent
focus: true
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
if (root.filterText) root.setFilter("")
else root.dismiss()
event.accepted = true
} else if (event.key === Qt.Key_Backspace) {
if (root.filterText.length > 0) root.setFilter(root.filterText.slice(0, -1))
event.accepted = true
} else if (event.key === Qt.Key_Left) {
root.select(-1)
event.accepted = true
} else if (event.key === Qt.Key_Right) {
root.select(1)
event.accepted = true
} else if (event.key === Qt.Key_Up) {
root.selectRow(-1)
event.accepted = true
} else if (event.key === Qt.Key_Down) {
root.selectRow(1)
event.accepted = true
} else if (event.key === Qt.Key_PageUp) {
root.selectPage(-1)
event.accepted = true
} else if (event.key === Qt.Key_PageDown) {
root.selectPage(1)
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.activateIndex(root.selectedIndex)
event.accepted = true
} else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127) {
root.setFilter(root.filterText + event.text)
event.accepted = true
}
}
}
Column {
anchors.fill: parent
anchors.margins: root.contentMargin
spacing: root.contentSpacing
Rectangle {
width: parent.width
height: root.headerHeight
radius: root.cornerRadius
color: "transparent"
border.width: 0
Text {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.filterText || "Search emojis…"
color: root.foreground
opacity: root.filterText ? 1 : 0.58
font.family: root.fontFamily
font.pixelSize: 16
elide: Text.ElideRight
}
}
Item {
width: parent.width
height: parent.height - root.headerHeight - root.contentSpacing
GridView {
id: resultGrid
anchors.fill: parent
model: displayModel
clip: true
cellWidth: root.cellWidth
cellHeight: root.cellHeight
boundsBehavior: Flickable.StopAtBounds
delegate: Rectangle {
required property int index
required property string emoji
width: root.cellWidth
height: root.cellHeight
radius: root.cornerRadius
color: index === root.selectedIndex ? root.withAlpha(root.foreground, 0.08) : root.withAlpha(root.foreground, mouseArea.containsMouse ? 0.045 : 0)
Text {
text: parent.emoji
font.family: root.fontFamily
font.pixelSize: 24
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.selectedIndex = index
root.activateIndex(index)
}
}
}
}
Column {
anchors.centerIn: parent
spacing: 8
visible: displayModel.count === 0
Text {
text: "󰈉"
color: root.accent
opacity: 0.8
font.family: root.fontFamily
font.pixelSize: 28
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
Text {
text: "No matches for “" + root.filterText + "”"
color: root.foreground
opacity: 0.7
font.family: root.fontFamily
font.pixelSize: 14
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
}
}
}
}
}
}
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "omarchy.emoji-picker",
"name": "Emoji picker",
"version": "1.0.0",
"author": "Omarchy",
"description": "An emoji picker to copy or type emojis",
"kinds": ["overlay"],
"activation": "on-demand",
"keepLoaded": true,
"entryPoints": {
"overlay": "EmojiPicker.qml"
}
}
+668
View File
@@ -0,0 +1,668 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import QtQuick.Effects
import QtQuick.Shapes
import qs.Commons
Item {
id: root
// Injected by omarchy-shell. Optional here — the picker doesn't need
// omarchyPath itself, but every plugin gets it so user-installed scripts
// referenced by other plugins can stay path-portable.
property string omarchyPath: ""
// Set by omarchy-shell when summoning the overlay; not currently consumed but
// declared so the host's onLoaded injection doesn't trip a missing-property
// warning.
property var shell: null
property var manifest: null
property string imageDirs: Quickshell.env("OMARCHY_IMAGE_SELECTOR_DIRS") || Quickshell.env("OMARCHY_IMAGE_SELECTOR_DIR") || Quickshell.env("OMARCHY_STOCK_BACKGROUNDS_DIR") || (Quickshell.env("HOME") + "/.config/omarchy/current/theme/backgrounds")
property string imageRows: ""
property string loadedImageRows: ""
property string selectionFile: Quickshell.env("OMARCHY_IMAGE_SELECTOR_SELECTION_FILE") || Quickshell.env("OMARCHY_BACKGROUND_SELECTION_FILE")
property string selectedImage: Quickshell.env("OMARCHY_IMAGE_SELECTOR_SELECTED")
property int selectedIndex: 0
property bool imagesLoaded: false
property bool opened: false
property bool showLabels: false
property bool filterable: false
property bool layoutSettled: false
property bool requestActive: false
property int requestSerial: 0
property int applySerial: 0
property string doneFile: ""
property string filterText: ""
property var doneFilesToRelease: []
// Bound to the central [image-picker] section in shell.toml via Color.qml.
property color background: Color.imagePicker.background
property color foreground: Color.imagePicker.text
property color selectedBorder: Color.imagePicker.selectedBorder
property color unselectedBorder: Color.imagePicker.unselectedBorder
property int expandedWidth: 768
property int expandedHeight: 475
property int sliceWidth: 108
property int sliceHeight: 432
property int sliceSpacing: -30
property int skewOffset: 28
property int bottomChromeHeight: showLabels ? (filterable ? 104 : 74) : (filterable ? 60 : 30)
onOpenedChanged: if (!opened) layoutSettled = false
function fileUrl(path) {
return "file://" + path.split("/").map(encodeURIComponent).join("/")
}
function shellQuote(value) {
return "'" + String(value).replace(/'/g, "'\\''") + "'"
}
function scriptPath(name) {
var base = omarchyPath || Quickshell.env("OMARCHY_PATH") || (Quickshell.env("HOME") + "/.local/share/omarchy")
return base + "/shell/scripts/" + name
}
function focusPicker() {
if (root.opened && root.imagesLoaded && root.layoutSettled)
carousel.forceActiveFocus()
}
function revealWhenSettled(serial) {
Qt.callLater(function() {
if (serial === root.requestSerial && root.opened && root.imagesLoaded && root.imageArray.length > 0) {
root.layoutSettled = true
root.focusPicker()
}
})
}
// Decode a base64-encoded UTF-8 string sent via IPC. Used for fields that
// would otherwise carry embedded newlines or tabs (image rows, raw colors
// JSON) which bash IPC arguments can't reliably round-trip.
function decodeBase64(value) {
var s = String(value || "")
if (!s) return ""
try { return Qt.atob(s) } catch (e) { return s }
}
function withAlpha(color, alpha) {
return Qt.rgba(color.r, color.g, color.b, alpha)
}
function currentPath() {
if (imageArray.length === 0 || !itemMatches(selectedIndex)) return ""
return imageArray[selectedIndex].filePath
}
function nameForPath(path) {
return path.split("/").pop().replace(/\.[^/.]+$/, "")
}
function labelForPath(path) {
return nameForPath(path).replace(/[-_]+/g, " ").replace(/\b\w/g, function(match) { return match.toUpperCase() })
}
function currentLabel() {
var path = currentPath()
if (!path) return filterText ? "No matches" : ""
return labelForPath(path)
}
function itemMatches(index) {
if (index < 0 || index >= imageArray.length) return false
if (!filterText) return true
var path = imageArray[index].filePath
var needle = filterText.toLowerCase()
return nameForPath(path).toLowerCase().indexOf(needle) !== -1 || labelForPath(path).toLowerCase().indexOf(needle) !== -1
}
function matchingCount() {
if (!filterText) return imageArray.length
var count = 0
for (var i = 0; i < imageArray.length; i++) {
if (itemMatches(i)) count++
}
return count
}
function firstMatchingIndex() {
for (var i = 0; i < imageArray.length; i++) {
if (itemMatches(i)) return i
}
return -1
}
function filteredPosition(index) {
if (!filterText) return index
var position = 0
for (var i = 0; i < index; i++) {
if (itemMatches(i)) position++
}
return position
}
function selectedFilteredPosition() {
if (!filterText) return selectedIndex
return itemMatches(selectedIndex) ? filteredPosition(selectedIndex) : 0
}
function select(index, immediate) {
if (imageArray.length === 0) return
if (index < 0) index = 0
else if (index >= imageArray.length) index = imageArray.length - 1
if (!itemMatches(index)) return
if (index === selectedIndex && immediate !== true) return
selectedIndex = index
}
function selectAdjacent(direction) {
var count = imageArray.length
if (count === 0) return
var index = selectedIndex
for (var i = 0; i < count; i++) {
index = (index + direction + count) % count
if (itemMatches(index)) {
select(index)
return
}
}
}
function updateFilter(nextFilterText) {
filterText = nextFilterText
if (!itemMatches(selectedIndex)) {
var first = firstMatchingIndex()
if (first >= 0) selectedIndex = first
}
}
function releaseNextDoneFile() {
if (releaseProc.running || doneFilesToRelease.length === 0) return
var path = doneFilesToRelease.shift()
releaseProc.command = ["bash", "-lc", ": > " + shellQuote(path)]
releaseProc.running = true
}
function finishDoneFile(path) {
if (!path) return
doneFilesToRelease.push(path)
releaseNextDoneFile()
}
function applySelected() {
var path = currentPath()
if (!path || !selectionFile) {
cancel()
return
}
var activeSelectionFile = selectionFile
var activeDoneFile = doneFile
applySerial = requestSerial
requestActive = false
selectionFile = ""
doneFile = ""
applyProc.command = ["bash", "-lc", "printf '%s\\n' " + shellQuote(path) + " > " + shellQuote(activeSelectionFile) + "; : > " + shellQuote(activeDoneFile)]
applyProc.running = true
}
function cancel() {
if (requestActive)
finishDoneFile(doneFile)
requestActive = false
selectionFile = ""
doneFile = ""
root.opened = false
}
function closeSelector(nextDoneFile) {
requestSerial += 1
if (requestActive)
finishDoneFile(doneFile)
if (nextDoneFile && nextDoneFile !== doneFile)
finishDoneFile(nextDoneFile)
requestActive = false
selectionFile = ""
doneFile = ""
filterText = ""
root.opened = false
}
function loadRows(rows, reveal) {
var newImages = []
var seen = {}
var paths = rows.split("\n")
for (var i = 0; i < paths.length; i++) {
var row = paths[i]
if (!row) continue
var columns = row.split("\t")
var path = columns[0]
if (!path) continue
var fileName = path.split("/").pop()
if (seen[fileName]) continue
seen[fileName] = true
newImages.push({
filePath: path,
fileName: fileName,
thumbnailPath: columns[1] || path
})
}
root.loadedImageRows = rows
root.selectedIndex = root.indexForSelectedImage(newImages)
root.imageArray = newImages
root.imagesLoaded = true
if (reveal !== false) {
root.opened = true
root.revealWhenSettled(root.requestSerial)
}
}
function openSelector(nextImageDirs, nextImageRows, nextSelectedImage, nextSelectionFile, nextDoneFile, nextShowLabels, nextFilterable) {
if (requestActive && doneFile && doneFile !== nextDoneFile)
finishDoneFile(doneFile)
requestSerial += 1
imageDirs = nextImageDirs
imageRows = nextImageRows
selectedImage = nextSelectedImage
selectionFile = nextSelectionFile
doneFile = nextDoneFile
requestActive = !!doneFile
showLabels = nextShowLabels === true || nextShowLabels === "true"
filterable = nextFilterable === true || nextFilterable === "true"
filterText = ""
layoutSettled = false
if (imageRows && imageRows === loadedImageRows && imageArray.length > 0) {
root.select(root.selectedImageIndex(), true)
imagesLoaded = true
opened = true
root.revealWhenSettled(requestSerial)
return
}
if (imageRows) {
var rowsToLoad = imageRows
var rowsSerial = requestSerial
imageArray = []
selectedIndex = 0
imagesLoaded = true
opened = true
Qt.callLater(function() {
if (rowsSerial === root.requestSerial)
root.loadRows(rowsToLoad, true)
})
return
}
imageArray = []
selectedIndex = 0
imagesLoaded = false
opened = false
loadImagesProc.requestSerial = requestSerial
loadImagesProc.running = true
}
property var imageArray: []
function indexForSelectedImage(images) {
for (var i = 0; i < images.length; i++) {
if (images[i].filePath === selectedImage)
return i
}
return 0
}
function selectedImageIndex() {
return indexForSelectedImage(imageArray)
}
Process {
id: loadImagesProc
property int requestSerial: 0
command: [root.scriptPath("image-picker-list.sh"), root.imageDirs]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
if (loadImagesProc.requestSerial === root.requestSerial)
root.loadRows(String(text || ""), true)
}
}
}
// Lifecycle hooks invoked by omarchy-shell summon/hide. shell.summon(id,
// payloadJson) hands the JSON to open() here; shell.hide(id) calls close().
// External CLI callers can either go through `shell summon omarchy.image-
// picker` (JSON payload), or hit the dedicated `image-selector` IpcHandler
// below for the lower-level positional call that omarchy-menu-images uses.
function open(payload) {
var args = {}
if (payload) {
try { args = JSON.parse(payload) || {} } catch (e) { args = {} }
}
var dirs = String(args.imageDirs || imageDirs)
var rows = String(args.imageRows || "")
var sel = String(args.selectedImage || selectedImage)
var selFile = String(args.selectionFile || "")
var doneF = String(args.doneFile || "")
var labels = args.showLabels === true || args.showLabels === "true"
var filter = args.filterable === true || args.filterable === "true"
openSelector(dirs, rows, sel, selFile, doneF, labels, filter)
}
function close() {
cancel()
}
function preloadRows(nextImageRows, nextSelectedImage, nextShowLabels, nextFilterable) {
requestSerial += 1
imageRows = nextImageRows
selectedImage = nextSelectedImage
showLabels = nextShowLabels === true || nextShowLabels === "true"
filterable = nextFilterable === true || nextFilterable === "true"
filterText = ""
layoutSettled = false
if (imageRows && imageRows === loadedImageRows && imageArray.length > 0) {
selectedIndex = selectedImageIndex()
imagesLoaded = true
} else if (imageRows) {
loadRows(imageRows, false)
}
}
// IPC surface. All arguments are strings (Quickshell IPC marshalling).
// imageRows can contain newlines/tabs, so the CLI caller base64-encodes
// it; everything else passes through verbatim. The two boolean-like
// fields use the literal strings "true" or "false".
IpcHandler {
target: "image-selector"
function open(imageDirs: string,
imageRowsB64: string,
selectedImage: string,
selectionFile: string,
doneFile: string,
showLabels: string,
filterable: string): string {
var rows = root.decodeBase64(imageRowsB64)
root.openSelector(imageDirs, rows, selectedImage, selectionFile, doneFile,
showLabels, filterable)
return "ok"
}
function preload(imageRowsB64: string,
selectedImage: string,
showLabels: string,
filterable: string): string {
var rows = root.decodeBase64(imageRowsB64)
root.preloadRows(rows, selectedImage, showLabels, filterable)
return "ok"
}
function cancel(doneFile: string): void {
root.closeSelector(doneFile || "")
}
function ping(): string {
return "ok"
}
}
Process {
id: applyProc
onExited: {
if (root.applySerial === root.requestSerial)
root.opened = false
}
}
Process {
id: releaseProc
onExited: root.releaseNextDoneFile()
}
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "omarchy-image-selector"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: root.opened && root.imagesLoaded ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
visible: root.opened && root.imagesLoaded
color: root.withAlpha(root.background, 0.5)
}
MouseArea {
anchors.fill: parent
enabled: root.opened && root.imagesLoaded
onClicked: root.cancel()
}
Item {
id: card
visible: root.opened && root.imagesLoaded && root.layoutSettled && root.imageArray.length > 0
width: Math.min(parent.width - 80, root.expandedWidth + 13 * (root.sliceWidth + root.sliceSpacing) + 40)
height: root.expandedHeight + 30 + root.bottomChromeHeight
anchors.centerIn: parent
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
id: carousel
anchors.top: parent.top
anchors.topMargin: 30
anchors.bottom: parent.bottom
anchors.bottomMargin: root.bottomChromeHeight
anchors.horizontalCenter: parent.horizontalCenter
width: root.expandedWidth + 13 * (root.sliceWidth + root.sliceSpacing)
clip: false
focus: true
readonly property real itemStep: root.sliceWidth + root.sliceSpacing
readonly property real previewX: (width - root.expandedWidth) / 2
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
if (root.filterText) {
root.updateFilter("")
} else {
root.cancel()
}
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.applySelected()
event.accepted = true
} else if (event.key === Qt.Key_Backspace && root.filterable) {
if (root.filterText.length > 0)
root.updateFilter(root.filterText.slice(0, -1))
event.accepted = true
} else if (event.key === Qt.Key_Left || (event.key === Qt.Key_Tab && event.modifiers & Qt.ShiftModifier) || event.key === Qt.Key_Backtab) {
root.selectAdjacent(-1)
event.accepted = true
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_Tab) {
root.selectAdjacent(1)
event.accepted = true
} else if (root.filterable && event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127 && (event.modifiers === Qt.NoModifier || event.modifiers === Qt.ShiftModifier)) {
root.updateFilter(root.filterText + event.text)
event.accepted = true
}
}
Component.onCompleted: forceActiveFocus()
Repeater {
model: root.imageArray.length
delegate: Item {
id: item
required property int index
readonly property var imageData: root.imageArray[index]
readonly property string filePath: imageData ? imageData.filePath : ""
readonly property string fileName: imageData ? imageData.fileName : ""
readonly property string thumbnailPath: imageData ? imageData.thumbnailPath : ""
readonly property bool matched: root.itemMatches(index)
readonly property int relativeIndex: root.filteredPosition(index) - root.selectedFilteredPosition()
readonly property bool selected: matched && index === root.selectedIndex
readonly property bool nearby: matched && Math.abs(relativeIndex) <= 16
property bool sourceActivated: nearby
onNearbyChanged: if (nearby) sourceActivated = true
visible: nearby
x: selected ? carousel.previewX : (relativeIndex < 0 ? carousel.previewX + relativeIndex * carousel.itemStep : carousel.previewX + root.expandedWidth + root.sliceSpacing + (relativeIndex - 1) * carousel.itemStep)
width: selected ? root.expandedWidth : root.sliceWidth
height: selected ? root.expandedHeight : root.sliceHeight
y: selected ? 0 : (root.expandedHeight - root.sliceHeight) / 2
z: selected ? 100 : 50 - Math.min(Math.abs(relativeIndex), 40)
readonly property real skAbs: Math.abs(root.skewOffset)
readonly property real topLeft: root.skewOffset >= 0 ? skAbs : 0
readonly property real topRight: root.skewOffset >= 0 ? width : width - skAbs
readonly property real bottomRight: root.skewOffset >= 0 ? width - skAbs : width
readonly property real bottomLeft: root.skewOffset >= 0 ? 0 : skAbs
Item {
id: maskShape
anchors.fill: parent
visible: false
layer.enabled: true
Shape {
anchors.fill: parent
antialiasing: true
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: "white"
strokeColor: "transparent"
startX: item.topLeft; startY: 0
PathLine { x: item.topRight; y: 0 }
PathLine { x: item.bottomRight; y: item.height }
PathLine { x: item.bottomLeft; y: item.height }
PathLine { x: item.topLeft; y: 0 }
}
}
}
Item {
anchors.fill: parent
layer.enabled: true
layer.smooth: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: maskShape
maskThresholdMin: 0.3
maskSpreadAtMin: 0.3
}
Image {
id: image
anchors.fill: parent
// Load only the initial/visited nearby images, but keep the
// source once activated so Qt does not tear textures down as
// selection moves through the carousel.
source: item.sourceActivated && item.thumbnailPath ? root.fileUrl(item.thumbnailPath) : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
smooth: true
}
Rectangle {
anchors.fill: parent
color: root.withAlpha(root.background, item.selected ? 0 : 0.42)
}
}
Shape {
anchors.fill: parent
antialiasing: true
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: "transparent"
strokeColor: item.selected ? root.selectedBorder : root.withAlpha(root.unselectedBorder, 0.28)
strokeWidth: item.selected ? 3 : 1
startX: item.topLeft; startY: 0
PathLine { x: item.topRight; y: 0 }
PathLine { x: item.bottomRight; y: item.height }
PathLine { x: item.bottomLeft; y: item.height }
PathLine { x: item.topLeft; y: 0 }
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: item.selected ? root.applySelected() : root.select(index)
}
}
}
}
Text {
id: selectedLabel
visible: root.showLabels
anchors.top: carousel.bottom
anchors.topMargin: 16
anchors.horizontalCenter: carousel.horizontalCenter
width: root.expandedWidth
text: root.currentLabel()
color: root.foreground
style: Text.Outline
styleColor: root.withAlpha(root.background, 0.7)
font.pixelSize: 24
font.weight: Font.DemiBold
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
Text {
visible: root.filterable && root.filterText
anchors.top: selectedLabel.bottom
anchors.topMargin: 8
anchors.horizontalCenter: carousel.horizontalCenter
width: root.expandedWidth
text: root.filterText
color: root.foreground
opacity: 0.85
style: Text.Outline
styleColor: root.withAlpha(root.background, 0.7)
font.pixelSize: 14
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"id": "omarchy.image-picker",
"name": "Image picker",
"version": "1.0.0",
"author": "Omarchy",
"description": "Image-grid selector overlay used for wallpapers, themes, and any other directory of images",
"kinds": ["overlay"],
"activation": "on-demand",
"keepLoaded": true,
"entryPoints": { "overlay": "ImagePicker.qml" },
"ipc": {
"summon": "image-picker"
}
}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"id": "omarchy.menu",
"name": "Omarchy menu",
"version": "1.0.0",
"author": "Omarchy",
"description": "Quickshell-powered Omarchy command menu",
"kinds": ["menu"],
"activation": "on-demand",
"keepLoaded": true,
"entryPoints": { "menu": "Menu.qml" },
"ipc": {
"summon": "menu"
}
}
+891
View File
@@ -0,0 +1,891 @@
// Notification service. Adapted from noctalia-shell (MIT) and
// DankMaterialShell (MIT). Original implementations:
// https://github.com/noctalia-dev/noctalia-shell
// https://github.com/AvengeMedia/DankMaterialShell
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Services.Notifications
import qs.Commons
import "components"
Item {
id: service
// Injected by omarchy-shell (the first-party service loader).
property string omarchyPath: ""
property var shell: null
property var manifest: null
readonly property string home: Quickshell.env("HOME")
// History + DND live under XDG_STATE_HOME: they're persistent user state
// (history of received notifications, last-set DND preference), not
// regeneratable cache that a `rm -rf ~/.cache` should wipe.
readonly property string stateDir: home + "/.local/state/omarchy/"
readonly property string historyPath: stateDir + "notifications.json"
// Thumbnails copied from /tmp screenshots are genuinely disposable — if
// they vanish the row just renders without an image — so they stay in
// ~/.cache where regeneratable artifacts belong.
readonly property string cacheDir: home + "/.cache/omarchy/"
readonly property string imageCacheDir: cacheDir + "notification-images/"
// Corner radius is shared with omarchy-shell menu and bar settings panel —
// `omarchy style corners <sharp|round>` writes this file once and every
// surface reads it. Default 0 for sharp corners.
readonly property int cornerRadius: Style.cornerRadius
// Surfaces anchor relative to the omarchy bar so popups and history land
// alongside the other shell panels rather than on top of the bar itself.
// Falls back to the bar's default size (26 horizontal / 28 vertical) when
// shell.bar isn't reachable so the popup never lands on top of the bar.
readonly property string barPosition: shell && shell.barConfig ? String(shell.barConfig.position || "top") : "top"
readonly property bool barVertical: barPosition === "left" || barPosition === "right"
readonly property int defaultBarSize: barVertical ? 28 : 26
readonly property int liveBarSize: shell && shell.bar && !shell.bar.barHidden ? Math.max(0, shell.bar.barSize) : defaultBarSize
readonly property int barClearance: liveBarSize + 12
// Fired by IPC (`omarchy-shell notifications showHistory`) so the
// bar widget can drop its PopupCard from the same anchor a click would.
signal historyOpenRequested()
// PersistentProperties handles in-process QML reloads. The on-disk
// notifications.json file is the cross-restart backstop — its `dnd` key
// is hydrated into persisted.doNotDisturb on startup and written back via
// the same debounced save timer used for history entries.
PersistentProperties {
id: persisted
reloadableId: "omarchy-notifications"
property bool doNotDisturb: false
onDoNotDisturbChanged: {
// Suppress the write that load-time hydration would otherwise trigger.
if (service._hydrating) return
service.scheduleHistorySave()
}
}
// Guards onDoNotDisturbChanged while we're hydrating from disk so the
// hydration assignment doesn't immediately schedule a write-back.
property bool _hydrating: false
readonly property alias doNotDisturb: persisted.doNotDisturb
function setDoNotDisturb(value) {
persisted.doNotDisturb = !!value
}
// popupModel feeds the on-screen toast stack.
// pendingModel = notifications received but not yet "seen" by the user.
// Anything DND-suppressed lands here and stays there until
// the user reviews it; anything that pops up also lives
// here until the popup dismisses, then moves to pastModel.
// pastModel = notifications the user has already seen on-screen.
// Surfaced under the Past tab in the history panel.
//
// Aliased as properties so the bar widget and HistoryPanel (outside this
// Item's id scope) can bind to them. QML ids aren't visible to external
// consumers without the alias.
property alias popupModel: popupModel
property alias pendingModel: pendingModel
property alias pastModel: pastModel
ListModel { id: popupModel }
ListModel { id: pendingModel }
ListModel { id: pastModel }
readonly property int historyCap: 100
function durationFor(urgency) {
switch (urgency) {
case NotificationUrgency.Critical:
return 0
case NotificationUrgency.Low:
return 3000
default:
return 5000
}
}
// DND bypass: only let through notifications we trust to be intentional
// and rare.
// - omarchy-action: a user-action confirmation toast ("Theme changed",
// "Screenshot saved"). The user JUST did something — their feedback
// should show.
// - urgency=critical AND app_name=notify-send: bare-CLI emergency alerts
// (omarchy-battery-monitor uses this for low-battery; a few scripts
// use it for emergency failures). Trusted because it's almost always
// omarchy or system shell scripts — chat apps set app_name to
// their brand (Discord/Slack/Vesktop) which falls outside this rule.
function shouldBypassDnd(notification) {
var appName = String(notification.appName || "")
if (appName === "omarchy-action") return true
if (appName === "notify-send" && notification.urgency === NotificationUrgency.Critical) return true
return false
}
function snapshotOf(notification) {
var glyph = ""
try {
if (notification.hints) {
var hintGlyph = notification.hints["omarchy-glyph"]
if (hintGlyph !== undefined && hintGlyph !== null)
glyph = String(hintGlyph)
}
} catch (e) { glyph = "" }
var summary = String(notification.summary || "")
return {
id: notification.id,
originalId: notification.id,
app: notification.appName || "",
appIcon: notification.appIcon || "",
summary: summary,
body: notification.body || "",
image: notification.image || "",
glyph: glyph,
urgency: notification.urgency,
timestamp: Date.now(),
ref: notification
}
}
function handleNotification(notification) {
// Without `tracked = true` the Notification object is destroyed as soon
// as this signal handler returns, which would null out the `ref` we just
// captured for the popup card.
notification.tracked = true
var snapshot = snapshotOf(notification)
// History is for notifications from real apps (Slack, Discord, mailer,
// etc.) — things the user might want to look back at. Skip the pending
// / past bookkeeping when:
// - the freedesktop `transient` hint is set ("popup only, don't store")
// - app_name is "notify-send" (the CLI default — means the sender
// didn't bother declaring an identity, so it's almost certainly
// ephemeral test/feedback noise)
// - app_name is "omarchy-action" (omarchy's own user-action
// confirmation toasts — the user just triggered them, they don't
// need to be archived)
var transient = false
try {
transient = !!(notification.hints && notification.hints["transient"])
} catch (e) { transient = false }
var appName = String(notification.appName || "")
var ephemeralApp = appName === "notify-send" || appName === "omarchy-action"
if (transient || ephemeralApp) {
if (service.doNotDisturb && !shouldBypassDnd(notification)) {
notification.tracked = false
return
}
Qt.callLater(function() {
removeByOriginalId(popupModel, snapshot.originalId)
popupModel.insert(0, snapshot)
})
return
}
// Pending first, unconditionally. DND only suppresses the toast — the
// record still has to land somewhere the user can review later.
addToPending(snapshot)
// Kick off a copy of any /tmp screenshot into the persistent image cache.
// The cp races the popup; the popup keeps the original path so it always
// renders, and the history row gets rewritten to the cached path once
// cp.exits.
maybeCacheImage(snapshot)
// DND bypass rules — see ~/Work/omarchy/dnd-fix-plan.md. The pending
// entry already captured this notification above; we just decide here
// whether to also pop a toast. Chat apps abuse urgency=critical to
// force visibility, so critical alone isn't enough — we also require
// the sender to be CLI-style. See shouldBypassDnd().
if (service.doNotDisturb && !shouldBypassDnd(notification)) {
notification.tracked = false
return
}
// Qt.callLater avoids "QV4::Object::insertMember" crashes when a
// Repeater is mid-incubation while we mutate its model — see noctalia
// NotificationService.qml ~L307.
Qt.callLater(function() {
removeByOriginalId(popupModel, snapshot.originalId)
popupModel.insert(0, snapshot)
})
}
// Remove every row in `model` whose originalId matches. Chat apps reuse
// `replaces_id` per the freedesktop spec to update a single notification
// in place — without this, every Discord/Slack ping leaves a fresh row
// behind and pending fills with hundreds of duplicates.
function removeByOriginalId(model, originalId) {
for (var i = model.count - 1; i >= 0; i--) {
var row = model.get(i)
if (row && row.originalId === originalId) model.remove(i)
}
}
function addToPending(snapshot) {
Qt.callLater(function() {
removeByOriginalId(pendingModel, snapshot.originalId)
pendingModel.insert(0, snapshot)
while (pendingModel.count > service.historyCap) {
pendingModel.remove(pendingModel.count - 1)
}
scheduleHistorySave()
})
}
// Find a pending entry by its libnotify id and move it to pastModel. Called
// when a popup naturally dismisses (timer expired or user clicked X / the
// default action) — the user is assumed to have seen it.
function markSeenByOriginalId(originalId) {
Qt.callLater(function() {
for (var i = 0; i < pendingModel.count; i++) {
var entry = pendingModel.get(i)
if (!entry || entry.originalId !== originalId) continue
var snapshot = service.snapshotFromRow(entry)
pendingModel.remove(i)
pastModel.insert(0, snapshot)
while (pastModel.count > service.historyCap) {
pastModel.remove(pastModel.count - 1)
}
scheduleHistorySave()
return
}
})
}
// Copy a ListModel row into a plain JS object so we can re-insert it into
// a different model without sharing references.
function snapshotFromRow(row) {
return {
id: row.id,
originalId: row.originalId,
app: row.app,
appIcon: row.appIcon,
summary: row.summary,
body: row.body,
image: row.image,
glyph: row.glyph || "",
urgency: row.urgency,
timestamp: row.timestamp
}
}
function markAllSeen() {
Qt.callLater(function() {
while (pendingModel.count > 0) {
var entry = pendingModel.get(0)
var snapshot = service.snapshotFromRow(entry)
pendingModel.remove(0)
pastModel.insert(0, snapshot)
}
while (pastModel.count > service.historyCap) {
pastModel.remove(pastModel.count - 1)
}
scheduleHistorySave()
})
}
function dismissPopup(index) {
if (index < 0 || index >= popupModel.count) return
var entry = popupModel.get(index)
var ref = entry ? entry.ref : null
var originalId = entry ? entry.originalId : -1
popupModel.remove(index)
if (ref) {
try {
if (ref.tracked) ref.dismiss()
} catch (e) {
// Object already torn down by the server — nothing to dismiss.
}
}
// User (or the lifetime timer) saw the popup — archive it.
if (originalId >= 0) markSeenByOriginalId(originalId)
}
function clearPopups() {
while (popupModel.count > 0) dismissPopup(0)
}
function dismissPending(index) {
if (index < 0 || index >= pendingModel.count) return
var entry = pendingModel.get(index)
if (entry) maybeDeleteCachedImage(entry.image)
pendingModel.remove(index)
scheduleHistorySave()
}
function dismissPast(index) {
if (index < 0 || index >= pastModel.count) return
var entry = pastModel.get(index)
if (entry) maybeDeleteCachedImage(entry.image)
pastModel.remove(index)
scheduleHistorySave()
}
function clearPending() {
for (var i = 0; i < pendingModel.count; i++) {
var entry = pendingModel.get(i)
if (entry) maybeDeleteCachedImage(entry.image)
}
pendingModel.clear()
scheduleHistorySave()
}
function clearPast() {
for (var i = 0; i < pastModel.count; i++) {
var entry = pastModel.get(i)
if (entry) maybeDeleteCachedImage(entry.image)
}
pastModel.clear()
scheduleHistorySave()
}
// Invoke the libnotify "default" action on the popup's underlying
// notification, if it has one, then dismiss. Clients register the default
// action with the canonical identifier "default"; e.g. screenshot toasts
// use `notify-send -A default=Edit ...` so click-the-card opens the editor.
function invokePopupDefault(index) {
if (index < 0 || index >= popupModel.count) return
var entry = popupModel.get(index)
var ref = entry ? entry.ref : null
var invoked = false
if (ref && ref.actions) {
for (var i = 0; i < ref.actions.length; i++) {
var action = ref.actions[i]
if (action && action.identifier === "default") {
try { action.invoke(); invoked = true } catch (e) { console.warn("invoke default failed:", e) }
break
}
}
}
// Chat apps (Slack, Discord, Vesktop, etc.) rarely register a "default"
// libnotify action — they just expect clicking the notification to
// focus their window. Fall back to focusing the sending app by class so
// that click-to-jump actually works.
if (!invoked) focusApp(entry)
dismissPopup(index)
}
// Try to focus an existing Hyprland window matching the notification's
// sender. We shell out to a small bash one-liner because Hyprland's class
// matcher is regex-based but its case-sensitivity is implementation-
// defined (std::regex doesn't reliably honor `(?i)`). Easier to query the
// client list ourselves and pick the first match.
function focusApp(entry) {
if (!entry || !entry.app) return
var lower = String(entry.app).toLowerCase()
focusAppProc.command = ["bash", "-lc",
"hyprctl clients -j 2>/dev/null | " +
"jq -r --arg name \"" + lower + "\" " +
"'[.[] | select((.class // \"\") | ascii_downcase | startswith($name))] | first.address // empty' | " +
"xargs -r -I{} hyprctl dispatch focuswindow address:{}"]
focusAppProc.running = true
}
Process { id: focusAppProc; running: false }
// Open the popup's large image in the user's default image viewer.
// image:// URIs come from the upstream raw-bytes provider and have no
// on-disk path to hand to xdg-open, so we just dismiss in that case.
function openPopupImage(index) {
if (index < 0 || index >= popupModel.count) return
var entry = popupModel.get(index)
if (!entry) return
var image = String(entry.image || "")
if (image.indexOf("file://") === 0) {
var lower = image.toLowerCase()
if (lower.endsWith(".png") || lower.endsWith(".jpg") ||
lower.endsWith(".jpeg") || lower.endsWith(".webp")) {
var path = decodeURIComponent(image.substring(7))
Quickshell.execDetached(["xdg-open", path])
}
}
dismissPopup(index)
}
// ---------------------------------------------------- image cache
//
// Notifications coming from screenshot helpers ship an `image-path` hint
// pointing at /tmp/<file>. We want the history thumbnail to outlive that
// file, so we copy it into a long-lived cache dir on ingress and rewrite
// the history row's `image` to point at the cache once cp finishes.
// image:// (raw-bytes) URIs aren't trivially copyable from QML; document
// and skip them for v1.
function imageExtension(srcPath) {
var lower = srcPath.toLowerCase()
var dot = lower.lastIndexOf(".")
if (dot < 0) return "png"
var ext = lower.substring(dot + 1)
if (ext.length === 0 || ext.length > 5) return "png"
return ext
}
function maybeCacheImage(snapshot) {
var image = String(snapshot.image || "")
if (!image) return
// image:// URIs are decoded from raw bytes by Quickshell's image provider.
// We can't copy them out from QML, so let history reference them by URI
// and accept that they disappear with the source notification.
if (image.indexOf("image://") === 0) return
if (image.indexOf("file:///tmp/") !== 0) return
var srcPath = decodeURIComponent(image.substring(7))
var ext = imageExtension(srcPath)
var destPath = imageCacheDir + snapshot.timestamp + "-" + snapshot.originalId + "." + ext
var destUri = "file://" + destPath
imageCacheProc.targetUri = destUri
imageCacheProc.matchOriginalId = snapshot.originalId
imageCacheProc.matchTimestamp = snapshot.timestamp
imageCacheProc.command = ["cp", "-f", srcPath, destPath]
imageCacheProc.running = true
}
function maybeDeleteCachedImage(image) {
var path = String(image || "")
if (!path) return
if (path.indexOf("file://") !== 0) return
var local = decodeURIComponent(path.substring(7))
if (local.indexOf(imageCacheDir) !== 0) return
deleteImageProc.command = ["rm", "-f", local]
deleteImageProc.running = true
}
Process {
id: ensureDirsProc
command: ["mkdir", "-p", service.stateDir, service.imageCacheDir]
running: false
}
Process {
id: imageCacheProc
property string targetUri: ""
property int matchOriginalId: -1
property double matchTimestamp: 0
onExited: function(exitCode) {
if (exitCode !== 0 || !targetUri) return
// Search both models since a notification may have moved to past
// between when we kicked off the copy and when it finished.
function rewrite(model) {
for (var i = 0; i < model.count; i++) {
var row = model.get(i)
if (row && row.originalId === matchOriginalId && row.timestamp === matchTimestamp) {
model.setProperty(i, "image", targetUri)
return true
}
}
return false
}
if (rewrite(pendingModel) || rewrite(pastModel)) scheduleHistorySave()
}
}
Process { id: deleteImageProc; running: false }
// ---------------------------------------------------- history persistence
FileView {
id: historyFile
path: service.historyPath
watchChanges: false
atomicWrites: true
printErrors: false
onLoaded: service.loadHistory(text())
// First-run: the file doesn't exist yet. Without this branch,
// `historyLoaded` stays false forever and `scheduleHistorySave` becomes
// a no-op — so the file is never created and history vanishes on
// shell restart.
onLoadFailed: service.loadHistory("")
}
Timer {
id: historySaveTimer
interval: 200
repeat: false
onTriggered: service.flushHistory()
}
// Past is a rolling "recently" window. Sweep every minute and drop
// anything older than 15 minutes so the tab doesn't accumulate forever.
readonly property int pastTtlMs: 15 * 60 * 1000
Timer {
id: pastPruneTimer
interval: 60 * 1000
repeat: true
running: true
triggeredOnStart: true
onTriggered: service.prunePast()
}
function prunePast() {
if (pastModel.count === 0) return
var cutoff = Date.now() - service.pastTtlMs
var removed = false
for (var i = pastModel.count - 1; i >= 0; i--) {
var entry = pastModel.get(i)
if (entry && entry.timestamp && entry.timestamp < cutoff) {
if (entry.image) maybeDeleteCachedImage(entry.image)
pastModel.remove(i)
removed = true
}
}
if (removed) scheduleHistorySave()
}
function scheduleHistorySave() {
if (!service.historyLoaded) return
historySaveTimer.restart()
}
property bool historyLoaded: false
function loadHistory(raw) {
// FileView can fire onLoaded more than once during startup — the implicit
// preload when `path` resolves, plus the explicit `historyFile.reload()`
// in Component.onCompleted can both end up calling here. Without this
// guard, the second fire appends a second copy of every persisted row
// to the in-memory model.
if (service.historyLoaded) return
var text = String(raw || "").trim()
if (!text) { service.historyLoaded = true; return }
try {
var parsed = JSON.parse(text)
if (parsed && typeof parsed.dnd === "boolean") {
service._hydrating = true
persisted.doNotDisturb = parsed.dnd
service._hydrating = false
}
var pending = (parsed && Array.isArray(parsed.pending)) ? parsed.pending : []
var past = (parsed && Array.isArray(parsed.past)) ? parsed.past : []
// v1 backwards compat: the old schema had a single `entries` array.
// Treat all of those as past since the user already presumably saw
// them (and DND-suppressed notifications from before the split are
// a rare edge case).
if (parsed && Array.isArray(parsed.entries)) past = past.concat(parsed.entries)
function entryFor(e) {
return {
id: e.id || 0,
originalId: e.originalId || e.id || 0,
app: e.app || "",
appIcon: e.appIcon || "",
summary: e.summary || "",
body: e.body || "",
image: e.image || "",
glyph: e.glyph || "",
urgency: typeof e.urgency === "number" ? e.urgency : NotificationUrgency.Normal,
timestamp: e.timestamp || 0,
ref: null
}
}
// Older builds didn't dedupe chat-app replacements, so hydrated files
// can hold hundreds of identical rows (same originalId). Collapse on
// load — keep the newest occurrence (highest timestamp) and drop the
// rest. Save is rescheduled below so the disk file rewrites cleanly.
function dedupeByOriginalId(rows) {
var keep = {}
for (var k = 0; k < rows.length; k++) {
var r = rows[k]
if (!r) continue
var key = r.originalId
if (key === undefined || key === null) { keep["_" + k] = r; continue }
var prior = keep[key]
if (!prior || (r.timestamp || 0) >= (prior.timestamp || 0)) keep[key] = r
}
var out = []
for (var id in keep) out.push(keep[id])
out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
return out
}
var pendingDeduped = dedupeByOriginalId(pending)
var pastDeduped = dedupeByOriginalId(past)
var hadDuplicates = pendingDeduped.length !== pending.length
|| pastDeduped.length !== past.length
// Newest-first on disk; insert in order so models match.
Qt.callLater(function() {
for (var i = 0; i < pendingDeduped.length; i++) {
pendingModel.append(entryFor(pendingDeduped[i]))
if (pendingModel.count > service.historyCap) pendingModel.remove(pendingModel.count - 1)
}
for (var j = 0; j < pastDeduped.length; j++) {
pastModel.append(entryFor(pastDeduped[j]))
if (pastModel.count > service.historyCap) pastModel.remove(pastModel.count - 1)
}
service.historyLoaded = true
if (hadDuplicates) service.scheduleHistorySave()
})
} catch (e) {
console.warn("notifications: history parse failed:", e)
service.historyLoaded = true
}
}
function flushHistory() {
function dump(model) {
var out = []
for (var i = 0; i < model.count; i++) {
var r = model.get(i)
if (!r) continue
out.push({
id: r.id,
originalId: r.originalId,
app: r.app,
appIcon: r.appIcon,
summary: r.summary,
body: r.body,
image: r.image,
glyph: r.glyph || "",
urgency: r.urgency,
timestamp: r.timestamp
})
}
return out
}
var payload = {
version: 2,
dnd: persisted.doNotDisturb,
pending: dump(pendingModel),
past: dump(pastModel)
}
historyFile.setText(JSON.stringify(payload, null, 2) + "\n")
}
Component.onCompleted: {
ensureDirsProc.running = true
// Once mkdir has had a tick, load the existing history file. FileView
// surfaces an empty string when the file doesn't exist; loadHistory
// handles that path.
Qt.callLater(function() { historyFile.reload() })
}
// ---------------------------------------------------- IPC
IpcHandler {
target: "notifications"
function dndState(): string {
return service.doNotDisturb ? "on" : "off"
}
function toggleDnd(): string {
service.setDoNotDisturb(!service.doNotDisturb)
return dndState()
}
function setDnd(value: string): string {
var v = String(value || "").toLowerCase()
var on = v === "true" || v === "1" || v === "on" || v === "yes"
service.setDoNotDisturb(on)
return dndState()
}
function isDnd(): string {
return dndState()
}
function showHistory(): string {
service.historyOpenRequested()
return "ok"
}
// `clear` empties the past tab (the "I already saw these" bucket).
function clear(): string {
service.clearPast()
return "ok"
}
function clearPending(): string {
service.clearPending()
return "ok"
}
function markAllSeen(): string {
service.markAllSeen()
return "ok"
}
function dismissAll(): string {
service.clearPopups()
service.clearPending()
service.clearPast()
return "ok"
}
// dismiss the most recent popup; fall back to the most recent pending
// entry, then past, if no popup is currently showing.
function dismissOne(): string {
if (popupModel.count > 0) {
service.dismissPopup(0)
return "ok"
}
if (pendingModel.count > 0) {
service.dismissPending(0)
return "ok"
}
if (pastModel.count > 0) {
service.dismissPast(0)
return "ok"
}
return "none"
}
// Fire the default action on the most recent popup, then dismiss it.
function invokeLast(): string {
if (popupModel.count === 0) return "none"
service.invokePopupDefault(0)
return "ok"
}
function dismiss(summary: string): string {
var needle = String(summary || "")
if (!needle) return "none"
var hit = false
function sweep(model, dismissFn) {
for (var i = model.count - 1; i >= 0; i--) {
var row = model.get(i)
if (row && String(row.summary || "").indexOf(needle) !== -1) {
dismissFn(i)
hit = true
}
}
}
sweep(pendingModel, service.dismissPending)
sweep(pastModel, service.dismissPast)
sweep(popupModel, service.dismissPopup)
return hit ? "ok" : "none"
}
function ping(): string { return "ok" }
}
// ---------------------------------------------------- server
NotificationServer {
id: server
keepOnReload: false
imageSupported: true
actionsSupported: true
bodyMarkupSupported: true
bodyHyperlinksSupported: true
persistenceSupported: true
onNotification: function(notification) {
service.handleNotification(notification)
}
}
// -------------------------------------------------------------- popup UI
//
// One PanelWindow per output (Variants on Quickshell.screens) holding the
// stacked toast cards. Layer is Overlay, exclusionMode Ignore, no
// keyboard focus — popups are passive surfaces and must never steal input
// from the focused application.
Variants {
model: Quickshell.screens
PanelWindow {
id: popupWindow
required property var modelData
screen: modelData
visible: popupModel.count > 0
WlrLayershell.namespace: "omarchy-notifications"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
color: "transparent"
anchors {
top: service.barPosition !== "bottom"
bottom: service.barPosition === "bottom"
left: service.barPosition === "left"
right: service.barPosition !== "left"
}
margins {
top: service.barPosition === "top" ? service.barClearance + 12 : 20
bottom: service.barPosition === "bottom" ? service.barClearance + 12 : 20
left: service.barPosition === "left" ? service.barClearance + 12 : 20
right: service.barPosition === "right" ? service.barClearance + 12 : 20
}
implicitWidth: popupColumn.implicitWidth
implicitHeight: popupColumn.implicitHeight
ColumnLayout {
id: popupColumn
anchors.right: parent.right
anchors.top: parent.top
spacing: 8
Repeater {
model: popupModel
// The delegate is a slot Item that owns lifetime state (timer,
// progress, paused). The actual visuals live in NotificationCard,
// which the history panel also reuses.
delegate: Item {
id: cardSlot
required property int index
required property string app
required property string appIcon
required property string summary
required property string body
required property string image
required property string glyph
required property int urgency
required property double timestamp
// Each card sizes itself based on mode (text vs media); the slot
// tracks the card so the column auto-fits to whichever is widest.
Layout.preferredWidth: card.implicitWidth
Layout.alignment: Qt.AlignRight
implicitHeight: card.implicitHeight
readonly property real lifetime: service.durationFor(cardSlot.urgency)
property real progress: 1.0
readonly property bool ticking: cardSlot.lifetime > 0 && !card.hovered
Timer {
interval: 50
repeat: true
running: cardSlot.ticking
onTriggered: {
if (cardSlot.lifetime <= 0) return
cardSlot.progress -= 50.0 / cardSlot.lifetime
if (cardSlot.progress <= 0) {
cardSlot.progress = 0
service.dismissPopup(cardSlot.index)
}
}
}
NotificationCard {
id: card
anchors.right: parent.right
app: cardSlot.app
appIcon: cardSlot.appIcon
summary: cardSlot.summary
body: cardSlot.body
image: cardSlot.image
urgency: cardSlot.urgency
timestamp: cardSlot.timestamp
cornerRadius: service.cornerRadius
fontFamily: service.shell && service.shell.bar ? service.shell.bar.fontFamily : ""
glyph: cardSlot.glyph
progress: cardSlot.progress
showProgress: cardSlot.lifetime > 0
onCloseRequested: service.dismissPopup(cardSlot.index)
onCardClicked: service.invokePopupDefault(cardSlot.index)
onImageClicked: service.openPopupImage(cardSlot.index)
}
}
}
}
}
}
}
@@ -0,0 +1,261 @@
// Notification card. Pure presentational — no service, Notification, or
// ListModel references. The popup container drives lifetime; the history
// panel drives static rendering. Both use the same component.
import QtQuick
import QtQuick.Layouts
import qs.Commons
Rectangle {
id: root
property string app: ""
property string appIcon: ""
property string summary: ""
property string body: ""
property string image: ""
// Nerd Font glyph rendered in the icon slot when no real icon is set.
// Used by omarchy-notification-send so user-action toasts (`Silenced
// notifications` etc.) show their bell/lock/etc. glyph without leaking
// into the summary text.
property string glyph: ""
// NotificationUrgency: Low=0, Normal=1, Critical=2 (upstream).
property int urgency: 1
property double timestamp: 0
property int cornerRadius: 0
property real progress: 1.0
property bool showProgress: false
// System font from shell.json bar.fontFamily, injected by the container.
property string fontFamily: ""
readonly property bool hovered: hoverTracker.hovered
signal closeRequested()
signal cardClicked()
signal imageClicked()
// Media mode = the notification carries a real screenshot or screen
// recording preview. Quickshell normalizes file paths from `-i` and the
// `image-path` hint into `image://icon//<absolute path>` (double slash
// marks an absolute filesystem path vs a themed icon name like
// `image://icon/firefox`).
function _imageFilePath(s) {
if (!s) return ""
if (s.indexOf("image://icon//") === 0) return s.substring("image://icon/".length)
if (s.indexOf("file://") === 0) return decodeURIComponent(s.substring(7))
return ""
}
function _isMediaFile(path) {
if (!path) return false
var lower = path.toLowerCase()
return lower.endsWith(".png") || lower.endsWith(".jpg") ||
lower.endsWith(".jpeg") || lower.endsWith(".webp") ||
lower.endsWith(".gif")
}
readonly property string mediaImageSource: ""
readonly property bool mediaMode: false
// Use only what the notification explicitly carries — no themed-icon
// theme-lookup fallback because Quickshell's icon image provider returns
// a placeholder for missing names (rather than erroring), which means
// we'd render Qt's pink "broken image" pattern for any unknown app.
// Apps that send their own icon via `image` (image-data hint) or
// `appIcon` (-i flag) still get one.
readonly property string smallIconSource: image.length > 0 ? image : appIcon
readonly property bool hasGlyph: glyph.length > 0
readonly property bool hasSmallIcon: !mediaMode && (smallIconSource.length > 0 || hasGlyph)
readonly property bool chromiumDerived: {
var source = (app + "\n" + appIcon).toLowerCase()
return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 ||
source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 ||
source.indexOf("opera") >= 0
}
readonly property string sanitizedBody: sanitizeBody(body)
readonly property color dimColor: Qt.darker(Color.notifications.text, 1.4)
readonly property color bodyColor: Qt.darker(Color.notifications.text, 1.15)
readonly property color hoverColor: Qt.rgba(Color.notifications.text.r, Color.notifications.text.g, Color.notifications.text.b, 0.14)
readonly property color accentColor: urgency === 2 ? Color.urgent : (urgency === 0 ? dimColor : Color.notifications.countdown)
function sanitizeBody(s) {
var text = String(s).replace(/<img[^>]*>/gi, "")
if (!chromiumDerived) return text
// Chromium web notifications often prefix the body with the sending
// origin, sometimes as a hyperlink. The browser icon already identifies
// the source, so drop only that leading URL/domain.
return text
.replace(/^\s*<a\b[^>]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "")
.replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "")
}
implicitWidth: 380
// Add 2 * border.width so mainColumn (inset by border.width on top/left/right)
// doesn't push content under the bottom edge. The bottom edge is also inset
// for symmetry except when the progress bar replaces it.
implicitHeight: mainColumn.implicitHeight + border.width * 2
radius: cornerRadius
color: Color.notifications.background
border.color: urgency === 2 ? Color.urgent : Color.notifications.border
border.width: 2
clip: true
HoverHandler { id: hoverTracker }
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.cardClicked()
}
ColumnLayout {
id: mainColumn
// Inset by the card border so the hero image (and the text row) don't
// paint over the card's outer border. Without this the left/right/top
// border is invisible under the image.
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: root.border.width
anchors.leftMargin: root.border.width
anchors.rightMargin: root.border.width
spacing: 0
// Hero image strip (media notifications only). PreserveAspectCrop so
// the preview looks like a clean banner without dark letterboxing.
Item {
Layout.fillWidth: true
Layout.preferredHeight: 140
visible: root.mediaMode
clip: true
Image {
anchors.fill: parent
source: root.mediaImageSource
fillMode: Image.PreserveAspectCrop
sourceSize.width: width > 0 ? width * Screen.devicePixelRatio : 0
sourceSize.height: height > 0 ? height * Screen.devicePixelRatio : 0
asynchronous: true
smooth: true
cache: false
}
// Bottom divider matching the card border so the screenshot is
// visually framed on every side (card border wraps top/left/right;
// this line completes the bottom).
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: root.border.width
color: root.urgency === 2 ? Color.urgent : Color.notifications.border
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.imageClicked()
}
}
// Text content. Always rendered — for media notifications this carries
// the summary/body ("Screenshot saved" etc) under the hero image.
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 12
Layout.rightMargin: 12
Layout.topMargin: 10
Layout.bottomMargin: 10
spacing: 12
Item {
id: smallIconSlot
Layout.preferredWidth: 40
Layout.preferredHeight: 40
Layout.alignment: Qt.AlignVCenter
// Hide the slot when the icon failed to resolve (themed-icon name
// not in the user's icon theme) AND we don't have a glyph fallback
// — prevents rendering Qt's pink broken-image placeholder.
visible: root.hasSmallIcon && (root.hasGlyph || smallIconImage.status !== Image.Error)
Image {
id: smallIconImage
anchors.fill: parent
source: root.smallIconSource
sourceSize.width: 40 * Screen.devicePixelRatio
sourceSize.height: 40 * Screen.devicePixelRatio
fillMode: Image.PreserveAspectFit
asynchronous: true
smooth: true
visible: !root.hasGlyph || smallIconImage.status === Image.Ready
}
// Glyph fallback (Nerd Font character) when no image icon is
// available. Used by omarchy-notification-send's `-g` flag.
Text {
anchors.centerIn: parent
visible: root.hasGlyph && smallIconImage.status !== Image.Ready
text: root.glyph
color: Color.notifications.text
font.family: root.fontFamily
font.pixelSize: 18
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
spacing: 2
Text {
Layout.fillWidth: true
visible: root.summary.length > 0
text: root.summary
font.family: "Liberation Sans"
color: Color.notifications.text
font.pixelSize: 14
font.bold: true
wrapMode: Text.WordWrap
elide: Text.ElideRight
maximumLineCount: 2
}
Text {
Layout.fillWidth: true
Layout.topMargin: 2
visible: root.sanitizedBody.length > 0
text: root.sanitizedBody
textFormat: Text.StyledText
font.family: "Liberation Sans"
color: root.bodyColor
font.pixelSize: 14
wrapMode: Text.WordWrap
elide: Text.ElideRight
maximumLineCount: 3
}
}
}
}
// Progress bar at the bottom edge. Stays visible while the container has
// a finite lifetime; freezes (doesn't decrement) when hover pauses the
// tick from the container side.
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 3
color: Color.notifications.border
visible: false
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * Math.max(0, Math.min(1, root.progress))
color: root.accentColor
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "omarchy.notifications",
"name": "Notifications",
"version": "1.0.0",
"author": "Omarchy",
"description": "Notification daemon, popups, and history",
"kinds": ["service"],
"activation": "persistent",
"keepLoaded": true,
"entryPoints": {
"service": "Service.qml"
}
}
+143
View File
@@ -0,0 +1,143 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.Commons
Item {
id: root
property string omarchyPath: ""
property var shell: null
property var manifest: null
property bool opened: false
property string icon: ""
property string message: ""
property int value: 0
property int maxValue: 100
property bool hasProgress: true
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)) }
function iconFor(name, percent) {
var n = String(name || "").toLowerCase()
if (n === "volume-muted" || n === "volume-mute" || n === "muted" || n === "mute") return "󰝟"
if (n === "volume-low") return ""
if (n === "volume-medium") return ""
if (n === "volume-high" || n === "volume") return ""
if (n === "microphone-muted" || n === "microphone-off" || n === "mic-muted" || n === "mic-off") return "󰍭"
if (n === "microphone" || n === "mic") return "󰍬"
if (n === "keyboard") return "󰌌"
if (n === "brightness" || n === "display") return "󰃠"
if (n === "touchpad") return "󰟸"
if (n === "touch" || n === "touchscreen") return "󰜉"
if (n === "media" || n === "player") return "󰝚"
if (percent <= 0) return "󰝟"
if (percent <= 33) return ""
if (percent <= 66) return ""
return ""
}
function show(iconName, rawMessage, rawValue, rawMax, rawProgressText) {
maxValue = Math.max(1, parseInt(rawMax || "100", 10))
var parsed = parseInt(rawValue || "0", 10)
hasProgress = rawValue !== "" && !isNaN(parsed) && rawMessage === ""
value = hasProgress ? clamp(parsed, 0, maxValue) : 0
message = String(rawMessage || (hasProgress ? (rawProgressText || Math.round(value * 100 / maxValue) + "%") : ""))
icon = iconFor(iconName, hasProgress ? Math.round(value * 100 / maxValue) : -1)
opened = true
hideTimer.restart()
}
function open(payloadJson) {
try {
var p = JSON.parse(payloadJson || "{}")
show(p.icon || "", p.message || "", p.value === undefined ? "" : String(p.value), p.max === undefined ? "100" : String(p.max), p.progressText || "")
} catch (e) {}
}
function close() { opened = false }
Timer {
id: hideTimer
interval: 1200
onTriggered: root.opened = false
}
IpcHandler {
target: "osd"
function show(payloadJson: string): string {
root.open(payloadJson)
return "ok"
}
function close(): string { root.close(); return "ok" }
function state(): string { return root.opened ? "open" : "closed" }
function ping(): string { return "ok" }
}
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "omarchy-osd"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
Rectangle {
id: card
width: 269
height: 68
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 67
color: Color.alpha(Color.background, 0.97)
border.color: Color.foreground
border.width: 2
radius: Style.cornerRadius
opacity: root.opened ? 1 : 0
Row {
anchors.fill: parent
anchors.leftMargin: 16
anchors.rightMargin: 16
spacing: 16
Text {
width: 28
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignHCenter
text: root.icon
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 27
color: Color.foreground
}
Rectangle {
visible: root.hasProgress
width: visible ? 142 : 0
height: 6
anchors.verticalCenter: parent.verticalCenter
color: Color.alpha(Color.foreground, 0.45)
Rectangle {
height: parent.height
width: parent.width * (root.hasProgress ? root.value / root.maxValue : 0)
color: Color.accent
}
}
Text {
width: root.hasProgress ? 41 : 190
anchors.verticalCenter: parent.verticalCenter
text: root.message
font.family: "JetBrainsMono Nerd Font"
font.bold: true
font.pixelSize: 14
color: Color.foreground
elide: Text.ElideRight
maximumLineCount: 1
clip: true
}
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"schemaVersion": 1,
"id": "omarchy.osd",
"name": "On-screen display",
"version": "1.0.0",
"description": "Quickshell volume, brightness, and status overlays.",
"kinds": ["panel"],
"activation": "persistent",
"keepLoaded": true,
"entryPoints": { "panel": "Osd.qml" }
}
+341
View File
@@ -0,0 +1,341 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Polkit
import Quickshell.Wayland
import qs.Commons
Item {
id: root
property string omarchyPath: ""
property var shell: null
property var manifest: null
property string fontFamily: Quickshell.env("OMARCHY_MENU_FONT") || "monospace"
property color accent: Color.accent
property color background: Color.menu.background
property color foreground: Color.menu.text
property color border: foreground
readonly property int cornerRadius: Style.cornerRadius
property int contentMargin: 18
property int contentSpacing: 12
property int fieldHeight: 42
property bool closing: false
property bool submitted: false
property string currentMessage: ""
property string currentPrompt: ""
property string currentSupplementary: ""
property bool responseRequired: false
property bool responseVisible: false
property bool failed: false
property bool errorFlash: false
property bool fingerprintFirst: false
property int shakeOffset: 0
readonly property bool dialogVisible: polkitAgent.isActive || closing
readonly property bool fingerprintWaiting: dialogVisible && !responseRequired && !submitted && (fingerprintFirst || promptLooksFingerprint(currentPrompt + " " + currentSupplementary))
readonly property int cardWidth: Math.min(312, Math.max(260, panel.width - 48))
readonly property int cardHeight: panel.height > 0 ? Math.min(fieldHeight + contentMargin * 2, panel.height - 48) : fieldHeight + contentMargin * 2
function withAlpha(color, alpha) {
return Qt.rgba(color.r, color.g, color.b, alpha)
}
function messageText() {
return "Authentication is needed..."
}
function promptLooksFingerprint(text) {
var s = String(text || "").toLowerCase()
return s.indexOf("finger") !== -1 || s.indexOf("fprint") !== -1 || s.indexOf("swipe") !== -1
}
function loadPamConfig(raw) {
fingerprintFirst = false
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].replace(/^\s+|\s+$/g, "")
if (!line || line.charAt(0) === "#") continue
if (!line.match(/^auth\s+/)) continue
fingerprintFirst = line.indexOf("pam_fprintd.so") !== -1
return
}
}
function resetSnapshot() {
currentMessage = ""
currentPrompt = ""
currentSupplementary = ""
responseRequired = false
responseVisible = false
failed = false
errorFlash = false
submitted = false
passwordInput.text = ""
}
function syncFromFlow() {
var flow = polkitAgent.flow
if (!flow) return
currentMessage = String(flow.message || "Authentication is needed...")
currentPrompt = String(flow.inputPrompt || "")
currentSupplementary = String(flow.supplementaryMessage || "")
responseRequired = !!flow.isResponseRequired
responseVisible = !!flow.responseVisible
failed = !!flow.failed
if (responseRequired) submitted = false
}
function beginFlow() {
closeTimer.stop()
closing = false
submitted = false
passwordInput.text = ""
syncFromFlow()
Qt.callLater(refocus)
}
function refocus() {
if (!dialogVisible) return
if (fingerprintWaiting) keyCatcher.forceActiveFocus()
else passwordInput.forceActiveFocus()
}
function submitResponse() {
var flow = polkitAgent.flow
if (!flow || !flow.isResponseRequired) return
submitted = true
errorFlash = false
flow.submit(passwordInput.text)
passwordInput.text = ""
keyCatcher.forceActiveFocus()
}
function cancelRequest() {
var flow = polkitAgent.flow
passwordInput.text = ""
submitted = false
closing = true
closeTimer.restart()
if (flow) flow.cancelAuthenticationRequest()
}
function triggerFailureFeedback() {
submitted = false
errorFlash = true
passwordInput.text = ""
errorTimer.restart()
shakeAnimation.restart()
Qt.callLater(refocus)
}
Timer {
id: closeTimer
interval: 300
repeat: false
onTriggered: {
closing = false
resetSnapshot()
}
}
Timer {
id: errorTimer
interval: 1200
repeat: false
onTriggered: root.errorFlash = false
}
SequentialAnimation {
id: shakeAnimation
NumberAnimation { target: root; property: "shakeOffset"; to: -8; duration: 35; easing.type: Easing.OutQuad }
NumberAnimation { target: root; property: "shakeOffset"; to: 8; duration: 50; easing.type: Easing.InOutQuad }
NumberAnimation { target: root; property: "shakeOffset"; to: 0; duration: 55; easing.type: Easing.OutQuad }
}
FileView {
path: "/etc/pam.d/polkit-1"
watchChanges: true
printErrors: false
onLoaded: root.loadPamConfig(text())
onLoadFailed: root.fingerprintFirst = false
onFileChanged: reload()
}
PolkitAgent {
id: polkitAgent
path: "/org/omarchy/PolkitAgent"
onAuthenticationRequestStarted: root.beginFlow()
onIsActiveChanged: {
if (isActive) root.syncFromFlow()
else if (!root.closing) root.resetSnapshot()
}
onIsRegisteredChanged: {
if (isRegistered) console.log("omarchy polkit agent registered")
else console.warn("omarchy polkit agent is not registered; another agent may be running")
}
}
Connections {
target: polkitAgent.flow
function onIsResponseRequiredChanged() {
root.syncFromFlow()
if (!polkitAgent.flow || !polkitAgent.flow.isResponseRequired) passwordInput.text = ""
Qt.callLater(root.refocus)
}
function onInputPromptChanged() { root.syncFromFlow() }
function onResponseVisibleChanged() { root.syncFromFlow() }
function onSupplementaryMessageChanged() { root.syncFromFlow() }
function onFailedChanged() { root.syncFromFlow() }
function onAuthenticationFailed() {
root.syncFromFlow()
root.triggerFailureFeedback()
}
function onAuthenticationSucceeded() {
root.closing = true
closeTimer.restart()
}
function onAuthenticationRequestCancelled() {
root.closing = true
closeTimer.restart()
}
}
PanelWindow {
id: panel
visible: root.dialogVisible
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "omarchy-polkit"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: root.withAlpha(root.background, 0.5)
}
MouseArea {
anchors.fill: parent
onClicked: root.refocus()
}
Rectangle {
id: card
width: root.cardWidth
height: root.cardHeight
radius: root.cornerRadius
anchors.centerIn: parent
anchors.horizontalCenterOffset: root.shakeOffset
color: root.background
border.color: root.accent
border.width: 2
MouseArea { anchors.fill: parent; onClicked: root.refocus() }
Item {
id: keyCatcher
anchors.fill: parent
focus: true
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
root.cancelRequest()
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (root.responseRequired) root.submitResponse()
event.accepted = true
}
}
}
Row {
id: cardRow
anchors.fill: parent
anchors.margins: root.contentMargin
spacing: 14
Text {
text: "\uf023"
color: root.errorFlash ? Color.urgent : root.accent
font.family: root.fontFamily
font.pixelSize: 20
width: 26
height: root.fieldHeight
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
Item {
width: parent.width - 40
height: root.fieldHeight
TextInput {
id: passwordInput
anchors.fill: parent
verticalAlignment: TextInput.AlignVCenter
activeFocusOnPress: true
clip: true
selectionColor: root.withAlpha(root.accent, 0.45)
selectedTextColor: root.foreground
font.family: root.fontFamily
font.pixelSize: 19
echoMode: root.responseVisible ? TextInput.Normal : TextInput.Password
passwordCharacter: "\u2022"
color: root.errorFlash ? Color.urgent : root.foreground
cursorVisible: activeFocus && !root.submitted && !root.errorFlash
readOnly: root.submitted || root.errorFlash
enabled: root.dialogVisible && !root.fingerprintWaiting
visible: !root.fingerprintWaiting
onAccepted: root.submitResponse()
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
root.cancelRequest()
event.accepted = true
}
}
}
Text {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.errorFlash ? "Wrong" : (root.submitted ? "Checking..." : "Enter password")
color: root.errorFlash ? Color.urgent : root.foreground
opacity: root.errorFlash ? 1 : 0.36
font.family: root.fontFamily
font.pixelSize: 19
elide: Text.ElideRight
visible: passwordInput.visible && passwordInput.text.length === 0
}
Rectangle {
width: 2
height: 24
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
color: root.errorFlash ? Color.urgent : root.foreground
visible: passwordInput.visible && passwordInput.activeFocus && passwordInput.text.length === 0 && !root.submitted && !root.errorFlash
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
enabled: passwordInput.visible
onClicked: passwordInput.forceActiveFocus()
}
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "omarchy.polkit",
"name": "Polkit Agent",
"version": "1.0.0",
"author": "Omarchy",
"description": "Theme-aware authentication dialog for privileged actions.",
"kinds": ["service"],
"activation": "persistent",
"keepLoaded": true,
"entryPoints": {
"service": "PolkitAgent.qml"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,134 @@
import QtQuick
import QtQuick.Controls
// Themed ComboBox + popup. Anchors below the trigger, paints with the host
// shell's foreground/background palette, and inherits the shell-wide corner
// radius so nothing renders rounded when the user has set sharp corners.
Item {
id: root
property string label: ""
property string value: ""
property var options: []
property color foreground: "#cacccc"
property color background: "#101315"
property color accent: "#cacccc"
property string fontFamily: "monospace"
property int cornerRadius: 0
property int rowHeight: 28
property int popupRowHeight: 28
property bool showLabel: true
signal changed(string value)
implicitWidth: 240
implicitHeight: showLabel ? rowHeight + 18 : rowHeight
Column {
anchors.fill: parent
spacing: 4
Text {
visible: root.showLabel && root.label !== ""
text: root.label
color: Qt.darker(root.foreground, 1.4)
font.family: root.fontFamily
font.pixelSize: 10
font.bold: true
}
ComboBox {
id: combo
width: parent.width
height: root.rowHeight
font.family: root.fontFamily
font.pixelSize: 12
model: root.options
currentIndex: {
for (var i = 0; i < model.length; i++) if (model[i] === root.value) return i
return -1
}
onActivated: function(index) {
if (index >= 0 && index < model.length) root.changed(model[index])
}
background: Rectangle {
color: root.background
border.color: combo.activeFocus
? root.accent
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.4)
border.width: 1
radius: root.cornerRadius
}
contentItem: Text {
leftPadding: 8
rightPadding: 24
text: combo.displayText
color: root.foreground
font: combo.font
verticalAlignment: Text.AlignVCenter
}
indicator: Text {
x: combo.width - width - 8
y: combo.topPadding + (combo.availableHeight - height) / 2
text: "▾"
color: Qt.darker(root.foreground, 1.2)
font.family: root.fontFamily
font.pixelSize: 10
}
popup: Popup {
// Anchor the popup directly under the field, full width, sharp/round
// matching the shell radius. Override the native white-with-blue look.
y: combo.height
width: combo.width
implicitHeight: Math.min(contentItem.implicitHeight, root.popupRowHeight * 8)
padding: 1
background: Rectangle {
color: root.background
border.color: root.foreground
border.width: 1
radius: root.cornerRadius
}
contentItem: ListView {
clip: true
implicitHeight: contentHeight
model: combo.delegateModel
currentIndex: combo.highlightedIndex
boundsBehavior: Flickable.StopAtBounds
}
}
delegate: ItemDelegate {
required property var modelData
required property int index
width: combo.width
height: root.popupRowHeight
padding: 0
contentItem: Text {
text: String(modelData)
color: index === combo.highlightedIndex ? root.accent : root.foreground
font.family: root.fontFamily
font.pixelSize: 12
leftPadding: 10
rightPadding: 10
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
background: Rectangle {
color: index === combo.highlightedIndex
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12)
: "transparent"
radius: 0
}
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"schemaVersion": 1,
"id": "omarchy.settings",
"name": "Omarchy Bar Settings",
"version": "1.0.0",
"author": "Omarchy",
"description": "Customize the Omarchy bar position and widgets",
"kinds": ["panel"],
"activation": "on-demand",
"entryPoints": { "panel": "SettingsPanel.qml" }
}