Promote shell to its own top-level directory
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
// Single source of truth for shell color surfaces. Top-level tokens
|
||||
// (foreground/background/accent/urgent) come from the theme's colors.toml.
|
||||
// Per-surface roles (Color.bar.*, Color.popups.*, Color.notifications.*,
|
||||
// Color.menu.*, Color.imagePicker.*) come from shell.toml, which is generated
|
||||
// per theme from default/themed/shell.toml.tpl (or shipped directly by a
|
||||
// theme to override). Surfaces that don't appear in shell.toml fall back to
|
||||
// the foundational palette, so themes can ship partial overrides.
|
||||
QtObject {
|
||||
id: root
|
||||
|
||||
// Foundational palette. Live updated from theme/colors.toml.
|
||||
property color foreground: "#cacccc"
|
||||
property color background: "#101315"
|
||||
property color accent: "#cacccc"
|
||||
property color urgent: "#a55555"
|
||||
|
||||
// Flat dictionary of "section.key" -> "#rrggbb" parsed from shell.toml.
|
||||
// Reassigning this whole property is what makes surface bindings below
|
||||
// re-evaluate when the theme swaps; mutating it in place would not.
|
||||
property var shellValues: ({})
|
||||
|
||||
function pick(key, fallback) {
|
||||
var v = shellValues[key]
|
||||
return (typeof v === "string" && v.length > 0) ? v : fallback
|
||||
}
|
||||
|
||||
// Surface roles. Each property reads its shell.toml override if set,
|
||||
// otherwise falls back to a foundational palette token.
|
||||
readonly property QtObject bar: QtObject {
|
||||
property color background: root.pick("bar.background", root.background)
|
||||
property color text: root.pick("bar.text", root.foreground)
|
||||
property color active: root.pick("bar.active", root.urgent)
|
||||
}
|
||||
readonly property QtObject popups: QtObject {
|
||||
property color background: root.pick("popups.background", root.background)
|
||||
property color border: root.pick("popups.border", root.foreground)
|
||||
}
|
||||
readonly property QtObject notifications: QtObject {
|
||||
property color background: root.pick("notifications.background", root.background)
|
||||
property color text: root.pick("notifications.text", root.foreground)
|
||||
property color border: root.pick("notifications.border", root.accent)
|
||||
property color countdown: root.pick("notifications.countdown", root.accent)
|
||||
}
|
||||
readonly property QtObject menu: QtObject {
|
||||
property color background: root.pick("menu.background", root.background)
|
||||
property color text: root.pick("menu.text", root.foreground)
|
||||
property color selected: root.pick("menu.selected", root.accent)
|
||||
}
|
||||
readonly property QtObject imagePicker: QtObject {
|
||||
property color background: root.pick("image-picker.background", root.background)
|
||||
property color text: root.pick("image-picker.text", root.foreground)
|
||||
property color selectedBorder: root.pick("image-picker.selected-border", root.accent)
|
||||
property color unselectedBorder: root.pick("image-picker.unselected-border", root.foreground)
|
||||
}
|
||||
|
||||
function alpha(c, opacity) {
|
||||
if (!c) return Qt.rgba(0, 0, 0, opacity)
|
||||
return Qt.rgba(c.r, c.g, c.b, opacity)
|
||||
}
|
||||
|
||||
function loadColors(raw) {
|
||||
var lines = String(raw || "").split("\n")
|
||||
var foundAccent = false
|
||||
var color4Value = ""
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var match = lines[i].match(/^\s*([A-Za-z0-9_-]+)\s*=\s*["']?(#[0-9A-Fa-f]{6})/)
|
||||
if (!match) continue
|
||||
if (match[1] === "foreground") foreground = match[2]
|
||||
else if (match[1] === "background") background = match[2]
|
||||
// Prefer the explicit `accent` key; only fall back to color4 when the
|
||||
// theme doesn't define a separate accent. Aether/oodle/etc define both,
|
||||
// and color4 appears later in the file so the old single-property
|
||||
// approach was clobbering accent with color4 (#8274fd purple).
|
||||
else if (match[1] === "accent") { accent = match[2]; foundAccent = true }
|
||||
else if (match[1] === "color4") color4Value = match[2]
|
||||
else if (match[1] === "red" || match[1] === "color1") urgent = match[2]
|
||||
}
|
||||
if (!foundAccent && color4Value.length > 0) accent = color4Value
|
||||
}
|
||||
|
||||
// Walk shell.toml line-by-line. We only need string values for color keys,
|
||||
// and the file is small, so no proper TOML parser. Accepts double- or
|
||||
// single-quoted values and tolerates trailing inline comments.
|
||||
function loadShell(raw) {
|
||||
var parsed = {}
|
||||
var text = String(raw || "")
|
||||
if (text) {
|
||||
var lines = text.split("\n")
|
||||
var section = ""
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].replace(/^\s+|\s+$/g, "")
|
||||
if (!line || line.charAt(0) === "#") continue
|
||||
var sectionMatch = line.match(/^\[([A-Za-z0-9_-]+)\]\s*(#.*)?$/)
|
||||
if (sectionMatch) { section = sectionMatch[1]; continue }
|
||||
var kv = line.match(/^([A-Za-z0-9_-]+)\s*=\s*["']([^"']+)["']\s*(#.*)?$/)
|
||||
if (!kv || !section) continue
|
||||
parsed[section + "." + kv[1]] = kv[2]
|
||||
}
|
||||
}
|
||||
shellValues = parsed
|
||||
}
|
||||
|
||||
property bool themeReloadSuspended: false
|
||||
|
||||
function suspendThemeReloads() {
|
||||
themeReloadSuspended = true
|
||||
}
|
||||
|
||||
function resumeThemeReloads() {
|
||||
themeReloadSuspended = false
|
||||
}
|
||||
|
||||
function reloadTheme() {
|
||||
if (themeReloadSuspended) return
|
||||
colorsFile.reload()
|
||||
shellFile.reload()
|
||||
}
|
||||
|
||||
// `omarchy-theme-set` recreates the theme/ directory via rm+mv, which kills
|
||||
// the inotify watch on colors.toml. Use theme.name (overwritten in place) as
|
||||
// a tripwire that forces a fresh reload after each swap.
|
||||
property FileView colorsFile: FileView {
|
||||
id: colorsFile
|
||||
path: Quickshell.env("HOME") + "/.config/omarchy/current/theme/colors.toml"
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onLoaded: root.loadColors(text())
|
||||
onFileChanged: root.reloadTheme()
|
||||
}
|
||||
property FileView shellFile: FileView {
|
||||
id: shellFile
|
||||
path: Quickshell.env("HOME") + "/.config/omarchy/current/theme/shell.toml"
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onLoaded: root.loadShell(text())
|
||||
onLoadFailed: root.loadShell("")
|
||||
onFileChanged: root.reloadTheme()
|
||||
}
|
||||
property FileView themeNameFile: FileView {
|
||||
path: Quickshell.env("HOME") + "/.config/omarchy/current/theme.name"
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onFileChanged: root.reloadTheme()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
// Shared structural style tokens for the shell. Color is the palette
|
||||
// singleton; Style holds the *shape* and *focus-affordance* tokens that
|
||||
// every panel surface and qs.Ui component should bind to so they stay
|
||||
// in sync as the user toggles round/sharp corners or as themes change.
|
||||
//
|
||||
// `cornerRadius` mirrors Hyprland's `decoration:rounding`. Themes ship
|
||||
// their own rounding via theme/hyprland.lua; the user toggle via
|
||||
// `omarchy style corners <round|sharp>` flips Hyprland's flag file
|
||||
// and Hyprland's auto-reload pushes the new value out. The shell picks
|
||||
// up the change here by re-running `hyprctl getoption` whenever either
|
||||
// of those input files changes.
|
||||
//
|
||||
// Single source of truth lives in Hyprland; the shell follows. That
|
||||
// means a theme that ships `rounding = 10` gives us 10px panels by
|
||||
// default, and the round/sharp user toggle still works on top of it.
|
||||
QtObject {
|
||||
id: root
|
||||
|
||||
property int cornerRadius: 0
|
||||
|
||||
// Focus affordances. Deliberately distinct from "selected" (which uses an
|
||||
// accent fill) so the keyboard cursor never reads as the chosen value.
|
||||
// The settings panel originated these tokens; promoting them here means
|
||||
// every Ui component picks them up uniformly.
|
||||
readonly property color focusBorderColor: Color.accent
|
||||
readonly property color focusFillColor: Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.22)
|
||||
readonly property int focusBorderWidth: 3
|
||||
|
||||
// Convenience: the standard "hot" (hover or keyboard cursor) tint used by
|
||||
// PillButton, PanelActionButton, etc. Foreground at 0.12 alpha matches the
|
||||
// value PillButton was already painting before promotion.
|
||||
readonly property color hotFill: Qt.rgba(Color.foreground.r, Color.foreground.g, Color.foreground.b, 0.12)
|
||||
|
||||
function refresh() {
|
||||
hyprctlProc.running = true
|
||||
}
|
||||
|
||||
function applyRoundingJson(raw) {
|
||||
try {
|
||||
var json = JSON.parse(raw || "{}")
|
||||
var n = Number(json.int)
|
||||
if (isFinite(n) && n >= 0) cornerRadius = n
|
||||
} catch (e) {
|
||||
// hyprctl missing / Hyprland not running — leave the previous value.
|
||||
}
|
||||
}
|
||||
|
||||
property Process hyprctlProc: Process {
|
||||
id: hyprctlProc
|
||||
command: ["hyprctl", "-j", "getoption", "decoration:rounding"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: root.applyRoundingJson(text)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-poll Hyprland a beat after either input file changes. Hyprland's
|
||||
// auto-reload runs asynchronously when its sourced .lua files change,
|
||||
// so racing it with an immediate hyprctl gives the old value. 200ms is
|
||||
// generous enough for Hyprland to settle without being user-visible.
|
||||
property Timer refreshTimer: Timer {
|
||||
id: refreshTimer
|
||||
interval: 200
|
||||
repeat: false
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
// The theme name flips whenever `omarchy-theme-set` swaps the theme/
|
||||
// symlink; that's when theme/hyprland.lua's `rounding` value changes.
|
||||
property FileView themeNameFile: FileView {
|
||||
path: Quickshell.env("HOME") + "/.config/omarchy/current/theme.name"
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onFileChanged: refreshTimer.restart()
|
||||
}
|
||||
|
||||
// `omarchy style corners <round|sharp>` creates or removes this flag file.
|
||||
// Hyprland reloads its config when sourced files change, then hyprctl
|
||||
// reflects the new effective rounding value.
|
||||
property FileView roundedCornersToggle: FileView {
|
||||
path: Quickshell.env("HOME") + "/.local/state/omarchy/toggles/hypr/rounded-corners.lua"
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onFileChanged: refreshTimer.restart()
|
||||
onLoaded: refreshTimer.restart()
|
||||
onLoadFailed: refreshTimer.restart()
|
||||
}
|
||||
|
||||
Component.onCompleted: refresh()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module qs.Commons
|
||||
singleton Color 1.0 Color.qml
|
||||
singleton Style 1.0 Style.qml
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
# Omarchy shell
|
||||
|
||||
`omarchy-shell` is a single long-running [Quickshell](https://quickshell.org/)
|
||||
instance that hosts the Omarchy desktop. A supervised user systemd service
|
||||
keeps one shell running per graphical session; everything else — the bar,
|
||||
the bar settings UI, the background switcher, future panels and overlays —
|
||||
runs **inside** the shell as a plugin.
|
||||
|
||||
Hosting everything inside one shell means:
|
||||
|
||||
- shared services and singletons live once, not once per process
|
||||
- summoning a panel is an IPC call into a process that is already running,
|
||||
not a fresh `quickshell -p ...` cold start
|
||||
- third-party plugins can be loaded from disk without changing any source
|
||||
code in Omarchy itself
|
||||
|
||||
The runtime layout:
|
||||
|
||||
```
|
||||
shell/
|
||||
shell.qml entry point (ShellRoot)
|
||||
shell-defaults.json canonical out-of-the-box config
|
||||
services/
|
||||
PluginRegistry.qml discovers, validates plugins, looks up enabled state in shell.json
|
||||
BarWidgetRegistry.qml unified registry for bar widgets (1p + 3p)
|
||||
ui/
|
||||
settings/
|
||||
DynamicSettingsForm.qml renders plugin-declared schemas
|
||||
plugins/
|
||||
bar/ first-party plugins (see plugins/README.md)
|
||||
settings/
|
||||
image-picker/
|
||||
menu/
|
||||
notifications/
|
||||
osd/
|
||||
polkit/
|
||||
```
|
||||
|
||||
The plugin discovery path is documented in [plugins/README.md](plugins/README.md).
|
||||
|
||||
## Plugin manifest
|
||||
|
||||
Every plugin ships a `manifest.json` describing what it is and how the
|
||||
shell should load it. Minimal example:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "my.org.cool-clock",
|
||||
"name": "Cool clock",
|
||||
"version": "1.0.0",
|
||||
"author": "You",
|
||||
"description": "A clock that does cool things",
|
||||
"kinds": ["bar-widget"],
|
||||
"activation": "on-demand",
|
||||
"entryPoints": { "barWidget": "Widget.qml" },
|
||||
"barWidget": {
|
||||
"displayName": "Cool clock",
|
||||
"category": "Time",
|
||||
"allowMultiple": false,
|
||||
"defaults": { "format": "HH:mm" },
|
||||
"schema": [
|
||||
{ "key": "format", "type": "string", "label": "Format" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported `kinds`:
|
||||
|
||||
| Kind | What it is |
|
||||
|--------------|--------------------------------------------------------------|
|
||||
| `bar-widget` | A component that the bar can drop into a section |
|
||||
| `panel` | A persistent or summoned floating window (e.g. bar settings) |
|
||||
| `overlay` | A fullscreen overlay (e.g. background switcher) |
|
||||
| `menu` | A summoned menu surface |
|
||||
| `service` | A headless singleton, no UI |
|
||||
| `bar` | Reserved for the first-party bar host (`omarchy.bar`). Third-party plugins should ship `bar-widget`s; they do not replace the host bar. |
|
||||
|
||||
`activation` is either `persistent` (loaded on startup, never unloaded) or
|
||||
`on-demand` (loaded by `shell summon <id>` and unloaded by `shell hide`).
|
||||
Plugins that need to outlive a single summon can set `keepLoaded: true`
|
||||
(e.g. the image picker keeps its overlay window mounted between
|
||||
summons).
|
||||
|
||||
The full schema lives in `services/PluginRegistry.qml`.
|
||||
|
||||
## Installing a third-party plugin
|
||||
|
||||
1. Drop the plugin into `~/.config/omarchy/plugins/<plugin-id>/`.
|
||||
The directory must contain a `manifest.json` plus the QML files
|
||||
referenced from its `entryPoints`.
|
||||
2. `omarchy-shell shell rescanPlugins`.
|
||||
3. Enable the plugin with `omarchy-shell shell setPluginEnabled <id> true`.
|
||||
4. If it's a `bar-widget`, add it to a layout section from the bar editor.
|
||||
|
||||
First-party plugins under `shell/plugins/`
|
||||
are discovered the same way and cannot be disabled.
|
||||
|
||||
## IPC contract
|
||||
|
||||
The shell exposes a single `shell` IPC target plus whatever extra targets
|
||||
individual plugins register (e.g. the bar's `bar` target for refresh
|
||||
hooks, the image picker's `image-selector` target). `omarchy-menu` uses the
|
||||
shell target to summon the first-party `omarchy.menu` plugin instead of
|
||||
running a separate Quickshell instance.
|
||||
|
||||
| Method | Returns | Effect |
|
||||
|------------------------------------------|---------|-------------------------------------------------------|
|
||||
| `ping` | `ok` | health check |
|
||||
| `summon <id> <payloadJson>` | `ok` / `unknown` | load + open a panel/overlay plugin |
|
||||
| `hide <id>` | — | close a previously-summoned plugin |
|
||||
| `toggle <id> <payloadJson>` | — | summon if closed, hide if open |
|
||||
| `rescanPlugins` | — | re-walk plugin dirs and pick up new/changed manifests |
|
||||
| `setPluginEnabled <id> <enabled>` | — | flip the persisted enabled bit (see note) |
|
||||
| `listPlugins` | JSON | every discovered plugin (id, name, kinds, enabled) |
|
||||
|
||||
Direct invocation:
|
||||
|
||||
```
|
||||
quickshell ipc -p $OMARCHY_PATH/shell call shell ping
|
||||
```
|
||||
|
||||
The `omarchy-shell.service` user unit starts the shell for the graphical
|
||||
session and restarts it if it exits. Use `omarchy-restart-shell` to reload
|
||||
the long-running shell process.
|
||||
|
||||
A convenience wrapper, [`omarchy-shell`](../bin/omarchy-shell),
|
||||
forwards IPC calls to the running service. It does not start the shell; the
|
||||
systemd unit owns the shell lifecycle.
|
||||
|
||||
```
|
||||
omarchy-shell shell ping
|
||||
omarchy-shell shell summon omarchy.settings "{}"
|
||||
omarchy-shell shell listPlugins
|
||||
omarchy-shell shell rescanPlugins
|
||||
```
|
||||
|
||||
**Note on `setPluginEnabled`:** the `enabled` argument is a string. Only the
|
||||
literal `"true"` enables the plugin; every other value (including `"True"`,
|
||||
`"1"`, `"yes"`, or omitted) disables it. This keeps the IPC surface
|
||||
type-stable across QML's `string`-only IPC arguments.
|
||||
|
||||
## Persisted state
|
||||
|
||||
There is one user config file. Everything that distinguishes your
|
||||
customization from the shipped defaults lives in it.
|
||||
|
||||
| Path | Owner | Purpose |
|
||||
|-----------------------------------|----------------|--------------------------------------------------------|
|
||||
| `~/.config/omarchy/shell.json` | the shell | full layout + per-entry settings + enabled plugin list |
|
||||
| `~/.config/omarchy/plugins/<id>/` | user | drop-in third-party plugin source files |
|
||||
|
||||
The `shell-defaults.json` bundled with the shell describes the
|
||||
fresh-install state. When the user has no `shell.json`, the shell uses
|
||||
the defaults verbatim. Once the user customizes anything, `shell.json`
|
||||
becomes the authoritative file — we do **not** deep-merge defaults back
|
||||
in. Pressing **Reset bar to defaults** in `omarchy launch bar settings`
|
||||
rewrites the `bar` subtree from the current `shell-defaults.json`.
|
||||
|
||||
### shell.json shape
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"bar": {
|
||||
"position": "top",
|
||||
"transparent": false,
|
||||
"centerAnchor": "calendar",
|
||||
"fontFamily": "JetBrainsMono Nerd Font",
|
||||
"layout": {
|
||||
"left": [ { "id": "omarchy" }, { "id": "workspaces" } ],
|
||||
"center": [ { "id": "calendar", "format": "HH:mm" } ],
|
||||
"right": [
|
||||
{ "id": "audioPanel" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
{ "id": "omarchy.settings" },
|
||||
{ "id": "omarchy.image-picker" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Storage rules
|
||||
|
||||
1. **Every plugin instance is one entry.** Either in `bar.layout.<section>`
|
||||
for bar widgets, or in `plugins[]` for panels, overlays, services,
|
||||
menus, and anything else non-bar.
|
||||
2. **Settings are inline on the entry.** No `config:` sub-object, no
|
||||
separate per-plugin settings file, no merge layers. The fields on each
|
||||
entry are the values the plugin sees.
|
||||
3. **Enabled ⇔ present.** A plugin is enabled iff its id appears somewhere
|
||||
in shell.json. For bar widgets, the bar settings UI adds/removes layout
|
||||
entries; other plugin kinds are enabled with the shell IPC.
|
||||
4. **Multiple instances** are allowed when a manifest sets
|
||||
`allowMultiple: true`. Each instance is independent — e.g. two clocks
|
||||
in different timezones are just two `{"id":"calendar", "timezone": ...}`
|
||||
entries with their own values.
|
||||
5. **`version: 1` is required** at the top level. The shell will fall back
|
||||
to defaults rather than load an unknown version.
|
||||
|
||||
## Implementation history
|
||||
|
||||
Built up in phases on this branch:
|
||||
|
||||
- Phase 1 — `omarchy-shell phase 1: host the existing bar in a single shell`
|
||||
- Phase 2 — `omarchy-shell phase 2: plugin registry and bar widget registry`
|
||||
- Phase 3 — `omarchy-shell phase 3: fold bar-settings into the shell as a panel plugin`
|
||||
- Phase 4 — `omarchy-shell phase 4: absorb background-switcher as a plugin`
|
||||
- Phase 5 — `omarchy-shell phase 5: docs, cleanup, and migration crumbs`
|
||||
- Phase 6 — `omarchy-shell phase 6: reviewer cleanup (path traversal, collision, races)`
|
||||
- Phase 7 — `omarchy-shell phase 7: replace socket with IpcHandler, rename to image-picker`
|
||||
- Phase 8a — `omarchy-shell phase 8a: unified shell.json with inline plugin settings`
|
||||
|
||||
Shared services and Pipewire/UPower/Hyprland consolidation are explicitly
|
||||
out of scope here and deferred to a follow-up after a review pass.
|
||||
@@ -0,0 +1,77 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
// A single button in a mutually-exclusive choice group (a Row of these
|
||||
// makes a "segmented control"). Distinct from PillButton because it has
|
||||
// a real `selected` state semantic — used for picking between options
|
||||
// (bar position: top/bottom/left/right), not for momentary actions.
|
||||
//
|
||||
// Selected styling uses the accent fill+border; focus styling uses the
|
||||
// Style.focusBorderColor outline so keyboard nav can land on a non-selected
|
||||
// option without it reading as the chosen one. This separation matters in
|
||||
// the settings panel and any future "pick one" UI.
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string text: ""
|
||||
property bool selected: false
|
||||
|
||||
// Panel-cursor flag. Same role as PillButton.hasCursor: panels that own
|
||||
// their own cursor state bind this to drive the keyboard highlight
|
||||
// separately from real activeFocus. Visuals match the activeFocus look
|
||||
// (foreground 2px border) so cursor and Tab focus read the same.
|
||||
property bool hasCursor: false
|
||||
|
||||
property color foreground: Color.foreground
|
||||
property color background: Color.background
|
||||
property color accent: Color.accent
|
||||
property string fontFamily: "monospace"
|
||||
property real fontSize: 12
|
||||
|
||||
signal clicked()
|
||||
signal hovered(bool isHovered)
|
||||
|
||||
activeFocusOnTab: true
|
||||
Keys.onReturnPressed: root.clicked()
|
||||
Keys.onEnterPressed: root.clicked()
|
||||
Keys.onSpacePressed: root.clicked()
|
||||
|
||||
implicitWidth: Math.max(56, label.implicitWidth + 22)
|
||||
implicitHeight: 28
|
||||
radius: Style.cornerRadius
|
||||
|
||||
color: selected
|
||||
? Qt.rgba(accent.r, accent.g, accent.b, 0.18)
|
||||
: (mouse.containsMouse ? Style.hotFill : background)
|
||||
border.color: selected
|
||||
? accent
|
||||
: (activeFocus || hasCursor ? foreground : Qt.rgba(foreground.r, foreground.g, foreground.b, 0.4))
|
||||
border.width: selected ? 2 : (activeFocus || hasCursor ? 2 : 1)
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 100 } }
|
||||
|
||||
Text {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: root.text
|
||||
color: root.selected ? root.accent : root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: root.fontSize
|
||||
font.bold: root.selected
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
root.forceActiveFocus()
|
||||
root.clicked()
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: root.hovered(hovered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import QtQuick
|
||||
|
||||
// PillButton that participates in a panel's single-cursor model. Use
|
||||
// inside a row of pills (DNS providers, bluetooth header actions, choice
|
||||
// chips) where mouse hover and keyboard cursor should land in the same
|
||||
// place.
|
||||
//
|
||||
// Caller binds `hasCursor` to the panel's cursor state and listens to
|
||||
// `hovered(bool)` to update that state when the mouse enters or leaves.
|
||||
// This is structurally a wrapper around PillButton with one extra
|
||||
// HoverHandler — but extracting it lets every panel use the same wiring
|
||||
// idiom and lets plugin authors drop into the same cursor model without
|
||||
// touching internals.
|
||||
//
|
||||
// Why HoverHandler instead of a MouseArea overlay: HoverHandler doesn't
|
||||
// steal pointer events from PillButton's internal click MouseArea, so
|
||||
// clicks still reach the underlying button. An overlay MouseArea with
|
||||
// acceptedButtons: Qt.NoButton works but is fragile around tooltip
|
||||
// timing and event propagation.
|
||||
PillButton {
|
||||
id: root
|
||||
|
||||
signal hovered(bool isHovered)
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: root.hovered(hovered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
// Shared visual chrome for keyboard-and-mouse-navigable items inside a panel.
|
||||
// Contract: items must NOT read `containsMouse` for color/border. Mouse
|
||||
// hover updates the panel's cursor state at the root; visuals derive from
|
||||
// `hasCursor` / `current`. That's what guarantees a single highlight on
|
||||
// screen at any time across both keyboard and mouse interaction.
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property bool hasCursor: false
|
||||
property bool current: false
|
||||
|
||||
property color foreground: "#cacccc"
|
||||
property color fill: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.18)
|
||||
|
||||
radius: Style.cornerRadius
|
||||
color: (hasCursor || current) ? fill : "transparent"
|
||||
border.width: hasCursor ? 1 : 0
|
||||
border.color: foreground
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: 60 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import qs.Commons
|
||||
|
||||
// Themed single-select dropdown. Trigger row paints with the kit's focus
|
||||
// chrome; the popup anchors below and uses Color.popups.background +
|
||||
// Color.popups.border so it reads as a panel surface rather than the
|
||||
// platform-native ComboBox look.
|
||||
//
|
||||
// `options` accepts either a plain string[] or an array of
|
||||
// { value, label } objects (label is what we render; value is what we
|
||||
// emit). Mixing is fine — each row is interpreted independently.
|
||||
//
|
||||
// Keyboard: Tab to focus the trigger, Enter/Space opens, Esc closes,
|
||||
// j/k or Up/Down walks options inside the open popup, Enter selects.
|
||||
// A sibling SearchableDropdown reuses the same visuals but adds an
|
||||
// embedded filter input — keep the two separate so each stays simple.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property string value: ""
|
||||
property var options: []
|
||||
|
||||
property color foreground: Color.foreground
|
||||
property color background: Color.popups.background
|
||||
property color popupBorder: Color.popups.border
|
||||
property color accent: Color.accent
|
||||
property string fontFamily: "JetBrainsMono Nerd Font"
|
||||
property int rowHeight: 28
|
||||
property int popupRowHeight: 28
|
||||
property bool showLabel: true
|
||||
|
||||
// Panel-cursor flag. When true, the trigger renders the same focus ring
|
||||
// as Tab-focus so a panel's keyboard cursor lands here identically.
|
||||
// Emits `hovered(bool)` on pointer enter/leave so the panel can keep
|
||||
// its cursor state in sync with the mouse.
|
||||
property bool hasCursor: false
|
||||
|
||||
// popupOpen + open()/close()/toggle() let a parent panel know when the
|
||||
// dropdown owns keys (its embedded ListView is active) and suspend its
|
||||
// own keyCatcher so j/k inside the popup don't double-drive the panel
|
||||
// cursor.
|
||||
readonly property bool popupOpen: popup.opened
|
||||
function open() { popup.open() }
|
||||
function close() { popup.close() }
|
||||
function toggle() { popup.opened ? popup.close() : popup.open() }
|
||||
|
||||
signal changed(string value)
|
||||
signal hovered(bool isHovered)
|
||||
|
||||
function optionValue(o) {
|
||||
return (o && typeof o === "object") ? String(o.value) : String(o)
|
||||
}
|
||||
function optionLabel(o) {
|
||||
return (o && typeof o === "object") ? String(o.label) : String(o)
|
||||
}
|
||||
function currentLabel() {
|
||||
for (var i = 0; i < options.length; i++) {
|
||||
if (optionValue(options[i]) === value) return optionLabel(options[i])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
implicitWidth: 240
|
||||
implicitHeight: showLabel && label !== "" ? 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
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: trigger
|
||||
width: parent.width
|
||||
height: root.rowHeight
|
||||
radius: Style.cornerRadius
|
||||
|
||||
readonly property bool _focused: trigger.activeFocus || root.hasCursor
|
||||
|
||||
color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b,
|
||||
trigger._focused ? 0.08 : 0.04)
|
||||
border.color: trigger._focused
|
||||
? Style.focusBorderColor
|
||||
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.4)
|
||||
border.width: trigger._focused ? Style.focusBorderWidth : 1
|
||||
|
||||
activeFocusOnTab: true
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: root.hovered(hovered)
|
||||
}
|
||||
|
||||
Keys.onPressed: function(event) {
|
||||
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|
||||
|| event.key === Qt.Key_Space || event.key === Qt.Key_Down) {
|
||||
popup.opened ? popup.close() : popup.open()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Escape && popup.opened) {
|
||||
popup.close(); event.accepted = true
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.right: chevron.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 6
|
||||
text: root.currentLabel()
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
id: chevron
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.rightMargin: 8
|
||||
text: ""
|
||||
color: Qt.darker(root.foreground, 1.2)
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
trigger.forceActiveFocus()
|
||||
popup.opened ? popup.close() : popup.open()
|
||||
}
|
||||
}
|
||||
|
||||
Popup {
|
||||
id: popup
|
||||
x: 0
|
||||
y: trigger.height + 2
|
||||
width: trigger.width
|
||||
implicitHeight: Math.min(root.options.length * root.popupRowHeight + Math.max(0, root.options.length - 1) * 4 + 2,
|
||||
root.popupRowHeight * 8 + 7 * 4 + 2)
|
||||
padding: 1
|
||||
focus: true
|
||||
|
||||
background: Rectangle {
|
||||
color: root.background
|
||||
border.color: root.popupBorder
|
||||
border.width: 1
|
||||
radius: Style.cornerRadius
|
||||
}
|
||||
|
||||
onOpened: {
|
||||
optionList.currentIndex = Math.max(0, optionList.indexOfValue(root.value))
|
||||
optionList.forceActiveFocus()
|
||||
}
|
||||
|
||||
contentItem: ListView {
|
||||
id: optionList
|
||||
spacing: 4
|
||||
|
||||
Keys.priority: Keys.BeforeItem
|
||||
Keys.onPressed: function(event) {
|
||||
if (event.key === Qt.Key_Escape) { popup.close(); event.accepted = true }
|
||||
else if (event.key === Qt.Key_Down || event.text === "j") {
|
||||
optionList.currentIndex = Math.min(root.options.length - 1, optionList.currentIndex + 1)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Up || event.text === "k") {
|
||||
optionList.currentIndex = Math.max(0, optionList.currentIndex - 1)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||
optionList.selectCurrent(); event.accepted = true
|
||||
}
|
||||
}
|
||||
implicitHeight: contentHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.options
|
||||
currentIndex: -1
|
||||
|
||||
function indexOfValue(v) {
|
||||
for (var i = 0; i < root.options.length; i++)
|
||||
if (root.optionValue(root.options[i]) === v) return i
|
||||
return -1
|
||||
}
|
||||
|
||||
function selectCurrent() {
|
||||
if (currentIndex < 0 || currentIndex >= root.options.length) return
|
||||
var v = root.optionValue(root.options[currentIndex])
|
||||
root.value = v
|
||||
root.changed(v)
|
||||
popup.close()
|
||||
}
|
||||
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: optionList.width
|
||||
height: root.popupRowHeight
|
||||
color: index === optionList.currentIndex
|
||||
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.14)
|
||||
: "transparent"
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
text: root.optionLabel(modelData)
|
||||
color: index === optionList.currentIndex ? root.accent : root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onPositionChanged: optionList.currentIndex = parent.index
|
||||
onClicked: optionList.selectCurrent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
|
||||
// Layer-shell popup attached to a bar widget icon, designed for
|
||||
// click-driven AND keyboard-driven panels (e.g. SUPER+CTRL+W summon).
|
||||
//
|
||||
// Built on PanelWindow with WlrKeyboardFocus.Exclusive rather than
|
||||
// PopupWindow (xdg-popup). Layer-shell surfaces declared Exclusive get
|
||||
// keyboard focus from Hyprland *at map time*, which is the protocol-level
|
||||
// equivalent of focus-on-launch for xdg-toplevels. xdg-popups don't get
|
||||
// that — they only receive keys after a click/hover routes focus through
|
||||
// their parent surface — so keyboard-summoned popups fell flat without it.
|
||||
//
|
||||
// API is a subset of Common.PopupCard: anchorItem, owner, bar, open,
|
||||
// padding, margin, contentWidth/Height, default contentItem. Missing on
|
||||
// purpose (for now): centerOnBar, triggerMode ("hover"), containsMouse.
|
||||
// Hover-mode popups (system-stats, weather-flyout) and centered popups
|
||||
// (calendar week-view) need extra plumbing before migrating; converting
|
||||
// them is a follow-up.
|
||||
//
|
||||
// Positioning: full-screen layer-shell with the card placed inside at
|
||||
// `cardOrigin`. We use the bar window's height/width for the perpendicular
|
||||
// axis (away-from-bar) because mapToItem on the anchor returns
|
||||
// bar-content-relative coords with internal layout offsets baked in
|
||||
// (e.g. ~13px from the bar's vertical centering of its widget row). The
|
||||
// parallel axis (along-the-bar) uses the anchor's content x/y since the
|
||||
// bar spans full screen on that axis.
|
||||
//
|
||||
// Outside-click dismissal: an overlay MouseArea catches clicks, with the
|
||||
// QsWindow.mask subtracting the bar strip so clicks on the bar still
|
||||
// reach the bar widgets (activePopout coordinator hands off to another
|
||||
// popup if the user clicks a different bar icon).
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
required property Item anchorItem
|
||||
required property QtObject bar
|
||||
property var owner: null
|
||||
property int margin: 10
|
||||
property int padding: 14
|
||||
property int contentWidth: 280
|
||||
property int contentHeight: 200
|
||||
property bool open: false
|
||||
property int gap: 10 // distance between bar edge and panel
|
||||
|
||||
default property alias contentItem: contentHolder.children
|
||||
|
||||
readonly property var coordinatorKey: owner || root
|
||||
readonly property var anchorWindow: anchorItem ? anchorItem.QsWindow.window : null
|
||||
readonly property string barPos: bar ? bar.position : "top"
|
||||
|
||||
function closePopout() {
|
||||
if (owner && "closePopout" in owner) owner.closePopout()
|
||||
else root.open = false
|
||||
}
|
||||
|
||||
// --- screen + lifetime ---------------------------------------------------
|
||||
|
||||
screen: anchorWindow ? anchorWindow.screen : null
|
||||
visible: open || card.opacity > 0
|
||||
color: "transparent"
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
WlrLayershell.namespace: "omarchy-keyboard-panel"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
// Keyboard focus follows `open` (NOT `visible`). The window remains
|
||||
// mapped during the fade-out so the opacity animation has something to
|
||||
// animate, but keyboard/click ownership must release the moment the
|
||||
// logical close fires — otherwise the user is locked out for 140ms.
|
||||
WlrLayershell.keyboardFocus: open ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
|
||||
|
||||
// Full-screen layer-shell. The visible card is positioned inside via
|
||||
// `cardOrigin`. The `mask` below makes the bar area click-through (so
|
||||
// the user can click another bar icon while the panel is open and the
|
||||
// activePopout coordinator swaps to that popup); everywhere else, the
|
||||
// overlay catches the click and dismisses via the MouseArea below.
|
||||
anchors {
|
||||
top: true
|
||||
bottom: true
|
||||
left: true
|
||||
right: true
|
||||
}
|
||||
|
||||
// Clickable region = whole screen MINUS the bar's strip. Clicks on the
|
||||
// bar pass through to the bar layer; clicks anywhere else are caught
|
||||
// by us and either land on the card (no-op) or trigger dismissal.
|
||||
readonly property real _barStripSize: bar ? bar.barSize : 0
|
||||
mask: Region {
|
||||
width: root.screenW
|
||||
height: root.screenH
|
||||
Region {
|
||||
x: root.barPos === "right" ? root.screenW - root._barStripSize : 0
|
||||
y: root.barPos === "bottom" ? root.screenH - root._barStripSize : 0
|
||||
width: (root.barPos === "top" || root.barPos === "bottom") ? root.screenW : root._barStripSize
|
||||
height: (root.barPos === "top" || root.barPos === "bottom") ? root._barStripSize : root.screenH
|
||||
intersection: Intersection.Subtract
|
||||
}
|
||||
}
|
||||
|
||||
// Track every layout change between the bar's contentItem and the
|
||||
// anchor item. `transform` updates whenever any item in that chain
|
||||
// moves/resizes, which is what makes the position binding below
|
||||
// actually reactive — mapToItem on its own is a one-shot.
|
||||
TransformWatcher {
|
||||
id: anchorWatcher
|
||||
a: anchorWindow ? anchorWindow.contentItem : null
|
||||
b: anchorItem
|
||||
}
|
||||
|
||||
// Anchor item's position within the bar's content surface. For a
|
||||
// full-width top bar, the content x maps directly to screen x; the y
|
||||
// returned here has the bar's internal padding baked in (e.g. ~13px
|
||||
// from vertical centering of the widget row), which is why `cardOrigin`
|
||||
// below uses `barH` for the perpendicular axis instead of this y.
|
||||
readonly property point anchorScreenPos: {
|
||||
anchorWatcher.transform // reactive dependency
|
||||
if (!anchorItem || !anchorWindow) return Qt.point(0, 0)
|
||||
return anchorItem.mapToItem(anchorWindow.contentItem, 0, 0)
|
||||
}
|
||||
readonly property real anchorW: anchorItem ? anchorItem.width : 0
|
||||
readonly property real anchorH: anchorItem ? anchorItem.height : 0
|
||||
readonly property real screenW: screen ? screen.width : 0
|
||||
readonly property real screenH: screen ? screen.height : 0
|
||||
|
||||
// Desired top-left of the card in screen coordinates. For the
|
||||
// perpendicular axis (away-from-bar) we anchor to the bar window's edge
|
||||
// directly — not the anchor item's y/x — because mapToItem(barContent)
|
||||
// returns coordinates in the bar's content space, which can be offset
|
||||
// from the bar surface's screen-anchored corner by internal layout
|
||||
// (centering wrappers, padding). The bar's surface IS aligned to its
|
||||
// anchored screen edge, so using `barW`/`barH` gives the right edge
|
||||
// regardless of how the bar's internal widgets are positioned. For the
|
||||
// parallel axis (along the bar) the anchor item's reported position is
|
||||
// still consistent with the bar content origin, so it's accurate for
|
||||
// centering the card under the icon.
|
||||
readonly property real barW: anchorWindow ? anchorWindow.width : screenW
|
||||
readonly property real barH: anchorWindow ? anchorWindow.height : 0
|
||||
readonly property point cardOrigin: {
|
||||
if (!anchorItem || !bar) return Qt.point(margin, margin)
|
||||
var x = 0, y = 0
|
||||
if (barPos === "bottom") {
|
||||
x = anchorScreenPos.x + anchorW / 2 - contentWidth / 2
|
||||
y = screenH - barH - contentHeight - gap
|
||||
} else if (barPos === "left") {
|
||||
x = barW + gap
|
||||
y = anchorScreenPos.y + anchorH / 2 - contentHeight / 2
|
||||
} else if (barPos === "right") {
|
||||
x = screenW - barW - contentWidth - gap
|
||||
y = anchorScreenPos.y + anchorH / 2 - contentHeight / 2
|
||||
} else { // "top" (default)
|
||||
x = anchorScreenPos.x + anchorW / 2 - contentWidth / 2
|
||||
y = barH + gap
|
||||
}
|
||||
x = Math.max(margin, Math.min(x, screenW - contentWidth - margin))
|
||||
y = Math.max(margin, Math.min(y, screenH - contentHeight - margin))
|
||||
return Qt.point(Math.round(x), Math.round(y))
|
||||
}
|
||||
|
||||
|
||||
// --- popout coordination (same-bar single-popout model) -----------------
|
||||
|
||||
// Coordinate on `open`, not `visible`. `visible` lags into the fade-out
|
||||
// animation, which made ownership transfer to a sibling popup race.
|
||||
onOpenChanged: {
|
||||
if (!bar) return
|
||||
if (open) bar.requestPopout(coordinatorKey)
|
||||
else if (bar.activePopout === coordinatorKey) bar.releasePopout(coordinatorKey)
|
||||
}
|
||||
|
||||
// --- outside-click dismissal --------------------------------------------
|
||||
|
||||
// Catches clicks anywhere in the clickable region (i.e. everywhere on
|
||||
// screen except the bar strip, which is masked out). The card has its
|
||||
// own MouseArea below so clicks on it don't bubble up here. Disabled
|
||||
// during the fade-out so the dying overlay doesn't swallow clicks that
|
||||
// were meant for the apps behind it.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.open
|
||||
onClicked: root.closePopout()
|
||||
}
|
||||
|
||||
// --- card ----------------------------------------------------------------
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
x: root.cardOrigin.x
|
||||
y: root.cardOrigin.y
|
||||
width: root.contentWidth
|
||||
height: root.contentHeight
|
||||
color: Color.popups.background
|
||||
border.color: Color.popups.border
|
||||
border.width: 2
|
||||
radius: Style.cornerRadius
|
||||
opacity: root.open ? 1.0 : 0
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
// Swallow clicks on the card so they don't bubble to the dismissal
|
||||
// MouseArea behind us.
|
||||
MouseArea { anchors.fill: parent }
|
||||
|
||||
Item {
|
||||
id: contentHolder
|
||||
anchors.fill: parent
|
||||
anchors.margins: root.padding
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls as QQC
|
||||
import qs.Commons
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property int value: 0
|
||||
property int from: 0
|
||||
property int to: 100
|
||||
property int stepSize: 1
|
||||
property color foreground: Color.foreground
|
||||
property color accent: Color.accent
|
||||
property string fontFamily: "JetBrainsMono Nerd Font"
|
||||
property real fontSize: 12
|
||||
property real fieldWidth: 120
|
||||
property bool hasCursor: false
|
||||
property alias field: spin
|
||||
|
||||
signal modified(int value)
|
||||
signal hovered(bool on)
|
||||
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
visible: root.label !== ""
|
||||
text: root.label
|
||||
color: Qt.darker(root.foreground, 1.4)
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
QQC.SpinBox {
|
||||
id: spin
|
||||
width: root.fieldWidth
|
||||
from: root.from
|
||||
to: root.to
|
||||
stepSize: root.stepSize
|
||||
value: root.value
|
||||
editable: true
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: root.fontSize
|
||||
|
||||
onValueModified: root.modified(value)
|
||||
|
||||
background: Rectangle {
|
||||
readonly property bool _hot: spin.activeFocus || (root.hasCursor && !spin.activeFocus)
|
||||
color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, _hot ? 0.10 : 0.05)
|
||||
border.color: _hot
|
||||
? Style.focusBorderColor
|
||||
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.3)
|
||||
border.width: _hot ? Style.focusBorderWidth : 1
|
||||
radius: Style.cornerRadius
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: root.hovered(hovered)
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: TextInput {
|
||||
text: spin.displayText
|
||||
font: spin.font
|
||||
color: root.foreground
|
||||
selectionColor: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.35)
|
||||
selectedTextColor: root.foreground
|
||||
horizontalAlignment: Qt.AlignHCenter
|
||||
verticalAlignment: Qt.AlignVCenter
|
||||
readOnly: !spin.editable
|
||||
validator: spin.validator
|
||||
inputMethodHints: Qt.ImhFormattedNumbersOnly
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
// Small (22×22 by default) icon button used at the right edge of panel rows
|
||||
// for inline actions — forget network, confirm passphrase, unpair device,
|
||||
// etc. Two visual modes are supported via `hoverColor`:
|
||||
// - default: hoverColor === foreground → subtle foreground-tint hover
|
||||
// - urgent: hoverColor === bar.urgent → red-tint hover for destructive
|
||||
// actions like forget/unpair
|
||||
//
|
||||
// `enabled` gates clicks and dims the icon. The component owns its own
|
||||
// hover state visuals; mouse hover does NOT update any panel cursor state
|
||||
// here because action buttons are not cursor targets — the row they live
|
||||
// in is.
|
||||
//
|
||||
// Set `focusable: true` to make the button keyboard-tabbable with an
|
||||
// accent focus ring (Style.focusBorderColor / FillColor / Width). Use this
|
||||
// in form contexts (the bar settings widget cards) where Tab walks a list
|
||||
// of controls; leave it false for the right-edge actions on panel rows
|
||||
// where the row's CursorSurface owns the keyboard cursor.
|
||||
//
|
||||
// Set `hasCursor: true` to have the button render the same fill as a
|
||||
// mouse hover — so a panel's keyboard cursor lands on it identically.
|
||||
// Use this when a PanelActionButton is itself the cursor target (rather
|
||||
// than living inside a CursorSurface row). Emits `hovered(bool)` on
|
||||
// pointer enter/leave so the panel can update its cursor state to match.
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string iconText: ""
|
||||
property string tooltipText: ""
|
||||
property color foreground: "#cacccc"
|
||||
property color hoverColor: foreground
|
||||
property color panelBackground: "#101315"
|
||||
property string fontFamily: "JetBrainsMono Nerd Font"
|
||||
property real fontSize: 14
|
||||
property real size: 22
|
||||
|
||||
property bool focusable: false
|
||||
property bool hasCursor: false
|
||||
|
||||
signal clicked()
|
||||
signal hovered(bool isHovered)
|
||||
|
||||
activeFocusOnTab: focusable
|
||||
Keys.onReturnPressed: if (focusable) root.clicked()
|
||||
Keys.onEnterPressed: if (focusable) root.clicked()
|
||||
Keys.onSpacePressed: if (focusable) root.clicked()
|
||||
|
||||
implicitWidth: size
|
||||
implicitHeight: size
|
||||
radius: Style.cornerRadius
|
||||
|
||||
readonly property bool _showFocusRing: focusable && activeFocus
|
||||
readonly property bool _hot: (mouse.containsMouse || root.hasCursor) && root.enabled
|
||||
|
||||
color: _showFocusRing
|
||||
? Style.focusFillColor
|
||||
: (_hot
|
||||
? Qt.rgba(hoverColor.r, hoverColor.g, hoverColor.b, 0.20)
|
||||
: "transparent")
|
||||
border.width: _showFocusRing ? Style.focusBorderWidth : 0
|
||||
border.color: _showFocusRing ? Style.focusBorderColor : "transparent"
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 60 } }
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: root.iconText
|
||||
color: root.enabled
|
||||
? (root._hot ? root.hoverColor : Qt.darker(root.foreground, 1.3))
|
||||
: Qt.darker(root.foreground, 2.0)
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: root.fontSize
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
enabled: root.enabled
|
||||
onContainsMouseChanged: root.hovered(containsMouse)
|
||||
onClicked: {
|
||||
if (root.focusable) root.forceActiveFocus()
|
||||
root.clicked()
|
||||
}
|
||||
}
|
||||
|
||||
PanelToolTip {
|
||||
visible: root.tooltipText !== "" && mouse.containsMouse
|
||||
text: root.tooltipText
|
||||
panelForeground: root.foreground
|
||||
panelBackground: root.panelBackground
|
||||
fontFamily: root.fontFamily
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import QtQuick
|
||||
|
||||
// Drop-in key dispatcher for keyboard-driven panels. Wraps panel content
|
||||
// and emits semantic signals so each panel keeps its own state machine
|
||||
// (focusSection, selectedIndex, activation rules) while the boilerplate
|
||||
// key handling lives here.
|
||||
//
|
||||
// Usage:
|
||||
// Common.KeyboardPanel {
|
||||
// ...
|
||||
// PanelKeyCatcher {
|
||||
// anchors.fill: parent
|
||||
// onMoveRequested: function(dx, dy) { root.moveCursor(dx, dy) }
|
||||
// onActivateRequested: root.activateCursor()
|
||||
// onCloseRequested: root.closePopout()
|
||||
// onDeleteRequested: root.deleteSelected()
|
||||
// onTextKey: function(t) { if (t === "r") root.refresh() }
|
||||
//
|
||||
// Column { ... panel content ... }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Keys.priority: Keys.BeforeItem means this handler gets keys first,
|
||||
// even when a descendant has activeFocus. That's what lets Up/Down
|
||||
// arrows drive the cursor instead of being consumed by an inner
|
||||
// Flickable's built-in scroll handling. When a panel has an inline
|
||||
// editor (wifi passphrase, gallery TextField demo) the panel must
|
||||
// set `blocked: editor.activeFocus` so this handler short-circuits
|
||||
// and the editor receives keys normally.
|
||||
//
|
||||
// blocked: when true, ALL keys are forwarded to descendants without
|
||||
// triggering signals.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool blocked: false
|
||||
|
||||
signal moveRequested(int dx, int dy)
|
||||
signal activateRequested()
|
||||
signal closeRequested()
|
||||
signal deleteRequested()
|
||||
signal textKey(string text)
|
||||
|
||||
focus: true
|
||||
Keys.priority: Keys.BeforeItem
|
||||
Keys.onPressed: function(event) {
|
||||
if (blocked) return
|
||||
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
closeRequested(); event.accepted = true; return
|
||||
}
|
||||
if (event.key === Qt.Key_Down || event.text === "j") {
|
||||
moveRequested(0, 1); event.accepted = true; return
|
||||
}
|
||||
if (event.key === Qt.Key_Up || event.text === "k") {
|
||||
moveRequested(0, -1); event.accepted = true; return
|
||||
}
|
||||
if (event.key === Qt.Key_Right || event.text === "l") {
|
||||
moveRequested(1, 0); event.accepted = true; return
|
||||
}
|
||||
if (event.key === Qt.Key_Left || event.text === "h") {
|
||||
moveRequested(-1, 0); event.accepted = true; return
|
||||
}
|
||||
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space) {
|
||||
activateRequested(); event.accepted = true; return
|
||||
}
|
||||
if (event.text === "x" || event.text === "X") {
|
||||
deleteRequested(); event.accepted = true; return
|
||||
}
|
||||
if (event.text && event.text.length === 1) {
|
||||
textKey(event.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import QtQuick
|
||||
|
||||
// Small-caps-style label that introduces a panel section ("DNS provider",
|
||||
// "Wi-Fi networks", "Output device", "Paired devices"). Sits between a
|
||||
// PanelSeparator and the content rows.
|
||||
Text {
|
||||
id: root
|
||||
|
||||
property color foreground: "#cacccc"
|
||||
property string fontFamily: "JetBrainsMono Nerd Font"
|
||||
property real fontSize: 10
|
||||
|
||||
color: Qt.darker(foreground, 1.4)
|
||||
font.family: fontFamily
|
||||
font.pixelSize: fontSize
|
||||
font.bold: true
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import QtQuick
|
||||
|
||||
// 1px horizontal divider for panel sections. The alpha-on-foreground tint
|
||||
// keeps the rule legible against the panel background without competing
|
||||
// with text or borders.
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property color foreground: "#cacccc"
|
||||
property real strength: 0.12
|
||||
|
||||
width: parent ? parent.width : implicitWidth
|
||||
implicitWidth: 100
|
||||
implicitHeight: 1
|
||||
height: 1
|
||||
color: Qt.rgba(foreground.r, foreground.g, foreground.b, strength)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import QtQuick
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property QtObject bar: null
|
||||
property real value: 0
|
||||
property real minimum: 0
|
||||
property real maximum: 1
|
||||
property real step: 0.05
|
||||
property bool integer: false
|
||||
property color trackColor: bar ? Qt.rgba(bar.foreground.r, bar.foreground.g, bar.foreground.b, 0.18) : "#333"
|
||||
property color fillColor: bar ? bar.foreground : "#cacccc"
|
||||
property color knobColor: bar ? bar.foreground : "#cacccc"
|
||||
property bool dragging: false
|
||||
property real trackHeight: 4
|
||||
property real liveValue: value
|
||||
|
||||
onValueChanged: if (!dragging) liveValue = value
|
||||
|
||||
signal moved(real value)
|
||||
signal released(real value)
|
||||
|
||||
implicitWidth: 200
|
||||
implicitHeight: 22
|
||||
|
||||
readonly property real range: Math.max(0.0001, maximum - minimum)
|
||||
readonly property real progress: Math.max(0, Math.min(1, (liveValue - minimum) / range))
|
||||
|
||||
Rectangle {
|
||||
id: track
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: root.trackHeight
|
||||
radius: height / 2
|
||||
color: root.trackColor
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: fill
|
||||
anchors.verticalCenter: track.verticalCenter
|
||||
anchors.left: track.left
|
||||
height: track.height
|
||||
radius: track.radius
|
||||
color: root.fillColor
|
||||
width: track.width * root.progress
|
||||
|
||||
Behavior on width {
|
||||
enabled: !root.dragging
|
||||
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: knob
|
||||
width: 14
|
||||
height: 14
|
||||
radius: 7
|
||||
color: root.knobColor
|
||||
border.color: root.bar ? root.bar.background : "#101315"
|
||||
border.width: 2
|
||||
anchors.verticalCenter: track.verticalCenter
|
||||
x: Math.max(0, Math.min(track.width - width, track.width * root.progress - width / 2))
|
||||
scale: mouseArea.containsMouse || root.dragging ? 1.15 : 1.0
|
||||
|
||||
Behavior on x {
|
||||
enabled: !root.dragging
|
||||
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
Behavior on scale {
|
||||
NumberAnimation { duration: 110; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton
|
||||
|
||||
function valueFromX(x) {
|
||||
var clamped = Math.max(0, Math.min(track.width, x))
|
||||
var raw = root.minimum + (clamped / track.width) * root.range
|
||||
if (root.integer) raw = Math.round(raw)
|
||||
return Math.max(root.minimum, Math.min(root.maximum, raw))
|
||||
}
|
||||
|
||||
onPressed: function(mouse) {
|
||||
root.dragging = true
|
||||
var next = valueFromX(mouse.x)
|
||||
root.liveValue = next
|
||||
root.moved(next)
|
||||
}
|
||||
onPositionChanged: function(mouse) {
|
||||
if (!root.dragging) return
|
||||
var next = valueFromX(mouse.x)
|
||||
root.liveValue = next
|
||||
root.moved(next)
|
||||
}
|
||||
onReleased: function(mouse) {
|
||||
root.dragging = false
|
||||
root.released(root.liveValue)
|
||||
root.liveValue = root.value
|
||||
}
|
||||
onWheel: function(wheel) {
|
||||
var delta = wheel.angleDelta.y > 0 ? root.step : -root.step
|
||||
var next = Math.max(root.minimum, Math.min(root.maximum, root.liveValue + delta))
|
||||
if (root.integer) next = Math.round(next)
|
||||
root.liveValue = next
|
||||
root.moved(next)
|
||||
root.released(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
// Styled wrapper around Qt Quick Controls ToolTip. Drop-in: declare inside
|
||||
// the hovered item and bind `visible` to the hover state, e.g.
|
||||
// Common.PanelToolTip {
|
||||
// visible: mouse.containsMouse
|
||||
// text: "Forget network"
|
||||
// panelForeground: bar.foreground
|
||||
// panelBackground: bar.background
|
||||
// fontFamily: bar.fontFamily
|
||||
// }
|
||||
//
|
||||
// Property names are prefixed `panel*` to avoid clashing with ToolTip's
|
||||
// built-in `background`/`font` properties.
|
||||
ToolTip {
|
||||
id: root
|
||||
|
||||
property color panelForeground: "#cacccc"
|
||||
property color panelBackground: "#101315"
|
||||
property string fontFamily: "JetBrainsMono Nerd Font"
|
||||
property real fontSize: 11
|
||||
|
||||
delay: 400
|
||||
padding: 0
|
||||
|
||||
background: Rectangle {
|
||||
color: root.panelBackground
|
||||
border.color: root.panelForeground
|
||||
border.width: 1
|
||||
radius: 0
|
||||
opacity: 0.97
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
text: root.text
|
||||
color: root.panelForeground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: root.fontSize
|
||||
leftPadding: 10
|
||||
rightPadding: 10
|
||||
topPadding: 6
|
||||
bottomPadding: 6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import qs.Commons
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string text: ""
|
||||
property string iconText: ""
|
||||
property string tooltipText: ""
|
||||
property color foreground: "#cacccc"
|
||||
property color background: "transparent"
|
||||
property color hoverBackground: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.12)
|
||||
property color pressedBackground: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.22)
|
||||
property color tooltipBackground: "#101315"
|
||||
property color tooltipForeground: foreground
|
||||
property string fontFamily: "JetBrainsMono Nerd Font"
|
||||
property real fontSize: 12
|
||||
property real iconSize: 14
|
||||
property real horizontalPadding: 10
|
||||
property real verticalPadding: 6
|
||||
property bool active: false
|
||||
property bool leftAlign: false
|
||||
property color activeBackground: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.18)
|
||||
|
||||
// Keyboard cursor flag. When true, the pill renders the same fill + border
|
||||
// as a mouse hover — so j/k navigation and pointer hover are visually
|
||||
// indistinguishable. Default false; bind from a panel's cursor state.
|
||||
property bool hasCursor: false
|
||||
|
||||
// Tab-focusable form-button mode. Enables activeFocusOnTab and
|
||||
// Enter/Return/Space activation, and uses Style.focusBorderColor / FillColor
|
||||
// for the focus ring (distinct from the panel-cursor `hot` state). Set
|
||||
// true for settings buttons (Save, Cancel, Reset); leave false for the
|
||||
// panel-cursor pills (DNS picker, header actions).
|
||||
property bool focusable: false
|
||||
|
||||
// Persistent 1px foreground border at idle. Use for "primary" form buttons
|
||||
// (Save, Apply, + Add widget) so they read as buttons before the cursor
|
||||
// hits them. The hot/cursor state paints its own 1px border, so changing
|
||||
// this doesn't affect panel-pill visuals.
|
||||
property bool bordered: false
|
||||
|
||||
activeFocusOnTab: focusable
|
||||
Keys.onReturnPressed: if (focusable) root.clicked()
|
||||
Keys.onEnterPressed: if (focusable) root.clicked()
|
||||
Keys.onSpacePressed: if (focusable) root.clicked()
|
||||
|
||||
ToolTip {
|
||||
visible: root.tooltipText !== "" && mouseArea.containsMouse
|
||||
text: root.tooltipText
|
||||
delay: 400
|
||||
padding: 0
|
||||
|
||||
background: Rectangle {
|
||||
color: root.tooltipBackground
|
||||
border.color: root.tooltipForeground
|
||||
border.width: 1
|
||||
radius: 0
|
||||
opacity: 0.97
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
text: root.tooltipText
|
||||
color: root.tooltipForeground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: 11
|
||||
leftPadding: 10
|
||||
rightPadding: 10
|
||||
topPadding: 6
|
||||
bottomPadding: 6
|
||||
}
|
||||
}
|
||||
|
||||
signal clicked()
|
||||
signal rightClicked()
|
||||
|
||||
implicitWidth: row.implicitWidth + horizontalPadding * 2
|
||||
implicitHeight: row.implicitHeight + verticalPadding * 2
|
||||
radius: Style.cornerRadius
|
||||
|
||||
// Hot = mouse hover OR keyboard cursor. Pressed wins over both; otherwise
|
||||
// hot beats `active` (so a cursor on an already-active pill still reads
|
||||
// as hovered, matching CursorSurface's behaviour for navigable rows).
|
||||
readonly property bool hot: mouseArea.containsMouse || hasCursor
|
||||
|
||||
// Tab-focus styling wins over hot — the accent ring is the strongest
|
||||
// signal and shouldn't be masked by a hover landing on the focused item.
|
||||
readonly property bool _showFocusRing: focusable && activeFocus
|
||||
|
||||
color: mouseArea.pressed ? pressedBackground
|
||||
: _showFocusRing ? Style.focusFillColor
|
||||
: hot ? hoverBackground
|
||||
: (active ? activeBackground : background)
|
||||
border.width: _showFocusRing ? Style.focusBorderWidth
|
||||
: hot ? 1
|
||||
: (bordered ? 1 : 0)
|
||||
border.color: _showFocusRing ? Style.focusBorderColor : foreground
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: 120 }
|
||||
}
|
||||
|
||||
Row {
|
||||
id: row
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: root.leftAlign ? parent.left : undefined
|
||||
anchors.leftMargin: root.leftAlign ? root.horizontalPadding : 0
|
||||
anchors.horizontalCenter: root.leftAlign ? undefined : parent.horizontalCenter
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
visible: root.iconText !== ""
|
||||
text: root.iconText
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: root.iconSize
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.text !== ""
|
||||
text: root.text
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: root.fontSize
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: function(mouse) {
|
||||
if (root.focusable) root.forceActiveFocus()
|
||||
if (mouse.button === Qt.RightButton) root.rightClicked()
|
||||
else root.clicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import qs.Commons
|
||||
|
||||
PopupWindow {
|
||||
id: root
|
||||
|
||||
required property Item anchorItem
|
||||
required property QtObject bar
|
||||
property var owner: null
|
||||
property int margin: 10
|
||||
property int padding: 14
|
||||
property int contentWidth: 280
|
||||
property int contentHeight: 200
|
||||
property color borderColor: Color.popups.border
|
||||
property bool open: false
|
||||
property bool centerOnBar: false
|
||||
// "click" — uses HyprlandFocusGrab so clicking outside dismisses the popup.
|
||||
// "hover" — passive overlay; the owning widget controls open via hover.
|
||||
property string triggerMode: "click"
|
||||
|
||||
readonly property var coordinatorKey: owner || root
|
||||
readonly property var anchorWindow: anchorItem ? anchorItem.QsWindow.window : null
|
||||
readonly property bool containsMouse: cardHover.hovered
|
||||
|
||||
function closePopout() {
|
||||
if (owner && "closePopout" in owner) owner.closePopout()
|
||||
else root.open = false
|
||||
}
|
||||
|
||||
default property alias contentItem: contentHolder.children
|
||||
|
||||
visible: open || card.opacity > 0
|
||||
color: "transparent"
|
||||
implicitWidth: contentWidth
|
||||
implicitHeight: contentHeight
|
||||
|
||||
onOpenChanged: {
|
||||
if (!bar) return
|
||||
if (open) bar.requestPopout(coordinatorKey)
|
||||
else if (bar.activePopout === coordinatorKey) bar.releasePopout(coordinatorKey)
|
||||
}
|
||||
|
||||
// Outside-click dismissal via Hyprland's focus grab. While `active`, input
|
||||
// is routed only to the listed windows; clicking anywhere else clears the
|
||||
// grab and we close the popup. Skipped for hover-mode popups so the cursor
|
||||
// can move freely between the trigger and the popup.
|
||||
HyprlandFocusGrab {
|
||||
active: root.open && root.triggerMode === "click"
|
||||
windows: root.anchorWindow ? [root, root.anchorWindow] : [root]
|
||||
onCleared: root.closePopout()
|
||||
}
|
||||
|
||||
anchor {
|
||||
id: popupAnchor
|
||||
window: anchorItem ? anchorItem.QsWindow.window : null
|
||||
adjustment: PopupAdjustment.Slide
|
||||
edges: Edges.Top | Edges.Left
|
||||
gravity: Edges.Bottom | Edges.Right
|
||||
rect.width: 1
|
||||
rect.height: 1
|
||||
|
||||
onAnchoring: {
|
||||
if (!root.anchorItem || !root.bar) return
|
||||
|
||||
var target = root.anchorItem
|
||||
var popupWidth = root.implicitWidth
|
||||
var popupHeight = root.implicitHeight
|
||||
var localX = target.width / 2 - popupWidth / 2
|
||||
var localY = target.height + root.margin
|
||||
|
||||
if (root.bar.position === "bottom") {
|
||||
localY = -popupHeight - root.margin
|
||||
} else if (root.bar.position === "left") {
|
||||
localX = target.width + root.margin
|
||||
localY = target.height / 2 - popupHeight / 2
|
||||
} else if (root.bar.position === "right") {
|
||||
localX = -popupWidth - root.margin
|
||||
localY = target.height / 2 - popupHeight / 2
|
||||
}
|
||||
|
||||
var window = target.QsWindow.window
|
||||
if (!window) return
|
||||
|
||||
if (root.centerOnBar) {
|
||||
var cx = 0;
|
||||
var cy = 0;
|
||||
if (root.bar.position === "top" || root.bar.position === "bottom") {
|
||||
cx = window.width / 2 - popupWidth / 2
|
||||
cy = root.bar.position === "bottom" ? -popupHeight - root.margin : window.height + root.margin
|
||||
cx = Math.max(root.margin, Math.min(cx, window.width - popupWidth - root.margin))
|
||||
} else {
|
||||
cx = root.bar.position === "left" ? window.width + root.margin : -popupWidth - root.margin
|
||||
cy = window.height / 2 - popupHeight / 2
|
||||
cy = Math.max(root.margin, Math.min(cy, window.height - popupHeight - root.margin))
|
||||
}
|
||||
|
||||
popupAnchor.rect.x = Math.round(cx)
|
||||
popupAnchor.rect.y = Math.round(cy)
|
||||
return
|
||||
}
|
||||
|
||||
var point = window.contentItem.mapFromItem(target, localX, localY)
|
||||
|
||||
if (root.bar.position === "top" || root.bar.position === "bottom") {
|
||||
point.x = Math.max(root.margin, Math.min(point.x, window.width - popupWidth - root.margin))
|
||||
} else {
|
||||
point.y = Math.max(root.margin, Math.min(point.y, window.height - popupHeight - root.margin))
|
||||
}
|
||||
|
||||
popupAnchor.rect.x = Math.round(point.x)
|
||||
popupAnchor.rect.y = Math.round(point.y)
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
anchors.fill: parent
|
||||
color: Color.popups.background
|
||||
border.color: root.borderColor
|
||||
border.width: 2
|
||||
radius: Style.cornerRadius
|
||||
opacity: root.open ? 1.0 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
Item {
|
||||
id: contentHolder
|
||||
anchors.fill: parent
|
||||
anchors.margins: root.padding
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: cardHover
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls as QQC
|
||||
import qs.Commons
|
||||
|
||||
// Searchable single-select dropdown. Same trigger shape as Dropdown, but
|
||||
// the popup leads with an embedded TextField that filters the option
|
||||
// list in real time. Use for pickers with enough options that scanning
|
||||
// is friction (e.g. bar settings "+ Add widget").
|
||||
//
|
||||
// Filtering is case-insensitive substring against each option's label.
|
||||
// Options can be string[] or [{ value, label, description? }] — the same
|
||||
// shape Dropdown accepts. The filter clears whenever the popup closes.
|
||||
//
|
||||
// Keyboard: Tab to focus the trigger, Enter/Space opens (search focused
|
||||
// immediately). Down arrow from the search jumps to the first match;
|
||||
// Up from the first match returns to the search. Enter selects, Esc
|
||||
// closes (and clears the filter).
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property string value: ""
|
||||
property var options: []
|
||||
property string placeholderText: "Search..."
|
||||
property string emptyText: "No matches"
|
||||
property string triggerLabel: ""
|
||||
|
||||
property color foreground: Color.foreground
|
||||
property color background: Color.popups.background
|
||||
property color popupBorder: Color.popups.border
|
||||
property color accent: Color.accent
|
||||
property string fontFamily: "JetBrainsMono Nerd Font"
|
||||
property int rowHeight: 28
|
||||
property int popupRowHeight: 28
|
||||
property int popupMinHeight: 220
|
||||
property bool showLabel: true
|
||||
|
||||
// Panel-cursor flag. When true, the trigger renders the same focus ring
|
||||
// as Tab-focus so a panel's keyboard cursor lands here identically.
|
||||
// Emits `hovered(bool)` on pointer enter/leave so the panel can keep
|
||||
// its cursor state in sync with the mouse.
|
||||
property bool hasCursor: false
|
||||
|
||||
// popupOpen + open()/close()/toggle() let a parent panel know when the
|
||||
// dropdown owns keys (search field + result list are active) and
|
||||
// suspend its own keyCatcher so typing into the filter doesn't drive
|
||||
// the panel cursor.
|
||||
readonly property bool popupOpen: popup.opened
|
||||
function open() { popup.open() }
|
||||
function close() { popup.close() }
|
||||
function toggle() { popup.opened ? popup.close() : popup.open() }
|
||||
|
||||
signal changed(string value)
|
||||
signal hovered(bool isHovered)
|
||||
|
||||
function optionValue(o) {
|
||||
return (o && typeof o === "object") ? String(o.value) : String(o)
|
||||
}
|
||||
function optionLabel(o) {
|
||||
return (o && typeof o === "object") ? String(o.label) : String(o)
|
||||
}
|
||||
function optionDescription(o) {
|
||||
return (o && typeof o === "object" && o.description) ? String(o.description) : ""
|
||||
}
|
||||
function currentLabel() {
|
||||
for (var i = 0; i < options.length; i++) {
|
||||
if (optionValue(options[i]) === value) return optionLabel(options[i])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
property var filtered: options
|
||||
function recomputeFiltered() {
|
||||
var q = searchField.text.toLowerCase()
|
||||
if (!q) { filtered = options; return }
|
||||
var out = []
|
||||
for (var i = 0; i < options.length; i++) {
|
||||
var lbl = optionLabel(options[i]).toLowerCase()
|
||||
var desc = optionDescription(options[i]).toLowerCase()
|
||||
if (lbl.indexOf(q) !== -1 || desc.indexOf(q) !== -1) out.push(options[i])
|
||||
}
|
||||
filtered = out
|
||||
}
|
||||
|
||||
onOptionsChanged: recomputeFiltered()
|
||||
|
||||
implicitWidth: 260
|
||||
implicitHeight: showLabel && label !== "" ? 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
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: trigger
|
||||
width: parent.width
|
||||
height: root.rowHeight
|
||||
radius: Style.cornerRadius
|
||||
|
||||
readonly property bool _focused: trigger.activeFocus || root.hasCursor
|
||||
|
||||
color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b,
|
||||
trigger._focused ? 0.08 : 0.04)
|
||||
border.color: trigger._focused
|
||||
? Style.focusBorderColor
|
||||
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.4)
|
||||
border.width: trigger._focused ? Style.focusBorderWidth : 1
|
||||
|
||||
activeFocusOnTab: true
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: root.hovered(hovered)
|
||||
}
|
||||
|
||||
Keys.onPressed: function(event) {
|
||||
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|
||||
|| event.key === Qt.Key_Space || event.key === Qt.Key_Down) {
|
||||
popup.opened ? popup.close() : popup.open()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Escape && popup.opened) {
|
||||
popup.close(); event.accepted = true
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.right: chevron.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 6
|
||||
text: root.currentLabel() || root.triggerLabel || root.placeholderText
|
||||
color: (root.currentLabel() || root.triggerLabel) ? root.foreground : Qt.darker(root.foreground, 1.5)
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
id: chevron
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.rightMargin: 8
|
||||
text: ""
|
||||
color: Qt.darker(root.foreground, 1.2)
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
trigger.forceActiveFocus()
|
||||
popup.opened ? popup.close() : popup.open()
|
||||
}
|
||||
}
|
||||
|
||||
QQC.Popup {
|
||||
id: popup
|
||||
x: 0
|
||||
y: trigger.height + 2
|
||||
width: trigger.width
|
||||
implicitHeight: Math.max(root.popupMinHeight,
|
||||
Math.min(resultList.contentHeight + 50,
|
||||
root.popupRowHeight * 6 + 5 * 4 + 50))
|
||||
padding: 1
|
||||
focus: true
|
||||
|
||||
background: Rectangle {
|
||||
color: root.background
|
||||
border.color: root.popupBorder
|
||||
border.width: 1
|
||||
radius: Style.cornerRadius
|
||||
}
|
||||
|
||||
onOpened: {
|
||||
searchField.text = ""
|
||||
root.recomputeFiltered()
|
||||
Qt.callLater(function() { searchField.forceActiveFocus() })
|
||||
}
|
||||
onClosed: searchField.text = ""
|
||||
|
||||
contentItem: Column {
|
||||
spacing: 0
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 38
|
||||
|
||||
TextField {
|
||||
id: searchField
|
||||
anchors.fill: parent
|
||||
anchors.margins: 6
|
||||
placeholderText: root.placeholderText
|
||||
foreground: root.foreground
|
||||
accent: root.accent
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: 12
|
||||
|
||||
onTextChanged: {
|
||||
root.recomputeFiltered()
|
||||
if (resultList.count > 0) resultList.currentIndex = 0
|
||||
}
|
||||
|
||||
Keys.onPressed: function(event) {
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
popup.close(); event.accepted = true
|
||||
} else if (event.key === Qt.Key_Down) {
|
||||
if (resultList.count > 0) {
|
||||
resultList.currentIndex = 0
|
||||
resultList.forceActiveFocus()
|
||||
}
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||
if (resultList.count > 0) {
|
||||
resultList.currentIndex = 0
|
||||
resultList.selectCurrent()
|
||||
}
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10)
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: popup.height - 38 - 2 - 1
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: resultList.count === 0
|
||||
text: root.emptyText
|
||||
color: Qt.darker(root.foreground, 1.6)
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: resultList
|
||||
anchors.fill: parent
|
||||
spacing: 4
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.filtered
|
||||
currentIndex: -1
|
||||
keyNavigationEnabled: false
|
||||
|
||||
function selectCurrent() {
|
||||
if (currentIndex < 0 || currentIndex >= root.filtered.length) return
|
||||
var v = root.optionValue(root.filtered[currentIndex])
|
||||
root.value = v
|
||||
root.changed(v)
|
||||
popup.close()
|
||||
}
|
||||
|
||||
Keys.priority: Keys.BeforeItem
|
||||
Keys.onPressed: function(event) {
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
popup.close(); event.accepted = true
|
||||
} else if (event.key === Qt.Key_Down || event.text === "j") {
|
||||
if (resultList.currentIndex >= resultList.count - 1) {
|
||||
event.accepted = true; return
|
||||
}
|
||||
resultList.currentIndex = resultList.currentIndex + 1
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Up || event.text === "k") {
|
||||
if (resultList.currentIndex <= 0) {
|
||||
searchField.forceActiveFocus()
|
||||
event.accepted = true; return
|
||||
}
|
||||
resultList.currentIndex = resultList.currentIndex - 1
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||
resultList.selectCurrent(); event.accepted = true
|
||||
}
|
||||
}
|
||||
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: resultList.width
|
||||
height: Math.max(root.popupRowHeight, rowContent.implicitHeight + 12)
|
||||
color: index === resultList.currentIndex
|
||||
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.14)
|
||||
: "transparent"
|
||||
|
||||
Column {
|
||||
id: rowContent
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
text: root.optionLabel(modelData)
|
||||
color: index === resultList.currentIndex ? root.accent : root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
}
|
||||
Text {
|
||||
visible: text !== ""
|
||||
text: root.optionDescription(modelData)
|
||||
color: Qt.darker(root.foreground, 1.5)
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: 10
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onPositionChanged: resultList.currentIndex = parent.index
|
||||
onClicked: resultList.selectCurrent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import qs.Commons
|
||||
|
||||
// Single-line text input with the kit's focus + selection styling. Inherits
|
||||
// from Qt Quick Controls TextField so the underlying type's API (text,
|
||||
// placeholderText, accepted, editingFinished, validator, ...) is available
|
||||
// to callers without re-exposing each property.
|
||||
//
|
||||
// Defaults bind to qs.Commons.Color so a caller with no theme overrides
|
||||
// just works; foreground / accent / selectionTint can be overridden per
|
||||
// instance. Focus styling uses Style.focusBorderColor (the same accent
|
||||
// ring Toggle and ChoiceButton paint) so keyboard cursor and form-focus
|
||||
// chrome stay consistent across the shell.
|
||||
//
|
||||
// Sizing is driven by font.pixelSize + verticalPadding. The default 30px
|
||||
// implicitHeight fits dialog forms; inline callers (wifi's row-embedded
|
||||
// passphrase prompt) drop verticalPadding to match a 22-26px row.
|
||||
TextField {
|
||||
id: root
|
||||
|
||||
property color foreground: Color.foreground
|
||||
property color accent: Color.accent
|
||||
property color selectionTint: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.35)
|
||||
property bool password: false
|
||||
property real horizontalPadding: 10
|
||||
property real verticalPadding: 7
|
||||
|
||||
// Panel-cursor flag. When true (and the field isn't already focused),
|
||||
// the background paints the same accent ring as activeFocus so the
|
||||
// panel's keyboard cursor lands here identically to a mouse hover.
|
||||
// For mouse-enter/leave the consumer reads QQC TextField's inherited
|
||||
// `hovered` property (via onHoveredChanged) — we don't add a sibling
|
||||
// signal because the inherited property would shadow it.
|
||||
property bool hasCursor: false
|
||||
|
||||
readonly property bool _focused: activeFocus || hasCursor
|
||||
|
||||
echoMode: password ? TextInput.Password : TextInput.Normal
|
||||
color: foreground
|
||||
selectionColor: selectionTint
|
||||
selectedTextColor: foreground
|
||||
placeholderTextColor: Qt.darker(foreground, 1.6)
|
||||
|
||||
leftPadding: horizontalPadding
|
||||
rightPadding: horizontalPadding
|
||||
topPadding: verticalPadding
|
||||
bottomPadding: verticalPadding
|
||||
|
||||
background: Rectangle {
|
||||
color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b,
|
||||
root._focused ? 0.08 : 0.04)
|
||||
border.color: root._focused
|
||||
? Style.focusBorderColor
|
||||
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18)
|
||||
border.width: root._focused ? Style.focusBorderWidth : 1
|
||||
radius: Style.cornerRadius
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
// Labeled toggle row: title + optional description on the left, a switch
|
||||
// on the right. Clicking anywhere on the row emits `clicked()`; consumers
|
||||
// flip `checked` in response (the component is stateless about the actual
|
||||
// value so it composes cleanly with model-driven UI).
|
||||
//
|
||||
// Focus styling follows the shared Style tokens (accent border + tinted
|
||||
// fill on activeFocus) so keyboard nav looks the same here as on
|
||||
// ChoiceButton and other focusable Ui components.
|
||||
//
|
||||
// `rounded` auto-detects from Style.cornerRadius so the switch follows
|
||||
// the theme: pill shape on round-corners themes, square on sharp.
|
||||
// Callers can override per-instance.
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property string description: ""
|
||||
property bool checked: false
|
||||
|
||||
// Panel-cursor flag. Same role as PillButton.hasCursor / ChoiceButton.hasCursor:
|
||||
// panels with their own keyboard cursor bind this to drive the highlight
|
||||
// separately from activeFocus. Visuals match the activeFocus look (accent
|
||||
// border + tinted fill via Style tokens) so cursor and Tab focus read the same.
|
||||
property bool hasCursor: false
|
||||
|
||||
// Switch shape follows the theme by default: pill on round, square on sharp.
|
||||
// Override per-instance if a caller wants the opposite.
|
||||
property bool rounded: Style.cornerRadius > 0
|
||||
|
||||
property color foreground: Color.foreground
|
||||
property color accent: Color.accent
|
||||
property string fontFamily: "monospace"
|
||||
property real titleSize: 13
|
||||
property real descriptionSize: 10
|
||||
|
||||
signal clicked()
|
||||
signal hovered(bool isHovered)
|
||||
|
||||
activeFocusOnTab: true
|
||||
Keys.onReturnPressed: root.clicked()
|
||||
Keys.onEnterPressed: root.clicked()
|
||||
Keys.onSpacePressed: root.clicked()
|
||||
|
||||
implicitHeight: Math.max(54, content.implicitHeight + 18)
|
||||
implicitWidth: 240
|
||||
radius: Style.cornerRadius
|
||||
|
||||
color: activeFocus || hasCursor
|
||||
? Style.focusFillColor
|
||||
: (mouse.containsMouse ? Style.hotFill : Qt.rgba(foreground.r, foreground.g, foreground.b, 0.03))
|
||||
border.color: activeFocus || hasCursor
|
||||
? Style.focusBorderColor
|
||||
: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.12)
|
||||
border.width: activeFocus || hasCursor ? Style.focusBorderWidth : 1
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 100 } }
|
||||
|
||||
Row {
|
||||
id: content
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
spacing: 12
|
||||
|
||||
Column {
|
||||
width: parent.width - track.width - parent.spacing
|
||||
spacing: 3
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
text: root.label
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: root.titleSize
|
||||
font.bold: true
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.description !== ""
|
||||
text: root.description
|
||||
color: Qt.darker(root.foreground, 1.5)
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: root.descriptionSize
|
||||
wrapMode: Text.WordWrap
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: track
|
||||
width: 42
|
||||
height: 22
|
||||
radius: root.rounded ? height / 2 : 0
|
||||
color: root.checked
|
||||
? Qt.rgba(root.accent.r, root.accent.g, root.accent.b, 0.35)
|
||||
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12)
|
||||
border.color: root.checked
|
||||
? root.accent
|
||||
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.28)
|
||||
border.width: 1
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 120 } }
|
||||
|
||||
Rectangle {
|
||||
width: 16
|
||||
height: 16
|
||||
radius: root.rounded ? 8 : 0
|
||||
x: root.checked ? track.width - width - 3 : 3
|
||||
y: 3
|
||||
color: root.checked ? root.accent : Qt.darker(root.foreground, 1.25)
|
||||
|
||||
Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } }
|
||||
Behavior on color { ColorAnimation { duration: 120 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.clicked()
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: root.hovered(hovered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import QtQuick
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var bar: null
|
||||
property string text: ""
|
||||
property string fontFamily: bar ? bar.fontFamily : "JetBrainsMono Nerd Font"
|
||||
property real fontSize: 12
|
||||
property color foreground: bar ? bar.foreground : "#cacccc"
|
||||
property color activeColor: bar ? bar.urgent : "#a55555"
|
||||
property bool active: false
|
||||
property real horizontalMargin: 8.5
|
||||
property real rightExtraMargin: 0
|
||||
property real verticalPadding: 6
|
||||
property real fixedWidth: -1
|
||||
property real fixedHeight: -1
|
||||
property real textRotation: 0
|
||||
property bool keepSpace: false
|
||||
property string tooltipText: ""
|
||||
|
||||
signal pressed(int button)
|
||||
signal wheelMoved(int delta)
|
||||
|
||||
readonly property bool vertical: bar ? bar.vertical : false
|
||||
readonly property int barSize: bar ? bar.barSize : 26
|
||||
|
||||
visible: text !== "" || keepSpace
|
||||
opacity: text === "" ? 0 : 1
|
||||
implicitWidth: fixedWidth > 0 ? fixedWidth : (vertical ? barSize : Math.max(12, label.implicitWidth + horizontalMargin * 2 + rightExtraMargin))
|
||||
implicitHeight: fixedHeight > 0 ? fixedHeight : (vertical ? Math.max(12, label.implicitHeight + verticalPadding * 2) : barSize)
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
Text {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
anchors.horizontalCenterOffset: root.vertical ? 0 : -root.rightExtraMargin / 2
|
||||
text: root.text
|
||||
color: root.active ? root.activeColor : root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pointSize: root.fontSize * 0.75
|
||||
renderType: Text.NativeRendering
|
||||
rotation: root.textRotation
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: 160 }
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onEntered: if (root.bar) root.bar.showTooltip(root, root.tooltipText)
|
||||
onExited: if (root.bar) root.bar.hideTooltip(root)
|
||||
onClicked: function(mouse) {
|
||||
if (root.bar) root.bar.hideTooltip(root)
|
||||
root.pressed(mouse.button)
|
||||
}
|
||||
onWheel: function(wheel) { root.wheelMoved(wheel.angleDelta.y) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
module qs.Ui
|
||||
|
||||
ChoiceButton 1.0 ChoiceButton.qml
|
||||
CursorPill 1.0 CursorPill.qml
|
||||
CursorSurface 1.0 CursorSurface.qml
|
||||
Dropdown 1.0 Dropdown.qml
|
||||
KeyboardPanel 1.0 KeyboardPanel.qml
|
||||
NumberField 1.0 NumberField.qml
|
||||
PanelActionButton 1.0 PanelActionButton.qml
|
||||
PanelKeyCatcher 1.0 PanelKeyCatcher.qml
|
||||
PanelSectionHeader 1.0 PanelSectionHeader.qml
|
||||
PanelSeparator 1.0 PanelSeparator.qml
|
||||
PanelSlider 1.0 PanelSlider.qml
|
||||
PanelToolTip 1.0 PanelToolTip.qml
|
||||
PillButton 1.0 PillButton.qml
|
||||
PopupCard 1.0 PopupCard.qml
|
||||
SearchableDropdown 1.0 SearchableDropdown.qml
|
||||
TextField 1.0 TextField.qml
|
||||
Toggle 1.0 Toggle.qml
|
||||
WidgetButton 1.0 WidgetButton.qml
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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`.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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" }
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
|
||||
image_dirs=${1:-}
|
||||
cache_dir=${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/image-selector
|
||||
index_file="$cache_dir/index.tsv"
|
||||
|
||||
mkdir -p "$cache_dir"
|
||||
|
||||
thumbnail_for() {
|
||||
local image="$1"
|
||||
local signature hash thumbnail legacy_hash
|
||||
|
||||
signature=$(stat -Lc '%s:%Y' "$image") || return
|
||||
hash=$(awk -F '\t' -v path="$image" -v sig="$signature" '$1 == path && $2 == sig { print $3; exit }' "$index_file" 2>/dev/null)
|
||||
|
||||
if [[ -z $hash ]]; then
|
||||
hash=$(printf '%s\t%s' "$image" "$signature" | md5sum | cut -d ' ' -f 1)
|
||||
fi
|
||||
|
||||
thumbnail="$cache_dir/$hash.jpg"
|
||||
|
||||
if [[ ! -f $thumbnail ]]; then
|
||||
# Older on-demand picker code keyed fallback thumbnails by file content.
|
||||
# Keep finding those if a user still has them cached.
|
||||
legacy_hash=$(md5sum "$image" 2>/dev/null | cut -d ' ' -f 1)
|
||||
[[ -n $legacy_hash && -f $cache_dir/$legacy_hash.jpg ]] && thumbnail="$cache_dir/$legacy_hash.jpg"
|
||||
fi
|
||||
|
||||
if [[ -f $thumbnail ]]; then
|
||||
printf '%s' "$thumbnail"
|
||||
else
|
||||
printf '%s' "$image"
|
||||
fi
|
||||
}
|
||||
|
||||
while IFS= read -r dir; do
|
||||
[[ -n $dir && -d $dir ]] || continue
|
||||
find -L "$dir" -maxdepth 1 -type f \
|
||||
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) \
|
||||
-print0 2>/dev/null
|
||||
done <<<"$image_dirs" | sort -z | while IFS= read -r -d '' image; do
|
||||
thumbnail=$(thumbnail_for "$image")
|
||||
[[ -n $thumbnail ]] || continue
|
||||
printf '%s\t%s\n' "$image" "$thumbnail"
|
||||
done
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
if pgrep -x hypridle >/dev/null; then
|
||||
echo '{"text": ""}'
|
||||
else
|
||||
echo '{"text": "", "tooltip": "Idle lock disabled", "class": "active"}'
|
||||
fi
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
state=$(omarchy-shell notifications isDnd 2>/dev/null || echo off)
|
||||
if [[ $state == "on" ]]; then
|
||||
echo '{"text": "", "tooltip": "Notifications silenced", "class": "active"}'
|
||||
else
|
||||
echo '{"text": ""}'
|
||||
fi
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
if pgrep -f "^gpu-screen-recorder" >/dev/null; then
|
||||
echo '{"text": "", "tooltip": "Stop recording", "class": "active"}'
|
||||
else
|
||||
echo '{"text": ""}'
|
||||
fi
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
icon=$(omarchy-weather-icon 2>/dev/null)
|
||||
|
||||
if [[ -n $icon ]]; then
|
||||
icon=$(printf '%s' "$icon" | sed 's/["\\]/\\&/g')
|
||||
printf '{"text":"%s"}\n' "$icon"
|
||||
else
|
||||
printf '{"text":"","class":"unavailable"}\n'
|
||||
fi
|
||||
@@ -0,0 +1,54 @@
|
||||
import QtQuick
|
||||
|
||||
// Instance, not a singleton — instantiated once by shell.qml and injected into
|
||||
// plugins that need to read or extend the widget catalogue. Relative-path
|
||||
// singleton imports were creating per-importer instances which prevented the
|
||||
// bar settings panel from seeing what the bar registered.
|
||||
QtObject {
|
||||
id: registry
|
||||
|
||||
// { widgetId: { component: Component, metadata: var } }
|
||||
property var widgets: ({})
|
||||
property int revision: 0
|
||||
|
||||
signal changed()
|
||||
|
||||
function register(id, component, metadata) {
|
||||
var key = String(id)
|
||||
if (!key) return
|
||||
var next = {}
|
||||
for (var k in widgets) next[k] = widgets[k]
|
||||
next[key] = { component: component, metadata: metadata || {} }
|
||||
widgets = next
|
||||
revision++
|
||||
changed()
|
||||
}
|
||||
|
||||
function unregister(id) {
|
||||
var key = String(id)
|
||||
if (!widgets[key]) return
|
||||
var next = {}
|
||||
for (var k in widgets) if (k !== key) next[k] = widgets[k]
|
||||
widgets = next
|
||||
revision++
|
||||
changed()
|
||||
}
|
||||
|
||||
function componentFor(id) {
|
||||
var entry = widgets[String(id)]
|
||||
return entry ? entry.component : null
|
||||
}
|
||||
|
||||
function metadataFor(id) {
|
||||
var entry = widgets[String(id)]
|
||||
return entry ? entry.metadata : null
|
||||
}
|
||||
|
||||
function availableIds() {
|
||||
return Object.keys(widgets)
|
||||
}
|
||||
|
||||
function has(id) {
|
||||
return widgets[String(id)] !== undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
// Instance, not a singleton — see BarWidgetRegistry for rationale.
|
||||
QtObject {
|
||||
id: registry
|
||||
|
||||
property string home: Quickshell.env("HOME")
|
||||
property string pluginsDir: home + "/.config/omarchy/plugins"
|
||||
|
||||
// Set by shell.qml at startup so we can also scan bundled first-party plugins.
|
||||
property string firstPartyDir: ""
|
||||
|
||||
// Wired by shell.qml so the registry can read the canonical shell.json
|
||||
// without owning file IO itself. shellConfigProvider returns the current
|
||||
// effective shell config; shellConfigMutator takes a function that receives
|
||||
// a deep-cloned config it can mutate in place and persists the result.
|
||||
property var shellConfigProvider: null
|
||||
property var shellConfigMutator: null
|
||||
|
||||
// { pluginId: manifest } — manifests have __sourceDir and __isFirstParty stamped in.
|
||||
property var installedPlugins: ({})
|
||||
property int registryRevision: 0
|
||||
property bool scanning: false
|
||||
|
||||
signal pluginsChanged()
|
||||
signal pluginLoadFailed(string id, string error)
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function fileUrl(path) {
|
||||
return "file://" + String(path).split("/").map(encodeURIComponent).join("/")
|
||||
}
|
||||
|
||||
function isSafeEntryPoint(value) {
|
||||
if (typeof value !== "string" || value.length === 0) return false
|
||||
if (value.charAt(0) === "/") return false
|
||||
if (value.indexOf("..") !== -1) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function validateManifest(manifest, sourcePath) {
|
||||
if (!isPlainObject(manifest)) {
|
||||
console.warn("PluginRegistry: manifest is not an object at " + sourcePath)
|
||||
return null
|
||||
}
|
||||
if (manifest.schemaVersion !== 1) {
|
||||
console.warn("PluginRegistry: unsupported schemaVersion at " + sourcePath)
|
||||
return null
|
||||
}
|
||||
var required = ["id", "name", "version", "kinds", "entryPoints"]
|
||||
for (var i = 0; i < required.length; i++) {
|
||||
if (manifest[required[i]] === undefined) {
|
||||
console.warn("PluginRegistry: missing required field '" + required[i] + "' at " + sourcePath)
|
||||
return null
|
||||
}
|
||||
}
|
||||
var id = String(manifest.id)
|
||||
if (!id || id.indexOf("/") !== -1 || id.indexOf("..") !== -1 || id.charAt(0) === "/") {
|
||||
console.warn("PluginRegistry: invalid plugin id '" + id + "' at " + sourcePath)
|
||||
return null
|
||||
}
|
||||
if (!Array.isArray(manifest.kinds) || manifest.kinds.length === 0) {
|
||||
console.warn("PluginRegistry: kinds must be a non-empty array at " + sourcePath)
|
||||
return null
|
||||
}
|
||||
if (!isPlainObject(manifest.entryPoints)) {
|
||||
console.warn("PluginRegistry: entryPoints must be an object at " + sourcePath)
|
||||
return null
|
||||
}
|
||||
// Every entry point must be a relative path inside the plugin's source
|
||||
// directory. Reject the whole manifest if anything looks like an attempt
|
||||
// to escape the plugin's sandbox.
|
||||
for (var key in manifest.entryPoints) {
|
||||
if (!isSafeEntryPoint(manifest.entryPoints[key])) {
|
||||
console.warn("PluginRegistry: unsafe entryPoint '" + key + "'='"
|
||||
+ manifest.entryPoints[key] + "' at " + sourcePath)
|
||||
return null
|
||||
}
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
function entryPointUrl(manifest, kind) {
|
||||
if (!isPlainObject(manifest)) return ""
|
||||
var ep = manifest.entryPoints ? manifest.entryPoints[kind] : null
|
||||
if (!ep) return ""
|
||||
var dir = manifest.__sourceDir || ""
|
||||
if (!dir) return ""
|
||||
// Defense in depth: even after validateManifest, confirm the resolved
|
||||
// path stays inside the plugin's sourceDir.
|
||||
var resolved = dir.replace(/\/$/, "") + "/" + String(ep)
|
||||
var expectedPrefix = dir.replace(/\/$/, "") + "/"
|
||||
if (resolved.indexOf(expectedPrefix) !== 0) {
|
||||
console.warn("PluginRegistry: entry point escapes sourceDir: " + resolved)
|
||||
return ""
|
||||
}
|
||||
return fileUrl(resolved)
|
||||
}
|
||||
|
||||
// Enabled = the plugin id is referenced somewhere in shell.json. That can
|
||||
// be either a layout entry inside `bar.layout.*` (bar widgets) or a top-level
|
||||
// entry in `plugins[]` (panels, overlays, services).
|
||||
//
|
||||
// Special cases (implicitly always enabled, no shell.json entry needed):
|
||||
// - plugins whose `kinds` contains "bar" are mounted directly by the host.
|
||||
// - first-party plugins are shell infrastructure (settings,
|
||||
// image-picker, ...). Requiring users to add them to plugins[] just to
|
||||
// summon them was a footgun: a stock shell.json with `plugins: []` would
|
||||
// silently make `omarchy launch bar-settings` a no-op.
|
||||
function isEnabled(id) {
|
||||
var key = String(id)
|
||||
var manifest = installedPlugins[key]
|
||||
if (manifest) {
|
||||
if (Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar") !== -1) return true
|
||||
if (manifest.__isFirstParty) return true
|
||||
}
|
||||
var config = shellConfigProvider ? shellConfigProvider() : null
|
||||
return findEntryLocation(config, key).found
|
||||
}
|
||||
|
||||
function findEntryLocation(config, id) {
|
||||
if (!isPlainObject(config)) return { found: false }
|
||||
if (isPlainObject(config.bar) && isPlainObject(config.bar.layout)) {
|
||||
var sections = ["left", "center", "right"]
|
||||
for (var s = 0; s < sections.length; s++) {
|
||||
var arr = config.bar.layout[sections[s]]
|
||||
if (!Array.isArray(arr)) continue
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
if (arr[i] && arr[i].id === id) return { found: true, kind: "bar", section: sections[s], index: i }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(config.plugins)) {
|
||||
for (var j = 0; j < config.plugins.length; j++) {
|
||||
if (config.plugins[j] && config.plugins[j].id === id) return { found: true, kind: "plugin", index: j }
|
||||
}
|
||||
}
|
||||
return { found: false }
|
||||
}
|
||||
|
||||
// Adding a plugin places it in the right section based on its declared
|
||||
// kinds. Bar widgets default to the right section; panels/overlays/menus/
|
||||
// services go into the plugins[] array.
|
||||
function setEnabled(id, value) {
|
||||
var key = String(id)
|
||||
if (!shellConfigMutator) {
|
||||
console.warn("PluginRegistry.setEnabled called before shellConfigMutator wired")
|
||||
return
|
||||
}
|
||||
var manifest = installedPlugins[key]
|
||||
var isBarWidget = manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar-widget") !== -1
|
||||
shellConfigMutator(function(config) {
|
||||
// Ensure shape exists.
|
||||
if (!isPlainObject(config.bar)) config.bar = { layout: { left: [], center: [], right: [] } }
|
||||
if (!isPlainObject(config.bar.layout)) config.bar.layout = { left: [], center: [], right: [] }
|
||||
if (!Array.isArray(config.plugins)) config.plugins = []
|
||||
var location = findEntryLocation(config, key)
|
||||
if (value && !location.found) {
|
||||
var entry = { id: key }
|
||||
if (isBarWidget) {
|
||||
if (!Array.isArray(config.bar.layout.right)) config.bar.layout.right = []
|
||||
config.bar.layout.right.push(entry)
|
||||
} else {
|
||||
config.plugins.push(entry)
|
||||
}
|
||||
} else if (!value && location.found) {
|
||||
if (location.kind === "bar") {
|
||||
config.bar.layout[location.section].splice(location.index, 1)
|
||||
} else {
|
||||
config.plugins.splice(location.index, 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
registryRevision++
|
||||
pluginsChanged()
|
||||
}
|
||||
|
||||
function manifestsOfKind(kind) {
|
||||
var result = []
|
||||
for (var id in installedPlugins) {
|
||||
var m = installedPlugins[id]
|
||||
if (m && Array.isArray(m.kinds) && m.kinds.indexOf(kind) !== -1) result.push(m)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- scanning
|
||||
|
||||
// Output format produced by the rescan script:
|
||||
// ===<kind>::<absolute-source-dir>===
|
||||
// ... raw manifest.json content ...
|
||||
// === EOM ===
|
||||
// (repeating for every manifest found)
|
||||
function parseScanOutput(text) {
|
||||
var lines = String(text || "").split("\n")
|
||||
var firstParty = {}
|
||||
var thirdParty = {}
|
||||
var currentSource = null
|
||||
var currentKind = null
|
||||
var currentJson = []
|
||||
|
||||
function flush() {
|
||||
if (!currentSource) return
|
||||
var raw = currentJson.join("\n").trim()
|
||||
try {
|
||||
var manifest = JSON.parse(raw)
|
||||
manifest.__sourceDir = currentSource
|
||||
manifest.__isFirstParty = (currentKind === "firstparty")
|
||||
var validated = validateManifest(manifest, currentSource + "/manifest.json")
|
||||
if (validated) {
|
||||
if (currentKind === "firstparty") firstParty[validated.id] = validated
|
||||
else thirdParty[validated.id] = validated
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("PluginRegistry: bad manifest at " + currentSource + ": " + e)
|
||||
}
|
||||
currentSource = null
|
||||
currentKind = null
|
||||
currentJson = []
|
||||
}
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i]
|
||||
var startMatch = line.match(/^===([a-z]+)::(.+)===$/)
|
||||
if (startMatch) {
|
||||
flush()
|
||||
currentKind = startMatch[1]
|
||||
currentSource = startMatch[2].replace(/\/$/, "")
|
||||
currentJson = []
|
||||
continue
|
||||
}
|
||||
if (line === "=== EOM ===") {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
if (currentSource) currentJson.push(line)
|
||||
}
|
||||
flush()
|
||||
|
||||
var merged = {}
|
||||
for (var fk in firstParty) merged[fk] = firstParty[fk]
|
||||
// Third-party plugins never shadow a first-party one with the same id.
|
||||
for (var tk in thirdParty) {
|
||||
if (firstParty[tk]) {
|
||||
console.warn("PluginRegistry: plugin " + tk
|
||||
+ " rejected: id collides with first-party plugin")
|
||||
continue
|
||||
}
|
||||
merged[tk] = thirdParty[tk]
|
||||
}
|
||||
|
||||
installedPlugins = merged
|
||||
registryRevision++
|
||||
scanning = false
|
||||
pluginsChanged()
|
||||
}
|
||||
|
||||
property Process scanProcess: Process {
|
||||
onExited: function(exitCode) {
|
||||
var output = scanStdout.text || ""
|
||||
registry.parseScanOutput(output)
|
||||
}
|
||||
stdout: StdioCollector {
|
||||
id: scanStdout
|
||||
waitForEnd: true
|
||||
}
|
||||
}
|
||||
|
||||
property Process initProcess: Process {
|
||||
onExited: registry.rescan()
|
||||
}
|
||||
|
||||
function rescan() {
|
||||
if (scanning) return
|
||||
scanning = true
|
||||
// $0 = first-party dir, $1 = third-party dir. Some bash versions need the explicit -- separator.
|
||||
var script = ""
|
||||
+ "scan() { local dir=\"$1\"; local kind=\"$2\"; "
|
||||
+ " [[ -d \"$dir\" ]] || return 0; "
|
||||
+ " for sub in \"$dir\"/*/; do "
|
||||
+ " [[ -f \"$sub/manifest.json\" ]] || continue; "
|
||||
+ " printf '===%s::%s===\\n' \"$kind\" \"$sub\"; "
|
||||
+ " cat \"$sub/manifest.json\"; "
|
||||
+ " printf '\\n=== EOM ===\\n'; "
|
||||
+ " done; "
|
||||
+ "}; "
|
||||
+ "scan \"$0\" firstparty; "
|
||||
+ "scan \"$1\" thirdparty"
|
||||
scanProcess.command = ["bash", "-c", script, registry.firstPartyDir, registry.pluginsDir]
|
||||
scanProcess.running = true
|
||||
}
|
||||
|
||||
function ensureUserDir() {
|
||||
initProcess.command = ["bash", "-c", "mkdir -p \"$0\"", registry.pluginsDir]
|
||||
initProcess.running = true
|
||||
}
|
||||
|
||||
Component.onCompleted: ensureUserDir()
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"version": 1,
|
||||
"bar": {
|
||||
"position": "top",
|
||||
"transparent": false,
|
||||
"centerAnchor": "calendar",
|
||||
"layout": {
|
||||
"left": [
|
||||
{
|
||||
"id": "omarchy"
|
||||
},
|
||||
{
|
||||
"id": "workspaces"
|
||||
}
|
||||
],
|
||||
"center": [
|
||||
{
|
||||
"id": "calendar",
|
||||
"format": "dddd HH:mm",
|
||||
"formatAlt": "dd MMMM 'W'ww yyyy",
|
||||
"verticalFormat": "HH\n\u2014\nmm"
|
||||
},
|
||||
{
|
||||
"id": "weatherFlyout"
|
||||
},
|
||||
{
|
||||
"id": "update"
|
||||
},
|
||||
{
|
||||
"id": "voxtype"
|
||||
},
|
||||
{
|
||||
"id": "screenRecording"
|
||||
},
|
||||
{
|
||||
"id": "idleInhibitor"
|
||||
},
|
||||
{
|
||||
"id": "notifications"
|
||||
}
|
||||
],
|
||||
"right": [
|
||||
{
|
||||
"id": "tray"
|
||||
},
|
||||
{
|
||||
"id": "bluetoothPanel"
|
||||
},
|
||||
{
|
||||
"id": "networkPanel"
|
||||
},
|
||||
{
|
||||
"id": "audioPanel"
|
||||
},
|
||||
{
|
||||
"id": "monitorPanel"
|
||||
},
|
||||
{
|
||||
"id": "battery"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"id": "omarchy.osd"
|
||||
}
|
||||
]
|
||||
}
|
||||
+658
@@ -0,0 +1,658 @@
|
||||
import QtQuick
|
||||
import QtQml.Models
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
import "plugins/bar"
|
||||
import "services"
|
||||
|
||||
ShellRoot {
|
||||
id: shell
|
||||
|
||||
// Shared service instances. Plugins receive these via property injection
|
||||
// rather than re-importing them as singletons — relative-path imports do
|
||||
// not share singleton state, which silently leaves consumers with their
|
||||
// own empty copies.
|
||||
property PluginRegistry pluginRegistry: PluginRegistry { }
|
||||
property BarWidgetRegistry barWidgetRegistry: BarWidgetRegistry { }
|
||||
|
||||
property string home: Quickshell.env("HOME")
|
||||
|
||||
// The omarchy-shell host is the long-running entry point. Plugins live in
|
||||
// sibling directories under plugins/. Most child code (the bar, future
|
||||
// plugins) accept omarchyPath as a required property; resolve it once here
|
||||
// from the shellDir so we don't depend on OMARCHY_PATH being set in the env.
|
||||
function deriveOmarchyPath() {
|
||||
var env = Quickshell.env("OMARCHY_PATH")
|
||||
if (env) return env
|
||||
var dir = String(Quickshell.shellDir || "")
|
||||
while (dir.length > 1 && dir.charAt(dir.length - 1) === "/")
|
||||
dir = dir.substring(0, dir.length - 1)
|
||||
var suffix = "/shell"
|
||||
if (dir.length > suffix.length && dir.substring(dir.length - suffix.length) === suffix)
|
||||
return dir.substring(0, dir.length - suffix.length)
|
||||
return home + "/.local/share/omarchy"
|
||||
}
|
||||
property string omarchyPath: deriveOmarchyPath()
|
||||
readonly property string shellPath: omarchyPath + "/shell"
|
||||
readonly property string firstPartyPluginsDir: shellPath + "/plugins"
|
||||
readonly property string defaultsPath: shellPath + "/shell-defaults.json"
|
||||
readonly property string userConfigPath: home + "/.config/omarchy/shell.json"
|
||||
|
||||
// Bundled fallback so the shell can start even when shell-defaults.json is
|
||||
// missing or unreadable. The bar config here mirrors the on-disk defaults
|
||||
// closely enough to render a usable bar; not authoritative.
|
||||
readonly property var builtinShellConfig: ({
|
||||
version: 1,
|
||||
bar: {
|
||||
position: "top",
|
||||
transparent: false,
|
||||
fontFamily: "JetBrainsMono Nerd Font",
|
||||
centerAnchor: "calendar",
|
||||
layout: {
|
||||
left: [{ id: "omarchy" }, { id: "workspaces" }],
|
||||
center: [{ id: "calendar", format: "dddd HH:mm" }],
|
||||
right: [{ id: "audioPanel" }]
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
{ id: "omarchy.settings" },
|
||||
{ id: "omarchy.image-picker" },
|
||||
{ id: "omarchy.osd" }
|
||||
]
|
||||
})
|
||||
|
||||
property var defaultsConfig: builtinShellConfig
|
||||
property var shellConfig: builtinShellConfig
|
||||
property bool suppressUserReload: false
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function applyShellConfig() {
|
||||
// Decide which source is canonical: a valid user shell.json overrides
|
||||
// defaults entirely; otherwise fall back to defaults. We do not deep-merge.
|
||||
var defaults = isPlainObject(defaultsConfig) ? defaultsConfig : builtinShellConfig
|
||||
var user = null
|
||||
var userText = userConfigFile.text() || ""
|
||||
if (userText.trim()) {
|
||||
try {
|
||||
var parsed = JSON.parse(userText)
|
||||
if (isPlainObject(parsed) && parsed.version === 1) user = parsed
|
||||
else if (isPlainObject(parsed)) console.warn("shell.json missing version: 1, using defaults")
|
||||
} catch (e) {
|
||||
console.warn("shell.json parse failed, using defaults:", e)
|
||||
}
|
||||
}
|
||||
shellConfig = user || defaults
|
||||
}
|
||||
|
||||
function loadDefaults(raw) {
|
||||
var text = String(raw || "").trim()
|
||||
if (!text) {
|
||||
defaultsConfig = builtinShellConfig
|
||||
applyShellConfig()
|
||||
return
|
||||
}
|
||||
try {
|
||||
var parsed = JSON.parse(text)
|
||||
if (isPlainObject(parsed) && parsed.version === 1) defaultsConfig = parsed
|
||||
else defaultsConfig = builtinShellConfig
|
||||
} catch (e) {
|
||||
console.warn("shell-defaults.json parse failed, using builtin:", e)
|
||||
defaultsConfig = builtinShellConfig
|
||||
}
|
||||
applyShellConfig()
|
||||
}
|
||||
|
||||
function persistShellConfig(nextConfig) {
|
||||
suppressUserReload = true
|
||||
var payload = JSON.parse(JSON.stringify(nextConfig))
|
||||
payload.version = 1
|
||||
shellConfig = payload
|
||||
userConfigFile.setText(JSON.stringify(payload, null, 2) + "\n")
|
||||
}
|
||||
|
||||
readonly property var barConfig: shellConfig && isPlainObject(shellConfig.bar) ? shellConfig.bar : builtinShellConfig.bar
|
||||
readonly property var pluginsConfig: shellConfig && Array.isArray(shellConfig.plugins) ? shellConfig.plugins : []
|
||||
|
||||
FileView {
|
||||
id: defaultsFile
|
||||
path: shell.defaultsPath
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onLoaded: shell.loadDefaults(text())
|
||||
onLoadFailed: function(error) {
|
||||
console.warn("shell-defaults load failed: " + error + " path=" + shell.defaultsPath)
|
||||
shell.loadDefaults("")
|
||||
}
|
||||
onFileChanged: reload()
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: userConfigFile
|
||||
path: shell.userConfigPath
|
||||
watchChanges: true
|
||||
atomicWrites: true
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
if (shell.suppressUserReload) {
|
||||
shell.suppressUserReload = false
|
||||
return
|
||||
}
|
||||
shell.applyShellConfig()
|
||||
}
|
||||
onLoadFailed: function(error) { shell.applyShellConfig() }
|
||||
onFileChanged: reload()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
console.log("omarchy-shell paths",
|
||||
"omarchyPath=" + shell.omarchyPath,
|
||||
"shellDir=" + Quickshell.shellDir,
|
||||
"firstPartyPluginsDir=" + shell.firstPartyPluginsDir,
|
||||
"defaultsPath=" + shell.defaultsPath,
|
||||
"userConfigPath=" + shell.userConfigPath)
|
||||
pluginRegistry.firstPartyDir = shell.firstPartyPluginsDir
|
||||
pluginRegistry.shellConfigProvider = function() { return shell.shellConfig }
|
||||
pluginRegistry.shellConfigMutator = function(mutate) { shell.mutateShellConfig(mutate) }
|
||||
// PluginRegistry.ensureUserDir() runs in its own Component.onCompleted and
|
||||
// chains rescan() once the directory exists. We also kick a scan here in
|
||||
// case the user dir already existed at startup.
|
||||
pluginRegistry.rescan()
|
||||
shell._syncFirstPartyServices()
|
||||
}
|
||||
|
||||
function mutateShellConfig(mutator) {
|
||||
var copy = JSON.parse(JSON.stringify(shellConfig || builtinShellConfig))
|
||||
mutator(copy)
|
||||
persistShellConfig(copy)
|
||||
}
|
||||
|
||||
// Exposed as a property so child plugins (notifications, future panels)
|
||||
// can read barSize/barHidden/position to anchor relative to the bar.
|
||||
property alias bar: bar
|
||||
|
||||
Bar {
|
||||
id: bar
|
||||
omarchyPath: shell.omarchyPath
|
||||
barWidgetRegistry: shell.barWidgetRegistry
|
||||
barConfig: shell.barConfig
|
||||
shell: shell
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- first-party services
|
||||
//
|
||||
// Generic loader for any first-party plugin that declares kind "service".
|
||||
// The notification daemon is the first user; future first-party services
|
||||
// (background updaters, idle inhibitor, etc.) plug in here.
|
||||
Item {
|
||||
id: firstPartyServiceHost
|
||||
visible: false
|
||||
}
|
||||
|
||||
property var _firstPartyServices: ({})
|
||||
|
||||
function firstPartyServiceFor(pluginId) {
|
||||
return _firstPartyServices[String(pluginId)] || null
|
||||
}
|
||||
|
||||
function ensureFirstPartyService(pluginId) {
|
||||
var key = String(pluginId)
|
||||
if (_firstPartyServices[key]) return _firstPartyServices[key]
|
||||
var manifest = pluginRegistry && pluginRegistry.installedPlugins
|
||||
? pluginRegistry.installedPlugins[key] : null
|
||||
if (!manifest || !manifest.__isFirstParty) return null
|
||||
if (!Array.isArray(manifest.kinds) || manifest.kinds.indexOf("service") === -1) return null
|
||||
if (!manifest.entryPoints || !manifest.entryPoints.service) return null
|
||||
var url = pluginRegistry.entryPointUrl(manifest, "service")
|
||||
if (!url) return null
|
||||
|
||||
var comp = Qt.createComponent(url, Component.PreferSynchronous)
|
||||
function finalize() {
|
||||
if (comp.status !== Component.Ready) {
|
||||
console.warn("first-party service load failed for " + key + ": " + comp.errorString())
|
||||
return
|
||||
}
|
||||
var inst = comp.createObject(firstPartyServiceHost, {
|
||||
omarchyPath: shell.omarchyPath,
|
||||
shell: shell,
|
||||
manifest: manifest
|
||||
})
|
||||
if (!inst) {
|
||||
console.warn("first-party service createObject returned null for", key)
|
||||
return
|
||||
}
|
||||
var snext = ({})
|
||||
for (var sk in _firstPartyServices) snext[sk] = _firstPartyServices[sk]
|
||||
snext[key] = inst
|
||||
_firstPartyServices = snext
|
||||
}
|
||||
if (comp.status === Component.Loading) {
|
||||
comp.statusChanged.connect(finalize)
|
||||
return null
|
||||
}
|
||||
finalize()
|
||||
return _firstPartyServices[key] || null
|
||||
}
|
||||
|
||||
function _syncFirstPartyServices() {
|
||||
if (!pluginRegistry || !pluginRegistry.installedPlugins) return
|
||||
var plugins = pluginRegistry.installedPlugins
|
||||
for (var id in plugins) {
|
||||
var m = plugins[id]
|
||||
if (!m || !m.__isFirstParty) continue
|
||||
if (!Array.isArray(m.kinds) || m.kinds.indexOf("service") === -1) continue
|
||||
if (!m.entryPoints || !m.entryPoints.service) continue
|
||||
if (!pluginRegistry.isEnabled(id)) continue
|
||||
if (_firstPartyServices[id]) continue
|
||||
ensureFirstPartyService(id)
|
||||
}
|
||||
// Drop services for plugins that have been disabled or removed.
|
||||
for (var existingId in _firstPartyServices) {
|
||||
var stillThere = plugins[existingId]
|
||||
var stillEnabled = stillThere && pluginRegistry.isEnabled(existingId)
|
||||
if (stillThere && stillEnabled) continue
|
||||
var inst = _firstPartyServices[existingId]
|
||||
if (inst && typeof inst.destroy === "function") inst.destroy()
|
||||
var next = ({})
|
||||
for (var k in _firstPartyServices) if (k !== existingId) next[k] = _firstPartyServices[k]
|
||||
_firstPartyServices = next
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: shell.pluginRegistry
|
||||
function onPluginsChanged() { shell._syncFirstPartyServices() }
|
||||
}
|
||||
|
||||
// Writes inline settings to a bar layout entry or top-level plugin entry in
|
||||
// shell.json. moduleName is the entry id; settings is the merged plugin
|
||||
// state. Returns true if anything actually changed. Compute the proposed
|
||||
// new shellConfig in a local clone, and only persist if anything actually
|
||||
// changed so reactive bindings do not dirty shell.json unnecessarily.
|
||||
function updateEntryInline(moduleName, settings) {
|
||||
var stripped = String(moduleName)
|
||||
var copy = JSON.parse(JSON.stringify(shellConfig || builtinShellConfig))
|
||||
if (!isPlainObject(copy.bar)) copy.bar = { layout: { left: [], center: [], right: [] } }
|
||||
if (!isPlainObject(copy.bar.layout)) copy.bar.layout = { left: [], center: [], right: [] }
|
||||
if (!Array.isArray(copy.plugins)) copy.plugins = []
|
||||
|
||||
var sections = ["left", "center", "right"]
|
||||
var foundInLayout = false
|
||||
var dirty = false
|
||||
for (var s = 0; s < sections.length; s++) {
|
||||
var arr = copy.bar.layout[sections[s]] || []
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
if (arr[i] && arr[i].id === stripped) {
|
||||
var next = { id: stripped }
|
||||
for (var k in settings) if (k !== "id") next[k] = settings[k]
|
||||
if (JSON.stringify(arr[i]) !== JSON.stringify(next)) {
|
||||
arr[i] = next
|
||||
dirty = true
|
||||
}
|
||||
foundInLayout = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!foundInLayout) {
|
||||
for (var j = 0; j < copy.plugins.length; j++) {
|
||||
if (copy.plugins[j] && copy.plugins[j].id === stripped) {
|
||||
var pnext = { id: stripped }
|
||||
for (var pk in settings) if (pk !== "id") pnext[pk] = settings[pk]
|
||||
if (JSON.stringify(copy.plugins[j]) !== JSON.stringify(pnext)) {
|
||||
copy.plugins[j] = pnext
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!dirty) return false
|
||||
persistShellConfig(copy)
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- on-demand panels
|
||||
|
||||
// openPanelIds is a plain object treated as a set. A plugin id maps to
|
||||
// `true` while the panel is summoned; deleting the key (well, building a new
|
||||
// object without it) hides it. Reassigning the whole object is required for
|
||||
// QML to notice the change.
|
||||
property var openPanelIds: ({})
|
||||
|
||||
function isPanelOpen(id) { return openPanelIds[id] === true }
|
||||
|
||||
// Pending payloads to deliver to a plugin's open() once its loader resolves.
|
||||
// Keyed by plugin id; the value is an array so two summon() calls before
|
||||
// the Loader resolves both reach the plugin in arrival order rather than
|
||||
// the second clobbering the first.
|
||||
property var pendingPayloads: ({})
|
||||
|
||||
function summon(pluginId, payloadJson) {
|
||||
var id = String(pluginId || "")
|
||||
if (!id) return false
|
||||
var plugins = shell.pluginRegistry.installedPlugins
|
||||
if (!plugins[id]) {
|
||||
console.warn("summon: unknown plugin", id)
|
||||
return false
|
||||
}
|
||||
// A disabled plugin has no Loader, so setting openPanelIds would only
|
||||
// produce an invisible "open" state that toggle() then has to unwind.
|
||||
// Tell the caller plainly instead of silently no-op'ing.
|
||||
if (!shell.pluginRegistry.isEnabled(id)) {
|
||||
console.warn("summon: plugin not enabled, not summoning:", id)
|
||||
return false
|
||||
}
|
||||
var next = ({})
|
||||
for (var k in openPanelIds) next[k] = openPanelIds[k]
|
||||
next[id] = true
|
||||
openPanelIds = next
|
||||
|
||||
// Stash payload so the Loader.onLoaded handler can hand it to open().
|
||||
var pending = ({})
|
||||
for (var p in pendingPayloads) pending[p] = pendingPayloads[p].slice()
|
||||
var queue = pending[id] || []
|
||||
queue.push(payloadJson || "")
|
||||
pending[id] = queue
|
||||
pendingPayloads = pending
|
||||
|
||||
// If the plugin is keepLoaded and already mounted, deliver immediately.
|
||||
deliverIfLoaded(id)
|
||||
return true
|
||||
}
|
||||
|
||||
function hide(pluginId) {
|
||||
var id = String(pluginId || "")
|
||||
if (!id) return false
|
||||
invokeIfLoaded(id, "close", null)
|
||||
if (!openPanelIds[id]) return true
|
||||
var next = ({})
|
||||
for (var k in openPanelIds) if (k !== id) next[k] = openPanelIds[k]
|
||||
openPanelIds = next
|
||||
return true
|
||||
}
|
||||
|
||||
function toggle(pluginId, payloadJson) {
|
||||
var id = String(pluginId || "")
|
||||
return openPanelIds[id] ? hide(id) : summon(id, payloadJson)
|
||||
}
|
||||
|
||||
// Map of pluginId -> Loader, populated by the Instantiator delegate below.
|
||||
property var panelLoaders: ({})
|
||||
|
||||
function registerPanelLoader(pluginId, loader) {
|
||||
var next = ({})
|
||||
for (var k in panelLoaders) next[k] = panelLoaders[k]
|
||||
next[pluginId] = loader
|
||||
panelLoaders = next
|
||||
deliverIfLoaded(pluginId)
|
||||
}
|
||||
|
||||
function unregisterPanelLoader(pluginId) {
|
||||
if (!panelLoaders[pluginId]) return
|
||||
var next = ({})
|
||||
for (var k in panelLoaders) if (k !== pluginId) next[k] = panelLoaders[k]
|
||||
panelLoaders = next
|
||||
}
|
||||
|
||||
function deliverIfLoaded(pluginId) {
|
||||
var loader = panelLoaders[pluginId]
|
||||
if (!loader || !loader.item) return
|
||||
var queue = pendingPayloads[pluginId]
|
||||
if (!Array.isArray(queue) || queue.length === 0) return
|
||||
if (typeof loader.item.open === "function") {
|
||||
for (var i = 0; i < queue.length; i++) {
|
||||
try { loader.item.open(queue[i]) } catch (e) {
|
||||
console.warn("plugin " + pluginId + " open() threw:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
var next = ({})
|
||||
for (var k in pendingPayloads) if (k !== pluginId) next[k] = pendingPayloads[k].slice()
|
||||
pendingPayloads = next
|
||||
}
|
||||
|
||||
function invokeIfLoaded(pluginId, method, arg) {
|
||||
var loader = panelLoaders[pluginId]
|
||||
if (!loader || !loader.item) return
|
||||
if (typeof loader.item[method] !== "function") return
|
||||
try { loader.item[method](arg) } catch (e) {
|
||||
console.warn("plugin " + pluginId + " " + method + "() threw:", e)
|
||||
}
|
||||
}
|
||||
|
||||
// One Loader per discoverable panel/overlay/menu plugin. Active when the
|
||||
// host marks it open. The Loader holds onto the instance while active so the
|
||||
// plugin's FloatingWindow + state survive between summons within a session.
|
||||
property var panelEntries: []
|
||||
|
||||
function computePanelEntries() {
|
||||
var out = []
|
||||
var plugins = shell.pluginRegistry.installedPlugins
|
||||
var panelKinds = ["panel", "overlay", "menu"]
|
||||
for (var id in plugins) {
|
||||
var m = plugins[id]
|
||||
if (!m || !Array.isArray(m.kinds)) continue
|
||||
var matched = false
|
||||
for (var i = 0; i < panelKinds.length; i++)
|
||||
if (m.kinds.indexOf(panelKinds[i]) !== -1) { matched = true; break }
|
||||
if (!matched) continue
|
||||
if (!shell.pluginRegistry.isEnabled(id)) continue
|
||||
var kind = m.kinds.indexOf("panel") !== -1 ? "panel"
|
||||
: (m.kinds.indexOf("overlay") !== -1 ? "overlay" : "menu")
|
||||
out.push({ id: id, manifest: m, kind: kind, keepLoaded: m.keepLoaded === true })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: shell.pluginRegistry
|
||||
function onPluginsChanged() { shell.panelEntries = shell.computePanelEntries() }
|
||||
}
|
||||
|
||||
Instantiator {
|
||||
model: shell.panelEntries
|
||||
active: true
|
||||
|
||||
delegate: QtObject {
|
||||
id: panelEntry
|
||||
required property var modelData
|
||||
readonly property string pluginId: modelData.id
|
||||
readonly property var manifest: modelData.manifest
|
||||
readonly property string entryKind: modelData.kind
|
||||
readonly property bool keepLoaded: modelData.keepLoaded === true
|
||||
readonly property string sourceUrl: shell.pluginRegistry.entryPointUrl(manifest, entryKind)
|
||||
|
||||
property Loader panelLoader: Loader {
|
||||
source: panelEntry.sourceUrl
|
||||
active: panelEntry.sourceUrl !== "" && (panelEntry.keepLoaded || shell.openPanelIds[panelEntry.pluginId] === true)
|
||||
asynchronous: true
|
||||
onLoaded: {
|
||||
if (!item) return
|
||||
if ("omarchyPath" in item) item.omarchyPath = shell.omarchyPath
|
||||
if ("shell" in item) item.shell = shell
|
||||
if ("manifest" in item) item.manifest = panelEntry.manifest
|
||||
if ("barWidgetRegistry" in item) item.barWidgetRegistry = shell.barWidgetRegistry
|
||||
if ("pluginRegistry" in item) item.pluginRegistry = shell.pluginRegistry
|
||||
// First-party plugins that pair a panel UI with a service entry
|
||||
// (e.g. omarchy.notifications) read shared state off `service`. We
|
||||
// hand them the matching service instance if one was loaded.
|
||||
if ("service" in item) item.service = shell.firstPartyServiceFor(panelEntry.pluginId)
|
||||
shell.registerPanelLoader(panelEntry.pluginId, this)
|
||||
}
|
||||
onStatusChanged: {
|
||||
if (status === Loader.Error) {
|
||||
// Loader.errorString() reflects the source-load failure even when
|
||||
// sourceComponent is null. Surface both so the user sees something
|
||||
// actionable instead of a panel that silently refuses to open.
|
||||
var detail = errorString && errorString() ? errorString() : ""
|
||||
if (!detail && sourceComponent) detail = sourceComponent.errorString()
|
||||
console.warn("panel plugin " + panelEntry.pluginId + " failed to load:", detail)
|
||||
shell.hide(panelEntry.pluginId)
|
||||
}
|
||||
}
|
||||
Component.onDestruction: shell.unregisterPanelLoader(panelEntry.pluginId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- plugin loader
|
||||
|
||||
// Mirror plugin registry state into BarWidgetRegistry whenever it changes.
|
||||
// Each enabled plugin with kind "bar-widget" gets a Component created from
|
||||
// its manifest entry point and registered under its plain manifest id.
|
||||
// First-party widget ids (calendar, weather, etc.) are short and don't
|
||||
// collide with namespaced plugin ids, so we don't need a separate
|
||||
// "plugin:" namespace anymore.
|
||||
Connections {
|
||||
target: shell.pluginRegistry
|
||||
function onPluginsChanged() { shell.syncPluginWidgets() }
|
||||
}
|
||||
|
||||
property var pluginWidgetComponents: ({})
|
||||
|
||||
function syncPluginWidgets() {
|
||||
var plugins = shell.pluginRegistry.installedPlugins
|
||||
var seen = ({})
|
||||
|
||||
for (var pluginId in plugins) {
|
||||
var manifest = plugins[pluginId]
|
||||
if (!manifest || !manifest.kinds || manifest.kinds.indexOf("bar-widget") === -1) continue
|
||||
if (!shell.pluginRegistry.isEnabled(pluginId)) continue
|
||||
|
||||
var registryKey = String(manifest.id)
|
||||
seen[registryKey] = true
|
||||
|
||||
// Already loaded with matching source — leave it alone.
|
||||
var existing = pluginWidgetComponents[registryKey]
|
||||
var url = shell.pluginRegistry.entryPointUrl(manifest, "barWidget")
|
||||
if (!url) {
|
||||
console.warn("Plugin " + manifest.id + " has no barWidget entry point")
|
||||
continue
|
||||
}
|
||||
if (existing && existing.url === url && shell.barWidgetRegistry.has(registryKey)) continue
|
||||
|
||||
var meta = manifest.barWidget || {}
|
||||
meta = {
|
||||
displayName: meta.displayName || manifest.name,
|
||||
description: meta.description || manifest.description,
|
||||
category: meta.category || "Plugin",
|
||||
allowMultiple: meta.allowMultiple === true,
|
||||
defaults: meta.defaults || {},
|
||||
schema: meta.schema || [],
|
||||
pluginId: manifest.id,
|
||||
source: "plugin"
|
||||
}
|
||||
|
||||
loadPluginWidget(registryKey, url, meta)
|
||||
}
|
||||
|
||||
// Drop registrations for plugins that are no longer present or enabled.
|
||||
var allIds = shell.barWidgetRegistry.availableIds()
|
||||
for (var i = 0; i < allIds.length; i++) {
|
||||
var id = allIds[i]
|
||||
if (!pluginWidgetComponents[id]) continue
|
||||
if (!seen[id]) {
|
||||
shell.barWidgetRegistry.unregister(id)
|
||||
var next = ({})
|
||||
for (var k in pluginWidgetComponents) if (k !== id) next[k] = pluginWidgetComponents[k]
|
||||
pluginWidgetComponents = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadPluginWidget(registryKey, url, meta) {
|
||||
var comp = Qt.createComponent(url, Component.Asynchronous)
|
||||
function finalize() {
|
||||
if (comp.status === Component.Ready) {
|
||||
shell.barWidgetRegistry.register(registryKey, comp, meta)
|
||||
var next = ({})
|
||||
for (var k in pluginWidgetComponents) next[k] = pluginWidgetComponents[k]
|
||||
next[registryKey] = { url: url, component: comp }
|
||||
pluginWidgetComponents = next
|
||||
} else if (comp.status === Component.Error) {
|
||||
console.warn("Plugin widget " + registryKey + " failed: " + comp.errorString())
|
||||
shell.pluginRegistry.pluginLoadFailed(registryKey, comp.errorString())
|
||||
}
|
||||
}
|
||||
if (comp.status === Component.Loading) {
|
||||
comp.statusChanged.connect(finalize)
|
||||
} else {
|
||||
finalize()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- shell IPC
|
||||
|
||||
IpcHandler {
|
||||
target: "shell"
|
||||
|
||||
function ping(): string {
|
||||
return "ok"
|
||||
}
|
||||
|
||||
function applyTheme(colorsB64: string, shellB64: string): string {
|
||||
var colorsRaw = ""
|
||||
var shellRaw = ""
|
||||
try { colorsRaw = Qt.atob(String(colorsB64 || "")) } catch (e) { colorsRaw = "" }
|
||||
try { shellRaw = Qt.atob(String(shellB64 || "")) } catch (e2) { shellRaw = "" }
|
||||
NoctaliaCommons.Color.resumeThemeReloads()
|
||||
NoctaliaCommons.Color.loadColors(colorsRaw)
|
||||
NoctaliaCommons.Color.loadShell(shellRaw)
|
||||
return "ok"
|
||||
}
|
||||
|
||||
function suspendThemeReloads(): string {
|
||||
NoctaliaCommons.Color.suspendThemeReloads()
|
||||
return "ok"
|
||||
}
|
||||
|
||||
function reloadTheme(): string {
|
||||
NoctaliaCommons.Color.reloadTheme()
|
||||
return "ok"
|
||||
}
|
||||
|
||||
function rescanPlugins(): void {
|
||||
shell.pluginRegistry.rescan()
|
||||
}
|
||||
|
||||
function setPluginEnabled(id: string, enabled: string): void {
|
||||
shell.pluginRegistry.setEnabled(id, enabled === "true")
|
||||
}
|
||||
|
||||
function listPlugins(): string {
|
||||
var out = []
|
||||
var plugins = shell.pluginRegistry.installedPlugins
|
||||
for (var id in plugins) {
|
||||
out.push({
|
||||
id: id,
|
||||
name: plugins[id].name,
|
||||
kinds: plugins[id].kinds,
|
||||
enabled: shell.pluginRegistry.isEnabled(id),
|
||||
firstParty: !!plugins[id].__isFirstParty
|
||||
})
|
||||
}
|
||||
return JSON.stringify(out)
|
||||
}
|
||||
|
||||
// Returns the effective shell.json content as JSON. Useful for debugging
|
||||
// and for CLI tools that want to inspect the merged state without
|
||||
// re-implementing the load logic.
|
||||
function listShellConfig(): string {
|
||||
return JSON.stringify(shell.shellConfig || {})
|
||||
}
|
||||
|
||||
function summon(id: string, payloadJson: string): string {
|
||||
return shell.summon(id, payloadJson) ? "ok" : "unknown"
|
||||
}
|
||||
|
||||
function hide(id: string): void {
|
||||
shell.hide(id)
|
||||
}
|
||||
|
||||
function toggle(id: string, payloadJson: string): void {
|
||||
shell.toggle(id, payloadJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Ui
|
||||
|
||||
// Generic schema-driven settings form. The host panel passes:
|
||||
// - schema: array of { key, label, type, defaultValue?, options?, min?, max?, step?, description? }
|
||||
// - entry: current widget entry object (set by the loader); fields override defaults
|
||||
// - signal fieldChanged(string key, var value) — emitted on user edits
|
||||
//
|
||||
// Supported types: boolean, enum, integer, number, string, path, command, color.
|
||||
Column {
|
||||
id: root
|
||||
|
||||
signal fieldChanged(string key, var value)
|
||||
|
||||
property var schema: []
|
||||
property var entry: ({})
|
||||
property color foregroundColor: "#cacccc"
|
||||
property string fontFamilyName: "JetBrainsMono Nerd Font"
|
||||
spacing: 10
|
||||
width: parent ? parent.width : 0
|
||||
|
||||
function currentValue(field) {
|
||||
if (entry && entry[field.key] !== undefined) return entry[field.key]
|
||||
if (field.defaultValue !== undefined) return field.defaultValue
|
||||
switch (field.type) {
|
||||
case "boolean": return false
|
||||
case "integer":
|
||||
case "number": return field.min !== undefined ? field.min : 0
|
||||
case "enum": return field.options && field.options.length > 0 ? field.options[0] : ""
|
||||
default: return ""
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.schema
|
||||
|
||||
Column {
|
||||
required property var modelData
|
||||
width: root.width
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
text: modelData && modelData.label ? modelData.label : (modelData && modelData.key ? modelData.key : "")
|
||||
color: Qt.darker(root.foregroundColor, 1.3)
|
||||
font.family: root.fontFamilyName
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
visible: text !== ""
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: !!(modelData && modelData.description)
|
||||
text: modelData ? (modelData.description || "") : ""
|
||||
color: Qt.darker(root.foregroundColor, 1.6)
|
||||
font.family: root.fontFamilyName
|
||||
font.pixelSize: 10
|
||||
wrapMode: Text.WordWrap
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
Loader {
|
||||
sourceComponent: {
|
||||
if (!modelData || !modelData.type) return stringField
|
||||
switch (String(modelData.type)) {
|
||||
case "boolean": return booleanField
|
||||
case "enum": return enumField
|
||||
case "integer": return integerField
|
||||
case "number": return numberField
|
||||
case "color":
|
||||
case "string":
|
||||
case "path":
|
||||
case "command":
|
||||
default: return stringField
|
||||
}
|
||||
}
|
||||
onLoaded: if (item && "fieldKey" in item) {
|
||||
item.fieldKey = modelData.key
|
||||
item.field = modelData
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: stringField
|
||||
TextField {
|
||||
property string fieldKey: ""
|
||||
property var field: ({})
|
||||
width: parent.width
|
||||
foreground: root.foregroundColor
|
||||
font.family: root.fontFamilyName
|
||||
font.pixelSize: 12
|
||||
text: root.currentValue(field) === undefined ? "" : String(root.currentValue(field))
|
||||
onEditingFinished: if (fieldKey) root.fieldChanged(fieldKey, text)
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: booleanField
|
||||
CheckBox {
|
||||
property string fieldKey: ""
|
||||
property var field: ({})
|
||||
font.family: root.fontFamilyName
|
||||
font.pixelSize: 12
|
||||
text: field && field.label ? "" : ""
|
||||
checked: !!root.currentValue(field)
|
||||
onToggled: if (fieldKey) root.fieldChanged(fieldKey, checked)
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: enumField
|
||||
ComboBox {
|
||||
property string fieldKey: ""
|
||||
property var field: ({})
|
||||
width: parent.width
|
||||
font.family: root.fontFamilyName
|
||||
font.pixelSize: 12
|
||||
model: field && field.options ? field.options : []
|
||||
currentIndex: {
|
||||
var v = root.currentValue(field)
|
||||
for (var i = 0; i < (field && field.options ? field.options.length : 0); i++)
|
||||
if (field.options[i] === v) return i
|
||||
return 0
|
||||
}
|
||||
onActivated: function(index) {
|
||||
if (fieldKey && field && field.options) root.fieldChanged(fieldKey, field.options[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: integerField
|
||||
SpinBox {
|
||||
property string fieldKey: ""
|
||||
property var field: ({})
|
||||
from: field && field.min !== undefined ? field.min : 0
|
||||
to: field && field.max !== undefined ? field.max : 9999
|
||||
stepSize: field && field.step !== undefined ? field.step : 1
|
||||
value: {
|
||||
var v = root.currentValue(field)
|
||||
var n = typeof v === "number" ? v : parseInt(String(v || 0), 10)
|
||||
return isFinite(n) ? n : 0
|
||||
}
|
||||
onValueModified: if (fieldKey) root.fieldChanged(fieldKey, value)
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: numberField
|
||||
Row {
|
||||
property string fieldKey: ""
|
||||
property var field: ({})
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
property real currentNumber: {
|
||||
var v = root.currentValue(field)
|
||||
var n = typeof v === "number" ? v : parseFloat(String(v || 0))
|
||||
return isFinite(n) ? n : 0
|
||||
}
|
||||
Slider {
|
||||
id: slider
|
||||
width: parent.width - readout.width - 8
|
||||
from: field && field.min !== undefined ? field.min : 0
|
||||
to: field && field.max !== undefined ? field.max : 1
|
||||
stepSize: field && field.step !== undefined ? field.step : 0.01
|
||||
value: parent.currentNumber
|
||||
onMoved: if (parent.fieldKey) root.fieldChanged(parent.fieldKey, value)
|
||||
}
|
||||
Text {
|
||||
id: readout
|
||||
text: slider.value.toFixed(2)
|
||||
color: root.foregroundColor
|
||||
font.family: root.fontFamilyName
|
||||
font.pixelSize: 11
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: !root.schema || root.schema.length === 0
|
||||
text: "No settings."
|
||||
color: Qt.darker(root.foregroundColor, 1.5)
|
||||
font.family: root.fontFamilyName
|
||||
font.pixelSize: 11
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user