feat(quickshell): add HyprChromeShell, workspaces panel, persistent backdrop
HyprChromeShell owns what the rail's surfaces have to agree on: the target screen, the density and the shortcut that toggles it, and the bar/backdrop layer PAIR. That pair is why the wrapper exists — the backdrop has to sit exactly one layer below the bar in both densities, and split across two files the two assignments drifted apart and put the scrim over the bar. HyprChromeBar is now just its own surface; ChromeBackdrop takes its layer. The backdrop renders in both densities instead of fading out: full-screen while expanded, scoped to the band the rail occupies while collapsed, with a proportional tail fading off the bottom of that band. Both gradients end on their solid color when there is no fade — with a zero-length ramp the start and end stops coincide, Qt sorts stops unstably, and the transparent one winning turned "no fade" into a ramp across the whole scrim. WorkspacesPanel, between host and vitals, shows the active workspace per monitor: one accent box per output collapsed, the full strip with the shown one filled expanded, lined up in a column behind a fixed-width name cell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U19X5LGTxtq4pb4jdNVivv
This commit is contained in:
@@ -5,12 +5,21 @@ import Quickshell.Wayland
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.hyprchrome.theme
|
import qs.hyprchrome.theme
|
||||||
|
|
||||||
// Full-screen scrim behind the bar: dims the desktop and lays the dense bar's
|
// Scrim behind the bar: dims the desktop and lays the dense bar's drafting grid
|
||||||
// drafting grid over it while the rail is expanded.
|
// over it. It is always on screen — only how far DOWN it reaches changes.
|
||||||
|
// Expanded, it covers the whole output; collapsed, it shrinks to the band the
|
||||||
|
// rail itself occupies, so the bar keeps its backing without the desktop being
|
||||||
|
// dimmed. That collapsed band ends in a fade rather than a cut, so there is no
|
||||||
|
// hard line across the wallpaper; expanded there is nothing to fade against —
|
||||||
|
// the scrim runs to the bottom of the output.
|
||||||
//
|
//
|
||||||
// Sits on the TOP layer while the bar itself is on OVERLAY. Two surfaces on the
|
// Its layer arrives from HyprChromeShell, which derives it together with the
|
||||||
// same layer stack by creation order, which is not something to rely on; one
|
// bar's so the two stay exactly one level apart — see that file for why the
|
||||||
// layer apart is a guarantee — above ordinary windows, below the bar.
|
// pair cannot be split. Collapsed that puts it on BACKGROUND, shared with the
|
||||||
|
// wallpaper (hyprpaper), where order IS creation order: if the wallpaper is
|
||||||
|
// restarted under a running shell it comes up on top and the collapsed band
|
||||||
|
// goes with it. A `layerrule = order` in the Hyprland config is the fix if that
|
||||||
|
// ever bites.
|
||||||
//
|
//
|
||||||
// It reserves nothing and takes no input: the mask is an empty Region, so
|
// It reserves nothing and takes no input: the mask is an empty Region, so
|
||||||
// clicks land on whatever is underneath rather than on the scrim.
|
// clicks land on whatever is underneath rather than on the scrim.
|
||||||
@@ -18,20 +27,71 @@ PanelWindow {
|
|||||||
id: backdrop
|
id: backdrop
|
||||||
|
|
||||||
property bool active: true
|
property bool active: true
|
||||||
property real dim: 0.55
|
|
||||||
property int gridSpacing: 120
|
// Which layer to sit on. An int rather than a private decision: it is half
|
||||||
|
// of a pair with the bar's, so the shell derives both.
|
||||||
|
property int wlrLayer: WlrLayer.Top
|
||||||
|
|
||||||
|
property real dim: 0.75
|
||||||
|
property int gridSpacing: 60
|
||||||
|
|
||||||
|
// Height of the collapsed rail, including its margins — the band the scrim
|
||||||
|
// stays behind while the bar is collapsed. Driven by the host, which is the
|
||||||
|
// only thing that knows what the panels currently measure.
|
||||||
|
property real barHeight: 0
|
||||||
|
|
||||||
|
// Share of the COLLAPSED band spent fading to nothing at the bottom, as a
|
||||||
|
// fraction rather than a pixel length so the tail scales with whatever the
|
||||||
|
// rail currently measures. Expanded there is no fade at all: the scrim runs
|
||||||
|
// to the bottom of the output, where the screen edge ends it.
|
||||||
|
property real fade: 0.5
|
||||||
|
readonly property real fadeAmount: Math.max(0, Math.min(0.95, backdrop.fade))
|
||||||
|
|
||||||
|
// The share actually in force. Animated rather than switched, so expanding
|
||||||
|
// shrinks the tail away as the scrim grows instead of dropping a hard edge
|
||||||
|
// onto the desktop the moment the state flips.
|
||||||
|
property real fadeSpan: backdrop.active ? 0 : backdrop.fadeAmount
|
||||||
|
// Position, in fractions of revealHeight, where the falloff starts. 1 while
|
||||||
|
// expanded, i.e. no falloff.
|
||||||
|
readonly property real fadeStart: 1 - backdrop.fadeSpan
|
||||||
|
|
||||||
|
// Collapsed, the SOLID part is the bar band and the tail hangs below it,
|
||||||
|
// hence the division: barHeight is what must survive the fade, not what the
|
||||||
|
// whole scrim measures. Sized off the static fadeAmount, not the animated
|
||||||
|
// fadeSpan — the height and the ramp have to animate independently or each
|
||||||
|
// would be chasing the other. Bound rather than readonly so the Behavior
|
||||||
|
// below can animate the state change.
|
||||||
|
property real revealHeight: backdrop.active
|
||||||
|
? backdrop.height
|
||||||
|
: Math.min(backdrop.height, backdrop.barHeight / (1 - backdrop.fadeAmount))
|
||||||
|
|
||||||
|
// Both matched to the bar's own collapse so the scrim and the panels
|
||||||
|
// resolve together rather than one trailing the other.
|
||||||
|
Behavior on revealHeight {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: 200
|
||||||
|
easing.type: Easing.OutCubic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on fadeSpan {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: 200
|
||||||
|
easing.type: Easing.OutCubic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The dense bar drew this grid at 0.018 against its own near-black panel.
|
// The dense bar drew this grid at 0.018 against its own near-black panel.
|
||||||
// Over a 55% scrim on top of lit windows that is invisible, so it is a
|
// Over a 55% scrim on top of lit windows that is invisible, so it is a
|
||||||
// knob rather than a constant.
|
// knob rather than a constant.
|
||||||
property real gridOpacity: 0.1
|
property real gridOpacity: 0.15
|
||||||
|
|
||||||
// The accent with its saturation pulled back: warm enough to read as part
|
// The accent with its saturation pulled back: warm enough to read as part
|
||||||
// of the palette, not so loud that a full-screen grid competes with the
|
// of the palette, not so loud that a full-screen grid competes with the
|
||||||
// bar. Derived rather than a literal so it tracks a palette change.
|
// bar. Derived rather than a literal so it tracks a palette change.
|
||||||
// Registration crosses sit on every other intersection of the grid.
|
// Registration crosses sit on every other intersection of the grid.
|
||||||
property color crossColor: Theme.muted
|
property color crossColor: Theme.muted
|
||||||
property real crossOpacity: 0.35
|
property real crossOpacity: 0.45
|
||||||
property int crossSize: 20
|
property int crossSize: 20
|
||||||
|
|
||||||
// Thickness in STEPS, not pixels: 1 -> 1px, 2 -> 3px, 3 -> 5px. Only odd
|
// Thickness in STEPS, not pixels: 1 -> 1px, 2 -> 3px, 3 -> 5px. Only odd
|
||||||
@@ -45,10 +105,42 @@ PanelWindow {
|
|||||||
Theme.accent.hslLightness,
|
Theme.accent.hslLightness,
|
||||||
1)
|
1)
|
||||||
|
|
||||||
visible: scrim.opacity > 0
|
// Fade targets keep the source RGB and drop only the alpha: interpolating
|
||||||
|
// toward a plain "transparent" would run the gradient through black.
|
||||||
|
readonly property color gridSolid: Qt.rgba(backdrop.gridColor.r, backdrop.gridColor.g,
|
||||||
|
backdrop.gridColor.b, backdrop.gridOpacity)
|
||||||
|
readonly property color gridClear: Qt.rgba(backdrop.gridColor.r, backdrop.gridColor.g,
|
||||||
|
backdrop.gridColor.b, 0)
|
||||||
|
readonly property color dimSolid: Qt.rgba(Theme.surface.r, Theme.surface.g,
|
||||||
|
Theme.surface.b, backdrop.dim)
|
||||||
|
readonly property color dimClear: Qt.rgba(Theme.surface.r, Theme.surface.g,
|
||||||
|
Theme.surface.b, 0)
|
||||||
|
|
||||||
|
// What the gradients END on. With no fade the ramp has zero length, so its
|
||||||
|
// start stop and its end stop sit on the SAME position — and Qt sorts stops
|
||||||
|
// with an unstable sort, leaving which of the two wins undefined. It picked
|
||||||
|
// the transparent one, which turned "no fade" into a ramp across the entire
|
||||||
|
// scrim. Ending on the solid color instead makes the degenerate case
|
||||||
|
// unambiguous: all three stops match and the fill is flat.
|
||||||
|
readonly property color gridEnd: backdrop.fadeSpan > 0 ? backdrop.gridClear : backdrop.gridSolid
|
||||||
|
readonly property color dimEnd: backdrop.fadeSpan > 0 ? backdrop.dimClear : backdrop.dimSolid
|
||||||
|
|
||||||
|
// Same ramp as the gradients above, for the marks that are placed at a
|
||||||
|
// single y and so cannot carry a gradient of their own. Reads revealHeight
|
||||||
|
// and fadeStart, so bindings that call it re-evaluate when either changes.
|
||||||
|
function fadeAt(y: real): real {
|
||||||
|
const start = backdrop.revealHeight * backdrop.fadeStart;
|
||||||
|
if (y <= start)
|
||||||
|
return 1;
|
||||||
|
if (y >= backdrop.revealHeight)
|
||||||
|
return 0;
|
||||||
|
return 1 - (y - start) / (backdrop.revealHeight - start);
|
||||||
|
}
|
||||||
|
|
||||||
|
visible: backdrop.revealHeight > 0
|
||||||
|
|
||||||
WlrLayershell.namespace: "hyprchrome-scrim"
|
WlrLayershell.namespace: "hyprchrome-scrim"
|
||||||
WlrLayershell.layer: WlrLayer.Top
|
WlrLayershell.layer: backdrop.wlrLayer
|
||||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
@@ -68,26 +160,25 @@ PanelWindow {
|
|||||||
Item {
|
Item {
|
||||||
id: scrim
|
id: scrim
|
||||||
|
|
||||||
anchors.fill: parent
|
// Only as tall as the scrim currently reaches; everything inside is
|
||||||
opacity: backdrop.active ? 1 : 0
|
// laid out against this, so shrinking it scopes the whole drawing
|
||||||
|
// rather than just clipping it.
|
||||||
// Matched to the bar's own collapse so the scrim and the panels resolve
|
width: backdrop.width
|
||||||
// together rather than one trailing the other.
|
height: backdrop.revealHeight
|
||||||
Behavior on opacity {
|
clip: true
|
||||||
NumberAnimation {
|
|
||||||
duration: 200
|
|
||||||
easing.type: Easing.OutCubic
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
color: Theme.surface
|
gradient: Gradient {
|
||||||
opacity: backdrop.dim
|
GradientStop { position: 0; color: backdrop.dimSolid }
|
||||||
|
GradientStop { position: backdrop.fadeStart; color: backdrop.dimSolid }
|
||||||
|
GradientStop { position: 1; color: backdrop.dimEnd }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Faint drafting grid; no gradient and deliberately subordinate to
|
// Faint drafting grid; no gradient and deliberately subordinate to
|
||||||
// whatever is showing through it.
|
// whatever is showing through it — except at the bottom, where it has to
|
||||||
|
// fade with the scrim it sits on.
|
||||||
Repeater {
|
Repeater {
|
||||||
model: Math.ceil(scrim.width / backdrop.gridSpacing)
|
model: Math.ceil(scrim.width / backdrop.gridSpacing)
|
||||||
|
|
||||||
@@ -97,22 +188,29 @@ PanelWindow {
|
|||||||
x: index * backdrop.gridSpacing
|
x: index * backdrop.gridSpacing
|
||||||
width: 1
|
width: 1
|
||||||
height: scrim.height
|
height: scrim.height
|
||||||
color: backdrop.gridColor
|
gradient: Gradient {
|
||||||
opacity: backdrop.gridOpacity
|
GradientStop { position: 0; color: backdrop.gridSolid }
|
||||||
|
GradientStop { position: backdrop.fadeStart; color: backdrop.gridSolid }
|
||||||
|
GradientStop { position: 1; color: backdrop.gridEnd }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: Math.ceil(scrim.height / backdrop.gridSpacing)
|
// Modelled against the whole output, not the current reveal, so a
|
||||||
|
// collapse fades the rules out where they stand instead of
|
||||||
|
// restocking the Repeater on every animation frame.
|
||||||
|
model: Math.ceil(backdrop.height / backdrop.gridSpacing)
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
required property int index
|
required property int index
|
||||||
|
readonly property real line: index * backdrop.gridSpacing
|
||||||
|
|
||||||
y: index * backdrop.gridSpacing
|
y: line
|
||||||
width: scrim.width
|
width: scrim.width
|
||||||
height: 1
|
height: 1
|
||||||
color: backdrop.gridColor
|
color: backdrop.gridColor
|
||||||
opacity: backdrop.gridOpacity
|
opacity: backdrop.gridOpacity * backdrop.fadeAt(line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +237,9 @@ PanelWindow {
|
|||||||
y: row * backdrop.gridSpacing * 2 - Math.floor(backdrop.crossSize / 2)
|
y: row * backdrop.gridSpacing * 2 - Math.floor(backdrop.crossSize / 2)
|
||||||
width: backdrop.crossSize
|
width: backdrop.crossSize
|
||||||
height: backdrop.crossSize
|
height: backdrop.crossSize
|
||||||
opacity: backdrop.crossOpacity
|
// Sampled at the intersection the mark registers against, not at
|
||||||
|
// its own top edge, so a cross fades as one piece.
|
||||||
|
opacity: backdrop.crossOpacity * backdrop.fadeAt(row * backdrop.gridSpacing * 2)
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
// Placed with the same Math.floor the item's own offset uses.
|
// Placed with the same Math.floor the item's own offset uses.
|
||||||
|
|||||||
@@ -1,72 +1,35 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Shapes
|
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Hyprland
|
|
||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
import qs.hyprchrome.widgets
|
|
||||||
import qs.hyprchrome.widgets.host
|
import qs.hyprchrome.widgets.host
|
||||||
import qs.hyprchrome.widgets.vitals
|
import qs.hyprchrome.widgets.vitals
|
||||||
|
import qs.hyprchrome.widgets.workspaces
|
||||||
import qs.hyprchrome.widgets.tray
|
import qs.hyprchrome.widgets.tray
|
||||||
|
|
||||||
Scope {
|
// The dense status rail itself: one layer surface holding the panel row.
|
||||||
id: root
|
//
|
||||||
|
// It owns nothing shared — the screen, the density and its layer all arrive
|
||||||
// Monitor the rail lives on. Falls back to the FIRST connected screen when
|
// from HyprChromeShell, which is also what keeps this surface and the backdrop
|
||||||
// the name matches nothing, so the bar still appears on a single-monitor
|
// one layer apart. What it does own is its own measurement: `contentHeight` is
|
||||||
// session or after a cable swap (DebugWindow falls back to the last one
|
// the settled height of the current density, which the shell hands to the
|
||||||
// instead — it wants the secondary).
|
// backdrop and which sizes the exclusive zone.
|
||||||
property string screenName: "DP-2"
|
|
||||||
|
|
||||||
// Quickshell.screens is a QML list, not a JS array — no .find() on it.
|
|
||||||
readonly property var targetScreen: {
|
|
||||||
const screens = Quickshell.screens;
|
|
||||||
if (screens.length === 0)
|
|
||||||
return null;
|
|
||||||
for (let i = 0; i < screens.length; i++) {
|
|
||||||
if (screens[i].name === root.screenName)
|
|
||||||
return screens[i];
|
|
||||||
}
|
|
||||||
return screens[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Density is a property of the BAR: every panel follows it, so the whole rail
|
|
||||||
// expands and collapses as one. Panels keep their own animation; only the
|
|
||||||
// decision is centralised here.
|
|
||||||
property bool expanded: true
|
|
||||||
|
|
||||||
function toggle() {
|
|
||||||
root.expanded = !root.expanded;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SUPER A — see hosts/terra/home/hyprland.nix.
|
|
||||||
GlobalShortcut {
|
|
||||||
name: "chrome"
|
|
||||||
description: "Expand or collapse the hyprchrome bar"
|
|
||||||
onPressed: root.toggle()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scrim first: it is a layer below the bar, so stacking does not depend on
|
|
||||||
// creation order, but keeping the declaration order the same as the visual
|
|
||||||
// order costs nothing.
|
|
||||||
ChromeBackdrop {
|
|
||||||
screen: root.targetScreen
|
|
||||||
active: root.expanded
|
|
||||||
}
|
|
||||||
|
|
||||||
PanelWindow {
|
PanelWindow {
|
||||||
id: window
|
id: window
|
||||||
|
|
||||||
screen: root.targetScreen
|
// Density for every panel on the rail; driven by the shell.
|
||||||
visible: root.targetScreen !== null
|
property bool expanded: false
|
||||||
|
|
||||||
|
// Which layer to sit on. An int rather than a private decision: it is half
|
||||||
|
// of a pair with the backdrop's, so the shell derives both. See
|
||||||
|
// HyprChromeShell.
|
||||||
|
property int wlrLayer: WlrLayer.Overlay
|
||||||
|
|
||||||
// One layer above the scrim, so the stacking is guaranteed rather than
|
|
||||||
// dependent on surface creation order. See ChromeBackdrop.
|
|
||||||
// Own namespace so a layerrule can exempt the rail from Hyprland's layer
|
// Own namespace so a layerrule can exempt the rail from Hyprland's layer
|
||||||
// animation without also catching the launchers, which share the default
|
// animation without also catching the launchers, which share the default
|
||||||
// "quickshell" namespace and do want their fade.
|
// "quickshell" namespace and do want their fade.
|
||||||
WlrLayershell.namespace: "hyprchrome-bar"
|
WlrLayershell.namespace: "hyprchrome-bar"
|
||||||
WlrLayershell.layer: WlrLayer.Overlay
|
WlrLayershell.layer: window.wlrLayer
|
||||||
|
|
||||||
property int margin: 12
|
property int margin: 12
|
||||||
|
|
||||||
@@ -77,8 +40,8 @@ Scope {
|
|||||||
//
|
//
|
||||||
// The zone still follows the target height, so tiled windows reflow once
|
// The zone still follows the target height, so tiled windows reflow once
|
||||||
// per toggle, at the start, and slide while the panels animate.
|
// per toggle, at the start, and slide while the panels animate.
|
||||||
readonly property real expandedContent: Math.max(hostPanel.expandedHeight, vitalsPanel.expandedHeight, trayPanel.expandedHeight) + window.margin * 2
|
readonly property real expandedContent: Math.max(hostPanel.expandedHeight, workspacesPanel.expandedHeight, vitalsPanel.expandedHeight, trayPanel.expandedHeight) + window.margin * 2
|
||||||
readonly property real contentHeight: Math.max(hostPanel.targetHeight, vitalsPanel.targetHeight, trayPanel.targetHeight) + window.margin * 2
|
readonly property real contentHeight: Math.max(hostPanel.targetHeight, workspacesPanel.targetHeight, vitalsPanel.targetHeight, trayPanel.targetHeight) + window.margin * 2
|
||||||
|
|
||||||
implicitHeight: Math.round(window.expandedContent)
|
implicitHeight: Math.round(window.expandedContent)
|
||||||
|
|
||||||
@@ -105,21 +68,35 @@ Scope {
|
|||||||
HostPanel {
|
HostPanel {
|
||||||
id: hostPanel
|
id: hostPanel
|
||||||
|
|
||||||
expanded: root.expanded
|
expanded: window.expanded
|
||||||
// Vitals butts against its right edge; the left end of the row is free.
|
// Workspaces butt against its right edge; the left end of the row is free.
|
||||||
rightChamfer: false
|
rightChamfer: false
|
||||||
|
|
||||||
toggleOnClick: false
|
toggleOnClick: false
|
||||||
Layout.preferredWidth: 350
|
Layout.preferredWidth: 400
|
||||||
|
Layout.fillHeight: true
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkspacesPanel {
|
||||||
|
id: workspacesPanel
|
||||||
|
|
||||||
|
expanded: window.expanded
|
||||||
|
// Mid-rail: a panel on either side, so neither corner is cut.
|
||||||
|
leftChamfer: false
|
||||||
|
rightChamfer: false
|
||||||
|
|
||||||
|
toggleOnClick: false
|
||||||
|
// No preferred width: the panel sizes itself to however many outputs
|
||||||
|
// the session has, the same way the tray sizes itself to its items.
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
}
|
}
|
||||||
|
|
||||||
VitalsPanel {
|
VitalsPanel {
|
||||||
id: vitalsPanel
|
id: vitalsPanel
|
||||||
|
|
||||||
expanded: root.expanded
|
expanded: window.expanded
|
||||||
// Host on the left, the spacer on the right — and a spacer is not a
|
// Workspaces on the left, the spacer on the right — and a spacer is not
|
||||||
// panel, so that edge keeps its cut.
|
// a panel, so that edge keeps its cut.
|
||||||
leftChamfer: false
|
leftChamfer: false
|
||||||
|
|
||||||
toggleOnClick: false
|
toggleOnClick: false
|
||||||
@@ -134,10 +111,9 @@ Scope {
|
|||||||
TrayPanel {
|
TrayPanel {
|
||||||
id: trayPanel
|
id: trayPanel
|
||||||
|
|
||||||
expanded: root.expanded
|
expanded: window.expanded
|
||||||
toggleOnClick: false
|
toggleOnClick: false
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import qs.hyprchrome.widgets
|
||||||
|
|
||||||
|
// The hyprchrome shell: owns everything the rail's surfaces have to agree on,
|
||||||
|
// and instantiates them.
|
||||||
|
//
|
||||||
|
// State lives here rather than in any one surface because more than one of them
|
||||||
|
// reads it, and a second reader is what turns a local property into shared
|
||||||
|
// state. Three things qualify so far:
|
||||||
|
//
|
||||||
|
// * which monitor the shell lives on — every surface has to pick the same one
|
||||||
|
// * the density — the whole rail expands and collapses as one, so the toggle
|
||||||
|
// and the shortcut that drives it belong to the shell, not to the bar
|
||||||
|
// * the layer PAIR — the backdrop must sit exactly one layer below the bar in
|
||||||
|
// both densities. Two surfaces on the same layer stack by creation order,
|
||||||
|
// which is not something to rely on; one layer apart is a guarantee. Split
|
||||||
|
// across two files those two assignments drifted apart and the scrim ended
|
||||||
|
// up over the bar, so they are derived together here and passed down.
|
||||||
|
//
|
||||||
|
// A future widget joins by taking `targetScreen` and `expanded` the same way.
|
||||||
|
Scope {
|
||||||
|
id: shell
|
||||||
|
|
||||||
|
// Monitor the rail lives on. Falls back to the FIRST connected screen when
|
||||||
|
// the name matches nothing, so the bar still appears on a single-monitor
|
||||||
|
// session or after a cable swap (DebugWindow falls back to the last one
|
||||||
|
// instead — it wants the secondary).
|
||||||
|
property string screenName: "DP-2"
|
||||||
|
|
||||||
|
// Quickshell.screens is a QML list, not a JS array — no .find() on it.
|
||||||
|
readonly property var targetScreen: {
|
||||||
|
const screens = Quickshell.screens;
|
||||||
|
if (screens.length === 0)
|
||||||
|
return null;
|
||||||
|
for (let i = 0; i < screens.length; i++) {
|
||||||
|
if (screens[i].name === shell.screenName)
|
||||||
|
return screens[i];
|
||||||
|
}
|
||||||
|
return screens[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Density is a property of the SHELL: every panel follows it, so the whole
|
||||||
|
// rail expands and collapses as one. Panels keep their own animation; only
|
||||||
|
// the decision is centralised here.
|
||||||
|
property bool expanded: false
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
shell.expanded = !shell.expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded the rail is over everything; collapsed it drops below ordinary
|
||||||
|
// windows. BOTTOM rather than BACKGROUND for the collapsed bar: it is the
|
||||||
|
// lowest level that still leaves a layer underneath for the backdrop, and
|
||||||
|
// it keeps the rail off the wallpaper's own level.
|
||||||
|
readonly property int barLayer: shell.expanded ? WlrLayer.Overlay : WlrLayer.Bottom
|
||||||
|
readonly property int backdropLayer: shell.expanded ? WlrLayer.Top : WlrLayer.Background
|
||||||
|
|
||||||
|
// SUPER A — see hosts/terra/home/hyprland.nix.
|
||||||
|
GlobalShortcut {
|
||||||
|
name: "chrome"
|
||||||
|
description: "Expand or collapse the hyprchrome bar"
|
||||||
|
onPressed: shell.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backdrop first: it is a layer below the bar, so stacking does not depend
|
||||||
|
// on creation order, but keeping the declaration order the same as the
|
||||||
|
// visual order costs nothing.
|
||||||
|
ChromeBackdrop {
|
||||||
|
screen: shell.targetScreen
|
||||||
|
active: shell.expanded
|
||||||
|
wlrLayer: shell.backdropLayer
|
||||||
|
|
||||||
|
// Collapsed, the scrim only backs the rail, so it needs the band the
|
||||||
|
// rail occupies. contentHeight is the SETTLED height for the current
|
||||||
|
// state — it jumps once per toggle rather than tracking the panels
|
||||||
|
// frame by frame, so the backdrop animates the change itself instead of
|
||||||
|
// chasing a value that is already being animated.
|
||||||
|
barHeight: bar.contentHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
HyprChromeBar {
|
||||||
|
id: bar
|
||||||
|
|
||||||
|
screen: shell.targetScreen
|
||||||
|
// Set here, not from the window's own `screen`: reading that inside
|
||||||
|
// `visible` is circular — a hidden window has no screen to report.
|
||||||
|
visible: shell.targetScreen !== null
|
||||||
|
expanded: shell.expanded
|
||||||
|
wlrLayer: shell.barLayer
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ Item {
|
|||||||
// Where the summary sits when collapsed: just past the slug chip, and
|
// Where the summary sits when collapsed: just past the slug chip, and
|
||||||
// centred in the strip the panel collapses to, which is sized to the
|
// centred in the strip the panel collapses to, which is sized to the
|
||||||
// summary itself (or the height floor, whichever is taller).
|
// summary itself (or the height floor, whichever is taller).
|
||||||
readonly property real briefLeft: panel.headerContentWidth
|
readonly property real briefLeft: panel.headerContentWidth + 6
|
||||||
readonly property real collapsedHeight: Math.max(panel.minimumHeight, brief.height + panel.headerPadding * 2)
|
readonly property real collapsedHeight: Math.max(panel.minimumHeight, brief.height + panel.headerPadding * 2)
|
||||||
readonly property real briefTop: Math.round((panel.collapsedHeight - brief.height) / 2)
|
readonly property real briefTop: Math.round((panel.collapsedHeight - brief.height) / 2)
|
||||||
|
|
||||||
@@ -154,24 +154,6 @@ Item {
|
|||||||
PathLine { x: 0; y: panel.offsetY }
|
PathLine { x: 0; y: panel.offsetY }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connecting edges — drawn only on a side whose chamfer is off, which is
|
|
||||||
// exactly where a neighbour butts against this panel.
|
|
||||||
ShapePath {
|
|
||||||
fillColor: "transparent"
|
|
||||||
strokeColor: Theme.accent
|
|
||||||
strokeWidth: panel.rightChamfer ? 0 : panel.connectorWidth
|
|
||||||
startX: panelShape.width; startY: panel.offsetY
|
|
||||||
PathLine { x: panelShape.width; y: panelShape.height }
|
|
||||||
}
|
|
||||||
|
|
||||||
ShapePath {
|
|
||||||
fillColor: "transparent"
|
|
||||||
strokeColor: Theme.accent
|
|
||||||
strokeWidth: panel.leftChamfer ? 0 : panel.connectorWidth
|
|
||||||
startX: 0; startY: panel.offsetY
|
|
||||||
PathLine { x: 0; y: panelShape.height }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upper left accent line
|
// Upper left accent line
|
||||||
ShapePath {
|
ShapePath {
|
||||||
|
|
||||||
@@ -206,7 +188,7 @@ Item {
|
|||||||
y: Math.round((panel.headerHeight - height) / 2)
|
y: Math.round((panel.headerHeight - height) / 2)
|
||||||
// Puts the title where the accent line ends: chip + headerPadding on
|
// Puts the title where the accent line ends: chip + headerPadding on
|
||||||
// both sides of it.
|
// both sides of it.
|
||||||
spacing: panel.headerPadding
|
spacing: panel.headerPadding + 6
|
||||||
|
|
||||||
// Header slug — the one piece that survives a collapse, so the strip
|
// Header slug — the one piece that survives a collapse, so the strip
|
||||||
// still says which panel it is.
|
// still says which panel it is.
|
||||||
@@ -223,7 +205,7 @@ Item {
|
|||||||
text: panel.panelId
|
text: panel.panelId
|
||||||
color: Theme.surface
|
color: Theme.surface
|
||||||
font.family: Theme.microFont
|
font.family: Theme.microFont
|
||||||
font.pixelSize: 9
|
font.pixelSize: 11
|
||||||
font.bold: true
|
font.bold: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import QtQuick
|
||||||
|
import qs.hyprchrome.theme
|
||||||
|
import qs.hyprchrome.widgets.panels
|
||||||
|
|
||||||
|
// Which workspace each monitor is currently showing, in the hyprchrome panel
|
||||||
|
// chrome, in both of BarPanel's densities.
|
||||||
|
//
|
||||||
|
// One indicator in both: a box carrying the workspace's name, filled accent
|
||||||
|
// while its monitor is showing it and outlined otherwise. Collapsed, each
|
||||||
|
// output gets exactly one — the workspace it is on. Expanded, it gets the whole
|
||||||
|
// strip it cycles through, at a larger cell, with the shown one filled.
|
||||||
|
//
|
||||||
|
// Expanded, the strips line up in a column: the output name sits in a
|
||||||
|
// fixed-width cell, so the boxes start at the same x on every line regardless
|
||||||
|
// of how long a connector name is.
|
||||||
|
//
|
||||||
|
// Both densities read the same two models, so they cannot disagree. Hyprland's
|
||||||
|
// own distinction is kept: `active` is the workspace its monitor is showing
|
||||||
|
// (one per output), `focused` is the single one taking input — so the fill
|
||||||
|
// marks the shown workspace and the accent marker marks where the keyboard is.
|
||||||
|
//
|
||||||
|
// Display only: workspaces expose activate(), but BarPanel's density toggle
|
||||||
|
// covers the whole panel, so a chip could not receive the click anyway.
|
||||||
|
BarPanel {
|
||||||
|
id: panel
|
||||||
|
|
||||||
|
panelId: "WKS"
|
||||||
|
title: "WORKSPACES"
|
||||||
|
meta: panel.monitorCount + (panel.monitorCount === 1 ? " OUTPUT" : " OUTPUTS")
|
||||||
|
|
||||||
|
readonly property int monitorCount: Hyprland.monitors.values.length
|
||||||
|
|
||||||
|
// Sizes itself horizontally, like the tray: how many outputs a session has
|
||||||
|
// is not something the bar can hardcode.
|
||||||
|
implicitWidth: Math.max(panel.headerMinWidth,
|
||||||
|
panel.briefLeft + brief.implicitWidth + panel.padding,
|
||||||
|
panel.padding * 2 + outputs.implicitWidth)
|
||||||
|
|
||||||
|
// Height of one expanded output line; every cell on it centres against this.
|
||||||
|
readonly property int lineHeight: 26
|
||||||
|
|
||||||
|
// Width of the output-name cell, which is what makes the strips align.
|
||||||
|
// Fixed rather than measured: a connector name is "DP-2" or "HDMI-A-1", and
|
||||||
|
// the alternative is probing every name's rendered width to take a maximum,
|
||||||
|
// which costs a hidden Text per output to save nothing. Anything longer
|
||||||
|
// elides.
|
||||||
|
readonly property int nameWidth: 64
|
||||||
|
|
||||||
|
// What to call a workspace. Hyprland numbers them, but a named workspace
|
||||||
|
// carries its name instead and a scratchpad arrives as "special:<name>" —
|
||||||
|
// the prefix is noise once it is sitting next to a monitor's name.
|
||||||
|
function label(ws): string {
|
||||||
|
if (!ws)
|
||||||
|
return "--";
|
||||||
|
const name = ws.name ?? "";
|
||||||
|
if (name.startsWith("special:"))
|
||||||
|
return name.slice(8).toUpperCase();
|
||||||
|
return (name.length > 0 ? name : String(ws.id)).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ordinary workspaces on one output, lowest id first. Specials share
|
||||||
|
// the same list under negative ids: they show up as the active workspace
|
||||||
|
// when one is open, but never as a slot in the strip, which is meant to be
|
||||||
|
// the fixed set the output cycles through.
|
||||||
|
function slots(monitor): var {
|
||||||
|
return Hyprland.workspaces.values
|
||||||
|
.filter(ws => ws.monitor === monitor && ws.id > 0)
|
||||||
|
.sort((a, b) => a.id - b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsed: output name and the one box it is showing.
|
||||||
|
summary: Row {
|
||||||
|
id: brief
|
||||||
|
|
||||||
|
spacing: 14
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: Hyprland.monitors
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: briefOutput
|
||||||
|
|
||||||
|
required property HyprlandMonitor modelData
|
||||||
|
|
||||||
|
spacing: 8
|
||||||
|
height: 18
|
||||||
|
|
||||||
|
// A Row aligns its children by their tops only, so the label
|
||||||
|
// takes the box's height and centres its text in it.
|
||||||
|
Text {
|
||||||
|
text: briefOutput.modelData.name
|
||||||
|
color: briefOutput.modelData.focused ? Theme.accent : Theme.muted
|
||||||
|
font.family: Theme.microFont
|
||||||
|
font.pixelSize: 11
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
height: parent.height
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Chip {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
modelData: briefOutput.modelData.activeWorkspace
|
||||||
|
// Filled by construction: this box IS the output's active
|
||||||
|
// workspace, so it does not wait on the flag that says so.
|
||||||
|
shown: true
|
||||||
|
cell: 16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: panel.monitorCount === 0
|
||||||
|
text: "NO OUTPUTS"
|
||||||
|
color: Theme.muted
|
||||||
|
font.family: Theme.microFont
|
||||||
|
font.pixelSize: 9
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
height: 18
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded: one line per output, strips aligned.
|
||||||
|
Column {
|
||||||
|
id: outputs
|
||||||
|
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: Hyprland.monitors
|
||||||
|
|
||||||
|
Output {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: panel.monitorCount === 0
|
||||||
|
text: "NO OUTPUTS"
|
||||||
|
color: Theme.muted
|
||||||
|
font.family: Theme.microFont
|
||||||
|
font.pixelSize: 9
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
height: panel.lineHeight
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One output: focus marker, name, then its workspace strip. Every cell is
|
||||||
|
// lineHeight tall and centres its own content, so the pieces sit on one
|
||||||
|
// line and the same cell widths repeat down the column.
|
||||||
|
component Output: Row {
|
||||||
|
id: output
|
||||||
|
|
||||||
|
required property HyprlandMonitor modelData
|
||||||
|
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
// Marker rather than a colored name: the focused output has to be
|
||||||
|
// findable without reading anything.
|
||||||
|
Rectangle {
|
||||||
|
width: 3
|
||||||
|
height: panel.lineHeight
|
||||||
|
color: output.modelData.focused ? Theme.accent : Theme.hair
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: panel.nameWidth
|
||||||
|
height: panel.lineHeight
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: parent.width
|
||||||
|
text: output.modelData.name
|
||||||
|
color: output.modelData.focused ? Theme.accent : Theme.muted
|
||||||
|
font.family: Theme.microFont
|
||||||
|
font.pixelSize: 10
|
||||||
|
font.letterSpacing: 1.4
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: strip.implicitWidth
|
||||||
|
height: panel.lineHeight
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: strip
|
||||||
|
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: panel.slots(output.modelData)
|
||||||
|
|
||||||
|
Chip {
|
||||||
|
cell: 22
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The indicator, at whatever size the density asks for: filled accent while
|
||||||
|
// its output is showing that workspace, outlined otherwise.
|
||||||
|
component Chip: Rectangle {
|
||||||
|
id: chip
|
||||||
|
|
||||||
|
required property HyprlandWorkspace modelData
|
||||||
|
|
||||||
|
// Box height; the width grows with the label and the type scales with
|
||||||
|
// the box, so one component covers both densities.
|
||||||
|
property int cell: 16
|
||||||
|
property bool shown: chip.modelData?.active ?? false
|
||||||
|
|
||||||
|
readonly property bool urgent: chip.modelData?.urgent ?? false
|
||||||
|
|
||||||
|
width: Math.max(chip.cell, chipText.implicitWidth + chip.cell / 2)
|
||||||
|
height: chip.cell
|
||||||
|
color: chip.shown ? Theme.accent : "transparent"
|
||||||
|
border.width: 1
|
||||||
|
border.color: chip.urgent ? Theme.hot : chip.shown ? Theme.accent : Theme.hair
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: chipText
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: panel.label(chip.modelData)
|
||||||
|
color: chip.shown ? Theme.surface : chip.urgent ? Theme.hot : Theme.muted
|
||||||
|
font.family: Theme.microFont
|
||||||
|
font.pixelSize: 11
|
||||||
|
font.bold: chip.shown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import qs.hyprchrome.widgets.vitals
|
|||||||
|
|
||||||
Scope {
|
Scope {
|
||||||
// Dense multi-monitor status rail; visual core is headlessly renderable.
|
// Dense multi-monitor status rail; visual core is headlessly renderable.
|
||||||
HyprChromeBar {}
|
HyprChromeShell {}
|
||||||
|
|
||||||
// App launcher variants — 1–11; variant 8 remains the primary HUD.
|
// App launcher variants — 1–11; variant 8 remains the primary HUD.
|
||||||
LauncherStack {} // 1 — left vertical list
|
LauncherStack {} // 1 — left vertical list
|
||||||
|
|||||||
Reference in New Issue
Block a user