diff --git a/plans/01-takemi-design-hierarchy.md b/plans/01-takemi-design-hierarchy.md index edffa5f..d1fe3d8 100644 --- a/plans/01-takemi-design-hierarchy.md +++ b/plans/01-takemi-design-hierarchy.md @@ -12,6 +12,45 @@ Preserve the crimson/black/white palette, services, keybinds, one-slab bar/rail, `P5Panel`, session-lock security, and current functional coverage. This plan changes visual hierarchy and interaction clarity, not the desktop stack. +## Phase 1 execution override — 2026-08-11 + +For the current Phase 1 implementation, the user explicitly removed reduced-motion +work and screen-reader metadata from scope. This override supersedes Phase 1.1, the +`Accessible.*` parts of Phase 1.4, and their related definition-of-done and +verification requirements for this execution only. + +- Do not create durable motion preferences or change animation policy. +- Do not add or alter `Accessible.*` metadata or accessible action handlers. +- Keep keyboard focus, visible focus treatment, keyboard navigation, root-disabled + input behavior, semantic color/treatment tokens, and the surface hierarchy in + scope. +- Defer the remaining overridden requirements to a separately approved milestone. + +## Implementation progress — 2026-08-11 + +- **Phase 1 foundation: complete for the approved scope.** Added explicit surface + presets and semantic treatment tokens; made `P5Button` and `P5Slider` + keyboard-capable with visible focus and root-gated disabled behavior. The user + explicitly excluded durable reduced-motion work and `Accessible.*` metadata, so + those items remain deferred rather than incomplete regressions. +- **Phase 2 calm chrome: complete.** Bar and rail use the quiet `ChromeSurface` + tier without permanent texture; the clock omits seconds; CPU/MEM/GPU are visibly + labelled; MediaPill uses elided static text and a static play/pause mark; rail + clicks consistently pin detail panels and undisclosed secondary state actions + were removed; delayed Persona tooltips show state and scroll hints without + taking focus or overlapping an opening detail panel. +- **Audio vertical slice: complete.** Output percentage is dominant, output/input + are distinct bands, application streams are subordinate, and mute plus mixer + actions are explicit. Pinned popouts use `PopupWindow.grabFocus` as their sole + focus/dismiss owner, remap when promoted from hover, focus the first Audio + control, support Tab and Escape, and synchronously release coordinator ownership. +- **Verification status.** Changed QML passes individual Qt 6 `qmllint` and the + repository diff passes whitespace validation. Tooltip placement/suppression and + hover open/close were exercised in a live Quickshell session and passed. Pinned + click/remap, outside-click dismissal, Escape, and compositor-close synchronization + remain untested and require interaction QA before the milestone is considered + runtime-verified. + ## Non-goals - Do not replace Quickshell, Hyprland, Fluxo, PAM, or the service layer. diff --git a/quickshell/README.md b/quickshell/README.md index a9512ab..5126c24 100644 --- a/quickshell/README.md +++ b/quickshell/README.md @@ -39,10 +39,18 @@ cost 4.4% of the height on top of what the bar already takes. - **rail** — one slab: network, bluetooth, audio, mic, backlight, battery, tray, notifications, power. Popouts open leftward, into the screen. -Each surface is a single continuous `P5Panel` with modules divided by sheared -hairlines. `BarPill` is chromeless by default — a module is *content inside* a -surface, not a surface of its own — and marks hover with a crimson underline. -Pass `standalone: true` for the rare thing that really is its own panel. +Each permanent surface is a single continuous `ChromeSurface` with modules +divided by sheared hairlines. `BarPill` is chromeless by default — a module is +*content inside* a surface, not a surface of its own — and marks hover with a +crimson underline. Pass `standalone: true` for the rare thing that really is its +own panel. + +The shell uses three deliberate surface tiers. **Chrome** (`ChromeSurface`) is +the calm, texture-free bar, rail, and tooltip layer. **Transient** +(`PopoutSurface`) is reserved for expressive menus and detail panels, with one +clear composition and stronger separation from the desktop. **Cinematic** +(`P5Backdrop`) belongs only to scene-scale experiences such as power and lock; +it is never used as decoration inside ordinary chrome or popouts. ``` shell.qml entrypoint — bar, rail, power menu, toasts, OSD per screen @@ -67,23 +75,31 @@ contentPad * 2`), which keeps the shape maths out of binding loops. | Sigil | bar | app menu | reload shell | | | | | Workspaces | bar | switch | | | | | | Clock | bar | pin calendar | | | | calendar | -| Network | rail | pin panel | toggle Wi-Fi | nm-connection-editor | | networks | -| Bluetooth | rail | pin panel | toggle adapter | | | devices | -| Volume | rail | mute | pin panel | pavucontrol | volume | audio mixer | -| Mic | rail | mute | pin panel | | mic volume | audio mixer | -| Brightness | rail | | | | brightness | | +| Network | rail | pin details | | | | state + networks | +| Bluetooth | rail | pin details | | | | state + devices | +| Volume | rail | pin audio | | | volume | state + scroll hint + audio | +| Mic | rail | pin audio | | | mic volume | state + scroll hint + audio | +| Brightness | rail | | | | brightness | state + scroll hint | | Battery | rail | pin panel | | | | power detail | | Tray | rail | activate | app menu | secondary | | | -| Bell | rail | history + mark read | do not disturb | | | history | +| Bell | rail | pin history + mark read | | | | state + history | | Power | rail | power menu | | | | | | Media | bar | play/pause | next | raise player | next/prev | full player | | CPU/MEM/GPU | bar | btop | | | | resource dashboard | Popouts open on a short dwell and stay while the pointer is over the trigger or -the panel. Clicking pins one open. A shell-wide coordinator keeps only one +the panel. Hover openings remain passive. Clicking remaps a detail panel as a +keyboard-focused popup: Tab moves through its controls, Escape dismisses it, +and an outside click closes it. A shell-wide coordinator keeps only one transient surface mapped at a time, including tray context menus, so sweeping across the rail cannot stack several animated panels over one another. +Audio is the reference vertical-slice popout: current output percentage leads, +output and input have distinct bands, and application streams form a subordinate +mixer list. Output/input mute actions and **Open audio mixer** are explicit +buttons inside the panel; scrolling the rail icons remains the fast level +adjustment. + The power menu is fully keyboard-driven as well as clickable: `Up`/`Down` or `J`/`K` moves the active slab, `Enter`/`Space` confirms it, and `Escape` dismisses the screen. diff --git a/quickshell/bar/ActiveWindow.qml b/quickshell/bar/ActiveWindow.qml index e3a48a2..f143c27 100644 --- a/quickshell/bar/ActiveWindow.qml +++ b/quickshell/bar/ActiveWindow.qml @@ -4,8 +4,8 @@ import Quickshell.Hyprland import "root:/config" import "root:/components" -// Focused window title, glitching on every change the way a Persona 5 caption -// snaps into place. +// Focused window title. Rapid application title churn is settled before it is +// painted, then the stable caption lands like a misregistered print layer. Item { id: root @@ -19,7 +19,8 @@ Item { && (root.monitor === null || toplevel.monitor === null || toplevel.monitor.id === root.monitor.id) - readonly property string title: mine && toplevel.title ? toplevel.title : "—" + readonly property string incomingTitle: mine && toplevel.title ? toplevel.title : "—" + property string displayedTitle: "—" // The bar shrinks this when the three clusters would otherwise collide. property real maxWidth: 240 @@ -31,18 +32,60 @@ Item { NumberAnimation { duration: Theme.durSlow; easing.type: Easing.OutExpo } } - Marquee { - id: label + Item { + id: titleStage anchors.fill: parent - text: root.title - color: root.mine ? Theme.subtext : Theme.muted - pixelSize: Theme.fsSmall - family: Theme.fontDisplay - italic: true - weight: Font.DemiBold + clip: true + + Text { + id: label + anchors.verticalCenter: parent.verticalCenter + width: parent.width + text: root.displayedTitle + color: root.mine ? Theme.subtext : Theme.muted + elide: Text.ElideRight + font.pixelSize: Theme.fsSmall + font.family: Theme.fontDisplay + font.italic: true + font.weight: Font.DemiBold + renderType: Text.NativeRendering + } + + // Crimson duplicate: visible only during the landing transition. The + // small opposing offset gives the title Persona's rough print snap + // without leaving permanent motion in the bar. + Text { + id: ghost + anchors.verticalCenter: parent.verticalCenter + width: parent.width + text: root.displayedTitle + color: Theme.accent + opacity: 0 + elide: Text.ElideRight + font: label.font + renderType: Text.NativeRendering + } } - onTitleChanged: flash.restart() + function commitTitle(): void { + if (root.displayedTitle === root.incomingTitle) + return; + root.displayedTitle = root.incomingTitle; + titleLanding.restart(); + } + + // Browser tabs, terminals and editors can update titles several times in a + // frame. Keep the old caption steady until the stream settles instead of + // restarting an animation for every intermediate value. + Timer { + id: titleSettle + interval: 120 + repeat: false + onTriggered: root.commitTitle() + } + + onIncomingTitleChanged: titleSettle.restart() + Component.onCompleted: displayedTitle = incomingTitle // A quick crimson wipe under the title on focus change. Rectangle { @@ -55,7 +98,7 @@ Item { } SequentialAnimation { - id: flash + id: titleLanding ParallelAnimation { NumberAnimation { target: wipe; property: "width" @@ -66,6 +109,24 @@ Item { target: wipe; property: "opacity" from: 0; to: 1; duration: Theme.durFast } + NumberAnimation { + target: label; property: "x" + from: -10; to: 0 + duration: Theme.durBase; easing.type: Easing.OutBack + } + NumberAnimation { + target: label; property: "opacity" + from: 0.35; to: 1; duration: Theme.durFast + } + NumberAnimation { + target: ghost; property: "x" + from: 9; to: -3 + duration: Theme.durBase; easing.type: Easing.OutExpo + } + NumberAnimation { + target: ghost; property: "opacity" + from: 0.8; to: 0; duration: Theme.durBase + } } NumberAnimation { target: wipe; property: "opacity" diff --git a/quickshell/bar/Bar.qml b/quickshell/bar/Bar.qml index ff4c756..12df02b 100644 --- a/quickshell/bar/Bar.qml +++ b/quickshell/bar/Bar.qml @@ -37,29 +37,16 @@ PanelWindow { exclusiveZone: Theme.barHeight + Theme.barMargin color: "transparent" - // The slab. One panel, full width, flat fill, black keyline. - P5Panel { + // Calm permanent chrome: one opaque slab with no texture or offset copy. + ChromeSurface { id: slab anchors.fill: parent lean: Theme.skew chop: Theme.cut chopCorners: [1, 3] padding: 0 - fill: Theme.surface - fillOpacity: 1.0 - border: Theme.alpha(Theme.outline, 0.9) - borderWidth: 1 - slash: true - slashColor: Theme.accent + active: true slashWidth: 5 - - // No offset copy on the big slabs. The 3px black silhouette behind the - // fill is a print-misregistration trick, and at this scale it stops - // reading as one: on a 1900px bar you only ever see it at the chopped - // corners, where it turns the single clean cut into a staggered double - // edge that looks like a rendering fault. The black keyline still - // carries the heavy outer contour, which is what the corner needs. - drop: false } readonly property real edgeInset: Theme.barHeight * Theme.skew + Theme.padM diff --git a/quickshell/bar/Clock.qml b/quickshell/bar/Clock.qml index 0cbf91c..8d8fcbc 100644 --- a/quickshell/bar/Clock.qml +++ b/quickshell/bar/Clock.qml @@ -17,7 +17,7 @@ BarPill { SystemClock { id: clock - precision: SystemClock.Seconds + precision: SystemClock.Minutes } onClicked: calendar.pinned = !calendar.pinned @@ -65,16 +65,6 @@ BarPill { split: 1.4 } - // Seconds, kept quiet so they read as a ticking detail rather than noise. - Text { - anchors.verticalCenter: parent.verticalCenter - anchors.verticalCenterOffset: 4 - text: Qt.formatDateTime(clock.date, "ss") - color: Theme.alpha(Theme.primary, 0.8) - font.family: Theme.fontMono - font.pixelSize: Theme.fsMicro - renderType: Text.NativeRendering - } } // Glitch the time on the hour, because it should feel like something happened. diff --git a/quickshell/bar/MediaPill.qml b/quickshell/bar/MediaPill.qml index 6a2ff02..a45c20f 100644 --- a/quickshell/bar/MediaPill.qml +++ b/quickshell/bar/MediaPill.qml @@ -17,7 +17,7 @@ BarPill { visible: Media.hasPlayer || width > 1 // Track position, drawn by the pill in its own rule band. - progress: Media.canSeek ? Media.progress : -1 + progress: Media.hasTimeline ? Media.progress : -1 implicitWidth: Media.hasPlayer ? layout.implicitWidth + contentPad * 2 @@ -83,8 +83,6 @@ BarPill { text: Media.sourceIcon font.pixelSize: Theme.fsLarge color: Theme.primary - pulsing: Media.playing - pulseScale: 1.12 } } @@ -101,28 +99,33 @@ BarPill { readonly property real cap: 150 - Marquee { + Text { width: Math.min(meta.cap, implicitWidth) height: 14 text: Media.title || Media.identity color: Theme.text - pixelSize: Theme.fsSmall - family: Theme.fontDisplay - weight: Font.DemiBold - italic: true + elide: Text.ElideRight + font.pixelSize: Theme.fsSmall + font.family: Theme.fontDisplay + font.weight: Font.DemiBold + font.italic: true + renderType: Text.NativeRendering } - Marquee { + Text { width: Math.min(meta.cap, implicitWidth) height: 12 text: Media.artist || Media.album || Media.identity color: Theme.muted - pixelSize: Theme.fsMicro - family: Theme.fontMono + elide: Text.ElideRight + font.pixelSize: Theme.fsMicro + font.family: Theme.fontMono + renderType: Text.NativeRendering } } - // Play state, as a two-bar equaliser that freezes when paused. + // Compact play-state equaliser. It moves only while audio is playing + // and freezes into three quiet bars when paused. Row { anchors.verticalCenter: parent.verticalCenter spacing: 2 diff --git a/quickshell/bar/Rail.qml b/quickshell/bar/Rail.qml index 6f62722..67fd5e2 100644 --- a/quickshell/bar/Rail.qml +++ b/quickshell/bar/Rail.qml @@ -40,8 +40,8 @@ PanelWindow { exclusiveZone: Theme.railWidth + Theme.barMargin color: "transparent" - // The slab. - P5Panel { + // The same calm permanent-chrome preset used by the top bar. + ChromeSurface { id: slab anchors.fill: parent vertical: true @@ -49,18 +49,8 @@ PanelWindow { chop: Theme.cut chopCorners: [1, 3] padding: 0 - fill: Theme.surface - fillOpacity: 1.0 - border: Theme.alpha(Theme.outline, 0.9) - borderWidth: 1 - slash: true - slashColor: Theme.accent + active: true slashWidth: 5 - - // Same as the bar — see the note there. The doubled corner is worse on - // the rail, because its chopped corners sit against the wallpaper's - // high-contrast stripes rather than against other chrome. - drop: false } readonly property real edgeInset: Theme.railWidth * Theme.skew + Theme.padM @@ -76,18 +66,21 @@ PanelWindow { RailIcon { id: netIcon icon: Net.icon + label: "Network" + statusText: Net.label + suppressTooltip: netPopout.detailActive dim: !Sys.netUp - onClicked: netPopout.pinned = !netPopout.pinned - onRightClicked: Net.toggleWifi() - onMiddleClicked: Actions.openNetworkSettings() + onClicked: netPopout.togglePinned() } RailIcon { id: btIcon icon: Bt.icon + label: "Bluetooth" + statusText: Bt.label + suppressTooltip: btPopout.detailActive dim: !Bt.enabled - onClicked: btPopout.pinned = !btPopout.pinned - onRightClicked: Bt.toggle() + onClicked: btPopout.togglePinned() } Divider {} @@ -96,19 +89,24 @@ PanelWindow { id: volIcon icon: Audio.icon readout: Audio.muted ? "" : Audio.volumePercent + "%" + label: "Volume" + statusText: Audio.muted ? "Muted" : Audio.volumePercent + "% · " + Audio.sinkName + hint: "Scroll to adjust" + suppressTooltip: audioPopout.detailActive alert: Audio.muted - onClicked: Audio.toggleMute() - onRightClicked: audioPopout.pinned = !audioPopout.pinned - onMiddleClicked: Actions.openAudioSettings() + onClicked: audioPopout.togglePinned() onScrolled: delta => Audio.changeVolume(delta > 0 ? 0.05 : -0.05) } RailIcon { id: micIcon icon: Audio.micIcon + label: "Microphone" + statusText: Audio.micMuted ? "Muted" : Audio.micPercent + "% · " + Audio.sourceName + hint: "Scroll to adjust" + suppressTooltip: audioPopout.detailActive alert: Audio.micMuted - onClicked: Audio.toggleMicMute() - onRightClicked: audioPopout.pinned = !audioPopout.pinned + onClicked: audioPopout.togglePinned() onScrolled: delta => Audio.setMicVolume(Audio.micVolume + (delta > 0 ? 0.05 : -0.05)) } @@ -117,6 +115,9 @@ PanelWindow { visible: Brightness.available icon: Brightness.icon readout: Math.round(Brightness.percent) + "%" + label: "Brightness" + statusText: Math.round(Brightness.percent) + "%" + hint: "Scroll to adjust" onScrolled: delta => Brightness.change(delta > 0 ? 5 : -5) } @@ -127,9 +128,12 @@ PanelWindow { visible: Battery.available icon: Battery.icon readout: Math.round(Battery.percent) + "%" + label: "Battery" + statusText: Math.round(Battery.percent) + "% · " + Battery.timeLabel + suppressTooltip: battPopout.detailActive alert: Battery.critical pulsing: Battery.critical - onClicked: battPopout.pinned = !battPopout.pinned + onClicked: battPopout.togglePinned() } } @@ -151,21 +155,27 @@ PanelWindow { RailIcon { id: bellIcon icon: Notifs.icon + label: "Notifications" + statusText: Notifs.dnd + ? "Do not disturb" + : (Notifs.unread > 0 ? Notifs.unread + " unread" : "All caught up") readout: Notifs.unread > 0 ? (Notifs.unread > 9 ? "9+" : String(Notifs.unread)) : "" dim: Notifs.dnd alert: Notifs.hasUnread && !Notifs.dnd + suppressTooltip: notifPopout.detailActive onClicked: { - notifPopout.pinned = !notifPopout.pinned; + notifPopout.togglePinned(); Notifs.markRead(); } - onRightClicked: Notifs.toggleDnd() } RailIcon { id: powerIcon icon: "󰐥" + label: "Power" + statusText: "Session actions" alert: Actions.powerMenuOpen onClicked: Actions.togglePowerMenu() } diff --git a/quickshell/bar/RailIcon.qml b/quickshell/bar/RailIcon.qml index e5e0825..403d73a 100644 --- a/quickshell/bar/RailIcon.qml +++ b/quickshell/bar/RailIcon.qml @@ -12,6 +12,10 @@ Item { property string icon: "" property string readout: "" + property string label: "" + property string statusText: "" + property string hint: "" + property bool suppressTooltip: false property color tint: Theme.primary property bool alert: false property bool dim: false @@ -24,8 +28,6 @@ Item { readonly property real wipeInset: 5 signal clicked() - signal rightClicked() - signal middleClicked() signal scrolled(real delta) implicitWidth: Theme.railWidth @@ -110,16 +112,17 @@ Item { acceptedButtons: Qt.LeftButton onTapped: root.clicked() } - TapHandler { - acceptedButtons: Qt.RightButton - onTapped: root.rightClicked() - } - TapHandler { - acceptedButtons: Qt.MiddleButton - onTapped: root.middleClicked() - } WheelHandler { acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad onWheel: event => root.scrolled(event.angleDelta.y) } + + P5Tooltip { + anchorItem: root + triggerHovered: root.hovered + title: root.label + status: root.statusText + hint: root.hint + suppressed: root.suppressTooltip + } } diff --git a/quickshell/bar/Resources.qml b/quickshell/bar/Resources.qml index 04a7e12..b58dd7c 100644 --- a/quickshell/bar/Resources.qml +++ b/quickshell/bar/Resources.qml @@ -56,10 +56,8 @@ BarPill { color: Theme.alpha(Theme.outline, 0.8) } - // The CPU/MEM/GPU labels are gone: three characters of chrome per metric, - // repeated three times, in the most cramped part of the shell. The number - // over its meter is legible on its own, and dropping them buys the room to - // set the figure in display italic instead of squeezing it into mono. + // A tiny fixed label makes every number self-describing without turning the + // compact meter cluster back into three separate cards. component Metric: Column { id: metric @@ -70,18 +68,34 @@ BarPill { anchors.verticalCenter: parent.verticalCenter spacing: 2 - Text { - text: metric.readout - color: Theme.heat(metric.value) - font.family: Theme.fontDisplay - font.pixelSize: Theme.fsBody - font.weight: Theme.weightDisplay - font.italic: true - font.letterSpacing: 0.5 - renderType: Text.NativeRendering + Row { + spacing: 4 - Behavior on color { - ColorAnimation { duration: Theme.durBase } + Text { + anchors.baseline: readoutText.baseline + text: metric.label + color: Theme.muted + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.weight: Font.Bold + font.letterSpacing: 0.5 + renderType: Text.NativeRendering + } + + Text { + id: readoutText + text: metric.readout + color: Theme.heat(metric.value) + font.family: Theme.fontDisplay + font.pixelSize: Theme.fsBody + font.weight: Theme.weightDisplay + font.italic: true + font.letterSpacing: 0.5 + renderType: Text.NativeRendering + + Behavior on color { + ColorAnimation { duration: Theme.durBase } + } } } diff --git a/quickshell/components/ChromeSurface.qml b/quickshell/components/ChromeSurface.qml new file mode 100644 index 0000000..b4e6649 --- /dev/null +++ b/quickshell/components/ChromeSurface.qml @@ -0,0 +1,27 @@ +import QtQuick +import "root:/config" +import "root:/components" + +// Calm, permanent-chrome preset. It is deliberately flat and opaque: one +// neutral keyline, no texture, no offset copy, and no accent unless active. +P5Panel { + property bool active: false + property color tone: Theme.treatmentActiveMark + + fill: Theme.treatmentRestFill + fillOpacity: 1.0 + border: Theme.treatmentRestMark + borderWidth: 1 + // Keep the hard ink contour. Removing both texture and keyline made the + // permanent slabs read as generic broadcast HUD chrome. + keyline: true + drop: false + halftone: false + sheen: false + slash: active + slashColor: tone + slashWidth: 4 + lean: 0.06 + chop: Theme.cut + padding: Theme.padS +} diff --git a/quickshell/components/P5Button.qml b/quickshell/components/P5Button.qml index 1aff322..7bab0f2 100644 --- a/quickshell/components/P5Button.qml +++ b/quickshell/components/P5Button.qml @@ -1,15 +1,13 @@ import QtQuick import "root:/config" -// Chopped, leaning button. On hover it lunges toward the pointer and flips to -// crimson-on-white; on press it snaps flat. -Item { +// Chopped, leaning button with one activation path for pointer and keyboard. +FocusScope { id: root property string text: "" property string icon: "" property color accent: Theme.primary - property color hoverFill: Theme.accent property bool destructive: false property bool selected: false property real lean: Theme.skew @@ -18,8 +16,25 @@ Item { signal clicked() - readonly property bool hovered: hover.hovered || root.selected + readonly property bool hovered: root.enabled && hover.hovered readonly property bool pressed: tap.pressed + readonly property bool focusedOrSelected: root.activeFocus || root.selected + readonly property bool highlighted: root.hovered || root.focusedOrSelected + + // Pointer activation must not turn a transient action into the black/white + // keyboard-focus treatment. Tab still reaches it, and programmatic focus + // (power-menu roving selection / pinned popouts) still works. + focusPolicy: root.enabled ? Qt.TabFocus : Qt.NoFocus + + function activate(): void { + if (root.enabled) + root.clicked(); + } + + function activateFromKey(event): void { + if (!event.isAutoRepeat) + root.activate(); + } implicitWidth: wide ? 260 : (row.implicitWidth + panel.contentPad * 2) implicitHeight: 44 @@ -29,13 +44,24 @@ Item { anchors.fill: parent lean: root.lean padding: Theme.padS - fill: root.hovered ? (root.destructive ? root.hoverFill : Theme.text) : Theme.surface - fillOpacity: root.hovered ? 1.0 : 0.9 - border: root.hovered ? "transparent" : Theme.alpha(root.accent, 0.55) - slash: !root.hovered - slashColor: root.destructive ? Theme.accent : root.accent - halftone: root.hovered - halftoneColor: root.destructive ? Theme.text : Theme.base + fill: { + if (!root.enabled) return Theme.treatmentDisabledFill; + if (root.destructive) return Theme.treatmentDestructiveFill; + if (root.highlighted) return Theme.treatmentHoverFill; + return Theme.treatmentRestFill; + } + fillOpacity: root.enabled ? 1.0 : 0.72 + border: { + if (!root.enabled) return Theme.treatmentDisabledMark; + if (root.destructive) return Theme.treatmentFocusMark; + if (root.highlighted) return Theme.treatmentHoverMark; + return Theme.alpha(root.accent, 0.55); + } + borderWidth: Theme.stroke + slash: !root.highlighted + slashColor: root.destructive ? Theme.treatmentDestructiveMark : root.accent + halftone: root.enabled && root.highlighted + halftoneColor: Theme.ink halftoneOpacity: 0.14 Behavior on fillOpacity { @@ -53,17 +79,23 @@ Item { visible: root.icon !== "" text: root.icon font.pixelSize: Theme.fsTitle - color: root.hovered - ? (root.destructive ? Theme.text : Theme.base) - : root.accent + color: { + if (!root.enabled) return Theme.treatmentDisabledText; + if (root.destructive) return Theme.treatmentDestructiveText; + if (root.highlighted) return Theme.treatmentHoverText; + return root.accent; + } } Text { anchors.verticalCenter: parent.verticalCenter text: root.text - color: root.hovered - ? (root.destructive ? Theme.text : Theme.base) - : Theme.text + color: { + if (!root.enabled) return Theme.treatmentDisabledText; + if (root.destructive) return Theme.treatmentDestructiveText; + if (root.highlighted) return Theme.treatmentHoverText; + return Theme.treatmentRestText; + } font.family: Theme.fontDisplay font.pixelSize: Theme.fsBody font.weight: Theme.weightDisplay @@ -80,7 +112,7 @@ Item { } transform: Translate { - x: root.pressed ? 0 : (root.hovered ? root.lunge : 0) + x: root.pressed ? 0 : (root.highlighted ? root.lunge : 0) Behavior on x { NumberAnimation { @@ -97,11 +129,17 @@ Item { HoverHandler { id: hover + enabled: root.enabled cursorShape: Qt.PointingHandCursor } TapHandler { id: tap - onTapped: root.clicked() + enabled: root.enabled + onTapped: root.activate() } + + Keys.onReturnPressed: event => root.activateFromKey(event) + Keys.onEnterPressed: event => root.activateFromKey(event) + Keys.onSpacePressed: event => root.activateFromKey(event) } diff --git a/quickshell/components/P5Slider.qml b/quickshell/components/P5Slider.qml index 4b1d8e4..d989cf6 100644 --- a/quickshell/components/P5Slider.qml +++ b/quickshell/components/P5Slider.qml @@ -8,23 +8,38 @@ Item { property real value: 0 // 0..1 property color accent: Theme.primary - property bool enabledControl: true + property real stepSize: 0.05 signal moved(real value) implicitHeight: 18 implicitWidth: 200 + focusPolicy: root.enabled ? Qt.StrongFocus : Qt.NoFocus readonly property real _lean: Theme.skew * height + function commit(nextValue: real): void { + if (!root.enabled) + return; + root.moved(Math.max(0, Math.min(1, nextValue))); + } + // Rail Skew { id: rail anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.right: parent.right - height: 6 - color: Theme.alpha(Theme.outline, 0.7) + height: root.activeFocus ? 10 : 6 + color: root.activeFocus + ? Theme.treatmentFocusFill + : Theme.alpha(root.enabled ? Theme.outline : Theme.treatmentDisabledMark, 0.7) + borderColor: root.activeFocus ? Theme.treatmentFocusMark : "transparent" + borderWidth: root.activeFocus ? Theme.stroke : 0 + + Behavior on height { + NumberAnimation { duration: Theme.durFast } + } } // Fill @@ -33,7 +48,7 @@ Item { anchors.left: parent.left width: Math.max(0, Math.min(1, root.value)) * root.width height: 6 - color: root.enabledControl ? root.accent : Theme.muted + color: root.enabled ? root.accent : Theme.treatmentDisabledText Behavior on width { enabled: !drag.active @@ -49,8 +64,8 @@ Item { Skew { id: handle width: 6 - height: hover.hovered || drag.active ? 18 : 14 - color: root.enabledControl ? Theme.text : Theme.muted + height: root.activeFocus || hover.hovered || drag.active ? 18 : 14 + color: root.enabled ? Theme.text : Theme.treatmentDisabledText anchors.verticalCenter: parent.verticalCenter x: Math.max(0, Math.min(1, root.value)) * root.width - width / 2 @@ -65,21 +80,46 @@ Item { HoverHandler { id: hover + enabled: root.enabled cursorShape: Qt.PointingHandCursor } TapHandler { - onTapped: event => root.moved(Math.max(0, Math.min(1, event.position.x / root.width))) + enabled: root.enabled + onPressedChanged: if (pressed) root.forceActiveFocus(Qt.MouseFocusReason) + onTapped: event => root.commit(event.position.x / root.width) } DragHandler { id: drag + enabled: root.enabled target: null xAxis.enabled: true yAxis.enabled: false onCentroidChanged: { if (!active) return; - root.moved(Math.max(0, Math.min(1, centroid.position.x / root.width))); + root.forceActiveFocus(Qt.MouseFocusReason); + root.commit(centroid.position.x / root.width); } } + + Keys.onPressed: event => { + switch (event.key) { + case Qt.Key_Left: + root.commit(root.value - root.stepSize); + break; + case Qt.Key_Right: + root.commit(root.value + root.stepSize); + break; + case Qt.Key_Home: + root.commit(0); + break; + case Qt.Key_End: + root.commit(1); + break; + default: + return; + } + event.accepted = true; + } } diff --git a/quickshell/components/P5Tooltip.qml b/quickshell/components/P5Tooltip.qml new file mode 100644 index 0000000..ea9a4c8 --- /dev/null +++ b/quickshell/components/P5Tooltip.qml @@ -0,0 +1,114 @@ +import QtQuick +import Quickshell +import "root:/config" + +// Calm, informational hover label for permanent chrome. It never takes focus +// or participates in popup coordination; a delayed label should not dismiss or +// displace the detail panel attached to the same rail control. +PopupWindow { + id: root + + required property Item anchorItem + property bool triggerHovered: false + property string title: "" + property string status: "" + property string hint: "" + property int delay: 400 + property bool suppressed: false + + readonly property int pad: Theme.padS + readonly property real textWidth: Math.max(titleText.implicitWidth, + statusText.implicitWidth, hintText.implicitWidth) + readonly property real contentWidth: Math.max(112, textWidth + pad * 4) + readonly property real contentHeight: textColumn.implicitHeight + pad * 2 + + anchor.item: anchorItem + anchor.edges: Edges.Left + anchor.gravity: Edges.Left + anchor.adjustment: PopupAdjustment.Flip | PopupAdjustment.SlideY + anchor.margins.left: Theme.popoutGap + + implicitWidth: contentWidth + implicitHeight: contentHeight + color: "transparent" + visible: shown && title !== "" && !suppressed + + property bool shown: false + + onTriggerHoveredChanged: { + if (triggerHovered && title !== "" && !suppressed) { + hideTimer.stop(); + showTimer.restart(); + } else { + showTimer.stop(); + hideTimer.restart(); + } + } + + onSuppressedChanged: { + if (suppressed) { + showTimer.stop(); + shown = false; + } else if (triggerHovered) { + showTimer.restart(); + } + } + + Timer { + id: showTimer + interval: root.delay + onTriggered: root.shown = root.triggerHovered && !root.suppressed + } + + Timer { + id: hideTimer + interval: 70 + onTriggered: root.shown = false + } + + ChromeSurface { + anchors.fill: parent + padding: root.pad + lean: 0.06 + + Column { + id: textColumn + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Text { + id: titleText + text: root.title.toUpperCase() + color: Theme.text + font.family: Theme.fontDisplay + font.pixelSize: Theme.fsSmall + font.weight: Theme.weightDisplay + font.italic: true + renderType: Text.NativeRendering + } + + Text { + id: statusText + visible: text !== "" + text: root.status + color: Theme.subtext + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + renderType: Text.NativeRendering + } + + Text { + id: hintText + visible: text !== "" + text: root.hint.toUpperCase() + color: Theme.accent + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.weight: Font.Bold + font.letterSpacing: 0.8 + renderType: Text.NativeRendering + } + } + } +} diff --git a/quickshell/components/Popout.qml b/quickshell/components/Popout.qml index b112fc7..321b8d0 100644 --- a/quickshell/components/Popout.qml +++ b/quickshell/components/Popout.qml @@ -1,6 +1,5 @@ import QtQuick import Quickshell -import Quickshell.Hyprland import "root:/config" // Hover-driven panel that hangs off a bar item. Opens on a short dwell, stays @@ -17,6 +16,8 @@ PopupWindow { property int fromEdge: Edges.Bottom property real contentWidth: Theme.popoutWidth property real contentHeight: 200 + property Item initialFocusItem: null + property bool remappingPinned: false // Extra surface around the content so the entry overshoot and glow are not // clipped by the window edge. @@ -25,6 +26,8 @@ PopupWindow { default property alias body: contentHolder.data readonly property bool wantsOpen: triggerHovered || panelHover.hovered || pinned + readonly property bool pending: showTimer.running || remapTimer.running || remappingPinned + readonly property bool detailActive: shown || pending signal dismissedFully() @@ -52,13 +55,15 @@ PopupWindow { implicitHeight: contentHeight + bleed * 2 color: "transparent" visible: shown + grabFocus: pinned && shown property bool shown: false onWantsOpenChanged: { if (wantsOpen) { hideTimer.stop(); - showTimer.restart(); + if (!pinned && !remappingPinned) + showTimer.restart(); } else { showTimer.stop(); hideTimer.restart(); @@ -71,29 +76,17 @@ PopupWindow { onTriggered: root.open() } - // A pinned popout ignores hover by design, which left it with exactly one - // way out: another click on the same bar item that opened it. Moving the - // pointer away did nothing, so a panel opened by a click — the notification - // history, most often — simply stayed on screen. Click anywhere else and - // the compositor drops the grab, which unpins it and hands it back to the - // hover rules. - // - // The grab can only take hold once the surface is mapped; asked for in the - // same tick as `shown`, Hyprland clears it immediately. - Timer { - id: grabDelay - interval: 60 - running: root.pinned && root.shown + onVisibleChanged: { + if (!visible && shown) + dismissImmediately(); } - - HyprlandFocusGrab { - active: root.pinned && root.shown && !grabDelay.running - windows: [root] - - // A click on the trigger itself also clears the grab, but that click is - // the pill's own toggle — unpinning here as well would cancel it out and - // the panel would never close from its own button. - onCleared: if (!root.triggerHovered) root.pinned = false + onClosed: { + if (remappingPinned) { + remappingPinned = false; + remapTimer.restart(); + } else if (shown || pinned) { + dismissImmediately(); + } } Timer { @@ -109,14 +102,85 @@ PopupWindow { enterAnim.restart(); } + // Focus policy is fixed at map time. A hover-visible passive surface must + // therefore unmap before returning as a click-owned keyboard popup. + function openPinned() { + showTimer.stop(); + hideTimer.stop(); + PopupCoordinator.claim(root); + if (shown) { + remappingPinned = true; + shown = false; + enterAnim.stop(); + exitAnim.stop(); + pinned = true; + } else { + pinned = true; + open(); + focusTimer.restart(); + } + } + + function togglePinned() { + if (pinned) { + pinned = false; + dismiss(); + } else { + openPinned(); + } + } + function dismiss() { - if (!shown) return; + if (!shown) { + if (pinned || pending) + dismissImmediately(); + return; + } + pinned = false; + PopupCoordinator.release(root); enterAnim.stop(); exitAnim.restart(); } + function dismissImmediately() { + showTimer.stop(); + hideTimer.stop(); + remapTimer.stop(); + focusTimer.stop(); + enterAnim.stop(); + exitAnim.stop(); + remappingPinned = false; + pinned = false; + shown = false; + contentHolder.opacity = 0; + contentHolder.scale = 0.94; + contentHolder.x = root.bleed; + contentHolder.y = root.bleed; + PopupCoordinator.release(root); + root.dismissedFully(); + } + + Timer { + id: remapTimer + interval: 0 + onTriggered: { + root.open(); + focusTimer.restart(); + } + } + + Timer { + id: focusTimer + interval: 0 + onTriggered: { + const target = root.initialFocusItem || contentHolder; + target.forceActiveFocus(Qt.TabFocusReason); + } + } + Item { id: contentHolder + focus: root.pinned x: root.bleed y: root.bleed width: root.contentWidth @@ -145,6 +209,13 @@ PopupWindow { id: panelHover enabled: root.shown } + + Keys.onEscapePressed: event => { + if (root.pinned) { + event.accepted = true; + root.dismissImmediately(); + } + } } ParallelAnimation { diff --git a/quickshell/config/PopupCoordinator.qml b/quickshell/config/PopupCoordinator.qml index 1ea22e6..1f0418a 100644 --- a/quickshell/config/PopupCoordinator.qml +++ b/quickshell/config/PopupCoordinator.qml @@ -10,8 +10,11 @@ Singleton { property var active: null function claim(surface: var) { - if (active && active !== surface) - active.dismiss(); + if (active && active !== surface) { + const previous = active; + active = null; + previous.dismissImmediately(); + } active = surface; } diff --git a/quickshell/config/Theme.qml b/quickshell/config/Theme.qml index 9d58668..1a8a03b 100644 --- a/quickshell/config/Theme.qml +++ b/quickshell/config/Theme.qml @@ -54,6 +54,46 @@ Singleton { // resolves to crimson. Pointing at a thing lights it up red. readonly property color glow: root.accent + // ------------------------------------------------ semantic treatments + // These tokens describe interaction meaning, not new palette entries. Each + // state is composed only from Takemi's black, white, crimson, and neutral + // surfaces so callers do not invent one-off state colors. + readonly property color treatmentRestFill: root.surface + readonly property color treatmentRestMark: root.outline + readonly property color treatmentRestText: root.text + + readonly property color treatmentHoverFill: root.surface + readonly property color treatmentHoverMark: root.accent + readonly property color treatmentHoverText: root.text + + readonly property color treatmentFocusFill: root.text + readonly property color treatmentFocusMark: root.ink + readonly property color treatmentFocusText: root.ink + readonly property color treatmentSelectedFill: root.treatmentFocusFill + readonly property color treatmentSelectedMark: root.treatmentFocusMark + readonly property color treatmentSelectedText: root.treatmentFocusText + + readonly property color treatmentActiveFill: root.surface + readonly property color treatmentActiveMark: root.accent + readonly property color treatmentActiveText: root.text + + // Crimson carries destructive/error meaning. Text and keylines stay black: + // black-on-crimson is 5.70:1, while small white-on-crimson is only 3.68:1. + readonly property color treatmentDestructiveFill: root.accent + readonly property color treatmentDestructiveMark: root.ink + readonly property color treatmentDestructiveText: root.ink + readonly property color treatmentErrorFill: root.treatmentDestructiveFill + readonly property color treatmentErrorMark: root.treatmentDestructiveMark + readonly property color treatmentErrorText: root.treatmentDestructiveText + + readonly property color treatmentUrgentFill: root.surface + readonly property color treatmentUrgentMark: root.accent + readonly property color treatmentUrgentText: root.text + + readonly property color treatmentDisabledFill: root.surface + readonly property color treatmentDisabledMark: root.outline + readonly property color treatmentDisabledText: root.muted + // Retired hues. Aliases only — do not introduce new uses. // // `warn` used to be accentSoft, which put a washed-out pink-red on screen diff --git a/quickshell/overlays/PowerMenu.qml b/quickshell/overlays/PowerMenu.qml index a641dab..955a08b 100644 --- a/quickshell/overlays/PowerMenu.qml +++ b/quickshell/overlays/PowerMenu.qml @@ -28,6 +28,18 @@ PanelWindow { readonly property bool open: Actions.powerMenuOpen property int selectedAction: 0 + function focusSelectedAction(): void { + const entry = buttonRepeater.itemAt(root.selectedAction); + if (entry && entry.children.length > 0) + entry.children[0].forceActiveFocus(Qt.TabFocusReason); + } + + function moveSelection(delta: int): void { + root.selectedAction = (root.selectedAction + delta + root.actionModel.length) + % root.actionModel.length; + root.focusSelectedAction(); + } + readonly property var actionModel: [ { label: "Lock", icon: "󰌾", danger: false, act: "lock" }, { label: "Suspend", icon: "󰤄", danger: false, act: "suspend" }, @@ -160,6 +172,7 @@ PanelWindow { ] P5Button { + id: actionButton anchors.fill: parent text: entry.modelData.label icon: entry.modelData.icon @@ -170,6 +183,8 @@ PanelWindow { lunge: -10 onClicked: root.invoke(entry.modelData.act) onHoveredChanged: if (hovered) root.selectedAction = entry.index + onActiveFocusChanged: if (actionButton.activeFocus) root.selectedAction = entry.index + Keys.forwardTo: [keyController] } // Staggered entry, one button after another. @@ -241,20 +256,22 @@ PanelWindow { // Escape closes. The layer takes exclusive focus while open. Item { + id: keyController anchors.fill: parent focus: root.open Keys.onEscapePressed: Actions.closePowerMenu() Keys.onPressed: event => { if (!root.open) return; if (event.key === Qt.Key_Up || event.key === Qt.Key_K) { - root.selectedAction = (root.selectedAction + root.actionModel.length - 1) - % root.actionModel.length; + root.moveSelection(-1); event.accepted = true; } else if (event.key === Qt.Key_Down || event.key === Qt.Key_J) { - root.selectedAction = (root.selectedAction + 1) % root.actionModel.length; + root.moveSelection(1); event.accepted = true; - } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter - || event.key === Qt.Key_Space) { + } else if (keyController.activeFocus + && !event.isAutoRepeat + && (event.key === Qt.Key_Return || event.key === Qt.Key_Enter + || event.key === Qt.Key_Space)) { root.invoke(root.actionModel[root.selectedAction].act); event.accepted = true; } diff --git a/quickshell/popouts/AudioInputBand.qml b/quickshell/popouts/AudioInputBand.qml new file mode 100644 index 0000000..e260893 --- /dev/null +++ b/quickshell/popouts/AudioInputBand.qml @@ -0,0 +1,72 @@ +import QtQuick +import "root:/config" +import "root:/components" +import "root:/services" + +// Secondary input band with an explicit mute action. +Item { + id: root + + implicitHeight: 68 + + Row { + anchors.left: parent.left + anchors.right: muteButton.left + anchors.rightMargin: Theme.padM + anchors.verticalCenter: muteButton.verticalCenter + spacing: Theme.padS + + Icon { + anchors.verticalCenter: parent.verticalCenter + text: Audio.micIcon + color: Audio.micMuted ? Theme.accent : Theme.text + font.pixelSize: Theme.fsLarge + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - Theme.fsLarge - Theme.padS + spacing: 2 + + Text { + width: parent.width + text: Audio.sourceName + elide: Text.ElideRight + color: Theme.text + font.family: Theme.fontDisplay + font.pixelSize: Theme.fsSmall + font.weight: Font.DemiBold + font.italic: true + renderType: Text.NativeRendering + } + + Text { + text: Audio.micMuted ? "MUTED" : Audio.micPercent + "%" + color: Audio.micMuted ? Theme.accent : Theme.subtext + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.weight: Font.Bold + renderType: Text.NativeRendering + } + } + } + + P5Button { + id: muteButton + anchors.right: parent.right + anchors.top: parent.top + text: Audio.micMuted ? "Unmute input" : "Mute input" + icon: Audio.micIcon + onClicked: Audio.toggleMicMute() + } + + P5Slider { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + value: Audio.micVolume + accent: Theme.primary + enabled: !Audio.micMuted + onMoved: v => Audio.setMicVolume(v) + } +} diff --git a/quickshell/popouts/AudioOutputHero.qml b/quickshell/popouts/AudioOutputHero.qml new file mode 100644 index 0000000..c4b1bda --- /dev/null +++ b/quickshell/popouts/AudioOutputHero.qml @@ -0,0 +1,71 @@ +import QtQuick +import "root:/config" +import "root:/components" +import "root:/services" + +// Dominant current-output readout for AudioPopout. +Item { + implicitHeight: 112 + + Text { + id: outputPercent + anchors.left: parent.left + anchors.top: parent.top + text: Audio.volumePercent + "%" + color: Audio.muted ? Theme.muted : Theme.text + font.family: Theme.fontDisplay + font.pixelSize: Theme.fsHuge + font.weight: Theme.weightDisplay + font.italic: true + renderType: Text.NativeRendering + } + + Column { + anchors.left: outputPercent.right + anchors.leftMargin: Theme.padL + anchors.right: parent.right + anchors.verticalCenter: outputPercent.verticalCenter + spacing: 3 + + Text { + text: "CURRENT OUTPUT" + color: Theme.accent + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.weight: Font.Bold + font.letterSpacing: 2 + renderType: Text.NativeRendering + } + + Text { + width: parent.width + text: Audio.sinkName + elide: Text.ElideRight + color: Theme.subtext + font.family: Theme.fontDisplay + font.pixelSize: Theme.fsSmall + font.weight: Font.DemiBold + font.italic: true + renderType: Text.NativeRendering + } + + Text { + text: Audio.muted ? "MUTED" : "LIVE" + color: Audio.muted ? Theme.accent : Theme.text + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.weight: Font.Bold + renderType: Text.NativeRendering + } + } + + P5Slider { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + value: Audio.volume + accent: Theme.accent + enabled: !Audio.muted + onMoved: v => Audio.setVolume(v) + } +} diff --git a/quickshell/popouts/AudioPopout.qml b/quickshell/popouts/AudioPopout.qml index 3313092..4debbcf 100644 --- a/quickshell/popouts/AudioPopout.qml +++ b/quickshell/popouts/AudioPopout.qml @@ -5,12 +5,15 @@ import "root:/config" import "root:/components" import "root:/services" -// Output and input levels, the sink picker, and a per-app mixer. +// Audio is the expressive vertical slice for rail popouts: output owns the +// composition, input is a clear secondary band, and application streams recede +// into a compact mixer below both device controls. Popout { id: root - contentWidth: 360 + contentWidth: 400 contentHeight: surface.implicitContentHeight + Theme.padM * 2 + initialFocusItem: outputMuteButton PopoutSurface { id: surface @@ -39,29 +42,38 @@ Popout { spacing: Theme.padM // ------------------------------------------------------- output - LevelRow { + AudioOutputHero { width: parent.width - icon: Audio.icon - label: "Output" - readout: Audio.muted ? "MUTED" : Audio.volumePercent + "%" - value: Audio.volume - muted: Audio.muted - accent: Theme.primary - onMoved: v => Audio.setVolume(v) - onToggled: Audio.toggleMute() + } + + Column { + width: parent.width + spacing: Theme.padS + + P5Button { + id: outputMuteButton + width: parent.width + text: Audio.muted ? "Unmute output" : "Mute output" + icon: Audio.icon + onClicked: Audio.toggleMute() + } + + P5Button { + width: parent.width + text: "Open audio mixer" + icon: "󰕾" + onClicked: Actions.openAudioSettings() + } } // -------------------------------------------------------- input - LevelRow { + SectionRule { + width: parent.width + text: "Input" + } + + AudioInputBand { width: parent.width - icon: Audio.micIcon - label: "Input" - readout: Audio.micMuted ? "MUTED" : Audio.micPercent + "%" - value: Audio.micVolume - muted: Audio.micMuted - accent: Theme.blue - onMoved: v => Audio.setMicVolume(v) - onToggled: Audio.toggleMicMute() } SectionRule { @@ -98,19 +110,14 @@ Popout { icon: "󰝚" label: Audio.streamName(modelData) readout: modelData.audio ? Math.round(modelData.audio.volume * 100) + "%" : "" - value: modelData.audio?.volume ?? 0 - muted: modelData.audio?.muted ?? false + value: modelData.audio ? modelData.audio.volume : 0 + muted: modelData.audio ? modelData.audio.muted : false accent: Theme.glow onMoved: v => { if (modelData.audio) modelData.audio.volume = v; } onToggled: { if (modelData.audio) modelData.audio.muted = !modelData.audio.muted; } } } - P5Button { - text: "Mixer" - icon: "󰕾" - onClicked: Actions.openAudioSettings() - } } } @@ -178,7 +185,7 @@ Popout { anchors.right: parent.right value: levelRow.value accent: levelRow.accent - enabledControl: !levelRow.muted + enabled: !levelRow.muted onMoved: v => levelRow.moved(v) } } diff --git a/quickshell/popouts/MediaPopout.qml b/quickshell/popouts/MediaPopout.qml index 4ab0ca2..a1aaaf6 100644 --- a/quickshell/popouts/MediaPopout.qml +++ b/quickshell/popouts/MediaPopout.qml @@ -155,26 +155,26 @@ Popout { TransportButton { glyph: "󰒮" - enabledControl: Media.canGoPrevious + enabled: Media.canGoPrevious onClicked: Media.previous() } TransportButton { glyph: Media.playing ? "󰏤" : "󰐊" primary: true - enabledControl: Media.hasPlayer + enabled: Media.hasPlayer onClicked: Media.playPause() } TransportButton { glyph: "󰒭" - enabledControl: Media.canGoNext + enabled: Media.canGoNext onClicked: Media.next() } TransportButton { glyph: "󰐒" - enabledControl: Media.hasPlayer + enabled: Media.hasPlayer onClicked: Media.raise() } } @@ -196,7 +196,7 @@ Popout { id: elapsed anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - text: Media.timeString(Media.position) + text: Media.positionSupported ? Media.timeString(Media.position) : "--:--" color: Theme.subtext font.family: Theme.fontMono font.pixelSize: Theme.fsMicro @@ -207,7 +207,7 @@ Popout { id: total anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - text: Media.timeString(Media.length) + text: Media.lengthSupported ? Media.timeString(Media.length) : "LIVE" color: Theme.subtext font.family: Theme.fontMono font.pixelSize: Theme.fsMicro @@ -222,7 +222,7 @@ Popout { anchors.verticalCenter: parent.verticalCenter value: Media.progress accent: Theme.accent - enabledControl: Media.canSeek + enabled: Media.canSeek onMoved: v => Media.seekTo(v) } } @@ -238,7 +238,7 @@ Popout { Repeater { model: Media.players - Item { + FocusScope { id: chip required property var modelData @@ -246,21 +246,44 @@ Popout { implicitWidth: chipLabel.implicitWidth + Theme.padM implicitHeight: 22 + focusPolicy: chip.enabled ? Qt.TabFocus : Qt.NoFocus + + function activate(): void { + if (chip.enabled) + Media.select(chip.modelData); + } + + function activateFromKey(event): void { + if (!event.isAutoRepeat) + chip.activate(); + } Skew { anchors.fill: parent - color: chip.current - ? Theme.alpha(Theme.primary, 0.22) - : (chipHover.hovered ? Theme.alpha(Theme.primary, 0.1) : "transparent") - borderWidth: 1 - borderColor: chip.current ? Theme.primary : Theme.alpha(Theme.outline, 0.7) + color: !chip.enabled + ? Theme.treatmentDisabledFill + : (chip.activeFocus + ? Theme.treatmentFocusFill + : (chip.current + ? Theme.alpha(Theme.primary, 0.22) + : (chipHover.hovered ? Theme.alpha(Theme.primary, 0.1) : "transparent"))) + borderWidth: chip.activeFocus ? Theme.stroke : 1 + borderColor: !chip.enabled + ? Theme.treatmentDisabledMark + : (chip.activeFocus + ? Theme.treatmentFocusMark + : (chip.current ? Theme.primary : Theme.alpha(Theme.outline, 0.7))) } Text { id: chipLabel anchors.centerIn: parent text: chip.modelData.identity - color: chip.current ? Theme.text : Theme.muted + color: !chip.enabled + ? Theme.treatmentDisabledText + : (chip.activeFocus + ? Theme.treatmentFocusText + : (chip.current ? Theme.text : Theme.muted)) font.family: Theme.fontMono font.pixelSize: Theme.fsMicro renderType: Text.NativeRendering @@ -268,39 +291,57 @@ Popout { HoverHandler { id: chipHover + enabled: chip.enabled cursorShape: Qt.PointingHandCursor } TapHandler { - onTapped: Media.select(chip.modelData) + enabled: chip.enabled + onTapped: chip.activate() } + + Keys.onReturnPressed: event => chip.activateFromKey(event) + Keys.onEnterPressed: event => chip.activateFromKey(event) + Keys.onSpacePressed: event => chip.activateFromKey(event) } } } } - component TransportButton: Item { + component TransportButton: FocusScope { id: tb property string glyph: "" property bool primary: false - property bool enabledControl: true - signal clicked() implicitWidth: primary ? 38 : 30 implicitHeight: primary ? 38 : 30 + focusPolicy: tb.enabled ? Qt.TabFocus : Qt.NoFocus + + function activate(): void { + if (tb.enabled) + tb.clicked(); + } + + function activateFromKey(event): void { + if (!event.isAutoRepeat) + tb.activate(); + } Skew { anchors.fill: parent color: { - if (!tb.enabledControl) return "transparent"; + if (!tb.enabled) return "transparent"; + if (tb.activeFocus) return Theme.treatmentFocusFill; if (tbHover.hovered) return tb.primary ? Theme.accent : Theme.alpha(Theme.primary, 0.22); return tb.primary ? Theme.alpha(Theme.primary, 0.18) : "transparent"; } - borderWidth: tb.primary ? 2 : 1 - borderColor: tb.enabledControl - ? (tbHover.hovered ? Theme.text : Theme.alpha(Theme.primary, 0.6)) + borderWidth: tb.activeFocus ? Theme.stroke * 2 : (tb.primary ? 2 : 1) + borderColor: tb.enabled + ? (tb.activeFocus + ? Theme.treatmentFocusMark + : (tbHover.hovered ? Theme.text : Theme.alpha(Theme.primary, 0.6))) : Theme.alpha(Theme.outline, 0.4) Behavior on color { @@ -313,26 +354,31 @@ Popout { text: tb.glyph font.pixelSize: tb.primary ? Theme.fsTitle : Theme.fsLarge color: { - if (!tb.enabledControl) return Theme.alpha(Theme.muted, 0.5); + if (!tb.enabled) return Theme.alpha(Theme.muted, 0.5); + if (tb.activeFocus) return Theme.treatmentFocusText; if (tbHover.hovered && tb.primary) return Theme.text; return Theme.primary; } } - scale: tbHover.hovered && tb.enabledControl ? 1.12 : 1.0 + scale: tbHover.hovered && tb.enabled ? 1.12 : 1.0 Behavior on scale { NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack } } HoverHandler { id: tbHover - enabled: tb.enabledControl + enabled: tb.enabled cursorShape: Qt.PointingHandCursor } TapHandler { - enabled: tb.enabledControl - onTapped: tb.clicked() + enabled: tb.enabled + onTapped: tb.activate() } + + Keys.onReturnPressed: event => tb.activateFromKey(event) + Keys.onEnterPressed: event => tb.activateFromKey(event) + Keys.onSpacePressed: event => tb.activateFromKey(event) } } diff --git a/quickshell/popouts/PopoutSurface.qml b/quickshell/popouts/PopoutSurface.qml index 00eaa2d..9ba9d5b 100644 --- a/quickshell/popouts/PopoutSurface.qml +++ b/quickshell/popouts/PopoutSurface.qml @@ -6,12 +6,13 @@ import "root:/components" // own, so P5Panel's default content property stays usable by callers. P5Panel { property color tone: Theme.primary + property bool textured: false fill: Theme.mantle fillOpacity: 0.97 border: Theme.alpha(tone, 0.75) borderWidth: Theme.stroke - halftone: true + halftone: textured halftoneColor: tone halftoneOpacity: 0.06 sheen: true diff --git a/quickshell/popouts/TrayMenu.qml b/quickshell/popouts/TrayMenu.qml index 7e42b65..49005af 100644 --- a/quickshell/popouts/TrayMenu.qml +++ b/quickshell/popouts/TrayMenu.qml @@ -74,6 +74,16 @@ PopupWindow { close(); } + function dismissImmediately() { + openAnim.stop(); + closeAnim.stop(); + shown = false; + holder.opacity = 0; + holder.scale = 0.94; + holder.x = root.bleed; + PopupCoordinator.release(root); + } + // The grab can only take hold once the popup is actually mapped. Asked for // in the same tick as `shown`, Hyprland has no surface to hand it and // clears it immediately — which closed the menu the instant it opened. diff --git a/quickshell/services/Media.qml b/quickshell/services/Media.qml index c57ddfb..73e3e64 100644 --- a/quickshell/services/Media.qml +++ b/quickshell/services/Media.qml @@ -34,11 +34,14 @@ Singleton { readonly property bool canGoNext: active?.canGoNext ?? false readonly property bool canGoPrevious: active?.canGoPrevious ?? false - readonly property bool canSeek: (active?.canSeek ?? false) && (active?.lengthSupported ?? false) + readonly property bool positionSupported: active?.positionSupported ?? false + readonly property bool lengthSupported: active?.lengthSupported ?? false + readonly property bool hasTimeline: positionSupported && lengthSupported && length > 0 + readonly property bool canSeek: (active?.canSeek ?? false) && hasTimeline - readonly property real length: active?.lengthSupported ? (active?.length ?? 0) : 0 - readonly property real position: active?.positionSupported ? (active?.position ?? 0) : 0 - readonly property real progress: length > 0 ? Math.min(1, position / length) : 0 + readonly property real length: lengthSupported ? (active?.length ?? 0) : 0 + readonly property real position: positionSupported ? (active?.position ?? 0) : 0 + readonly property real progress: hasTimeline ? Math.min(1, position / length) : 0 readonly property string label: { if (!hasPlayer) return "Nothing playing"; @@ -53,8 +56,8 @@ Singleton { // MPRIS position is pull-only; tick it so the seek bar moves. Timer { - interval: 500 - running: root.playing && root.canSeek + interval: 1000 + running: root.playing && root.positionSupported repeat: true onTriggered: if (root.active) root.active.positionChanged() } @@ -85,9 +88,11 @@ Singleton { return "󰝚"; } - function timeString(micros: real): string { - if (!(micros > 0)) return "0:00"; - const total = Math.floor(micros / 1000000); + // Quickshell exposes MPRIS position/length in seconds (with millisecond + // precision), not the protocol's raw microseconds. + function timeString(seconds: real): string { + if (!(seconds > 0)) return "0:00"; + const total = Math.floor(seconds); const m = Math.floor(total / 60); const s = total % 60; if (m >= 60) {