improvements

This commit is contained in:
2026-08-11 17:35:26 +02:00
parent 39f925cb58
commit a868f50ab7
24 changed files with 924 additions and 239 deletions
+39
View File
@@ -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 `P5Panel`, session-lock security, and current functional coverage. This plan changes
visual hierarchy and interaction clarity, not the desktop stack. 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 ## Non-goals
- Do not replace Quickshell, Hyprland, Fluxo, PAM, or the service layer. - Do not replace Quickshell, Hyprland, Fluxo, PAM, or the service layer.
+27 -11
View File
@@ -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, - **rail** — one slab: network, bluetooth, audio, mic, backlight, battery, tray,
notifications, power. Popouts open leftward, into the screen. notifications, power. Popouts open leftward, into the screen.
Each surface is a single continuous `P5Panel` with modules divided by sheared Each permanent surface is a single continuous `ChromeSurface` with modules
hairlines. `BarPill` is chromeless by default — a module is *content inside* a divided by sheared hairlines. `BarPill` is chromeless by default — a module is
surface, not a surface of its own — and marks hover with a crimson underline. *content inside* a surface, not a surface of its own — and marks hover with a
Pass `standalone: true` for the rare thing that really is its own panel. 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 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 | | | | | Sigil | bar | app menu | reload shell | | | |
| Workspaces | bar | switch | | | | | | Workspaces | bar | switch | | | | |
| Clock | bar | pin calendar | | | | calendar | | Clock | bar | pin calendar | | | | calendar |
| Network | rail | pin panel | toggle Wi-Fi | nm-connection-editor | | networks | | Network | rail | pin details | | | | state + networks |
| Bluetooth | rail | pin panel | toggle adapter | | | devices | | Bluetooth | rail | pin details | | | | state + devices |
| Volume | rail | mute | pin panel | pavucontrol | volume | audio mixer | | Volume | rail | pin audio | | | volume | state + scroll hint + audio |
| Mic | rail | mute | pin panel | | mic volume | audio mixer | | Mic | rail | pin audio | | | mic volume | state + scroll hint + audio |
| Brightness | rail | | | | brightness | | | Brightness | rail | | | | brightness | state + scroll hint |
| Battery | rail | pin panel | | | | power detail | | Battery | rail | pin panel | | | | power detail |
| Tray | rail | activate | app menu | secondary | | | | 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 | | | | | | Power | rail | power menu | | | | |
| Media | bar | play/pause | next | raise player | next/prev | full player | | Media | bar | play/pause | next | raise player | next/prev | full player |
| CPU/MEM/GPU | bar | btop | | | | resource dashboard | | CPU/MEM/GPU | bar | btop | | | | resource dashboard |
Popouts open on a short dwell and stay while the pointer is over the trigger or 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 transient surface mapped at a time, including tray context menus, so sweeping
across the rail cannot stack several animated panels over one another. 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 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` `J`/`K` moves the active slab, `Enter`/`Space` confirms it, and `Escape`
dismisses the screen. dismisses the screen.
+73 -12
View File
@@ -4,8 +4,8 @@ import Quickshell.Hyprland
import "root:/config" import "root:/config"
import "root:/components" import "root:/components"
// Focused window title, glitching on every change the way a Persona 5 caption // Focused window title. Rapid application title churn is settled before it is
// snaps into place. // painted, then the stable caption lands like a misregistered print layer.
Item { Item {
id: root id: root
@@ -19,7 +19,8 @@ Item {
&& (root.monitor === null || toplevel.monitor === null && (root.monitor === null || toplevel.monitor === null
|| toplevel.monitor.id === root.monitor.id) || 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. // The bar shrinks this when the three clusters would otherwise collide.
property real maxWidth: 240 property real maxWidth: 240
@@ -31,18 +32,60 @@ Item {
NumberAnimation { duration: Theme.durSlow; easing.type: Easing.OutExpo } NumberAnimation { duration: Theme.durSlow; easing.type: Easing.OutExpo }
} }
Marquee { Item {
id: label id: titleStage
anchors.fill: parent anchors.fill: parent
text: root.title clip: true
Text {
id: label
anchors.verticalCenter: parent.verticalCenter
width: parent.width
text: root.displayedTitle
color: root.mine ? Theme.subtext : Theme.muted color: root.mine ? Theme.subtext : Theme.muted
pixelSize: Theme.fsSmall elide: Text.ElideRight
family: Theme.fontDisplay font.pixelSize: Theme.fsSmall
italic: true font.family: Theme.fontDisplay
weight: Font.DemiBold font.italic: true
font.weight: Font.DemiBold
renderType: Text.NativeRendering
} }
onTitleChanged: flash.restart() // 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
}
}
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. // A quick crimson wipe under the title on focus change.
Rectangle { Rectangle {
@@ -55,7 +98,7 @@ Item {
} }
SequentialAnimation { SequentialAnimation {
id: flash id: titleLanding
ParallelAnimation { ParallelAnimation {
NumberAnimation { NumberAnimation {
target: wipe; property: "width" target: wipe; property: "width"
@@ -66,6 +109,24 @@ Item {
target: wipe; property: "opacity" target: wipe; property: "opacity"
from: 0; to: 1; duration: Theme.durFast 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 { NumberAnimation {
target: wipe; property: "opacity" target: wipe; property: "opacity"
+3 -16
View File
@@ -37,29 +37,16 @@ PanelWindow {
exclusiveZone: Theme.barHeight + Theme.barMargin exclusiveZone: Theme.barHeight + Theme.barMargin
color: "transparent" color: "transparent"
// The slab. One panel, full width, flat fill, black keyline. // Calm permanent chrome: one opaque slab with no texture or offset copy.
P5Panel { ChromeSurface {
id: slab id: slab
anchors.fill: parent anchors.fill: parent
lean: Theme.skew lean: Theme.skew
chop: Theme.cut chop: Theme.cut
chopCorners: [1, 3] chopCorners: [1, 3]
padding: 0 padding: 0
fill: Theme.surface active: true
fillOpacity: 1.0
border: Theme.alpha(Theme.outline, 0.9)
borderWidth: 1
slash: true
slashColor: Theme.accent
slashWidth: 5 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 readonly property real edgeInset: Theme.barHeight * Theme.skew + Theme.padM
+1 -11
View File
@@ -17,7 +17,7 @@ BarPill {
SystemClock { SystemClock {
id: clock id: clock
precision: SystemClock.Seconds precision: SystemClock.Minutes
} }
onClicked: calendar.pinned = !calendar.pinned onClicked: calendar.pinned = !calendar.pinned
@@ -65,16 +65,6 @@ BarPill {
split: 1.4 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. // Glitch the time on the hour, because it should feel like something happened.
+15 -12
View File
@@ -17,7 +17,7 @@ BarPill {
visible: Media.hasPlayer || width > 1 visible: Media.hasPlayer || width > 1
// Track position, drawn by the pill in its own rule band. // 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 implicitWidth: Media.hasPlayer
? layout.implicitWidth + contentPad * 2 ? layout.implicitWidth + contentPad * 2
@@ -83,8 +83,6 @@ BarPill {
text: Media.sourceIcon text: Media.sourceIcon
font.pixelSize: Theme.fsLarge font.pixelSize: Theme.fsLarge
color: Theme.primary color: Theme.primary
pulsing: Media.playing
pulseScale: 1.12
} }
} }
@@ -101,28 +99,33 @@ BarPill {
readonly property real cap: 150 readonly property real cap: 150
Marquee { Text {
width: Math.min(meta.cap, implicitWidth) width: Math.min(meta.cap, implicitWidth)
height: 14 height: 14
text: Media.title || Media.identity text: Media.title || Media.identity
color: Theme.text color: Theme.text
pixelSize: Theme.fsSmall elide: Text.ElideRight
family: Theme.fontDisplay font.pixelSize: Theme.fsSmall
weight: Font.DemiBold font.family: Theme.fontDisplay
italic: true font.weight: Font.DemiBold
font.italic: true
renderType: Text.NativeRendering
} }
Marquee { Text {
width: Math.min(meta.cap, implicitWidth) width: Math.min(meta.cap, implicitWidth)
height: 12 height: 12
text: Media.artist || Media.album || Media.identity text: Media.artist || Media.album || Media.identity
color: Theme.muted color: Theme.muted
pixelSize: Theme.fsMicro elide: Text.ElideRight
family: Theme.fontMono 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 { Row {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: 2 spacing: 2
+36 -26
View File
@@ -40,8 +40,8 @@ PanelWindow {
exclusiveZone: Theme.railWidth + Theme.barMargin exclusiveZone: Theme.railWidth + Theme.barMargin
color: "transparent" color: "transparent"
// The slab. // The same calm permanent-chrome preset used by the top bar.
P5Panel { ChromeSurface {
id: slab id: slab
anchors.fill: parent anchors.fill: parent
vertical: true vertical: true
@@ -49,18 +49,8 @@ PanelWindow {
chop: Theme.cut chop: Theme.cut
chopCorners: [1, 3] chopCorners: [1, 3]
padding: 0 padding: 0
fill: Theme.surface active: true
fillOpacity: 1.0
border: Theme.alpha(Theme.outline, 0.9)
borderWidth: 1
slash: true
slashColor: Theme.accent
slashWidth: 5 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 readonly property real edgeInset: Theme.railWidth * Theme.skew + Theme.padM
@@ -76,18 +66,21 @@ PanelWindow {
RailIcon { RailIcon {
id: netIcon id: netIcon
icon: Net.icon icon: Net.icon
label: "Network"
statusText: Net.label
suppressTooltip: netPopout.detailActive
dim: !Sys.netUp dim: !Sys.netUp
onClicked: netPopout.pinned = !netPopout.pinned onClicked: netPopout.togglePinned()
onRightClicked: Net.toggleWifi()
onMiddleClicked: Actions.openNetworkSettings()
} }
RailIcon { RailIcon {
id: btIcon id: btIcon
icon: Bt.icon icon: Bt.icon
label: "Bluetooth"
statusText: Bt.label
suppressTooltip: btPopout.detailActive
dim: !Bt.enabled dim: !Bt.enabled
onClicked: btPopout.pinned = !btPopout.pinned onClicked: btPopout.togglePinned()
onRightClicked: Bt.toggle()
} }
Divider {} Divider {}
@@ -96,19 +89,24 @@ PanelWindow {
id: volIcon id: volIcon
icon: Audio.icon icon: Audio.icon
readout: Audio.muted ? "" : Audio.volumePercent + "%" 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 alert: Audio.muted
onClicked: Audio.toggleMute() onClicked: audioPopout.togglePinned()
onRightClicked: audioPopout.pinned = !audioPopout.pinned
onMiddleClicked: Actions.openAudioSettings()
onScrolled: delta => Audio.changeVolume(delta > 0 ? 0.05 : -0.05) onScrolled: delta => Audio.changeVolume(delta > 0 ? 0.05 : -0.05)
} }
RailIcon { RailIcon {
id: micIcon id: micIcon
icon: Audio.micIcon icon: Audio.micIcon
label: "Microphone"
statusText: Audio.micMuted ? "Muted" : Audio.micPercent + "% · " + Audio.sourceName
hint: "Scroll to adjust"
suppressTooltip: audioPopout.detailActive
alert: Audio.micMuted alert: Audio.micMuted
onClicked: Audio.toggleMicMute() onClicked: audioPopout.togglePinned()
onRightClicked: audioPopout.pinned = !audioPopout.pinned
onScrolled: delta => Audio.setMicVolume(Audio.micVolume + (delta > 0 ? 0.05 : -0.05)) onScrolled: delta => Audio.setMicVolume(Audio.micVolume + (delta > 0 ? 0.05 : -0.05))
} }
@@ -117,6 +115,9 @@ PanelWindow {
visible: Brightness.available visible: Brightness.available
icon: Brightness.icon icon: Brightness.icon
readout: Math.round(Brightness.percent) + "%" readout: Math.round(Brightness.percent) + "%"
label: "Brightness"
statusText: Math.round(Brightness.percent) + "%"
hint: "Scroll to adjust"
onScrolled: delta => Brightness.change(delta > 0 ? 5 : -5) onScrolled: delta => Brightness.change(delta > 0 ? 5 : -5)
} }
@@ -127,9 +128,12 @@ PanelWindow {
visible: Battery.available visible: Battery.available
icon: Battery.icon icon: Battery.icon
readout: Math.round(Battery.percent) + "%" readout: Math.round(Battery.percent) + "%"
label: "Battery"
statusText: Math.round(Battery.percent) + "% · " + Battery.timeLabel
suppressTooltip: battPopout.detailActive
alert: Battery.critical alert: Battery.critical
pulsing: Battery.critical pulsing: Battery.critical
onClicked: battPopout.pinned = !battPopout.pinned onClicked: battPopout.togglePinned()
} }
} }
@@ -151,21 +155,27 @@ PanelWindow {
RailIcon { RailIcon {
id: bellIcon id: bellIcon
icon: Notifs.icon icon: Notifs.icon
label: "Notifications"
statusText: Notifs.dnd
? "Do not disturb"
: (Notifs.unread > 0 ? Notifs.unread + " unread" : "All caught up")
readout: Notifs.unread > 0 readout: Notifs.unread > 0
? (Notifs.unread > 9 ? "9+" : String(Notifs.unread)) ? (Notifs.unread > 9 ? "9+" : String(Notifs.unread))
: "" : ""
dim: Notifs.dnd dim: Notifs.dnd
alert: Notifs.hasUnread && !Notifs.dnd alert: Notifs.hasUnread && !Notifs.dnd
suppressTooltip: notifPopout.detailActive
onClicked: { onClicked: {
notifPopout.pinned = !notifPopout.pinned; notifPopout.togglePinned();
Notifs.markRead(); Notifs.markRead();
} }
onRightClicked: Notifs.toggleDnd()
} }
RailIcon { RailIcon {
id: powerIcon id: powerIcon
icon: "󰐥" icon: "󰐥"
label: "Power"
statusText: "Session actions"
alert: Actions.powerMenuOpen alert: Actions.powerMenuOpen
onClicked: Actions.togglePowerMenu() onClicked: Actions.togglePowerMenu()
} }
+13 -10
View File
@@ -12,6 +12,10 @@ Item {
property string icon: "" property string icon: ""
property string readout: "" property string readout: ""
property string label: ""
property string statusText: ""
property string hint: ""
property bool suppressTooltip: false
property color tint: Theme.primary property color tint: Theme.primary
property bool alert: false property bool alert: false
property bool dim: false property bool dim: false
@@ -24,8 +28,6 @@ Item {
readonly property real wipeInset: 5 readonly property real wipeInset: 5
signal clicked() signal clicked()
signal rightClicked()
signal middleClicked()
signal scrolled(real delta) signal scrolled(real delta)
implicitWidth: Theme.railWidth implicitWidth: Theme.railWidth
@@ -110,16 +112,17 @@ Item {
acceptedButtons: Qt.LeftButton acceptedButtons: Qt.LeftButton
onTapped: root.clicked() onTapped: root.clicked()
} }
TapHandler {
acceptedButtons: Qt.RightButton
onTapped: root.rightClicked()
}
TapHandler {
acceptedButtons: Qt.MiddleButton
onTapped: root.middleClicked()
}
WheelHandler { WheelHandler {
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onWheel: event => root.scrolled(event.angleDelta.y) 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
}
} }
+18 -4
View File
@@ -56,10 +56,8 @@ BarPill {
color: Theme.alpha(Theme.outline, 0.8) color: Theme.alpha(Theme.outline, 0.8)
} }
// The CPU/MEM/GPU labels are gone: three characters of chrome per metric, // A tiny fixed label makes every number self-describing without turning the
// repeated three times, in the most cramped part of the shell. The number // compact meter cluster back into three separate cards.
// 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.
component Metric: Column { component Metric: Column {
id: metric id: metric
@@ -70,7 +68,22 @@ BarPill {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: 2 spacing: 2
Row {
spacing: 4
Text { 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 text: metric.readout
color: Theme.heat(metric.value) color: Theme.heat(metric.value)
font.family: Theme.fontDisplay font.family: Theme.fontDisplay
@@ -84,6 +97,7 @@ BarPill {
ColorAnimation { duration: Theme.durBase } ColorAnimation { duration: Theme.durBase }
} }
} }
}
MeterBar { MeterBar {
value: metric.value value: metric.value
+27
View File
@@ -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
}
+58 -20
View File
@@ -1,15 +1,13 @@
import QtQuick import QtQuick
import "root:/config" import "root:/config"
// Chopped, leaning button. On hover it lunges toward the pointer and flips to // Chopped, leaning button with one activation path for pointer and keyboard.
// crimson-on-white; on press it snaps flat. FocusScope {
Item {
id: root id: root
property string text: "" property string text: ""
property string icon: "" property string icon: ""
property color accent: Theme.primary property color accent: Theme.primary
property color hoverFill: Theme.accent
property bool destructive: false property bool destructive: false
property bool selected: false property bool selected: false
property real lean: Theme.skew property real lean: Theme.skew
@@ -18,8 +16,25 @@ Item {
signal clicked() 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 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) implicitWidth: wide ? 260 : (row.implicitWidth + panel.contentPad * 2)
implicitHeight: 44 implicitHeight: 44
@@ -29,13 +44,24 @@ Item {
anchors.fill: parent anchors.fill: parent
lean: root.lean lean: root.lean
padding: Theme.padS padding: Theme.padS
fill: root.hovered ? (root.destructive ? root.hoverFill : Theme.text) : Theme.surface fill: {
fillOpacity: root.hovered ? 1.0 : 0.9 if (!root.enabled) return Theme.treatmentDisabledFill;
border: root.hovered ? "transparent" : Theme.alpha(root.accent, 0.55) if (root.destructive) return Theme.treatmentDestructiveFill;
slash: !root.hovered if (root.highlighted) return Theme.treatmentHoverFill;
slashColor: root.destructive ? Theme.accent : root.accent return Theme.treatmentRestFill;
halftone: root.hovered }
halftoneColor: root.destructive ? Theme.text : Theme.base 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 halftoneOpacity: 0.14
Behavior on fillOpacity { Behavior on fillOpacity {
@@ -53,17 +79,23 @@ Item {
visible: root.icon !== "" visible: root.icon !== ""
text: root.icon text: root.icon
font.pixelSize: Theme.fsTitle font.pixelSize: Theme.fsTitle
color: root.hovered color: {
? (root.destructive ? Theme.text : Theme.base) if (!root.enabled) return Theme.treatmentDisabledText;
: root.accent if (root.destructive) return Theme.treatmentDestructiveText;
if (root.highlighted) return Theme.treatmentHoverText;
return root.accent;
}
} }
Text { Text {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: root.text text: root.text
color: root.hovered color: {
? (root.destructive ? Theme.text : Theme.base) if (!root.enabled) return Theme.treatmentDisabledText;
: Theme.text if (root.destructive) return Theme.treatmentDestructiveText;
if (root.highlighted) return Theme.treatmentHoverText;
return Theme.treatmentRestText;
}
font.family: Theme.fontDisplay font.family: Theme.fontDisplay
font.pixelSize: Theme.fsBody font.pixelSize: Theme.fsBody
font.weight: Theme.weightDisplay font.weight: Theme.weightDisplay
@@ -80,7 +112,7 @@ Item {
} }
transform: Translate { transform: Translate {
x: root.pressed ? 0 : (root.hovered ? root.lunge : 0) x: root.pressed ? 0 : (root.highlighted ? root.lunge : 0)
Behavior on x { Behavior on x {
NumberAnimation { NumberAnimation {
@@ -97,11 +129,17 @@ Item {
HoverHandler { HoverHandler {
id: hover id: hover
enabled: root.enabled
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
} }
TapHandler { TapHandler {
id: tap 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)
} }
+48 -8
View File
@@ -8,23 +8,38 @@ Item {
property real value: 0 // 0..1 property real value: 0 // 0..1
property color accent: Theme.primary property color accent: Theme.primary
property bool enabledControl: true property real stepSize: 0.05
signal moved(real value) signal moved(real value)
implicitHeight: 18 implicitHeight: 18
implicitWidth: 200 implicitWidth: 200
focusPolicy: root.enabled ? Qt.StrongFocus : Qt.NoFocus
readonly property real _lean: Theme.skew * height 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 // Rail
Skew { Skew {
id: rail id: rail
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
height: 6 height: root.activeFocus ? 10 : 6
color: Theme.alpha(Theme.outline, 0.7) 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 // Fill
@@ -33,7 +48,7 @@ Item {
anchors.left: parent.left anchors.left: parent.left
width: Math.max(0, Math.min(1, root.value)) * root.width width: Math.max(0, Math.min(1, root.value)) * root.width
height: 6 height: 6
color: root.enabledControl ? root.accent : Theme.muted color: root.enabled ? root.accent : Theme.treatmentDisabledText
Behavior on width { Behavior on width {
enabled: !drag.active enabled: !drag.active
@@ -49,8 +64,8 @@ Item {
Skew { Skew {
id: handle id: handle
width: 6 width: 6
height: hover.hovered || drag.active ? 18 : 14 height: root.activeFocus || hover.hovered || drag.active ? 18 : 14
color: root.enabledControl ? Theme.text : Theme.muted color: root.enabled ? Theme.text : Theme.treatmentDisabledText
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
x: Math.max(0, Math.min(1, root.value)) * root.width - width / 2 x: Math.max(0, Math.min(1, root.value)) * root.width - width / 2
@@ -65,21 +80,46 @@ Item {
HoverHandler { HoverHandler {
id: hover id: hover
enabled: root.enabled
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
} }
TapHandler { 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 { DragHandler {
id: drag id: drag
enabled: root.enabled
target: null target: null
xAxis.enabled: true xAxis.enabled: true
yAxis.enabled: false yAxis.enabled: false
onCentroidChanged: { onCentroidChanged: {
if (!active) return; 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;
}
} }
+114
View File
@@ -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
}
}
}
}
+95 -24
View File
@@ -1,6 +1,5 @@
import QtQuick import QtQuick
import Quickshell import Quickshell
import Quickshell.Hyprland
import "root:/config" import "root:/config"
// Hover-driven panel that hangs off a bar item. Opens on a short dwell, stays // 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 int fromEdge: Edges.Bottom
property real contentWidth: Theme.popoutWidth property real contentWidth: Theme.popoutWidth
property real contentHeight: 200 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 // Extra surface around the content so the entry overshoot and glow are not
// clipped by the window edge. // clipped by the window edge.
@@ -25,6 +26,8 @@ PopupWindow {
default property alias body: contentHolder.data default property alias body: contentHolder.data
readonly property bool wantsOpen: triggerHovered || panelHover.hovered || pinned 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() signal dismissedFully()
@@ -52,12 +55,14 @@ PopupWindow {
implicitHeight: contentHeight + bleed * 2 implicitHeight: contentHeight + bleed * 2
color: "transparent" color: "transparent"
visible: shown visible: shown
grabFocus: pinned && shown
property bool shown: false property bool shown: false
onWantsOpenChanged: { onWantsOpenChanged: {
if (wantsOpen) { if (wantsOpen) {
hideTimer.stop(); hideTimer.stop();
if (!pinned && !remappingPinned)
showTimer.restart(); showTimer.restart();
} else { } else {
showTimer.stop(); showTimer.stop();
@@ -71,29 +76,17 @@ PopupWindow {
onTriggered: root.open() onTriggered: root.open()
} }
// A pinned popout ignores hover by design, which left it with exactly one onVisibleChanged: {
// way out: another click on the same bar item that opened it. Moving the if (!visible && shown)
// pointer away did nothing, so a panel opened by a click — the notification dismissImmediately();
// 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 onClosed: {
// hover rules. if (remappingPinned) {
// remappingPinned = false;
// The grab can only take hold once the surface is mapped; asked for in the remapTimer.restart();
// same tick as `shown`, Hyprland clears it immediately. } else if (shown || pinned) {
Timer { dismissImmediately();
id: grabDelay
interval: 60
running: root.pinned && root.shown
} }
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
} }
Timer { Timer {
@@ -109,14 +102,85 @@ PopupWindow {
enterAnim.restart(); 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() { function dismiss() {
if (!shown) return; if (!shown) {
if (pinned || pending)
dismissImmediately();
return;
}
pinned = false;
PopupCoordinator.release(root);
enterAnim.stop(); enterAnim.stop();
exitAnim.restart(); 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 { Item {
id: contentHolder id: contentHolder
focus: root.pinned
x: root.bleed x: root.bleed
y: root.bleed y: root.bleed
width: root.contentWidth width: root.contentWidth
@@ -145,6 +209,13 @@ PopupWindow {
id: panelHover id: panelHover
enabled: root.shown enabled: root.shown
} }
Keys.onEscapePressed: event => {
if (root.pinned) {
event.accepted = true;
root.dismissImmediately();
}
}
} }
ParallelAnimation { ParallelAnimation {
+5 -2
View File
@@ -10,8 +10,11 @@ Singleton {
property var active: null property var active: null
function claim(surface: var) { function claim(surface: var) {
if (active && active !== surface) if (active && active !== surface) {
active.dismiss(); const previous = active;
active = null;
previous.dismissImmediately();
}
active = surface; active = surface;
} }
+40
View File
@@ -54,6 +54,46 @@ Singleton {
// resolves to crimson. Pointing at a thing lights it up red. // resolves to crimson. Pointing at a thing lights it up red.
readonly property color glow: root.accent 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. // Retired hues. Aliases only — do not introduce new uses.
// //
// `warn` used to be accentSoft, which put a washed-out pink-red on screen // `warn` used to be accentSoft, which put a washed-out pink-red on screen
+22 -5
View File
@@ -28,6 +28,18 @@ PanelWindow {
readonly property bool open: Actions.powerMenuOpen readonly property bool open: Actions.powerMenuOpen
property int selectedAction: 0 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: [ readonly property var actionModel: [
{ label: "Lock", icon: "󰌾", danger: false, act: "lock" }, { label: "Lock", icon: "󰌾", danger: false, act: "lock" },
{ label: "Suspend", icon: "󰤄", danger: false, act: "suspend" }, { label: "Suspend", icon: "󰤄", danger: false, act: "suspend" },
@@ -160,6 +172,7 @@ PanelWindow {
] ]
P5Button { P5Button {
id: actionButton
anchors.fill: parent anchors.fill: parent
text: entry.modelData.label text: entry.modelData.label
icon: entry.modelData.icon icon: entry.modelData.icon
@@ -170,6 +183,8 @@ PanelWindow {
lunge: -10 lunge: -10
onClicked: root.invoke(entry.modelData.act) onClicked: root.invoke(entry.modelData.act)
onHoveredChanged: if (hovered) root.selectedAction = entry.index onHoveredChanged: if (hovered) root.selectedAction = entry.index
onActiveFocusChanged: if (actionButton.activeFocus) root.selectedAction = entry.index
Keys.forwardTo: [keyController]
} }
// Staggered entry, one button after another. // Staggered entry, one button after another.
@@ -241,20 +256,22 @@ PanelWindow {
// Escape closes. The layer takes exclusive focus while open. // Escape closes. The layer takes exclusive focus while open.
Item { Item {
id: keyController
anchors.fill: parent anchors.fill: parent
focus: root.open focus: root.open
Keys.onEscapePressed: Actions.closePowerMenu() Keys.onEscapePressed: Actions.closePowerMenu()
Keys.onPressed: event => { Keys.onPressed: event => {
if (!root.open) return; if (!root.open) return;
if (event.key === Qt.Key_Up || event.key === Qt.Key_K) { if (event.key === Qt.Key_Up || event.key === Qt.Key_K) {
root.selectedAction = (root.selectedAction + root.actionModel.length - 1) root.moveSelection(-1);
% root.actionModel.length;
event.accepted = true; event.accepted = true;
} else if (event.key === Qt.Key_Down || event.key === Qt.Key_J) { } 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; event.accepted = true;
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter } else if (keyController.activeFocus
|| event.key === Qt.Key_Space) { && !event.isAutoRepeat
&& (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|| event.key === Qt.Key_Space)) {
root.invoke(root.actionModel[root.selectedAction].act); root.invoke(root.actionModel[root.selectedAction].act);
event.accepted = true; event.accepted = true;
} }
+72
View File
@@ -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)
}
}
+71
View File
@@ -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)
}
}
+34 -27
View File
@@ -5,12 +5,15 @@ import "root:/config"
import "root:/components" import "root:/components"
import "root:/services" 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 { Popout {
id: root id: root
contentWidth: 360 contentWidth: 400
contentHeight: surface.implicitContentHeight + Theme.padM * 2 contentHeight: surface.implicitContentHeight + Theme.padM * 2
initialFocusItem: outputMuteButton
PopoutSurface { PopoutSurface {
id: surface id: surface
@@ -39,29 +42,38 @@ Popout {
spacing: Theme.padM spacing: Theme.padM
// ------------------------------------------------------- output // ------------------------------------------------------- output
LevelRow { AudioOutputHero {
width: parent.width width: parent.width
}
Column {
width: parent.width
spacing: Theme.padS
P5Button {
id: outputMuteButton
width: parent.width
text: Audio.muted ? "Unmute output" : "Mute output"
icon: Audio.icon icon: Audio.icon
label: "Output" onClicked: Audio.toggleMute()
readout: Audio.muted ? "MUTED" : Audio.volumePercent + "%" }
value: Audio.volume
muted: Audio.muted P5Button {
accent: Theme.primary width: parent.width
onMoved: v => Audio.setVolume(v) text: "Open audio mixer"
onToggled: Audio.toggleMute() icon: "󰕾"
onClicked: Actions.openAudioSettings()
}
} }
// -------------------------------------------------------- input // -------------------------------------------------------- input
LevelRow { SectionRule {
width: parent.width
text: "Input"
}
AudioInputBand {
width: parent.width 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 { SectionRule {
@@ -98,19 +110,14 @@ Popout {
icon: "󰝚" icon: "󰝚"
label: Audio.streamName(modelData) label: Audio.streamName(modelData)
readout: modelData.audio ? Math.round(modelData.audio.volume * 100) + "%" : "" readout: modelData.audio ? Math.round(modelData.audio.volume * 100) + "%" : ""
value: modelData.audio?.volume ?? 0 value: modelData.audio ? modelData.audio.volume : 0
muted: modelData.audio?.muted ?? false muted: modelData.audio ? modelData.audio.muted : false
accent: Theme.glow accent: Theme.glow
onMoved: v => { if (modelData.audio) modelData.audio.volume = v; } onMoved: v => { if (modelData.audio) modelData.audio.volume = v; }
onToggled: { if (modelData.audio) modelData.audio.muted = !modelData.audio.muted; } 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 anchors.right: parent.right
value: levelRow.value value: levelRow.value
accent: levelRow.accent accent: levelRow.accent
enabledControl: !levelRow.muted enabled: !levelRow.muted
onMoved: v => levelRow.moved(v) onMoved: v => levelRow.moved(v)
} }
} }
+72 -26
View File
@@ -155,26 +155,26 @@ Popout {
TransportButton { TransportButton {
glyph: "󰒮" glyph: "󰒮"
enabledControl: Media.canGoPrevious enabled: Media.canGoPrevious
onClicked: Media.previous() onClicked: Media.previous()
} }
TransportButton { TransportButton {
glyph: Media.playing ? "󰏤" : "󰐊" glyph: Media.playing ? "󰏤" : "󰐊"
primary: true primary: true
enabledControl: Media.hasPlayer enabled: Media.hasPlayer
onClicked: Media.playPause() onClicked: Media.playPause()
} }
TransportButton { TransportButton {
glyph: "󰒭" glyph: "󰒭"
enabledControl: Media.canGoNext enabled: Media.canGoNext
onClicked: Media.next() onClicked: Media.next()
} }
TransportButton { TransportButton {
glyph: "󰐒" glyph: "󰐒"
enabledControl: Media.hasPlayer enabled: Media.hasPlayer
onClicked: Media.raise() onClicked: Media.raise()
} }
} }
@@ -196,7 +196,7 @@ Popout {
id: elapsed id: elapsed
anchors.left: parent.left anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: Media.timeString(Media.position) text: Media.positionSupported ? Media.timeString(Media.position) : "--:--"
color: Theme.subtext color: Theme.subtext
font.family: Theme.fontMono font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro font.pixelSize: Theme.fsMicro
@@ -207,7 +207,7 @@ Popout {
id: total id: total
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: Media.timeString(Media.length) text: Media.lengthSupported ? Media.timeString(Media.length) : "LIVE"
color: Theme.subtext color: Theme.subtext
font.family: Theme.fontMono font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro font.pixelSize: Theme.fsMicro
@@ -222,7 +222,7 @@ Popout {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
value: Media.progress value: Media.progress
accent: Theme.accent accent: Theme.accent
enabledControl: Media.canSeek enabled: Media.canSeek
onMoved: v => Media.seekTo(v) onMoved: v => Media.seekTo(v)
} }
} }
@@ -238,7 +238,7 @@ Popout {
Repeater { Repeater {
model: Media.players model: Media.players
Item { FocusScope {
id: chip id: chip
required property var modelData required property var modelData
@@ -246,21 +246,44 @@ Popout {
implicitWidth: chipLabel.implicitWidth + Theme.padM implicitWidth: chipLabel.implicitWidth + Theme.padM
implicitHeight: 22 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 { Skew {
anchors.fill: parent anchors.fill: parent
color: chip.current color: !chip.enabled
? Theme.treatmentDisabledFill
: (chip.activeFocus
? Theme.treatmentFocusFill
: (chip.current
? Theme.alpha(Theme.primary, 0.22) ? Theme.alpha(Theme.primary, 0.22)
: (chipHover.hovered ? Theme.alpha(Theme.primary, 0.1) : "transparent") : (chipHover.hovered ? Theme.alpha(Theme.primary, 0.1) : "transparent")))
borderWidth: 1 borderWidth: chip.activeFocus ? Theme.stroke : 1
borderColor: chip.current ? Theme.primary : Theme.alpha(Theme.outline, 0.7) borderColor: !chip.enabled
? Theme.treatmentDisabledMark
: (chip.activeFocus
? Theme.treatmentFocusMark
: (chip.current ? Theme.primary : Theme.alpha(Theme.outline, 0.7)))
} }
Text { Text {
id: chipLabel id: chipLabel
anchors.centerIn: parent anchors.centerIn: parent
text: chip.modelData.identity 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.family: Theme.fontMono
font.pixelSize: Theme.fsMicro font.pixelSize: Theme.fsMicro
renderType: Text.NativeRendering renderType: Text.NativeRendering
@@ -268,39 +291,57 @@ Popout {
HoverHandler { HoverHandler {
id: chipHover id: chipHover
enabled: chip.enabled
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
} }
TapHandler { 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 id: tb
property string glyph: "" property string glyph: ""
property bool primary: false property bool primary: false
property bool enabledControl: true
signal clicked() signal clicked()
implicitWidth: primary ? 38 : 30 implicitWidth: primary ? 38 : 30
implicitHeight: 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 { Skew {
anchors.fill: parent anchors.fill: parent
color: { 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); if (tbHover.hovered) return tb.primary ? Theme.accent : Theme.alpha(Theme.primary, 0.22);
return tb.primary ? Theme.alpha(Theme.primary, 0.18) : "transparent"; return tb.primary ? Theme.alpha(Theme.primary, 0.18) : "transparent";
} }
borderWidth: tb.primary ? 2 : 1 borderWidth: tb.activeFocus ? Theme.stroke * 2 : (tb.primary ? 2 : 1)
borderColor: tb.enabledControl borderColor: tb.enabled
? (tbHover.hovered ? Theme.text : Theme.alpha(Theme.primary, 0.6)) ? (tb.activeFocus
? Theme.treatmentFocusMark
: (tbHover.hovered ? Theme.text : Theme.alpha(Theme.primary, 0.6)))
: Theme.alpha(Theme.outline, 0.4) : Theme.alpha(Theme.outline, 0.4)
Behavior on color { Behavior on color {
@@ -313,26 +354,31 @@ Popout {
text: tb.glyph text: tb.glyph
font.pixelSize: tb.primary ? Theme.fsTitle : Theme.fsLarge font.pixelSize: tb.primary ? Theme.fsTitle : Theme.fsLarge
color: { 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; if (tbHover.hovered && tb.primary) return Theme.text;
return Theme.primary; return Theme.primary;
} }
} }
scale: tbHover.hovered && tb.enabledControl ? 1.12 : 1.0 scale: tbHover.hovered && tb.enabled ? 1.12 : 1.0
Behavior on scale { Behavior on scale {
NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack } NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack }
} }
HoverHandler { HoverHandler {
id: tbHover id: tbHover
enabled: tb.enabledControl enabled: tb.enabled
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
} }
TapHandler { TapHandler {
enabled: tb.enabledControl enabled: tb.enabled
onTapped: tb.clicked() onTapped: tb.activate()
} }
Keys.onReturnPressed: event => tb.activateFromKey(event)
Keys.onEnterPressed: event => tb.activateFromKey(event)
Keys.onSpacePressed: event => tb.activateFromKey(event)
} }
} }
+2 -1
View File
@@ -6,12 +6,13 @@ import "root:/components"
// own, so P5Panel's default content property stays usable by callers. // own, so P5Panel's default content property stays usable by callers.
P5Panel { P5Panel {
property color tone: Theme.primary property color tone: Theme.primary
property bool textured: false
fill: Theme.mantle fill: Theme.mantle
fillOpacity: 0.97 fillOpacity: 0.97
border: Theme.alpha(tone, 0.75) border: Theme.alpha(tone, 0.75)
borderWidth: Theme.stroke borderWidth: Theme.stroke
halftone: true halftone: textured
halftoneColor: tone halftoneColor: tone
halftoneOpacity: 0.06 halftoneOpacity: 0.06
sheen: true sheen: true
+10
View File
@@ -74,6 +74,16 @@ PopupWindow {
close(); 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 // 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 // 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. // clears it immediately — which closed the menu the instant it opened.
+14 -9
View File
@@ -34,11 +34,14 @@ Singleton {
readonly property bool canGoNext: active?.canGoNext ?? false readonly property bool canGoNext: active?.canGoNext ?? false
readonly property bool canGoPrevious: active?.canGoPrevious ?? 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 length: lengthSupported ? (active?.length ?? 0) : 0
readonly property real position: active?.positionSupported ? (active?.position ?? 0) : 0 readonly property real position: positionSupported ? (active?.position ?? 0) : 0
readonly property real progress: length > 0 ? Math.min(1, position / length) : 0 readonly property real progress: hasTimeline ? Math.min(1, position / length) : 0
readonly property string label: { readonly property string label: {
if (!hasPlayer) return "Nothing playing"; if (!hasPlayer) return "Nothing playing";
@@ -53,8 +56,8 @@ Singleton {
// MPRIS position is pull-only; tick it so the seek bar moves. // MPRIS position is pull-only; tick it so the seek bar moves.
Timer { Timer {
interval: 500 interval: 1000
running: root.playing && root.canSeek running: root.playing && root.positionSupported
repeat: true repeat: true
onTriggered: if (root.active) root.active.positionChanged() onTriggered: if (root.active) root.active.positionChanged()
} }
@@ -85,9 +88,11 @@ Singleton {
return "󰝚"; return "󰝚";
} }
function timeString(micros: real): string { // Quickshell exposes MPRIS position/length in seconds (with millisecond
if (!(micros > 0)) return "0:00"; // precision), not the protocol's raw microseconds.
const total = Math.floor(micros / 1000000); function timeString(seconds: real): string {
if (!(seconds > 0)) return "0:00";
const total = Math.floor(seconds);
const m = Math.floor(total / 60); const m = Math.floor(total / 60);
const s = total % 60; const s = total % 60;
if (m >= 60) { if (m >= 60) {