feat(quickshell): cap the rail's open chamfers and trace between them
A chamfer with no neighbour behind it now carries the corner it removed, put back OUTSIDE the panel as a detached accent triangle. capGap is the perpendicular distance from the cut, hence the per-axis shift of capGap over root 2: the cap moves along the cut's normal, not along an axis. The trace joining two caps belongs to the RAIL, not the panel — the run it draws is the gap BETWEEN two panels, which no panel can see. HyprChromeBar filters the row down to panels (a slack Item has no `rightChamfer`, so spacers drop out, and dropping them is exactly what makes a trace span them), then joins each right cap to the next left cap: straight, one 45° step at the midpoint of the gap, straight. It meets the middle of each cap's outward face rather than its tip. TestPanel is a staging slot between two spacers. It counts seconds since load, which is the cheapest proof the panel is live, and standing alone it exercises a cap and a trace at both ends — something a rail of butted panels never does. Restructures the module in the same commit, since the moves and the edits above land in the same files: folders are PascalCase, the bar and its widgets moved under Widgets/Bar, and DebugWindow takes the HyprChrome Theme instead of the legacy one. The two singletons are identical today, so that is not a visual fix — it is a palette edit reaching the debug stage in future. Rename detection needs -M40% to follow HyprChromeBar, which grew past the default similarity threshold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U19X5LGTxtq4pb4jdNVivv
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.HyprChrome.Theme
|
||||
|
||||
// Scrim behind the bar: dims the desktop and lays the dense bar's drafting grid
|
||||
// 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.
|
||||
//
|
||||
// Its layer arrives from HyprChromeShell, which derives it together with the
|
||||
// bar's so the two stay exactly one level apart — see that file for why the
|
||||
// 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
|
||||
// clicks land on whatever is underneath rather than on the scrim.
|
||||
PanelWindow {
|
||||
id: backdrop
|
||||
|
||||
property bool active: true
|
||||
|
||||
// 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.
|
||||
// Over a 55% scrim on top of lit windows that is invisible, so it is a
|
||||
// knob rather than a constant.
|
||||
property real gridOpacity: 0.15
|
||||
|
||||
// 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
|
||||
// bar. Derived rather than a literal so it tracks a palette change.
|
||||
// Registration crosses sit on every other intersection of the grid.
|
||||
property color crossColor: Theme.muted
|
||||
property real crossOpacity: 0.45
|
||||
property int crossSize: 20
|
||||
|
||||
// Thickness in STEPS, not pixels: 1 -> 1px, 2 -> 3px, 3 -> 5px. Only odd
|
||||
// widths can straddle a 1px rule symmetrically, so an even pixel count
|
||||
// would push every mark half a pixel off the grid it registers against.
|
||||
property int crossThickness: 2
|
||||
readonly property int crossWeight: Math.max(1, backdrop.crossThickness) * 2 - 1
|
||||
|
||||
property color gridColor: Qt.hsla(Theme.accent.hslHue,
|
||||
Theme.accent.hslSaturation * 0.45,
|
||||
Theme.accent.hslLightness,
|
||||
1)
|
||||
|
||||
// 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.layer: backdrop.wlrLayer
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
color: "transparent"
|
||||
|
||||
readonly property int crossColumns: Math.ceil(backdrop.width / (backdrop.gridSpacing * 2)) + 1
|
||||
readonly property int crossRows: Math.ceil(backdrop.height / (backdrop.gridSpacing * 2)) + 1
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
left: true
|
||||
right: true
|
||||
bottom: true
|
||||
}
|
||||
|
||||
mask: Region {}
|
||||
|
||||
Item {
|
||||
id: scrim
|
||||
|
||||
// Only as tall as the scrim currently reaches; everything inside is
|
||||
// laid out against this, so shrinking it scopes the whole drawing
|
||||
// rather than just clipping it.
|
||||
width: backdrop.width
|
||||
height: backdrop.revealHeight
|
||||
clip: true
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
gradient: Gradient {
|
||||
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
|
||||
// whatever is showing through it — except at the bottom, where it has to
|
||||
// fade with the scrim it sits on.
|
||||
Repeater {
|
||||
model: Math.ceil(scrim.width / backdrop.gridSpacing)
|
||||
|
||||
Rectangle {
|
||||
required property int index
|
||||
|
||||
x: index * backdrop.gridSpacing
|
||||
width: 1
|
||||
height: scrim.height
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: backdrop.gridSolid }
|
||||
GradientStop { position: backdrop.fadeStart; color: backdrop.gridSolid }
|
||||
GradientStop { position: 1; color: backdrop.gridEnd }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
// 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 {
|
||||
required property int index
|
||||
readonly property real line: index * backdrop.gridSpacing
|
||||
|
||||
y: line
|
||||
width: scrim.width
|
||||
height: 1
|
||||
color: backdrop.gridColor
|
||||
opacity: backdrop.gridOpacity * backdrop.fadeAt(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Register marks on every other line, so they land on a 2x grid rather
|
||||
// than on every crossing — sparse enough to read as drafting registration
|
||||
// rather than as texture.
|
||||
Repeater {
|
||||
model: backdrop.crossColumns * backdrop.crossRows
|
||||
|
||||
Item {
|
||||
required property int index
|
||||
|
||||
readonly property int column: index % backdrop.crossColumns
|
||||
readonly property int row: Math.floor(index / backdrop.crossColumns)
|
||||
|
||||
// Marks on every other rule in both directions, so they line up
|
||||
// in columns as well as rows. Both offsets are whole multiples of
|
||||
// gridSpacing, so every mark lands on a real intersection.
|
||||
//
|
||||
// Math.floor, not /2: the item offset and the bars inside it must
|
||||
// round the same way, or an even crossSize sits half a pixel off the
|
||||
// rule it marks.
|
||||
x: column * backdrop.gridSpacing * 2 - Math.floor(backdrop.crossSize / 2)
|
||||
y: row * backdrop.gridSpacing * 2 - Math.floor(backdrop.crossSize / 2)
|
||||
width: backdrop.crossSize
|
||||
height: backdrop.crossSize
|
||||
// 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 {
|
||||
// Placed with the same Math.floor the item's own offset uses.
|
||||
// anchors.verticalCenter halves the height unfloored, so an even
|
||||
// crossSize put the 1px bar half a pixel off the rule it marks.
|
||||
y: Math.floor(backdrop.crossSize / 2) - Math.floor(backdrop.crossWeight / 2)
|
||||
width: parent.width
|
||||
height: backdrop.crossWeight
|
||||
color: backdrop.crossColor
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
x: Math.floor(backdrop.crossSize / 2) - Math.floor(backdrop.crossWeight / 2)
|
||||
width: backdrop.crossWeight
|
||||
height: parent.height
|
||||
color: backdrop.crossColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user