Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cf03d4529 | ||
|
|
2a716dac9e | ||
|
|
719ef2f38e | ||
|
|
9ba224d776 | ||
|
|
8b59b78d9f | ||
|
|
e6b4d3c1a1 | ||
|
|
70d8d32ee2 | ||
|
|
c37ca3baee | ||
|
|
bd5d030d91 |
@@ -152,11 +152,14 @@ case "$cmd" in
|
||||
|
||||
# 2.4) Run flatpak updates (user then system)
|
||||
flatpak update -y
|
||||
sudo flatpak update
|
||||
sudo flatpak update -y
|
||||
|
||||
# 2.5) Optional firmware via fwupd
|
||||
if $firmware; then
|
||||
sudo fwupdmgr refresh
|
||||
# fwupdmgr exits non-zero when metadata is already current -- that is
|
||||
# not an error, but under 'set -e' it would abort the script before
|
||||
# 'fwupdmgr update' ever runs.
|
||||
sudo fwupdmgr refresh || true
|
||||
sudo fwupdmgr update
|
||||
fi
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ than presenting inert Hyprland controls.
|
||||
| Caffeine | Replaced by a real logind inhibitor in quick settings |
|
||||
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
|
||||
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
|
||||
| Wi-Fi QR | Deliberately omitted; it is not useful enough to justify another credential-reading surface |
|
||||
| Wi-Fi QR | Replaced by an on-demand QR-code sharing flow in Control Center's Wi-Fi panel; the code is generated only while shown and written to tmpfs, never persisted |
|
||||
| GSConnect | Replaced by capability-aware KDE Connect phone continuity in Control Center; the paired iPhone exposes file, clipboard, and Ring actions when reachable |
|
||||
| Home Assistant | Replaced by secure favourites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup |
|
||||
| Custom Hot Corners Extended | No action was configured, so there is no behavior to port |
|
||||
|
||||
@@ -247,7 +247,7 @@ The mental model is unchanged from Forge:
|
||||
| `SUPER + SHIFT + H/J/K/L` | Move window |
|
||||
| `SUPER + CTRL + H/J/K/L` | Swap window |
|
||||
| `SUPER + SHIFT + Y/O` · `B/M` | Wider · narrower |
|
||||
| `SUPER + SHIFT + I/U` · `P/N` | Taller · shorter |
|
||||
| `SUPER + SHIFT + I/U` · `,/N` | Taller · shorter |
|
||||
| `SUPER + [` / `]` / `=` | Shrink / expand / reset split |
|
||||
| `SUPER + Q` | Close |
|
||||
| `SUPER + U` | Fullscreen |
|
||||
|
||||
@@ -23,8 +23,16 @@ hl.on("hyprland.start", function()
|
||||
hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=Hyprland")
|
||||
hl.exec_cmd("systemctl --user start hyprland-session.target")
|
||||
|
||||
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock.
|
||||
hl.exec_cmd("systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service")
|
||||
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock. All four
|
||||
-- carry `ConditionEnvironment=WAYLAND_DISPLAY`, and hl.exec_cmd fires
|
||||
-- commands without waiting for them to finish, so the dbus-update call
|
||||
-- above racing this one is not safe to assume complete -- a lost race
|
||||
-- leaves the Condition unmet and the unit silently never starts (exit 0,
|
||||
-- no error). hypridle is the only listener for the logind Lock signal,
|
||||
-- so that failure mode is "lock-session goes to nobody". Re-import
|
||||
-- synchronously in the same shell invocation first so the Condition
|
||||
-- always sees it, regardless of how the dbus-update call above scheduled.
|
||||
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service")
|
||||
|
||||
-- The shell: bar, dock, overview, quick settings, notifications, capture.
|
||||
-- No systemd unit ships with quickshell, so it runs as a compositor child.
|
||||
|
||||
@@ -192,7 +192,10 @@ bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative =
|
||||
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
-- SUPER+SHIFT+P was double-bound with the colour picker above; moved to
|
||||
-- Comma, which continues the bottom-row cluster (B/M/N) this axis already
|
||||
-- uses rather than landing on an arbitrary free key.
|
||||
bind(mod .. " + SHIFT + Comma", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
|
||||
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
|
||||
@@ -223,6 +226,17 @@ bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description
|
||||
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
|
||||
-- moving right past the last workspace creates a new one, and moving left from
|
||||
-- the first clamps instead of wrapping.
|
||||
|
||||
-- Behaviour for the relative/cyclic binds below. These are Hyprland's own
|
||||
-- `binds:` options -- not part of general/dwindle -- and have no other home
|
||||
-- in the config, so they are read here rather than in looks.lua.
|
||||
hl.config({
|
||||
binds = {
|
||||
workspace_back_and_forth = prefs.get("workspaceBackAndForth", false),
|
||||
allow_workspace_cycles = prefs.get("allowWorkspaceCycles", false),
|
||||
},
|
||||
})
|
||||
|
||||
bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
||||
bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
||||
bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
-- Look and feel -- Tokyo Night Moon
|
||||
--
|
||||
-- Colours here must stay in sync with quickshell/config/Theme.qml.
|
||||
-- accent #82aaff borders / focus
|
||||
-- accent user-selectable, see `accents` below -- blue (#82aaff) ships
|
||||
-- bg #222436 base
|
||||
--
|
||||
-- Performance note: every animation below is event-driven. Nothing uses the
|
||||
@@ -14,6 +14,30 @@
|
||||
|
||||
local prefs = require("prefs")
|
||||
|
||||
-- Mirrors config/Theme.qml's `accents` map. Lua has no import path into a QML
|
||||
-- singleton, so the hex pairs are restated here -- just the four hex strings
|
||||
-- each named accent needs, not the labels, which stay UI-only.
|
||||
local accents = {
|
||||
blue = { dark = "82aaff", darkSecondary = "b172b0", light = "2e7de9", lightSecondary = "9854f1" },
|
||||
orchid = { dark = "c099ff", darkSecondary = "fca7ea", light = "7847bd", lightSecondary = "9854f1" },
|
||||
teal = { dark = "86e1fc", darkSecondary = "82aaff", light = "007197", lightSecondary = "2e7de9" },
|
||||
green = { dark = "c3e88d", darkSecondary = "86e1fc", light = "587539", lightSecondary = "007197" },
|
||||
amber = { dark = "ffc777", darkSecondary = "ff966c", light = "8c6c3e", lightSecondary = "b15c00" },
|
||||
orange = { dark = "ff966c", darkSecondary = "ff757f", light = "b15c00", lightSecondary = "c64343" },
|
||||
rose = { dark = "ff757f", darkSecondary = "c099ff", light = "f52a65", lightSecondary = "9854f1" },
|
||||
slate = { dark = "828bb8", darkSecondary = "82aaff", light = "6172b0", lightSecondary = "2e7de9" },
|
||||
}
|
||||
|
||||
-- The accent pair a fresh session or `hyprctl reload` starts from.
|
||||
-- services/ColorScheme.qml overwrites this live, from the same Theme.accents
|
||||
-- data, once the shell settles (~1.2s after startup -- see its `settle`
|
||||
-- Timer). This table exists only so the compositor is never observably blue
|
||||
-- for a non-blue accent during the gap before that first live apply.
|
||||
local accentScheme = prefs.get("colorScheme", "dark")
|
||||
local accentPair = accents[prefs.get("accentName", "blue")] or accents.blue
|
||||
local accentStart = accentScheme == "light" and accentPair.light or accentPair.dark
|
||||
local accentEnd = accentScheme == "light" and accentPair.lightSecondary or accentPair.darkSecondary
|
||||
|
||||
hl.config({
|
||||
general = {
|
||||
gaps_in = prefs.get("gapsIn", 5),
|
||||
@@ -22,11 +46,13 @@ hl.config({
|
||||
border_size = prefs.get("borderSize", 2),
|
||||
|
||||
col = {
|
||||
-- The focused accent role: blue leads, orchid follows, on a
|
||||
-- diagonal so the pair is visible on both a tall and a wide
|
||||
-- window. ColorScheme never writes this role; a future accent
|
||||
-- picker can own it without fighting light/dark mode.
|
||||
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
|
||||
-- The focused accent role, on a diagonal so the pair is visible
|
||||
-- on both a tall and a wide window. Driven by the chosen
|
||||
-- accentName (see the `accents` table above); services/
|
||||
-- ColorScheme.qml applies the same values live, and restates them
|
||||
-- from Theme.accent/Theme.accentSecondary on every scheme change
|
||||
-- too, since each accent carries a separate pair per scheme.
|
||||
active_border = { colors = { "rgba(" .. accentStart .. "ee)", "rgba(" .. accentEnd .. "ee)" }, angle = 115 },
|
||||
-- The neutral inactive role follows the colour scheme because a
|
||||
-- dark neutral disappears against a light desktop.
|
||||
-- services/ColorScheme.qml applies the same values live; this is
|
||||
@@ -104,11 +130,13 @@ hl.config({
|
||||
-- New in 0.56. Kept deliberately faint: in this direction the gradient
|
||||
-- border is the signature, and a strong halo would compete with it.
|
||||
-- This is just enough to lift the focused window off the wallpaper.
|
||||
-- Derived from the same accent as active_border above, not hardcoded,
|
||||
-- so the halo never disagrees with the border it surrounds.
|
||||
glow = {
|
||||
enabled = prefs.get("glowEnabled", true),
|
||||
range = prefs.get("glowRange", 8),
|
||||
render_power = 2,
|
||||
color = "rgba(82aaff33)",
|
||||
color = "rgba(" .. accentStart .. "33)",
|
||||
color_inactive = "rgba(00000000)",
|
||||
},
|
||||
|
||||
|
||||
@@ -871,6 +871,26 @@ Singleton {
|
||||
{ value: "light", label: "Light" }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "accentName", type: "enum", def: "blue", group: "appearance",
|
||||
label: "Accent colour",
|
||||
detail: "Drives the focused window border, the bar hairline, and every active state",
|
||||
// NAMED accents, not a free colour. Each name carries a curated
|
||||
// pair per scheme, because one hex cannot serve both: a colour
|
||||
// legible on the dark ground is usually illegible on the light one.
|
||||
// The palette and its measured contrast live in config/Theme.qml,
|
||||
// which is also what stops this list drifting from what is drawn.
|
||||
options: [
|
||||
{ value: "blue", label: "Prism blue" },
|
||||
{ value: "orchid", label: "Orchid" },
|
||||
{ value: "teal", label: "Teal" },
|
||||
{ value: "green", label: "Green" },
|
||||
{ value: "amber", label: "Amber" },
|
||||
{ value: "orange", label: "Orange" },
|
||||
{ value: "rose", label: "Rose" },
|
||||
{ value: "slate", label: "Slate" }
|
||||
]
|
||||
},
|
||||
|
||||
// ── Application themes ─────────────────────────────────────────────
|
||||
// ColorScheme owns GTK's light/dark theme. These are the two theme
|
||||
|
||||
@@ -42,13 +42,41 @@ Singleton {
|
||||
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
|
||||
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
|
||||
|
||||
// The pair. `accent` is the primary and carries every state meaning
|
||||
// (focused, active, on). `accentSecondary` is the orchid from the tmux
|
||||
// theme — it never appears alone, only as the far end of a gradient. That
|
||||
// restraint is the whole point: the two colours meeting is the signature,
|
||||
// so the pink stops being special the moment it's used as a flat fill.
|
||||
readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue
|
||||
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
|
||||
// ── The accent ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// `accent` is the primary and carries every state meaning (focused, active,
|
||||
// on). `accentSecondary` never appears alone, only as the far end of a
|
||||
// gradient. That restraint is the whole point: the two colours meeting is
|
||||
// the signature, so the second colour stops being special the moment it is
|
||||
// used as a flat fill.
|
||||
//
|
||||
// NAMED accents rather than a free colour. Each name carries a curated
|
||||
// triple per scheme, because an arbitrary hex cannot work in both: a colour
|
||||
// legible on the Moon background is usually illegible on the Day one, and a
|
||||
// picker that lets someone choose an unreadable desktop is not a feature.
|
||||
// Every pair below measures at least 3:1 against the ground it sits on.
|
||||
// This is also GNOME's model, which is the parity being chased.
|
||||
//
|
||||
// Blue is the shipped Prism -- blue leading, orchid following -- and stays
|
||||
// the default.
|
||||
readonly property var accents: ({
|
||||
"blue": { dark: "#82aaff", darkSecondary: "#b172b0", light: "#2e7de9", lightSecondary: "#9854f1", label: "Prism blue" },
|
||||
"orchid": { dark: "#c099ff", darkSecondary: "#fca7ea", light: "#7847bd", lightSecondary: "#9854f1", label: "Orchid" },
|
||||
"teal": { dark: "#86e1fc", darkSecondary: "#82aaff", light: "#007197", lightSecondary: "#2e7de9", label: "Teal" },
|
||||
"green": { dark: "#c3e88d", darkSecondary: "#86e1fc", light: "#587539", lightSecondary: "#007197", label: "Green" },
|
||||
"amber": { dark: "#ffc777", darkSecondary: "#ff966c", light: "#8c6c3e", lightSecondary: "#b15c00", label: "Amber" },
|
||||
"orange": { dark: "#ff966c", darkSecondary: "#ff757f", light: "#b15c00", lightSecondary: "#c64343", label: "Orange" },
|
||||
"rose": { dark: "#ff757f", darkSecondary: "#c099ff", light: "#f52a65", lightSecondary: "#9854f1", label: "Rose" },
|
||||
"slate": { dark: "#828bb8", darkSecondary: "#82aaff", light: "#6172b0", lightSecondary: "#2e7de9", label: "Slate" }
|
||||
})
|
||||
|
||||
// Falls back to blue for an unknown name, so a settings file written by a
|
||||
// newer Panama -- or edited by hand -- degrades to the shipped identity
|
||||
// rather than to an undefined colour.
|
||||
readonly property var accentPair: root.accents[DesktopPreferences.get("accentName")] ?? root.accents["blue"]
|
||||
|
||||
readonly property color accent: root.dark ? root.accentPair.dark : root.accentPair.light
|
||||
readonly property color accentSecondary: root.dark ? root.accentPair.darkSecondary : root.accentPair.lightSecondary
|
||||
readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
|
||||
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
|
||||
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
|
||||
|
||||
@@ -25,10 +25,18 @@ PanelWindow {
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
// Deliberately does not read Capture.recordingSeconds (or anything else
|
||||
// that ticks once a second): this array is a plain JS array, not an
|
||||
// identity-preserving model, so any dependency that changes every second
|
||||
// would make the whole thing re-derive every second, and the Repeater
|
||||
// below would destroy and recreate every row -- including one the user
|
||||
// might be hovering or about to click. The set of activities should only
|
||||
// change when an activity actually starts or stops. Elapsed time is
|
||||
// rendered by each row's own Text binding instead, further down.
|
||||
readonly property var activities: {
|
||||
const result = [];
|
||||
if (PrivacyState.recordingActive)
|
||||
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama · " + root.elapsed(), tone: "danger", stoppable: true });
|
||||
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama", tone: "danger", stoppable: true });
|
||||
if (PrivacyState.screenSharingActive)
|
||||
result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false });
|
||||
if (PrivacyState.cameraActive)
|
||||
@@ -38,11 +46,12 @@ PanelWindow {
|
||||
return result;
|
||||
}
|
||||
|
||||
function elapsed(): string {
|
||||
const total = Capture.recordingSeconds;
|
||||
const seconds = String(total % 60).padStart(2, "0");
|
||||
const minutes = Math.floor(total / 60) % 60;
|
||||
const hours = Math.floor(total / 3600);
|
||||
// Pure formatter, no ticking property read here -- callers decide what
|
||||
// seconds value to pass, and only they take on the per-second dependency.
|
||||
function formatElapsed(totalSeconds: int): string {
|
||||
const seconds = String(totalSeconds % 60).padStart(2, "0");
|
||||
const minutes = Math.floor(totalSeconds / 60) % 60;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
@@ -164,7 +173,11 @@ PanelWindow {
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: activityRow.modelData.detail
|
||||
// Only this Text re-evaluates every second while
|
||||
// recording -- Capture.recordingSeconds is read
|
||||
// here, not in the parent `activities` array, so
|
||||
// the row itself is never torn down for a tick.
|
||||
text: activityRow.modelData.kind === "recording" ? activityRow.modelData.detail + " · " + root.formatElapsed(Capture.recordingSeconds) : activityRow.modelData.detail
|
||||
color: Theme.fgDim
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
// The calendar that drops out of the clock — GNOME's date menu, minus the
|
||||
// notification list (that lives in its own panel). Month grid, today marked
|
||||
// with the accent, arrows to page through months, current weather at the foot.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Popover {
|
||||
id: root
|
||||
|
||||
// Popover's container is a plain Item, which does not derive an implicit
|
||||
// size from its children, so the window has to be sized from the body.
|
||||
implicitWidth: body.implicitWidth + contentPadding * 2
|
||||
implicitHeight: body.implicitHeight + contentPadding * 2
|
||||
|
||||
readonly property int cellSize: 34
|
||||
readonly property int cellHeight: 30
|
||||
|
||||
// Hours precision is enough: the only thing that has to change on its own
|
||||
// is which cell counts as "today", and that only moves at midnight.
|
||||
SystemClock {
|
||||
id: clock
|
||||
precision: SystemClock.Hours
|
||||
}
|
||||
|
||||
readonly property int todayYear: clock.date.getFullYear()
|
||||
readonly property int todayMonth: clock.date.getMonth()
|
||||
readonly property int todayDay: clock.date.getDate()
|
||||
|
||||
// The month currently on screen. Reset to today every time the popover
|
||||
// opens, so it never comes back showing wherever you paged off to.
|
||||
property int viewYear: root.todayYear
|
||||
property int viewMonth: root.todayMonth
|
||||
|
||||
onVisibleChanged: if (visible)
|
||||
root.showToday()
|
||||
|
||||
function showToday(): void {
|
||||
root.viewYear = root.todayYear;
|
||||
root.viewMonth = root.todayMonth;
|
||||
}
|
||||
|
||||
function stepMonth(delta: int): void {
|
||||
const d = new Date(root.viewYear, root.viewMonth + delta, 1);
|
||||
root.viewYear = d.getFullYear();
|
||||
root.viewMonth = d.getMonth();
|
||||
}
|
||||
|
||||
// Six weeks of cells, so the grid height never changes as you page through
|
||||
// months. Days from the neighbouring months fill the edges, dimmed.
|
||||
readonly property var cells: {
|
||||
const first = new Date(root.viewYear, root.viewMonth, 1);
|
||||
const offset = first.getDay(); // 0 = Sunday, matching the header row
|
||||
const inThisMonth = new Date(root.viewYear, root.viewMonth + 1, 0).getDate();
|
||||
const inPrevMonth = new Date(root.viewYear, root.viewMonth, 0).getDate();
|
||||
|
||||
const out = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const n = i - offset + 1;
|
||||
if (n < 1)
|
||||
out.push({
|
||||
day: inPrevMonth + n,
|
||||
current: false
|
||||
});
|
||||
else if (n > inThisMonth)
|
||||
out.push({
|
||||
day: n - inThisMonth,
|
||||
current: false
|
||||
});
|
||||
else
|
||||
out.push({
|
||||
day: n,
|
||||
current: true
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Column {
|
||||
id: body
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
// ── Month header ────────────────────────────────────────────────────
|
||||
Item {
|
||||
width: root.cellSize * 7
|
||||
height: 28
|
||||
|
||||
CalendarArrow {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
glyph: "\u{F0141}" // md-chevron_left
|
||||
onActivated: root.stepMonth(-1)
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Qt.formatDateTime(new Date(root.viewYear, root.viewMonth, 1), "MMMM yyyy")
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
color: Theme.fg
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.showToday()
|
||||
}
|
||||
}
|
||||
|
||||
CalendarArrow {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
glyph: "\u{F0142}" // md-chevron_right
|
||||
onActivated: root.stepMonth(1)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weekday header ──────────────────────────────────────────────────
|
||||
Row {
|
||||
Repeater {
|
||||
model: ["S", "M", "T", "W", "T", "F", "S"]
|
||||
|
||||
delegate: Text {
|
||||
required property string modelData
|
||||
|
||||
width: root.cellSize
|
||||
height: 20
|
||||
text: modelData
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.fgMuted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Day grid ────────────────────────────────────────────────────────
|
||||
Grid {
|
||||
columns: 7
|
||||
|
||||
Repeater {
|
||||
model: root.cells
|
||||
|
||||
delegate: Item {
|
||||
id: cell
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool isToday: cell.modelData.current && cell.modelData.day === root.todayDay && root.viewMonth === root.todayMonth && root.viewYear === root.todayYear
|
||||
|
||||
width: root.cellSize
|
||||
height: root.cellHeight
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: root.cellHeight - 2
|
||||
height: root.cellHeight - 2
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
visible: cell.isToday
|
||||
color: Theme.accent
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: cell.modelData.day
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: cell.isToday ? Font.DemiBold : Font.Normal
|
||||
|
||||
color: {
|
||||
if (cell.isToday)
|
||||
return Theme.bgDark;
|
||||
return cell.modelData.current ? Theme.fg : Theme.fgMuted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weather ─────────────────────────────────────────────────────────
|
||||
Rectangle {
|
||||
width: root.cellSize * 7
|
||||
height: 1
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
visible: Weather.available
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: Theme.itemSpacing
|
||||
visible: Weather.available
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Weather.icon
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeTitle
|
||||
color: Theme.accentAlt
|
||||
}
|
||||
|
||||
Column {
|
||||
Text {
|
||||
text: Math.round(Weather.temperature) + Weather.unitSuffix
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
color: Theme.fg
|
||||
}
|
||||
|
||||
Text {
|
||||
text: Weather.description
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.fgDim
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// The "you are being recorded" pill.
|
||||
//
|
||||
// GNOME puts a red dot and a timer in the top bar while a screencast runs and
|
||||
// clicking it stops the recording. The bar is another module's window, so this
|
||||
// is its own tiny layer surface parked just below it.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
|
||||
visible: Capture.recording
|
||||
color: "transparent"
|
||||
|
||||
// Top only: with neither left nor right anchored, layer-shell centres the
|
||||
// surface horizontally.
|
||||
anchors.top: true
|
||||
margins.top: Theme.barGap * 2 + Theme.barHeight + Theme.barGap
|
||||
|
||||
implicitWidth: pill.implicitWidth
|
||||
implicitHeight: pill.implicitHeight
|
||||
exclusiveZone: 0
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-recording"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
function elapsed(): string {
|
||||
const t = Capture.recordingSeconds;
|
||||
const s = ("0" + (t % 60)).slice(-2);
|
||||
const m = Math.floor(t / 60) % 60;
|
||||
const h = Math.floor(t / 3600);
|
||||
return h > 0 ? h + ":" + ("0" + m).slice(-2) + ":" + s : m + ":" + s;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: pill
|
||||
implicitWidth: row.implicitWidth + 12
|
||||
implicitHeight: 34
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
color: Theme.redDeep
|
||||
|
||||
Row {
|
||||
id: row
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
|
||||
// Static dot, not a blinking one: nothing in this shell repaints
|
||||
// while idle.
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 10
|
||||
height: 10
|
||||
radius: 5
|
||||
border.width: 0
|
||||
color: Theme.fg
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: win.elapsed()
|
||||
color: Theme.fg
|
||||
// Tabular figures: the timer must not shuffle as it counts.
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: stop
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 26
|
||||
height: 26
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
color: stopMouse.containsMouse ? Theme.alpha(Theme.fg, 0.28) : Theme.alpha(Theme.fg, 0.14)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: ""
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: stopMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// SIGINT, so wf-recorder finalises the container.
|
||||
onClicked: Capture.stopRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,12 +48,22 @@ PanelWindow {
|
||||
implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin
|
||||
|
||||
// ── Intellihide ─────────────────────────────────────────────────────────
|
||||
// This instance's own monitor, the same lookup Workspaces.qml uses to
|
||||
// scope a per-screen bar to its own screen. Falls back to null when this
|
||||
// Dock is created standalone (no `screen` set) rather than via Variants.
|
||||
readonly property HyprlandMonitor monitor: root.screen ? Hyprland.monitorFor(root.screen) : null
|
||||
|
||||
// Hyprland does not expose live toplevel geometry, so exact overlap cannot
|
||||
// be computed. "Is anything on this workspace at all" is the robust proxy,
|
||||
// and it is what Dash-to-Dock's all-windows intellihide felt like in
|
||||
// practice: an empty workspace keeps the dock out.
|
||||
//
|
||||
// Deliberately this instance's own monitor's active workspace, not the
|
||||
// globally-focused one -- with one Dock per screen, keying off the global
|
||||
// focus would make focusing an empty workspace on monitor A hide the dock
|
||||
// on monitor B even though B's own workspace is still busy.
|
||||
readonly property bool workspaceOccupied: {
|
||||
const ws = Hyprland.focusedWorkspace;
|
||||
const ws = root.monitor ? root.monitor.activeWorkspace : Hyprland.focusedWorkspace;
|
||||
return !!ws && ws.toplevels.values.length > 0;
|
||||
}
|
||||
|
||||
@@ -83,15 +93,31 @@ PanelWindow {
|
||||
onTriggered: root.revealed = false
|
||||
}
|
||||
|
||||
// Other modules (the bar, the capture overlay) read this.
|
||||
onRevealedChanged: ShellState.dockRevealed = revealed
|
||||
// Other modules (the bar, the capture overlay) read this. It is one
|
||||
// shared flag but there is one Dock per monitor, so only the instance on
|
||||
// the currently-focused monitor is allowed to write it -- otherwise
|
||||
// whichever instance last changed reveal state would stomp the others,
|
||||
// and a reader would see an arbitrary monitor's value. This scopes the
|
||||
// flag to mean "is the dock revealed on the monitor the user is on",
|
||||
// which is what a capture overlay or the bar actually care about.
|
||||
// (A true per-monitor flag would need ShellState.dockRevealed itself to
|
||||
// become keyed by screen, which is out of scope here -- see the report.)
|
||||
readonly property bool isFocusedMonitorInstance: root.monitor === null || root.monitor === Hyprland.focusedMonitor
|
||||
|
||||
onRevealedChanged: root._syncShellState()
|
||||
onIsFocusedMonitorInstanceChanged: root._syncShellState()
|
||||
|
||||
function _syncShellState(): void {
|
||||
if (root.isFocusedMonitorInstance)
|
||||
ShellState.dockRevealed = root.revealed;
|
||||
}
|
||||
|
||||
// wantRevealed's first evaluation emits no change signal when it lands on
|
||||
// false (the default), so the initial state has to be taken explicitly —
|
||||
// otherwise a shell started on a busy workspace would leave the dock up.
|
||||
Component.onCompleted: {
|
||||
revealed = wantRevealed;
|
||||
ShellState.dockRevealed = revealed;
|
||||
root._syncShellState();
|
||||
}
|
||||
|
||||
// ── Input region ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
// GNOME's message tray: everything that has arrived, grouped by app.
|
||||
//
|
||||
// Drops from the top centre because that is where GNOME's tray lives and the
|
||||
// muscle memory is the whole point. Layer-shell centres a surface on whichever
|
||||
// axis it is not anchored to, so anchoring only `top` does it.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Widgets
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
import qs.widgets
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
visible: ShellState.notificationsOpen
|
||||
color: "transparent"
|
||||
|
||||
anchors.top: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
exclusiveZone: 0
|
||||
|
||||
implicitWidth: 440
|
||||
implicitHeight: surface.implicitHeight
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-notifications"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||
|
||||
onVisibleChanged: {
|
||||
if (root.visible)
|
||||
Notifs.markAllRead();
|
||||
}
|
||||
|
||||
HyprlandFocusGrab {
|
||||
windows: [root]
|
||||
active: root.visible
|
||||
onCleared: ShellState.close()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: surface
|
||||
anchors.fill: parent
|
||||
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
// The prism edge — see widgets/PrismEdge.qml. Sits just inside the 1px
|
||||
// border so the two don't fight for the same row of pixels.
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 1
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
Keys.onEscapePressed: ShellState.close()
|
||||
|
||||
Column {
|
||||
id: content
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Theme.popoverPadding
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 32
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Notifications"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 4
|
||||
|
||||
IconButton {
|
||||
size: 30
|
||||
iconSize: 16
|
||||
icon: "notifications-disabled-symbolic"
|
||||
tint: Notifs.doNotDisturb ? Theme.accent : Theme.fgDim
|
||||
onClicked: Notifs.doNotDisturb = !Notifs.doNotDisturb
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: clearLabel.implicitWidth + 20
|
||||
height: 28
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
visible: Notifs.hasNotifications
|
||||
color: clearMouse.containsMouse ? Theme.alpha(Theme.fg, 0.16) : Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: clearLabel
|
||||
anchors.centerIn: parent
|
||||
text: "Clear all"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: clearMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: Notifs.dismissAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empty state ─────────────────────────────────────────────
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 120
|
||||
visible: !Notifs.hasNotifications
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Notifs.doNotDisturb ? "Do Not Disturb is on" : "No notifications"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grouped history ─────────────────────────────────────────
|
||||
ScrollColumn {
|
||||
width: parent.width
|
||||
maxHeight: 620
|
||||
spacing: Theme.itemSpacing
|
||||
visible: Notifs.hasNotifications
|
||||
|
||||
Repeater {
|
||||
model: Notifs.groups
|
||||
|
||||
Column {
|
||||
id: group
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
spacing: 4
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 26
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: group.modelData.app
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
IconButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 24
|
||||
iconSize: 13
|
||||
tint: Theme.fgDim
|
||||
icon: "edit-clear-all-symbolic"
|
||||
iconFallback: "window-close-symbolic"
|
||||
onClicked: Notifs.dismissApp(group.modelData.app)
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: group.modelData.items
|
||||
|
||||
NotificationCard {
|
||||
required property var modelData
|
||||
|
||||
width: group.width
|
||||
compact: true
|
||||
notification: modelData
|
||||
onDismissed: Notifs.dismiss(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// A single banner: a notification card that slides in and times itself out.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell.Services.Notifications
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
@@ -14,28 +13,25 @@ Item {
|
||||
// into a keyboard focus request on the layer surface.
|
||||
signal replyFocusChanged(bool focused)
|
||||
|
||||
// Mirrors the signal above so the dismiss timer below can read it.
|
||||
property bool replyFocused: false
|
||||
|
||||
implicitHeight: card.implicitHeight
|
||||
|
||||
// Critical notifications stay until dismissed (the setting is 0). An app
|
||||
// asking for 0 means "never expire" per the freedesktop spec; -1 means
|
||||
// "server decides", which is our default.
|
||||
readonly property int timeoutMs: {
|
||||
if (root.notification.urgency === NotificationUrgency.Critical)
|
||||
return Settings.notificationTimeoutCriticalMs;
|
||||
if (root.notification.expireTimeout === 0)
|
||||
return 0;
|
||||
if (root.notification.expireTimeout > 0)
|
||||
return Math.round(root.notification.expireTimeout * 1000);
|
||||
return Settings.notificationTimeoutMs;
|
||||
}
|
||||
// "server decides", which is our default. Shared with Notifs.qml, which
|
||||
// gives a DND-hidden transient notification the same lifetime.
|
||||
readonly property int timeoutMs: Notifs.notificationTimeoutMs(root.notification)
|
||||
|
||||
HoverHandler {
|
||||
id: hover
|
||||
}
|
||||
|
||||
Timer {
|
||||
// Hovering holds the banner open; the countdown restarts on leave.
|
||||
running: root.timeoutMs > 0 && !hover.hovered
|
||||
// Hovering, or actively typing a reply, holds the banner open; the
|
||||
// countdown restarts (from the top, same as hover) once both let go.
|
||||
running: root.timeoutMs > 0 && !hover.hovered && !root.replyFocused
|
||||
interval: root.timeoutMs
|
||||
onTriggered: Notifs.dropPopup(root.notification)
|
||||
}
|
||||
@@ -45,7 +41,10 @@ Item {
|
||||
width: parent.width
|
||||
notification: root.notification
|
||||
onDismissed: Notifs.dropPopup(root.notification)
|
||||
onReplyFocusChanged: focused => root.replyFocusChanged(focused)
|
||||
onReplyFocusChanged: focused => {
|
||||
root.replyFocused = focused;
|
||||
root.replyFocusChanged(focused);
|
||||
}
|
||||
|
||||
// Slide in from the right edge. Runs once, on creation.
|
||||
NumberAnimation on x {
|
||||
|
||||
@@ -45,7 +45,17 @@ PanelWindow {
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
Repeater {
|
||||
model: Notifs.popups.slice(0, Settings.maxVisibleToasts)
|
||||
// Notifs.popups.slice() is a fresh array on every change (a new
|
||||
// arrival, a dismissal, a sibling toast timing out). Handing that
|
||||
// straight to Repeater would reset the model and rebuild every
|
||||
// delegate each time, blowing away whichever toast has a reply
|
||||
// field mid-typing. ScriptModel diffs by object identity
|
||||
// (Notification instances are unique QObjects), so only genuinely
|
||||
// added/removed notifications add/remove delegates — unrelated
|
||||
// toasts, and their slide-in animations, are untouched.
|
||||
model: ScriptModel {
|
||||
values: Notifs.popups.slice(0, Settings.maxVisibleToasts)
|
||||
}
|
||||
|
||||
Toast {
|
||||
required property var modelData
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// The three system power profiles, as an expandable list.
|
||||
//
|
||||
// A row per profile rather than a cycling button: there are three, and cycling
|
||||
// through them means passing through one you did not want on a machine where
|
||||
// the change is immediate and audible. The same shape the audio device lists
|
||||
// use, so the panel reads consistently.
|
||||
//
|
||||
// The daemon owns the profile -- it survives a shell restart and anything else
|
||||
// on the system can change it -- so this reads back rather than assuming, the
|
||||
// same as monitor brightness.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: PowerProfiles.profiles
|
||||
|
||||
RowButton {
|
||||
required property var modelData
|
||||
|
||||
width: root.width
|
||||
icon: {
|
||||
switch (modelData) {
|
||||
case "power-saver": return "power-profile-power-saver-symbolic";
|
||||
case "performance": return "power-profile-performance-symbolic";
|
||||
default: return "power-profile-balanced-symbolic";
|
||||
}
|
||||
}
|
||||
iconFallback: "preferences-system-power-symbolic"
|
||||
label: PowerProfiles.label(modelData)
|
||||
sublabel: PowerProfiles.detail(modelData)
|
||||
selected: modelData === PowerProfiles.active
|
||||
dimmed: PowerProfiles.busy
|
||||
onClicked: PowerProfiles.set(modelData)
|
||||
}
|
||||
}
|
||||
|
||||
// Only when it is true. A machine that cannot deliver the profile it is set
|
||||
// to is the one case where the label alone is misleading.
|
||||
Text {
|
||||
visible: PowerProfiles.degraded !== ""
|
||||
width: root.width
|
||||
text: "Limited right now: " + PowerProfiles.degraded
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
leftPadding: 10
|
||||
topPadding: 4
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,9 @@ PanelWindow {
|
||||
if (root.visible) {
|
||||
KdeConnect.refresh();
|
||||
HomeAssistant.refresh();
|
||||
// The daemon owns the power profile and anything on the system can
|
||||
// change it, so the panel asks rather than trusting what it last saw.
|
||||
PowerProfiles.refresh();
|
||||
openAnim.restart();
|
||||
} else {
|
||||
panel.reset();
|
||||
|
||||
@@ -130,6 +130,21 @@ Item {
|
||||
onToggled: Caffeine.toggle()
|
||||
}
|
||||
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
icon: ColorScheme.dark ? "weather-clear-night-symbolic" : "weather-clear-symbolic"
|
||||
label: "Appearance"
|
||||
sublabel: ColorScheme.dark ? "Dark" : "Light"
|
||||
// "active" reads as the non-default state across this grid, and
|
||||
// dark is what Panama ships, so light is the lit one.
|
||||
active: !ColorScheme.dark
|
||||
// Writes the preference and stops. ColorScheme propagates it to
|
||||
// GTK, the portal, the terminals, the launcher, btop, tmux and
|
||||
// the lock screen; nothing here needs to know that list.
|
||||
onToggled: SystemSettings.commitPreference("colorScheme",
|
||||
ColorScheme.dark ? "light" : "dark")
|
||||
}
|
||||
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
icon: "night-light-symbolic"
|
||||
@@ -190,6 +205,38 @@ Item {
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
|
||||
// ── Power ───────────────────────────────────────────────────────────
|
||||
// Only where a power-profiles daemon is actually running. A desktop
|
||||
// without one should not show a control that cannot do anything.
|
||||
RowButton {
|
||||
visible: PowerProfiles.available
|
||||
width: content.width
|
||||
icon: {
|
||||
switch (PowerProfiles.active) {
|
||||
case "power-saver": return "power-profile-power-saver-symbolic";
|
||||
case "performance": return "power-profile-performance-symbolic";
|
||||
default: return "power-profile-balanced-symbolic";
|
||||
}
|
||||
}
|
||||
iconFallback: "preferences-system-power-symbolic"
|
||||
label: "Power profile"
|
||||
sublabel: PowerProfiles.degraded !== ""
|
||||
? PowerProfiles.label(PowerProfiles.active) + " · limited"
|
||||
: PowerProfiles.label(PowerProfiles.active)
|
||||
selected: root.expandedSection === "power"
|
||||
onClicked: root.expand("power")
|
||||
}
|
||||
|
||||
Section {
|
||||
width: content.width
|
||||
expanded: root.expandedSection === "power"
|
||||
|
||||
PowerProfileList {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sliders ─────────────────────────────────────────────────────────
|
||||
AudioSlider {
|
||||
width: content.width
|
||||
|
||||
@@ -137,7 +137,18 @@ Item {
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.networks
|
||||
// root.networks is a fresh array on every signal-strength tick (the
|
||||
// sort comparator reads signalStrength/connected/known, so any of
|
||||
// those changing on ANY network recomputes the whole list). Handing
|
||||
// that straight to Repeater would reset the model and rebuild every
|
||||
// delegate each tick, blowing away whichever row has its password
|
||||
// Section open and focused. ScriptModel diffs by object identity
|
||||
// (WifiNetwork instances are unique QObjects) and turns a reorder
|
||||
// into move operations, so existing delegates -- and their expanded
|
||||
// state -- survive.
|
||||
model: ScriptModel {
|
||||
values: root.networks
|
||||
}
|
||||
|
||||
Column {
|
||||
id: entry
|
||||
|
||||
@@ -17,3 +17,4 @@ RowButton 1.0 RowButton.qml
|
||||
ScrollColumn 1.0 ScrollColumn.qml
|
||||
Section 1.0 Section.qml
|
||||
WifiList 1.0 WifiList.qml
|
||||
PowerProfileList 1.0 PowerProfileList.qml
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Choosing the desktop accent.
|
||||
//
|
||||
// Each swatch is drawn as the GRADIENT it will actually produce, not a flat
|
||||
// dot, because the gradient is the thing being chosen -- the focused window
|
||||
// border, the bar hairline and every active state are the two colours meeting.
|
||||
// A row of flat circles would misrepresent all of them.
|
||||
//
|
||||
// Named accents rather than a colour wheel: each name carries a curated pair
|
||||
// per scheme, so every choice stays legible in both light and dark. See
|
||||
// config/Theme.qml for the palette and the reasoning.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Flow {
|
||||
id: root
|
||||
|
||||
spacing: 10
|
||||
|
||||
readonly property string current: DesktopPreferences.get("accentName") || "blue"
|
||||
|
||||
// The schema's own option list, not Theme.accents directly -- two lists
|
||||
// hand-kept in sync is how they drift. This is the same pattern
|
||||
// ChoiceRow.qml uses for every other enum row.
|
||||
readonly property var spec: PreferenceSchema.spec("accentName")
|
||||
readonly property var options: root.spec && root.spec.options ? root.spec.options : []
|
||||
|
||||
Repeater {
|
||||
// The schema's option order is the palette's order, so blue is first
|
||||
// because it is what Panama ships.
|
||||
model: root.options
|
||||
|
||||
Column {
|
||||
id: entry
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property string name: entry.modelData.value
|
||||
readonly property var pair: Theme.accents[entry.name]
|
||||
readonly property bool selected: entry.name === root.current
|
||||
readonly property color start: Theme.dark ? entry.pair.dark : entry.pair.light
|
||||
readonly property color end: Theme.dark ? entry.pair.darkSecondary : entry.pair.lightSecondary
|
||||
|
||||
spacing: 5
|
||||
|
||||
// The hit target is the whole swatch+label unit, not just the
|
||||
// 46px circle: the label exists specifically so someone with a
|
||||
// colour vision deficiency can identify an accent without it, and
|
||||
// a label that cannot itself be tapped defeats that.
|
||||
HoverHandler {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: {
|
||||
if (!SystemSettings.commitPreference("accentName", entry.name))
|
||||
console.warn("AccentPicker: commitPreference rejected accent", entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 46
|
||||
height: 46
|
||||
radius: 23
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: "transparent"
|
||||
// The ring sits outside the gradient rather than over it, so a
|
||||
// selected swatch still shows its true colours.
|
||||
border.width: entry.selected ? 2 : 1
|
||||
border.color: entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14)
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: entry.selected ? 4 : 3
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: entry.start }
|
||||
GradientStop { position: 1.0; color: entry.end }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always shown, not a tooltip. Telling swatches apart by colour is
|
||||
// exactly what someone with a colour vision deficiency cannot do,
|
||||
// and it is the reason the palette is named rather than freeform --
|
||||
// hiding the names behind a hover would waste that.
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: entry.modelData.label
|
||||
color: entry.selected ? Theme.fg : Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: entry.selected ? Font.DemiBold : Font.Normal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,13 @@ SettingsPage {
|
||||
? ColorScheme.lastError
|
||||
: "Light is Tokyo Night Day, the official light variant — the same hues at a different lightness, so the blue-into-orchid signature survives the switch. Applications and window borders follow."
|
||||
|
||||
ChoiceRow { setting: "colorScheme"; divider: false }
|
||||
ChoiceRow { setting: "colorScheme" }
|
||||
|
||||
// Drawn as the gradient each accent produces rather than a flat dot,
|
||||
// because the gradient is what is being chosen.
|
||||
AccentPicker {
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
|
||||
@@ -441,7 +441,7 @@ SettingsPage {
|
||||
ActionRow {
|
||||
objectName: "health-fedora-handoff:color"
|
||||
label: "Colour profiles"
|
||||
detail: "ICC profiles for displays, printers, and scanners"
|
||||
detail: "Assigning an ICC profile here has no visible effect in this session: the colord daemon that loads it onto a display isn't running under Hyprland"
|
||||
action: "Open colour"
|
||||
onTriggered: SystemSettings.openGnomePanel("color")
|
||||
}
|
||||
|
||||
@@ -96,9 +96,11 @@ reason and an update to `tests/quickshell/settings-ownership-contract.sh`.
|
||||
Window border colour follows the same ownership rule. The inactive border is a
|
||||
**scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain
|
||||
neutral contrast in light and dark modes. The focused Prism border is the
|
||||
accent role owned by the visual theme (and, eventually, an accent picker).
|
||||
`ColorScheme.qml` must never write the focused border, so changing schemes
|
||||
cannot erase a user-selected accent.
|
||||
**accent role**, driven by the chosen `accentName` and also owned by
|
||||
`ColorScheme.qml`: each accent carries a separate pair for light and dark, so
|
||||
`ColorScheme.qml` restates the focused border alongside the inactive one on
|
||||
every scheme change, rather than leaving a scheme flip to erase a
|
||||
user-selected accent.
|
||||
|
||||
## The rows
|
||||
|
||||
|
||||
@@ -51,9 +51,11 @@ Item {
|
||||
return typeof value === "number" ? value : root.minimum;
|
||||
}
|
||||
|
||||
// Shown while dragging; -1 means "nothing pending, show what is stored".
|
||||
property real pending: -1
|
||||
readonly property real shown: root.pending >= 0 ? root.pending : root.stored
|
||||
// Shown while dragging; null means "nothing pending, show what is stored".
|
||||
// Not -1: several schema entries (e.g. pointerSensitivity) are legitimately
|
||||
// negative, and -1 would be indistinguishable from a real committed value.
|
||||
property var pending: null
|
||||
readonly property real shown: root.pending !== null ? root.pending : root.stored
|
||||
|
||||
// Below this the label and a usable slider cannot share a line without one
|
||||
// of them becoming useless.
|
||||
@@ -176,7 +178,7 @@ Item {
|
||||
id: commitTimer
|
||||
interval: 140
|
||||
onTriggered: {
|
||||
if (root.pending < 0)
|
||||
if (root.pending === null)
|
||||
return;
|
||||
SystemSettings.commitPreference(root.setting, root.pending);
|
||||
// Hand the display back to the stored value. If the write was
|
||||
@@ -188,6 +190,6 @@ Item {
|
||||
Timer {
|
||||
id: releaseTimer
|
||||
interval: 160
|
||||
onTriggered: root.pending = -1
|
||||
onTriggered: root.pending = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,3 +61,4 @@ PrivacyPage 1.0 PrivacyPage.qml
|
||||
RegionPage 1.0 RegionPage.qml
|
||||
SearchPicker 1.0 SearchPicker.qml
|
||||
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
|
||||
AccentPicker 1.0 AccentPicker.qml
|
||||
|
||||
@@ -255,7 +255,11 @@ def watch(start: int, end: int) -> int:
|
||||
return 1
|
||||
|
||||
loop = GLib.MainLoop()
|
||||
try:
|
||||
registry = EDataServer.SourceRegistry.new_sync(None)
|
||||
except Exception:
|
||||
_write_snapshot(_unavailable_snapshot())
|
||||
return 1
|
||||
debounce_source = 0
|
||||
subscriptions: list[int] = []
|
||||
|
||||
|
||||
@@ -61,6 +61,16 @@ class DoctorConfig:
|
||||
runtime_dir: Path
|
||||
path: str
|
||||
timeout: float
|
||||
# Defaults to this file's own directory, where its sibling helpers
|
||||
# (panama-action, panama-brightness, calendar-agenda) actually live.
|
||||
scripts_dir: Path = Path(__file__).resolve().parent
|
||||
# Repair actions (service restarts, the Quickshell restart-shell action)
|
||||
# can legitimately run longer than a quick health-check probe -- a
|
||||
# Quickshell restart alone waits for the old process to exit, the new one
|
||||
# to start, and settle. Reusing `timeout` here would kill a slow-but-
|
||||
# successful repair and report it as failed even though a following
|
||||
# health scan would show everything recovered. See run_repair_command.
|
||||
repair_timeout: float = 15.0
|
||||
|
||||
@property
|
||||
def command_env(self) -> dict[str, str]:
|
||||
@@ -193,11 +203,25 @@ def config_from_environment() -> DoctorConfig:
|
||||
state_home = environment_path("PANAMA_DOCTOR_STATE_HOME", Path(os.environ.get("XDG_STATE_HOME", home / ".local/state")))
|
||||
runtime_dir = environment_path("PANAMA_DOCTOR_RUNTIME_DIR", Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/0")))
|
||||
root = environment_path("PANAMA_DOCTOR_ROOT", Path(__file__).resolve().parents[4])
|
||||
# Sibling helpers (panama-action, panama-brightness, calendar-agenda) live
|
||||
# next to this file. PATH is not a reliable way to find them -- nothing in
|
||||
# this repository puts the Quickshell scripts directory on PATH -- so they
|
||||
# are invoked by resolved path instead, the same way check_hyprlock already
|
||||
# resolves panama-lock.
|
||||
scripts_dir = environment_path("PANAMA_DOCTOR_SCRIPTS_DIR", Path(__file__).resolve().parent)
|
||||
try:
|
||||
timeout = float(os.environ.get("PANAMA_DOCTOR_TIMEOUT", "3"))
|
||||
except ValueError:
|
||||
timeout = 3.0
|
||||
return DoctorConfig(root, home, config_home, state_home, runtime_dir, os.environ.get("PANAMA_DOCTOR_PATH", os.environ.get("PATH", "")), max(0.05, min(timeout, 15.0)))
|
||||
timeout = max(0.05, min(timeout, 15.0))
|
||||
try:
|
||||
repair_timeout = float(os.environ.get("PANAMA_DOCTOR_REPAIR_TIMEOUT", "15"))
|
||||
except ValueError:
|
||||
repair_timeout = 15.0
|
||||
# Never shorter than the probe timeout, and bounded so a hung repair still
|
||||
# gives up rather than blocking the caller indefinitely.
|
||||
repair_timeout = max(timeout, min(repair_timeout, 30.0))
|
||||
return DoctorConfig(root, home, config_home, state_home, runtime_dir, os.environ.get("PANAMA_DOCTOR_PATH", os.environ.get("PATH", "")), timeout, scripts_dir, repair_timeout)
|
||||
|
||||
|
||||
def run_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None = None) -> CommandResult:
|
||||
@@ -222,7 +246,7 @@ def run_repair_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=config.timeout,
|
||||
timeout=config.repair_timeout,
|
||||
check=False,
|
||||
env=config.command_env,
|
||||
cwd=cwd,
|
||||
@@ -357,7 +381,7 @@ def check_hyprlock(config: DoctorConfig) -> Check:
|
||||
|
||||
|
||||
def check_brightness(config: DoctorConfig) -> Check:
|
||||
result = run_command(("panama-brightness", "list"), config)
|
||||
result = run_command((str(config.scripts_dir / "panama-brightness"), "list"), config)
|
||||
instructions = Action("instructions", "View setup instructions", target="ddc-permissions")
|
||||
if result.state == "timeout":
|
||||
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe timed out.", instructions)
|
||||
@@ -418,7 +442,7 @@ def check_home_assistant(config: DoctorConfig) -> Check:
|
||||
|
||||
|
||||
def check_calendar(config: DoctorConfig) -> Check:
|
||||
result = run_command(("calendar-agenda", "probe"), config)
|
||||
result = run_command((str(config.scripts_dir / "calendar-agenda"), "probe"), config)
|
||||
action = Action("open", "Open Date & Time", target="datetime")
|
||||
if result.state == "missing":
|
||||
return Check("integration.calendar", "integrations", "Calendar", "unconfigured", "Calendar integration is not installed.")
|
||||
@@ -583,6 +607,11 @@ def snapshot(config: DoctorConfig) -> dict[str, object]:
|
||||
|
||||
def repair_authored_command(check_id: str, config: DoctorConfig) -> RepairResult:
|
||||
command = REPAIR_COMMANDS[check_id]
|
||||
if check_id == "desktop.quickshell":
|
||||
# panama-action is a sibling helper script, not a PATH-resolved
|
||||
# executable; see check_brightness and check_calendar for the same
|
||||
# resolution against the same bug.
|
||||
command = (str(config.scripts_dir / command[0]), *command[1:])
|
||||
exit_code, _ = run_repair_command(command, config)
|
||||
message = "Repair completed. A fresh health check will verify recovery." if exit_code == 0 \
|
||||
else "The authored repair command could not be completed."
|
||||
|
||||
@@ -25,6 +25,14 @@ generated="$state_dir/hypridle.conf"
|
||||
dropin_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/hypridle.service.d"
|
||||
dropin="$dropin_dir/panama.conf"
|
||||
|
||||
# Set by generate() to the mktemp path it is currently writing, so concurrent
|
||||
# invocations (e.g. rapid settings changes each spawning `apply`) never share
|
||||
# a tmp file and interleave writes into a corrupt hypridle.conf. Cleared once
|
||||
# the atomic mv below lands, so this is a no-op on a normal exit.
|
||||
generated_tmp=""
|
||||
cleanup() { rm -f "$generated_tmp" 2>/dev/null || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
read_setting() {
|
||||
local key="$1" fallback="$2"
|
||||
[[ -r "$settings" ]] || { printf '%s' "$fallback"; return; }
|
||||
@@ -53,6 +61,10 @@ generate() {
|
||||
load
|
||||
mkdir -p "$state_dir"
|
||||
|
||||
# Unique per invocation, in the same directory as the destination so the
|
||||
# final mv is an atomic same-filesystem rename rather than a copy.
|
||||
generated_tmp="$(mktemp "$generated.XXXXXX")"
|
||||
|
||||
{
|
||||
printf '# Generated by panama-idle from %s\n' "$settings"
|
||||
printf '# Do not edit: it is rewritten whenever the idle settings change.\n'
|
||||
@@ -91,9 +103,10 @@ generate() {
|
||||
printf ' on-timeout = systemctl suspend\n'
|
||||
printf '}\n'
|
||||
fi
|
||||
} >"$generated.tmp"
|
||||
} >"$generated_tmp"
|
||||
|
||||
mv "$generated.tmp" "$generated"
|
||||
mv "$generated_tmp" "$generated"
|
||||
generated_tmp=""
|
||||
}
|
||||
|
||||
install_dropin() {
|
||||
|
||||
@@ -39,13 +39,36 @@ import re
|
||||
import sys
|
||||
|
||||
|
||||
def daemon_origin():
|
||||
"""Whether the running secrets daemon came from PAM or from D-Bus activation.
|
||||
def secrets_name_owner_pid():
|
||||
"""PID currently owning the org.freedesktop.secrets D-Bus name, if any.
|
||||
|
||||
A D-Bus-activated daemon is the signature of the crash-and-replace case
|
||||
above: it is the one that cannot have the login password. PAM's daemon lives
|
||||
outside the app slice, so the cgroup tells the two apart.
|
||||
This is the only reliable way to identify which daemon actually answers
|
||||
Secret Service calls right now.
|
||||
"""
|
||||
try:
|
||||
import gi
|
||||
|
||||
gi.require_version("Gio", "2.0")
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
result = bus.call_sync(
|
||||
"org.freedesktop.DBus",
|
||||
"/org/freedesktop/DBus",
|
||||
"org.freedesktop.DBus",
|
||||
"GetConnectionUnixProcessID",
|
||||
GLib.Variant("(s)", ("org.freedesktop.secrets",)),
|
||||
GLib.VariantType("(u)"),
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
None,
|
||||
)
|
||||
return result.unpack()[0]
|
||||
except Exception: # noqa: BLE001 - no name owner is a legitimate state
|
||||
return None
|
||||
|
||||
|
||||
def any_keyring_daemon_running():
|
||||
try:
|
||||
for pid in os.listdir("/proc"):
|
||||
if not pid.isdigit():
|
||||
@@ -55,19 +78,46 @@ def daemon_origin():
|
||||
cmdline = handle.read().decode("utf-8", "replace")
|
||||
except OSError:
|
||||
continue
|
||||
if "gnome-keyring-daemon" not in cmdline:
|
||||
continue
|
||||
if "gnome-keyring-daemon" in cmdline:
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def daemon_origin():
|
||||
"""Whether the running secrets daemon came from PAM or from D-Bus activation.
|
||||
|
||||
A D-Bus-activated daemon is the signature of the crash-and-replace case
|
||||
above: it is the one that cannot have the login password. PAM's daemon lives
|
||||
outside the app slice, so the cgroup tells the two apart.
|
||||
|
||||
A machine can have two gnome-keyring-daemon processes at once -- a
|
||||
lingering PAM one alongside its D-Bus-activated replacement -- so which
|
||||
process this reports on matters: it must be the one that actually owns
|
||||
org.freedesktop.secrets right now, not merely the first one /proc happens
|
||||
to enumerate.
|
||||
"""
|
||||
owner_pid = secrets_name_owner_pid()
|
||||
if owner_pid is None:
|
||||
return "unknown" if any_keyring_daemon_running() else "none"
|
||||
|
||||
try:
|
||||
with open(f"/proc/{pid}/cgroup", "r") as handle:
|
||||
with open(f"/proc/{owner_pid}/cmdline", "rb") as handle:
|
||||
cmdline = handle.read().decode("utf-8", "replace")
|
||||
except OSError:
|
||||
return "unknown"
|
||||
if "gnome-keyring-daemon" not in cmdline:
|
||||
return "unknown"
|
||||
|
||||
try:
|
||||
with open(f"/proc/{owner_pid}/cgroup", "r") as handle:
|
||||
cgroup = handle.read()
|
||||
except OSError:
|
||||
return "unknown"
|
||||
if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup):
|
||||
return "dbus"
|
||||
return "pam"
|
||||
except OSError:
|
||||
pass
|
||||
return "none"
|
||||
|
||||
|
||||
def load_service():
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Generates Panama's hyprlock configuration into the state directory. The
|
||||
# tracked config is never rewritten and remains the fallback if generation
|
||||
# fails, so a malformed preference can never leave the session without a
|
||||
# working locker.
|
||||
# gitignored fallback config (rendered from the tracked .template at link
|
||||
# time) is never rewritten by this script and remains the fallback if
|
||||
# generation fails, so a malformed preference can never leave the session
|
||||
# without a working locker.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -78,6 +79,31 @@ valid_path() {
|
||||
[[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* ]]
|
||||
}
|
||||
|
||||
# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
|
||||
# hand -- there is no shared source between QML and a shell script), primary
|
||||
# hue per scheme only: the lock screen shows a flat focus ring, not the
|
||||
# two-stop gradient the window border does. Falls back to blue for an unknown
|
||||
# name, matching Theme.accentPair's own fallback.
|
||||
accent_hex() {
|
||||
local name="$1" scheme="$2"
|
||||
case "$name" in
|
||||
orchid) [[ "$scheme" == light ]] && printf '7847bd' || printf 'c099ff' ;;
|
||||
teal) [[ "$scheme" == light ]] && printf '007197' || printf '86e1fc' ;;
|
||||
green) [[ "$scheme" == light ]] && printf '587539' || printf 'c3e88d' ;;
|
||||
amber) [[ "$scheme" == light ]] && printf '8c6c3e' || printf 'ffc777' ;;
|
||||
orange) [[ "$scheme" == light ]] && printf 'b15c00' || printf 'ff966c' ;;
|
||||
rose) [[ "$scheme" == light ]] && printf 'f52a65' || printf 'ff757f' ;;
|
||||
slate) [[ "$scheme" == light ]] && printf '6172b0' || printf '828bb8' ;;
|
||||
*) [[ "$scheme" == light ]] && printf '2e7de9' || printf '82aaff' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# hyprlock wants "R, G, B" decimal, not hex.
|
||||
hex_to_rgb() {
|
||||
local hex="$1"
|
||||
printf '%d, %d, %d' "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}"
|
||||
}
|
||||
|
||||
load_preferences() {
|
||||
if [[ -r "$settings" ]] && jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then
|
||||
settings_valid=true
|
||||
@@ -99,6 +125,12 @@ load_preferences() {
|
||||
color_scheme="$(read_string colorScheme dark)"
|
||||
[[ "$color_scheme" == dark || "$color_scheme" == light ]] || color_scheme=dark
|
||||
|
||||
accent_name="$(read_string accentName blue)"
|
||||
case "$accent_name" in
|
||||
blue|orchid|teal|green|amber|orange|rose|slate) ;;
|
||||
*) accent_name=blue ;;
|
||||
esac
|
||||
|
||||
wallpaper_mode="$(read_string wallpaperMode single)"
|
||||
[[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \
|
||||
|| wallpaper_mode=single
|
||||
@@ -121,8 +153,6 @@ load_preferences() {
|
||||
background_color='rgba(225, 226, 231, 1.0)'
|
||||
foreground_color='rgba(55, 96, 191, 1.0)'
|
||||
dim_color='rgba(97, 114, 176, 1.0)'
|
||||
accent_color='rgba(46, 125, 233, 1.0)'
|
||||
accent_ring_color='rgba(46, 125, 233, 0.9)'
|
||||
error_color='rgba(245, 42, 101, 1.0)'
|
||||
field_color='rgba(208, 213, 227, 0.85)'
|
||||
dim_hex='6172b0'
|
||||
@@ -131,13 +161,18 @@ load_preferences() {
|
||||
background_color='rgba(34, 36, 54, 1.0)'
|
||||
foreground_color='rgba(200, 211, 245, 1.0)'
|
||||
dim_color='rgba(130, 139, 184, 1.0)'
|
||||
accent_color='rgba(130, 170, 255, 1.0)'
|
||||
accent_ring_color='rgba(130, 170, 255, 0.9)'
|
||||
error_color='rgba(255, 117, 127, 1.0)'
|
||||
field_color='rgba(46, 47, 61, 0.85)'
|
||||
dim_hex='828bb8'
|
||||
error_hex='ff757f'
|
||||
fi
|
||||
|
||||
# The focus ring is the accent role, not a scheme-relative one: it follows
|
||||
# accentName the same way ColorScheme.qml's active_border does, and needs
|
||||
# both the accent and the scheme since each accent carries a separate pair.
|
||||
accent_rgb="$(hex_to_rgb "$(accent_hex "$accent_name" "$color_scheme")")"
|
||||
accent_color="rgba($accent_rgb, 1.0)"
|
||||
accent_ring_color="rgba($accent_rgb, 0.9)"
|
||||
}
|
||||
|
||||
monitor_names() {
|
||||
|
||||
@@ -71,7 +71,7 @@ adjust_microphone() {
|
||||
|
||||
brightness_percent() {
|
||||
local output="$1" percent
|
||||
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
|
||||
percent="$(awk -F, 'NR == 1 { value=$4; gsub(/%/, "", value); print value }' <<<"$output")"
|
||||
[[ $percent =~ ^[0-9]+$ ]] || return 1
|
||||
printf '%s\n' "$percent"
|
||||
}
|
||||
|
||||
@@ -67,15 +67,19 @@ cmd_list() {
|
||||
}
|
||||
|
||||
cmd_set() {
|
||||
local profile="${1:-}"
|
||||
local profile="${1:-}" status
|
||||
# Constrained rather than passed through: this reaches a system service.
|
||||
[[ "$profile" =~ ^[a-z-]+$ ]] || {
|
||||
printf 'panama-power-profile: refusing a profile name with unexpected characters\n' >&2
|
||||
return 2
|
||||
}
|
||||
# pipefail is set above, so $? here is busctl's real exit status, not
|
||||
# head's -- a failed write (daemon stopped, profile rejected) must be
|
||||
# reported rather than always claimed as a success.
|
||||
busctl set-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" ActiveProfile s "$profile" 2>&1 >/dev/null \
|
||||
| head -2 >&2
|
||||
return 0
|
||||
status=$?
|
||||
return "$status"
|
||||
}
|
||||
|
||||
case "${1:-list}" in
|
||||
|
||||
@@ -16,21 +16,61 @@
|
||||
# is authoritative for GTK3 applications. Pinned to dark, it contradicted the
|
||||
# scheme in light mode, so it is generated from a template here instead.
|
||||
#
|
||||
# panama-theme-apps dark|light
|
||||
# panama-theme-apps dark|light [accent]
|
||||
#
|
||||
# kitty gets it twice: the generated include file so terminals opened later
|
||||
# start correct, and a live `set-colors` over its control socket so terminals
|
||||
# already open change now. Without the second, a scheme change appears to do
|
||||
# nothing until you open a new window.
|
||||
#
|
||||
# kitty's active_border_color and hyprlock's focus ring are also the accent
|
||||
# role, not just the scheme -- see accent_hex() below, which mirrors the same
|
||||
# lookup hypr/looks.lua and scripts/panama-lock carry for the same reason.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
scheme="${1:-dark}"
|
||||
case "$scheme" in
|
||||
dark|light) ;;
|
||||
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
|
||||
*) printf 'usage: panama-theme-apps [dark|light] [accent]\n' >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# Falls back to blue for an unknown or omitted name, matching Theme.qml's own
|
||||
# accentPair fallback -- so a caller that only ever knew about scheme (a stale
|
||||
# ColorScheme.qml, a manual invocation) still gets a real accent instead of an
|
||||
# empty string reaching sed.
|
||||
accent="${2:-blue}"
|
||||
case "$accent" in
|
||||
blue|orchid|teal|green|amber|orange|rose|slate) ;;
|
||||
*) accent=blue ;;
|
||||
esac
|
||||
|
||||
# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
|
||||
# hand -- there is no shared source between QML and a shell script), primary
|
||||
# hue per scheme only: both consumers below draw a flat colour, not the
|
||||
# two-stop gradient the window border does.
|
||||
accent_hex() {
|
||||
local name="$1" scheme="$2"
|
||||
case "$name" in
|
||||
orchid) [[ "$scheme" == light ]] && printf '7847bd' || printf 'c099ff' ;;
|
||||
teal) [[ "$scheme" == light ]] && printf '007197' || printf '86e1fc' ;;
|
||||
green) [[ "$scheme" == light ]] && printf '587539' || printf 'c3e88d' ;;
|
||||
amber) [[ "$scheme" == light ]] && printf '8c6c3e' || printf 'ffc777' ;;
|
||||
orange) [[ "$scheme" == light ]] && printf 'b15c00' || printf 'ff966c' ;;
|
||||
rose) [[ "$scheme" == light ]] && printf 'f52a65' || printf 'ff757f' ;;
|
||||
slate) [[ "$scheme" == light ]] && printf '6172b0' || printf '828bb8' ;;
|
||||
*) [[ "$scheme" == light ]] && printf '2e7de9' || printf '82aaff' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# hyprlock wants "R, G, B" decimal, not hex.
|
||||
hex_to_rgb() {
|
||||
local hex="$1"
|
||||
printf '%d, %d, %d' "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}"
|
||||
}
|
||||
|
||||
accent_border_hex="$(accent_hex "$accent" "$scheme")"
|
||||
|
||||
# ── hyprlock ─────────────────────────────────────────────────────────────────
|
||||
# The lock screen. hyprlock is launched fresh on every lock (`pidof hyprlock ||
|
||||
# hyprlock`), so it reads this file each time and needs no restart.
|
||||
@@ -45,7 +85,6 @@ lock_template="$lock_dir/hyprlock.conf.template"
|
||||
if [[ "$scheme" == "light" ]]; then
|
||||
lock_fg="55, 96, 191" # #3760bf
|
||||
lock_muted="97, 114, 176" # #6172b0
|
||||
lock_accent="46, 125, 233" # #2e7de9
|
||||
lock_error="245, 42, 101" # #f52a65
|
||||
lock_bg="225, 226, 231" # #e1e2e7
|
||||
lock_field="208, 213, 227" # #d0d5e3
|
||||
@@ -54,7 +93,6 @@ if [[ "$scheme" == "light" ]]; then
|
||||
else
|
||||
lock_fg="200, 211, 245" # #c8d3f5
|
||||
lock_muted="130, 139, 184" # #828bb8
|
||||
lock_accent="130, 170, 255" # #82aaff
|
||||
lock_error="255, 117, 127" # #ff757f
|
||||
lock_bg="34, 36, 54" # #222436
|
||||
lock_field="46, 47, 61" # #2e2f3d
|
||||
@@ -62,6 +100,10 @@ else
|
||||
lock_error_hex="ff757f"
|
||||
fi
|
||||
|
||||
# The focus ring is the accent role, not a scheme-relative one -- follows
|
||||
# accentName the same way scripts/panama-lock's own generator does.
|
||||
lock_accent="$(hex_to_rgb "$accent_border_hex")"
|
||||
|
||||
status_hyprlock="skipped"
|
||||
if [[ -r "$lock_template" ]]; then
|
||||
# Written atomically: a lock triggered mid-write would otherwise read a
|
||||
@@ -169,9 +211,16 @@ theme_file="$kitty_dir/themes/tokyonight-moon.conf"
|
||||
status_kitty="skipped"
|
||||
if [[ -r "$theme_file" ]]; then
|
||||
# Written atomically: kitty may read this while a new window is starting.
|
||||
# The accent override is appended after the copied theme rather than
|
||||
# edited into the tracked theme file: kitty applies settings top to
|
||||
# bottom, so a later active_border_color line wins, and this file is
|
||||
# itself generated -- the tracked per-scheme theme stays scheme-only.
|
||||
if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \
|
||||
&& printf 'active_border_color #%s\n' "$accent_border_hex" >>"$kitty_dir/current-theme.conf.tmp" \
|
||||
&& mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then
|
||||
status_kitty="written"
|
||||
else
|
||||
rm -f "$kitty_dir/current-theme.conf.tmp"
|
||||
fi
|
||||
|
||||
# Live-apply to running terminals. kitty appends its PID to the socket name
|
||||
@@ -182,7 +231,8 @@ if [[ -r "$theme_file" ]]; then
|
||||
applied=0
|
||||
while read -r socket; do
|
||||
[[ -n "$socket" ]] || continue
|
||||
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1; then
|
||||
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1 \
|
||||
&& kitty @ --to "unix:@${socket#@}" set-colors --override "active_border_color=#$accent_border_hex" >/dev/null 2>&1; then
|
||||
applied=$((applied + 1))
|
||||
fi
|
||||
done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u)
|
||||
|
||||
@@ -26,6 +26,16 @@
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# The payload file (created in cmd_qr) is tracked at script scope so it can be
|
||||
# removed no matter how the script exits -- success, an emit_error exit 0, or a
|
||||
# signal -- rather than only on a clean function return.
|
||||
payload_file=""
|
||||
|
||||
cleanup_payload() {
|
||||
[[ -n $payload_file ]] && rm -f -- "$payload_file"
|
||||
}
|
||||
trap cleanup_payload EXIT
|
||||
|
||||
emit_error() {
|
||||
printf '{"networks":[],"path":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
|
||||
exit 0
|
||||
@@ -35,22 +45,33 @@ command -v nmcli >/dev/null 2>&1 || emit_error 'NetworkManager is not available'
|
||||
command -v qrencode >/dev/null 2>&1 || emit_error 'qrencode is not installed, so a Wi-Fi QR code cannot be drawn'
|
||||
|
||||
cmd_list() {
|
||||
local rows=() name ssid psk
|
||||
local rows=() name type ssid psk
|
||||
while IFS= read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
|
||||
|
||||
# nmcli's terse mode backslash-escapes ':' and '\' WITHIN a field so a
|
||||
# combined NAME,TYPE line stays splittable -- but a plain awk -F:
|
||||
# doesn't know that, so a name containing either character (e.g.
|
||||
# "Cafe: Guest") gets split in the wrong place and TYPE no longer
|
||||
# lines up, silently dropping the connection from this list. Querying
|
||||
# one field at a time with escaping turned off (-e no) sidesteps the
|
||||
# problem entirely: there's nothing to split, so each value comes
|
||||
# back exactly as stored.
|
||||
type="$(nmcli -e no -g connection.type connection show "$name" 2>/dev/null)"
|
||||
[[ "$type" == "802-11-wireless" ]] || continue
|
||||
|
||||
ssid="$(nmcli -e no -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
|
||||
[[ -n "$ssid" ]] || ssid="$name"
|
||||
|
||||
# Only networks whose passphrase this user can actually read are
|
||||
# shareable. An enterprise network has no passphrase to share at all,
|
||||
# and a QR code for one would simply not work.
|
||||
psk="$(nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null)"
|
||||
psk="$(nmcli -e no -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null)"
|
||||
|
||||
rows+=("$(jq -cn --arg name "$name" --arg ssid "$ssid" \
|
||||
--argjson shareable "$([[ -n "$psk" ]] && echo true || echo false)" \
|
||||
'{name: $name, ssid: $ssid, shareable: $shareable}')")
|
||||
done < <(nmcli -t -f NAME,TYPE connection show 2>/dev/null \
|
||||
| awk -F: '$2 == "802-11-wireless" { print $1 }')
|
||||
done < <(nmcli -e no -t -f NAME connection show 2>/dev/null)
|
||||
|
||||
if [[ ${#rows[@]} -eq 0 ]]; then
|
||||
printf '{"networks":[],"path":"","error":"No saved Wi-Fi networks."}\n'
|
||||
@@ -77,11 +98,16 @@ cmd_qr() {
|
||||
local name="${1:-}"
|
||||
[[ -n "$name" ]] || emit_error 'no network named'
|
||||
|
||||
local ssid hidden psk_file payload_file out_dir out_file
|
||||
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
|
||||
local ssid hidden psk_file out_dir out_file
|
||||
# -e no here too: $name is the literal connection name (list emits it
|
||||
# un-escaped -- see cmd_list), and nmcli's terse escaping is a one-way
|
||||
# transform on VALUES, not something connection-show lookups expect on
|
||||
# their NAME argument. Escaping $ssid/$psk here would feed escape_field
|
||||
# an already-escaped value below and double-escape it.
|
||||
ssid="$(nmcli -e no -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
|
||||
[[ -n "$ssid" ]] || emit_error "There is no saved network called \"$name\"."
|
||||
|
||||
hidden="$(nmcli -g 802-11-wireless.hidden connection show "$name" 2>/dev/null)"
|
||||
hidden="$(nmcli -e no -g 802-11-wireless.hidden connection show "$name" 2>/dev/null)"
|
||||
[[ "$hidden" == "yes" ]] && hidden=true || hidden=false
|
||||
|
||||
out_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/panama"
|
||||
@@ -96,13 +122,12 @@ cmd_qr() {
|
||||
# Built in a file rather than a variable that could be echoed, and piped to
|
||||
# qrencode on stdin so the passphrase never appears in argv.
|
||||
payload_file="$(mktemp "$out_dir/payload.XXXXXX")" || emit_error 'could not create a temporary file'
|
||||
trap 'rm -f "$payload_file"' RETURN
|
||||
|
||||
{
|
||||
printf 'WIFI:T:WPA;S:'
|
||||
printf '%s' "$ssid" | escape_field
|
||||
printf ';P:'
|
||||
nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null | escape_field
|
||||
nmcli -e no -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null | escape_field
|
||||
printf ';H:%s;;' "$hidden"
|
||||
} >"$payload_file"
|
||||
|
||||
|
||||
@@ -138,6 +138,9 @@ Singleton {
|
||||
Process {
|
||||
id: writer
|
||||
onExited: {
|
||||
reader.exited = false;
|
||||
reader.streamFinished = false;
|
||||
reader.settled = false;
|
||||
reader.command = [root.helperPath, "get", String(root.writingBus)];
|
||||
reader.running = true;
|
||||
}
|
||||
@@ -145,9 +148,36 @@ Singleton {
|
||||
|
||||
Process {
|
||||
id: reader
|
||||
|
||||
property string outputText: ""
|
||||
property bool exited: false
|
||||
property bool streamFinished: false
|
||||
property bool settled: false
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const actual = parseInt(this.text.trim());
|
||||
reader.outputText = this.text;
|
||||
reader.streamFinished = true;
|
||||
root.settleReader();
|
||||
}
|
||||
}
|
||||
|
||||
// `running` can still read true at the moment onStreamFinished fires --
|
||||
// the same exited/streamFinished ordering hazard HomeAssistantConfig.qml
|
||||
// guards against -- so pump() must not be re-entered from here directly.
|
||||
// Settle on whichever of exited/streamFinished arrives last instead.
|
||||
onExited: (code, status) => {
|
||||
reader.exited = true;
|
||||
root.settleReader();
|
||||
}
|
||||
}
|
||||
|
||||
function settleReader(): void {
|
||||
if (reader.settled || !reader.exited || !reader.streamFinished)
|
||||
return;
|
||||
reader.settled = true;
|
||||
|
||||
const actual = parseInt(reader.outputText.trim());
|
||||
if (!isNaN(actual)) {
|
||||
root.displays = root.displays.map(display =>
|
||||
display.bus === root.writingBus
|
||||
@@ -158,6 +188,4 @@ Singleton {
|
||||
// Anything queued while this write was in flight goes now.
|
||||
root.pump();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,13 @@ Singleton {
|
||||
property int fixtureNow: 0
|
||||
property bool watchWanted: false
|
||||
|
||||
// Counts watch attempts that exit without ever emitting a snapshot, so a
|
||||
// helper that is missing or crashes on launch (e.g. no evolution-data-server)
|
||||
// is reported instead of retried forever in silence.
|
||||
property int consecutiveWatchFailures: 0
|
||||
property bool watchProducedSnapshot: false
|
||||
readonly property int maxConsecutiveWatchFailures: 3
|
||||
|
||||
readonly property int nowEpoch: fixtureNow > 0 ? fixtureNow : Math.floor(clock.date.getTime() / 1000)
|
||||
readonly property var nextEvent: {
|
||||
const candidates = root.events.filter(event => !event.allDay && Number(event.end) > root.nowEpoch);
|
||||
@@ -135,14 +142,20 @@ Singleton {
|
||||
function _restartWatch(): void {
|
||||
if (root.fixtureMode || root.rangeStart <= 0 || root.rangeEnd <= root.rangeStart)
|
||||
return;
|
||||
// Once the helper has been declared unavailable, don't flash back to
|
||||
// "loading" on every retry -- only a real snapshot should clear it.
|
||||
if (root.phase !== "unavailable")
|
||||
root.phase = root.events.length > 0 ? root.phase : "loading";
|
||||
root.watchWanted = false;
|
||||
root.watchProducedSnapshot = false;
|
||||
startTimer.restart();
|
||||
}
|
||||
|
||||
function consumeSnapshot(data: string): void {
|
||||
if (root.fixtureMode || data.trim() === "")
|
||||
return;
|
||||
root.watchProducedSnapshot = true;
|
||||
root.consecutiveWatchFailures = 0;
|
||||
try {
|
||||
const snapshot = JSON.parse(data);
|
||||
if (snapshot.ok !== true) {
|
||||
@@ -263,6 +276,7 @@ Singleton {
|
||||
root.errors = [];
|
||||
root.selectedDate = new Date();
|
||||
root.phase = "loading";
|
||||
root.consecutiveWatchFailures = 0;
|
||||
root.setVisibleMonth(root.selectedDate.getFullYear(), root.selectedDate.getMonth());
|
||||
root._restartWatch();
|
||||
}
|
||||
@@ -275,6 +289,16 @@ Singleton {
|
||||
onRead: data => root.consumeSnapshot(data)
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
if (!root.watchProducedSnapshot) {
|
||||
root.consecutiveWatchFailures += 1;
|
||||
// The helper died before ever emitting a snapshot, repeatedly --
|
||||
// stop pretending this is still loading and surface the same
|
||||
// "unavailable" state probe()/collect_snapshot() report.
|
||||
if (root.consecutiveWatchFailures >= root.maxConsecutiveWatchFailures) {
|
||||
root.phase = "unavailable";
|
||||
root.errors = [{ code: "eds-unavailable" }];
|
||||
}
|
||||
}
|
||||
if (root.watchWanted && !root.fixtureMode)
|
||||
restartTimer.restart();
|
||||
}
|
||||
|
||||
@@ -402,20 +402,35 @@ esac'
|
||||
id: recProc
|
||||
onExited: (code, status) => {
|
||||
recTimer.stop();
|
||||
const path = root.recordingPath;
|
||||
// SIGINT gives exit code 2 (or 130 through a shell); both mean the
|
||||
// user pressed stop and the file was finalised normally.
|
||||
if (root.recordingPath !== "") {
|
||||
// user pressed stop and the file was finalised normally. Any other
|
||||
// code means wf-recorder died or errored before finalising, so the
|
||||
// file may be missing or truncated -- never report success then.
|
||||
const clean = code === 2 || code === 130;
|
||||
if (path !== "") {
|
||||
if (clean) {
|
||||
StatusEvents.publish({
|
||||
key: "capture-recording",
|
||||
glyph: "\u{F044A}",
|
||||
title: "Screen recording saved",
|
||||
detail: root.recordingPath.split("/").pop(),
|
||||
detail: path.split("/").pop(),
|
||||
tone: "ok",
|
||||
priority: StatusEvents.importantPriority,
|
||||
actionId: "open-path",
|
||||
actionData: root.recordingPath
|
||||
actionData: path
|
||||
});
|
||||
Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", root.recordingPath]);
|
||||
Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", path]);
|
||||
} else {
|
||||
StatusEvents.publish({
|
||||
key: "capture-recording",
|
||||
glyph: "\u{F044A}",
|
||||
title: "Screen recording failed",
|
||||
detail: "wf-recorder exited unexpectedly (code " + code + ")",
|
||||
tone: "warn",
|
||||
priority: StatusEvents.importantPriority
|
||||
});
|
||||
}
|
||||
}
|
||||
root.recordingPath = "";
|
||||
root.recordingSeconds = 0;
|
||||
|
||||
@@ -55,6 +55,18 @@ Singleton {
|
||||
// installed. Distinct from an empty history.
|
||||
property bool available: true
|
||||
|
||||
// query's `exited` and its stdout `streamFinished` are not guaranteed to
|
||||
// fire in a particular order (the same signal-ordering hazard
|
||||
// HomeAssistantConfig.qml's settle-both pattern guards against). These
|
||||
// track which of the two have arrived for the query currently in flight
|
||||
// so the result is only finalized once both are known -- otherwise a
|
||||
// failed query's empty stdout could be parsed as "empty history" before
|
||||
// the non-zero exit code is seen, or vice versa.
|
||||
property bool _queryExited: false
|
||||
property int _queryExitCode: 0
|
||||
property bool _queryStdoutDone: false
|
||||
property string _queryStdoutText: ""
|
||||
|
||||
// Wall-clock seconds at the moment `entries` was filled. Relative times are
|
||||
// rendered against this rather than against a live clock, so a row's label
|
||||
// cannot change while the user is reading it and nothing has to tick.
|
||||
@@ -69,6 +81,8 @@ Singleton {
|
||||
if (query.running)
|
||||
return;
|
||||
root.loading = true;
|
||||
root._queryExited = false;
|
||||
root._queryStdoutDone = false;
|
||||
query.running = true;
|
||||
}
|
||||
|
||||
@@ -146,16 +160,18 @@ Singleton {
|
||||
command: ["sqlite3", "-readonly", "-json", root.dbPath, root.sql]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root._parse(this.text)
|
||||
onStreamFinished: {
|
||||
root._queryStdoutDone = true;
|
||||
root._queryStdoutText = this.text;
|
||||
root._settleQuery();
|
||||
}
|
||||
}
|
||||
|
||||
onExited: exitCode => {
|
||||
root.loading = false;
|
||||
if (exitCode !== 0) {
|
||||
root.available = false;
|
||||
root.entries = [];
|
||||
root.refreshed();
|
||||
}
|
||||
root._queryExited = true;
|
||||
root._queryExitCode = exitCode;
|
||||
root._settleQuery();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +185,29 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
// Called from both query.onExited and its stdout streamFinished. Only
|
||||
// finalizes once both signals have arrived, since their firing order is
|
||||
// not guaranteed -- see the _queryExited / _queryStdoutDone comment
|
||||
// above. A non-zero exit always means the history is unavailable,
|
||||
// regardless of what (if anything) stdout produced; only a clean exit
|
||||
// reaches _parse, where empty stdout is legitimately "no history yet".
|
||||
function _settleQuery(): void {
|
||||
if (!root._queryExited || !root._queryStdoutDone)
|
||||
return;
|
||||
const exitCode = root._queryExitCode;
|
||||
const text = root._queryStdoutText;
|
||||
root._queryExited = false;
|
||||
root._queryStdoutDone = false;
|
||||
|
||||
if (exitCode !== 0) {
|
||||
root.available = false;
|
||||
root.entries = [];
|
||||
root.refreshed();
|
||||
return;
|
||||
}
|
||||
root._parse(text);
|
||||
}
|
||||
|
||||
function _parse(text: string): void {
|
||||
root.available = true;
|
||||
root.queriedAt = Date.now() / 1000;
|
||||
|
||||
@@ -29,8 +29,28 @@ Singleton {
|
||||
readonly property string inactiveBorderDark: "rgba(3b426199)"
|
||||
readonly property string inactiveBorderLight: "rgba(a8aecb99)"
|
||||
readonly property string inactiveBorder: root.dark ? root.inactiveBorderDark : root.inactiveBorderLight
|
||||
|
||||
// The focused border follows the chosen accent. `ee` is the shipped alpha
|
||||
// for the Prism gradient; Theme owns which colours, this owns the form the
|
||||
// compositor wants them in.
|
||||
function hyprColor(value: var): string {
|
||||
// Qt gives "#rrggbb" -- or "#aarrggbb" if the colour ever carries an
|
||||
// alpha channel. slice(-6) keeps the trailing rrggbb either way;
|
||||
// slice(0, 6) would instead grab "aarrgg" out of an 8-digit string and
|
||||
// call it RGB. Hyprland wants rgba(rrggbbaa).
|
||||
return "rgba(" + String(value).replace("#", "").slice(-6) + "ee)";
|
||||
}
|
||||
readonly property string accentBorderStart: root.hyprColor(Theme.accent)
|
||||
readonly property string accentBorderEnd: root.hyprColor(Theme.accentSecondary)
|
||||
property string lastError: ""
|
||||
|
||||
// What the last push actually sent, so apply() can tell an accent-only
|
||||
// change apart from a scheme change and skip the steps that do not depend
|
||||
// on whichever did not move. Their starting values do not matter: the
|
||||
// first apply() always runs with force set, which ignores both.
|
||||
property bool appliedDark: false
|
||||
property string appliedAccentName: ""
|
||||
|
||||
// Applied one command at a time: Process runs a single command, and several
|
||||
// of these are separate programs.
|
||||
property var pending: []
|
||||
@@ -65,7 +85,7 @@ Singleton {
|
||||
Timer {
|
||||
id: settle
|
||||
interval: 1200
|
||||
onTriggered: root.apply()
|
||||
onTriggered: root.apply(true)
|
||||
}
|
||||
|
||||
Connections {
|
||||
@@ -76,12 +96,30 @@ Singleton {
|
||||
Timer {
|
||||
id: coalesce
|
||||
interval: 250
|
||||
onTriggered: root.apply()
|
||||
onTriggered: root.apply(false)
|
||||
}
|
||||
|
||||
function apply(): void {
|
||||
// `force` pushes every step regardless of what moved -- startup needs
|
||||
// that, because gsettings and the compositor keep their own state and
|
||||
// have no way to know a previous Quickshell session already told them the
|
||||
// answer. Everywhere else this reacts to ANY preference changing (see
|
||||
// onRevisionChanged above), so most calls have nothing to do with either
|
||||
// scheme or accent; only the steps whose inputs actually moved since the
|
||||
// last push run, which keeps sampling accent swatches from also rewriting
|
||||
// gsettings and re-running the whole app-theming script on every sample.
|
||||
function apply(force: bool): void {
|
||||
root.lastError = "";
|
||||
|
||||
const accentName = DesktopPreferences.get("accentName") || "blue";
|
||||
const schemeChanged = force || root.dark !== root.appliedDark;
|
||||
const accentChanged = force || accentName !== root.appliedAccentName;
|
||||
|
||||
if (!schemeChanged && !accentChanged)
|
||||
return;
|
||||
|
||||
const commands = [];
|
||||
|
||||
if (schemeChanged) {
|
||||
const scheme = root.dark ? "prefer-dark" : "prefer-light";
|
||||
|
||||
// adw-gtk3, not Adwaita. This is the bug that made dark mode look
|
||||
@@ -99,21 +137,51 @@ Singleton {
|
||||
// because the failure mode is silent in exactly this way.
|
||||
const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";
|
||||
|
||||
const commands = [
|
||||
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
|
||||
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
|
||||
];
|
||||
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme]);
|
||||
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]);
|
||||
|
||||
// Unfocused window borders need scheme-relative contrast. The focused
|
||||
// Prism border is deliberately owned by the accent/theme layer.
|
||||
// Unfocused window borders need scheme-relative contrast, and stay
|
||||
// this service's to own. The FOCUSED border below is the accent
|
||||
// role and belongs to the theme -- see modules/settings/README.md.
|
||||
// Unlike that role, this one does not depend on the accent, so an
|
||||
// accent-only change never needs to restate it.
|
||||
commands.push(["hyprctl", "eval",
|
||||
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
|
||||
}
|
||||
|
||||
// Applications that predate org.freedesktop.appearance and carry their
|
||||
// own palettes -- terminals, chiefly. Everything that reads the portal
|
||||
// (GTK4, Qt6, Chromium, Electron) is already handled by the gsettings
|
||||
// write above and needs nothing here.
|
||||
commands.push([root.appThemePath, root.dark ? "dark" : "light"]);
|
||||
if (schemeChanged || accentChanged) {
|
||||
// Each accent carries a separate pair for light and dark, so this
|
||||
// runs on either kind of change: a scheme flip restates the same
|
||||
// accent's other pair, and an accent change restates the same
|
||||
// scheme's other colours.
|
||||
//
|
||||
// A two-stop gradient at the shipped angle. Written as a Lua TABLE:
|
||||
// the string form of a gradient carries only one stop, and passing
|
||||
// "rgba(a) rgba(b) 115deg" as a string is accepted and silently
|
||||
// keeps the previous value. Multi-stop must be the table form --
|
||||
// built by the same serialiseValue() SystemSettings uses for every
|
||||
// other gradient, rather than a second hand-rolled copy of that
|
||||
// escaping here.
|
||||
const activeBorder = SystemSettings.serialiseValue({
|
||||
colors: [root.accentBorderStart, root.accentBorderEnd],
|
||||
angle: 115
|
||||
});
|
||||
commands.push(["hyprctl", "eval",
|
||||
`hl.config({ general = { col = { active_border = ${activeBorder} } } })`]);
|
||||
|
||||
// Applications that predate org.freedesktop.appearance and carry
|
||||
// their own palettes -- terminals, chiefly. Everything that reads
|
||||
// the portal (GTK4, Qt6, Chromium, Electron) is already handled by
|
||||
// the gsettings write above and needs nothing here. The accent is
|
||||
// passed through too: kitty's border and the hyprlock fallback
|
||||
// template are also the accent role, not just the scheme -- so
|
||||
// this still has to run on an accent-only change, even though the
|
||||
// scheme-relative work it also does is then redundant.
|
||||
commands.push([root.appThemePath, root.dark ? "dark" : "light", accentName]);
|
||||
}
|
||||
|
||||
root.appliedDark = root.dark;
|
||||
root.appliedAccentName = accentName;
|
||||
|
||||
root.enqueue(commands);
|
||||
}
|
||||
|
||||
@@ -146,4 +146,11 @@ Singleton {
|
||||
onWifiDeviceChanged: root.syncScanners()
|
||||
onWifiEnabledChanged: root.syncScanners()
|
||||
onAdapterChanged: root.syncScanners()
|
||||
|
||||
// adapter.enabled has no property on root to bind onXChanged to, so it
|
||||
// needs its own Connections -- the Bluetooth equivalent of onWifiEnabledChanged.
|
||||
Connections {
|
||||
target: root.adapter
|
||||
function onEnabledChanged(): void { root.syncScanners(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,6 @@ Singleton {
|
||||
root.revertWithMessage("The display rejected that change and Panama restored the previous setting.");
|
||||
return;
|
||||
}
|
||||
verifyTimer.attempts = 0;
|
||||
verifyTimer.ticks = 0;
|
||||
verifyTimer.restart();
|
||||
}
|
||||
@@ -107,7 +106,6 @@ Singleton {
|
||||
// Exit status is advisory only. Hyprland's Lua bridge can report
|
||||
// success without applying a value, so exact readback decides.
|
||||
root.revertVerificationActive = true;
|
||||
revertVerifyTimer.attempts = 0;
|
||||
revertVerifyTimer.ticks = 0;
|
||||
revertVerifyTimer.restart();
|
||||
}
|
||||
@@ -542,7 +540,6 @@ Singleton {
|
||||
|
||||
Timer {
|
||||
id: verifyTimer
|
||||
property int attempts: 0
|
||||
property int ticks: 0
|
||||
interval: 120
|
||||
repeat: true
|
||||
@@ -552,14 +549,12 @@ Singleton {
|
||||
root.verificationTimedOut();
|
||||
return;
|
||||
}
|
||||
if (root.refresh())
|
||||
attempts++;
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: revertVerifyTimer
|
||||
property int attempts: 0
|
||||
property int ticks: 0
|
||||
interval: 120
|
||||
repeat: true
|
||||
@@ -569,8 +564,7 @@ Singleton {
|
||||
root.revertVerificationTimedOut();
|
||||
return;
|
||||
}
|
||||
if (root.refresh())
|
||||
attempts++;
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@ Singleton {
|
||||
root.searching = false;
|
||||
if (exitCode !== 0)
|
||||
root.lastError = "Could not reach the location service.";
|
||||
|
||||
// Typing kept going while this fetch was in flight -- rather than
|
||||
// leaving the newer query stranded until another keystroke, go
|
||||
// fetch it now. run() no-ops if pending is now too short.
|
||||
if (root.pending !== root.lastQuery)
|
||||
root.run();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,16 @@ Singleton {
|
||||
|
||||
property string payload: ""
|
||||
|
||||
onStarted: copyProcess.write(copyProcess.payload)
|
||||
stdinEnabled: true
|
||||
onStarted: {
|
||||
copyProcess.write(copyProcess.payload);
|
||||
// wl-copy reads stdin until EOF before it exits; leaving the
|
||||
// channel open (Process.write alone never closes it) would hang
|
||||
// it forever waiting for more input. Disabling stdin closes the
|
||||
// write side -- see HomeAssistantConfig.qml's writeProc for the
|
||||
// same stdinEnabled pattern.
|
||||
copyProcess.stdinEnabled = false;
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.lastCopyResult = exitCode === 0
|
||||
? "Report copied."
|
||||
@@ -339,6 +348,10 @@ Singleton {
|
||||
return false;
|
||||
copyProcess.payload = JSON.stringify(root.snapshot, null, 2);
|
||||
root.lastCopyResult = "";
|
||||
// Re-arm stdin: the previous run closed it (see copyProcess.onStarted)
|
||||
// and a disabled channel stays closed even after being set back to
|
||||
// true mid-run, so each new run needs it explicitly re-enabled.
|
||||
copyProcess.stdinEnabled = true;
|
||||
copyProcess.exec(["wl-copy"]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,16 @@ Singleton {
|
||||
property string actionKind: ""
|
||||
property string actionPath: ""
|
||||
|
||||
// actionProc's `exited` and its stdout `streamFinished` are not guaranteed
|
||||
// to fire in a particular order (same hazard HomeAssistantConfig.qml's
|
||||
// settle-both pattern guards against). These track which of the two have
|
||||
// been observed for the action currently in flight so finishAction() is
|
||||
// only ever called once both have arrived, with the real stdout JSON as
|
||||
// the authoritative result.
|
||||
property bool actionExited: false
|
||||
property bool actionStdoutDone: false
|
||||
property string actionStdoutText: ""
|
||||
|
||||
readonly property var preferredPhone: {
|
||||
const phones = root.devices.filter(device => device.type === "phone" && device.paired);
|
||||
return phones.find(device => device.reachable) ?? phones[0] ?? null;
|
||||
@@ -66,6 +76,8 @@ Singleton {
|
||||
root.transferActive = true;
|
||||
root.transferFileName = path.split("/").pop();
|
||||
root.transferDeviceName = root.preferredPhone.name;
|
||||
root.actionExited = false;
|
||||
root.actionStdoutDone = false;
|
||||
actionProc.command = [root.helperPath, "send-file", root.preferredPhone.id, path];
|
||||
actionProc.running = true;
|
||||
}
|
||||
@@ -83,6 +95,8 @@ Singleton {
|
||||
return;
|
||||
root.actionKind = kind;
|
||||
root.actionPath = "";
|
||||
root.actionExited = false;
|
||||
root.actionStdoutDone = false;
|
||||
actionProc.command = [root.helperPath, command, root.preferredPhone.id];
|
||||
actionProc.running = true;
|
||||
}
|
||||
@@ -95,6 +109,22 @@ Singleton {
|
||||
root.transferActive = false;
|
||||
root.transferFileName = "";
|
||||
root.transferDeviceName = "";
|
||||
root.actionExited = false;
|
||||
root.actionStdoutDone = false;
|
||||
}
|
||||
|
||||
// Called from both actionProc.onExited and its stdout streamFinished.
|
||||
// Only finalizes once both signals have arrived for the in-flight action,
|
||||
// since their firing order is not guaranteed -- see the actionExited /
|
||||
// actionStdoutDone comment above.
|
||||
function settleAction(): void {
|
||||
if (root.actionKind === "")
|
||||
return;
|
||||
if (!root.actionExited || !root.actionStdoutDone)
|
||||
return;
|
||||
root.actionExited = false;
|
||||
root.actionStdoutDone = false;
|
||||
root.finishAction(root.actionStdoutText);
|
||||
}
|
||||
|
||||
function finishAction(text: string): void {
|
||||
@@ -198,11 +228,15 @@ Singleton {
|
||||
Process {
|
||||
id: actionProc
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.finishAction(this.text)
|
||||
onStreamFinished: {
|
||||
root.actionStdoutDone = true;
|
||||
root.actionStdoutText = this.text;
|
||||
root.settleAction();
|
||||
}
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
if (root.actionKind !== "")
|
||||
root.finishAction("");
|
||||
root.actionExited = true;
|
||||
root.settleAction();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ pragma Singleton
|
||||
// The freedesktop notification server, plus the two lists the UI renders:
|
||||
//
|
||||
// popups — what Toasts.qml is currently showing (transient, timed)
|
||||
// history — GNOME's message tray, what NotificationCenter.qml shows
|
||||
// history — GNOME's message tray, what NotificationList.qml shows
|
||||
//
|
||||
// A notification lives exactly as long as `tracked` is true, so history holds
|
||||
// the *live* objects rather than copies: that keeps actions and inline replies
|
||||
@@ -80,6 +80,21 @@ Singleton {
|
||||
return at ? Qt.formatDateTime(at, Settings.use24Hour ? "HH:mm" : "h:mm AP") : "";
|
||||
}
|
||||
|
||||
// Freedesktop timeout resolution, shared by the toast countdown (Toast.qml)
|
||||
// and the no-display expiry a transient notification gets while Do Not
|
||||
// Disturb is on (below). Critical urgency and an explicit expireTimeout
|
||||
// override policy; -1 ("server decides") falls back to it. 0 means "never
|
||||
// auto-expire" per spec.
|
||||
function notificationTimeoutMs(notification: var): int {
|
||||
if (notification.urgency === NotificationUrgency.Critical)
|
||||
return Settings.notificationTimeoutCriticalMs;
|
||||
if (notification.expireTimeout === 0)
|
||||
return 0;
|
||||
if (notification.expireTimeout > 0)
|
||||
return Math.round(notification.expireTimeout * 1000);
|
||||
return Settings.notificationTimeoutMs;
|
||||
}
|
||||
|
||||
readonly property bool hasNotifications: root.history.length > 0
|
||||
|
||||
function notificationAppId(notification: var): string {
|
||||
@@ -156,7 +171,7 @@ Singleton {
|
||||
}
|
||||
|
||||
// history grouped by app, in most-recent-app-first order — the shape
|
||||
// NotificationCenter.qml renders directly.
|
||||
// NotificationList.qml renders directly.
|
||||
readonly property var groups: {
|
||||
const out = [];
|
||||
const byApp = {};
|
||||
@@ -219,8 +234,44 @@ Singleton {
|
||||
root.unreadCount += 1;
|
||||
}
|
||||
|
||||
if (!root.doNotDisturb)
|
||||
if (!root.doNotDisturb) {
|
||||
root.popups = [notification].concat(root.popups);
|
||||
} else if (notification.transient) {
|
||||
// Never shown, and (being transient) never filed in history
|
||||
// either — nothing will otherwise dismiss() it, so schedule
|
||||
// the same release its popup timeout would have given it.
|
||||
root.scheduleTransientExpiry(notification);
|
||||
}
|
||||
}
|
||||
|
||||
// Runs a DND-hidden transient notification through the same lifetime it
|
||||
// would have gotten as a visible popup (Toast.qml's countdown), just
|
||||
// without ever showing it, so it still gets released instead of staying
|
||||
// tracked forever.
|
||||
function scheduleTransientExpiry(notification: var): void {
|
||||
const ms = root.notificationTimeoutMs(notification);
|
||||
if (ms <= 0)
|
||||
return;
|
||||
|
||||
const timer = Qt.createQmlObject("import QtQuick; Timer { repeat: false }", root);
|
||||
timer.interval = ms;
|
||||
timer.triggered.connect(() => {
|
||||
timer.destroy();
|
||||
root.releaseTransient(notification);
|
||||
});
|
||||
|
||||
// Closed some other way first (app-side close, dismissAll()) — cancel
|
||||
// the pending timer instead of firing a stale dismiss() later.
|
||||
notification.closed.connect(() => timer.destroy());
|
||||
timer.running = true;
|
||||
}
|
||||
|
||||
// Transient notifications are never filed in history, so nothing else
|
||||
// holds a reference once their lifetime ends — release tracked state
|
||||
// directly. Shared by the DND-hidden expiry above and dismissAll() below.
|
||||
function releaseTransient(n: var): void {
|
||||
if (n.transient)
|
||||
n.dismiss();
|
||||
}
|
||||
|
||||
// ── Mutation ────────────────────────────────────────────────────────────
|
||||
@@ -243,10 +294,7 @@ Singleton {
|
||||
if (next.length === root.popups.length)
|
||||
return;
|
||||
root.popups = next;
|
||||
|
||||
// Transients were never in history, so nothing else holds them.
|
||||
if (n.transient)
|
||||
n.dismiss();
|
||||
root.releaseTransient(n);
|
||||
}
|
||||
|
||||
function dismiss(n: Notification): void {
|
||||
@@ -257,10 +305,19 @@ Singleton {
|
||||
// Copy first: dismiss() re-enters through forget() and rewrites both
|
||||
// lists while we iterate.
|
||||
const all = root.history.slice();
|
||||
|
||||
// Transient notifications are never filed in history — whether
|
||||
// currently shown as a popup or hidden by Do Not Disturb with a
|
||||
// pending scheduleTransientExpiry() timer — so releasing them needs
|
||||
// its own pass over the server's full tracked set.
|
||||
const transients = root.active.values.filter(n => n.transient);
|
||||
|
||||
root.history = [];
|
||||
root.popups = [];
|
||||
for (const n of all)
|
||||
n.dismiss();
|
||||
for (const n of transients)
|
||||
root.releaseTransient(n);
|
||||
root.unreadCount = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ Singleton {
|
||||
// entry here still appears in results and routes to Home rather than being
|
||||
// dropped, so adding a group can never make a setting unreachable.
|
||||
readonly property var groupPages: ({
|
||||
"appearance": "appearance",
|
||||
"clock": "appearance",
|
||||
"vitals": "appearance",
|
||||
"typography": "appearance",
|
||||
|
||||
@@ -35,13 +35,32 @@ Singleton {
|
||||
|
||||
function setEventSounds(enabled: bool): void {
|
||||
root.eventSounds = enabled;
|
||||
eventWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "event-sounds", String(enabled)];
|
||||
eventWrite.running = true;
|
||||
root._writeEventSounds();
|
||||
}
|
||||
|
||||
function setInputFeedback(enabled: bool): void {
|
||||
root.inputFeedback = enabled;
|
||||
inputWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "input-feedback-sounds", String(enabled)];
|
||||
root._writeInputFeedback();
|
||||
}
|
||||
|
||||
// Assigning `running = true` to a Process that is already running is a
|
||||
// no-op, not a queue -- so a toggle that lands mid-write would otherwise
|
||||
// be dropped silently. eventWrite.onExited re-checks root.eventSounds
|
||||
// against what was actually written and calls this again if they still
|
||||
// disagree.
|
||||
function _writeEventSounds(): void {
|
||||
if (eventWrite.running)
|
||||
return;
|
||||
eventWrite.writtenValue = root.eventSounds;
|
||||
eventWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "event-sounds", String(root.eventSounds)];
|
||||
eventWrite.running = true;
|
||||
}
|
||||
|
||||
function _writeInputFeedback(): void {
|
||||
if (inputWrite.running)
|
||||
return;
|
||||
inputWrite.writtenValue = root.inputFeedback;
|
||||
inputWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "input-feedback-sounds", String(root.inputFeedback)];
|
||||
inputWrite.running = true;
|
||||
}
|
||||
|
||||
@@ -71,6 +90,7 @@ Singleton {
|
||||
|
||||
Process {
|
||||
id: eventWrite
|
||||
property bool writtenValue: true
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0) {
|
||||
root.lastError = "Event sound preferences could not be changed.";
|
||||
@@ -78,11 +98,14 @@ Singleton {
|
||||
} else {
|
||||
root.lastError = "";
|
||||
}
|
||||
if (root.eventSounds !== eventWrite.writtenValue)
|
||||
root._writeEventSounds();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: inputWrite
|
||||
property bool writtenValue: false
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0) {
|
||||
root.lastError = "Input feedback preferences could not be changed.";
|
||||
@@ -90,6 +113,8 @@ Singleton {
|
||||
} else {
|
||||
root.lastError = "";
|
||||
}
|
||||
if (root.inputFeedback !== inputWrite.writtenValue)
|
||||
root._writeInputFeedback();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,9 +46,25 @@ Singleton {
|
||||
list.running = true;
|
||||
}
|
||||
|
||||
// A picker click while a change is still applying is queued rather than
|
||||
// fired directly: assigning `running = true` to an already-running
|
||||
// Process is a no-op, so a second set() here would otherwise be silently
|
||||
// dropped and apply.onExited would then adopt the second value as if it
|
||||
// had actually been applied. requestedValue always holds the latest
|
||||
// request; apply.onExited re-fires with it once the in-flight apply
|
||||
// settles, mirroring Brightness.qml's pending-write queue.
|
||||
property string requestedValue: ""
|
||||
|
||||
function set(value: string): void {
|
||||
if (value === root.current)
|
||||
return;
|
||||
root.requestedValue = value;
|
||||
if (!apply.running)
|
||||
root._applyValue(value);
|
||||
}
|
||||
|
||||
function _applyValue(value: string): void {
|
||||
root.requestedValue = "";
|
||||
apply.command = [root.helperPath, "set", value];
|
||||
apply.pendingValue = value;
|
||||
apply.running = true;
|
||||
@@ -94,6 +110,16 @@ Singleton {
|
||||
} else {
|
||||
root.lastError = "The system did not accept that language. It may have needed a password.";
|
||||
}
|
||||
|
||||
// A set() call that arrived while this apply was running only
|
||||
// queued itself in requestedValue (see the comment above). Fire
|
||||
// it now if it still names something other than what was just
|
||||
// applied, so the UI never settles on a locale the system was
|
||||
// never actually asked for.
|
||||
if (root.requestedValue !== "" && root.requestedValue !== apply.pendingValue)
|
||||
root._applyValue(root.requestedValue);
|
||||
else
|
||||
root.requestedValue = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,12 @@ Item {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
||||
function apply(mouseX) {
|
||||
const ratio = Math.max(0, Math.min(1, (mouseX + 8) / track.width));
|
||||
// mouseX is local to this MouseArea, whose anchors.margins: -8
|
||||
// pushes its own origin 8px before the track's left edge. So
|
||||
// MouseArea-local x=8 is track-local x=0 -- subtract the
|
||||
// margin to land back in the track's own coordinate space
|
||||
// before turning it into a fraction.
|
||||
const ratio = Math.max(0, Math.min(1, (mouseX - 8) / track.width));
|
||||
root.moved(ratio);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,10 +49,16 @@ gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true
|
||||
# still run: a missing optional package should not stop the dotfiles being
|
||||
# linked. The summary at the end is what decides whether the install worked,
|
||||
# because a failure scrolled past twenty minutes ago is a failure nobody saw.
|
||||
#
|
||||
# Explicit order, not glob order: change-settings runs `vicinae theme set`,
|
||||
# which needs both vicinae itself (installed by install-packages) and the
|
||||
# theme files it selects among (symlinked into place by link-dotfiles). New
|
||||
# scripts must be added here explicitly, or they will not run at all.
|
||||
STAGES=(install-packages link-dotfiles change-settings link-vicinae-scripts)
|
||||
failed=()
|
||||
for script in "$PANAMA_PATH"/setup/scripts/*; do
|
||||
for stage in "${STAGES[@]}"; do
|
||||
script="$PANAMA_PATH/setup/scripts/$stage"
|
||||
[[ -x "$script" ]] || continue
|
||||
stage="$(basename "$script")"
|
||||
printf '\n=== %s ===\n' "$stage"
|
||||
if ! "$script"; then
|
||||
failed+=("$stage")
|
||||
|
||||
@@ -32,6 +32,7 @@ mokutil
|
||||
mozilla-openh264
|
||||
nautilus-extensions
|
||||
nautilus-python
|
||||
nextcloud-client
|
||||
openssl-devel
|
||||
opus-devel
|
||||
python3-dnf-plugin-versionlock
|
||||
|
||||
@@ -2,3 +2,5 @@ org.gtk.Gtk3theme.adw-gtk3-dark
|
||||
io.github.Foldex.AdwSteamGtk
|
||||
com.bitwarden.desktop
|
||||
app.bluebubbles.BlueBubbles
|
||||
org.mozilla.thunderbird_esr
|
||||
io.missioncenter.MissionCenter
|
||||
|
||||
@@ -8,6 +8,7 @@ gpu-screen-recorder
|
||||
grim
|
||||
grimblast
|
||||
gtk-update-icon-cache
|
||||
helium-browser-bin
|
||||
hypridle
|
||||
hyprland
|
||||
hyprland-guiutils
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- Helper functions ---
|
||||
log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
|
||||
exists() { command -v "$1" >/dev/null 2>&1; }
|
||||
@@ -10,33 +12,37 @@ LOCAL_BIN_PATH="$HOME/.local/bin"
|
||||
|
||||
echo -e "\n--- Installing Repositories ---"
|
||||
log "Installing RPM Fusion Free and Nonfree Repositories"
|
||||
sudo dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm > /dev/null 2>&1
|
||||
sudo dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm > /dev/null
|
||||
log "Enabling Fedora Cisco OpenH264 Repository"
|
||||
sudo dnf config-manager setopt fedora-cisco-openh264.enabled=1
|
||||
log "Installing RPM Fusion AppStream Metadata"
|
||||
sudo dnf update @core -y > /dev/null 2>&1
|
||||
sudo dnf install -y rpmfusion-\*-appstream-data > /dev/null 2>&1
|
||||
sudo dnf update @core -y > /dev/null
|
||||
sudo dnf install -y rpmfusion-\*-appstream-data > /dev/null
|
||||
log "Installing Terra Repository"
|
||||
sudo dnf install -y --nogpgcheck --repofrompath 'terra,https://repos.fyralabs.com/terra$releasever' terra-release > /dev/null 2>&1
|
||||
sudo dnf install -y --nogpgcheck --repofrompath 'terra,https://repos.fyralabs.com/terra$releasever' terra-release > /dev/null
|
||||
|
||||
echo -e "\n--- Installing relevant packages ---"
|
||||
log "Updating all packages. This may take a while"
|
||||
sudo dnf update -y --refresh > /dev/null 2>&1
|
||||
sudo dnf update -y --refresh > /dev/null
|
||||
|
||||
log "Updating core, multimedia, and sound-and-video groups"
|
||||
# A trailing `&& sync` here previously meant a failing groupupdate was exempt
|
||||
# from set -e (bash does not apply -e to the left side of a && list), so the
|
||||
# failure went unreported. Sync unconditionally on its own line instead.
|
||||
sudo dnf4 groupupdate -y 'core' 'multimedia' 'sound-and-video' \
|
||||
--setop='install_weak_deps=False' \
|
||||
--exclude='PackageKit-gstreamer-plugin' \
|
||||
--allowerasing && sync > /dev/null 2>&1
|
||||
--allowerasing > /dev/null
|
||||
sync
|
||||
log "Swapping ffmpeg-free for ffmpeg"
|
||||
sudo dnf swap -y 'ffmpeg-free' 'ffmpeg' --allowerasing > /dev/null 2>&1
|
||||
sudo dnf swap -y 'ffmpeg-free' 'ffmpeg' --allowerasing > /dev/null
|
||||
log "Swapping mesa-va-drivers for mesa-va-drivers-freeworld"
|
||||
sudo dnf swap -y mesa-va-drivers mesa-va-drivers-freeworld > /dev/null 2>&1
|
||||
sudo dnf swap -y mesa-va-drivers mesa-va-drivers-freeworld > /dev/null
|
||||
log "Upgrading Multimedia group with optional packages"
|
||||
sudo dnf4 group upgrade -y --with-optional Multimedia > /dev/null 2>&1
|
||||
sudo dnf4 group upgrade -y --with-optional Multimedia > /dev/null
|
||||
log "Installing GStreamer plugins (bad, good, base)"
|
||||
sudo dnf install -y gstreamer1-plugins-{bad-\*,good-\*,base} \
|
||||
--exclude=gstreamer1-plugins-bad-free-devel > /dev/null 2>&1
|
||||
--exclude=gstreamer1-plugins-bad-free-devel > /dev/null
|
||||
|
||||
# --- Install all initial packages ---
|
||||
PACKAGES_FILE="$PANAMA_PATH/setup/packages/initial-packages"
|
||||
@@ -45,12 +51,25 @@ if [[ -f "$PACKAGES_FILE" ]]; then
|
||||
log "Installing Initial Packages"
|
||||
echo -e "Includes the following packages:"
|
||||
echo -e "$(<"$PACKAGES_FILE")"
|
||||
sudo dnf install -y "$INITIAL_PACKAGES" > /dev/null 2>&1
|
||||
sudo dnf install -y $INITIAL_PACKAGES > /dev/null
|
||||
log "Initial packages installed!"
|
||||
else
|
||||
log "Package list was not in specified path: $PACKAGES_FILE"
|
||||
fi
|
||||
|
||||
# --- Install Desktop Packages ---
|
||||
DESKTOP_FILE="$PANAMA_PATH/setup/packages/desktop-packages"
|
||||
if [[ -f "$DESKTOP_FILE" ]]; then
|
||||
DESKTOP_PACKAGES=$(tr "\n" " " <"$DESKTOP_FILE")
|
||||
log "Installing Desktop Packages"
|
||||
echo -e "Includes the following packages:"
|
||||
echo -e "$(<"$DESKTOP_FILE")"
|
||||
sudo dnf install -y $DESKTOP_PACKAGES > /dev/null
|
||||
log "Desktop packages installed!"
|
||||
else
|
||||
log "Package list was not in specified path: $DESKTOP_FILE"
|
||||
fi
|
||||
|
||||
# --- Install oh-my-posh if not already installed. ---
|
||||
mkdir -p "$LOCAL_BIN_PATH"
|
||||
if [[ -x "$LOCAL_BIN_PATH/oh-my-posh" ]]; then
|
||||
@@ -67,7 +86,7 @@ if [[ -f "$DEV_FILE" ]]; then
|
||||
log "Installing Development Packages. Mostly for Neovim."
|
||||
echo -e "Includes the following packages:"
|
||||
echo -e "$(<"$DEV_FILE")"
|
||||
sudo dnf install -y $DEV_PACKAGES > /dev/null 2>&1
|
||||
sudo dnf install -y $DEV_PACKAGES > /dev/null
|
||||
log "Development packages installed!"
|
||||
else
|
||||
log "Package list was not in specified path: $DEV_FILE"
|
||||
@@ -78,12 +97,12 @@ fi
|
||||
HYPR_FILE="$PANAMA_PATH/setup/packages/hyprland-packages"
|
||||
if [[ -f "$HYPR_FILE" ]]; then
|
||||
log "Enabling Hyprland COPR"
|
||||
sudo dnf copr enable -y lionheartp/Hyprland > /dev/null 2>&1
|
||||
sudo dnf copr enable -y lionheartp/Hyprland > /dev/null
|
||||
HYPR_PACKAGES=$(tr "\n" " " <"$HYPR_FILE")
|
||||
log "Installing Hyprland desktop packages"
|
||||
echo -e "Includes the following packages:"
|
||||
echo -e "$(<"$HYPR_FILE")"
|
||||
sudo dnf install -y --setopt=install_weak_deps=False $HYPR_PACKAGES > /dev/null 2>&1
|
||||
sudo dnf install -y --setopt=install_weak_deps=False $HYPR_PACKAGES > /dev/null
|
||||
log "Hyprland packages installed!"
|
||||
else
|
||||
log "Package list was not in specified path: $HYPR_FILE"
|
||||
@@ -96,3 +115,18 @@ else
|
||||
log "Installing Bun via curl..."
|
||||
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
# --- Install Flatpak Packages ---
|
||||
FLATPAK_FILE="$PANAMA_PATH/setup/packages/flatpak-packages"
|
||||
if [[ -f "$FLATPAK_FILE" ]]; then
|
||||
FLATPAK_PACKAGES=$(tr "\n" " " <"$FLATPAK_FILE")
|
||||
log "Adding Flathub remote"
|
||||
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo > /dev/null
|
||||
log "Installing Flatpak Packages"
|
||||
echo -e "Includes the following packages:"
|
||||
echo -e "$(<"$FLATPAK_FILE")"
|
||||
sudo flatpak install -y flathub $FLATPAK_PACKAGES > /dev/null
|
||||
log "Flatpak packages installed!"
|
||||
else
|
||||
log "Package list was not in specified path: $FLATPAK_FILE"
|
||||
fi
|
||||
|
||||
@@ -63,6 +63,12 @@ for dir in "${dirs[@]}"; do
|
||||
if [ -d "$CONFIG/$dir" ]; then
|
||||
mv "$CONFIG/$dir" "$PANAMA_OLD/$dir"
|
||||
log "Moved existing $dir config to $PANAMA_OLD/$dir"
|
||||
# A regular file at this path (not a directory, not a symlink) is missed by
|
||||
# the guards above, so `ln -s` below would fail with "File exists" -- back
|
||||
# it up the same way.
|
||||
elif [ -f "$CONFIG/$dir" ]; then
|
||||
mv "$CONFIG/$dir" "$PANAMA_OLD/$dir"
|
||||
log "Moved existing $dir file to $PANAMA_OLD/$dir"
|
||||
fi
|
||||
|
||||
# Create symlink
|
||||
|
||||
@@ -69,7 +69,7 @@ assert_schema_entry() {
|
||||
}
|
||||
|
||||
[[ -x "$helper" ]] || fail 'panama-lock helper is missing or not executable'
|
||||
for key in lockBackgroundMode lockBlurLevel lockShowClock lockShowDate lockShowUser lockFadeOnEmpty; do
|
||||
for key in lockBackgroundMode lockBlurLevel lockShowClock lockShowDate lockShowUser lockFadeOnEmpty accentName; do
|
||||
assert_schema_entry "$key"
|
||||
done
|
||||
|
||||
@@ -112,6 +112,25 @@ if rg -q '^label \{' "$generated"; then
|
||||
fail 'hidden clock, date, and user labels were still generated'
|
||||
fi
|
||||
|
||||
# ── The focus ring follows the chosen accent, not just the scheme ───────────
|
||||
# A pinned blue literal only ever proves the DEFAULT accent renders correctly,
|
||||
# not that the helper actually reads accentName. Orchid is picked because its
|
||||
# hex is nowhere close to blue's in either scheme, so a helper that quietly
|
||||
# ignored accentName and kept emitting blue would be caught here.
|
||||
write_settings '{"accentName":"orchid","colorScheme":"dark"}'
|
||||
run_helper generate
|
||||
rg -Fq 'outer_color = rgba(192, 153, 255, 0.9)' "$generated" || fail 'dark orchid accent did not drive the focus ring'
|
||||
rg -Fq 'check_color = rgba(192, 153, 255, 1.0)' "$generated" || fail 'dark orchid accent did not drive the success colour'
|
||||
|
||||
write_settings '{"accentName":"orchid","colorScheme":"light"}'
|
||||
run_helper generate
|
||||
rg -Fq 'outer_color = rgba(120, 71, 189, 0.9)' "$generated" || fail 'light orchid accent did not drive the focus ring'
|
||||
rg -Fq 'check_color = rgba(120, 71, 189, 1.0)' "$generated" || fail 'light orchid accent did not drive the success colour'
|
||||
|
||||
write_settings '{"accentName":"not-a-real-accent","colorScheme":"dark"}'
|
||||
run_helper generate
|
||||
rg -Fq 'outer_color = rgba(130, 170, 255, 0.9)' "$generated" || fail 'an unknown accent name did not fall back to blue'
|
||||
|
||||
write_settings '{
|
||||
"lockBackgroundMode":"wallpaper",
|
||||
"wallpaperMode":"per-monitor",
|
||||
|
||||
@@ -27,7 +27,7 @@ printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||
printf '\n' >>"$OSD_TEST_LOG"
|
||||
if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then
|
||||
[[ ${BACKLIGHT_AVAILABLE:-true} == true ]] || exit 1
|
||||
printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}"
|
||||
printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,50%,1000}"
|
||||
fi
|
||||
SH
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ run_doctor() {
|
||||
PANAMA_DOCTOR_STATE_HOME="$state_home" \
|
||||
PANAMA_DOCTOR_RUNTIME_DIR="$runtime_dir" \
|
||||
PANAMA_DOCTOR_PATH="$bin_dir" \
|
||||
PANAMA_DOCTOR_SCRIPTS_DIR="$bin_dir" \
|
||||
PANAMA_DOCTOR_TIMEOUT="${PANAMA_DOCTOR_TIMEOUT:-0.2}" \
|
||||
/usr/bin/python3 "$doctor" "$@"
|
||||
}
|
||||
@@ -375,6 +376,7 @@ run_repair() {
|
||||
PANAMA_DOCTOR_STATE_HOME="$state_home" \
|
||||
PANAMA_DOCTOR_RUNTIME_DIR="$runtime_dir" \
|
||||
PANAMA_DOCTOR_PATH="$bin_dir:/usr/bin" \
|
||||
PANAMA_DOCTOR_SCRIPTS_DIR="$bin_dir" \
|
||||
PANAMA_DOCTOR_TIMEOUT=0.2 \
|
||||
/usr/bin/python3 "$doctor" "$@"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
# A setting has one schema-routed owner. A second page may mirror it only when
|
||||
# this contract names the exact owner and mirror set. The same ownership rule
|
||||
# keeps colour scheme propagation away from the focused Prism border.
|
||||
# says who writes each half of the window border: ColorScheme.qml owns the
|
||||
# neutral inactive role AND the accent-derived focused role, restating both
|
||||
# together on every scheme or accent change.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -124,8 +126,28 @@ if dark.group(1) not in looks or light.group(1) not in looks:
|
||||
raise SystemExit("Hyprland startup values disagree with the live scheme roles")
|
||||
|
||||
without_comments = re.sub(r"//.*", "", scheme)
|
||||
if re.search(r"(?<![A-Za-z_])active_border\b", without_comments):
|
||||
raise SystemExit("ColorScheme writes the focused border")
|
||||
|
||||
# The focused border is the accent role, and ColorScheme.qml owns it too:
|
||||
# each named accent carries a separate pair per scheme, so a scheme change
|
||||
# must restate the focused border, not just the neutral one, or a chosen
|
||||
# accent goes stale the moment light/dark flips.
|
||||
start = re.search(r'property string accentBorderStart:\s*root\.hyprColor\(Theme\.accent\)', scheme)
|
||||
end = re.search(r'property string accentBorderEnd:\s*root\.hyprColor\(Theme\.accentSecondary\)', scheme)
|
||||
if not start or not end:
|
||||
raise SystemExit("ColorScheme does not derive the focused border from the chosen accent")
|
||||
|
||||
# Written as a Lua TABLE, not a string: the string form of a Hyprland gradient
|
||||
# carries only one stop, so writing it that way is accepted and silently
|
||||
# keeps whatever the previous accent left behind. Built through the same
|
||||
# serialiseValue() SystemSettings.qml uses for every other gradient, rather
|
||||
# than a second hand-rolled (and unescaped) copy of that table syntax here.
|
||||
if 'SystemSettings.serialiseValue({' not in without_comments:
|
||||
raise SystemExit("ColorScheme does not build the focused border through the shared gradient serialiser")
|
||||
if 'colors: [root.accentBorderStart, root.accentBorderEnd]' not in without_comments:
|
||||
raise SystemExit("ColorScheme does not derive the focused-border gradient from the chosen accent")
|
||||
if 'active_border = ${activeBorder}' not in without_comments:
|
||||
raise SystemExit("ColorScheme does not write the focused border as the serialised gradient table")
|
||||
|
||||
if 'inactive_border = "${root.inactiveBorder}"' not in scheme:
|
||||
raise SystemExit("ColorScheme does not apply its effective inactive role")
|
||||
PY
|
||||
|
||||
@@ -26,20 +26,29 @@ readonly SECRET='hunter2-secret'
|
||||
|
||||
cat >"$work/bin/nmcli" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
# -t -f NAME,TYPE connection show
|
||||
if [[ "\$*" == *"-f NAME,TYPE"* ]]; then
|
||||
printf 'home net:802-11-wireless\n'
|
||||
printf 'work-eap:802-11-wireless\n'
|
||||
printf 'Wired connection 1:802-3-ethernet\n'
|
||||
# -e no -t -f NAME connection show: one name per line, no field to split, so
|
||||
# a name containing ':' or '\\' (real nmcli would otherwise backslash-escape
|
||||
# both) comes back byte-for-byte.
|
||||
if [[ "\$*" == *"-f NAME connection show"* ]]; then
|
||||
printf 'home net\n'
|
||||
printf 'work-eap\n'
|
||||
printf 'Cafe: Guest\n'
|
||||
printf 'Wired connection 1\n'
|
||||
exit 0
|
||||
fi
|
||||
name="\${@: -1}"
|
||||
case "\$*" in
|
||||
*connection.type*)
|
||||
case "\$name" in
|
||||
"home net"|"work-eap"|"Cafe: Guest") printf '802-11-wireless\n' ;;
|
||||
"Wired connection 1") printf '802-3-ethernet\n' ;;
|
||||
esac ;;
|
||||
*802-11-wireless.ssid*)
|
||||
# An SSID containing reserved characters, to prove they are escaped.
|
||||
case "\$name" in
|
||||
"home net") printf 'home;net\n' ;;
|
||||
"work-eap") printf 'work-eap\n' ;;
|
||||
"Cafe: Guest") printf 'Cafe: Guest\n' ;;
|
||||
esac ;;
|
||||
*802-11-wireless.hidden*) printf 'no\n' ;;
|
||||
*802-11-wireless-security.psk*)
|
||||
@@ -78,13 +87,20 @@ run() { PATH="$work/bin:$PATH" XDG_RUNTIME_DIR="$work/run" "$helper" "$@"; }
|
||||
# ── Listing distinguishes shareable from not ────────────────────────────────
|
||||
out="$(run list)"
|
||||
jq -e . >/dev/null 2>&1 <<<"$out" || fail "list did not emit JSON: $out"
|
||||
[[ "$(jq -r '.networks | length' <<<"$out")" == "2" ]] \
|
||||
[[ "$(jq -r '.networks | length' <<<"$out")" == "3" ]] \
|
||||
|| fail "only wireless connections belong in the list: $out"
|
||||
jq -e '.networks[] | select(.name == "home net") | .shareable == true' >/dev/null <<<"$out" \
|
||||
|| fail "a network with a passphrase must be shareable: $out"
|
||||
jq -e '.networks[] | select(.name == "work-eap") | .shareable == false' >/dev/null <<<"$out" \
|
||||
|| fail "an enterprise network has no passphrase, so a QR code for it cannot work: $out"
|
||||
|
||||
# A name containing a colon must survive intact: nmcli's terse mode would
|
||||
# backslash-escape it (real nmcli escapes ':' and '\' in terse/-g output), and
|
||||
# a naive colon-split parser truncates the name and misaligns the next field,
|
||||
# dropping the network from the list entirely.
|
||||
jq -e '.networks[] | select(.name == "Cafe: Guest")' >/dev/null <<<"$out" \
|
||||
|| fail "a network name containing a colon was mangled or dropped: $out"
|
||||
|
||||
# ── The payload ─────────────────────────────────────────────────────────────
|
||||
path="$(run qr 'home net' | jq -r .path)"
|
||||
[[ -n "$path" && -e "$path" ]] || fail 'no image was produced'
|
||||
@@ -123,4 +139,11 @@ out="$(run qr 'no-such-network')"
|
||||
jq -e '.path == "" and .error != ""' >/dev/null <<<"$out" \
|
||||
|| fail "an unknown network must be reported: $out"
|
||||
|
||||
# ── A name with a colon round-trips from list into qr ───────────────────────
|
||||
# The name `list` emits must be exactly what `qr` needs to look the network
|
||||
# back up; escaping it either direction breaks this lookup.
|
||||
out="$(run qr 'Cafe: Guest')"
|
||||
[[ "$(jq -r '.path' <<<"$out")" != "" ]] \
|
||||
|| fail "a saved network name containing a colon could not be looked back up: $out"
|
||||
|
||||
printf 'wifi qr contract: PASS\n'
|
||||
|
||||
Reference in New Issue
Block a user