diff --git a/bars/widgets/ruixen.notch/NotchWindow.qml b/bars/widgets/ruixen.notch/NotchWindow.qml new file mode 100644 index 0000000..f1e1f45 --- /dev/null +++ b/bars/widgets/ruixen.notch/NotchWindow.qml @@ -0,0 +1,1930 @@ +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Widgets +import qs.Commons + +// The notch's own visible window + its collapsed/expanded content -- +// extracted out of Overlay.qml (direct user report: the notch +// never showed up on a second/external monitor in extended mode, since +// it was one single, unbound PanelWindow with no `screen:` and no +// Variants over Quickshell.screens, unlike ruixen.bar's own per-screen +// windows). Instantiated once per screen by Overlay.qml's own Variants +// block below, each given a `notchRoot: root` back-reference the same +// way ruixen.bar/Bar.qml's own Phase 6 refactor already wires +// FrameWindow/BarPanel/etc. -- a separate file gets no free access to +// the surrounding document's ids/properties the way a nested +// `component` declaration in the SAME document would, so every +// `root.foo` read below was rewritten to `notchRoot.foo`. +// +// RoundCorner/WavyLine/UserAvatar/NotchSeparator/TabButton below are +// nested `component` declarations inside this PanelWindow's own body, +// not standalone sibling files of their own (unlike ruixen.bar/Bar.qml's +// equivalent leaf extractions) -- a bare top-level `component` +// declaration before a file's own root object is a QML syntax error +// (confirmed live via qmllint: "Syntax error" on the first one), it has +// to be a statement inside some object's body. These five are used +// only by this window's own content, so nesting them here costs +// nothing and avoids giving each one its own back-reference wiring for +// no reason. +// +// Singleton services (NotificationService/KanbanService/AppLibrary/ +// CavaFeed) and the "ruixen.notch" IpcHandler stay behind on +// Overlay.qml's own root, genuinely once each -- duplicating those per +// screen would register the IPC target N times (undefined/broken, not +// just wasteful) and give every monitor its own independent board/ +// notification state instead of the one shared one. What's shared is +// instead a handful of plain properties on notchRoot itself +// (pinnedOpen/launcherOpen/dashboardTab/activeScreenName) that every +// one of these per-screen windows reads a GATED view of -- see +// panel.isActiveScreen's own comment below for why a flat shared +// boolean alone isn't enough (two layer-shell surfaces both grabbing +// Wayland's exclusive keyboard focus at once has no defined winner). + +PanelWindow { + id: panel + + component RoundCorner: Item { + id: rc + property int corner: 0 + property int cornerSize: 20 + property color fillColor: "#ffffff" + + implicitWidth: cornerSize + implicitHeight: cornerSize + + onFillColorChanged: canvas.requestPaint() + onCornerChanged: canvas.requestPaint() + onCornerSizeChanged: canvas.requestPaint() + onVisibleChanged: if (visible) canvas.requestPaint() + + readonly property var cornerGeometry: ([ + { centerX: 1, centerY: 1, startAngle: Math.PI, endAngle: 1.5 * Math.PI, pointX: 0, pointY: 0 }, + { centerX: 0, centerY: 1, startAngle: 1.5 * Math.PI, endAngle: 2 * Math.PI, pointX: 1, pointY: 0 }, + { centerX: 1, centerY: 0, startAngle: 0.5 * Math.PI, endAngle: Math.PI, pointX: 0, pointY: 1 }, + { centerX: 0, centerY: 0, startAngle: 0, endAngle: 0.5 * Math.PI, pointX: 1, pointY: 1 } + ]) + + Canvas { + id: canvas + anchors.fill: parent + antialiasing: true + onPaint: { + var ctx = getContext("2d") + var size = rc.cornerSize + var g = rc.cornerGeometry[rc.corner] + ctx.clearRect(0, 0, width, height) + if (!g) return + + ctx.beginPath() + ctx.arc(g.centerX * size, g.centerY * size, size, g.startAngle, g.endAngle) + ctx.lineTo(g.pointX * size, g.pointY * size) + ctx.closePath() + ctx.fillStyle = rc.fillColor + ctx.fill() + } + } + } + + // A continuous sine-wave line, in the spirit of Material 3 + // Expressive's own wavy active-indicator pattern (amplitude + + // wavelength describing the wave, animated by a looping phase + // offset) -- written independently against that public design + // language rather than ported from any one project's own + // implementation. Parametrized directly in physical terms + // (wavelengthPx: the px distance between crests) instead of a raw + // cycle count relative to some separately-tracked reference length, + // which also removes the need for a second "what is this frequency + // relative to" property entirely -- the wave's spatial density no + // longer depends on the canvas's own current width at all, so it + // stays visually consistent even while that width is animating (see + // the caller below, where the visible portion shrinks/grows as + // playback progresses). + // + // Phase advances from an accumulated elapsed-time counter + // (FrameAnimation's own per-frame frameTime, in seconds) rather than + // dividing a raw Date.now() timestamp -- avoids the wave's motion + // silently losing precision from an ever-growing epoch value over a + // long-running session, and reads as "speed in radians/second" + // directly instead of an opaque magic divisor. + component WavyLine: Canvas { + id: wave + property color lineColor: "#ffffff" + property real lineWidth: 2 + property real wavelengthPx: 24 + property real amplitudeRatio: 0.5 + property bool running: true + // Radians/second -- 2.5 matches the undulation speed the previous + // Date.now()/400 divisor gave (1000ms / 400 = 2.5 rad/s). + property real phaseSpeed: 2.5 + + readonly property bool shouldAnimate: running && visible && width > 0 && opacity > 0 + property real phase: 0 + + onPaint: { + var ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + if (width <= 0 || height <= 0 || wave.wavelengthPx <= 0) return + + var amplitude = wave.lineWidth * wave.amplitudeRatio + var angularStep = (2 * Math.PI) / wave.wavelengthPx + var centerY = height / 2 + var halfStroke = wave.lineWidth / 2 + + ctx.strokeStyle = wave.lineColor + ctx.lineWidth = wave.lineWidth + ctx.lineCap = "round" + ctx.beginPath() + + var drawnFirstPoint = false + for (var x = halfStroke; x <= wave.width - halfStroke; x += 1) { + var y = centerY + amplitude * Math.sin(angularStep * x + wave.phase) + if (!drawnFirstPoint) { + ctx.moveTo(x, y) + drawnFirstPoint = true + } else { + ctx.lineTo(x, y) + } + } + ctx.stroke() + } + + FrameAnimation { + running: wave.shouldAnimate + onTriggered: { + wave.phase += wave.phaseSpeed * frameTime + wave.requestPaint() + } + } + } + + // Small circular user avatar, same ~/.face.icon convention as + // UserInfo.qml. Falls back to a plain dark circle if the file doesn't + // exist -- Image just shows nothing, the Rectangle behind it still + // reads as an avatar-shaped placeholder. + component UserAvatar: Item { + id: avatar + property int avatarSize: 20 + + implicitWidth: avatarSize + implicitHeight: avatarSize + + // AnimatedImage ONLY for a genuine multi-frame animation + // (frameCount > 1) -- plain Image for everything else, including + // every static DiceBear/GitHub/custom avatar. Direct live finding + // (confirmed with temporary console.log instrumentation, then + // isolated by forcing each element as the sole source in turn): + // AnimatedImage decodes a NEW static image correctly every time + // (status genuinely reaches Ready with the right content) but its + // own texture update does not reliably propagate to MultiEffect past + // the very first successful decode of its lifetime -- a second, + // different static image assigned to the SAME AnimatedImage element + // silently kept showing the first one on screen forever, while the + // exact same reload sequence through plain Image alone refreshed + // correctly every single time. Real animated GIFs are unaffected + // either way (their own frame timer drives repaints through a + // completely different path, confirmed live: frameCount > 1 still + // animates correctly here) -- this only changes which element + // renders STATIC content. .ico still needs the fallback too, for the + // older, separate reason below (AnimatedImage's decoder errors out + // on it outright). + readonly property var activeAvatarImage: (avatarImage.status === Image.Ready && avatarImage.frameCount > 1) + ? avatarImage : avatarImageFallback + property int avatarFrameIndex: 0 + readonly property string avatarSource: panel.notchRoot.avatarAnimated + ? "file://" + panel.notchRoot.avatarFrameDir + "/frame-" + ("00" + avatar.avatarFrameIndex).slice(-3) + ".png#" + panel.notchRoot.avatarCacheBust + : "file://" + Quickshell.env("HOME") + "/.face.icon#" + panel.notchRoot.avatarCacheBust + + Timer { + interval: panel.notchRoot.avatarFrameDelayMs + running: panel.notchRoot.avatarAnimated && panel.notchRoot.avatarFrameCount > 1 + repeat: true + onTriggered: avatar.avatarFrameIndex = (avatar.avatarFrameIndex + 1) % panel.notchRoot.avatarFrameCount + } + + // Placeholder shown until/unless ~/.face.icon exists -- a gradient + // square (see its own no-radius comment below) instead of a shipped + // PNG asset, so there's no fixed resolution/ratio to pick and it + // scales cleanly at any avatarSize. Explicitly hidden once a real + // image is loaded, not just painted over by an assumed-opaque one + // -- direct follow-up ("why do we need to keep showing the + // fallback gradient, why cant gradient just be something on and + // then off between dicebear... why do we need both to appear and + // overlap"): the two layers overlapping regardless of load state + // is exactly what let any imperfection show up as the gradient + // visibly bleeding through, real bug hit live and fixed twice + // before landing here. With this, the gradient is structurally + // absent whenever a real avatar is showing. + Rectangle { + anchors.fill: parent + // Circular, unlike the real DiceBear avatars this sits behind + // (deliberately plain square now, see selectAvatar's own + // comment) -- direct follow-up ("keep it for the gradient + // though, the gradient default we load in is square now... why + // not just make the gradient a circle"). Never shown at the same + // time as a real avatar (visible below is gated on the image + // NOT being ready), so the two shapes never need to match. + radius: width / 2 + visible: !panel.notchRoot.avatarAnimated && avatar.activeAvatarImage.status !== Image.Ready + // Theme-aware, not two hardcoded colors -- direct follow-up + // ("install you get the fallback or default gradient, maybe + // theme aware?"). notchRoot.accent is already Color.accent (see its + // own declaration above), so this now follows the active + // Omarchy theme instead of a fixed indigo/violet pair. + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.lighter(panel.notchRoot.accent, 1.6) } + GradientStop { position: 1.0; color: Qt.darker(panel.notchRoot.accent, 1.4) } + } + } + + // No circular treatment -- direct follow-up chain: first "why do + // we still hard cap a circle around it, doesnt dicebear take care + // of it" (tried DiceBear's own radius=50 param, which scales each + // style's content to fit a circle instead of the old MultiEffect + // mask's blind crop), then, after actually seeing it, "the circle + // is still there... the circle mask comes back" -- radius=50 still + // produces a circle, just a better-behaved one, which wasn't the + // actual ask. Dropped radius=50 too (see ruixen.settings' + // selectAvatar). + // + // Rounded-square clip, not a hard rectangle though -- direct + // follow-up ("on the site it shows it has like a curved around the + // edge, its not suppose to be rectangular with hard edge"). That + // curve is DiceBear's own website preview-card CSS, not part of + // the fetched image -- confirmed by reading pixelbot's raw SVG + // directly, rx="0" regardless of style, same as every other style + // checked so far. So a real mask is what gets that look here, same + // MultiEffect technique the notch's own shape uses, just a small + // proportional radius instead of width/2 -- a rounded square, not + // a circle (the circle stays for the gradient placeholder only, + // per its own comment above). + // AnimatedImage exists alongside plain Image so a picked GIF still + // actually animates (activeAvatarImage's own comment above covers + // when each one is actually selected) -- QMovie (the decoder behind + // AnimatedImage) also supports a narrower format set than + // QImageReader (behind plain Image): an .ico-format ~/.face.icon + // decodes fine via Image but errors out via AnimatedImage every + // time, confirmed live. + AnimatedImage { + id: avatarImage + anchors.fill: parent + source: avatar.avatarSource + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: false + visible: panel.notchRoot.avatarAnimated + } + + Image { + id: avatarImageFallback + anchors.fill: parent + source: avatar.avatarSource + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: false + visible: false + } + + Rectangle { + id: avatarImageMask + anchors.fill: parent + radius: width * 0.2 + color: "#ffffff" + visible: false + layer.enabled: true + } + + MultiEffect { + anchors.fill: parent + source: avatar.activeAvatarImage + visible: !panel.notchRoot.avatarAnimated + maskEnabled: true + maskSource: avatarImageMask + maskThresholdMin: 0.5 + maskThresholdMax: 1.0 + } + + // The click target for opening the dashboard now that hover-to- + // expand is gone -- per direct request. Only ever visible/hit- + // testable in the collapsed row (its parent Row's own visible: + // false while expanded already makes this inert then, no extra + // guard needed). Margins widen the actual hit target past the + // small 20px visual circle, same -6 pattern used elsewhere in this + // file for small icons. + MouseArea { + anchors.fill: parent + anchors.margins: -6 + cursorShape: Qt.PointingHandCursor + onClicked: panel.openPinned() + } + } + + // Thin vertical divider, ported from Separator.qml. + component NotchSeparator: Rectangle { + implicitWidth: 3 + implicitHeight: 16 + radius: width / 2 + color: panel.notchRoot.textColor + opacity: 0.1 + } + + // Left-side vertical tab-bar button (Widgets/Wallpapers/Metrics + // stacked, plus a settings gear pinned at the bottom). Just a plain + // icon + tonal hover/active background, no external styling + // dependency, same approach as every other component in this file. + component TabButton: Rectangle { + id: tabBtn + property string glyph: "" + property bool active: false + signal activated() + + // 48 -> 56, glyph 18 -> 20 -- per direct request to grow the + // left/right rail icons together rather than keep chasing a + // sub-pixel detail on the dial tip. + Layout.preferredWidth: 56 + Layout.preferredHeight: 56 + Layout.alignment: Qt.AlignHCenter + radius: 14 + color: active ? panel.notchRoot.surfaceTint(0.14) : (tabMouse.containsMouse ? panel.notchRoot.surfaceTint(0.07) : "transparent") + Behavior on color { ColorAnimation { duration: 120 } } + + Text { + anchors.centerIn: parent + text: tabBtn.glyph + // Direct request: always the theme accent color, not just while + // active -- the active/inactive distinction stays on the pill + // background behind this (tabBtn's own color: active ? surfaceTint + // ... above), not on the glyph itself anymore. + color: panel.notchRoot.accent + font.family: panel.notchRoot.fontFamily + font.pixelSize: 20 + } + + MouseArea { + id: tabMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: tabBtn.activated() + } + } + + + visible: !notchRoot.fullscreenActive && !notchRoot.barHidden && !notchRoot.fullbarStyle + // bottom:true too, not just top/left/right -- the click-away-to- + // dismiss MouseArea (see below) needs the panel's own surface to + // actually reach the full screen height for its widened mask to + // cover anywhere outside the notch. Anchoring both top and bottom + // makes the surface fill the full height regardless of + // implicitHeight; exclusionMode stays Ignore so this never reserves + // screen space the way ruixen.bar's own window does. + anchors { top: true; left: true; right: true; bottom: true } + // 0, not 4 -- direct report on "On Hover" mode: "theres a bit of + // dead area on top of the notch... hitting the top edge right now + // is considered not a on hover area". A Wayland layer-surface + // margin genuinely shrinks the surface's own occupied/mapped + // screen area, not just where its QML content starts drawing -- + // with this at 4, the true top 4 screen pixels belonged to no + // surface's input region at all (ruixen.frame-widget's own + // overlay is deliberately click/hover-through everywhere, mask: + // Region {}, so nothing was there to catch it either), a real dead + // strip impossible to reveal-by-hover into since there is nothing + // to visually aim for while faded out. The former 4px inset moved + // into notchOuter's own anchors.topMargin below instead (a plain + // QML anchor, not a surface margin) -- the pill's own final + // painted position on screen is unchanged, so ruixen.bar's own + // notchCollapsedBottomEdge constant (still 48 = 0 surface margin + + // notchOuter's own 4 anchor margin + 44 height) needs no change. + margins.top: 0 + implicitHeight: 220 + exclusionMode: ExclusionMode.Ignore + color: "transparent" + + WlrLayershell.namespace: "ruixen-notch" + // REVERTED back to Overlay -- the Overlay -> Top change (made to + // auto-hide over fullscreen windows) broke click-to-expand: this + // namespace's own layer surface geometry spans nearly the full + // screen (see `hyprctl layers`), overlapping omarchy-bar's own + // surface, which is ALSO on `top`. Two same-layer surfaces with + // overlapping geometry have compositor-decided (not + // deterministic-to-us) stacking order for pointer hit-testing -- + // omarchy-bar apparently won that contest, eating clicks meant for + // the notch pill before they ever reached this surface. Overlay is + // strictly above every other layer including top, so there's no + // same-layer contention -- confirmed working again after this + // revert. Fullscreen-hide needs a different mechanism (real + // fullscreen-state watching) that doesn't reuse a layer another + // always-on surface already occupies. + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.exclusionMode: ExclusionMode.Ignore + // None except while expanded (pinned dashboard or launcher), when it + // switches to Exclusive (same mode ruixen.settings/Settings.qml and + // Omarchy's own real Menu.qml use for their modal panels) so Escape + // can dismiss either view and the launcher's own TextInput can + // receive typed characters. Collapsed stays None -- passive hover, no + // reason to grab keyboard input. v1 of the launcher's search box + // needed this SAME flip, but combined it with a taller notch size, + // which is what actually broke (see launcherContent's own comment + // below) -- this flip alone, without the resize, is the untested + // half of that old combination. Extending it to pinnedOpen too + // carries the same low risk noted there: no new size involved, just + // a focus-mode change on an already-proven 900x400 footprint. + WlrLayershell.keyboardFocus: expanded ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + + // Widens to the full panel only while open (pinned or launcher -- + // see expanded below), so a click anywhere outside the notch itself + // actually reaches this surface (see the click-away MouseArea + // below) instead of passing straight through to whatever's behind. + // Collapsed is the only other state now -- hover-to-expand was + // removed (see expanded below), so there's no longer a third + // "briefly widened but shouldn't swallow other clicks" case to + // exclude here. + // + // Collapsed y is 0, not notchOuter's own resting offset -- this + // Region is what actually tells the compositor which pixels of + // this surface receive pointer input at all; leaving it at that + // offset would reopen the exact "On Hover" dead-zone strip + // margins.top's own comment above describes fixing, just moved + // from a surface-level gap to a mask-level one. Height reaches + // notchOuter's real RESTING bottom edge (notchOuter.restY + + // notchOuter.height, i.e. notchCollapsedBottomEdge) -- deliberately + // notchOuter.restY, not its live y: that now animates between + // restY and a negative off-screen value for the slide-down reveal + // (see notchOuter's own comment), and this mask has to stay fixed + // at the resting footprint regardless of where the pill's paint + // currently is mid-animation, or hover detection while fully + // hidden (y at its most negative) would shrink toward a near-zero + // or negative height and stop working -- the one moment it most + // needs to keep working, since that is the state hovering is + // supposed to reveal it FROM. + mask: Region { + x: panel.expanded ? 0 : notchOuter.x + y: panel.expanded ? 0 : 0 + width: panel.expanded ? panel.width : notchOuter.width + height: panel.expanded ? panel.height : (notchOuter.restY + notchOuter.height) + } + + // Back-reference to Overlay.qml's own root -- this file is a + // separate document now (ruixen-shell issue #96, multi-monitor + // notch), so it gets no free access to `root`'s properties/ + // functions/singleton services the way a nested `component` + // declaration in the SAME document would. Wired at the one + // instantiation site below (Overlay.qml's own Variants block), + // same `fooRoot: root` back-reference shape ruixen.bar/Bar.qml's + // own Phase 6 extraction already established for FrameWindow.qml + // et al. + property var notchRoot: null + + // Hover-to-expand removed entirely, per direct request -- clicking + // the avatar (see UserAvatar's own MouseArea) is now the only way + // to open the dashboard, dismissing by clicking away (below). + // Deliberate over accidental: hovering near the top of the screen + // for an unrelated reason (dragging a window, reaching for + // something else at the top edge) no longer pops the notch open. + // This also removes the hoverExitTimer debounce hack that hover + // needed (see the old git history if that's ever wanted back) -- + // a real click has no equivalent "did I actually mean to leave" + // ambiguity a hover does. + // + // pinnedOpen/launcherOpen/dashboardTab themselves now live on + // notchRoot -- genuinely shared, singleton state (the IpcHandler + // driving them does too, see Overlay.qml), since this window is + // instantiated once per screen but there is only one logical + // dashboard/launcher. What's declared here is a GATED, per-screen + // VIEW of that shared state: true only on the one screen that + // actually opened it (notchRoot.activeScreenName), everywhere else + // reads back false -- the same shared `pinnedOpen` flag that + // would otherwise make every monitor simultaneously try to render + // the full interactive dashboard and grab exclusive Wayland + // keyboard focus at once (confirmed by tracing it, not live -- + // two layer-shell surfaces both requesting Exclusive focus has no + // well-defined winner). Reads of panel.pinnedOpen/launcherOpen/ + // expanded/dashboardTab elsewhere in this file are UNCHANGED by + // this -- they already meant "should the thing *I* am looking at + // show as open" even back when there was only ever one screen to + // ask that about. + readonly property bool isActiveScreen: notchRoot.activeScreenName !== "" + ? (panel.screen && panel.screen.name === notchRoot.activeScreenName) + : panel.screen === Quickshell.screens[0] + readonly property bool pinnedOpen: panel.isActiveScreen && notchRoot.pinnedOpen + readonly property bool launcherOpen: panel.isActiveScreen && notchRoot.launcherOpen + // Launcher mode grabs focus itself (its own TextInput needs it to + // receive typed characters -- see launcherContent's onVisibleChanged + // below). pinnedOpen has no such input by default, so it needs an + // explicit focus target of its own for Escape to have anything to + // bubble up from -- WlrKeyboardFocus.Exclusive alone only grants the + // *surface* keyboard focus at the Wayland level, not Qt Quick's own + // scene-level activeFocus (same lesson learned building the + // launcher's search box). notchOuter is the shared ancestor of every + // dashboard tab's content, so giving it focus here means Escape + // closes the panel by default, and if the user then clicks into + // WallpapersContent's own search box, focus moves there like normal + // -- Escape typed there has nothing to handle it locally and bubbles + // back up to notchOuter's own Keys.onPressed (see below), so it + // keeps working either way without WallpapersContent.qml needing any + // changes of its own. + onPinnedOpenChanged: if (pinnedOpen) Qt.callLater(function() { notchOuter.forceActiveFocus() }) + // Only two ways to be open now (pinned or launcher), both from a + // real click -- used directly for the widened click-away mask too + // (see above), no separate "clickedOpen" needed anymore now that + // there's no passing-hover state to exclude from it. + readonly property bool expanded: pinnedOpen || launcherOpen + + // Which dashboard tab is showing (media/media hover/pin state only -- + // launcher mode is unrelated). 0 Widgets (DashboardContent, the real + // port), 1 Wallpapers, 2 Metrics -- the latter two are stub panes for + // now, shell only, per direct request to build the tab switcher + // before the actual pages. Deliberately NOT gated by isActiveScreen + // like pinnedOpen/launcherOpen above -- it's just an index into + // which tab's content shows, harmless to read identically on every + // screen, and the content it picks between is itself already hidden + // on an inactive screen by expanded (see expandedContent's own + // visible: below). + readonly property int dashboardTab: notchRoot.dashboardTab + + // Claims this screen as the one actually showing the expanded view, + // then opens it -- the one piece every "start showing the pinned + // dashboard" call site needs, so a stray click on an inactive + // screen's own still-collapsed pill cannot end up toggling state + // that then paints wrong on whichever screen WAS already active. + function openPinned() { + notchRoot.activeScreenName = panel.screen ? panel.screen.name : "" + notchRoot.pinnedOpen = true + } + + // Hover-to-reveal for "On Hover" mode, LOCAL to this screen's own + // window -- deliberately NOT on notchRoot. Sharing it there (as it + // was before this file existed once per screen) would mean hovering + // the pill on one monitor also faded it in on every other one, since + // they'd all be reading the exact same flag. Everything else about + // it is unchanged from before this split -- see the original + // comment on notchVisibilityMode (notchRoot's own) for the feature + // itself. + property bool notchHovering: false + + Timer { + id: notchHoverExitTimer + interval: 250 + repeat: false + onTriggered: panel.notchHovering = false + } + + function notchHoverEntered() { + notchHoverExitTimer.stop() + panel.notchHovering = true + } + + function notchHoverExited() { + notchHoverExitTimer.restart() + } + + // Whether the collapsed pill should actually be painted right now -- + // "always" mode (default, matches every prior release) or "hover" + // mode while the cursor is actually near it, or whenever expanded + // (opening it is proof enough it should be visible, independent of + // exactly where the cursor sits at that instant). Only ever fades + // notchOuter's own opacity, never its visible/geometry -- an + // invisible Item stops receiving hover in Qt Quick, which would + // make "on hover" unable to ever detect the hover that is + // supposed to reveal it in the first place. + readonly property bool notchPillRevealed: notchRoot.notchVisibilityMode !== "hover" || panel.notchHovering || panel.expanded + + // Fire-once, not auto-running -- triggered by the tab bar's bottom + // button (see expandedContent below). Opens Omarchy's own real main + // menu -- the exact same command Super+Space itself runs (confirmed + // in ~/.config/hypr/bindings/utilities.lua: `o.bind("SUPER + SPACE", + // "Omarchy menu", "omarchy-menu toggle")`) -- rather than jumping + // straight to the settings submenu or reimplementing a menu here. + Process { + id: mainMenuProc + command: ["omarchy-menu", "toggle"] + running: false + } + + // Click-away-to-dismiss -- covers the whole (now-widened) mask + // while deliberately clicked open (pinned dashboard or launcher). + // Declared before notchOuter on purpose: later siblings win + // hit-testing in QML, so notchOuter's own content (tab buttons, + // icon cards, the hover/pin MouseArea) still gets first crack at any + // click within its own bounds; this only ever catches clicks that + // land outside the notch shape entirely. Resets both flags rather + // than picking one -- only one is ever true at a time in practice, + // but clearing both is simpler than tracking which. + MouseArea { + anchors.fill: parent + // Was panel.clickedOpen, a property that never existed on panel -- + // enabled evaluated to undefined (silently falsy), so click-away + // never actually worked despite the comment above describing it as + // live. Real fix: the same expanded flag everything else already + // uses. + enabled: panel.expanded + onClicked: { panel.notchRoot.pinnedOpen = false; panel.notchRoot.launcherOpen = false } + } + + // "On Hover" mode's own hover-detection zone -- a sibling of + // notchOuter, not a HoverHandler nested inside it, specifically so + // it can start at this surface's true top edge (y: 0) rather than + // notchOuter's own resting offset (restY, 4 -- see that property's + // own comment for why the dead zone existed). Same width as + // notchOuter, height reaching down through its real RESTING bottom + // edge (restY + height, deliberately not notchOuter's own live y, + // which now animates for the slide-down reveal -- see notchOuter's + // own comment) so the whole collapsed footprint -- including the + // reclaimed strip above it -- stays one continuous, FIXED hoverable + // area no matter where the pill's paint currently is mid-animation. + // No visual of its own; purely observes hover, coexists fine with + // notchOuter's own nested MouseAreas (avatar, play/pause, bell) + // since HoverHandler does not consume/block those the way a + // MouseArea's own click handling would. + Item { + id: notchHoverZone + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + width: notchOuter.width + height: notchOuter.restY + notchOuter.height + + HoverHandler { + onHoveredChanged: { + if (hovered) panel.notchHoverEntered() + else panel.notchHoverExited() + } + } + } + + // Drag onto the Shelf: dragging local files over the collapsed pill asks + // ruixen.shelf to open (over its own IPC target -- no live object shared + // between the two plugins), so the drop can land in the open Shelf and be + // seen. No highlight here: the Shelf opening IS the feedback. The Shelf + // owns what happens next (it hides itself if the drag leaves without a + // drop, and stays open once something lands -- see Shelf.qml). + // + // A drop that lands on the pill itself, before the Shelf has taken over + // the drag, is still accepted and relayed (addMany), so a fast release + // never loses the files. Same footprint as notchHoverZone above (a + // sibling of notchOuter, so it keeps working while the pill is slid out + // of view in "On Hover" mode; entering it reveals the pill the way + // hovering does -- a drag doesn't deliver ordinary hover events, which is + // why this reuses notchHoverEntered/Exited explicitly). Inert while the + // notch is expanded: the dashboard/launcher own the surface then. + // + // Opening happens immediately on drag-enter (no dwell timer). Whether a + // drag already in progress carries into the freshly mapped Shelf window is + // compositor behavior that has to be confirmed live; if it does not, the + // single call to remove is relayShelfOpen() in onEntered below, which + // leaves the plain drop-on-the-pill path working. + DropArea { + id: shelfQuickDrop + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + width: notchOuter.width + height: notchOuter.restY + notchOuter.height + enabled: !panel.expanded + // file:// URLs only -- a web image dragged out of a browser arrives + // as an http(s) URL and is ignored here, matching the Shelf's own + // local-files-only contract. + function localUrls(urls) { + var out = [] + for (var i = 0; i < (urls || []).length; i++) { + var s = String(urls[i]) + // A real path after the scheme -- "file:///" (the filesystem + // root) is rejected by the Shelf anyway, so don't accept it here. + if (/^file:\/\/(?:localhost)?\/.+/i.test(s)) out.push(s) + } + return out + } + // Copy semantics only, explicitly (never acceptProposedAction(), + // which would echo a source app's proposed MoveAction): the Shelf + // stores a reference and never moves or deletes the source. + onEntered: (drag) => { + if (shelfQuickDrop.localUrls(drag.urls).length > 0) { + drag.accept(Qt.CopyAction) + panel.notchHoverEntered() + panel.notchRoot.relayShelfOpen() + } + } + onExited: panel.notchHoverExited() + onDropped: (drop) => { + var urls = shelfQuickDrop.localUrls(drop.urls) + panel.notchHoverExited() + if (urls.length === 0) return + panel.notchRoot.relayToShelf(urls) + drop.accept(Qt.CopyAction) + } + } + + Item { + id: notchOuter + anchors.horizontalCenter: parent.horizontalCenter + // y, not anchors.top/topMargin -- direct follow-up on "On Hover" + // mode ("instead of fading in, can we make it slide down"): an + // anchor continuously re-asserts position every frame, which + // would fight a Behavior on y, so vertical position is now a + // plain property instead. restY (4) is the exact same resting + // offset the old anchors.topMargin gave it -- moved here + // verbatim (see margins.top's own comment on the PanelWindow + // above) so the pill's final on-screen position when revealed is + // unchanged from before that fix. + readonly property int restY: 4 + y: panel.notchPillRevealed ? restY : -(height + 8) + Behavior on y { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } } + + // Escape closes the expanded dashboard, same as the launcher's own + // search box already does. Placed here (not on some deeper child) + // so it catches Escape by default (see onPinnedOpenChanged's + // forceActiveFocus above) and still catches it if focus later + // moves to a child that doesn't handle Escape itself, since + // unhandled key events bubble up the visual parent chain. + // + // Tab rotates the three real dashboard panels -- Widgets -> + // Wallpapers -> Metrics -> back to Widgets, same as clicking each + // TabButton in turn. The bottom button deliberately isn't part of + // this cycle: it's a one-shot action (mainMenuProc, opens + // Omarchy's real main menu), not a view, and landing on it via Tab + // either fired it immediately (one direct report: "giving me a + // jump scare") or needed a whole separate selected-vs-activated + // state to avoid that -- simpler to just leave it mouse-only. + // Guarded to pinnedOpen only so this never fights the launcher's + // own Tab handler (launcherOpen's Tab is fully consumed inside + // launcherContent before it could bubble here anyway, but the + // guard makes the split explicit rather than relying on that). + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Escape && panel.expanded) { + panel.notchRoot.pinnedOpen = false + panel.notchRoot.launcherOpen = false + event.accepted = true + } else if (event.key === Qt.Key_Tab && panel.pinnedOpen && !panel.launcherOpen) { + panel.notchRoot.dashboardTab = (panel.notchRoot.dashboardTab + 1) % 4 + event.accepted = true + } + } + // Notch corner geometry derived from this machine's own window + // rounding (Hyprland rounding=24), not a flat guess: + // cornerSize=roundness+4, collapsed radius=roundness+4, expanded + // radius=roundness+20 -- cornerSize=28, collapsed radius=28 (see centerMask + // below), expanded radius=44. + // + // Manually mirrored in ruixen.bar's own notchReservedWidth constant + // (#28, ruixen.bar's own reserved-space calculation; #41/#38 for + // why it's a plain constant there rather than a live read) -- + // change this value, change that one too. + readonly property int cornerSize: 28 + // The mask/shadow silhouette is built from three adjacent pieces + // (left flank, center, right flank). At fractional output scales, + // exact edge-to-edge joins can sample as a 1px hairline where a + // flank touches the center body. Overlap the center slightly + // under both flanks so those joins overdraw instead of butt up + // against each other. Keep this small: it is only a seam guard, + // not a geometry change. + readonly property int seamOverlap: 2 + // Collapsed width trimmed down from an initial 290 -- this row's + // actual content (avatar + divider + play glyph/wave + divider + + // bell) is narrower than that, so 290 left a big empty gap + // between the avatar/bell and the notch's own curved edges. First + // attempt went to 240, but collapsedContent's real parent is a + // clip:true Item sized to exactly bodyWidth (see below) -- + // content measures ~236-240px wide (avatar 20 + 2 dividers + + // wave-track 140 + play glyph + bell + Row spacing), landing + // right at that clip edge and cutting the bell off. 260 gives + // real margin instead of a knife's-edge fit. Expanded width (420) + // still fits this notch's own expanded content comfortably. + // Media hover/pin tries a real dashboard-sized target (implicitWidth + // 900, implicitHeight 56+48*6 = 344) instead of reusing the + // smaller 420x190 -- untested territory for this notch (only 44 + // and 190 are proven safe against the masking bug below), so + // watch for the same flat-bottom-corner symptom if this doesn't + // pan out. + // + // launcherOpen deliberately does NOT follow this -- it keeps its + // own separate 420x190 branch, proven safe, since an earlier + // attempt at a new size for launcher specifically (340x260, then + // up to 360) hit that exact masking bug. Decoupled from + // pinnedOpen here so a size change on one never risks the other. + // + // Collapsed 260 -> 284: the playerPill (see collapsedContent + // below) added its own ~20px of internal padding around the play + // glyph/wave that the old bare Row didn't have, pushing content + // back out to ~260px total -- the exact knife's-edge fit 260 was + // originally chosen to avoid. Per direct report ("avatar circle + // and bell icon is too close to the notch edge now"), bumped by + // roughly that same ~20-24px to restore real margin on both + // sides again. + // + // The collapsed case (284, both launcherOpen/pinnedOpen false) is + // manually mirrored in ruixen.bar's own notchReservedWidth constant + // (#28, #41/#38) -- change this default, change that one too. The + // 420/900 expanded cases are deliberately NOT reflected there: + // ruixen.bar only ever needs to reserve space for the Notch's + // always-present collapsed footprint, not its temporary expanded + // states. + readonly property int bodyWidth: panel.launcherOpen ? 420 : (panel.pinnedOpen ? 900 : 284) + width: bodyWidth + cornerSize * 2 + // 44px collapsed -- ruixen-bar's own reserved screen zone + // (notchClearance) was bumped to cover this plus a buffer, so it + // no longer overlaps tiled windows the way it did when this was + // smaller but the reserved zone was still just barSize-sized. + // Dashboard height bumped past the initial 344 to 400 per direct + // feedback ("a bit too short") -- past the + // previously-tested-safe value, so stress-tested 3x (open/close + // cycles, checking the bottom-corner mask each time) before + // keeping it. Collapsed case (44) manually mirrored in ruixen.bar's + // own notchCollapsedBottomEdge constant (#41/#38) -- change this + // value, change that one too. + height: panel.launcherOpen ? 190 : (panel.pinnedOpen ? 400 : 44) + + Behavior on width { NumberAnimation { duration: 230; easing.type: Easing.OutCubic } } + Behavior on height { NumberAnimation { duration: 230; easing.type: Easing.OutCubic } } + + // Painted background, masked into the notch silhouette below. + // Back to a plain, always-real fill -- direct live regression: + // "the notch bg is stuck on black, its not theme changing anymore". + // Root cause of THAT bug: this was briefly made transparent when + // collapsed (an interim step of the FrameWindow-merge experiment, + // meant to let the frame's own paint show through from underneath + // instead), but notchShadowBlur below is a SOLID filled shape, not + // a thin ring -- with notchBg transparent, nothing in this window + // covered that shadow's own opaque interior anymore, and since + // ruixen.notch's own window stacks ABOVE FrameWindow in the + // compositor, its solid black just sat on top of the frame's + // theme-colored fill, visible everywhere instead of just the + // surrounding halo. The frame-color sync itself was never actually + // the problem THAT merge was solving -- notchRoot.notchColor already + // tracks frameColorMode/Color.background correctly on its own (see + // its own property block above), always did, via the same shared + // state file both plugins read -- no FrameWindow-side hole-punch + // was ever required for THAT part. Reverting this specific piece + // fixes the regression AND restores the natural "shadow only + // visible in the halo, covered by the real shape everywhere else" + // behavior every normal drop shadow has, in both collapsed and + // expanded. + Rectangle { + id: notchBg + anchors.fill: parent + color: panel.notchRoot.notchColor + + layer.enabled: true + layer.smooth: true + layer.effect: MultiEffect { + maskEnabled: true + maskSource: notchMask + maskThresholdMin: 0.5 + maskThresholdMax: 1.0 + maskSpreadAtMin: 1.0 + } + + // Tried, reverted: adding shadowEnabled/shadowColor/shadowBlur/ + // etc. to this same MultiEffect instance ("start with notch + // then", trying the settings card's own shadow effect here + // too). Direct live report at the pinned/dashboard size (900x400): + // "the shape is completely broken, it has almost square edges + // now, the curves are gone" -- this is exactly the documented, + // non-deterministic masking bug this same effect has hit + // before (see this file's own launcher-history comments), + // reproduced live, not just a theoretical risk. Confirmed at + // the collapsed and launcher (420x190) sizes without visible + // breakage first, but the bug didn't show until the larger + // pinned size -- matches its own documented non-determinism, + // not a case of skipping the stress test. Reverted immediately + // per this file's own standing rule: "if the masking bug + // reproduces despite the size-avoidance, stop and report it + // rather than pushing through." Do not re-add shadow + // properties to this MultiEffect instance without solving the + // underlying masking fragility first -- a real fix, not + // another attempt at the same combination. + + // No blurred album-art here anymore -- was previously applied + // across the WHOLE collapsed notch (matching CompactPlayer.qml's + // backgroundArt treatment), but per direct feedback that "doesn't + // look too nice on some theme[s]", the notch itself now stays + // plain OLED black at all times. The art moved into its own + // small pill instead, scoped to just the play/pause + wave + // progress area below (see playerPill) -- avatar, dividers, and + // bell all still sit directly on this same flat black. + } + + // Shadow: a real, visible, BLURRED duplicate of the exact same + // 3-piece silhouette as notchMask below (left flank + center block + // + right flank), dark-filled, sitting BEHIND notchBg. Direct + // pivot after the hand-rolled Canvas ring-shadow (the previous + // version of this comment/block) kept getting the curve/radius + // wrong every tuning pass -- "hard drop", then corner dot + // artifacts, then a genuinely inverted "mustache" corner, then a + // reach that compressed against the shape's own small size: "cant + // the notch edges just produce its own shadow?" + // + // A PLAIN blur (MultiEffect's blurEnabled only) is a fundamentally + // different, much safer code path than the ORIGINAL broken attempt + // this file already has a hard-learned rule against: "Tried, + // reverted: adding shadowEnabled/shadowColor/... to this same + // MultiEffect instance [notchBg's own, which ALSO has maskEnabled] + // ... the shape is completely broken... Do not re-add shadow + // properties to this MultiEffect instance without solving the + // underlying masking fragility first." That bug was specifically + // shadowEnabled COMBINED with maskEnabled on one effect -- this + // shape is never masked at all, it just IS the shape, rendered + // directly and then blurred, so there's nothing for a mask+shadow + // interaction to break. Guaranteed to match the real silhouette + // exactly since it's the identical geometry (same cornerSize, same + // bottomLeftRadius/bottomRightRadius binding), not a hand-derived + // approximation that has to be kept in sync by hand. + // + // Direct live correction, right after this shipped: "check the + // compact notch top edge, right now it has its own top shadow + // which doesnt make sense cause its suppose to be part of the + // frame as one surface illusion." MultiEffect's blur spreads + // beyond its source item's own bounds in every direction by + // default (autoPaddingEnabled) -- exactly what makes the halo + // visible around the sides/bottom, but the same spillover was ALSO + // bleeding upward past the notch's own top edge, into the frame's + // own area, undermining the one thing this whole exclusion exists + // for. notchShadowClip below cuts that off: flush with the notch's + // own true top (no margin there, so nothing can ever render above + // it), but expanded on the left/right/bottom by more than the + // blur's own max reach, so the halo stays fully visible everywhere + // else. Simpler than the old ring-shadow's own approach (an open, + // top-excluding PATH) -- here the shape stays the full, real + // silhouette (still guaranteed to match by construction), only + // WHERE it's allowed to visibly render changes. + Item { + id: notchShadowClip + anchors.top: parent.top + anchors.left: parent.left + anchors.leftMargin: -40 + anchors.right: parent.right + anchors.rightMargin: -40 + anchors.bottom: parent.bottom + anchors.bottomMargin: -40 + z: -1 + clip: true + + // Shadow: a real, visible, BLURRED duplicate of the exact same + // 3-piece silhouette as notchMask below (left flank + center + // block + right flank), dark-filled. Direct pivot after the + // hand-rolled Canvas ring-shadow kept getting the curve/radius + // wrong every tuning pass -- "hard drop", corner dot artifacts, + // an inverted "mustache" corner, a reach that compressed against + // the shape's own small size: "cant the notch edges just produce + // its own shadow?" + // + // A PLAIN blur (MultiEffect's blurEnabled only) is a + // fundamentally different, much safer code path than the + // ORIGINAL broken attempt this file already has a hard-learned + // rule against: "Tried, reverted: adding shadowEnabled/ + // shadowColor/... to this same MultiEffect instance [notchBg's + // own, which ALSO has maskEnabled]... the shape is completely + // broken... Do not re-add shadow properties to this MultiEffect + // instance without solving the underlying masking fragility + // first." That bug was specifically shadowEnabled COMBINED with + // maskEnabled on one effect -- this shape is never masked at + // all, it just IS the shape, rendered directly and then blurred, + // so there's nothing for a mask+shadow interaction to break. + // Guaranteed to match the real silhouette exactly since it's the + // identical geometry (same cornerSize, same bottomLeftRadius/ + // bottomRightRadius binding), not a hand-derived approximation + // that has to be kept in sync by hand. + // + // anchors.margins: 40 + topMargin: 0 undoes notchShadowClip's own + // asymmetric expansion above, so this shape's own bounds land + // back on the notch's real, unexpanded geometry -- the outer + // Item only exists to decide where blur is ALLOWED to spill + // past those bounds, not to resize the shape itself. + Item { + id: notchShadowBlur + anchors.fill: parent + anchors.margins: 40 + anchors.topMargin: 0 + // 0.6 -> 0.9 -> 1.0. ruixen.bar's own frame shadow peaks at a + // true 1.0 right at its own edge (frameShadowCanvas's own + // alpha=1.0*t*t) -- 0.9 here was still a real, if small, gap + // below that peak (this shape's visible portion is only ever + // the blurred-out halo, its own true core hidden under + // notchBg, so the halo's own darkest pixel topped out at 90% + // black blended with wallpaper, never true black). Direct + // live report the gap was visible: "it seems less then the + // frame shadow setting." 1.0 matches the frame's own peak + // exactly. + opacity: 1.0 + + RoundCorner { + anchors.top: parent.top + anchors.left: parent.left + cornerSize: notchOuter.cornerSize + corner: 1 + fillColor: "#000000" + } + + Rectangle { + anchors.top: parent.top + anchors.left: parent.left + anchors.leftMargin: notchOuter.cornerSize - notchOuter.seamOverlap + anchors.right: parent.right + anchors.rightMargin: notchOuter.cornerSize - notchOuter.seamOverlap + height: parent.height + color: "#000000" + topLeftRadius: 0 + topRightRadius: 0 + bottomLeftRadius: panel.expanded ? 44 : 28 + bottomRightRadius: panel.expanded ? 44 : 28 + + Behavior on bottomLeftRadius { NumberAnimation { duration: 230; easing.type: Easing.OutCubic } } + Behavior on bottomRightRadius { NumberAnimation { duration: 230; easing.type: Easing.OutCubic } } + } + + RoundCorner { + anchors.top: parent.top + anchors.right: parent.right + cornerSize: notchOuter.cornerSize + corner: 0 + fillColor: "#000000" + } + + layer.enabled: true + layer.smooth: true + layer.effect: MultiEffect { + blurEnabled: true + // Tried matching ruixen.bar's own frame shadow's numeric + // reach (shadowReachPx: 8) by shrinking this to blurMax: 16 + // -- looked wrong live: "the shadow looks a little less... + // not as soft". The frame's 8px is a hard-edge ring-gradient + // reach, not directly comparable to a Gaussian blurMax; at + // the higher 0.9 opacity above, a short reach reads as an + // abrupt ring instead of a soft halo. Restored to the + // original, larger distance -- the actual bug (wrong + // curve/shape) is fixed independent of this value now, so + // widening it back doesn't reintroduce the old corner + // artifacts, it just fades over more distance again. + blurMax: 32 + blur: 0.6 + } + } + } + + // Mask silhouette: left flank (concave toward center) + center + // block (square top, animated round bottom) + right flank + // (concave toward center). Never drawn directly -- only sampled + // as texture by notchBg's layer.effect above. + Item { + id: notchMask + visible: false + anchors.fill: parent + layer.enabled: true + layer.smooth: true + + RoundCorner { + id: leftFlank + anchors.top: parent.top + anchors.left: parent.left + cornerSize: notchOuter.cornerSize + corner: 1 + fillColor: "#ffffff" + } + + Rectangle { + id: centerMask + anchors.top: parent.top + anchors.left: leftFlank.right + anchors.leftMargin: -notchOuter.seamOverlap + anchors.right: rightFlank.left + anchors.rightMargin: -notchOuter.seamOverlap + height: parent.height + color: "#ffffff" + topLeftRadius: 0 + topRightRadius: 0 + bottomLeftRadius: panel.expanded ? 44 : 28 + bottomRightRadius: panel.expanded ? 44 : 28 + + Behavior on bottomLeftRadius { NumberAnimation { duration: 230; easing.type: Easing.OutCubic } } + Behavior on bottomRightRadius { NumberAnimation { duration: 230; easing.type: Easing.OutCubic } } + } + + RoundCorner { + id: rightFlank + anchors.top: parent.top + anchors.right: parent.right + cornerSize: notchOuter.cornerSize + corner: 0 + fillColor: "#ffffff" + } + } + + // No general-purpose background MouseArea here anymore -- opening + // is now only via clicking the avatar (see UserAvatar's own + // MouseArea below), closing only via clicking away (the mask + // MouseArea above). Individual interactive elements in the + // collapsed row (avatar, play/pause, bell) each own their exact + // click target instead of one catch-all area behind everything. + + Item { + anchors.fill: parent + anchors.leftMargin: notchOuter.cornerSize + anchors.rightMargin: notchOuter.cornerSize + clip: true + + // Collapsed: avatar | divider | play glyph + small wave | divider | bell. + // Matches DefaultView.qml's mainRow layout. + Row { + id: collapsedContent + // expanded already folds in launcherOpen (see panel.expanded), + // so !expanded alone correctly excludes launcher mode too. + visible: !panel.expanded + opacity: panel.expanded ? 0 : 1 + anchors.centerIn: parent + // The frame visually eats the top ~6px (it merges into the + // frame's own border there), so centering in the notch's full + // height reads as shifted too high relative to the actually + // visible space below that. Nudge down to compensate. + anchors.verticalCenterOffset: 2 + spacing: 8 + Behavior on opacity { NumberAnimation { duration: 140 } } + + UserAvatar { anchors.verticalCenter: parent.verticalCenter } + NotchSeparator { anchors.verticalCenter: parent.verticalCenter } + + // Art pill -- the ONLY place blurred album art shows in the + // collapsed notch now (see notchBg above for why). Wraps just + // the play/pause glyph + wave progress, per direct scoping + // request ("leave the bell and avatar and separator bar + // outside the pill as is"). ClippingRectangle, not a plain + // Rectangle -- same documented gotcha as playerCard/the art + // disc in DashboardContent.qml: plain Rectangle.clip only + // clips to the bounding BOX, ignores radius. + ClippingRectangle { + id: playerPill + anchors.verticalCenter: parent.verticalCenter + width: pillRow.implicitWidth + pillPadding * 2 + height: 28 + radius: height / 2 + color: Color.background + + readonly property int pillPadding: 10 + // notchRoot.resolvedWallpaperPath (see its own comment) rather + // than the symlink path directly -- see that comment for why. + readonly property string wallpaperPath: panel.notchRoot.resolvedWallpaperPath !== "" + ? "file://" + panel.notchRoot.resolvedWallpaperPath + : "file://" + Quickshell.env("HOME") + "/.local/state/omarchy/current/background" + readonly property string pillBgSource: panel.notchRoot.artUrl !== "" ? panel.notchRoot.artUrl : wallpaperPath + + Image { + id: pillBgArt + anchors.fill: parent + source: playerPill.pillBgSource + sourceSize: Qt.size(64, 64) + fillMode: Image.PreserveAspectCrop + asynchronous: true + visible: false + } + + MultiEffect { + anchors.fill: parent + source: pillBgArt + blurEnabled: true + blurMax: 32 + blur: 1.0 + opacity: 0.28 + } + + // Dark scrim on top of the blur so the glyph/wave/playhead + // stay readable regardless of how bright the underlying art + // is -- same reasoning as notchBg's own flat base color. + Rectangle { + anchors.fill: parent + color: Qt.rgba(0, 0, 0, 0.35) + } + + Row { + id: pillRow + anchors.centerIn: parent + spacing: 6 + + Text { + id: collapsedPlayGlyph + anchors.verticalCenter: parent.verticalCenter + // Direct follow-up: "we dont need the inactive play + // button to show then" -- showing a play/pause glyph + // next to the active-window title (not a track) never + // made sense once that title took over this slot. + visible: panel.notchRoot.hasMedia + text: panel.notchRoot.playIcon + color: panel.notchRoot.textColor + font.family: panel.notchRoot.fontFamily + // Matches the bar's own icon standard (18px) -- was 11, + // visibly undersized next to the 20px avatar in this + // same row. + font.pixelSize: 18 + + // Own click target, same pattern as the avatar/bell now + // that there's no big background MouseArea to compete + // with -- each interactive element in this row owns its + // exact hit area independently. + MouseArea { + anchors.fill: parent + anchors.margins: -6 + enabled: panel.notchRoot.hasMedia + cursorShape: Qt.PointingHandCursor + onClicked: panel.notchRoot.sendMediaAction("playPause") + } + } + + // Fixed 140x20 "now playing" slot -- exactly one of three + // mutually-exclusive children visible at a time: the + // seeker (media, mediaVizMode "seeker"), a mini cava + // visualizer (media, mediaVizMode "cava"), or the active + // window name (no media at all, unconditional -- see its + // own comment below for why it has no toggle). Direct + // request: "since we have the cava visualizer now... + // click it and it switch between cava and seeker" -- + // scoped to a scroll toggle, and to ONLY the two media- + // present modes: cava reacts to real audio, so with + // nothing playing it would just sit at its own flat idle + // bars, no different from showing nothing at all. Window + // name already covers that state on its own. + Item { + id: nowPlayingSlot + anchors.verticalCenter: parent.verticalCenter + width: 140 + height: 20 + + MouseArea { + anchors.fill: parent + enabled: panel.notchRoot.hasMedia + cursorShape: panel.notchRoot.hasMedia ? Qt.PointingHandCursor : Qt.ArrowCursor + // This MouseArea only actually receives input while + // mediaVizMode is "cava" -- the seeker's own Item + // (with its own MouseArea) sits on top and shadows + // this one entirely whenever it's visible. Right + // click switches back to the seeker, mirroring the + // seeker's own MouseArea's right click going the + // other way -- direct follow-up: "right click to go + // back to the seeker." Wheel no longer toggles mode + // here (removed) -- right click now owns that switch + // exclusively in both directions, freeing wheel up to + // mean "seek" whenever the seeker is the thing + // showing, not "leave the seeker." + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: function(mouse) { + if (mouse.button === Qt.RightButton) { + panel.notchRoot.toggleMediaVizMode() + return + } + // Left click cycles bars/wave -- only meaningful + // once already in cava mode (nothing to cycle on + // the seeker or window name), direct request. + if (panel.notchRoot.mediaVizMode === "cava") panel.notchRoot.toggleCavaMiniStyle() + } + // Wheel cycles bars/wave here too, mirroring the + // seeker's own wheel-seeks-instead-of-switches change + // -- direct follow-up: "on the visualizer when the + // bars are showing, i cant scroll to the wave + // anymore... i guess right click and wheel scroll?" + // Left click keeps doing the same thing too (not + // replaced) -- both are just different ways to reach + // the one toggle, same as wheel/click already being + // two paths to the same seek on the other row. + onWheel: function(wheel) { + if (panel.notchRoot.mediaVizMode === "cava") panel.notchRoot.toggleCavaMiniStyle() + } + } + + // Track (full length, dim) + wave (played portion only, up + // to progressRatio) -- actually reflects position now, + // instead of a decorative full-width wave. No drag-to-seek + // yet, position display only. + Item { + anchors.fill: parent + visible: panel.notchRoot.hasMedia && panel.notchRoot.mediaVizMode === "seeker" + // Grown 12 -> 20 alongside the thickness bump below -- + // WavyLine is a Canvas, and Canvas content outside its + // own item bounds is simply never drawn (an implicit + // clip, not a real mask, but reads the same). At + // lineWidth 4 / amplitudeMultiplier 1.4 the wave's peak + // extent is +-(amp + lineWidth/2) = +-7.6px from center, + // needing >=15.2px of height -- the old 12px container + // was already too short for that, clipping the top/ + // bottom of the wave's crests and troughs. + height: 20 + + // Split point -- where the wave's played portion meets + // the dim unplayed track, before any gap trim. + // + // pressedButtons & LeftButton, not the bare pressed flag -- + // seekerDragArea now also accepts the right button (see its + // own comment), and pressed goes true for ANY accepted + // button. A right-click-hold with the bare flag would + // freeze this at previewRatio's last (possibly stale, e.g. + // 0 before any left-drag ever happened) value instead of + // showing the real live position while held. + readonly property real splitX: width * ((seekerDragArea.pressedButtons & Qt.LeftButton) ? seekerDragArea.previewRatio : panel.notchRoot.progressRatio) + // Same gap-around-the-tip design as the player ring/ + // dials/brightness bar, ported here too now that it's + // right on the other components -- per direct request + // ("bring the design there too"). Computed the same + // width-aware way that fixed the player ring's real bug + // (a flat copied constant doesn't clear a custom-sized + // tip): half the wave's own line width + half the + // playhead's own width + the desired real clearance. + // wave lineWidth 4 (half 2) + playhead width 4 (half 2) + // + 2px desired = 6px, applied as the FULL trim on EACH + // side independently (not split further) -- matches how + // the ring's own gapPx already works, not a fresh guess. + readonly property real gapPx: 6 + + // Track/wave/playhead thickened 2px -> 4px together, then + // wave/playhead alone toned back to 3 (read too thick next + // to the track). Direct follow-up while music was actually + // playing: "the wave thickness... kinda too thin now" -- + // back to 4, matching the track again. + Rectangle { + // Unplayed remainder, trimmed short of the playhead by + // gapPx on this side -- starts past the split point, + // not right at it. + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - parent.splitX - parent.gapPx) + height: 4 + radius: 2 + color: panel.notchRoot.surfaceTint(0.15) + } + + WavyLine { + // Trimmed short of the playhead by gapPx on this side + // too -- the wave's own Canvas bounding width directly + // controls where its rightmost point sits, so shrinking + // it here is enough on its own, no separate lineCap + // handling needed (unlike the circular ring's Canvas + // arc, this is just a smaller bounding box). + width: Math.max(0, parent.splitX - parent.gapPx) + height: parent.height + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + lineColor: panel.notchRoot.hasMedia ? panel.notchRoot.accent : panel.notchRoot.muted + lineWidth: 4 + // 140/6 preserves the same wave density the old + // frequency: 6 (cycles across a fixed 140px reference) + // gave -- expressed directly as a physical wavelength + // now, so it no longer needs that separate reference + // length property at all. + wavelengthPx: 140 / 6 + amplitudeRatio: panel.notchRoot.isPlaying ? 1.4 : 0.15 + running: panel.notchRoot.isPlaying + } + + // Playhead -- stays centered exactly at the true split + // point, unchanged -- only the wave/track on either side + // of it pull back now. + Rectangle { + width: 4 + height: parent.height + radius: width / 2 + anchors.verticalCenter: parent.verticalCenter + color: panel.notchRoot.textColor + x: parent.splitX - width / 2 + visible: panel.notchRoot.hasMedia + + Behavior on x { + enabled: !(seekerDragArea.pressedButtons & Qt.LeftButton) + NumberAnimation { duration: 450 } + } + } + + MouseArea { + id: seekerDragArea + anchors.fill: parent + enabled: panel.notchRoot.hasMedia && panel.notchRoot.canSeek && panel.notchRoot.trackLength > 0 + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + // Left drags to seek (this row's whole reason for + // existing), which collided with left click's prior job + // of switching between the seeker and the mini cava + // visualizer. Direct follow-up: "if left click controls + // the seeker then we cant use left click to switch to + // visualizer... right click triggers the visualizer + // instead now." MouseArea only accepts Left by default, + // so Right is explicitly opted in below and routed to + // toggleMediaVizMode via onClicked -- onPressed/ + // onPositionChanged/onReleased all stay gated to the + // left button specifically (mouse.button/pressedButtons + // checks, not the bare pressed/press-only mouse.button + // default) so a right click can't also start a seek + // drag. Wheel already toggled the same viz mode before + // this PR and still does, unchanged -- this just adds a + // second, more discoverable way to reach it now that + // left click itself means something else on this row. + acceptedButtons: Qt.LeftButton | Qt.RightButton + property real previewRatio: 0 + function ratioFromMouse(mouse) { + if (width <= 0) return 0 + return Math.max(0, Math.min(1, mouse.x / width)) + } + onPressed: function(mouse) { + if (mouse.button === Qt.LeftButton) previewRatio = ratioFromMouse(mouse) + } + onPositionChanged: function(mouse) { + if (mouse.buttons & Qt.LeftButton) { + previewRatio = ratioFromMouse(mouse) + } + } + onReleased: function(mouse) { + if (mouse.button !== Qt.LeftButton) return + var ratio = ratioFromMouse(mouse) + panel.notchRoot.sendMediaSeek(ratio * panel.notchRoot.trackLength) + } + onClicked: function(mouse) { + if (mouse.button === Qt.RightButton) panel.notchRoot.toggleMediaVizMode() + } + // Wheel now seeks (5s/notch, same flat-step convention + // Display/Audio's own volume-scroll rows already use) + // instead of switching to the visualizer -- direct + // follow-up: "when i [wheel] scroll it is going to the + // visualizer, we dont want that... lets use the wheel + // scroll to control the seeker when the seeker is + // showing." Right click (above) is now the only way to + // leave the seeker. + onWheel: function(wheel) { + var delta = wheel.angleDelta.y > 0 ? 5 : -5 + var target = Math.max(0, Math.min(panel.notchRoot.trackLength, panel.notchRoot.trackPosition + delta)) + panel.notchRoot.sendMediaSeek(target) + } + } + } + + // Mini cava visualizer -- a fresh, smaller CavaFeed + // instance (kept separate from ruixen.cava's own edge- + // docked overlay: a different plugin entirely, plugin + // folders can't share state, and this one only needs to + // run while actually visible). Fixed horizontal/bottom- + // grow layout, unlike the full overlay's own configurable + // orientation -- this slot is always exactly 140x20, so + // that generality buys nothing here. + // + // Mirrored, not a plain 1:1 sweep -- direct request: + // real bass on both edges, treble in the center, "looks + // better that way for aesthetic." displayBars is double + // cavaMirrorBands (root, see its own comment): position i + // and position (displayBars-1-i) both read the SAME real + // band, folded around the center. mirrorBandAt(i) is + // exactly "distance from the nearest edge" -- 0 at either + // edge (band 0, bass), displayBars/2-1 at the two + // innermost positions either side of center (the highest + // analyzed band, treble), by construction of the fold. + Item { + id: cavaMiniSlot + anchors.fill: parent + visible: panel.notchRoot.hasMedia && panel.notchRoot.mediaVizMode === "cava" + + readonly property int displayBars: panel.notchRoot.compactCavaFeed.bands * 2 + + function mirrorBandAt(i) { + return Math.min(i, cavaMiniSlot.displayBars - 1 - i) + } + + // Warm center, cool edges -- same formula ruixen.cava/ + // Overlay.qml's own bandColor() already uses, applied + // to the DISPLAY index/count here (mirroring already + // makes display-index distance-from-center behave the + // same way band-index distance-from-center does there). + function barColor(i, level) { + var mid = cavaMiniSlot.displayBars / 2 + var dist = cavaMiniSlot.displayBars > 1 ? Math.abs(i - mid + 0.5) / mid : 0 + var c = Qt.rgba( + panel.notchRoot.cavaWarmColor.r + (panel.notchRoot.cavaCoolColor.r - panel.notchRoot.cavaWarmColor.r) * dist, + panel.notchRoot.cavaWarmColor.g + (panel.notchRoot.cavaCoolColor.g - panel.notchRoot.cavaWarmColor.g) * dist, + panel.notchRoot.cavaWarmColor.b + (panel.notchRoot.cavaCoolColor.b - panel.notchRoot.cavaWarmColor.b) * dist, + 1) + return Qt.lighter(c, 1 + 0.35 * level) + } + + // A subtle opacity taper on the outermost couple of + // positions at EITHER edge -- direct follow-up: "the + // side of the visualizer edge, its kinda sharp... fade + // opacity the edges a bit to soften it up." Same idea + // ruixen.cava/Overlay.qml's own edgeFade already uses, + // just a much smaller edgeFadeBands -- that file's + // fixed 6 is tuned against a strip with 32-96 bands; + // applied unscaled here (displayBars is only 12) it + // would fade the entire left/right HALVES toward + // center, not just the last couple of positions at + // each edge. + readonly property int edgeFadeBands: 2 + + function edgeFade(i) { + var d = Math.min(i, cavaMiniSlot.displayBars - 1 - i) + return Math.max(0, Math.min(1, d / cavaMiniSlot.edgeFadeBands)) + } + + // Bars/wave, click-toggled (notchRoot.toggleCavaMiniStyle, + // nowPlayingSlot's own MouseArea.onClicked) -- no + // "segments" here, see notchRoot.cavaMiniStyle's own comment + // for the sub-pixel math that ruled it out at this size. + Repeater { + model: panel.notchRoot.cavaMiniStyle === "bars" ? cavaMiniSlot.displayBars : 0 + Rectangle { + readonly property real slot: cavaMiniSlot.width / cavaMiniSlot.displayBars + readonly property real thick: Math.max(1, slot - 2) + readonly property real level: panel.notchRoot.compactCavaFeed.levels[cavaMiniSlot.mirrorBandAt(index)] || 0 + width: thick + height: Math.max(2, level * cavaMiniSlot.height) + radius: thick / 2 + x: index * slot + (slot - thick) / 2 + y: cavaMiniSlot.height - height + color: cavaMiniSlot.barColor(index, level) + opacity: cavaMiniSlot.edgeFade(index) + + Behavior on height { NumberAnimation { duration: 90; easing.type: Easing.OutQuad } } + } + } + + // Wave -- a filled, smoothed curve through the same + // mirrored levels the bars use, not just a stroked + // zigzag -- direct correction: "it wasnt suppose to be + // a line, our desktop wave has like gradient filled + // in." Same technique ruixen.cava/Overlay.qml's own + // wave style already uses: quadratic-through-midpoints + // for a smooth curve (each segment's endpoint is the + // midpoint between two real points, the real point + // itself as the control point, so the curve passes + // close to every point without sharp corners), filled + // from that curve down to the bottom edge (bars grow up + // from there too -- same baseline) with the cool/warm/ + // cool gradient, then a brighter stroke retraced along + // the same curve on top for definition against the + // fill. + Canvas { + id: cavaMiniWave + anchors.fill: parent + visible: panel.notchRoot.cavaMiniStyle === "wave" + + function rgbaStr(c, a) { + return "rgba(" + Math.round(c.r * 255) + "," + Math.round(c.g * 255) + "," + Math.round(c.b * 255) + "," + a + ")" + } + + onVisibleChanged: if (visible) requestPaint() + + Connections { + target: panel.notchRoot.compactCavaFeed + function onLevelsChanged() { if (cavaMiniWave.visible) cavaMiniWave.requestPaint() } + } + + onPaint: { + var ctx = getContext("2d") + ctx.reset() + var n = cavaMiniSlot.displayBars + if (n < 2) return + + var pts = [] + for (var i = 0; i < n; i++) { + var level = panel.notchRoot.compactCavaFeed.levels[cavaMiniSlot.mirrorBandAt(i)] || 0 + var x = (i / (n - 1)) * width + var y = height - Math.max(1, level * height) + pts.push(Qt.point(x, y)) + } + + // 8-step gradient (not just 3 flat stops), each + // step's own alpha additionally scaled by + // cavaMiniSlot.edgeFade -- same technique + // ruixen.cava/Overlay.qml's own wave gradient + // already uses. Direct follow-up: "the side of the + // visualizer edge, its kinda sharp... fade opacity + // the edges a bit to soften it up." Without this, + // the fill/stroke hit full alpha right at x=0/x= + // width, where the shape's own edge coincides + // exactly with the canvas's own edge -- reading as + // a hard cutoff rather than easing out. + var grad = ctx.createLinearGradient(0, 0, width, 0) + var steps = 8 + for (var s = 0; s <= steps; s++) { + var t = s / steps + var bandIdx = t * (n - 1) + var c = cavaMiniSlot.barColor(bandIdx, 0) + var fade = cavaMiniSlot.edgeFade(bandIdx) + grad.addColorStop(t, cavaMiniWave.rgbaStr(c, 0.85 * fade)) + } + + ctx.beginPath() + ctx.moveTo(pts[0].x, height) + ctx.lineTo(pts[0].x, pts[0].y) + for (var j = 1; j < n - 1; j++) { + var mx = (pts[j].x + pts[j + 1].x) / 2 + var my = (pts[j].y + pts[j + 1].y) / 2 + ctx.quadraticCurveTo(pts[j].x, pts[j].y, mx, my) + } + ctx.lineTo(pts[n - 1].x, pts[n - 1].y) + ctx.lineTo(pts[n - 1].x, height) + ctx.closePath() + ctx.fillStyle = grad + ctx.fill() + + ctx.beginPath() + ctx.moveTo(pts[0].x, pts[0].y) + for (var k = 1; k < n - 1; k++) { + var mx2 = (pts[k].x + pts[k + 1].x) / 2 + var my2 = (pts[k].y + pts[k + 1].y) / 2 + ctx.quadraticCurveTo(pts[k].x, pts[k].y, mx2, my2) + } + ctx.lineTo(pts[n - 1].x, pts[n - 1].y) + ctx.lineWidth = 2 + ctx.lineCap = "round" + ctx.lineJoin = "round" + ctx.strokeStyle = grad + ctx.stroke() + } + } + } + + // The "no media" half of this same 140x20 slot -- direct + // request: "when theres no music it just shows active + // window". Reads Quickshell.Wayland's own ToplevelManager + // directly (already imported, already used for + // fullscreenActive above) rather than depending on the + // separate omarchy.active-window plugin being installed + // or pinned at all. Centered + elided, not a marquee -- + // direct follow-up ("is there an effect where it scrolls + // horizontally, or i guess truncate is fine tbh? center + // and truncate?") -- simpler, and matches ActiveWindow.qml's + // own elide: Text.ElideRight treatment of the same title + // string. "~" is the empty-state fallback for the rare + // moment nothing is focused at all (just closed + // everything, say), rather than leaving the pill blank. + // No toggle of its own -- unconditional whenever there's + // no media, see nowPlayingSlot's own header comment for why. + CompactWindowTitle { + anchors.fill: parent + visible: !panel.notchRoot.hasMedia + text: panel.notchRoot.compactWindowTitle !== "" ? panel.notchRoot.compactWindowTitle : "~" + // Direct follow-up ("are the text a bit muted") -- this + // is the primary content of the slot now, not a + // secondary status readout, so it gets the same full + // textColor a real track title would use, not muted. + color: panel.notchRoot.textColor + font.family: panel.notchRoot.fontFamily + font.pixelSize: Style.font.bodySmall + } + } + } + } + + NotchSeparator { anchors.verticalCenter: parent.verticalCenter } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: panel.notchRoot.dnd ? "󰂛" : "󰂚" + // Matches the dashboard's own bell exactly (see + // DashboardContent.qml's notification header) -- theme + // accent when notifications are live, the same fixed red + // when silenced. Was notchRoot.muted, per direct request to + // bring the same theme-color/red treatment here too. + // + // Glyph now swaps too (md-bell vs md-bell_off), not just + // color -- real bug caught in DashboardContent.qml's own + // copy of this same bell: the crossed-bell codepoint was + // hardcoded for BOTH states, so "notifications are live" + // was silently showing a slashed bell in accent color + // instead of the plain one. + color: panel.notchRoot.dnd ? "#e05252" : panel.notchRoot.accent + font.family: panel.notchRoot.fontFamily + font.pixelSize: 18 + + Behavior on color { ColorAnimation { duration: 160 } } + + // Real toggle now, not just a state readout -- per direct + // request ("the notification toggle right"). Goes through + // the real "notifications" IPC target (confirmed against + // the real service's own IpcHandler) rather than + // ruixen.quickactions' own bar.shell.firstPartyServiceFor() + // call -- this file has no bar capabilities to make that + // same call with, see the dnd property comment above. + MouseArea { + anchors.fill: parent + anchors.margins: -6 + cursorShape: Qt.PointingHandCursor + onClicked: panel.notchRoot.sendDndAction("toggleDnd") + } + } + } + + // Expanded: blurred-art thumbnail, title/artist, big wave + // progress, transport controls. + // Expanded: dashboard content (player + quick controls/calendar + + // notifications + volume dials) -- see DashboardContent.qml + // for the full breakdown. Media data/colors passed through + // from the same root-level properties the collapsed view + // already reads. + Item { + id: expandedContent + visible: panel.expanded && !panel.launcherOpen + opacity: panel.expanded && !panel.launcherOpen ? 1 : 0 + anchors.fill: parent + anchors.topMargin: 20 + anchors.bottomMargin: 12 + anchors.leftMargin: 12 + anchors.rightMargin: 12 + Behavior on opacity { NumberAnimation { duration: 160 } } + + RowLayout { + anchors.fill: parent + // 8px -- tightened from 10, a bit looser than this row's + // own rhythm otherwise. + spacing: 8 + + // Left vertical tab bar: Widgets/Wallpapers/Metrics stacked + // icon buttons, settings gear pinned at the bottom via a + // fillHeight spacer above it. All three are real now (see + // WallpapersContent.qml/MetricsContent.qml) -- Metrics' + // own right-side stat tiles are still a stub within that + // file, see its own comments. + ColumnLayout { + Layout.preferredWidth: 78 + Layout.maximumWidth: 78 + Layout.fillHeight: true + spacing: 8 + + // Explicitly sets pinnedOpen: true too, even though it's + // already true by the time a tab is clickable at all + // (this whole bar only exists inside the pinned-open + // dashboard now that hover-to-expand is gone) -- cheap + // safety net, not load-bearing anymore. + TabButton { + glyph: "󰕰" + active: panel.dashboardTab === 0 + onActivated: { panel.notchRoot.dashboardTab = 0; panel.openPinned() } + } + TabButton { + glyph: "" + active: panel.dashboardTab === 1 + onActivated: { panel.notchRoot.dashboardTab = 1; panel.openPinned() } + } + TabButton { + glyph: "" + active: panel.dashboardTab === 2 + onActivated: { panel.notchRoot.dashboardTab = 2; panel.openPinned() } + } + TabButton { + glyph: "" + active: panel.dashboardTab === 3 + onActivated: { panel.notchRoot.dashboardTab = 3; panel.openPinned() } + } + + Item { Layout.fillHeight: true } + + // The one real action in this tab bar -- opens Omarchy's + // actual main menu (see mainMenuProc above), the same + // Super+Space menu, not a notch-local settings page. Was + // "omarchy-menu summon settings" (straight to the Setup + // submenu) per direct follow-up asking for the real root + // menu instead. Mouse-only on purpose -- see notchOuter's + // Keys.onPressed comment for why Tab deliberately skips + // this one. + TabButton { + glyph: "" + onActivated: mainMenuProc.running = true + } + } + + // Right: active tab's content. + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + DashboardContent { + anchors.fill: parent + visible: panel.dashboardTab === 0 + shell: panel.notchRoot.shell + // This whole tab bar + content area (an ancestor of this + // item) stays visible: true/opacity-based, not behind a + // Loader -- DashboardContent is alive for the entire + // session, not just while the dashboard is open. Needed + // here so its own nightlight status poll (ruixen-shell + // issue #43) can gate on the same "only while actually + // visible" condition brightnessStateProc's own Timer + // above already uses, instead of polling forever. + panelExpanded: panel.expanded + textColor: panel.notchRoot.textColor + muted: panel.notchRoot.muted + accent: panel.notchRoot.accent + fontFamily: panel.notchRoot.fontFamily + themeSurfaceMode: panel.notchRoot.frameColorMode === "theme" + accentDashboardHeaders: panel.notchRoot.barIconTone === "accent" + sendMediaAction: panel.notchRoot.sendMediaAction + sendMediaSeek: panel.notchRoot.sendMediaSeek + hasMedia: panel.notchRoot.hasMedia + isPlaying: panel.notchRoot.isPlaying + canSeek: panel.notchRoot.canSeek + playIcon: panel.notchRoot.playIcon + title: panel.notchRoot.title + artist: panel.notchRoot.artist + album: panel.notchRoot.album + artUrl: panel.notchRoot.artUrl + resolvedWallpaperPath: panel.notchRoot.resolvedWallpaperPath + progressRatio: panel.notchRoot.progressRatio + trackPosition: panel.notchRoot.trackPosition + trackLength: panel.notchRoot.trackLength + userHost: panel.notchRoot.userHost + displayedTitle: panel.notchRoot.displayedTitle + dnd: panel.notchRoot.dnd + sendDndAction: panel.notchRoot.sendDndAction + notificationHistory: panel.notchRoot.notificationHistory + brightnessPercent: panel.notchRoot.brightnessPercent + brightnessAvailable: panel.notchRoot.brightnessAvailable + setBrightness: panel.notchRoot.setBrightness + } + + WallpapersContent { + id: wallpaperPicker + anchors.fill: parent + visible: panel.dashboardTab === 1 + active: panel.dashboardTab === 1 && panel.expanded + textColor: panel.notchRoot.textColor + muted: panel.notchRoot.muted + accent: panel.notchRoot.accent + secondary: panel.notchRoot.themeSecondaryColor + dangerColor: panel.notchRoot.kanbanDangerColor + fontFamily: panel.notchRoot.fontFamily + // The jump button itself runs the actual launcher + // IPC call (see its own comment); this only collapses + // the notch on the way out, same property + // closeDashboard() above sets -- jumping to the + // launcher's Wallpaper Shuffle extension with the + // notch still expanded underneath it would look like + // two overlays stacked on top of each other. + onOpenWallpaperShuffleRequested: panel.notchRoot.pinnedOpen = false + // Same reasoning, for the Wallpapers Effects jump + // button right next to it. + onOpenWallpaperEffectsRequested: panel.notchRoot.pinnedOpen = false + // Same reasoning, for Theme mode's own Bjarneo Themes + // jump button. + onOpenThemeBrowserRequested: panel.notchRoot.pinnedOpen = false + } + + MetricsContent { + anchors.fill: parent + visible: panel.dashboardTab === 2 + active: panel.dashboardTab === 2 && panel.expanded + textColor: panel.notchRoot.textColor + muted: panel.notchRoot.muted + accent: panel.notchRoot.accent + fontFamily: panel.notchRoot.fontFamily + avatarCacheBust: panel.notchRoot.avatarCacheBust + avatarAnimated: panel.notchRoot.avatarAnimated + avatarFrameCount: panel.notchRoot.avatarFrameCount + avatarFrameDelayMs: panel.notchRoot.avatarFrameDelayMs + avatarFrameDir: panel.notchRoot.avatarFrameDir + } + + KanbanContent { + anchors.fill: parent + visible: panel.dashboardTab === 3 + textColor: panel.notchRoot.textColor + muted: panel.notchRoot.muted + accent: panel.notchRoot.accent + confirmColor: panel.notchRoot.cavaCoolColor + successColor: panel.notchRoot.kanbanSuccessColor + dangerColor: panel.notchRoot.kanbanDangerColor + warningColor: panel.notchRoot.kanbanWarningColor + // Per direct request to swap which of the column panel + // and its nested cards reads darker: cards sit ON TOP + // of the column's own already-tinted fill (both + // double-painted -- KanbanContent's own cardBase + + // cardChrome -- and single-painted alike), so tinting + // them FURTHER toward textColor only ever pushes darker + // still when textColor itself is dark (a light theme) -- + // confirmed live, no alpha was low enough to escape + // that, since it's the wrong tint DIRECTION, not just + // too strong a magnitude. Tints toward white instead + // specifically when textColor is dark (surface is + // light) -- that's the one direction that can actually + // read lighter than an already-darkened parent. Keeps + // the original textColor-tint on a dark theme (textColor + // light there), where that direction already lightens + // correctly on its own, same as before. 0.65, tuned + // live against the actual rendered card (not derived on + // paper): the card's own MultiEffect drop shadow sits + // BEHIND cardBase at a real, non-trivial opacity + // (shadowOpacity 0.38) and bleeds through the card's own + // translucent fill from underneath, darkening the + // visible result well past what plain double-layer + // alpha compositing alone predicts -- 0.2 (matched to + // DashboardContent's own dashboardCardSurface, which + // has no such shadow-bleed factor) still rendered + // clearly darker than the column live, 0.4 landed + // exactly equal to it, 0.65 is what actually reads + // lighter on screen. + cardSurface: panel.notchRoot.surfaceLuminance(panel.notchRoot.textColor) < 0.5 + ? Qt.rgba(1, 1, 1, 0.65) + : Qt.rgba(panel.notchRoot.textColor.r, panel.notchRoot.textColor.g, panel.notchRoot.textColor.b, 0.055) + cardBorderColor: Qt.rgba(panel.notchRoot.textColor.r, panel.notchRoot.textColor.g, panel.notchRoot.textColor.b, 0.14) + editorSurface: Qt.rgba(panel.notchRoot.textColor.r, panel.notchRoot.textColor.g, panel.notchRoot.textColor.b, 0.075) + fontFamily: panel.notchRoot.fontFamily + kanbanService: panel.notchRoot.kanbanService + } + } + } + } + + LauncherContent { + overlayRoot: panel.notchRoot + panel: panel + appLibrary: panel.notchRoot.appLibrary + } + } + } +} diff --git a/bars/widgets/ruixen.notch/Overlay.qml b/bars/widgets/ruixen.notch/Overlay.qml index d9d0eb5..4cc02b5 100644 --- a/bars/widgets/ruixen.notch/Overlay.qml +++ b/bars/widgets/ruixen.notch/Overlay.qml @@ -456,44 +456,6 @@ Item { onLoadFailed: root.notchVisibilityMode = "always" } - // Hover-to-reveal for "On Hover" mode -- explicitly a different - // feature from the old hover-to-EXPAND that was removed (see - // notchOuter's own comment below for that history): this only fades - // the collapsed pill's own paint in/out, it never opens the - // dashboard, never grabs keyboard focus, and never eats a click - // meant for something else -- none of the reasons that removal - // happened apply here. Debounced on exit (short Timer below) so - // passing the cursor through on the way to something else at the - // top of the screen does not flicker it. - property bool notchHovering: false - - Timer { - id: notchHoverExitTimer - interval: 250 - repeat: false - onTriggered: root.notchHovering = false - } - - function notchHoverEntered() { - notchHoverExitTimer.stop() - root.notchHovering = true - } - - function notchHoverExited() { - notchHoverExitTimer.restart() - } - - // Whether the collapsed pill should actually be painted right now -- - // "always" mode (default, matches every prior release) or "hover" - // mode while the cursor is actually near it, or whenever expanded - // (opening it is proof enough it should be visible, independent of - // exactly where the cursor sits at that instant). Only ever fades - // notchOuter's own opacity, never its visible/geometry -- an - // invisible Item stops receiving hover in Qt Quick, which would - // make "on hover" unable to ever detect the hover that is - // supposed to reveal it in the first place. - readonly property bool notchPillRevealed: root.notchVisibilityMode !== "hover" || root.notchHovering || panel.expanded - function sendDndAction(action) { if (dndActionProcess.running) return dndActionProcess.command = ["omarchy-shell", "notifications", String(action)] @@ -609,7 +571,12 @@ Item { // while this is visible; no reason to poll while collapsed). Timer { interval: 5000 - running: panel.expanded + // panel.expanded (the old single window's own property) doesn't + // exist here any more -- this Timer lives on root, not on any one + // screen's own NotchWindow.qml instance. root.pinnedOpen/launcherOpen + // are the same "is the dashboard open at all" signal now, just + // shared instead of local to one window. + running: root.pinnedOpen || root.launcherOpen repeat: true onTriggered: if (!brightnessStateProc.running) brightnessStateProc.running = true } @@ -872,1977 +839,217 @@ Item { return minutes + ":" + String(rest).padStart(2, "0") } - // Quarter-circle silhouette, one corner at a time -- used as mask - // material for the concave flanks. Same data-driven shape as - // ruixen.bar/Bar.qml's own RoundCorner (see its comment for the - // geometry explanation) -- a quarter-circle arc of radius - // `cornerSize`, centered on the box's diagonally opposite corner, - // closed off by a line back to this wedge's own sharp corner. Keyed - // by an int here (0 TopLeft, 1 TopRight, 2 BottomLeft, 3 - // BottomRight) instead of a string, matching how this copy's own - // callers already pass `corner` as a plain int. - component RoundCorner: Item { - id: rc - property int corner: 0 - property int cornerSize: 20 - property color fillColor: "#ffffff" - - implicitWidth: cornerSize - implicitHeight: cornerSize - - onFillColorChanged: canvas.requestPaint() - onCornerChanged: canvas.requestPaint() - onCornerSizeChanged: canvas.requestPaint() - onVisibleChanged: if (visible) canvas.requestPaint() - - readonly property var cornerGeometry: ([ - { centerX: 1, centerY: 1, startAngle: Math.PI, endAngle: 1.5 * Math.PI, pointX: 0, pointY: 0 }, - { centerX: 0, centerY: 1, startAngle: 1.5 * Math.PI, endAngle: 2 * Math.PI, pointX: 1, pointY: 0 }, - { centerX: 1, centerY: 0, startAngle: 0.5 * Math.PI, endAngle: Math.PI, pointX: 0, pointY: 1 }, - { centerX: 0, centerY: 0, startAngle: 0, endAngle: 0.5 * Math.PI, pointX: 1, pointY: 1 } - ]) - - Canvas { - id: canvas - anchors.fill: parent - antialiasing: true - onPaint: { - var ctx = getContext("2d") - var size = rc.cornerSize - var g = rc.cornerGeometry[rc.corner] - ctx.clearRect(0, 0, width, height) - if (!g) return - - ctx.beginPath() - ctx.arc(g.centerX * size, g.centerY * size, size, g.startAngle, g.endAngle) - ctx.lineTo(g.pointX * size, g.pointY * size) - ctx.closePath() - ctx.fillStyle = rc.fillColor - ctx.fill() - } - } - } - - // A continuous sine-wave line, in the spirit of Material 3 - // Expressive's own wavy active-indicator pattern (amplitude + - // wavelength describing the wave, animated by a looping phase - // offset) -- written independently against that public design - // language rather than ported from any one project's own - // implementation. Parametrized directly in physical terms - // (wavelengthPx: the px distance between crests) instead of a raw - // cycle count relative to some separately-tracked reference length, - // which also removes the need for a second "what is this frequency - // relative to" property entirely -- the wave's spatial density no - // longer depends on the canvas's own current width at all, so it - // stays visually consistent even while that width is animating (see - // the caller below, where the visible portion shrinks/grows as - // playback progresses). - // - // Phase advances from an accumulated elapsed-time counter - // (FrameAnimation's own per-frame frameTime, in seconds) rather than - // dividing a raw Date.now() timestamp -- avoids the wave's motion - // silently losing precision from an ever-growing epoch value over a - // long-running session, and reads as "speed in radians/second" - // directly instead of an opaque magic divisor. - component WavyLine: Canvas { - id: wave - property color lineColor: "#ffffff" - property real lineWidth: 2 - property real wavelengthPx: 24 - property real amplitudeRatio: 0.5 - property bool running: true - // Radians/second -- 2.5 matches the undulation speed the previous - // Date.now()/400 divisor gave (1000ms / 400 = 2.5 rad/s). - property real phaseSpeed: 2.5 - - readonly property bool shouldAnimate: running && visible && width > 0 && opacity > 0 - property real phase: 0 - - onPaint: { - var ctx = getContext("2d") - ctx.clearRect(0, 0, width, height) - if (width <= 0 || height <= 0 || wave.wavelengthPx <= 0) return - - var amplitude = wave.lineWidth * wave.amplitudeRatio - var angularStep = (2 * Math.PI) / wave.wavelengthPx - var centerY = height / 2 - var halfStroke = wave.lineWidth / 2 - - ctx.strokeStyle = wave.lineColor - ctx.lineWidth = wave.lineWidth - ctx.lineCap = "round" - ctx.beginPath() - - var drawnFirstPoint = false - for (var x = halfStroke; x <= wave.width - halfStroke; x += 1) { - var y = centerY + amplitude * Math.sin(angularStep * x + wave.phase) - if (!drawnFirstPoint) { - ctx.moveTo(x, y) - drawnFirstPoint = true - } else { - ctx.lineTo(x, y) - } - } - ctx.stroke() - } - - FrameAnimation { - running: wave.shouldAnimate - onTriggered: { - wave.phase += wave.phaseSpeed * frameTime - wave.requestPaint() - } + // Shared, genuinely-singleton dashboard/launcher state -- one logical + // notch, even though NotchWindow.qml below is instantiated once per + // screen. Previously lived directly on the one-and-only PanelWindow + // when there was only ever one; moved here so every screen's own + // window can read the same open/closed/tab state instead of each + // tracking its own independent copy. See NotchWindow.qml's own + // panel.isActiveScreen comment for why only ONE screen at a time + // actually renders the expanded view / grabs exclusive keyboard + // focus for it, rather than every monitor doing so simultaneously. + property bool pinnedOpen: false + property bool launcherOpen: false + property int dashboardTab: 0 + // Which screen's own window is currently showing the expanded view -- + // set by NotchWindow.qml's own openPinned() the moment a click opens + // it on that screen. Empty until the first open; each window falls + // back to treating Quickshell.screens[0] as the active one while it's + // empty, so a cold start (or an IPC-driven open, which has no "which + // screen" concept of its own) still behaves exactly like the old + // single-window build always did. + property string activeScreenName: "" + + // Aliases so NotchWindow.qml (a separate file/document, one instance + // per screen) can reach these singleton services across the file + // boundary -- a plain id is only visible within the SAME document, + // unlike a nested `component` declaration, which is why these need + // an explicit property here at all. + property alias appLibrary: appLibrary + property alias notificationHistory: notificationHistory + property alias kanbanService: kanbanService + property alias compactCavaFeed: compactCavaFeed + + IpcHandler { + target: "ruixen.notch" + function openLauncher(): void { root.launcherOpen = true } + function closeLauncher(): void { root.launcherOpen = false } + function toggleLauncher(): void { root.launcherOpen = !root.launcherOpen } + // Real, permanent counterparts for the dashboard itself (not the + // launcher) -- direct Discord ask ("a keybind to summon the + // notch"), same shape as the launcher trio above. + // + // Always resets to the Widgets tab (0) -- direct follow-up after + // a live report that Super+K (openDashboardTab kanban) followed + // by Super+N (toggleDashboard) "stays in kanban": the two keys + // are meant to do different jobs (K jumps straight to Kanban, + // N opens/closes the dashboard itself) but originally shared + // dashboardTab's own "wherever you left it" state, so N looked + // like it did nothing right after K. Deliberately NOT touching + // the plain click-to-expand interaction elsewhere in this file + // (onClicked: root.pinnedOpen = true) or openDashboardTab below + // -- clicking the notch itself still opens on whatever tab was + // last selected, and a keybind can still jump straight to any + // specific tab; only these two IPC entry points force Widgets. + function openDashboard(): void { + root.dashboardTab = 0 + root.pinnedOpen = true } - } - - // Small circular user avatar, same ~/.face.icon convention as - // UserInfo.qml. Falls back to a plain dark circle if the file doesn't - // exist -- Image just shows nothing, the Rectangle behind it still - // reads as an avatar-shaped placeholder. - component UserAvatar: Item { - id: avatar - property int avatarSize: 20 - - implicitWidth: avatarSize - implicitHeight: avatarSize - - // AnimatedImage ONLY for a genuine multi-frame animation - // (frameCount > 1) -- plain Image for everything else, including - // every static DiceBear/GitHub/custom avatar. Direct live finding - // (confirmed with temporary console.log instrumentation, then - // isolated by forcing each element as the sole source in turn): - // AnimatedImage decodes a NEW static image correctly every time - // (status genuinely reaches Ready with the right content) but its - // own texture update does not reliably propagate to MultiEffect past - // the very first successful decode of its lifetime -- a second, - // different static image assigned to the SAME AnimatedImage element - // silently kept showing the first one on screen forever, while the - // exact same reload sequence through plain Image alone refreshed - // correctly every single time. Real animated GIFs are unaffected - // either way (their own frame timer drives repaints through a - // completely different path, confirmed live: frameCount > 1 still - // animates correctly here) -- this only changes which element - // renders STATIC content. .ico still needs the fallback too, for the - // older, separate reason below (AnimatedImage's decoder errors out - // on it outright). - readonly property var activeAvatarImage: (avatarImage.status === Image.Ready && avatarImage.frameCount > 1) - ? avatarImage : avatarImageFallback - property int avatarFrameIndex: 0 - readonly property string avatarSource: root.avatarAnimated - ? "file://" + root.avatarFrameDir + "/frame-" + ("00" + avatar.avatarFrameIndex).slice(-3) + ".png#" + root.avatarCacheBust - : "file://" + Quickshell.env("HOME") + "/.face.icon#" + root.avatarCacheBust - - Timer { - interval: root.avatarFrameDelayMs - running: root.avatarAnimated && root.avatarFrameCount > 1 - repeat: true - onTriggered: avatar.avatarFrameIndex = (avatar.avatarFrameIndex + 1) % root.avatarFrameCount + function closeDashboard(): void { root.pinnedOpen = false } + function toggleDashboard(): void { + if (!root.pinnedOpen) root.dashboardTab = 0 + root.pinnedOpen = !root.pinnedOpen } - - // Placeholder shown until/unless ~/.face.icon exists -- a gradient - // square (see its own no-radius comment below) instead of a shipped - // PNG asset, so there's no fixed resolution/ratio to pick and it - // scales cleanly at any avatarSize. Explicitly hidden once a real - // image is loaded, not just painted over by an assumed-opaque one - // -- direct follow-up ("why do we need to keep showing the - // fallback gradient, why cant gradient just be something on and - // then off between dicebear... why do we need both to appear and - // overlap"): the two layers overlapping regardless of load state - // is exactly what let any imperfection show up as the gradient - // visibly bleeding through, real bug hit live and fixed twice - // before landing here. With this, the gradient is structurally - // absent whenever a real avatar is showing. - Rectangle { - anchors.fill: parent - // Circular, unlike the real DiceBear avatars this sits behind - // (deliberately plain square now, see selectAvatar's own - // comment) -- direct follow-up ("keep it for the gradient - // though, the gradient default we load in is square now... why - // not just make the gradient a circle"). Never shown at the same - // time as a real avatar (visible below is gated on the image - // NOT being ready), so the two shapes never need to match. - radius: width / 2 - visible: !root.avatarAnimated && avatar.activeAvatarImage.status !== Image.Ready - // Theme-aware, not two hardcoded colors -- direct follow-up - // ("install you get the fallback or default gradient, maybe - // theme aware?"). root.accent is already Color.accent (see its - // own declaration above), so this now follows the active - // Omarchy theme instead of a fixed indigo/violet pair. - gradient: Gradient { - GradientStop { position: 0.0; color: Qt.lighter(root.accent, 1.6) } - GradientStop { position: 1.0; color: Qt.darker(root.accent, 1.4) } - } + // Jump straight to one tab -- direct follow-up ("a keybind that + // opens to the kanban board... to see updates"). A separate + // function with a required plain-string arg, not an optional + // JSON payload bolted onto openDashboard above: Quickshell's + // IpcHandler enforces exact arity against the declared typed + // signature (confirmed live -- "Too few arguments provided (1 + // required but 0 were provided)" the moment openDashboard grew a + // required param), so a single shared function would have broken + // every existing zero-arg `openDashboard` call instead of + // actually being optional. Valid tab values: "widgets" (0), + // "wallpapers" (1), "metrics" (2), "kanban" (3) -- matching + // dashboardTab's own fixed index order, left/right tab bar top + // to bottom. An unrecognized name is a no-op on the tab (still + // opens on whichever tab was already selected). + function openDashboardTab(tab: string): void { + var tabNames = ["widgets", "wallpapers", "metrics", "kanban"] + var index = tabNames.indexOf(tab) + if (index >= 0) root.dashboardTab = index + root.pinnedOpen = true } - - // No circular treatment -- direct follow-up chain: first "why do - // we still hard cap a circle around it, doesnt dicebear take care - // of it" (tried DiceBear's own radius=50 param, which scales each - // style's content to fit a circle instead of the old MultiEffect - // mask's blind crop), then, after actually seeing it, "the circle - // is still there... the circle mask comes back" -- radius=50 still - // produces a circle, just a better-behaved one, which wasn't the - // actual ask. Dropped radius=50 too (see ruixen.settings' - // selectAvatar). - // - // Rounded-square clip, not a hard rectangle though -- direct - // follow-up ("on the site it shows it has like a curved around the - // edge, its not suppose to be rectangular with hard edge"). That - // curve is DiceBear's own website preview-card CSS, not part of - // the fetched image -- confirmed by reading pixelbot's raw SVG - // directly, rx="0" regardless of style, same as every other style - // checked so far. So a real mask is what gets that look here, same - // MultiEffect technique the notch's own shape uses, just a small - // proportional radius instead of width/2 -- a rounded square, not - // a circle (the circle stays for the gradient placeholder only, - // per its own comment above). - // AnimatedImage exists alongside plain Image so a picked GIF still - // actually animates (activeAvatarImage's own comment above covers - // when each one is actually selected) -- QMovie (the decoder behind - // AnimatedImage) also supports a narrower format set than - // QImageReader (behind plain Image): an .ico-format ~/.face.icon - // decodes fine via Image but errors out via AnimatedImage every - // time, confirmed live. - AnimatedImage { - id: avatarImage - anchors.fill: parent - source: avatar.avatarSource - fillMode: Image.PreserveAspectCrop - asynchronous: true - cache: false - visible: root.avatarAnimated + // Wallpapers tab's own toggle -- same shape as toggleDashboard + // above (a closed notch opens ON the wallpapers tab, an open one + // just flips pinnedOpen), so the recommended keybind reads like + // the other notch keys instead of always re-opening. Keybind + // recipe: o.bind("SUPER + CTRL + SPACE", "Ruixen wallpapers", + // "omarchy-shell ruixen.notch toggleWallpapers") -- that key is + // Omarchy's default Background switcher, so it needs an explicit + // hl.unbind first (install.sh's --with-launcher-keybind recommends + // it but leaves a taken key untouched, printing the collision). + function toggleWallpapers(): void { + if (!root.pinnedOpen) root.dashboardTab = 1 + root.pinnedOpen = !root.pinnedOpen } - - Image { - id: avatarImageFallback - anchors.fill: parent - source: avatar.avatarSource - fillMode: Image.PreserveAspectCrop - asynchronous: true - cache: false - visible: false + // Called by ruixen.settings' General page after Shuffle/Reset + // writes or removes ~/.face.icon -- this plugin is keepLoaded: + // true, so nothing else would tell UserAvatar's Image to re-read + // the file. + function refreshAvatar(): void { + avatarStateFile.reload() + root.avatarCacheBust = root.avatarCacheBust + 1 } - Rectangle { - id: avatarImageMask - anchors.fill: parent - radius: width * 0.2 - color: "#ffffff" - visible: false - layer.enabled: true + // Kanban tab (4th dashboard tab) -- direct request, agent-native + // by design: these are the SAME functions KanbanContent.qml's + // own UI calls on kanbanService directly, exposed here too so + // the board can be driven entirely from the CLI, e.g. + // `omarchy-shell ruixen.notch kanbanAddCard "Fix bug" todo`. + // Column ids are always exactly "todo"/"in-progress"/"done" -- + // see KanbanModel.js's own header for why the count is fixed. + // priority is "high"/"medium"/"low" (defaults to "medium" if + // omitted or unrecognized) -- direct request ("take care of the + // priority via the api, this shit will be agent run mostly"): no + // manual UI to set it, CLI/agent-only, same as add/rename. + function kanbanAddCard(title: string, columnId: string, priority: string): string { + return kanbanService.addCard(title, columnId, priority) } - - MultiEffect { - anchors.fill: parent - source: avatar.activeAvatarImage - visible: !root.avatarAnimated - maskEnabled: true - maskSource: avatarImageMask - maskThresholdMin: 0.5 - maskThresholdMax: 1.0 + function kanbanMoveCard(cardId: string, columnId: string): void { + kanbanService.moveCard(cardId, columnId) } - - // The click target for opening the dashboard now that hover-to- - // expand is gone -- per direct request. Only ever visible/hit- - // testable in the collapsed row (its parent Row's own visible: - // false while expanded already makes this inert then, no extra - // guard needed). Margins widen the actual hit target past the - // small 20px visual circle, same -6 pattern used elsewhere in this - // file for small icons. - MouseArea { - anchors.fill: parent - anchors.margins: -6 - cursorShape: Qt.PointingHandCursor - onClicked: panel.pinnedOpen = true + function kanbanAdvanceCard(cardId: string): void { kanbanService.advanceCard(cardId) } + function kanbanRegressCard(cardId: string): void { kanbanService.regressCard(cardId) } + function kanbanRemoveCard(cardId: string): void { kanbanService.removeCard(cardId) } + function kanbanClearDone(): void { kanbanService.clearDone() } + function kanbanRenameColumn(columnId: string, label: string): void { + kanbanService.renameColumn(columnId, label) } - } - - // Thin vertical divider, ported from Separator.qml. - component NotchSeparator: Rectangle { - implicitWidth: 3 - implicitHeight: 16 - radius: width / 2 - color: root.textColor - opacity: 0.1 - } - - // Left-side vertical tab-bar button (Widgets/Wallpapers/Metrics - // stacked, plus a settings gear pinned at the bottom). Just a plain - // icon + tonal hover/active background, no external styling - // dependency, same approach as every other component in this file. - component TabButton: Rectangle { - id: tabBtn - property string glyph: "" - property bool active: false - signal activated() - - // 48 -> 56, glyph 18 -> 20 -- per direct request to grow the - // left/right rail icons together rather than keep chasing a - // sub-pixel detail on the dial tip. - Layout.preferredWidth: 56 - Layout.preferredHeight: 56 - Layout.alignment: Qt.AlignHCenter - radius: 14 - color: active ? root.surfaceTint(0.14) : (tabMouse.containsMouse ? root.surfaceTint(0.07) : "transparent") - Behavior on color { ColorAnimation { duration: 120 } } - - Text { - anchors.centerIn: parent - text: tabBtn.glyph - // Direct request: always the theme accent color, not just while - // active -- the active/inactive distinction stays on the pill - // background behind this (tabBtn's own color: active ? surfaceTint - // ... above), not on the glyph itself anymore. - color: root.accent - font.family: root.fontFamily - font.pixelSize: 20 + function kanbanSetPriority(cardId: string, priority: string): void { + kanbanService.setPriority(cardId, priority) } - - MouseArea { - id: tabMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: tabBtn.activated() + // No-op on a blank title -- see KanbanModel.renameCard's own + // comment. Fixes a real gap: a card's title was write-once at + // creation before this, no way to fix a typo or reword it after. + function kanbanRenameCard(cardId: string, title: string): void { + kanbanService.renameCard(cardId, title) } - } - - PanelWindow { - id: panel - visible: !root.fullscreenActive && !root.barHidden && !root.fullbarStyle - // bottom:true too, not just top/left/right -- the click-away-to- - // dismiss MouseArea (see below) needs the panel's own surface to - // actually reach the full screen height for its widened mask to - // cover anywhere outside the notch. Anchoring both top and bottom - // makes the surface fill the full height regardless of - // implicitHeight; exclusionMode stays Ignore so this never reserves - // screen space the way ruixen.bar's own window does. - anchors { top: true; left: true; right: true; bottom: true } - // 0, not 4 -- direct report on "On Hover" mode: "theres a bit of - // dead area on top of the notch... hitting the top edge right now - // is considered not a on hover area". A Wayland layer-surface - // margin genuinely shrinks the surface's own occupied/mapped - // screen area, not just where its QML content starts drawing -- - // with this at 4, the true top 4 screen pixels belonged to no - // surface's input region at all (ruixen.frame-widget's own - // overlay is deliberately click/hover-through everywhere, mask: - // Region {}, so nothing was there to catch it either), a real dead - // strip impossible to reveal-by-hover into since there is nothing - // to visually aim for while faded out. The former 4px inset moved - // into notchOuter's own anchors.topMargin below instead (a plain - // QML anchor, not a surface margin) -- the pill's own final - // painted position on screen is unchanged, so ruixen.bar's own - // notchCollapsedBottomEdge constant (still 48 = 0 surface margin + - // notchOuter's own 4 anchor margin + 44 height) needs no change. - margins.top: 0 - implicitHeight: 220 - exclusionMode: ExclusionMode.Ignore - color: "transparent" - - WlrLayershell.namespace: "ruixen-notch" - // REVERTED back to Overlay -- the Overlay -> Top change (made to - // auto-hide over fullscreen windows) broke click-to-expand: this - // namespace's own layer surface geometry spans nearly the full - // screen (see `hyprctl layers`), overlapping omarchy-bar's own - // surface, which is ALSO on `top`. Two same-layer surfaces with - // overlapping geometry have compositor-decided (not - // deterministic-to-us) stacking order for pointer hit-testing -- - // omarchy-bar apparently won that contest, eating clicks meant for - // the notch pill before they ever reached this surface. Overlay is - // strictly above every other layer including top, so there's no - // same-layer contention -- confirmed working again after this - // revert. Fullscreen-hide needs a different mechanism (real - // fullscreen-state watching) that doesn't reuse a layer another - // always-on surface already occupies. - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.exclusionMode: ExclusionMode.Ignore - // None except while expanded (pinned dashboard or launcher), when it - // switches to Exclusive (same mode ruixen.settings/Settings.qml and - // Omarchy's own real Menu.qml use for their modal panels) so Escape - // can dismiss either view and the launcher's own TextInput can - // receive typed characters. Collapsed stays None -- passive hover, no - // reason to grab keyboard input. v1 of the launcher's search box - // needed this SAME flip, but combined it with a taller notch size, - // which is what actually broke (see launcherContent's own comment - // below) -- this flip alone, without the resize, is the untested - // half of that old combination. Extending it to pinnedOpen too - // carries the same low risk noted there: no new size involved, just - // a focus-mode change on an already-proven 900x400 footprint. - WlrLayershell.keyboardFocus: expanded ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None - - // Widens to the full panel only while open (pinned or launcher -- - // see expanded below), so a click anywhere outside the notch itself - // actually reaches this surface (see the click-away MouseArea - // below) instead of passing straight through to whatever's behind. - // Collapsed is the only other state now -- hover-to-expand was - // removed (see expanded below), so there's no longer a third - // "briefly widened but shouldn't swallow other clicks" case to - // exclude here. - // - // Collapsed y is 0, not notchOuter's own resting offset -- this - // Region is what actually tells the compositor which pixels of - // this surface receive pointer input at all; leaving it at that - // offset would reopen the exact "On Hover" dead-zone strip - // margins.top's own comment above describes fixing, just moved - // from a surface-level gap to a mask-level one. Height reaches - // notchOuter's real RESTING bottom edge (notchOuter.restY + - // notchOuter.height, i.e. notchCollapsedBottomEdge) -- deliberately - // notchOuter.restY, not its live y: that now animates between - // restY and a negative off-screen value for the slide-down reveal - // (see notchOuter's own comment), and this mask has to stay fixed - // at the resting footprint regardless of where the pill's paint - // currently is mid-animation, or hover detection while fully - // hidden (y at its most negative) would shrink toward a near-zero - // or negative height and stop working -- the one moment it most - // needs to keep working, since that is the state hovering is - // supposed to reveal it FROM. - mask: Region { - x: panel.expanded ? 0 : notchOuter.x - y: panel.expanded ? 0 : 0 - width: panel.expanded ? panel.width : notchOuter.width - height: panel.expanded ? panel.height : (notchOuter.restY + notchOuter.height) - } - - // Hover-to-expand removed entirely, per direct request -- clicking - // the avatar (see UserAvatar's own MouseArea) is now the only way - // to open the dashboard, dismissing by clicking away (below). - // Deliberate over accidental: hovering near the top of the screen - // for an unrelated reason (dragging a window, reaching for - // something else at the top edge) no longer pops the notch open. - // This also removes the hoverExitTimer debounce hack that hover - // needed (see the old git history if that's ever wanted back) -- - // a real click has no equivalent "did I actually mean to leave" - // ambiguity a hover does. - property bool pinnedOpen: false - // Launcher mode grabs focus itself (its own TextInput needs it to - // receive typed characters -- see launcherContent's onVisibleChanged - // below). pinnedOpen has no such input by default, so it needs an - // explicit focus target of its own for Escape to have anything to - // bubble up from -- WlrKeyboardFocus.Exclusive alone only grants the - // *surface* keyboard focus at the Wayland level, not Qt Quick's own - // scene-level activeFocus (same lesson learned building the - // launcher's search box). notchOuter is the shared ancestor of every - // dashboard tab's content, so giving it focus here means Escape - // closes the panel by default, and if the user then clicks into - // WallpapersContent's own search box, focus moves there like normal - // -- Escape typed there has nothing to handle it locally and bubbles - // back up to notchOuter's own Keys.onPressed (see below), so it - // keeps working either way without WallpapersContent.qml needing any - // changes of its own. - onPinnedOpenChanged: if (pinnedOpen) Qt.callLater(function() { notchOuter.forceActiveFocus() }) - // Third mode, alongside collapsed/media -- quick app launcher (a - // few favorite icons), triggered by ruixen.applauncher's own bar - // icon over IPC (see IpcHandler below), not by clicking the avatar - // like pinnedOpen. - property bool launcherOpen: false - // Only two ways to be open now (pinned or launcher), both from a - // real click -- used directly for the widened click-away mask too - // (see above), no separate "clickedOpen" needed anymore now that - // there's no passing-hover state to exclude from it. - readonly property bool expanded: pinnedOpen || launcherOpen - - // Which dashboard tab is showing (media/media hover/pin state only -- - // launcher mode is unrelated). 0 Widgets (DashboardContent, the real - // port), 1 Wallpapers, 2 Metrics -- the latter two are stub panes for - // now, shell only, per direct request to build the tab switcher - // before the actual pages. - property int dashboardTab: 0 - - IpcHandler { - target: "ruixen.notch" - function openLauncher(): void { panel.launcherOpen = true } - function closeLauncher(): void { panel.launcherOpen = false } - function toggleLauncher(): void { panel.launcherOpen = !panel.launcherOpen } - // Real, permanent counterparts for the dashboard itself (not the - // launcher) -- direct Discord ask ("a keybind to summon the - // notch"), same shape as the launcher trio above. - // - // Always resets to the Widgets tab (0) -- direct follow-up after - // a live report that Super+K (openDashboardTab kanban) followed - // by Super+N (toggleDashboard) "stays in kanban": the two keys - // are meant to do different jobs (K jumps straight to Kanban, - // N opens/closes the dashboard itself) but originally shared - // dashboardTab's own "wherever you left it" state, so N looked - // like it did nothing right after K. Deliberately NOT touching - // the plain click-to-expand interaction elsewhere in this file - // (onClicked: panel.pinnedOpen = true) or openDashboardTab below - // -- clicking the notch itself still opens on whatever tab was - // last selected, and a keybind can still jump straight to any - // specific tab; only these two IPC entry points force Widgets. - function openDashboard(): void { - panel.dashboardTab = 0 - panel.pinnedOpen = true - } - function closeDashboard(): void { panel.pinnedOpen = false } - function toggleDashboard(): void { - if (!panel.pinnedOpen) panel.dashboardTab = 0 - panel.pinnedOpen = !panel.pinnedOpen - } - // Jump straight to one tab -- direct follow-up ("a keybind that - // opens to the kanban board... to see updates"). A separate - // function with a required plain-string arg, not an optional - // JSON payload bolted onto openDashboard above: Quickshell's - // IpcHandler enforces exact arity against the declared typed - // signature (confirmed live -- "Too few arguments provided (1 - // required but 0 were provided)" the moment openDashboard grew a - // required param), so a single shared function would have broken - // every existing zero-arg `openDashboard` call instead of - // actually being optional. Valid tab values: "widgets" (0), - // "wallpapers" (1), "metrics" (2), "kanban" (3) -- matching - // dashboardTab's own fixed index order, left/right tab bar top - // to bottom. An unrecognized name is a no-op on the tab (still - // opens on whichever tab was already selected). - function openDashboardTab(tab: string): void { - var tabNames = ["widgets", "wallpapers", "metrics", "kanban"] - var index = tabNames.indexOf(tab) - if (index >= 0) panel.dashboardTab = index - panel.pinnedOpen = true - } - // Wallpapers tab's own toggle -- same shape as toggleDashboard - // above (a closed notch opens ON the wallpapers tab, an open one - // just flips pinnedOpen), so the recommended keybind reads like - // the other notch keys instead of always re-opening. Keybind - // recipe: o.bind("SUPER + CTRL + SPACE", "Ruixen wallpapers", - // "omarchy-shell ruixen.notch toggleWallpapers") -- that key is - // Omarchy's default Background switcher, so it needs an explicit - // hl.unbind first (install.sh's --with-launcher-keybind recommends - // it but leaves a taken key untouched, printing the collision). - function toggleWallpapers(): void { - if (!panel.pinnedOpen) panel.dashboardTab = 1 - panel.pinnedOpen = !panel.pinnedOpen - } - // Called by ruixen.settings' General page after Shuffle/Reset - // writes or removes ~/.face.icon -- this plugin is keepLoaded: - // true, so nothing else would tell UserAvatar's Image to re-read - // the file. - function refreshAvatar(): void { - avatarStateFile.reload() - root.avatarCacheBust = root.avatarCacheBust + 1 - } - - // Kanban tab (4th dashboard tab) -- direct request, agent-native - // by design: these are the SAME functions KanbanContent.qml's - // own UI calls on kanbanService directly, exposed here too so - // the board can be driven entirely from the CLI, e.g. - // `omarchy-shell ruixen.notch kanbanAddCard "Fix bug" todo`. - // Column ids are always exactly "todo"/"in-progress"/"done" -- - // see KanbanModel.js's own header for why the count is fixed. - // priority is "high"/"medium"/"low" (defaults to "medium" if - // omitted or unrecognized) -- direct request ("take care of the - // priority via the api, this shit will be agent run mostly"): no - // manual UI to set it, CLI/agent-only, same as add/rename. - function kanbanAddCard(title: string, columnId: string, priority: string): string { - return kanbanService.addCard(title, columnId, priority) - } - function kanbanMoveCard(cardId: string, columnId: string): void { - kanbanService.moveCard(cardId, columnId) - } - function kanbanAdvanceCard(cardId: string): void { kanbanService.advanceCard(cardId) } - function kanbanRegressCard(cardId: string): void { kanbanService.regressCard(cardId) } - function kanbanRemoveCard(cardId: string): void { kanbanService.removeCard(cardId) } - function kanbanClearDone(): void { kanbanService.clearDone() } - function kanbanRenameColumn(columnId: string, label: string): void { - kanbanService.renameColumn(columnId, label) - } - function kanbanSetPriority(cardId: string, priority: string): void { - kanbanService.setPriority(cardId, priority) - } - // No-op on a blank title -- see KanbanModel.renameCard's own - // comment. Fixes a real gap: a card's title was write-once at - // creation before this, no way to fix a typo or reword it after. - function kanbanRenameCard(cardId: string, title: string): void { - kanbanService.renameCard(cardId, title) - } - // date is meant to be typed by hand -- "2026-09-12", - // "2026-09-12T18:00", or anything else Date.parse() recognizes - // -- not a raw epoch number computed by the caller. An empty - // string explicitly clears the due date; anything else that - // fails to parse is a no-op rather than also clearing it, so a - // typo cannot silently wipe a real deadline that was already set. - function kanbanSetDueDate(cardId: string, date: string): void { - var text = String(date || "").trim() - if (text === "") { - kanbanService.setDueDate(cardId, 0) - return - } - var parsed = Date.parse(text) - if (!isNaN(parsed)) kanbanService.setDueDate(cardId, parsed) - } - // One free-text label per card, not a multi-tag array -- see - // KanbanModel.js's own comment for why. An empty string clears - // it; a real one is capped (KanbanModel.clampLabel) for a fixed- - // width card row, not rejected -- an overlong label is just - // trimmed, never an error. - function kanbanSetLabel(cardId: string, label: string): void { - kanbanService.setLabel(cardId, label) - } - // A short second line under the title, always shown as a single - // elided line in the panel -- direct request: "i wanna see a - // short title and description... further detail might be better - // thru the terminal or agent context". There is no fuller notes - // field anywhere in this board on purpose. An empty string - // clears it; an overlong one is capped, not rejected. - function kanbanSetDescription(cardId: string, description: string): void { - kanbanService.setDescription(cardId, description) - } - // Returns the whole board as JSON ({columns, cards}) -- how a - // script (or me, driving the board on your behalf) reads it back - // without any QML access at all. - function kanbanListCards(): string { return kanbanService.listCards() } - // orderedIds: the full desired top-to-bottom order for columnId's - // own cards, newline-delimited -- e.g. `omarchy-shell ruixen.notch - // kanbanReorderColumn todo $'card-123\ncard-456'`. NOT a JSON - // array: confirmed live that `qs ipc call` tears a bracketed - // `[...]` argument into several before this function ever sees - // it (a real "2 required but 5 provided" error for a 4-id JSON - // array) -- plain comma- or semicolon-joined text arrives intact - // as one argument, only the brackets themselves are special. - // Newline matches ruixen.shelf's own addMany(pathsArg) convention - // for the identical problem (see its own comment) -- a real card - // id is always "card--" (KanbanModel.makeCardId), - // so it can never contain one anyway, same reasoning addMany's - // own comment gives for a path. Meant to be driven by an agent - // after reading the board back via kanbanListCards above, not - // typed by hand -- see KanbanModel.reorderColumn's own comment - // for the "clean up/reorganize the board" request this answers. - function kanbanReorderColumn(columnId: string, orderedIds: string): void { - var ids = String(orderedIds || "").split("\n").filter(function(id) { return id !== "" }) - kanbanService.reorderColumn(columnId, ids) + // date is meant to be typed by hand -- "2026-09-12", + // "2026-09-12T18:00", or anything else Date.parse() recognizes + // -- not a raw epoch number computed by the caller. An empty + // string explicitly clears the due date; anything else that + // fails to parse is a no-op rather than also clearing it, so a + // typo cannot silently wipe a real deadline that was already set. + function kanbanSetDueDate(cardId: string, date: string): void { + var text = String(date || "").trim() + if (text === "") { + kanbanService.setDueDate(cardId, 0) + return } + var parsed = Date.parse(text) + if (!isNaN(parsed)) kanbanService.setDueDate(cardId, parsed) } - - // Fire-once, not auto-running -- triggered by the tab bar's bottom - // button (see expandedContent below). Opens Omarchy's own real main - // menu -- the exact same command Super+Space itself runs (confirmed - // in ~/.config/hypr/bindings/utilities.lua: `o.bind("SUPER + SPACE", - // "Omarchy menu", "omarchy-menu toggle")`) -- rather than jumping - // straight to the settings submenu or reimplementing a menu here. - Process { - id: mainMenuProc - command: ["omarchy-menu", "toggle"] - running: false + // One free-text label per card, not a multi-tag array -- see + // KanbanModel.js's own comment for why. An empty string clears + // it; a real one is capped (KanbanModel.clampLabel) for a fixed- + // width card row, not rejected -- an overlong label is just + // trimmed, never an error. + function kanbanSetLabel(cardId: string, label: string): void { + kanbanService.setLabel(cardId, label) } - - // Click-away-to-dismiss -- covers the whole (now-widened) mask - // while deliberately clicked open (pinned dashboard or launcher). - // Declared before notchOuter on purpose: later siblings win - // hit-testing in QML, so notchOuter's own content (tab buttons, - // icon cards, the hover/pin MouseArea) still gets first crack at any - // click within its own bounds; this only ever catches clicks that - // land outside the notch shape entirely. Resets both flags rather - // than picking one -- only one is ever true at a time in practice, - // but clearing both is simpler than tracking which. - MouseArea { - anchors.fill: parent - // Was panel.clickedOpen, a property that never existed on panel -- - // enabled evaluated to undefined (silently falsy), so click-away - // never actually worked despite the comment above describing it as - // live. Real fix: the same expanded flag everything else already - // uses. - enabled: panel.expanded - onClicked: { panel.pinnedOpen = false; panel.launcherOpen = false } + // A short second line under the title, always shown as a single + // elided line in the panel -- direct request: "i wanna see a + // short title and description... further detail might be better + // thru the terminal or agent context". There is no fuller notes + // field anywhere in this board on purpose. An empty string + // clears it; an overlong one is capped, not rejected. + function kanbanSetDescription(cardId: string, description: string): void { + kanbanService.setDescription(cardId, description) } - - // "On Hover" mode's own hover-detection zone -- a sibling of - // notchOuter, not a HoverHandler nested inside it, specifically so - // it can start at this surface's true top edge (y: 0) rather than - // notchOuter's own resting offset (restY, 4 -- see that property's - // own comment for why the dead zone existed). Same width as - // notchOuter, height reaching down through its real RESTING bottom - // edge (restY + height, deliberately not notchOuter's own live y, - // which now animates for the slide-down reveal -- see notchOuter's - // own comment) so the whole collapsed footprint -- including the - // reclaimed strip above it -- stays one continuous, FIXED hoverable - // area no matter where the pill's paint currently is mid-animation. - // No visual of its own; purely observes hover, coexists fine with - // notchOuter's own nested MouseAreas (avatar, play/pause, bell) - // since HoverHandler does not consume/block those the way a - // MouseArea's own click handling would. - Item { - id: notchHoverZone - anchors.top: parent.top - anchors.horizontalCenter: parent.horizontalCenter - width: notchOuter.width - height: notchOuter.restY + notchOuter.height - - HoverHandler { - onHoveredChanged: { - if (hovered) root.notchHoverEntered() - else root.notchHoverExited() - } - } - } - - // Drag onto the Shelf: dragging local files over the collapsed pill asks - // ruixen.shelf to open (over its own IPC target -- no live object shared - // between the two plugins), so the drop can land in the open Shelf and be - // seen. No highlight here: the Shelf opening IS the feedback. The Shelf - // owns what happens next (it hides itself if the drag leaves without a - // drop, and stays open once something lands -- see Shelf.qml). - // - // A drop that lands on the pill itself, before the Shelf has taken over - // the drag, is still accepted and relayed (addMany), so a fast release - // never loses the files. Same footprint as notchHoverZone above (a - // sibling of notchOuter, so it keeps working while the pill is slid out - // of view in "On Hover" mode; entering it reveals the pill the way - // hovering does -- a drag doesn't deliver ordinary hover events, which is - // why this reuses notchHoverEntered/Exited explicitly). Inert while the - // notch is expanded: the dashboard/launcher own the surface then. - // - // Opening happens immediately on drag-enter (no dwell timer). Whether a - // drag already in progress carries into the freshly mapped Shelf window is - // compositor behavior that has to be confirmed live; if it does not, the - // single call to remove is relayShelfOpen() in onEntered below, which - // leaves the plain drop-on-the-pill path working. - DropArea { - id: shelfQuickDrop - anchors.top: parent.top - anchors.horizontalCenter: parent.horizontalCenter - width: notchOuter.width - height: notchOuter.restY + notchOuter.height - enabled: !panel.expanded - // file:// URLs only -- a web image dragged out of a browser arrives - // as an http(s) URL and is ignored here, matching the Shelf's own - // local-files-only contract. - function localUrls(urls) { - var out = [] - for (var i = 0; i < (urls || []).length; i++) { - var s = String(urls[i]) - // A real path after the scheme -- "file:///" (the filesystem - // root) is rejected by the Shelf anyway, so don't accept it here. - if (/^file:\/\/(?:localhost)?\/.+/i.test(s)) out.push(s) - } - return out - } - // Copy semantics only, explicitly (never acceptProposedAction(), - // which would echo a source app's proposed MoveAction): the Shelf - // stores a reference and never moves or deletes the source. - onEntered: (drag) => { - if (shelfQuickDrop.localUrls(drag.urls).length > 0) { - drag.accept(Qt.CopyAction) - root.notchHoverEntered() - root.relayShelfOpen() - } - } - onExited: root.notchHoverExited() - onDropped: (drop) => { - var urls = shelfQuickDrop.localUrls(drop.urls) - root.notchHoverExited() - if (urls.length === 0) return - root.relayToShelf(urls) - drop.accept(Qt.CopyAction) - } + // Returns the whole board as JSON ({columns, cards}) -- how a + // script (or me, driving the board on your behalf) reads it back + // without any QML access at all. + function kanbanListCards(): string { return kanbanService.listCards() } + // orderedIds: the full desired top-to-bottom order for columnId's + // own cards, newline-delimited -- e.g. `omarchy-shell ruixen.notch + // kanbanReorderColumn todo $'card-123\ncard-456'`. NOT a JSON + // array: confirmed live that `qs ipc call` tears a bracketed + // `[...]` argument into several before this function ever sees + // it (a real "2 required but 5 provided" error for a 4-id JSON + // array) -- plain comma- or semicolon-joined text arrives intact + // as one argument, only the brackets themselves are special. + // Newline matches ruixen.shelf's own addMany(pathsArg) convention + // for the identical problem (see its own comment) -- a real card + // id is always "card--" (KanbanModel.makeCardId), + // so it can never contain one anyway, same reasoning addMany's + // own comment gives for a path. Meant to be driven by an agent + // after reading the board back via kanbanListCards above, not + // typed by hand -- see KanbanModel.reorderColumn's own comment + // for the "clean up/reorganize the board" request this answers. + function kanbanReorderColumn(columnId: string, orderedIds: string): void { + var ids = String(orderedIds || "").split("\n").filter(function(id) { return id !== "" }) + kanbanService.reorderColumn(columnId, ids) } + } - Item { - id: notchOuter - anchors.horizontalCenter: parent.horizontalCenter - // y, not anchors.top/topMargin -- direct follow-up on "On Hover" - // mode ("instead of fading in, can we make it slide down"): an - // anchor continuously re-asserts position every frame, which - // would fight a Behavior on y, so vertical position is now a - // plain property instead. restY (4) is the exact same resting - // offset the old anchors.topMargin gave it -- moved here - // verbatim (see margins.top's own comment on the PanelWindow - // above) so the pill's final on-screen position when revealed is - // unchanged from before that fix. - readonly property int restY: 4 - y: root.notchPillRevealed ? restY : -(height + 8) - Behavior on y { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } } - - // Escape closes the expanded dashboard, same as the launcher's own - // search box already does. Placed here (not on some deeper child) - // so it catches Escape by default (see onPinnedOpenChanged's - // forceActiveFocus above) and still catches it if focus later - // moves to a child that doesn't handle Escape itself, since - // unhandled key events bubble up the visual parent chain. - // - // Tab rotates the three real dashboard panels -- Widgets -> - // Wallpapers -> Metrics -> back to Widgets, same as clicking each - // TabButton in turn. The bottom button deliberately isn't part of - // this cycle: it's a one-shot action (mainMenuProc, opens - // Omarchy's real main menu), not a view, and landing on it via Tab - // either fired it immediately (one direct report: "giving me a - // jump scare") or needed a whole separate selected-vs-activated - // state to avoid that -- simpler to just leave it mouse-only. - // Guarded to pinnedOpen only so this never fights the launcher's - // own Tab handler (launcherOpen's Tab is fully consumed inside - // launcherContent before it could bubble here anyway, but the - // guard makes the split explicit rather than relying on that). - Keys.onPressed: function(event) { - if (event.key === Qt.Key_Escape && panel.expanded) { - panel.pinnedOpen = false - panel.launcherOpen = false - event.accepted = true - } else if (event.key === Qt.Key_Tab && panel.pinnedOpen && !panel.launcherOpen) { - panel.dashboardTab = (panel.dashboardTab + 1) % 4 - event.accepted = true - } - } - // Notch corner geometry derived from this machine's own window - // rounding (Hyprland rounding=24), not a flat guess: - // cornerSize=roundness+4, collapsed radius=roundness+4, expanded - // radius=roundness+20 -- cornerSize=28, collapsed radius=28 (see centerMask - // below), expanded radius=44. - // - // Manually mirrored in ruixen.bar's own notchReservedWidth constant - // (#28, ruixen.bar's own reserved-space calculation; #41/#38 for - // why it's a plain constant there rather than a live read) -- - // change this value, change that one too. - readonly property int cornerSize: 28 - // The mask/shadow silhouette is built from three adjacent pieces - // (left flank, center, right flank). At fractional output scales, - // exact edge-to-edge joins can sample as a 1px hairline where a - // flank touches the center body. Overlap the center slightly - // under both flanks so those joins overdraw instead of butt up - // against each other. Keep this small: it is only a seam guard, - // not a geometry change. - readonly property int seamOverlap: 2 - // Collapsed width trimmed down from an initial 290 -- this row's - // actual content (avatar + divider + play glyph/wave + divider + - // bell) is narrower than that, so 290 left a big empty gap - // between the avatar/bell and the notch's own curved edges. First - // attempt went to 240, but collapsedContent's real parent is a - // clip:true Item sized to exactly bodyWidth (see below) -- - // content measures ~236-240px wide (avatar 20 + 2 dividers + - // wave-track 140 + play glyph + bell + Row spacing), landing - // right at that clip edge and cutting the bell off. 260 gives - // real margin instead of a knife's-edge fit. Expanded width (420) - // still fits this notch's own expanded content comfortably. - // Media hover/pin tries a real dashboard-sized target (implicitWidth - // 900, implicitHeight 56+48*6 = 344) instead of reusing the - // smaller 420x190 -- untested territory for this notch (only 44 - // and 190 are proven safe against the masking bug below), so - // watch for the same flat-bottom-corner symptom if this doesn't - // pan out. - // - // launcherOpen deliberately does NOT follow this -- it keeps its - // own separate 420x190 branch, proven safe, since an earlier - // attempt at a new size for launcher specifically (340x260, then - // up to 360) hit that exact masking bug. Decoupled from - // pinnedOpen here so a size change on one never risks the other. - // - // Collapsed 260 -> 284: the playerPill (see collapsedContent - // below) added its own ~20px of internal padding around the play - // glyph/wave that the old bare Row didn't have, pushing content - // back out to ~260px total -- the exact knife's-edge fit 260 was - // originally chosen to avoid. Per direct report ("avatar circle - // and bell icon is too close to the notch edge now"), bumped by - // roughly that same ~20-24px to restore real margin on both - // sides again. - // - // The collapsed case (284, both launcherOpen/pinnedOpen false) is - // manually mirrored in ruixen.bar's own notchReservedWidth constant - // (#28, #41/#38) -- change this default, change that one too. The - // 420/900 expanded cases are deliberately NOT reflected there: - // ruixen.bar only ever needs to reserve space for the Notch's - // always-present collapsed footprint, not its temporary expanded - // states. - readonly property int bodyWidth: panel.launcherOpen ? 420 : (panel.pinnedOpen ? 900 : 284) - width: bodyWidth + cornerSize * 2 - // 44px collapsed -- ruixen-bar's own reserved screen zone - // (notchClearance) was bumped to cover this plus a buffer, so it - // no longer overlaps tiled windows the way it did when this was - // smaller but the reserved zone was still just barSize-sized. - // Dashboard height bumped past the initial 344 to 400 per direct - // feedback ("a bit too short") -- past the - // previously-tested-safe value, so stress-tested 3x (open/close - // cycles, checking the bottom-corner mask each time) before - // keeping it. Collapsed case (44) manually mirrored in ruixen.bar's - // own notchCollapsedBottomEdge constant (#41/#38) -- change this - // value, change that one too. - height: panel.launcherOpen ? 190 : (panel.pinnedOpen ? 400 : 44) - - Behavior on width { NumberAnimation { duration: 230; easing.type: Easing.OutCubic } } - Behavior on height { NumberAnimation { duration: 230; easing.type: Easing.OutCubic } } - - // Painted background, masked into the notch silhouette below. - // Back to a plain, always-real fill -- direct live regression: - // "the notch bg is stuck on black, its not theme changing anymore". - // Root cause of THAT bug: this was briefly made transparent when - // collapsed (an interim step of the FrameWindow-merge experiment, - // meant to let the frame's own paint show through from underneath - // instead), but notchShadowBlur below is a SOLID filled shape, not - // a thin ring -- with notchBg transparent, nothing in this window - // covered that shadow's own opaque interior anymore, and since - // ruixen.notch's own window stacks ABOVE FrameWindow in the - // compositor, its solid black just sat on top of the frame's - // theme-colored fill, visible everywhere instead of just the - // surrounding halo. The frame-color sync itself was never actually - // the problem THAT merge was solving -- root.notchColor already - // tracks frameColorMode/Color.background correctly on its own (see - // its own property block above), always did, via the same shared - // state file both plugins read -- no FrameWindow-side hole-punch - // was ever required for THAT part. Reverting this specific piece - // fixes the regression AND restores the natural "shadow only - // visible in the halo, covered by the real shape everywhere else" - // behavior every normal drop shadow has, in both collapsed and - // expanded. - Rectangle { - id: notchBg - anchors.fill: parent - color: root.notchColor - - layer.enabled: true - layer.smooth: true - layer.effect: MultiEffect { - maskEnabled: true - maskSource: notchMask - maskThresholdMin: 0.5 - maskThresholdMax: 1.0 - maskSpreadAtMin: 1.0 - } - - // Tried, reverted: adding shadowEnabled/shadowColor/shadowBlur/ - // etc. to this same MultiEffect instance ("start with notch - // then", trying the settings card's own shadow effect here - // too). Direct live report at the pinned/dashboard size (900x400): - // "the shape is completely broken, it has almost square edges - // now, the curves are gone" -- this is exactly the documented, - // non-deterministic masking bug this same effect has hit - // before (see this file's own launcher-history comments), - // reproduced live, not just a theoretical risk. Confirmed at - // the collapsed and launcher (420x190) sizes without visible - // breakage first, but the bug didn't show until the larger - // pinned size -- matches its own documented non-determinism, - // not a case of skipping the stress test. Reverted immediately - // per this file's own standing rule: "if the masking bug - // reproduces despite the size-avoidance, stop and report it - // rather than pushing through." Do not re-add shadow - // properties to this MultiEffect instance without solving the - // underlying masking fragility first -- a real fix, not - // another attempt at the same combination. - - // No blurred album-art here anymore -- was previously applied - // across the WHOLE collapsed notch (matching CompactPlayer.qml's - // backgroundArt treatment), but per direct feedback that "doesn't - // look too nice on some theme[s]", the notch itself now stays - // plain OLED black at all times. The art moved into its own - // small pill instead, scoped to just the play/pause + wave - // progress area below (see playerPill) -- avatar, dividers, and - // bell all still sit directly on this same flat black. - } - - // Shadow: a real, visible, BLURRED duplicate of the exact same - // 3-piece silhouette as notchMask below (left flank + center block - // + right flank), dark-filled, sitting BEHIND notchBg. Direct - // pivot after the hand-rolled Canvas ring-shadow (the previous - // version of this comment/block) kept getting the curve/radius - // wrong every tuning pass -- "hard drop", then corner dot - // artifacts, then a genuinely inverted "mustache" corner, then a - // reach that compressed against the shape's own small size: "cant - // the notch edges just produce its own shadow?" - // - // A PLAIN blur (MultiEffect's blurEnabled only) is a fundamentally - // different, much safer code path than the ORIGINAL broken attempt - // this file already has a hard-learned rule against: "Tried, - // reverted: adding shadowEnabled/shadowColor/... to this same - // MultiEffect instance [notchBg's own, which ALSO has maskEnabled] - // ... the shape is completely broken... Do not re-add shadow - // properties to this MultiEffect instance without solving the - // underlying masking fragility first." That bug was specifically - // shadowEnabled COMBINED with maskEnabled on one effect -- this - // shape is never masked at all, it just IS the shape, rendered - // directly and then blurred, so there's nothing for a mask+shadow - // interaction to break. Guaranteed to match the real silhouette - // exactly since it's the identical geometry (same cornerSize, same - // bottomLeftRadius/bottomRightRadius binding), not a hand-derived - // approximation that has to be kept in sync by hand. - // - // Direct live correction, right after this shipped: "check the - // compact notch top edge, right now it has its own top shadow - // which doesnt make sense cause its suppose to be part of the - // frame as one surface illusion." MultiEffect's blur spreads - // beyond its source item's own bounds in every direction by - // default (autoPaddingEnabled) -- exactly what makes the halo - // visible around the sides/bottom, but the same spillover was ALSO - // bleeding upward past the notch's own top edge, into the frame's - // own area, undermining the one thing this whole exclusion exists - // for. notchShadowClip below cuts that off: flush with the notch's - // own true top (no margin there, so nothing can ever render above - // it), but expanded on the left/right/bottom by more than the - // blur's own max reach, so the halo stays fully visible everywhere - // else. Simpler than the old ring-shadow's own approach (an open, - // top-excluding PATH) -- here the shape stays the full, real - // silhouette (still guaranteed to match by construction), only - // WHERE it's allowed to visibly render changes. - Item { - id: notchShadowClip - anchors.top: parent.top - anchors.left: parent.left - anchors.leftMargin: -40 - anchors.right: parent.right - anchors.rightMargin: -40 - anchors.bottom: parent.bottom - anchors.bottomMargin: -40 - z: -1 - clip: true - - // Shadow: a real, visible, BLURRED duplicate of the exact same - // 3-piece silhouette as notchMask below (left flank + center - // block + right flank), dark-filled. Direct pivot after the - // hand-rolled Canvas ring-shadow kept getting the curve/radius - // wrong every tuning pass -- "hard drop", corner dot artifacts, - // an inverted "mustache" corner, a reach that compressed against - // the shape's own small size: "cant the notch edges just produce - // its own shadow?" - // - // A PLAIN blur (MultiEffect's blurEnabled only) is a - // fundamentally different, much safer code path than the - // ORIGINAL broken attempt this file already has a hard-learned - // rule against: "Tried, reverted: adding shadowEnabled/ - // shadowColor/... to this same MultiEffect instance [notchBg's - // own, which ALSO has maskEnabled]... the shape is completely - // broken... Do not re-add shadow properties to this MultiEffect - // instance without solving the underlying masking fragility - // first." That bug was specifically shadowEnabled COMBINED with - // maskEnabled on one effect -- this shape is never masked at - // all, it just IS the shape, rendered directly and then blurred, - // so there's nothing for a mask+shadow interaction to break. - // Guaranteed to match the real silhouette exactly since it's the - // identical geometry (same cornerSize, same bottomLeftRadius/ - // bottomRightRadius binding), not a hand-derived approximation - // that has to be kept in sync by hand. - // - // anchors.margins: 40 + topMargin: 0 undoes notchShadowClip's own - // asymmetric expansion above, so this shape's own bounds land - // back on the notch's real, unexpanded geometry -- the outer - // Item only exists to decide where blur is ALLOWED to spill - // past those bounds, not to resize the shape itself. - Item { - id: notchShadowBlur - anchors.fill: parent - anchors.margins: 40 - anchors.topMargin: 0 - // 0.6 -> 0.9 -> 1.0. ruixen.bar's own frame shadow peaks at a - // true 1.0 right at its own edge (frameShadowCanvas's own - // alpha=1.0*t*t) -- 0.9 here was still a real, if small, gap - // below that peak (this shape's visible portion is only ever - // the blurred-out halo, its own true core hidden under - // notchBg, so the halo's own darkest pixel topped out at 90% - // black blended with wallpaper, never true black). Direct - // live report the gap was visible: "it seems less then the - // frame shadow setting." 1.0 matches the frame's own peak - // exactly. - opacity: 1.0 - - RoundCorner { - anchors.top: parent.top - anchors.left: parent.left - cornerSize: notchOuter.cornerSize - corner: 1 - fillColor: "#000000" - } - - Rectangle { - anchors.top: parent.top - anchors.left: parent.left - anchors.leftMargin: notchOuter.cornerSize - notchOuter.seamOverlap - anchors.right: parent.right - anchors.rightMargin: notchOuter.cornerSize - notchOuter.seamOverlap - height: parent.height - color: "#000000" - topLeftRadius: 0 - topRightRadius: 0 - bottomLeftRadius: panel.expanded ? 44 : 28 - bottomRightRadius: panel.expanded ? 44 : 28 - - Behavior on bottomLeftRadius { NumberAnimation { duration: 230; easing.type: Easing.OutCubic } } - Behavior on bottomRightRadius { NumberAnimation { duration: 230; easing.type: Easing.OutCubic } } - } - - RoundCorner { - anchors.top: parent.top - anchors.right: parent.right - cornerSize: notchOuter.cornerSize - corner: 0 - fillColor: "#000000" - } - - layer.enabled: true - layer.smooth: true - layer.effect: MultiEffect { - blurEnabled: true - // Tried matching ruixen.bar's own frame shadow's numeric - // reach (shadowReachPx: 8) by shrinking this to blurMax: 16 - // -- looked wrong live: "the shadow looks a little less... - // not as soft". The frame's 8px is a hard-edge ring-gradient - // reach, not directly comparable to a Gaussian blurMax; at - // the higher 0.9 opacity above, a short reach reads as an - // abrupt ring instead of a soft halo. Restored to the - // original, larger distance -- the actual bug (wrong - // curve/shape) is fixed independent of this value now, so - // widening it back doesn't reintroduce the old corner - // artifacts, it just fades over more distance again. - blurMax: 32 - blur: 0.6 - } - } - } - - // Mask silhouette: left flank (concave toward center) + center - // block (square top, animated round bottom) + right flank - // (concave toward center). Never drawn directly -- only sampled - // as texture by notchBg's layer.effect above. - Item { - id: notchMask - visible: false - anchors.fill: parent - layer.enabled: true - layer.smooth: true - - RoundCorner { - id: leftFlank - anchors.top: parent.top - anchors.left: parent.left - cornerSize: notchOuter.cornerSize - corner: 1 - fillColor: "#ffffff" - } - - Rectangle { - id: centerMask - anchors.top: parent.top - anchors.left: leftFlank.right - anchors.leftMargin: -notchOuter.seamOverlap - anchors.right: rightFlank.left - anchors.rightMargin: -notchOuter.seamOverlap - height: parent.height - color: "#ffffff" - topLeftRadius: 0 - topRightRadius: 0 - bottomLeftRadius: panel.expanded ? 44 : 28 - bottomRightRadius: panel.expanded ? 44 : 28 - - Behavior on bottomLeftRadius { NumberAnimation { duration: 230; easing.type: Easing.OutCubic } } - Behavior on bottomRightRadius { NumberAnimation { duration: 230; easing.type: Easing.OutCubic } } - } - - RoundCorner { - id: rightFlank - anchors.top: parent.top - anchors.right: parent.right - cornerSize: notchOuter.cornerSize - corner: 0 - fillColor: "#ffffff" - } - } - - // No general-purpose background MouseArea here anymore -- opening - // is now only via clicking the avatar (see UserAvatar's own - // MouseArea below), closing only via clicking away (the mask - // MouseArea above). Individual interactive elements in the - // collapsed row (avatar, play/pause, bell) each own their exact - // click target instead of one catch-all area behind everything. - - Item { - anchors.fill: parent - anchors.leftMargin: notchOuter.cornerSize - anchors.rightMargin: notchOuter.cornerSize - clip: true - - // Collapsed: avatar | divider | play glyph + small wave | divider | bell. - // Matches DefaultView.qml's mainRow layout. - Row { - id: collapsedContent - // expanded already folds in launcherOpen (see panel.expanded), - // so !expanded alone correctly excludes launcher mode too. - visible: !panel.expanded - opacity: panel.expanded ? 0 : 1 - anchors.centerIn: parent - // The frame visually eats the top ~6px (it merges into the - // frame's own border there), so centering in the notch's full - // height reads as shifted too high relative to the actually - // visible space below that. Nudge down to compensate. - anchors.verticalCenterOffset: 2 - spacing: 8 - Behavior on opacity { NumberAnimation { duration: 140 } } - - UserAvatar { anchors.verticalCenter: parent.verticalCenter } - NotchSeparator { anchors.verticalCenter: parent.verticalCenter } - - // Art pill -- the ONLY place blurred album art shows in the - // collapsed notch now (see notchBg above for why). Wraps just - // the play/pause glyph + wave progress, per direct scoping - // request ("leave the bell and avatar and separator bar - // outside the pill as is"). ClippingRectangle, not a plain - // Rectangle -- same documented gotcha as playerCard/the art - // disc in DashboardContent.qml: plain Rectangle.clip only - // clips to the bounding BOX, ignores radius. - ClippingRectangle { - id: playerPill - anchors.verticalCenter: parent.verticalCenter - width: pillRow.implicitWidth + pillPadding * 2 - height: 28 - radius: height / 2 - color: Color.background - - readonly property int pillPadding: 10 - // root.resolvedWallpaperPath (see its own comment) rather - // than the symlink path directly -- see that comment for why. - readonly property string wallpaperPath: root.resolvedWallpaperPath !== "" - ? "file://" + root.resolvedWallpaperPath - : "file://" + Quickshell.env("HOME") + "/.local/state/omarchy/current/background" - readonly property string pillBgSource: root.artUrl !== "" ? root.artUrl : wallpaperPath - - Image { - id: pillBgArt - anchors.fill: parent - source: playerPill.pillBgSource - sourceSize: Qt.size(64, 64) - fillMode: Image.PreserveAspectCrop - asynchronous: true - visible: false - } - - MultiEffect { - anchors.fill: parent - source: pillBgArt - blurEnabled: true - blurMax: 32 - blur: 1.0 - opacity: 0.28 - } - - // Dark scrim on top of the blur so the glyph/wave/playhead - // stay readable regardless of how bright the underlying art - // is -- same reasoning as notchBg's own flat base color. - Rectangle { - anchors.fill: parent - color: Qt.rgba(0, 0, 0, 0.35) - } - - Row { - id: pillRow - anchors.centerIn: parent - spacing: 6 - - Text { - id: collapsedPlayGlyph - anchors.verticalCenter: parent.verticalCenter - // Direct follow-up: "we dont need the inactive play - // button to show then" -- showing a play/pause glyph - // next to the active-window title (not a track) never - // made sense once that title took over this slot. - visible: root.hasMedia - text: root.playIcon - color: root.textColor - font.family: root.fontFamily - // Matches the bar's own icon standard (18px) -- was 11, - // visibly undersized next to the 20px avatar in this - // same row. - font.pixelSize: 18 - - // Own click target, same pattern as the avatar/bell now - // that there's no big background MouseArea to compete - // with -- each interactive element in this row owns its - // exact hit area independently. - MouseArea { - anchors.fill: parent - anchors.margins: -6 - enabled: root.hasMedia - cursorShape: Qt.PointingHandCursor - onClicked: root.sendMediaAction("playPause") - } - } - - // Fixed 140x20 "now playing" slot -- exactly one of three - // mutually-exclusive children visible at a time: the - // seeker (media, mediaVizMode "seeker"), a mini cava - // visualizer (media, mediaVizMode "cava"), or the active - // window name (no media at all, unconditional -- see its - // own comment below for why it has no toggle). Direct - // request: "since we have the cava visualizer now... - // click it and it switch between cava and seeker" -- - // scoped to a scroll toggle, and to ONLY the two media- - // present modes: cava reacts to real audio, so with - // nothing playing it would just sit at its own flat idle - // bars, no different from showing nothing at all. Window - // name already covers that state on its own. - Item { - id: nowPlayingSlot - anchors.verticalCenter: parent.verticalCenter - width: 140 - height: 20 - - MouseArea { - anchors.fill: parent - enabled: root.hasMedia - cursorShape: root.hasMedia ? Qt.PointingHandCursor : Qt.ArrowCursor - // This MouseArea only actually receives input while - // mediaVizMode is "cava" -- the seeker's own Item - // (with its own MouseArea) sits on top and shadows - // this one entirely whenever it's visible. Right - // click switches back to the seeker, mirroring the - // seeker's own MouseArea's right click going the - // other way -- direct follow-up: "right click to go - // back to the seeker." Wheel no longer toggles mode - // here (removed) -- right click now owns that switch - // exclusively in both directions, freeing wheel up to - // mean "seek" whenever the seeker is the thing - // showing, not "leave the seeker." - acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: function(mouse) { - if (mouse.button === Qt.RightButton) { - root.toggleMediaVizMode() - return - } - // Left click cycles bars/wave -- only meaningful - // once already in cava mode (nothing to cycle on - // the seeker or window name), direct request. - if (root.mediaVizMode === "cava") root.toggleCavaMiniStyle() - } - // Wheel cycles bars/wave here too, mirroring the - // seeker's own wheel-seeks-instead-of-switches change - // -- direct follow-up: "on the visualizer when the - // bars are showing, i cant scroll to the wave - // anymore... i guess right click and wheel scroll?" - // Left click keeps doing the same thing too (not - // replaced) -- both are just different ways to reach - // the one toggle, same as wheel/click already being - // two paths to the same seek on the other row. - onWheel: function(wheel) { - if (root.mediaVizMode === "cava") root.toggleCavaMiniStyle() - } - } - - // Track (full length, dim) + wave (played portion only, up - // to progressRatio) -- actually reflects position now, - // instead of a decorative full-width wave. No drag-to-seek - // yet, position display only. - Item { - anchors.fill: parent - visible: root.hasMedia && root.mediaVizMode === "seeker" - // Grown 12 -> 20 alongside the thickness bump below -- - // WavyLine is a Canvas, and Canvas content outside its - // own item bounds is simply never drawn (an implicit - // clip, not a real mask, but reads the same). At - // lineWidth 4 / amplitudeMultiplier 1.4 the wave's peak - // extent is +-(amp + lineWidth/2) = +-7.6px from center, - // needing >=15.2px of height -- the old 12px container - // was already too short for that, clipping the top/ - // bottom of the wave's crests and troughs. - height: 20 - - // Split point -- where the wave's played portion meets - // the dim unplayed track, before any gap trim. - // - // pressedButtons & LeftButton, not the bare pressed flag -- - // seekerDragArea now also accepts the right button (see its - // own comment), and pressed goes true for ANY accepted - // button. A right-click-hold with the bare flag would - // freeze this at previewRatio's last (possibly stale, e.g. - // 0 before any left-drag ever happened) value instead of - // showing the real live position while held. - readonly property real splitX: width * ((seekerDragArea.pressedButtons & Qt.LeftButton) ? seekerDragArea.previewRatio : root.progressRatio) - // Same gap-around-the-tip design as the player ring/ - // dials/brightness bar, ported here too now that it's - // right on the other components -- per direct request - // ("bring the design there too"). Computed the same - // width-aware way that fixed the player ring's real bug - // (a flat copied constant doesn't clear a custom-sized - // tip): half the wave's own line width + half the - // playhead's own width + the desired real clearance. - // wave lineWidth 4 (half 2) + playhead width 4 (half 2) - // + 2px desired = 6px, applied as the FULL trim on EACH - // side independently (not split further) -- matches how - // the ring's own gapPx already works, not a fresh guess. - readonly property real gapPx: 6 - - // Track/wave/playhead thickened 2px -> 4px together, then - // wave/playhead alone toned back to 3 (read too thick next - // to the track). Direct follow-up while music was actually - // playing: "the wave thickness... kinda too thin now" -- - // back to 4, matching the track again. - Rectangle { - // Unplayed remainder, trimmed short of the playhead by - // gapPx on this side -- starts past the split point, - // not right at it. - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - width: Math.max(0, parent.width - parent.splitX - parent.gapPx) - height: 4 - radius: 2 - color: root.surfaceTint(0.15) - } - - WavyLine { - // Trimmed short of the playhead by gapPx on this side - // too -- the wave's own Canvas bounding width directly - // controls where its rightmost point sits, so shrinking - // it here is enough on its own, no separate lineCap - // handling needed (unlike the circular ring's Canvas - // arc, this is just a smaller bounding box). - width: Math.max(0, parent.splitX - parent.gapPx) - height: parent.height - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - lineColor: root.hasMedia ? root.accent : root.muted - lineWidth: 4 - // 140/6 preserves the same wave density the old - // frequency: 6 (cycles across a fixed 140px reference) - // gave -- expressed directly as a physical wavelength - // now, so it no longer needs that separate reference - // length property at all. - wavelengthPx: 140 / 6 - amplitudeRatio: root.isPlaying ? 1.4 : 0.15 - running: root.isPlaying - } - - // Playhead -- stays centered exactly at the true split - // point, unchanged -- only the wave/track on either side - // of it pull back now. - Rectangle { - width: 4 - height: parent.height - radius: width / 2 - anchors.verticalCenter: parent.verticalCenter - color: root.textColor - x: parent.splitX - width / 2 - visible: root.hasMedia - - Behavior on x { - enabled: !(seekerDragArea.pressedButtons & Qt.LeftButton) - NumberAnimation { duration: 450 } - } - } - - MouseArea { - id: seekerDragArea - anchors.fill: parent - enabled: root.hasMedia && root.canSeek && root.trackLength > 0 - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - // Left drags to seek (this row's whole reason for - // existing), which collided with left click's prior job - // of switching between the seeker and the mini cava - // visualizer. Direct follow-up: "if left click controls - // the seeker then we cant use left click to switch to - // visualizer... right click triggers the visualizer - // instead now." MouseArea only accepts Left by default, - // so Right is explicitly opted in below and routed to - // toggleMediaVizMode via onClicked -- onPressed/ - // onPositionChanged/onReleased all stay gated to the - // left button specifically (mouse.button/pressedButtons - // checks, not the bare pressed/press-only mouse.button - // default) so a right click can't also start a seek - // drag. Wheel already toggled the same viz mode before - // this PR and still does, unchanged -- this just adds a - // second, more discoverable way to reach it now that - // left click itself means something else on this row. - acceptedButtons: Qt.LeftButton | Qt.RightButton - property real previewRatio: 0 - function ratioFromMouse(mouse) { - if (width <= 0) return 0 - return Math.max(0, Math.min(1, mouse.x / width)) - } - onPressed: function(mouse) { - if (mouse.button === Qt.LeftButton) previewRatio = ratioFromMouse(mouse) - } - onPositionChanged: function(mouse) { - if (mouse.buttons & Qt.LeftButton) { - previewRatio = ratioFromMouse(mouse) - } - } - onReleased: function(mouse) { - if (mouse.button !== Qt.LeftButton) return - var ratio = ratioFromMouse(mouse) - root.sendMediaSeek(ratio * root.trackLength) - } - onClicked: function(mouse) { - if (mouse.button === Qt.RightButton) root.toggleMediaVizMode() - } - // Wheel now seeks (5s/notch, same flat-step convention - // Display/Audio's own volume-scroll rows already use) - // instead of switching to the visualizer -- direct - // follow-up: "when i [wheel] scroll it is going to the - // visualizer, we dont want that... lets use the wheel - // scroll to control the seeker when the seeker is - // showing." Right click (above) is now the only way to - // leave the seeker. - onWheel: function(wheel) { - var delta = wheel.angleDelta.y > 0 ? 5 : -5 - var target = Math.max(0, Math.min(root.trackLength, root.trackPosition + delta)) - root.sendMediaSeek(target) - } - } - } - - // Mini cava visualizer -- a fresh, smaller CavaFeed - // instance (kept separate from ruixen.cava's own edge- - // docked overlay: a different plugin entirely, plugin - // folders can't share state, and this one only needs to - // run while actually visible). Fixed horizontal/bottom- - // grow layout, unlike the full overlay's own configurable - // orientation -- this slot is always exactly 140x20, so - // that generality buys nothing here. - // - // Mirrored, not a plain 1:1 sweep -- direct request: - // real bass on both edges, treble in the center, "looks - // better that way for aesthetic." displayBars is double - // cavaMirrorBands (root, see its own comment): position i - // and position (displayBars-1-i) both read the SAME real - // band, folded around the center. mirrorBandAt(i) is - // exactly "distance from the nearest edge" -- 0 at either - // edge (band 0, bass), displayBars/2-1 at the two - // innermost positions either side of center (the highest - // analyzed band, treble), by construction of the fold. - Item { - id: cavaMiniSlot - anchors.fill: parent - visible: root.hasMedia && root.mediaVizMode === "cava" - - readonly property int displayBars: compactCavaFeed.bands * 2 - - function mirrorBandAt(i) { - return Math.min(i, cavaMiniSlot.displayBars - 1 - i) - } - - // Warm center, cool edges -- same formula ruixen.cava/ - // Overlay.qml's own bandColor() already uses, applied - // to the DISPLAY index/count here (mirroring already - // makes display-index distance-from-center behave the - // same way band-index distance-from-center does there). - function barColor(i, level) { - var mid = cavaMiniSlot.displayBars / 2 - var dist = cavaMiniSlot.displayBars > 1 ? Math.abs(i - mid + 0.5) / mid : 0 - var c = Qt.rgba( - root.cavaWarmColor.r + (root.cavaCoolColor.r - root.cavaWarmColor.r) * dist, - root.cavaWarmColor.g + (root.cavaCoolColor.g - root.cavaWarmColor.g) * dist, - root.cavaWarmColor.b + (root.cavaCoolColor.b - root.cavaWarmColor.b) * dist, - 1) - return Qt.lighter(c, 1 + 0.35 * level) - } - - // A subtle opacity taper on the outermost couple of - // positions at EITHER edge -- direct follow-up: "the - // side of the visualizer edge, its kinda sharp... fade - // opacity the edges a bit to soften it up." Same idea - // ruixen.cava/Overlay.qml's own edgeFade already uses, - // just a much smaller edgeFadeBands -- that file's - // fixed 6 is tuned against a strip with 32-96 bands; - // applied unscaled here (displayBars is only 12) it - // would fade the entire left/right HALVES toward - // center, not just the last couple of positions at - // each edge. - readonly property int edgeFadeBands: 2 - - function edgeFade(i) { - var d = Math.min(i, cavaMiniSlot.displayBars - 1 - i) - return Math.max(0, Math.min(1, d / cavaMiniSlot.edgeFadeBands)) - } - - // Bars/wave, click-toggled (root.toggleCavaMiniStyle, - // nowPlayingSlot's own MouseArea.onClicked) -- no - // "segments" here, see root.cavaMiniStyle's own comment - // for the sub-pixel math that ruled it out at this size. - Repeater { - model: root.cavaMiniStyle === "bars" ? cavaMiniSlot.displayBars : 0 - Rectangle { - readonly property real slot: cavaMiniSlot.width / cavaMiniSlot.displayBars - readonly property real thick: Math.max(1, slot - 2) - readonly property real level: compactCavaFeed.levels[cavaMiniSlot.mirrorBandAt(index)] || 0 - width: thick - height: Math.max(2, level * cavaMiniSlot.height) - radius: thick / 2 - x: index * slot + (slot - thick) / 2 - y: cavaMiniSlot.height - height - color: cavaMiniSlot.barColor(index, level) - opacity: cavaMiniSlot.edgeFade(index) - - Behavior on height { NumberAnimation { duration: 90; easing.type: Easing.OutQuad } } - } - } - - // Wave -- a filled, smoothed curve through the same - // mirrored levels the bars use, not just a stroked - // zigzag -- direct correction: "it wasnt suppose to be - // a line, our desktop wave has like gradient filled - // in." Same technique ruixen.cava/Overlay.qml's own - // wave style already uses: quadratic-through-midpoints - // for a smooth curve (each segment's endpoint is the - // midpoint between two real points, the real point - // itself as the control point, so the curve passes - // close to every point without sharp corners), filled - // from that curve down to the bottom edge (bars grow up - // from there too -- same baseline) with the cool/warm/ - // cool gradient, then a brighter stroke retraced along - // the same curve on top for definition against the - // fill. - Canvas { - id: cavaMiniWave - anchors.fill: parent - visible: root.cavaMiniStyle === "wave" - - function rgbaStr(c, a) { - return "rgba(" + Math.round(c.r * 255) + "," + Math.round(c.g * 255) + "," + Math.round(c.b * 255) + "," + a + ")" - } - - onVisibleChanged: if (visible) requestPaint() - - Connections { - target: compactCavaFeed - function onLevelsChanged() { if (cavaMiniWave.visible) cavaMiniWave.requestPaint() } - } - - onPaint: { - var ctx = getContext("2d") - ctx.reset() - var n = cavaMiniSlot.displayBars - if (n < 2) return - - var pts = [] - for (var i = 0; i < n; i++) { - var level = compactCavaFeed.levels[cavaMiniSlot.mirrorBandAt(i)] || 0 - var x = (i / (n - 1)) * width - var y = height - Math.max(1, level * height) - pts.push(Qt.point(x, y)) - } - - // 8-step gradient (not just 3 flat stops), each - // step's own alpha additionally scaled by - // cavaMiniSlot.edgeFade -- same technique - // ruixen.cava/Overlay.qml's own wave gradient - // already uses. Direct follow-up: "the side of the - // visualizer edge, its kinda sharp... fade opacity - // the edges a bit to soften it up." Without this, - // the fill/stroke hit full alpha right at x=0/x= - // width, where the shape's own edge coincides - // exactly with the canvas's own edge -- reading as - // a hard cutoff rather than easing out. - var grad = ctx.createLinearGradient(0, 0, width, 0) - var steps = 8 - for (var s = 0; s <= steps; s++) { - var t = s / steps - var bandIdx = t * (n - 1) - var c = cavaMiniSlot.barColor(bandIdx, 0) - var fade = cavaMiniSlot.edgeFade(bandIdx) - grad.addColorStop(t, cavaMiniWave.rgbaStr(c, 0.85 * fade)) - } - - ctx.beginPath() - ctx.moveTo(pts[0].x, height) - ctx.lineTo(pts[0].x, pts[0].y) - for (var j = 1; j < n - 1; j++) { - var mx = (pts[j].x + pts[j + 1].x) / 2 - var my = (pts[j].y + pts[j + 1].y) / 2 - ctx.quadraticCurveTo(pts[j].x, pts[j].y, mx, my) - } - ctx.lineTo(pts[n - 1].x, pts[n - 1].y) - ctx.lineTo(pts[n - 1].x, height) - ctx.closePath() - ctx.fillStyle = grad - ctx.fill() - - ctx.beginPath() - ctx.moveTo(pts[0].x, pts[0].y) - for (var k = 1; k < n - 1; k++) { - var mx2 = (pts[k].x + pts[k + 1].x) / 2 - var my2 = (pts[k].y + pts[k + 1].y) / 2 - ctx.quadraticCurveTo(pts[k].x, pts[k].y, mx2, my2) - } - ctx.lineTo(pts[n - 1].x, pts[n - 1].y) - ctx.lineWidth = 2 - ctx.lineCap = "round" - ctx.lineJoin = "round" - ctx.strokeStyle = grad - ctx.stroke() - } - } - } - - // The "no media" half of this same 140x20 slot -- direct - // request: "when theres no music it just shows active - // window". Reads Quickshell.Wayland's own ToplevelManager - // directly (already imported, already used for - // fullscreenActive above) rather than depending on the - // separate omarchy.active-window plugin being installed - // or pinned at all. Centered + elided, not a marquee -- - // direct follow-up ("is there an effect where it scrolls - // horizontally, or i guess truncate is fine tbh? center - // and truncate?") -- simpler, and matches ActiveWindow.qml's - // own elide: Text.ElideRight treatment of the same title - // string. "~" is the empty-state fallback for the rare - // moment nothing is focused at all (just closed - // everything, say), rather than leaving the pill blank. - // No toggle of its own -- unconditional whenever there's - // no media, see nowPlayingSlot's own header comment for why. - CompactWindowTitle { - anchors.fill: parent - visible: !root.hasMedia - text: root.compactWindowTitle !== "" ? root.compactWindowTitle : "~" - // Direct follow-up ("are the text a bit muted") -- this - // is the primary content of the slot now, not a - // secondary status readout, so it gets the same full - // textColor a real track title would use, not muted. - color: root.textColor - font.family: root.fontFamily - font.pixelSize: Style.font.bodySmall - } - } - } - } - - NotchSeparator { anchors.verticalCenter: parent.verticalCenter } - - Text { - anchors.verticalCenter: parent.verticalCenter - text: root.dnd ? "󰂛" : "󰂚" - // Matches the dashboard's own bell exactly (see - // DashboardContent.qml's notification header) -- theme - // accent when notifications are live, the same fixed red - // when silenced. Was root.muted, per direct request to - // bring the same theme-color/red treatment here too. - // - // Glyph now swaps too (md-bell vs md-bell_off), not just - // color -- real bug caught in DashboardContent.qml's own - // copy of this same bell: the crossed-bell codepoint was - // hardcoded for BOTH states, so "notifications are live" - // was silently showing a slashed bell in accent color - // instead of the plain one. - color: root.dnd ? "#e05252" : root.accent - font.family: root.fontFamily - font.pixelSize: 18 - - Behavior on color { ColorAnimation { duration: 160 } } - - // Real toggle now, not just a state readout -- per direct - // request ("the notification toggle right"). Goes through - // the real "notifications" IPC target (confirmed against - // the real service's own IpcHandler) rather than - // ruixen.quickactions' own bar.shell.firstPartyServiceFor() - // call -- this file has no bar capabilities to make that - // same call with, see the dnd property comment above. - MouseArea { - anchors.fill: parent - anchors.margins: -6 - cursorShape: Qt.PointingHandCursor - onClicked: root.sendDndAction("toggleDnd") - } - } - } - - // Expanded: blurred-art thumbnail, title/artist, big wave - // progress, transport controls. - // Expanded: dashboard content (player + quick controls/calendar + - // notifications + volume dials) -- see DashboardContent.qml - // for the full breakdown. Media data/colors passed through - // from the same root-level properties the collapsed view - // already reads. - Item { - id: expandedContent - visible: panel.expanded && !panel.launcherOpen - opacity: panel.expanded && !panel.launcherOpen ? 1 : 0 - anchors.fill: parent - anchors.topMargin: 20 - anchors.bottomMargin: 12 - anchors.leftMargin: 12 - anchors.rightMargin: 12 - Behavior on opacity { NumberAnimation { duration: 160 } } - - RowLayout { - anchors.fill: parent - // 8px -- tightened from 10, a bit looser than this row's - // own rhythm otherwise. - spacing: 8 - - // Left vertical tab bar: Widgets/Wallpapers/Metrics stacked - // icon buttons, settings gear pinned at the bottom via a - // fillHeight spacer above it. All three are real now (see - // WallpapersContent.qml/MetricsContent.qml) -- Metrics' - // own right-side stat tiles are still a stub within that - // file, see its own comments. - ColumnLayout { - Layout.preferredWidth: 78 - Layout.maximumWidth: 78 - Layout.fillHeight: true - spacing: 8 - - // Explicitly sets pinnedOpen: true too, even though it's - // already true by the time a tab is clickable at all - // (this whole bar only exists inside the pinned-open - // dashboard now that hover-to-expand is gone) -- cheap - // safety net, not load-bearing anymore. - TabButton { - glyph: "󰕰" - active: panel.dashboardTab === 0 - onActivated: { panel.dashboardTab = 0; panel.pinnedOpen = true } - } - TabButton { - glyph: "" - active: panel.dashboardTab === 1 - onActivated: { panel.dashboardTab = 1; panel.pinnedOpen = true } - } - TabButton { - glyph: "" - active: panel.dashboardTab === 2 - onActivated: { panel.dashboardTab = 2; panel.pinnedOpen = true } - } - TabButton { - glyph: "" - active: panel.dashboardTab === 3 - onActivated: { panel.dashboardTab = 3; panel.pinnedOpen = true } - } - - Item { Layout.fillHeight: true } - - // The one real action in this tab bar -- opens Omarchy's - // actual main menu (see mainMenuProc above), the same - // Super+Space menu, not a notch-local settings page. Was - // "omarchy-menu summon settings" (straight to the Setup - // submenu) per direct follow-up asking for the real root - // menu instead. Mouse-only on purpose -- see notchOuter's - // Keys.onPressed comment for why Tab deliberately skips - // this one. - TabButton { - glyph: "" - onActivated: mainMenuProc.running = true - } - } - - // Right: active tab's content. - Item { - Layout.fillWidth: true - Layout.fillHeight: true - - DashboardContent { - anchors.fill: parent - visible: panel.dashboardTab === 0 - shell: root.shell - // This whole tab bar + content area (an ancestor of this - // item) stays visible: true/opacity-based, not behind a - // Loader -- DashboardContent is alive for the entire - // session, not just while the dashboard is open. Needed - // here so its own nightlight status poll (ruixen-shell - // issue #43) can gate on the same "only while actually - // visible" condition brightnessStateProc's own Timer - // above already uses, instead of polling forever. - panelExpanded: panel.expanded - textColor: root.textColor - muted: root.muted - accent: root.accent - fontFamily: root.fontFamily - themeSurfaceMode: root.frameColorMode === "theme" - accentDashboardHeaders: root.barIconTone === "accent" - sendMediaAction: root.sendMediaAction - sendMediaSeek: root.sendMediaSeek - hasMedia: root.hasMedia - isPlaying: root.isPlaying - canSeek: root.canSeek - playIcon: root.playIcon - title: root.title - artist: root.artist - album: root.album - artUrl: root.artUrl - resolvedWallpaperPath: root.resolvedWallpaperPath - progressRatio: root.progressRatio - trackPosition: root.trackPosition - trackLength: root.trackLength - userHost: root.userHost - displayedTitle: root.displayedTitle - dnd: root.dnd - sendDndAction: root.sendDndAction - notificationHistory: notificationHistory - brightnessPercent: root.brightnessPercent - brightnessAvailable: root.brightnessAvailable - setBrightness: root.setBrightness - } - - WallpapersContent { - id: wallpaperPicker - anchors.fill: parent - visible: panel.dashboardTab === 1 - active: panel.dashboardTab === 1 && panel.expanded - textColor: root.textColor - muted: root.muted - accent: root.accent - secondary: root.themeSecondaryColor - dangerColor: root.kanbanDangerColor - fontFamily: root.fontFamily - // The jump button itself runs the actual launcher - // IPC call (see its own comment); this only collapses - // the notch on the way out, same property - // closeDashboard() above sets -- jumping to the - // launcher's Wallpaper Shuffle extension with the - // notch still expanded underneath it would look like - // two overlays stacked on top of each other. - onOpenWallpaperShuffleRequested: panel.pinnedOpen = false - // Same reasoning, for the Wallpapers Effects jump - // button right next to it. - onOpenWallpaperEffectsRequested: panel.pinnedOpen = false - // Same reasoning, for Theme mode's own Bjarneo Themes - // jump button. - onOpenThemeBrowserRequested: panel.pinnedOpen = false - } - - MetricsContent { - anchors.fill: parent - visible: panel.dashboardTab === 2 - active: panel.dashboardTab === 2 && panel.expanded - textColor: root.textColor - muted: root.muted - accent: root.accent - fontFamily: root.fontFamily - avatarCacheBust: root.avatarCacheBust - avatarAnimated: root.avatarAnimated - avatarFrameCount: root.avatarFrameCount - avatarFrameDelayMs: root.avatarFrameDelayMs - avatarFrameDir: root.avatarFrameDir - } - - KanbanContent { - anchors.fill: parent - visible: panel.dashboardTab === 3 - textColor: root.textColor - muted: root.muted - accent: root.accent - confirmColor: root.cavaCoolColor - successColor: root.kanbanSuccessColor - dangerColor: root.kanbanDangerColor - warningColor: root.kanbanWarningColor - // Per direct request to swap which of the column panel - // and its nested cards reads darker: cards sit ON TOP - // of the column's own already-tinted fill (both - // double-painted -- KanbanContent's own cardBase + - // cardChrome -- and single-painted alike), so tinting - // them FURTHER toward textColor only ever pushes darker - // still when textColor itself is dark (a light theme) -- - // confirmed live, no alpha was low enough to escape - // that, since it's the wrong tint DIRECTION, not just - // too strong a magnitude. Tints toward white instead - // specifically when textColor is dark (surface is - // light) -- that's the one direction that can actually - // read lighter than an already-darkened parent. Keeps - // the original textColor-tint on a dark theme (textColor - // light there), where that direction already lightens - // correctly on its own, same as before. 0.65, tuned - // live against the actual rendered card (not derived on - // paper): the card's own MultiEffect drop shadow sits - // BEHIND cardBase at a real, non-trivial opacity - // (shadowOpacity 0.38) and bleeds through the card's own - // translucent fill from underneath, darkening the - // visible result well past what plain double-layer - // alpha compositing alone predicts -- 0.2 (matched to - // DashboardContent's own dashboardCardSurface, which - // has no such shadow-bleed factor) still rendered - // clearly darker than the column live, 0.4 landed - // exactly equal to it, 0.65 is what actually reads - // lighter on screen. - cardSurface: root.surfaceLuminance(root.textColor) < 0.5 - ? Qt.rgba(1, 1, 1, 0.65) - : Qt.rgba(root.textColor.r, root.textColor.g, root.textColor.b, 0.055) - cardBorderColor: Qt.rgba(root.textColor.r, root.textColor.g, root.textColor.b, 0.14) - editorSurface: Qt.rgba(root.textColor.r, root.textColor.g, root.textColor.b, 0.075) - fontFamily: root.fontFamily - kanbanService: kanbanService - } - } - } - } - - LauncherContent { - overlayRoot: root - panel: panel - appLibrary: appLibrary - } + // One instance per connected screen -- was a single unbound + // PanelWindow with no `screen:` property at all, so Hyprland/wlroots + // picked exactly one output to map it to and every other monitor got + // no notch whatsoever (direct report: works fine mirrored, since + // that is literally the same framebuffer duplicated, but "isn't + // showing up on his other monitors" in a real extended multi-monitor + // setup). Same per-screen `Variants` shape ruixen.bar/Bar.qml already + // uses for its own four windows. + Variants { + model: Quickshell.screens + + delegate: Component { + NotchWindow { + required property var modelData + + screen: modelData + notchRoot: root } } } diff --git a/tests/avatar-custom-gif.sh b/tests/avatar-custom-gif.sh index f41f329..f573821 100755 --- a/tests/avatar-custom-gif.sh +++ b/tests/avatar-custom-gif.sh @@ -17,6 +17,11 @@ launcher_settings_qml="$repo_dir/ruixen.launcher/extensions/settings/SettingsCon # against launcher_settings_qml) stayed put. profile_page_qml="$repo_dir/ruixen.launcher/extensions/settings/pages/ProfilePage.qml" notch_qml="$repo_dir/bars/widgets/ruixen.notch/Overlay.qml" +# The avatar RENDERING (AnimatedImage, frame extraction/timer, +# MultiEffect mask swap) lives in NotchWindow.qml now -- the state +# itself (avatarAnimated = !!p.animated, reading the JSON) stays on +# Overlay.qml's own root, genuinely singleton. +notch_window_qml="$repo_dir/bars/widgets/ruixen.notch/NotchWindow.qml" metrics_qml="$repo_dir/bars/widgets/ruixen.notch/MetricsContent.qml" pass=0 @@ -53,19 +58,19 @@ check "launcher settings preview renders animated avatars directly instead of th check "launcher settings preview keeps the MultiEffect mask for static avatars only" \ "$(grep -cF 'visible: !settingsRoot.avatarAnimated' "$profile_page_qml")" "2" check "notch avatar uses AnimatedImage so a valid GIF can animate" \ - "$(grep -c 'AnimatedImage {' "$notch_qml")" "1" + "$(grep -c 'AnimatedImage {' "$notch_window_qml")" "1" check "notch reads avatar state to decide whether the active avatar is animated" \ "$(grep -cF 'root.avatarAnimated = !!p.animated' "$notch_qml")" "1" check "notch switches to extracted frame sources when avatar state is animated" \ - "$(grep -cF 'root.avatarFrameDir' "$notch_qml")" "2" + "$(grep -cF 'notchRoot.avatarFrameDir' "$notch_window_qml")" "2" check "notch advances extracted avatar frames with a timer" \ - "$(grep -cF 'root.avatarFrameCount > 1' "$notch_qml")" "1" + "$(grep -cF 'notchRoot.avatarFrameCount > 1' "$notch_window_qml")" "1" check "notch renders animated avatars directly instead of through MultiEffect" \ - "$(grep -cF 'visible: root.avatarAnimated' "$notch_qml")" "1" + "$(grep -cF 'visible: panel.notchRoot.avatarAnimated' "$notch_window_qml")" "1" check "notch keeps the MultiEffect mask for static avatars only" \ - "$(grep -cF 'visible: !root.avatarAnimated' "$notch_qml")" "2" + "$(grep -cF 'visible: !panel.notchRoot.avatarAnimated' "$notch_window_qml")" "2" check "expanded health page receives animated avatar state from the notch" \ - "$(grep -cF 'avatarAnimated: root.avatarAnimated' "$notch_qml")" "1" + "$(grep -cF 'avatarAnimated: panel.notchRoot.avatarAnimated' "$notch_window_qml")" "1" check "expanded health page switches to extracted frame sources when avatar state is animated" \ "$(grep -cF 'root.avatarFrameDir' "$metrics_qml")" "1" check "expanded health page advances extracted avatar frames with a timer" \ diff --git a/tests/bar-popup-clearance.sh b/tests/bar-popup-clearance.sh index d2db00e..74432ed 100755 --- a/tests/bar-popup-clearance.sh +++ b/tests/bar-popup-clearance.sh @@ -82,7 +82,11 @@ script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" repo_dir="$(cd -- "$script_dir/.." && pwd)" bar_qml="$repo_dir/bars/v2/ruixen.bar/Bar.qml" barpanel_qml="$repo_dir/bars/v2/ruixen.bar/BarPanel.qml" -notch_overlay_qml="$repo_dir/bars/widgets/ruixen.notch/Overlay.qml" +# Everything this file reads off the notch (margins.top, notchOuter's +# own restY/cornerSize/seamOverlap, the collapsed/expanded body size) +# is the window's own visible content -- lives in its own per-screen +# file now, see NotchWindow.qml's own header for why. +notch_overlay_qml="$repo_dir/bars/widgets/ruixen.notch/NotchWindow.qml" pass=0 fail_count=0 diff --git a/tests/bar-style-mode.sh b/tests/bar-style-mode.sh index 46a32cb..d17e7c1 100755 --- a/tests/bar-style-mode.sh +++ b/tests/bar-style-mode.sh @@ -72,8 +72,11 @@ check "legacy right docked background is disabled in fullbar or when frame owns check "notch reads shell.json to learn bar.style" \ "$(grep -m1 'readonly property string shellConfigPath:' "$notch_qml")" ' readonly property string shellConfigPath: Quickshell.env("HOME") + "/.config/omarchy/shell.json"' +# The notch's own PanelWindow visibility now lives in NotchWindow.qml -- +# see its own header for why the window itself is a separate, per-screen +# file. shellConfigPath (checked above) stays on Overlay.qml's own root. check "notch hides itself in fullbar style" \ - "$(grep -m1 'visible: !root.fullscreenActive && !root.barHidden && !root.fullbarStyle' "$notch_qml")" ' visible: !root.fullscreenActive && !root.barHidden && !root.fullbarStyle' + "$(grep -m1 'visible: !notchRoot.fullscreenActive && !notchRoot.barHidden && !notchRoot.fullbarStyle' "$repo_dir/bars/widgets/ruixen.notch/NotchWindow.qml")" ' visible: !notchRoot.fullscreenActive && !notchRoot.barHidden && !notchRoot.fullbarStyle' check "bar style helper supports fullbar" \ "$(grep -m1 'Usage: ruixen-bar-style ' "$style_script")" 'Usage: ruixen-bar-style ' diff --git a/tests/idle-nightlight-relay.sh b/tests/idle-nightlight-relay.sh index 3ee5722..a1d73e3 100755 --- a/tests/idle-nightlight-relay.sh +++ b/tests/idle-nightlight-relay.sh @@ -20,7 +20,9 @@ set -Eeuo pipefail script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" repo_dir="$(cd -- "$script_dir/.." && pwd)" dashboard_qml="$repo_dir/bars/widgets/ruixen.notch/DashboardContent.qml" -overlay_qml="$repo_dir/bars/widgets/ruixen.notch/Overlay.qml" +# DashboardContent's own instantiation is window content now -- +# see NotchWindow.qml's own header. +notch_window_qml="$repo_dir/bars/widgets/ruixen.notch/NotchWindow.qml" pass=0 fail_count=0 @@ -78,7 +80,7 @@ check "an action refreshes status immediately afterward rather than waiting up t check "DashboardContent.qml declares panelExpanded" \ "$(grep -c 'property bool panelExpanded: false' "$dashboard_qml")" "1" check "Overlay.qml wires panelExpanded from the real panel.expanded" \ - "$(grep -c 'panelExpanded: panel\.expanded' "$overlay_qml")" "1" + "$(grep -c 'panelExpanded: panel\.expanded' "$notch_window_qml")" "1" # --- no leftover debug scaffolding --------------------------------------- diff --git a/tests/notch-bar-hidden-sync.sh b/tests/notch-bar-hidden-sync.sh index 8966867..746154d 100755 --- a/tests/notch-bar-hidden-sync.sh +++ b/tests/notch-bar-hidden-sync.sh @@ -23,6 +23,9 @@ script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" repo_dir="$(cd -- "$script_dir/.." && pwd)" bar_qml="$repo_dir/bars/v2/ruixen.bar/Bar.qml" overlay_qml="$repo_dir/bars/widgets/ruixen.notch/Overlay.qml" +# The PanelWindow's own visible: line is window content now -- +# see NotchWindow.qml's own header. +notch_window_qml="$repo_dir/bars/widgets/ruixen.notch/NotchWindow.qml" pass=0 fail_count=0 @@ -55,7 +58,7 @@ check "ruixen.notch watches the parent toggles directory (FileView can't observe "$(grep -c 'path: Quickshell.env("HOME") + "/.local/state/omarchy/toggles"' "$overlay_qml" || true)" "1" check "the notch's own PanelWindow visibility now also depends on barHidden, not fullscreen alone" \ - "$(grep -c 'visible: !root.fullscreenActive && !root.barHidden' "$overlay_qml" || true)" "1" + "$(grep -c 'visible: !notchRoot.fullscreenActive && !notchRoot.barHidden' "$notch_window_qml" || true)" "1" printf '\n%d passed, %d failed\n' "$pass" "$fail_count" [[ "$fail_count" -eq 0 ]] diff --git a/tests/notch-kanban-gui.sh b/tests/notch-kanban-gui.sh index f86a21f..d32df02 100755 --- a/tests/notch-kanban-gui.sh +++ b/tests/notch-kanban-gui.sh @@ -22,6 +22,9 @@ script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" repo_dir="$(cd -- "$script_dir/.." && pwd)" content_qml="$repo_dir/bars/widgets/ruixen.notch/KanbanContent.qml" overlay_qml="$repo_dir/bars/widgets/ruixen.notch/Overlay.qml" +# KanbanContent's own instantiation (the color props it's fed) is +# window content now -- see NotchWindow.qml's own header. +notch_window_qml="$repo_dir/bars/widgets/ruixen.notch/NotchWindow.qml" service_qml="$repo_dir/bars/widgets/ruixen.notch/KanbanService.qml" model_js="$repo_dir/bars/widgets/ruixen.notch/KanbanModel.js" run_all="$repo_dir/tests/run-all.sh" @@ -141,18 +144,18 @@ check "add editor cancel uses the reusable red action button" \ "$(grep -c 'id: addCancelButton' "$content_qml")" "1" check "Kanban confirm token is supplied from the active theme blue swatch" \ - "$(grep -c 'confirmColor: root.cavaCoolColor' "$overlay_qml")" "1" + "$(grep -c 'confirmColor: panel.notchRoot.cavaCoolColor' "$notch_window_qml")" "1" check "Kanban semantic colors are supplied from the active theme palette" \ - "$(grep -E -c 'successColor: root.kanbanSuccessColor|dangerColor: root.kanbanDangerColor|warningColor: root.kanbanWarningColor' "$overlay_qml")" "4" + "$(grep -E -c 'successColor: panel.notchRoot.kanbanSuccessColor|dangerColor: panel.notchRoot.kanbanDangerColor|warningColor: panel.notchRoot.kanbanWarningColor' "$notch_window_qml")" "4" # 4, not 3 -- WallpapersContent's own search clear "x" started reading # the same kanbanDangerColor token too (was a hardcoded #e05252), # a second dangerColor: match alongside Kanban's three. check "Kanban card surface tints toward white on a light theme (nested lighter than the column it sits on), textColor on a dark one" \ - "$(( $(grep -c 'root.surfaceLuminance(root.textColor) < 0.5' "$overlay_qml") + $(grep -c '? Qt.rgba(1, 1, 1, 0.65)' "$overlay_qml") ))" "2" + "$(( $(grep -c 'panel.notchRoot.surfaceLuminance(panel.notchRoot.textColor) < 0.5' "$notch_window_qml") + $(grep -c '? Qt.rgba(1, 1, 1, 0.65)' "$notch_window_qml") ))" "2" check "Kanban editor surface is text-contrast derived for dark and light themes" \ - "$(grep -c 'editorSurface: Qt.rgba(root.textColor.r, root.textColor.g, root.textColor.b, 0.075)' "$overlay_qml")" "1" + "$(grep -c 'editorSurface: Qt.rgba(panel.notchRoot.textColor.r, panel.notchRoot.textColor.g, panel.notchRoot.textColor.b, 0.075)' "$notch_window_qml")" "1" check "Kanban idle cards use no grey outline" \ "$(( $(grep -F -c ': "transparent"' "$content_qml") + $(grep -F -c 'border.width: cardRoot.deleteArmed || cardArea.containsMouse || editButton.hovered || deleteButton.hovered ? 1.5 : 0' "$content_qml") ))" "2" diff --git a/tests/notch-media-viz-mode.sh b/tests/notch-media-viz-mode.sh index 58c5c52..54800a1 100755 --- a/tests/notch-media-viz-mode.sh +++ b/tests/notch-media-viz-mode.sh @@ -16,6 +16,12 @@ set -Eeuo pipefail script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" repo_dir="$(cd -- "$script_dir/.." && pwd)" overlay_qml="$repo_dir/bars/widgets/ruixen.notch/Overlay.qml" +# The window's own visible content (collapsed pill, cava mini feed, +# bars/wave rendering) now lives in its own per-screen file -- see +# NotchWindow.qml's own header for why. mediaVizMode/cavaWarmColor/etc. +# themselves stay genuinely singleton on Overlay.qml's own root; only +# their RENDERING (the checks below that read this file) moved. +notch_window_qml="$repo_dir/bars/widgets/ruixen.notch/NotchWindow.qml" notch_feed="$repo_dir/bars/widgets/ruixen.notch/CavaFeed.qml" cava_feed="$repo_dir/ruixen.cava/CavaFeed.qml" @@ -64,11 +70,11 @@ check "the mini feed uses a smaller band count than the full edge-docked overlay # --- mirroring: real bass-on-both-edges/treble-in-the-center, not a sweep -- check "displayBars doubles the real band count for the mirror fold" \ - "$(grep -c 'readonly property int displayBars: compactCavaFeed.bands \* 2' "$overlay_qml")" "1" + "$(grep -c 'readonly property int displayBars: panel.notchRoot.compactCavaFeed.bands \* 2' "$notch_window_qml")" "1" check "mirrorBandAt folds the display index around the center (distance from nearest edge)" \ - "$(grep -A2 'function mirrorBandAt' "$overlay_qml" | grep -c 'Math.min(i, cavaMiniSlot.displayBars - 1 - i)')" "1" + "$(grep -A2 'function mirrorBandAt' "$notch_window_qml" | grep -c 'Math.min(i, cavaMiniSlot.displayBars - 1 - i)')" "1" check "each rendered bar reads its level through the mirror fold, not a direct 1:1 index" \ - "$(grep -c 'compactCavaFeed.levels\[cavaMiniSlot.mirrorBandAt(index)\]' "$overlay_qml")" "1" + "$(grep -c 'panel.notchRoot.compactCavaFeed.levels\[cavaMiniSlot.mirrorBandAt(index)\]' "$notch_window_qml")" "1" # --- warm/cool theme color, same mechanism ruixen.cava/Overlay.qml uses -- @@ -79,27 +85,27 @@ check "colors are parsed from the real active theme file" \ check "a load failure falls back to Color.accent AND schedules the one-shot retry" \ "$(grep -A10 'onLoadFailed: {' "$overlay_qml" | grep -c 'cavaThemeColorsRetryTimer.restart()')" "1" check "each bar's color comes from the warm/cool gradient function, not a flat accent" \ - "$(grep -c 'color: cavaMiniSlot.barColor(index, level)' "$overlay_qml")" "1" + "$(grep -c 'color: cavaMiniSlot.barColor(index, level)' "$notch_window_qml")" "1" # --- nowPlayingSlot: exactly 3 mutually-exclusive children + the scroll toggle -- check "the seeker is scoped to media present AND seeker mode selected" \ - "$(grep -c 'visible: root.hasMedia && root.mediaVizMode === "seeker"' "$overlay_qml")" "1" + "$(grep -c 'visible: panel.notchRoot.hasMedia && panel.notchRoot.mediaVizMode === "seeker"' "$notch_window_qml")" "1" check "the cava mini slot is scoped to media present AND cava mode selected" \ - "$(grep -c 'visible: root.hasMedia && root.mediaVizMode === "cava"' "$overlay_qml")" "1" + "$(grep -c 'visible: panel.notchRoot.hasMedia && panel.notchRoot.mediaVizMode === "cava"' "$notch_window_qml")" "1" check "the window name stays unconditional on media absence, no mode gating" \ - "$(grep -c 'visible: !root.hasMedia' "$overlay_qml")" "1" + "$(grep -c 'visible: !panel.notchRoot.hasMedia' "$notch_window_qml")" "1" # Wheel no longer toggles mode -- the seeker's own MouseArea (visible # only in seeker mode) repurposes wheel to seek instead, and right # click is now the only way to switch modes (see the seek-support PR's # own follow-up commit for why: wheel scrolling the seeker used to # accidentally jump to the visualizer). check "right click toggles the mode on the always-present now-playing slot" \ - "$(grep -c 'if (mouse.button === Qt.RightButton) {' "$overlay_qml")" "1" + "$(grep -c 'if (mouse.button === Qt.RightButton) {' "$notch_window_qml")" "1" check "right click toggles the mode on the seeker's own MouseArea too" \ - "$(grep -c 'if (mouse.button === Qt.RightButton) root.toggleMediaVizMode()' "$overlay_qml")" "1" + "$(grep -c 'if (mouse.button === Qt.RightButton) panel.notchRoot.toggleMediaVizMode()' "$notch_window_qml")" "1" check "wheel on the seeker seeks instead of switching modes" \ - "$(grep -c 'wheel.angleDelta.y > 0 ? 5 : -5' "$overlay_qml")" "1" + "$(grep -c 'wheel.angleDelta.y > 0 ? 5 : -5' "$notch_window_qml")" "1" check "no MouseArea wheel handler still toggles the mode" \ "$(grep -c 'onWheel:.*toggleMediaVizMode' "$overlay_qml")" "0" @@ -117,39 +123,39 @@ check "toggleCavaMiniStyle flips bars/wave and persists" \ # 2, not 1 -- both left click AND wheel now reach the same toggle # (identical guard text in each handler), not just click alone. check "left click AND wheel on the slot toggle style, but only while already in cava mode" \ - "$(grep -c 'if (root.mediaVizMode === "cava") root.toggleCavaMiniStyle()' "$overlay_qml")" "2" + "$(grep -c 'if (panel.notchRoot.mediaVizMode === "cava") panel.notchRoot.toggleCavaMiniStyle()' "$notch_window_qml")" "2" check "the mode/style state file round-trips both fields together" \ "$(grep -c 'mode: root.mediaVizMode, style: root.cavaMiniStyle' "$overlay_qml")" "1" # --- bars vs. wave rendering: mutually exclusive, same mirrored data ------ check "the bars Repeater only populates in bars style (0 delegates otherwise)" \ - "$(grep -c 'model: root.cavaMiniStyle === "bars" ? cavaMiniSlot.displayBars : 0' "$overlay_qml")" "1" + "$(grep -c 'model: panel.notchRoot.cavaMiniStyle === "bars" ? cavaMiniSlot.displayBars : 0' "$notch_window_qml")" "1" check "the wave Canvas is scoped to wave style" \ - "$(grep -c 'visible: root.cavaMiniStyle === "wave"' "$overlay_qml")" "1" + "$(grep -c 'visible: panel.notchRoot.cavaMiniStyle === "wave"' "$notch_window_qml")" "1" check "the wave reads through the exact same mirror fold the bars use, not a separate index scheme" \ - "$(grep -c 'compactCavaFeed.levels\[cavaMiniSlot.mirrorBandAt(i)\]' "$overlay_qml")" "1" + "$(grep -c 'panel.notchRoot.compactCavaFeed.levels\[cavaMiniSlot.mirrorBandAt(i)\]' "$notch_window_qml")" "1" check "the wave repaints when new cava data arrives" \ - "$(grep -A1 'function onLevelsChanged() { if (cavaMiniWave.visible)' "$overlay_qml" | grep -c 'requestPaint()')" "1" + "$(grep -A1 'function onLevelsChanged() { if (cavaMiniWave.visible)' "$notch_window_qml" | grep -c 'requestPaint()')" "1" check "the wave forces a fresh paint on becoming visible, not stale cached content" \ - "$(grep -c 'onVisibleChanged: if (visible) requestPaint()' "$overlay_qml")" "1" + "$(grep -c 'onVisibleChanged: if (visible) requestPaint()' "$notch_window_qml")" "1" check "the wave is a FILLED area, not just a stroked line -- direct correction" \ - "$(grep -c 'ctx.fillStyle = grad' "$overlay_qml")" "1" + "$(grep -c 'ctx.fillStyle = grad' "$notch_window_qml")" "1" check "the fill curve is smoothed via quadratic-through-midpoints, same technique the desktop wave uses, not raw straight segments" \ - "$(grep -c 'ctx.quadraticCurveTo(pts\[j\].x, pts\[j\].y, mx, my)' "$overlay_qml")" "1" + "$(grep -c 'ctx.quadraticCurveTo(pts\[j\].x, pts\[j\].y, mx, my)' "$notch_window_qml")" "1" check "the fill starts and closes down to the bottom edge (same baseline bars grow up from)" \ - "$(grep -c 'ctx.moveTo(pts\[0\].x, height)' "$overlay_qml")$(grep -c 'ctx.lineTo(pts\[n - 1\].x, height)' "$overlay_qml")" "11" + "$(grep -c 'ctx.moveTo(pts\[0\].x, height)' "$notch_window_qml")$(grep -c 'ctx.lineTo(pts\[n - 1\].x, height)' "$notch_window_qml")" "11" check "a brighter stroke is retraced on top of the fill for definition" \ - "$(grep -c 'ctx.strokeStyle = grad' "$overlay_qml")" "1" + "$(grep -c 'ctx.strokeStyle = grad' "$notch_window_qml")" "1" # --- edge fade: opacity tapers at the outermost couple of positions ------- check "edgeFade exists, scaled down from the desktop's own fixed 6 to fit displayBars=12" \ - "$(grep -c 'readonly property int edgeFadeBands: 2' "$overlay_qml")" "1" + "$(grep -c 'readonly property int edgeFadeBands: 2' "$notch_window_qml")" "1" check "each bar's own opacity is driven by edgeFade, not always full-strength" \ - "$(grep -c 'opacity: cavaMiniSlot.edgeFade(index)' "$overlay_qml")" "1" + "$(grep -c 'opacity: cavaMiniSlot.edgeFade(index)' "$notch_window_qml")" "1" check "the wave gradient is an 8-step ramp (not 3 flat stops), each step scaled by edgeFade too" \ - "$(grep -c '0.85 \* fade' "$overlay_qml")" "1" + "$(grep -c '0.85 \* fade' "$notch_window_qml")" "1" printf '\n%d passed, %d failed\n' "$pass" "$fail_count" [[ "$fail_count" -eq 0 ]] diff --git a/tests/notch-theme-switcher.sh b/tests/notch-theme-switcher.sh index be13bbc..07f31a2 100755 --- a/tests/notch-theme-switcher.sh +++ b/tests/notch-theme-switcher.sh @@ -180,7 +180,7 @@ check "Overlay exposes toggleWallpapers on the notch IPC target" \ "$(grep -c 'function toggleWallpapers(): void' "$overlay_qml")" "1" check "toggleWallpapers lands on the wallpapers tab when closed" \ - "$(grep -A2 'function toggleWallpapers' "$overlay_qml" | grep -c 'panel.dashboardTab = 1')" "1" + "$(grep -A2 'function toggleWallpapers' "$overlay_qml" | grep -c 'root.dashboardTab = 1')" "1" # --- The installer ----------------------------------------------------- check "install.sh recommends the wallpapers keybind" \ diff --git a/tests/notch-wallpaper-shuffle-jump.sh b/tests/notch-wallpaper-shuffle-jump.sh index 0714685..b9f1d7f 100755 --- a/tests/notch-wallpaper-shuffle-jump.sh +++ b/tests/notch-wallpaper-shuffle-jump.sh @@ -21,7 +21,9 @@ set -Eeuo pipefail script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" repo_dir="$(cd -- "$script_dir/.." && pwd)" wallpapers_qml="$repo_dir/bars/widgets/ruixen.notch/WallpapersContent.qml" -overlay_qml="$repo_dir/bars/widgets/ruixen.notch/Overlay.qml" +# These close-requests are wired on the window content now -- +# see NotchWindow.qml's own header. +notch_window_qml="$repo_dir/bars/widgets/ruixen.notch/NotchWindow.qml" pass=0 fail_count=0 @@ -73,7 +75,7 @@ check "all three jumps go through omarchy-shell, not a raw IPC call (resolved OM "$(( $(grep -A2 'id: openWallpaperShuffleProc' "$wallpapers_qml" | grep -Fc '"omarchy-shell"') + $(grep -A2 'id: openWallpaperEffectsProc' "$wallpapers_qml" | grep -Fc '"omarchy-shell"') + $(grep -A2 'id: openThemeBrowserProc' "$wallpapers_qml" | grep -Fc '"omarchy-shell"') ))" "3" check "Overlay.qml collapses the notch when any of the three jumps fire, so the launcher doesn't open with the notch still expanded underneath it" \ - "$(( $(grep -Fc 'onOpenWallpaperShuffleRequested: panel.pinnedOpen = false' "$overlay_qml") + $(grep -Fc 'onOpenWallpaperEffectsRequested: panel.pinnedOpen = false' "$overlay_qml") + $(grep -Fc 'onOpenThemeBrowserRequested: panel.pinnedOpen = false' "$overlay_qml") ))" "3" + "$(( $(grep -Fc 'onOpenWallpaperShuffleRequested: panel.notchRoot.pinnedOpen = false' "$notch_window_qml") + $(grep -Fc 'onOpenWallpaperEffectsRequested: panel.notchRoot.pinnedOpen = false' "$notch_window_qml") + $(grep -Fc 'onOpenThemeBrowserRequested: panel.notchRoot.pinnedOpen = false' "$notch_window_qml") ))" "3" printf '\n%d passed, %d failed\n' "$pass" "$fail_count" [[ "$fail_count" -eq 0 ]] diff --git a/tests/notification-relay.sh b/tests/notification-relay.sh index 7c787bc..1b5cc44 100755 --- a/tests/notification-relay.sh +++ b/tests/notification-relay.sh @@ -37,6 +37,9 @@ set -Eeuo pipefail script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" repo_dir="$(cd -- "$script_dir/.." && pwd)" overlay_qml="$repo_dir/bars/widgets/ruixen.notch/Overlay.qml" +# The collapsed bell click and DashboardContent's own instantiation +# are window content now -- see NotchWindow.qml's own header. +notch_window_qml="$repo_dir/bars/widgets/ruixen.notch/NotchWindow.qml" dashboard_qml="$repo_dir/bars/widgets/ruixen.notch/DashboardContent.qml" service_qml="$repo_dir/bars/widgets/ruixen.notch/NotificationService.qml" @@ -80,7 +83,7 @@ check "a load failure defaults dnd to false rather than leaving it stale" \ # --- DND control: the real IPC target ----------------------------------- check "the collapsed bell toggles DND via the real IPC target" \ - "$(grep -c 'root\.sendDndAction("toggleDnd")' "$overlay_qml")" "1" + "$(grep -c 'notchRoot\.sendDndAction("toggleDnd")' "$notch_window_qml")" "1" check "the dashboard card's own bell also toggles DND via the passthrough" \ "$(grep -c 'root\.sendDndAction("toggleDnd")' "$dashboard_qml")" "1" check "sendDndAction calls the real \"notifications\" IPC target, not a first-party service handle" \ @@ -88,7 +91,7 @@ check "sendDndAction calls the real \"notifications\" IPC target, not a first-pa check "DashboardContent.qml threads sendDndAction down instead of a raw service handle" \ "$(grep -c 'property var sendDndAction: null' "$dashboard_qml")" "1" check "Overlay.qml passes sendDndAction to DashboardContent" \ - "$(grep -c 'sendDndAction: root\.sendDndAction' "$overlay_qml")" "1" + "$(grep -c 'sendDndAction: panel\.notchRoot\.sendDndAction' "$notch_window_qml")" "1" # --- history/entries: real on-disk directories, swept on a timer -------- diff --git a/tests/shelf-plugin.sh b/tests/shelf-plugin.sh index 1cb0200..876c7ed 100755 --- a/tests/shelf-plugin.sh +++ b/tests/shelf-plugin.sh @@ -18,6 +18,10 @@ service="$shelf_dir/ShelfService.qml" content="$shelf_dir/ShelfContent.qml" model="$shelf_dir/ShelfModel.js" notch="$repo_dir/bars/widgets/ruixen.notch/Overlay.qml" +# The pill's own quick-drop DropArea (visual/interaction content) is +# window content now -- see NotchWindow.qml's own header. relayShelfOpen/ +# relayToShelf themselves (the actual IPC relay) stay root-level functions. +notch_window="$repo_dir/bars/widgets/ruixen.notch/NotchWindow.qml" pass=0 fail_count=0 @@ -378,7 +382,7 @@ check "Copy semantics: acceptProposedAction() is never used in the shelf or the check "Copy semantics: the open Shelf accepts drags and drops with Qt.CopyAction explicitly" \ "$(grep -c 'drag.accept(Qt.CopyAction)\|drop.accept(Qt.CopyAction)' "$content")" "2" check "Copy semantics: the notch quick-drop accepts with Qt.CopyAction explicitly" \ - "$(grep -A45 'id: shelfQuickDrop' "$notch" | grep -c 'drag.accept(Qt.CopyAction)\|drop.accept(Qt.CopyAction)')" "2" + "$(grep -A45 'id: shelfQuickDrop' "$notch_window" | grep -c 'drag.accept(Qt.CopyAction)\|drop.accept(Qt.CopyAction)')" "2" check "Copy semantics: drag-out only advertises CopyAction" \ "$(grep -c 'Drag.supportedActions: Qt.CopyAction' "$content")" "1" check "Content: a drop is only accepted once the shelf actually added something" \ @@ -395,13 +399,13 @@ check "Shelf holds references only: no cp/mv/rm in the QML" \ # --- the notch: only the quick-drop relay, no Shelf UI ---------------- check "Notch: no Shelf service/content/tab left behind" \ "$(grep -cE 'ShelfService|ShelfContent|dashboardTab === 4|% 5|toggleShelf|shelfAdd|shelfList' "$notch")" "0" -check "Notch: still four dashboard tabs" "$(grep -c 'dashboardTab + 1) % 4' "$notch")" "1" +check "Notch: still four dashboard tabs" "$(grep -c 'dashboardTab + 1) % 4' "$notch_window")" "1" check "Notch: quick-drop DropArea on the collapsed footprint" \ - "$(grep -c 'id: shelfQuickDrop' "$notch")" "1" + "$(grep -c 'id: shelfQuickDrop' "$notch_window")" "1" check "Notch: quick-drop is inert while expanded" \ - "$(grep -A8 'id: shelfQuickDrop' "$notch" | grep -c 'enabled: !panel.expanded')" "1" + "$(grep -A8 'id: shelfQuickDrop' "$notch_window" | grep -c 'enabled: !panel.expanded')" "1" check "Notch: quick-drop only accepts local file URLs with a real path" \ - "$(grep -c 'file:\\/\\/(?:localhost)?\\/\.+' "$notch")" "1" + "$(grep -c 'file:\\/\\/(?:localhost)?\\/\.+' "$notch_window")" "1" check "Notch: relays one batched IPC call to ruixen.shelf, not one per path" \ "$(grep -c '"omarchy-shell", "ruixen.shelf", "addMany"' "$notch")" "1" check "Notch: relay queues behind a running relay (never reassigns a live Process)" \ @@ -410,13 +414,13 @@ check "Notch: relay queues behind a running relay (never reassigns a live Proces check "Notch: no highlight is drawn behind the pill -- the Shelf opening is the feedback" \ "$(grep -c 'shelfQuickDrop.containsDrag' "$notch" || true)" "0" check "Notch: a local-file drag entering the pill asks the Shelf to open" \ - "$(sed -n '/id: shelfQuickDrop/,/onExited:/p' "$notch" | grep -c 'root.relayShelfOpen()')" "1" + "$(sed -n '/id: shelfQuickDrop/,/onExited:/p' "$notch_window" | grep -c 'panel.notchRoot.relayShelfOpen()')" "1" check "Notch: the open request is one IPC call to ruixen.shelf openFromDrag" \ "$(grep -c '\["omarchy-shell", "ruixen.shelf", "openFromDrag"\]' "$notch")" "1" check "Notch: opens immediately on drag-enter -- no dwell/spring timer" \ "$(grep -cE 'dwellTimer|springLoad|openShelfTimer' "$notch")" "0" check "Notch: a drop on the pill itself is still relayed, so a fast release never loses the files" \ - "$(sed -n '/onDropped:/,/^ }/p' "$notch" | grep -c 'root.relayToShelf(urls)')" "1" + "$(sed -n '/onDropped:/,/^ }/p' "$notch_window" | grep -c 'panel.notchRoot.relayToShelf(urls)')" "1" check "Shelf: openFromDrag is part of the plugin's own IPC surface" \ "$(grep -c 'function openFromDrag(): void' "$shelf")" "1" check "Shelf: openFromDrag does nothing if the shelf is already open (never takes over a keybind open)" \ diff --git a/tests/wallpaper-sidebar-style.sh b/tests/wallpaper-sidebar-style.sh index 0e90f0e..9e4c57a 100755 --- a/tests/wallpaper-sidebar-style.sh +++ b/tests/wallpaper-sidebar-style.sh @@ -9,6 +9,9 @@ script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" repo_dir="$(cd -- "$script_dir/.." && pwd)" wallpapers_qml="$repo_dir/bars/widgets/ruixen.notch/WallpapersContent.qml" overlay_qml="$repo_dir/bars/widgets/ruixen.notch/Overlay.qml" +# WallpapersContent's own instantiation (the color props it's fed) is +# window content now -- see NotchWindow.qml's own header. +notch_window_qml="$repo_dir/bars/widgets/ruixen.notch/NotchWindow.qml" pass=0 fail_count=0 @@ -28,7 +31,7 @@ check "WallpapersContent exposes a secondary color token for the Top affordance" check "Overlay reads the active theme's secondary color" \ "$(grep -c 'themeSecondaryColor = root.parseCavaThemeColor(t, "secondary", Color.accent)' "$overlay_qml")" "1" check "Overlay passes theme secondary into WallpapersContent" \ - "$(grep -A8 'WallpapersContent {' "$overlay_qml" | grep -c 'secondary: root.themeSecondaryColor')" "1" + "$(grep -A8 'WallpapersContent {' "$notch_window_qml" | grep -c 'secondary: panel.notchRoot.themeSecondaryColor')" "1" # The old image/video/gif count tiles are gone -- direct follow-up # ("convert that side panel stuff we have into the chips here instead # for wallpaper"): the kind filter moved to the shared top chip row