glass-base: a glass opacity setting and one place for the surface look

- shell.conf glass (0.05 to 1, default 1 = solid, as before): the
  surfaces' opacity; read live with the theme
- surface.rs: shell() draws the island, the idle pill and the workspace
  pill (fill, rim, shadow), so a design is one file; the default is the
  look so far
- see-through surfaces can't hide content under a veil, so with glass
  below 1 the open/close fades use .opacity() while they run
- README: the Hyprland layer rule that frosts what's behind the shell
  (namespace match is a full match: ^magic-.*$; ignore_alpha keeps the
  transparent margin around the island sharp)
- docs/design: the solid look and glass 0.5 with the rule, for review
This commit is contained in:
server2
2026-10-09 05:20:44 +02:00
parent 036b326b04
commit 54ca75b855
9 changed files with 90 additions and 41 deletions
+18
View File
@@ -29,8 +29,25 @@ The Material You scheme type is Skwd-wall's setting (`matugen.schemeType`).
```
cover_colors = false # true: the open media card and equalizer take the song cover's colours as background
transition_ms = 400 # how long a colour change cross-fades; 0 switches instantly
glass = 1.0 # surface opacity, 0.05 to 1; below 1 the desktop shows through
```
## Glass
Below `glass = 1.0` the surfaces are see-through. Amane can only blur what the shell draws itself, so the frosting has to come from Hyprland: add this layer rule to the Hyprland config (`hyprland.lua`). The namespace match is a full match, and `ignore_alpha` keeps the transparent margin around the island from being blurred too.
```lua
-- frosted glass behind amane's island and workspace pills
amane_glass = hl.layer_rule({
name = "amane-glass",
match = { namespace = "^magic-.*$" },
blur = true,
ignore_alpha = 0.2,
})
```
The look of the surfaces (fill, rim, highlight, shadow) lives in `src/surface.rs`.
## Layout
```
@@ -41,6 +58,7 @@ src/spectrum.rs the shown player's sound as 24 levels (pw-record | cava)
src/art.rs cover download (curl) and the palette file
src/workspaces.rs the workspace pill, top left on every monitor
src/theme.rs the shell's colours: the matugen theme file, shell.conf, the cross-fade
src/surface.rs what the island and pills are made of: fill, rim, highlight, shadow
src/parse.rs pure text parsing with unit tests, no amane dependency
matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme
shell.conf settings: cover_colors, transition_ms
Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

+4
View File
@@ -7,3 +7,7 @@ cover_colors = false
# how long a colour change cross-fades, in milliseconds; 0 switches instantly
transition_ms = 400
# how opaque the shell's surfaces are, 0.05 to 1 (1 = solid). below 1 the desktop shows
# through; to frost it, Hyprland has to blur behind the shell (README: Glass)
glass = 1.0
+21 -27
View File
@@ -14,6 +14,7 @@ use crate::art::Art;
use crate::parse::{self, BANDS, Sink};
use crate::sound::{EQ_PRESETS, MAX_GAIN, Sound};
use crate::spectrum::Spectrum;
use crate::surface;
use crate::theme;
/*
@@ -331,26 +332,16 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
Rectangle::new().width(0.0).height(0.0)
};
let shape = Rectangle::new()
.width(width)
.height(height)
.radius(radius)
.fill(backdrop(&colors, 255))
.border(1.0, Color::blend(theme::now().outline, faded(theme::now().accent, 110), lift));
// the face centred along the top: while the shape grows it is narrower than the shape
let face = Rectangle::new().width(width).height(height).align_child(Center, Start).child(face);
/*
* one faint pink glow around the island, pill and card alike, stronger while the
* pill is hovered. blur 22 fades out within ~28 px (amane blurs with a deviation
* of blur / 2), inside the window's SHADOW margin. no clip anywhere: neither face
* ever overflows the shape
*/
let shape = shape.shadow(Shadow::drop(theme::now().accent).opacity(mix(0.3, 0.7, lift)).blur(22.0));
// the surface (fill, rim, glow) is surface.rs's; no clip anywhere: neither face ever overflows the shape
let shape = surface::shell(width, height, radius, &colors, lift, face);
let tapped = monitor.name.clone();
let hovered = monitor.name.clone();
let shape = shape
.align_child(Center, Start)
.on_click(move |_| tap(&tapped))
.on_hover(move |inside| {
if inside {
@@ -365,8 +356,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
if !inside && card_here {
collapse();
}
})
.child(face);
});
let shape = if island.expanded && opened_here { shape } else { shape.cursor(Pointer) };
@@ -551,13 +541,19 @@ fn phases(timeline: f32) -> (f32, f32, f32) {
* fades a face in over the island's backdrop by laying the same gradient over it,
* thinning as `shown` rises: the same picture as .opacity(shown), without a gpu layer.
* the veil spans the whole shape (minus the 1 px rim), so its gradient lines up with
* the backdrop's exactly; amane places gradients by each rectangle's own bounds
* the backdrop's exactly; amane places gradients by each rectangle's own bounds.
* a see-through surface can't hide anything under a veil, so glass fades with
* .opacity(), a gpu layer only while the fade runs
*/
fn veiled(face: Rectangle, width: f32, height: f32, radius: f32, shown: f32, colors: &[Color; 3]) -> Rectangle {
if shown >= 1.0 {
return face;
}
if theme::glass() < 1.0 {
return Rectangle::new().width(width).height(height).align_child(Center, Start).child(face).opacity(shown);
}
let veil = Rectangle::new()
.width(width - 2.0)
.height(height - 2.0)
@@ -602,17 +598,15 @@ pub fn faded(color: Color, alpha: u8) -> Color {
Color::rgba(color.red(), color.green(), color.blue(), alpha)
}
// the island while no player is open: a music note and "Nothing playing", glowing like the pill
// the island while no player is open: a music note and "Nothing playing"
fn idle(colors: &[Color; 3]) -> Rectangle {
Rectangle::new()
.width(COMPACT_W)
.height(COMPACT_H)
.radius(Full)
.fill(backdrop(colors, 255))
.border(1.0, theme::now().outline)
.shadow(Shadow::drop(theme::now().accent).opacity(0.3).blur(22.0))
.align_child(Center, Center)
.child(Row::new(children![icon(Glyph::Note, 15.0, theme::now().accent), Text::new("Nothing playing").size(12.0).color(theme::now().dim)]).gap(8.0).align(Center))
let content = Rectangle::new().width(COMPACT_W).height(COMPACT_H).align_child(Center, Center).child(
Row::new(children![icon(Glyph::Note, 15.0, theme::now().accent), Text::new("Nothing playing").size(12.0).color(theme::now().dim)])
.gap(8.0)
.align(Center),
);
surface::shell(COMPACT_W, COMPACT_H, COMPACT_H / 2.0, colors, 0.0, content)
}
// the pill: cover, title, the player's live levels
+1
View File
@@ -3,6 +3,7 @@ mod island;
mod parse;
mod sound;
mod spectrum;
mod surface;
mod theme;
mod workspaces;
+28
View File
@@ -0,0 +1,28 @@
use amane::{Blend, Color, Rectangle, Shadow};
use crate::island::{backdrop, faded};
use crate::theme;
/*
* what the island, the idle pill and the workspace pill are made of: one place, so
* a design is this file. `shell` wraps `content` (already width x height, with its
* own alignment and padding) in the surface. `lift` (0 to 1) is how hovered it is.
* the default: the gradient of `colors`, a 1 px rim, a soft glow in the accent
*/
pub fn shell(width: f32, height: f32, radius: f32, colors: &[Color; 3], lift: f32, content: Rectangle) -> Rectangle {
let theme = theme::now();
Rectangle::new()
.width(width)
.height(height)
.radius(radius)
.fill(backdrop(colors, alpha(theme::glass())))
.border(1.0, Color::blend(theme.outline, faded(theme.accent, 110), lift))
.shadow(Shadow::drop(theme.accent).opacity(0.3 + 0.4 * lift).blur(22.0))
.child(content)
}
// an opacity, 0 to 1, as a colour's alpha
pub fn alpha(opacity: f32) -> u8 {
(opacity.clamp(0.0, 1.0) * 255.0).round() as u8
}
+13 -1
View File
@@ -73,6 +73,9 @@ pub struct Theme {
// the open media card and the equalizer take the cover's colours as their background
pub cover_colors: bool,
// how opaque the shell's surfaces are, 0.05 to 1; below 1 the desktop shows through
glass: f32,
}
impl Service for Theme {
@@ -80,6 +83,7 @@ impl Service for Theme {
Self {
colors: Animation::new(PINK_ON_PURPLE),
cover_colors: false,
glass: 1.0,
}
}
@@ -174,6 +178,7 @@ fn apply(theme: &str, config: &str, startup: bool) {
let mut theme = Theme::write();
theme.cover_colors = setting("cover_colors") == Some("true");
theme.glass = setting("glass").and_then(|glass| glass.parse::<f32>().ok()).map_or(1.0, |glass| glass.clamp(0.05, 1.0));
// a change in the middle of a fade starts from the colours on screen now
let now = theme.colors.value();
@@ -182,9 +187,10 @@ fn apply(theme: &str, config: &str, startup: bool) {
theme.colors.to(colors);
}
// the colours of the frame being drawn, set by the view that draws it
// the colours and glass of the frame being drawn, set by the view that draws it
thread_local! {
static NOW: Cell<Colors> = const { Cell::new(PINK_ON_PURPLE) };
static GLASS: Cell<f32> = const { Cell::new(1.0) };
}
/*
@@ -195,6 +201,7 @@ pub fn begin() -> bool {
let theme = Theme::read();
NOW.set(theme.colors.value());
GLASS.set(theme.glass);
theme.cover_colors
}
@@ -202,3 +209,8 @@ pub fn begin() -> bool {
pub fn now() -> Colors {
NOW.get()
}
// the surfaces' opacity this frame, 0.05 to 1
pub fn glass() -> f32 {
GLASS.get()
}
+5 -13
View File
@@ -5,10 +5,11 @@ use std::time::Duration;
use amane::{
Animation, Blend, Center, Color, Full, Horizontal, InputArea, Layer, LayerWindow, Monitor, Padding, Parent, Pointer,
Rectangle, Row, Service, Shadow, Start, Text, Vertical, Weight, Widget, Workspace, Workspaces, Zone,
Rectangle, Row, Service, Start, Text, Vertical, Weight, Widget, Workspace, Workspaces, Zone,
};
use crate::island::{CHIP_HOVER, CLEAR, COMPACT_H, HOVER_MS, SHADOW, TOP_GAP, backdrop, faded};
use crate::island::{CHIP_HOVER, CLEAR, COMPACT_H, HOVER_MS, SHADOW, TOP_GAP, faded};
use crate::surface;
use crate::theme;
// one round slot per workspace, inside the pill's inset; the pill sits LEFT px from the monitor's edge
@@ -107,18 +108,9 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
));
}
let theme = theme::now();
let content = Rectangle::new().width(width).height(COMPACT_H).padding(INSET).align_child(Start, Center).child(Row::new(slots).gap(SLOT_GAP).align(Center));
let pill = Rectangle::new()
.width(width)
.height(COMPACT_H)
.radius(Full)
.fill(backdrop(&theme.surfaces(), 255))
.border(1.0, theme.outline)
.shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0))
.padding(INSET)
.align_child(Start, Center)
.child(Row::new(slots).gap(SLOT_GAP).align(Center));
let pill = surface::shell(width, COMPACT_H, COMPACT_H / 2.0, &theme::now().surfaces(), 0.0, content);
// clicks next to the pill reach the windows underneath
let region = InputArea {