updated theme

This commit is contained in:
2026-08-11 01:53:26 +02:00
parent 0a439d951e
commit 105cf6b3a8
31 changed files with 1218 additions and 976 deletions
+104 -35
View File
@@ -1,9 +1,9 @@
# Takemi
A Persona 5 flavoured Quickshell desktop for Hyprland. Cold teal and near-black
pulled from `~/.wallpapers/takemi_2.jpg`, with crimson as the accent voice.
Everything leans, corners are chopped rather than rounded, and headings carry
the wallpaper's RGB split.
A Persona 5 flavoured Quickshell desktop for Hyprland. Crimson, black and white,
with no gradients anywhere. Everything leans by the same 14°, corners are
chopped rather than rounded, and every surface is one continuous slab divided by
hairlines rather than a cluster of floating cards.
Replaces Waybar, dunst and hyprlock.
@@ -18,37 +18,38 @@ Hyprland starts it from `~/.config/hypr/modules/autostart.conf`.
## Layout
The shell is spread across three surfaces rather than crammed into one bar,
because on a 1080p panel horizontal space is cheap and vertical space is not: a
48px rail costs 2.5% of the width, where the same 48px along the bottom would
have cost 4.4% of the height on top of what the top bar already takes.
Two surfaces. Status moves off the bar and onto a vertical rail, because on a
1080p panel horizontal space is cheap and vertical space is not: a 48px rail
costs 2.5% of the width, where another 48px strip along the bottom would have
cost 4.4% of the height on top of what the bar already takes.
```
┌────────────────────────────────────────────┐
│ ◆ TAKEMI [1][2][3] window title 00:09 │ bar — 48px exclusive
│ ◆ │ 1 2 3 │ window title ♪ TRACK │ 42% │ 00:09 │ bar — 48px exclusive
├────────────────────────────────────────────┤
│ ▐█▌│ rail — 48px exclusive
│ ▐█▌│ rail — 48px exclusive
│ windows ▐ ▌│
▌│
♪ TRACK ╲ ▁▃▅ 42% dock — no exclusive zone
▌│
▐ ▌│
└────────────────────────────────────────────┘
```
- **bar** — identity, workspaces, focused window, clock.
- **rail** — network, bluetooth, audio, mic, backlight, battery, tray,
- **bar** — one slab: identity and workspaces on the left; the focused window,
the player, the CPU/MEM/GPU readout and the clock on the right.
- **rail** — one slab: network, bluetooth, audio, mic, backlight, battery, tray,
notifications, power. Popouts open leftward, into the screen.
- **dock** — now playing and the CPU/MEM/GPU readout. Reserves nothing: windows
tile the full height underneath, and the input mask is clipped to the cluster
so clicks elsewhere in the corner fall through. Idles near-invisible and wakes
on hover or on playback; the crimson corner wedge is the always-on handle that
marks where it lives.
Each surface is a single continuous `P5Panel` with modules divided by sheared
hairlines. `BarPill` is chromeless by default — a module is *content inside* a
surface, not a surface of its own — and marks hover with a crimson underline.
Pass `standalone: true` for the rare thing that really is its own panel.
```
shell.qml entrypoint — bar, rail, dock, power menu, toasts, OSD per screen
config/ palette, type scale, geometry, motion curves, shape variance
shell.qml entrypoint — bar, rail, power menu, toasts, OSD per screen
config/ palette, type scale, geometry, motion curves
components/ the P5 primitives: panels, gauges, sliders, split-ink text
services/ data sources, all singletons
bar/ the three surfaces and their modules
bar/ the two surfaces and their modules
popouts/ hover panels hanging off rail and bar modules
overlays/ power menu, notification toasts, volume/brightness OSD
lock/ session lock
@@ -75,8 +76,8 @@ contentPad * 2`), which keeps the shape maths out of binding loops.
| Tray | rail | activate | app menu | secondary | | |
| Bell | rail | history + mark read | do not disturb | | | history |
| Power | rail | power menu | | | | |
| Media | dock | play/pause | next | raise player | next/prev | full player |
| CPU/MEM/GPU | dock | btop | | | | resource dashboard |
| Media | bar | play/pause | next | raise player | next/prev | full player |
| CPU/MEM/GPU | bar | btop | | | | resource dashboard |
Popouts open on a short dwell and stay while the pointer is over the trigger or
the panel. Clicking pins one open.
@@ -142,15 +143,83 @@ The display face is Archivo Black, set italic almost everywhere. Change
## Palette discipline
Two hues, deliberately: teal carries every resting state, crimson means alarm
and nothing else. The old blue, green and amber remain in `Theme.qml` only as
aliases onto those two families so existing call sites keep working — don't
introduce new uses. `Theme.heat()` is a continuous teal→crimson ramp rather than
a hue cycle, so a number that has gone red means the same thing as a battery
that has gone red. Tray icons are force-flattened to a single colour for the
same reason: app brand colours wreck the palette faster than anything else.
Crimson, black, white. That is the whole palette.
Geometry variance lives in the same file. `Theme.leanFor(i)`, `chopFor(i)` and
`cornersFor(i)` hand each module a different lean and chamfer by index, so
modules stop being congruent parallelograms in an even rhythm — pass a module's
index rather than accepting the defaults.
**Changing the accent.** The colour is written in exactly three places, one per
program:
| File | Line | Covers |
|---|---|---|
| `quickshell/config/Theme.qml` | `readonly property color accent: "#ff2d40"` | the shell |
| `hypr/modules/takemi.conf` | `$accent = rgb(ff2d40)` | window borders, groups |
| `alacritty/takemi.toml` | `red`, cursor, selection | the terminal |
Everything else derives. `accentDim` and `accentSoft` come off `accent` via
`Qt.darker`/`Qt.lighter`, `glow` (the hover highlight) *is* `accent`, and every
Hyprland border, group indicator and focus state references `$accent`. Change
those lines and the whole setup recolours — swap in Persona 4's yellow or
Persona 3's blue and nothing else needs touching.
## The rest of the desktop
- **Alacritty** — `alacritty/takemi.toml`, selected by the `import` in
`alacritty.toml`. The Catppuccin files are left in place, just unreferenced.
Near-black ground, white text, crimson cursor and selection. The sixteen ANSI
slots keep their hues because syntax highlighters need to tell them apart, but
every one is desaturated so nothing competes with the accent.
- **Neovim** — `nvim/lua/takemi.lua`, loaded through `nvim/colors/takemi.lua`
so `:colorscheme takemi` and `require("takemi").lualine()` both work. Same
palette, same rule: crimson is spent only on keywords, types and errors,
functions are plain white-bold, and `terminal_color_0..15` mirror
`takemi.toml` slot for slot so `:terminal` matches the shell around it.
- **Fish** — `fish/conf.d/takemi_theme.fish`, with a preview copy in
`fish/themes/Takemi.theme` for `fish_config`. Replaced the Catppuccin
`fish_frozen_theme.fish` that fish 4.3 generated on upgrade. The prompt arrow
is white and turns crimson on a non-zero exit — the shell's one "wrong"
signal, and so the one place that earns the accent.
- **Fuzzel** — `fuzzel/takemi.ini`, selected by the `include` in `fuzzel.ini`.
Square corners rather than the stock radius, and a fully opaque ground.
- **No glass anywhere.** Alacritty is `opacity = 1.0, blur = false`, and
Hyprland's `decoration:blur` is disabled with both opacities at `1.0`. Blur is
the signature of the frosted-glass look, which is the opposite of flat ink on
flat ground — and with nothing translucent left it was costing three render
passes for no visible result.
White is the resting voice — text, icons, idle indicators. Crimson is the only
accent and always means one thing: active, or wrong. Black is structure, used as
a hard keyline rather than as a soft shadow. **There are no gradients in this
shell**; every fill is flat. `Theme.sheen` still exists as a no-op property
because a teal-to-transparent wash on every panel was the single biggest source
of the murky blue cast the design used to have.
`primary`, `glow`, `blue`, `ok` and `warn` survive in `Theme.qml` only as
aliases onto white, crimson and grey, so the existing call sites keep working —
don't introduce new uses. `warn` used to alias `accentSoft`, which put a
washed-out pink-red wherever something was merely notable; there is no
half-crimson in this palette, so it aliases the real `accent` now and anything
merely informational (brightness, most readouts) is white instead. `Theme.heat()` is a continuous white→crimson ramp, so
a CPU figure that has gone red means the same thing as a battery that has gone
red. Tray icons are force-flattened to a single colour for the same reason: app
brand colours wreck the palette faster than anything else in the shell.
The wallpaper is teal. The UI used to be teal too, and the two fought each other
into a blue-grey mush. Going monochrome-plus-red is what lets the wallpaper be
the colour in the composition.
## Geometry discipline
**One angle, one chamfer, everywhere.** `Theme.skew` is `tan(14°)` and every
edge in the shell leans by exactly that, in the same direction; every corner is
chopped by exactly `Theme.cut`. A vertical panel (the rail) leans its horizontal
edges by a fraction of its *width*, which works out to the same 14°.
This is deliberate and was learned the hard way. An earlier pass gave every
module a different lean, chamfer and height from per-index variance tables, on
the theory that Persona geometry is "chaotic". It isn't — Persona UI is
meticulously clean, and the chaos lives in composition and motion, not in each
element being a different shape. Randomised geometry just reads as noise. If you
find yourself adding a variance table, don't.
Each surface is **one continuous slab**, with modules divided by sheared
hairline rules rather than by gaps. A row of separate floating cards reads as
clutter; that is what the dividers exist to avoid.
+78 -66
View File
@@ -5,13 +5,13 @@ import "root:/config"
import "root:/components"
import "root:/services"
// One bar per monitor.
// One bar per monitor, and one surface.
//
// Identity, workspaces and the focused window on the left; the clock on the
// right. Status moved to the rail and the player and metrics moved to the dock,
// which is what buys the room for this to breathe — nothing here is fighting
// for horizontal space any more, so the title gets a generous cap and the type
// can run large instead of being squeezed to fit between two clusters.
// Identity and workspaces on the left, the player, metrics and clock on the
// right, all inside a single continuous slab and separated by hairline rules
// rather than by gaps. Nothing floats and nothing has its own card. Status
// lives on the rail, which is likewise one slab — two surfaces, not a dozen
// islands.
PanelWindow {
id: root
@@ -37,87 +37,99 @@ PanelWindow {
exclusiveZone: Theme.barHeight + Theme.barMargin
color: "transparent"
// The slab. One panel, full width, flat fill, black keyline.
P5Panel {
id: slab
anchors.fill: parent
lean: Theme.skew
chop: Theme.cut
chopCorners: [1, 3]
padding: 0
fill: Theme.surface
fillOpacity: 1.0
border: Theme.alpha(Theme.outline, 0.9)
borderWidth: 1
slash: true
slashColor: Theme.accent
slashWidth: 5
}
readonly property real edgeInset: Theme.barHeight * Theme.skew + Theme.padM
// ------------------------------------------------------------------ left
Row {
id: leftCluster
id: leftGroup
anchors.left: parent.left
anchors.leftMargin: root.edgeInset
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.barGap
spacing: 0
Sigil {
id: sigil
screenRef: root.modelData
}
BarPill {
id: spacesPill
accent: Theme.primary
padding: Theme.padM
interactive: false
lean: Theme.leanFor(1)
chop: Theme.chopFor(1)
chopCorners: Theme.cornersFor(1)
implicitWidth: spacesRow.implicitWidth + contentPad * 2
Divider {}
Row {
id: spacesRow
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
spacing: Theme.padL
Workspaces {
id: spaces
anchors.verticalCenter: parent.verticalCenter
screenRef: root.modelData
}
Workspaces {
id: spaces
anchors.verticalCenter: parent.verticalCenter
screenRef: root.modelData
}
Divider {}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 1
height: 18
color: Theme.alpha(Theme.outline, 0.8)
}
ActiveWindow {
anchors.verticalCenter: parent.verticalCenter
screenRef: root.modelData
// The only thing competing for this row is the clock, so the
// title can have most of the bar.
maxWidth: Math.max(120, root.width
- sigil.implicitWidth
- clock.implicitWidth
- spaces.implicitWidth
- Theme.barGap * 3
- Theme.padL * 2
- Theme.padXL
- spacesPill.contentPad * 2)
}
}
ActiveWindow {
anchors.verticalCenter: parent.verticalCenter
screenRef: root.modelData
maxWidth: Math.max(120, root.width
- leftGroup.x - rightGroup.width - sigil.implicitWidth
- spaces.implicitWidth - root.edgeInset * 2 - Theme.padXL)
}
}
// ----------------------------------------------------------------- right
Clock {
id: clock
Row {
id: rightGroup
anchors.right: parent.right
anchors.rightMargin: root.edgeInset
anchors.verticalCenter: parent.verticalCenter
screenRef: root.modelData
spacing: 0
MediaPill {
screenRef: root.modelData
}
Divider {}
Resources {
screenRef: root.modelData
}
Divider {}
Clock {
screenRef: root.modelData
}
}
// A hairline of crimson under the whole bar — the Persona 5 underscore.
Rectangle {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 1
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: 0.18; color: Theme.alpha(Theme.accent, 0.55) }
GradientStop { position: 0.5; color: Theme.alpha(Theme.primary, 0.35) }
GradientStop { position: 0.82; color: Theme.alpha(Theme.accent, 0.55) }
GradientStop { position: 1.0; color: "transparent" }
// A hairline rule between modules, sheared to the house angle. This is what
// divides the bar now — gaps between cards are what made it look scattered.
component Divider: Item {
anchors.verticalCenter: parent.verticalCenter
implicitWidth: Theme.padM * 2
implicitHeight: Theme.barHeight
Rectangle {
anchors.centerIn: parent
width: 1
height: 20
color: Theme.alpha(Theme.outline, 0.9)
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, -Theme.skew, 0, 20 * Theme.skew,
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
}
}
}
+76 -44
View File
@@ -2,12 +2,19 @@ import QtQuick
import "root:/config"
import "root:/components"
// Shared chrome for every bar module: a leaning slab that lifts and lights up
// under the pointer, and forwards clicks and scrolls to the widget.
// Shared chrome for every bar module.
//
// The bar is one continuous slab, so by default a module draws no panel of its
// own — it is content inside the bar's surface, and marks hover with a crimson
// underline rather than by lighting up a card. That is what stops the bar from
// reading as a row of floating islands.
//
// `standalone: true` brings back the individual leaning slab, for the few
// places that genuinely are separate surfaces.
Item {
id: root
property color accent: Theme.primary
property color accent: Theme.accent
property bool active: false
property bool slash: false
property real lean: Theme.skew
@@ -15,18 +22,29 @@ Item {
property real padding: Theme.padM
property bool interactive: true
property bool halftone: false
property bool standalone: false
// Chamfer, forwarded so modules can differ from one another instead of all
// sharing the panel default.
property real chop: Theme.cut
property var chopCorners: [1, 3]
readonly property bool hovered: hover.hovered
readonly property real leanInset: panel.leanInset
readonly property real leanInset: standalone ? panelLoader.item?.leanInset ?? 0 : 0
// Total horizontal inset a pill must add on top of its content width.
readonly property real contentPad: panel.contentPad
readonly property alias panelItem: panel
// The hover rule's own strip, reserved at the bottom of the module.
//
// The rule used to be drawn into the same box as the content, so anything
// taller than about 32px — the media pill's art, the metric meters — had a
// crimson line struck through its bottom edge on hover. Reserving the band
// and centring content in what is left is what keeps the rule under the
// module instead of across it.
readonly property real ruleHeight: 2
readonly property real ruleInset: 5
readonly property real ruleBand: root.standalone ? 0 : root.ruleHeight + root.ruleInset
// Total horizontal inset a module must add on top of its content width.
readonly property real contentPad: standalone
? (panelLoader.item?.contentPad ?? root.padding)
: root.padding
default property alias content: holder.data
@@ -37,49 +55,63 @@ Item {
implicitHeight: Theme.barHeight
P5Panel {
id: panel
Loader {
id: panelLoader
anchors.fill: parent
lean: root.lean
leanLeft: root.leanLeft
chop: root.chop
chopCorners: root.chopCorners
padding: root.padding
halftone: root.halftone
halftoneColor: root.accent
sheen: true
active: root.standalone
fill: root.active ? Theme.surfaceAlt : Theme.surface
fillOpacity: root.hovered ? 0.98 : 0.88
border: root.hovered || root.active
? Theme.alpha(root.accent, 0.9)
: Theme.alpha(Theme.outline, 0.7)
borderWidth: root.hovered || root.active ? Theme.stroke : 1
sourceComponent: P5Panel {
lean: root.lean
leanLeft: root.leanLeft
chop: root.chop
chopCorners: root.chopCorners
padding: root.padding
halftone: root.halftone
halftoneColor: root.accent
slash: root.slash || root.active
slashColor: root.accent
fill: root.active ? Theme.surfaceAlt : Theme.surface
fillOpacity: 1.0
border: root.hovered || root.active
? root.accent
: Theme.alpha(Theme.outline, 0.9)
borderWidth: root.hovered || root.active ? Theme.stroke : 1
Behavior on fillOpacity {
NumberAnimation { duration: Theme.durFast }
}
Behavior on border {
ColorAnimation { duration: Theme.durBase }
}
Item {
id: holder
anchors.fill: parent
slash: root.slash || root.active
slashColor: root.accent
}
}
// Lift toward the pointer. Subtle, but it makes the bar feel alive.
transform: Translate {
y: root.hovered && root.interactive ? -2 : 0
Item {
id: holder
anchors.fill: parent
anchors.leftMargin: root.contentPad
anchors.rightMargin: root.contentPad
anchors.bottomMargin: root.ruleBand
}
Behavior on y {
NumberAnimation {
duration: Theme.durBase
easing.type: Easing.OutBack
// Hover mark for in-bar modules: a crimson rule under the content, wiping
// out from the leading edge. Sheared to match the one angle everything uses.
Item {
visible: !root.standalone && root.interactive
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.bottomMargin: root.ruleInset
height: root.ruleHeight
clip: true
Rectangle {
height: parent.height
width: root.hovered || root.active ? parent.width : 0
color: root.accent
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, -Theme.skew, 0, 2 * Theme.skew,
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
Behavior on width {
NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutExpo }
}
}
}
-117
View File
@@ -1,117 +0,0 @@
import QtQuick
import QtQuick.Shapes
import Quickshell
import Quickshell.Wayland
import "root:/config"
import "root:/components"
import "root:/services"
// The floating bottom-left cluster: now playing, plus the CPU/MEM/GPU readout.
//
// This one reserves no space at all. Windows tile the full height of the screen
// underneath it, and the input mask is clipped to the cluster itself so clicks
// anywhere else in the corner fall straight through to whatever is below.
//
// At rest it sits at a quarter opacity — present enough to read at a glance,
// faint enough to ignore — and snaps to full weight when the pointer arrives.
PanelWindow {
id: root
required property var modelData
screen: modelData
WlrLayershell.layer: WlrLayer.Top
WlrLayershell.namespace: "takemi-dock"
anchors {
bottom: true
left: true
}
margins {
bottom: Theme.dockMargin
left: Theme.dockMargin
}
implicitWidth: cluster.implicitWidth
implicitHeight: cluster.implicitHeight
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore
color: "transparent"
// Only the cluster takes pointer input; the rest of the surface is
// transparent to clicks.
mask: Region { item: cluster }
readonly property bool awake: clusterHover.hovered || Media.playing
// A crimson wedge pinned to the corner. This is the one part of the dock
// that never fades — it marks where the cluster lives so a nearly
// invisible idle state is still something you can aim at, and it doubles
// as the corner furniture the layout was missing.
Shape {
anchors.left: parent.left
anchors.bottom: parent.bottom
width: 26
height: 26
z: -1
preferredRendererType: Shape.CurveRenderer
opacity: root.awake ? 0.0 : 0.9
Behavior on opacity {
NumberAnimation { duration: Theme.durBase }
}
ShapePath {
fillColor: Theme.accent
strokeColor: "transparent"
PathPolyline {
path: [
Qt.point(0, 26),
Qt.point(0, 6),
Qt.point(26, 26),
Qt.point(0, 26)
]
}
}
}
Row {
id: cluster
spacing: Theme.barGap
opacity: root.awake ? 1.0 : Theme.dockIdleOpacity
Behavior on opacity {
NumberAnimation {
duration: Theme.durSlow
easing.type: Easing.OutExpo
}
}
// Lift off the corner as it wakes.
transform: Translate {
y: root.awake ? 0 : 4
Behavior on y {
NumberAnimation {
duration: Theme.durSlow
easing.type: Easing.OutBack
}
}
}
HoverHandler {
id: clusterHover
}
MediaPill {
screenRef: root.modelData
}
Resources {
screenRef: root.modelData
}
}
}
+122 -169
View File
@@ -1,5 +1,4 @@
import QtQuick
import QtQuick.Shapes
import Quickshell
import Quickshell.Wayland
import "root:/config"
@@ -7,17 +6,15 @@ import "root:/components"
import "root:/services"
import "root:/popouts"
// The right-hand status rail.
// The right-hand status rail — one slab, not a stack of cards.
//
// Everything that used to be crammed into the right third of the top bar lives
// here instead: connectivity, audio, power, tray, notifications. On a 1080p
// panel this is the cheap edge — 48px of width is 2.5% of the screen, where the
// same 48px of height would have been 4.4% on top of what the top bar already
// takes.
// Connectivity, audio, power, tray and notifications all sit inside a single
// continuous column, divided by hairline rules the same way the bar divides its
// modules. Same lean, same chamfer, same keyline as the bar and the dock, so
// the three surfaces read as one system.
//
// Slabs are grouped by what they do rather than spaced evenly, and each pulls a
// different lean and chamfer from the shape tables, so the column reads as a
// stack of distinct cards instead of a ladder.
// On a 1080p panel this is the cheap edge: 48px of width is 2.5% of the screen,
// where the same 48px of height would be 4.4% on top of what the bar takes.
PanelWindow {
id: root
@@ -43,131 +40,91 @@ PanelWindow {
exclusiveZone: Theme.railWidth + Theme.barMargin
color: "transparent"
// Corner furniture. The dock pins a crimson wedge to the bottom-left; this
// is its opposite number, so the desktop is bracketed on the diagonal
// rather than merely edged. Decorative only — it sits under the slabs and
// takes no input.
Shape {
anchors.right: parent.right
anchors.bottom: parent.bottom
width: 30
height: 30
z: -1
preferredRendererType: Shape.CurveRenderer
opacity: 0.9
ShapePath {
fillColor: Theme.accent
strokeColor: "transparent"
PathPolyline {
path: [
Qt.point(30, 30),
Qt.point(30, 4),
Qt.point(2, 30),
Qt.point(30, 30)
]
}
}
// The slab.
P5Panel {
id: slab
anchors.fill: parent
vertical: true
lean: Theme.skew
chop: Theme.cut
chopCorners: [1, 3]
padding: 0
fill: Theme.surface
fillOpacity: 1.0
border: Theme.alpha(Theme.outline, 0.9)
borderWidth: 1
slash: true
slashColor: Theme.accent
slashWidth: 5
}
readonly property real edgeInset: Theme.railWidth * Theme.skew + Theme.padM
// ------------------------------------------------------------------- top
Column {
id: upper
anchors.top: parent.top
anchors.topMargin: root.edgeInset
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.railGap
spacing: 0
// Connectivity.
RailGroup {
variant: 0
accent: Sys.netUp ? Theme.primary : Theme.muted
implicitHeight: linkCol.implicitHeight + contentPad * 2
Column {
id: linkCol
anchors.centerIn: parent
spacing: Theme.padS
RailIcon {
id: netIcon
icon: Net.icon
tint: Theme.primary
dim: !Sys.netUp
onClicked: netPopout.pinned = !netPopout.pinned
onRightClicked: Net.toggleWifi()
onMiddleClicked: Actions.openNetworkSettings()
}
RailIcon {
id: btIcon
icon: Bt.icon
tint: Theme.primary
dim: !Bt.enabled
onClicked: btPopout.pinned = !btPopout.pinned
onRightClicked: Bt.toggle()
}
}
RailIcon {
id: netIcon
icon: Net.icon
dim: !Sys.netUp
onClicked: netPopout.pinned = !netPopout.pinned
onRightClicked: Net.toggleWifi()
onMiddleClicked: Actions.openNetworkSettings()
}
// Audio and backlight.
RailGroup {
variant: 1
accent: Audio.muted || Audio.micMuted ? Theme.accent : Theme.primary
implicitHeight: audioCol.implicitHeight + contentPad * 2
Column {
id: audioCol
anchors.centerIn: parent
spacing: Theme.padS
RailIcon {
id: volIcon
icon: Audio.icon
readout: Audio.muted ? "" : Audio.volumePercent + "%"
alert: Audio.muted
onClicked: Audio.toggleMute()
onRightClicked: audioPopout.pinned = !audioPopout.pinned
onMiddleClicked: Actions.openAudioSettings()
onScrolled: delta => Audio.changeVolume(delta > 0 ? 0.05 : -0.05)
}
RailIcon {
id: micIcon
icon: Audio.micIcon
alert: Audio.micMuted
onClicked: Audio.toggleMicMute()
onRightClicked: audioPopout.pinned = !audioPopout.pinned
onScrolled: delta => Audio.setMicVolume(Audio.micVolume + (delta > 0 ? 0.05 : -0.05))
}
RailIcon {
id: brightIcon
visible: Brightness.available
icon: Brightness.icon
readout: Math.round(Brightness.percent) + "%"
tint: Theme.primary
onScrolled: delta => Brightness.change(delta > 0 ? 5 : -5)
}
}
RailIcon {
id: btIcon
icon: Bt.icon
dim: !Bt.enabled
onClicked: btPopout.pinned = !btPopout.pinned
onRightClicked: Bt.toggle()
}
// Battery.
RailGroup {
variant: 2
Divider {}
RailIcon {
id: volIcon
icon: Audio.icon
readout: Audio.muted ? "" : Audio.volumePercent + "%"
alert: Audio.muted
onClicked: Audio.toggleMute()
onRightClicked: audioPopout.pinned = !audioPopout.pinned
onMiddleClicked: Actions.openAudioSettings()
onScrolled: delta => Audio.changeVolume(delta > 0 ? 0.05 : -0.05)
}
RailIcon {
id: micIcon
icon: Audio.micIcon
alert: Audio.micMuted
onClicked: Audio.toggleMicMute()
onRightClicked: audioPopout.pinned = !audioPopout.pinned
onScrolled: delta => Audio.setMicVolume(Audio.micVolume + (delta > 0 ? 0.05 : -0.05))
}
RailIcon {
id: brightIcon
visible: Brightness.available
icon: Brightness.icon
readout: Math.round(Brightness.percent) + "%"
onScrolled: delta => Brightness.change(delta > 0 ? 5 : -5)
}
Divider { visible: Battery.available }
RailIcon {
id: battIcon
visible: Battery.available
accent: Battery.critical ? Theme.accent : Theme.primary
implicitHeight: battIcon.implicitHeight + contentPad * 2
RailIcon {
id: battIcon
anchors.centerIn: parent
icon: Battery.icon
readout: Math.round(Battery.percent) + "%"
tint: Battery.tint
alert: Battery.critical
pulsing: Battery.critical
onClicked: battPopout.pinned = !battPopout.pinned
}
icon: Battery.icon
readout: Math.round(Battery.percent) + "%"
alert: Battery.critical
pulsing: Battery.critical
onClicked: battPopout.pinned = !battPopout.pinned
}
}
@@ -175,60 +132,56 @@ PanelWindow {
Column {
id: lower
anchors.bottom: parent.bottom
// Clear of the corner wedge, which would otherwise sit entirely behind
// the bottom slab and never be seen.
anchors.bottomMargin: 26
anchors.bottomMargin: root.edgeInset
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.railGap
spacing: 0
// Tray.
RailGroup {
variant: 3
visible: tray.visible
accent: Theme.primary
implicitHeight: tray.implicitHeight + contentPad * 2
Tray {
id: tray
anchors.centerIn: parent
}
Tray {
id: tray
anchors.horizontalCenter: parent.horizontalCenter
}
// Notifications and power — the two that act on the session rather than
// report on it, so they sit apart at the foot of the rail.
RailGroup {
variant: 4
accent: Notifs.dnd ? Theme.muted : Theme.primary
implicitHeight: actionCol.implicitHeight + contentPad * 2
Divider { visible: tray.visible }
Column {
id: actionCol
anchors.centerIn: parent
spacing: Theme.padS
RailIcon {
id: bellIcon
icon: Notifs.icon
readout: Notifs.unread > 0
? (Notifs.unread > 9 ? "9+" : String(Notifs.unread))
: ""
dim: Notifs.dnd
alert: Notifs.hasUnread && !Notifs.dnd
onClicked: {
notifPopout.pinned = !notifPopout.pinned;
Notifs.markRead();
}
onRightClicked: Notifs.toggleDnd()
}
RailIcon {
id: bellIcon
icon: Notifs.icon
readout: Notifs.unread > 0
? (Notifs.unread > 9 ? "9+" : String(Notifs.unread))
: ""
tint: Notifs.hasUnread ? Theme.accent : Theme.primary
dim: Notifs.dnd
alert: Notifs.hasUnread && !Notifs.dnd
onClicked: {
notifPopout.pinned = !notifPopout.pinned;
Notifs.markRead();
}
onRightClicked: Notifs.toggleDnd()
}
RailIcon {
id: powerIcon
icon: "󰐥"
alert: Actions.powerMenuOpen
onClicked: Actions.togglePowerMenu()
}
}
RailIcon {
id: powerIcon
icon: "󰐥"
tint: Actions.powerMenuOpen ? Theme.accent : Theme.subtext
onClicked: Actions.togglePowerMenu()
}
// The rail's counterpart to the bar's divider: a horizontal hairline,
// sheared to the same angle the rail's own edges lean at.
component Divider: Item {
implicitWidth: Theme.railWidth
implicitHeight: Theme.padM + 4
Rectangle {
anchors.centerIn: parent
width: 22
height: 1
color: Theme.alpha(Theme.outline, 0.9)
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, 0, 0, 0,
-Theme.skew, 1, 0, 22 * Theme.skew / 2,
0, 0, 1, 0, 0, 0, 0, 1)
}
}
}
-92
View File
@@ -1,92 +0,0 @@
import QtQuick
import "root:/config"
import "root:/components"
// A slab on the right-hand rail. The vertical counterpart to BarPill: it sizes
// itself to a stacked column rather than a row, and pulls its lean and chamfer
// from the shape-variance tables by `variant`, so no two slabs on the rail are
// congruent.
Item {
id: root
property color accent: Theme.primary
property int variant: 0
property bool active: false
property real padding: Theme.padS
property bool interactive: false
property bool halftone: false
readonly property bool hovered: hover.hovered
readonly property real contentPad: panel.contentPad
readonly property alias panelItem: panel
default property alias content: holder.data
signal clicked()
implicitWidth: Theme.railWidth
P5Panel {
id: panel
anchors.fill: parent
vertical: true
// Scaled down from the horizontal tables: a rail slab leans by a
// fraction of its 48px width, so the raw values would be far too steep.
lean: Theme.leanFor(root.variant) * 0.6
leanLeft: Theme.leanLeftFor(root.variant)
chop: Math.min(Theme.chopFor(root.variant), 10)
chopCorners: Theme.cornersFor(root.variant)
padding: root.padding
halftone: root.halftone
halftoneColor: root.accent
sheen: true
fill: root.active ? Theme.surfaceAlt : Theme.surface
fillOpacity: root.hovered ? 0.98 : 0.88
border: root.hovered || root.active
? Theme.alpha(root.accent, 0.9)
: Theme.alpha(Theme.outline, 0.7)
borderWidth: root.hovered || root.active ? Theme.stroke : 1
slash: root.active
slashColor: root.accent
Behavior on fillOpacity {
NumberAnimation { duration: Theme.durFast }
}
Behavior on border {
ColorAnimation { duration: Theme.durBase }
}
Item {
id: holder
anchors.fill: parent
}
}
// Slabs lift toward the pointer, away from the screen edge.
transform: Translate {
x: root.hovered && root.interactive ? -3 : 0
Behavior on x {
NumberAnimation {
duration: Theme.durBase
easing.type: Easing.OutBack
}
}
}
HoverHandler {
id: hover
enabled: root.interactive
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: root.interactive
acceptedButtons: Qt.LeftButton
onTapped: root.clicked()
}
}
+15 -1
View File
@@ -19,6 +19,10 @@ Item {
readonly property bool hovered: hover.hovered
// Clearance between the wipe and the rail's left edge, chosen so the bar
// stays inside the slab once `scale` has expanded the icon about its centre.
readonly property real wipeInset: 5
signal clicked()
signal rightClicked()
signal middleClicked()
@@ -68,12 +72,22 @@ Item {
// Leading-edge wipe. On the rail this runs vertically down the left side,
// mirroring the slash P5Panel puts on horizontal modules.
//
// Held off the edge by `wipeInset`: the icon spans the rail's full width, so
// at x = 0 this landed exactly on the slab's own border, and the 1.08 hover
// scale then pushed it out past the silhouette onto the desktop. The inset
// has to survive that scale, which is why it is not simply 1 or 2.
//
// It is crimson rather than `tint` because it only ever appears on hover,
// and hover is crimson everywhere else in the shell (Theme.glow). Painting
// it white put a white bar on the rail's edge for no stated reason.
Rectangle {
anchors.left: parent.left
anchors.leftMargin: root.wipeInset
anchors.verticalCenter: parent.verticalCenter
width: 2
height: root.hovered ? layout.implicitHeight : 0
color: root.alert ? Theme.accent : root.tint
color: Theme.glow
Behavior on height {
NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutExpo }
-6
View File
@@ -14,13 +14,7 @@ BarPill {
accent: Theme.heat(Math.max(Sys.cpuUsage, Sys.memPercent))
// Roomier than it was on the bar. In the dock there is nothing to compete
// with, so the figures get real padding instead of being pushed up against
// the slanted edges.
padding: Theme.padM
lean: Theme.leanFor(2)
chop: Theme.chopFor(2)
chopCorners: Theme.cornersFor(2)
implicitWidth: layout.implicitWidth + contentPad * 2
onClicked: Actions.openSystemMonitor()
+13 -14
View File
@@ -20,26 +20,25 @@ BarPill {
onClicked: Actions.launch("fuzzel")
onRightClicked: Actions.reloadShell()
Icon {
// The wordmark. Was the Death arcana skull (nf-md-skull, U+F068C) — a
// pictogram in a shell that otherwise says everything in heavy italic
// type. SplitText puts it in the display face with the crimson-and-black
// offset every other heading carries, so the house mark is built out of
// the same parts as the rest of the bar.
SplitText {
id: mark
anchors.centerIn: parent
text: "󰋠"
font.pixelSize: Theme.fsTitle
text: "P5"
pixelSize: Theme.fsLarge
letterSpacing: 0
color: root.hovered ? Theme.accent : Theme.primary
}
// Spin the mark on click, then settle.
// Kick the ghosts out and let them snap back. The skull could spin a full
// turn and still read as a skull; a wordmark upside down just reads as
// broken, so the click gets the glitch instead.
Connections {
target: root
function onClicked() { spin.restart(); }
}
SequentialAnimation {
id: spin
NumberAnimation {
target: mark; property: "rotation"
from: 0; to: 360; duration: Theme.durSlow
easing.type: Easing.OutExpo
}
function onClicked() { mark.glitch(); }
}
}
+3 -1
View File
@@ -71,7 +71,9 @@ BarPill {
icon: Brightness.icon
label: Math.round(Brightness.percent) + "%"
labelWidth: 34
tint: Theme.warn
// Brightness is never an alarm — it stays white and lets the
// battery be the only thing in the cluster that can go crimson.
tint: Theme.text
onScrolled: delta => Brightness.change(delta > 0 ? 5 : -5)
}
+19 -47
View File
@@ -46,9 +46,7 @@ Item {
id: row
anchors.verticalCenter: parent.verticalCenter
// Uneven gaps. An evenly spaced row of identical ticks reads as a
// progress bar; the whole point here is that it should not.
spacing: 5
spacing: 6
Repeater {
model: root.slots
@@ -65,19 +63,10 @@ Item {
readonly property bool focused: root.focusedId === wsId
readonly property bool hovered: pipHover.hovered
// Each tick pulls its own shear and its own height off the
// variance tables, and sits a little off the baseline, so the
// row is a ragged run of marks rather than a repeating comb.
readonly property real shear: Theme.leanFor(pip.index)
readonly property int baseHeight: [18, 14, 20, 16][pip.index % 4]
readonly property int drift: [0, 2, -1, 1][pip.index % 4]
width: focused ? 36 : (occupied ? 16 : 9)
// The focused tick grows past its neighbours and past the row's
// own height, so it breaks the line instead of sitting in it.
height: focused ? 24 : pip.baseHeight
y: focused ? -2 : pip.drift
// Every tick is the same height and the same shear. Only the
// width changes, and only to say which workspace is focused.
width: focused ? 32 : (occupied ? 16 : 9)
height: 18
Behavior on width {
NumberAnimation {
@@ -86,45 +75,28 @@ Item {
easing.overshoot: 1.6
}
}
Behavior on height {
NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack }
}
Behavior on y {
NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack }
}
// Black offset copy behind the focused tick — the same
// screen-print stack the panels use, at tick scale.
Rectangle {
visible: pip.focused
anchors.fill: parent
x: 3
y: 3
color: Theme.ink
transform: Matrix4x4 {
matrix: Qt.matrix4x4(
1, -pip.shear, 0, pip.height * pip.shear,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1)
}
}
Rectangle {
id: tick
anchors.fill: parent
// Focused is a solid crimson block. Occupied is a white
// block at half weight. Empty is an outline. Three states,
// two colours, no ambiguity.
color: {
if (pip.urgent) return Theme.accent;
if (pip.focused) return Theme.primary;
if (pip.hovered) return Theme.glow;
if (pip.occupied) return Theme.alpha(Theme.primary, 0.55);
return Theme.alpha(Theme.outline, 0.75);
if (pip.focused || pip.urgent) return Theme.accent;
if (pip.hovered) return Theme.text;
if (pip.occupied) return Theme.alpha(Theme.text, 0.5);
return "transparent";
}
border.color: pip.occupied || pip.focused
? "transparent"
: Theme.alpha(Theme.text, 0.35)
border.width: 1
transform: Matrix4x4 {
matrix: Qt.matrix4x4(
1, -pip.shear, 0, pip.height * pip.shear,
1, -Theme.skew, 0, pip.height * Theme.skew,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1)
@@ -141,7 +113,7 @@ Item {
opacity: pip.focused ? 1 : 0
visible: opacity > 0.01
text: pip.wsId
color: Theme.base
color: Theme.ink
font.family: Theme.fontDisplay
font.pixelSize: Theme.fsBody
font.weight: Theme.weightDisplay
+103
View File
@@ -0,0 +1,103 @@
import QtQuick
import QtQuick.Shapes
import "root:/config"
import "root:/components"
// The shared ground for the two full-screen surfaces: a crimson wedge leaning
// at the house angle, a tiled stripe field that sweeps in from the left, and a
// halftone dot wash over the top.
//
// The lock screen and the power menu each used to draw their own version of
// this. They disagreed — the power menu had a wedge and a full stripe field,
// the lock had a 42px stripe band and no wedge, and the wedge leaned at 0.32
// where every panel in the shell leans at Theme.skew. Building both on one
// component is what makes them read as the same object seen twice rather than
// as two guesses at the same brief.
Item {
id: root
// 0 = withdrawn, 1 = fully present. Drives the stripe slide, the stripe
// opacity and the wedge together, so a caller animates one number instead
// of keeping three in sync. The power menu runs it both ways on open and
// close; the lock runs it once on entry.
property real reveal: 1
property color tone: Theme.accent
property bool wedge: true
property real wedgeOpacity: 0.35
// Bottom-left corner of the wedge and its span, as fractions of width. The
// wedge leans right going up, like every other edge in the shell, so its
// top edge sits `leanOffset` further right than its bottom.
property real wedgeLeft: 0
property real wedgeSpan: 0.34
property bool stripes: true
property real stripeOpacity: 0.10
property bool halftone: true
property real halftoneOpacity: 0.05
property color halftoneColor: Theme.primary
// ONE angle, everywhere — the same rule the panels follow.
readonly property real leanOffset: root.height * Theme.skew
// Diagonal stripe field, sliding in from off-screen left.
Image {
anchors.fill: parent
visible: root.stripes && root.reveal > 0
source: Theme.texStripes
fillMode: Image.Tile
opacity: root.stripeOpacity * root.reveal
smooth: false
layer.enabled: true
layer.effect: TintEffect {
tintColor: root.tone
}
transform: Translate {
x: -root.width * (1 - root.reveal)
}
}
// The wedge. Drawn after the stripes so it reads as a slab laid over them.
Shape {
anchors.fill: parent
visible: root.wedge && root.reveal > 0
opacity: root.reveal
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: Theme.alpha(Theme.accentDim, root.wedgeOpacity)
strokeColor: "transparent"
joinStyle: ShapePath.MiterJoin
PathPolyline {
path: {
const x = root.wedgeLeft * root.width;
const span = root.wedgeSpan * root.width;
const off = root.leanOffset;
return Geom.closed([
Qt.point(x + off, 0),
Qt.point(x + off + span, 0),
Qt.point(x + span, root.height),
Qt.point(x, root.height)
]);
}
}
}
}
// Screen-printed dot wash over the whole thing.
Image {
anchors.fill: parent
visible: root.halftone
source: Theme.texHalftone
fillMode: Image.Tile
opacity: root.halftoneOpacity
smooth: false
layer.enabled: true
layer.effect: TintEffect { tintColor: root.halftoneColor }
}
}
+83 -74
View File
@@ -39,7 +39,10 @@ Item {
property color halftoneColor: Theme.primary
property real halftoneOpacity: 0.07
// Inner glow along the top edge, for panels that should feel backlit.
// Accepted and ignored. This used to paint a teal-to-transparent gradient
// down the top of every panel in the shell — the single biggest source of
// the murky blue cast. Fills are flat now. Kept as a no-op property so the
// call sites that still set it don't have to be chased down.
property bool sheen: false
// The screen-print stack. A pure-black copy of the silhouette sits behind
@@ -71,6 +74,28 @@ Item {
readonly property real contentPad: padding + leanInset
// The panel outline as a mask texture. Anything that is painted from the
// raw polygon rather than from the chamfered one has to be clipped to this,
// or it escapes at the chopped corners.
Shape {
id: silhouette
anchors.fill: parent
visible: false
layer.enabled: true
layer.smooth: true
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: "white"
strokeColor: "transparent"
joinStyle: ShapePath.MiterJoin
PathPolyline {
path: Geom.chamfer(root.polygon, root.chop, root.chopCorners)
}
}
}
// Offset black copy of the silhouette, behind everything.
Loader {
anchors.fill: parent
@@ -138,93 +163,77 @@ Item {
anchors.fill: parent
active: root.halftone
asynchronous: true
sourceComponent: Item {
Shape {
id: maskShape
anchors.fill: parent
visible: false
layer.enabled: true
layer.smooth: true
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: "white"
strokeColor: "transparent"
PathPolyline {
path: Geom.chamfer(root.polygon, root.chop, root.chopCorners)
}
}
}
Image {
anchors.fill: parent
source: Theme.texHalftone
fillMode: Image.Tile
opacity: root.halftoneOpacity
smooth: false
layer.enabled: true
layer.effect: MultiEffect {
colorization: 1.0
colorizationColor: root.halftoneColor
maskEnabled: true
maskSource: maskShape
}
}
}
}
// Backlit top edge.
Loader {
anchors.fill: parent
active: root.sheen
sourceComponent: Shape {
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: "transparent"
fillGradient: LinearGradient {
x1: 0; y1: 0; x2: 0; y2: root.height
GradientStop { position: 0.0; color: Theme.alpha(Theme.primary, 0.16) }
GradientStop { position: 0.55; color: "transparent" }
}
PathPolyline {
path: Geom.chamfer(root.polygon, root.chop, root.chopCorners)
}
sourceComponent: Image {
anchors.fill: parent
source: Theme.texHalftone
fillMode: Image.Tile
opacity: root.halftoneOpacity
smooth: false
layer.enabled: true
layer.effect: MultiEffect {
colorization: 1.0
colorizationColor: root.halftoneColor
maskEnabled: true
maskSource: silhouette
}
}
}
// Leading-edge slash.
//
// Its polygon is built from the panel's raw corners, so on a panel with a
// chopped corner the slash used to run straight through the cut and out the
// far side — a crimson wedge sitting a few pixels outside the silhouette,
// at an angle that matched nothing. It is masked to the outline now, so the
// slash ends exactly where the chamfer does.
Loader {
anchors.fill: parent
active: root.slash
sourceComponent: Shape {
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: root.slashColor
strokeColor: "transparent"
PathPolyline {
path: {
const t = root.slashWidth;
sourceComponent: Item {
Shape {
id: slashShape
anchors.fill: parent
visible: false
layer.enabled: true
layer.smooth: true
preferredRendererType: Shape.CurveRenderer
// On a rail slab the slash runs along the slanted top
// edge instead of down a side.
if (root.vertical) {
const voff = root.width * root.lean;
ShapePath {
fillColor: root.slashColor
strokeColor: "transparent"
PathPolyline {
path: {
const t = root.slashWidth;
// On a rail slab the slash runs along the slanted
// top edge instead of down a side.
if (root.vertical) {
const voff = root.width * root.lean;
return root.leanLeft
? Geom.closed([Qt.point(0, 0), Qt.point(root.width, voff),
Qt.point(root.width, voff + t), Qt.point(0, t)])
: Geom.closed([Qt.point(0, voff), Qt.point(root.width, 0),
Qt.point(root.width, t), Qt.point(0, voff + t)]);
}
const off = root.height * root.lean;
return root.leanLeft
? Geom.closed([Qt.point(0, 0), Qt.point(root.width, voff),
Qt.point(root.width, voff + t), Qt.point(0, t)])
: Geom.closed([Qt.point(0, voff), Qt.point(root.width, 0),
Qt.point(root.width, t), Qt.point(0, voff + t)]);
? Geom.closed([Qt.point(0, 0), Qt.point(t, 0),
Qt.point(off + t, root.height), Qt.point(off, root.height)])
: Geom.closed([Qt.point(off, 0), Qt.point(off + t, 0),
Qt.point(t, root.height), Qt.point(0, root.height)]);
}
const off = root.height * root.lean;
return root.leanLeft
? Geom.closed([Qt.point(0, 0), Qt.point(t, 0),
Qt.point(off + t, root.height), Qt.point(off, root.height)])
: Geom.closed([Qt.point(off, 0), Qt.point(off + t, 0),
Qt.point(t, root.height), Qt.point(0, root.height)]);
}
}
}
MultiEffect {
anchors.fill: parent
source: slashShape
maskEnabled: true
maskSource: silhouette
}
}
}
+74 -68
View File
@@ -3,54 +3,77 @@ pragma Singleton
import QtQuick
import Quickshell
// Takemi palette. Two hues, no exceptions: a cold teal that carries every
// resting state, and Persona 5 crimson that means alarm and nothing else.
// Anything that used to be blue/green/amber now resolves into one of those two
// families, so a splash of red in the bar always reads as "something is wrong".
// Derived from ~/.wallpapers/takemi_2.jpg.
// Takemi palette: crimson, black, white. Nothing else.
//
// White does the work: text, icons, idle indicators. Crimson is the only accent
// and it always means the same thing — active, or wrong. Black is structure,
// used as a hard keyline rather than as a shadow. There are no gradients in
// this shell; every fill is flat.
//
// The wallpaper is teal. The UI used to be teal too, and the two fought each
// other into a murky blue-grey. Letting the UI go monochrome-plus-red is what
// lets the wallpaper read as the colour in the composition.
Singleton {
id: root
// ---------------------------------------------------------------- colors
// True black, for keylines and drop shapes. `base` is the near-black the
// surfaces sit on; `ink` is never tinted and never blended.
readonly property color ink: "#000000"
readonly property color base: "#04070a"
readonly property color mantle: "#080e14"
readonly property color surface: "#0d1922"
readonly property color surfaceAlt: "#14262f"
readonly property color overlay: "#1d3a47"
readonly property color outline: "#2b5567"
readonly property color base: "#0a0a0c"
readonly property color mantle: "#0d0d10"
readonly property color surface: "#141418"
readonly property color surfaceAlt: "#1c1c22"
readonly property color overlay: "#26262e"
readonly property color outline: "#3a3a44"
readonly property color text: "#e2f4f8"
readonly property color subtext: "#93b8c4"
readonly property color muted: "#587c8a"
readonly property color text: "#ffffff"
readonly property color subtext: "#c8c8ce"
readonly property color muted: "#6b6b70"
readonly property color primary: "#34d3e6" // electric teal — the house colour
readonly property color primaryDim: "#17879b"
readonly property color glow: "#8ef2ff"
readonly property color deep: "#0d4b5e"
// ------------------------------------------------------------ THE KNOB
// The one colour in this shell. Change this line and the entire accent
// recolours — everything below is derived from it, and nothing else in the
// palette carries hue at all.
//
// Keep it in step with `$accent` at the top of
// ~/.config/hypr/modules/takemi.conf, which is what colours the Hyprland
// window borders. Those are the only two places the colour is written.
readonly property color accent: "#ff2d40"
readonly property color accent: "#ff2d40" // P5 crimson — alarm, only ever alarm
readonly property color accentDim: "#a8121f"
readonly property color accentSoft: "#ff6b78" // the "getting there" step below accent
readonly property color accentDim: Qt.darker(root.accent, 1.9)
readonly property color accentSoft: Qt.lighter(root.accent, 1.35)
// Retired hues, kept as aliases so call sites keep working while resolving
// into the two-hue system. Do not introduce new uses.
// `primary` is the resting voice of the UI, which is now simply white.
// Kept under the old name so the existing call sites don't all have to move.
readonly property color primary: root.text
readonly property color primaryDim: "#9a9aa2"
readonly property color deep: root.surfaceAlt
// `glow` is the hover highlight. It used to be a brighter teal, which now
// that everything rests at white would have made hover invisible — so it
// resolves to crimson. Pointing at a thing lights it up red.
readonly property color glow: root.accent
// Retired hues. Aliases only — do not introduce new uses.
//
// `warn` used to be accentSoft, which put a washed-out pink-red on screen
// wherever something was merely notable. That reads as a faded accent, not
// as a decision. There is no half-crimson in this palette: a thing is wrong
// (accent) or it is not (text). `warn` now resolves to the real accent, so
// low battery shouts properly and everything merely informational is white.
readonly property color blue: root.primaryDim
readonly property color ok: root.primary
readonly property color warn: root.accentSoft
readonly property color ok: root.text
readonly property color warn: root.accent
// Chromatic aberration pair, straight off the wallpaper's RGB split.
readonly property color splitRed: "#ff2b4d"
readonly property color splitCyan: "#25e8ff"
// Display text carries a crimson and a black offset rather than an RGB
// split. Same trick, correct palette.
readonly property color splitRed: root.accent
readonly property color splitCyan: root.ink
function alpha(c: color, a: real): color {
return Qt.rgba(c.r, c.g, c.b, a);
}
// Linear blend, t = 0 gives a, t = 1 gives b.
function mix(a: color, b: color, t: real): color {
const k = Math.max(0, Math.min(1, t));
return Qt.rgba(a.r + (b.r - a.r) * k,
@@ -59,12 +82,11 @@ Singleton {
a.a + (b.a - a.a) * k);
}
// Heat ramp for load/usage readouts. Deliberately *not* a hue cycle: it sits
// flat teal until load actually matters, then bleeds continuously into
// crimson. A number that has gone red means the same thing everywhere.
// Load readouts sit white until the number actually matters, then bleed to
// crimson. Not a hue cycle — red always means the same thing.
function heat(pct: real): color {
if (pct <= 55) return root.primary;
return root.mix(root.primary, root.accent, (pct - 55) / 45);
if (pct <= 55) return root.text;
return root.mix(root.text, root.accent, (pct - 55) / 45);
}
// ----------------------------------------------------------------- fonts
@@ -88,24 +110,22 @@ Singleton {
readonly property int barMargin: 8
readonly property int barGap: 8
// Right-hand status rail and the floating bottom-left dock.
// The right-hand rail uses the same lean and chamfer as the bar — a
// vertical panel leans its horizontal edges by a fraction of its width,
// which works out to the same 14 degrees.
readonly property int railWidth: 48
readonly property int railGap: 10
readonly property int dockMargin: 14
// Low enough that the cluster stops competing with whatever window is
// underneath it — at 0.25 it just muddies text it happens to overlap. The
// corner wedge stays fully opaque as the thing you aim at.
readonly property real dockIdleOpacity: 0.13
readonly property int dockRevealZone: 140 // px of screen bottom that wakes it
// Horizontal offset per unit of height. tan(14deg) — every panel leans.
// ONE angle, everywhere. tan(14deg). Persona geometry is disciplined, not
// random: every edge in the shell leans by exactly this much in exactly the
// same direction, and every corner is chopped by exactly `cut`. Varying
// these per module reads as noise, not as style.
readonly property real skew: 0.249
readonly property int cut: 9 // corner chamfer, in px
readonly property int cut: 10
readonly property int stroke: 2
// Pure-black keyline copy sitting behind every panel. This is what makes a
// shape read as screen-printed rather than as a clipped rectangle.
readonly property real dropOffset: 4
// Pure-black keyline and offset copy behind panels. This is the one piece of
// layering the style needs; it is not a soft shadow and never blurs.
readonly property real dropOffset: 3
readonly property real keylineWidth: 2
readonly property int padS: 6
@@ -116,24 +136,6 @@ Singleton {
readonly property int popoutWidth: 380
readonly property int popoutGap: 10
// ------------------------------------------------------- shape variance
// Persona geometry is never a repeating pattern, so modules pull their lean
// and chamfer from these tables by index rather than sharing one default.
// Deterministic, so the bar looks the same every launch.
readonly property var leanTable: [0.32, 0.18, 0.26, 0.0, 0.29, 0.14, 0.35, 0.21]
readonly property var chopTable: [12, 5, 18, 8, 14, 4, 10, 16]
readonly property var cornerTable: [
[1, 3], [0, 2], [1], [0, 2, 3], [3], [1, 3], [2], [0, 1, 3]
]
function leanFor(i: int): real { return root.leanTable[i % root.leanTable.length]; }
function chopFor(i: int): real { return root.chopTable[i % root.chopTable.length]; }
function cornersFor(i: int): var { return root.cornerTable[i % root.cornerTable.length]; }
// Every other module leans against its neighbour, so the row reads as
// opposing diagonals instead of a comb.
function leanLeftFor(i: int): bool { return i % 3 === 1; }
// ------------------------------------------------------------ animation
// P5 motion is snappy and slightly overshooting — never soft.
readonly property int durFast: 110
@@ -150,5 +152,9 @@ Singleton {
readonly property url texStripes: "root:/assets/stripes.png"
readonly property url texScanline: "root:/assets/scanline.png"
readonly property url wallpaper: "file:///home/narl/.wallpapers/takemi_2.jpg"
// Last resort only. The live wallpaper comes from the Wallpaper singleton,
// which asks hyprpaper what is actually on screen — a path written down
// here goes stale the moment the wallpaper is switched, which is exactly
// what happened to the lock screen.
readonly property url fallbackWallpaper: "file:///home/narl/.wallpapers/takemi_1.jpg"
}
+4
View File
@@ -15,10 +15,14 @@ WlSessionLock {
locked: Actions.lockRequested
WlSessionLockSurface {
id: lockSurface
color: "black"
LockFace {
anchors.fill: parent
// So each screen blurs its own wallpaper rather than all of them
// sharing whichever one hyprpaper happened to list first.
screenName: lockSurface.screen ? lockSurface.screen.name : ""
onUnlocked: Actions.lockRequested = false
}
}
+40 -30
View File
@@ -27,10 +27,17 @@ Item {
}
// ------------------------------------------------------- background
// Whatever hyprpaper is actually displaying, not a path written down once
// and left to rot. `screenName` lets a multi-monitor lock show each screen
// its own picture; it falls back to the first wallpaper hyprpaper reports.
property string screenName: ""
Image {
id: wall
anchors.fill: parent
source: Theme.wallpaper
source: surface.screenName !== ""
? Wallpaper.forMonitor(surface.screenName)
: Wallpaper.source
fillMode: Image.PreserveAspectCrop
asynchronous: false
cache: true
@@ -45,7 +52,9 @@ Item {
}
}
// Teal wash, pulling the photo toward the shell's palette.
// Wash pulling the photo down toward the shell's ground. Was described as
// a teal wash back when the UI was teal; the colours it actually uses are
// the neutral surfaces, which is what it should have been all along.
Rectangle {
anchors.fill: parent
gradient: Gradient {
@@ -55,35 +64,16 @@ Item {
}
}
Image {
// The same wedge-and-stripes ground the power menu stands on. The wedge
// sits under the clock, which is where the power menu puts it under the
// title — so the two screens land the crimson mass in the same place.
P5Backdrop {
id: backdrop
anchors.fill: parent
source: Theme.texHalftone
fillMode: Image.Tile
opacity: 0.05
smooth: false
layer.enabled: true
layer.effect: TintEffect { tintColor: Theme.primary }
}
// Crimson stripe band running behind the prompt — the Persona 5 title
// treatment. Declared before the auth block so it stays underneath it.
Item {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: authBlock.top
anchors.topMargin: -6
height: 42
clip: true
opacity: 0.5
Image {
anchors.fill: parent
source: Theme.texStripes
fillMode: Image.Tile
smooth: false
layer.enabled: true
layer.effect: TintEffect { tintColor: Theme.accent }
}
reveal: 0
wedgeSpan: 0.30
wedgeOpacity: 0.30
stripeOpacity: 0.07
}
// ------------------------------------------------------------ clock
@@ -92,6 +82,20 @@ Item {
precision: SystemClock.Minutes
}
// The house mark, same wordmark the bar carries far left. Small, above the
// clock — the lock's hero is the time, not the branding.
SplitText {
id: mark
anchors.left: timeBlock.left
anchors.bottom: timeBlock.top
anchors.bottomMargin: 6
text: "P5"
pixelSize: Theme.fsTitle
split: 2
letterSpacing: 0
opacity: timeBlock.opacity
}
Column {
id: timeBlock
anchors.left: parent.left
@@ -403,6 +407,12 @@ Item {
ParallelAnimation {
id: intro
// The stripes sweep in under everything, the same move the power menu
// opens with.
NumberAnimation {
target: backdrop; property: "reveal"
from: 0; to: 1; duration: Theme.durLazy; easing.type: Easing.OutExpo
}
NumberAnimation {
target: timeBlock; property: "opacity"
from: 0; to: 1; duration: Theme.durLazy; easing.type: Easing.OutExpo
+24 -7
View File
@@ -67,7 +67,8 @@ PanelWindow {
readonly property color tone: {
switch (root.kind) {
case "mic": return Audio.micMuted ? Theme.accent : Theme.blue;
case "brightness": return Theme.warn;
// Turning the screen up is not a warning. White, like volume.
case "brightness": return Theme.text;
default: return Audio.muted ? Theme.accent : Theme.primary;
}
}
@@ -143,24 +144,40 @@ PanelWindow {
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Row {
// Title left, readout hard right. This used to be a Row with a
// `parent.width - 90` spacer, which assumed the two labels never
// needed more than 90px between them — "BRIGHTNESS" plus "100%"
// does, and the percentage fell off the right edge. Anchoring the
// readout to the right and letting the title elide into whatever
// is left makes the widest case the safe one.
Item {
width: parent.width
height: Math.max(titleLabel.implicitHeight, readout.implicitHeight)
Text {
id: titleLabel
anchors.left: parent.left
anchors.right: readout.left
anchors.rightMargin: Theme.padS
anchors.verticalCenter: parent.verticalCenter
text: root.title.toUpperCase()
color: Theme.subtext
elide: Text.ElideRight
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
font.letterSpacing: 2
renderType: Text.NativeRendering
}
Item {
width: parent.width - 90
height: 1
}
SplitText {
id: readout
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
// Natural width, not a fixed one — SplitText offsets its
// ghosts with left/right anchor margins, so pinning the
// width and right-aligning the text would flatten the RGB
// split away. The title's left edge is anchored, so a
// widening readout only moves its own left edge.
text: Math.round(root.level * 100) + "%"
pixelSize: Theme.fsBody
split: 1
+24 -69
View File
@@ -1,5 +1,4 @@
import QtQuick
import QtQuick.Shapes
import Quickshell
import Quickshell.Wayland
import "root:/config"
@@ -48,46 +47,20 @@ PanelWindow {
}
}
// Diagonal stripe field sweeping in from the left.
Image {
id: stripes
// Stripes and wedge, shared with the lock screen. The wedge used to lean at
// 0.32 here, which was its own angle and nothing else's; P5Backdrop leans
// it at Theme.skew like every other edge in the shell.
P5Backdrop {
id: ground
anchors.fill: parent
source: Theme.texStripes
fillMode: Image.Tile
opacity: 0
smooth: false
layer.enabled: true
layer.effect: TintEffect {
tintColor: Theme.accent
}
transform: Translate {
id: stripeShift
x: -root.width
}
}
// A crimson wedge behind the buttons.
Shape {
id: wedge
anchors.fill: parent
opacity: 0
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: Theme.alpha(Theme.accentDim, 0.35)
strokeColor: "transparent"
PathPolyline {
path: [
Qt.point(root.width * 0.18, 0),
Qt.point(root.width * 0.52, 0),
Qt.point(root.width * 0.34, root.height),
Qt.point(0, root.height),
Qt.point(root.width * 0.18, 0)
]
}
}
reveal: 0
stripeOpacity: 0.10
wedgeOpacity: 0.35
wedgeSpan: 0.34
// The live desktop is still visible behind this, so a dot wash would be
// laid over other people's windows rather than over a ground of our
// own. The lock owns the halftone; here it just muddies things.
halftone: false
}
// ---------------------------------------------------------------- title
@@ -165,18 +138,10 @@ PanelWindow {
required property var modelData
required property int index
// Six identical slabs stacked at a constant width is the most
// obviously machine-made thing in the shell. Vary the width and
// push each row a different distance off the right margin, so
// the stack has a ragged edge on both sides.
readonly property int slabWidth: [286, 252, 300, 268, 312, 244][entry.index]
readonly property int nudge: [0, 22, 8, 34, 14, 26][entry.index]
readonly property int slabWidth: actions.stackWidth
// Column owns y, so the ragged edge is an explicit x offset
// measured against the widest slab in the stack.
width: entry.slabWidth
height: 52
x: actions.stackWidth - entry.slabWidth - entry.nudge
opacity: 0
transform: [
@@ -195,8 +160,8 @@ PanelWindow {
icon: entry.modelData.icon
destructive: entry.modelData.danger
accent: entry.modelData.danger ? Theme.accent : Theme.primary
lean: Theme.leanFor(entry.index)
lunge: -10 - entry.index * 2
lean: Theme.skew
lunge: -10
onClicked: root.invoke(entry.modelData.act)
}
@@ -225,10 +190,12 @@ PanelWindow {
}
// Slabs land out of true and snap square. Sliding alone
// reads like a slide deck; the tilt is what makes it
// land like a card being thrown down.
// land like a card being thrown down. Same direction
// every time — the motion is the flourish, the geometry
// stays disciplined.
NumberAnimation {
target: tilt; property: "angle"
from: entry.index % 2 === 0 ? -4 : 4; to: 0
from: -3; to: 0
duration: Theme.durSlow
easing.type: Easing.OutBack
easing.overshoot: 2.2
@@ -278,18 +245,10 @@ PanelWindow {
target: backdrop; property: "opacity"
to: 1; duration: Theme.durBase; easing.type: Easing.OutQuad
}
// One number now drives the slide, the stripe opacity and the wedge.
NumberAnimation {
target: stripes; property: "opacity"
to: 0.1; duration: Theme.durSlow
}
NumberAnimation {
target: stripeShift; property: "x"
from: -root.width; to: 0
duration: Theme.durLazy; easing.type: Easing.OutExpo
}
NumberAnimation {
target: wedge; property: "opacity"
to: 1; duration: Theme.durSlow; easing.type: Easing.OutExpo
target: ground; property: "reveal"
from: 0; to: 1; duration: Theme.durLazy; easing.type: Easing.OutExpo
}
NumberAnimation {
target: title; property: "opacity"
@@ -304,11 +263,7 @@ PanelWindow {
to: 0; duration: Theme.durFast
}
NumberAnimation {
target: stripes; property: "opacity"
to: 0; duration: Theme.durFast
}
NumberAnimation {
target: wedge; property: "opacity"
target: ground; property: "reveal"
to: 0; duration: Theme.durFast
}
NumberAnimation {
+120
View File
@@ -0,0 +1,120 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import "root:/config"
// The wallpaper currently on screen, per monitor.
//
// The lock screen used to point at a hardcoded path in Theme.qml, which meant
// it showed whatever picture was correct on the day that line was written —
// takemi_2 while hyprpaper had been switched to takemi_1. Anything that puts
// the wallpaper on screen should ask hyprpaper what it is actually displaying,
// which is what this does.
//
// hyprpaper is the source of truth at runtime; hyprpaper.conf is the fallback
// for the window between the shell starting and hyprpaper answering, and for
// the case where hyprpaper is not running at all.
Singleton {
id: root
// monitor name -> file path. Monitors hyprpaper set with a blank `monitor =`
// come back under the empty-string key.
property var byMonitor: ({})
// The wallpaper to use when no particular monitor is being asked about.
property string path: ""
readonly property url source: root.path !== ""
? Qt.resolvedUrl("file://" + root.path)
: Theme.fallbackWallpaper
// Path set from hyprpaper.conf, used until (and unless) hyprctl answers.
property string configPath: ""
signal changed()
function forMonitor(name: string): url {
const p = root.byMonitor[name];
return p ? Qt.resolvedUrl("file://" + p) : root.source;
}
function refresh() {
reader.running = true;
}
function _expand(p: string): string {
const s = p.trim();
if (s.startsWith("~/")) return Quickshell.env("HOME") + s.slice(1);
return s;
}
function _adopt(map: var, fallback: string) {
root.byMonitor = map;
// Prefer the focused monitor's wallpaper, then any monitor's, then the
// config. Object.values order is insertion order here, which is the
// order hyprpaper listed them in.
const vals = Object.keys(map).map(k => map[k]).filter(v => v !== "");
const next = vals.length > 0 ? vals[0] : fallback;
if (next !== root.path) {
root.path = next;
root.changed();
}
}
Process {
id: reader
command: ["hyprctl", "hyprpaper", "listactive"]
stdout: StdioCollector {
onStreamFinished: {
const map = {};
for (const line of text.split("\n")) {
// "eDP-1: /home/narl/.wallpapers/takemi_1.jpg". Split on the
// first colon only — the path may contain one.
const at = line.indexOf(":");
if (at === -1) continue;
const mon = line.slice(0, at).trim();
const file = line.slice(at + 1).trim();
if (file === "" || file.startsWith("invalid")) continue;
map[mon] = file;
}
root._adopt(map, root.configPath);
}
}
// hyprpaper down, or hyprctl missing. Fall back to the config file.
onExited: code => {
if (code !== 0 && root.path === "") root._adopt({}, root.configPath);
}
}
// hyprpaper has no change signal to subscribe to, so poll — but slowly. A
// wallpaper changes when a person decides to change it, not continuously.
Timer {
interval: 10000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.refresh()
}
// Editing the config is the other way the wallpaper changes here, and that
// one we can watch properly.
FileView {
id: conf
path: Quickshell.env("HOME") + "/.config/hypr/hyprpaper.conf"
watchChanges: true
onFileChanged: reload()
onLoaded: {
const m = /^\s*path\s*=\s*(.+)$/m.exec(text());
if (m) root.configPath = root._expand(m[1]);
// A config edit is usually followed by a hyprpaper reload, so go
// ask what actually took effect rather than trusting the file.
root.refresh();
}
}
}
-6
View File
@@ -42,12 +42,6 @@ ShellRoot {
Rail {}
}
Variants {
model: Quickshell.screens
Dock {}
}
Variants {
model: Quickshell.screens