island: animated hover on the island and every button

- each hovered spot eases a highlight in and out over 220 ms
- pill: widens 10 px and its drop shadow turns into a centred pink glow
  (the same shadow the pill already had, so no extra cost)
- previous/next: swell 12%, icon turns pink, pink halo; play/pause swells
  10%, halo, its pink warms toward white
- chips and presets brighten and grow 4-6%, dropdown rows slide right
- halos only exist while hovered (a shadow is two gpu layers per frame)
- one button lit at a time, and highlights reset when the card or the
  dropdown changes: amane matches hover targets by position in the frame,
  so a target that disappears never hears the pointer leave
This commit is contained in:
server2
2026-10-06 16:38:18 +02:00
parent c228f40154
commit fc90260d5d
+176 -34
View File
@@ -1,9 +1,10 @@
use std::collections::HashMap;
use std::f32::consts::{PI, TAU}; use std::f32::consts::{PI, TAU};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use amane::{ use amane::{
Animation, Arc, Argument, Bus, Canvas, Cap, Center, Color, Column, Easing, End, Full, Gradient, Animation, Arc, Argument, Blend, Bus, Canvas, Cap, Center, Color, Column, Easing, End, Full, Gradient,
Image, InputArea, Layer, LayerWindow, Line, Media, MediaPlayer, Padding, Palette, Parent, Path, Image, InputArea, Layer, LayerWindow, Line, Media, MediaPlayer, Padding, Palette, Parent, Path,
Pointer, Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack, Pointer, Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack,
Start, Text, Vertical, Weight, Widget, Zone, children, shapes, Start, Text, Vertical, Weight, Widget, Zone, children, shapes,
@@ -23,6 +24,10 @@ const OPEN_MS: u64 = 560;
const PILL_GONE: f32 = 0.18; const PILL_GONE: f32 = 0.18;
const SHAPE_DONE: f32 = 0.6; const SHAPE_DONE: f32 = 0.6;
// hover highlights ease in and out over this long; the pill widens by PILL_HOVER_GROW
const HOVER_MS: u64 = 220;
const PILL_HOVER_GROW: f32 = 10.0;
// sizes, in pixels // sizes, in pixels
const COMPACT_W: f32 = 220.0; const COMPACT_W: f32 = 220.0;
const COMPACT_H: f32 = 38.0; const COMPACT_H: f32 = 38.0;
@@ -90,6 +95,9 @@ const TEXT: Color = Color::rgb(241, 236, 246);
const DIM: Color = Color::rgba(205, 192, 218, 160); const DIM: Color = Color::rgba(205, 192, 218, 160);
const CHIP: Color = Color::rgba(255, 255, 255, 18); const CHIP: Color = Color::rgba(255, 255, 255, 18);
const CHIP_OPEN: Color = Color::rgba(255, 255, 255, 34); const CHIP_OPEN: Color = Color::rgba(255, 255, 255, 34);
// a hovered chip or button; CLEAR is its starting point where there is no fill, white so the fade stays grey-free
const CHIP_HOVER: Color = Color::rgba(255, 255, 255, 46);
const CLEAR: Color = Color::rgba(255, 255, 255, 0);
const TRACK: Color = Color::rgba(255, 255, 255, 24); const TRACK: Color = Color::rgba(255, 255, 255, 24);
const EDGE: Color = Color::rgba(255, 255, 255, 26); const EDGE: Color = Color::rgba(255, 255, 255, 26);
const SURFACE_BOTTOM: Color = Color::rgb(13, 10, 18); const SURFACE_BOTTOM: Color = Color::rgb(13, 10, 18);
@@ -101,6 +109,20 @@ pub enum Menu {
Player, Player,
} }
// everything that lights up under the pointer
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Spot {
Island,
Output,
Player,
Volume,
Previous,
PlayPause,
Next,
Preset(&'static str),
Row(usize),
}
pub struct Island { pub struct Island {
// 0 is the compact pill, 1 the open card; everything in between is drawn from it // 0 is the compact pill, 1 the open card; everything in between is drawn from it
open: Animation, open: Animation,
@@ -114,6 +136,9 @@ pub struct Island {
// where the time bar is pressed, 0 to 1, and how long a released seek stays on screen // where the time bar is pressed, 0 to 1, and how long a released seek stays on screen
scrub: Option<f32>, scrub: Option<f32>,
seek_shown_until: Option<Instant>, seek_shown_until: Option<Instant>,
// each hovered spot's highlight, fading between 0 and 1
hover: HashMap<Spot, Animation>,
} }
impl Service for Island { impl Service for Island {
@@ -125,6 +150,7 @@ impl Service for Island {
menu: None, menu: None,
scrub: None, scrub: None,
seek_shown_until: None, seek_shown_until: None,
hover: HashMap::new(),
} }
} }
@@ -132,6 +158,57 @@ impl Service for Island {
fn listen() {} fn listen() {}
} }
impl Island {
// how far the highlight on that spot has faded in, 0 to 1
fn lit(&self, spot: Spot) -> f32 {
self.hover.get(&spot).map_or(0.0, Animation::value)
}
/*
* amane tells hover targets apart by their place in the frame, so a button that
* disappears never hears the pointer leave: highlights of buttons that come and go
* with the menu or the card are dropped when those change
*/
fn set_menu(&mut self, menu: Option<Menu>) {
self.menu = menu;
self.hover.retain(|spot, _| !matches!(spot, Spot::Row(_)));
}
}
// an on_hover handler easing that spot's highlight in on enter and out on leave
fn hovering(spot: Spot) -> impl Fn(bool) + 'static {
move |inside| {
let mut island = Island::write();
// one button at a time, so a leave amane lost (see set_menu) can't keep another one lit
if inside && spot != Spot::Island {
for (other, highlight) in &mut island.hover {
if *other != spot && *other != Spot::Island {
highlight.to(0.0);
}
}
}
island
.hover
.entry(spot)
.or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(HOVER_MS)))
.to(if inside { 1.0 } else { 0.0 });
}
}
/*
* a soft pink halo that grows with `lit`; nothing at rest, since every shadow costs
* two gpu layers each frame
*/
fn glowing(rectangle: Rectangle, lit: f32, strength: f32) -> Rectangle {
if lit < 0.01 {
return rectangle;
}
rectangle.shadow(Shadow::drop(PINK).opacity(strength * lit).blur(6.0 + 12.0 * lit))
}
// the picked player while it's still open, else the one Media calls active // the picked player while it's still open, else the one Media calls active
pub fn shown<'a>(media: &'a Media, selected: Option<&str>) -> Option<&'a MediaPlayer> { pub fn shown<'a>(media: &'a Media, selected: Option<&str>) -> Option<&'a MediaPlayer> {
let picked = selected.and_then(|name| media.players().iter().find(|player| player.name() == name)); let picked = selected.and_then(|name| media.players().iter().find(|player| player.name() == name));
@@ -174,6 +251,9 @@ pub fn view() -> LayerWindow {
let (grow, pill, reveal) = phases(island.open.value()); let (grow, pill, reveal) = phases(island.open.value());
// the pill under the pointer widens a little and glows pink; the card doesn't
let lift = island.lit(Spot::Island) * (1.0 - grow);
/* /*
* live levels keep frames coming the way an animation does: a service change * live levels keep frames coming the way an animation does: a service change
* alone asks with an empty commit, which hyprland only answers on its next repaint * alone asks with an empty commit, which hyprland only answers on its next repaint
@@ -182,7 +262,7 @@ pub fn view() -> LayerWindow {
amane::request_frame(); amane::request_frame();
} }
let width = mix(COMPACT_W, WIDE_W, grow); let width = mix(COMPACT_W + PILL_HOVER_GROW * lift, WIDE_W, grow);
let height = mix(COMPACT_H, WIDE_H, grow); let height = mix(COMPACT_H, WIDE_H, grow);
let radius = mix(COMPACT_H / 2.0, WIDE_RADIUS, grow); let radius = mix(COMPACT_H / 2.0, WIDE_RADIUS, grow);
@@ -213,7 +293,7 @@ pub fn view() -> LayerWindow {
.height(height) .height(height)
.radius(radius) .radius(radius)
.fill(backdrop(&colors, 255)) .fill(backdrop(&colors, 255))
.border(1.0, EDGE); .border(1.0, Color::blend(EDGE, faded(PINK, 110), lift));
/* /*
* amane redraws the whole window every frame, so big static effects are paid * amane redraws the whole window every frame, so big static effects are paid
@@ -221,7 +301,16 @@ pub fn view() -> LayerWindow {
* with the card coming in. no clip anywhere: neither face ever overflows the shape * with the card coming in. no clip anywhere: neither face ever overflows the shape
*/ */
let shape = if reveal < 1.0 { let shape = if reveal < 1.0 {
shape.shadow(Shadow::drop("#000000").opacity(0.55 * (1.0 - reveal)).blur(24.0).offset(0.0, 10.0)) /*
* hovered, the pill's own drop shadow turns into a glow: pink, centred, a little
* stronger, so it costs nothing the pill wasn't already paying
*/
let shadow = Shadow::drop(Color::blend(Color::BLACK, PINK, lift))
.opacity(mix(0.55, 0.7, lift) * (1.0 - reveal))
.blur(mix(24.0, 20.0, lift))
.offset(0.0, mix(10.0, 0.0, lift));
shape.shadow(shadow)
} else { } else {
shape shape
}; };
@@ -230,6 +319,8 @@ pub fn view() -> LayerWindow {
.align_child(Center, Start) .align_child(Center, Start)
.on_click(|_| tap()) .on_click(|_| tap())
.on_hover(|inside| { .on_hover(|inside| {
hovering(Spot::Island)(inside);
if !inside { if !inside {
collapse(); collapse();
} }
@@ -277,14 +368,19 @@ fn collapse() {
let mut island = Island::write(); let mut island = Island::write();
island.expanded = false; island.expanded = false;
island.menu = None; island.set_menu(None);
island.open.to(0.0); island.open.to(0.0);
// the card's buttons are about to go, see set_menu
island.hover.retain(|spot, _| *spot == Spot::Island);
} }
fn toggle_menu(menu: Menu) { fn toggle_menu(menu: Menu) {
let mut island = Island::write(); let mut island = Island::write();
island.menu = if island.menu == Some(menu) { None } else { Some(menu) }; let menu = if island.menu == Some(menu) { None } else { Some(menu) };
island.set_menu(menu);
} }
/* /*
@@ -314,7 +410,7 @@ pub fn command(arguments: &[String]) -> String {
_ => None, _ => None,
}; };
Island::write().menu = menu; Island::write().set_menu(menu);
} }
["preset", name] => { ["preset", name] => {
let Some((player, identity)) = shown_player else { let Some((player, identity)) = shown_player else {
@@ -571,7 +667,7 @@ fn side(scene: &Scene) -> Column {
.gap(8.0) .gap(8.0)
.width(SIDE_W), .width(SIDE_W),
timeline(scene), timeline(scene),
controls(player), controls(player, scene.island),
]) ])
.justify(SpaceBetween) .justify(SpaceBetween)
.width(SIDE_W) .width(SIDE_W)
@@ -591,20 +687,25 @@ fn chips(scene: &Scene) -> Row {
let menu = scene.island.menu; let menu = scene.island.menu;
let output_lit = scene.island.lit(Spot::Output);
let player_lit = scene.island.lit(Spot::Player);
let output = Rectangle::new() let output = Rectangle::new()
.width(output_w) .width(output_w)
.height(CHIP_H) .height(CHIP_H)
.radius(6.0) .radius(6.0)
.fill(if menu == Some(Menu::Output) { CHIP_OPEN } else { CHIP }) .fill(Color::blend(if menu == Some(Menu::Output) { CHIP_OPEN } else { CHIP }, CHIP_HOVER, output_lit))
.padding(Padding { top: 0.0, right: 9.0, bottom: 0.0, left: 9.0 }) .padding(Padding { top: 0.0, right: 9.0, bottom: 0.0, left: 9.0 })
.align_child(Start, Center) .align_child(Start, Center)
.cursor(Pointer) .cursor(Pointer)
.scale(1.0 + 0.04 * output_lit)
.on_click(|_| toggle_menu(Menu::Output)) .on_click(|_| toggle_menu(Menu::Output))
.on_hover(hovering(Spot::Output))
.child( .child(
Row::new(children![ Row::new(children![
icon(if device.is_some_and(|device| device.bluetooth) { Glyph::Bluetooth } else { Glyph::Speaker }, 12.0, PINK), icon(if device.is_some_and(|device| device.bluetooth) { Glyph::Bluetooth } else { Glyph::Speaker }, 12.0, PINK),
Text::new(output_label).size(11.0).color(TEXT).elide(), Text::new(output_label).size(11.0).color(TEXT).elide(),
icon(Glyph::Chevron, 8.0, DIM), icon(Glyph::Chevron, 8.0, Color::blend(DIM, TEXT, output_lit)),
]) ])
.gap(6.0) .gap(6.0)
.align(Center), .align(Center),
@@ -614,15 +715,20 @@ fn chips(scene: &Scene) -> Row {
.width(player_w) .width(player_w)
.height(CHIP_H) .height(CHIP_H)
.radius(6.0) .radius(6.0)
.fill(if menu == Some(Menu::Player) { CHIP_OPEN } else { Color::TRANSPARENT }) .fill(Color::blend(if menu == Some(Menu::Player) { CHIP_OPEN } else { CLEAR }, CHIP_HOVER, player_lit))
.padding(Padding { top: 0.0, right: 4.0, bottom: 0.0, left: 4.0 }) .padding(Padding { top: 0.0, right: 4.0, bottom: 0.0, left: 4.0 })
.align_child(Start, Center) .align_child(Start, Center)
.cursor(Pointer) .cursor(Pointer)
.scale(1.0 + 0.04 * player_lit)
.on_click(|_| toggle_menu(Menu::Player)) .on_click(|_| toggle_menu(Menu::Player))
.on_hover(hovering(Spot::Player))
.child( .child(
Row::new(children![Text::new(player_label).size(11.0).color(DIM).elide(), icon(Glyph::Chevron, 8.0, DIM)]) Row::new(children![
.gap(6.0) Text::new(player_label).size(11.0).color(Color::blend(DIM, TEXT, player_lit)).elide(),
.align(Center), icon(Glyph::Chevron, 8.0, Color::blend(DIM, TEXT, player_lit)),
])
.gap(6.0)
.align(Center),
); );
Row::new(children![output, player]).gap(8.0).align(Center).width(SIDE_W).height(CHIP_H) Row::new(children![output, player]).gap(8.0).align(Center).width(SIDE_W).height(CHIP_H)
@@ -748,24 +854,38 @@ fn clock(seconds: f32) -> String {
format!("{:02}:{:02}", seconds / 60, seconds % 60) format!("{:02}:{:02}", seconds / 60, seconds % 60)
} }
fn controls(player: &MediaPlayer) -> Row { fn controls(player: &MediaPlayer, island: &Island) -> Row {
let previous = player.clone(); let previous = player.clone();
let toggle = player.clone(); let toggle = player.clone();
let next = player.clone(); let next = player.clone();
// pause is two tall pink bars, play a pink triangle /*
let middle = if player.playing() { * hovered, play/pause swells, gains a halo and its pink warms toward white;
let bar = || Rectangle::new().width(10.0).height(30.0).radius(3.0).fill(PINK); * previous and next do the same smaller, their icons turning pink
*/
let lit = island.lit(Spot::PlayPause);
let ink = Color::blend(PINK, FLAME, 0.55 * lit);
Rectangle::new().width(CONTROLS_H).height(CONTROLS_H).align_child(Center, Center).child(Row::new(children![bar(), bar()]).gap(7.0)) let middle = Rectangle::new()
.width(CONTROLS_H)
.height(CONTROLS_H)
.radius(Full)
.fill(Color::blend(faded(PINK, 0), faded(PINK, 34), lit))
.align_child(Center, Center)
.scale(1.0 + 0.1 * lit);
let middle = if player.playing() {
let bar = || Rectangle::new().width(10.0).height(30.0).radius(3.0).fill(ink);
middle.child(Row::new(children![bar(), bar()]).gap(7.0))
} else { } else {
Rectangle::new().width(CONTROLS_H).height(CONTROLS_H).align_child(Center, Center).child(icon(Glyph::Play, 30.0, PINK)) middle.child(icon(Glyph::Play, 30.0, ink))
}; };
Row::new(children![ Row::new(children![
button(icon(Glyph::Previous, 18.0, TEXT)).on_click(move |_| previous.previous()), button(Glyph::Previous, island.lit(Spot::Previous)).on_hover(hovering(Spot::Previous)).on_click(move |_| previous.previous()),
middle.cursor(Pointer).on_click(move |_| toggle.play_pause()), glowing(middle, lit, 0.55).cursor(Pointer).on_hover(hovering(Spot::PlayPause)).on_click(move |_| toggle.play_pause()),
button(icon(Glyph::Next, 18.0, TEXT)).on_click(move |_| next.next()), button(Glyph::Next, island.lit(Spot::Next)).on_hover(hovering(Spot::Next)).on_click(move |_| next.next()),
]) ])
.gap(26.0) .gap(26.0)
.align(Center) .align(Center)
@@ -774,8 +894,19 @@ fn controls(player: &MediaPlayer) -> Row {
.height(CONTROLS_H) .height(CONTROLS_H)
} }
fn button(glyph: Canvas) -> Rectangle { // a round icon button: hovered, it swells, its icon turns pink and a soft pink halo grows around it
Rectangle::new().width(40.0).height(40.0).radius(Full).align_child(Center, Center).cursor(Pointer).child(glyph) fn button(glyph: Glyph, lit: f32) -> Rectangle {
let circle = Rectangle::new()
.width(40.0)
.height(40.0)
.radius(Full)
.fill(Color::blend(faded(PINK, 0), faded(PINK, 26), lit))
.align_child(Center, Center)
.scale(1.0 + 0.12 * lit)
.cursor(Pointer)
.child(icon(glyph, 18.0, Color::blend(TEXT, PINK, lit)));
glowing(circle, lit, 0.4)
} }
fn eq_header(scene: &Scene) -> Row { fn eq_header(scene: &Scene) -> Row {
@@ -798,9 +929,11 @@ fn eq_header(scene: &Scene) -> Row {
.width(VOLUME_CHIP_W) .width(VOLUME_CHIP_W)
.height(22.0) .height(22.0)
.radius(6.0) .radius(6.0)
.fill(CHIP) .fill(Color::blend(CHIP, CHIP_HOVER, scene.island.lit(Spot::Volume)))
.align_child(Center, Center) .align_child(Center, Center)
.cursor(Pointer) .cursor(Pointer)
.scale(1.0 + 0.06 * scene.island.lit(Spot::Volume))
.on_hover(hovering(Spot::Volume))
.on_scroll(move |scroll| Sound::set_volume(&scrolling, stepped(volume, scroll))) .on_scroll(move |scroll| Sound::set_volume(&scrolling, stepped(volume, scroll)))
.on_click(move |_| Sound::toggle_mute(&muting)) .on_click(move |_| Sound::toggle_mute(&muting))
.child( .child(
@@ -951,18 +1084,24 @@ fn presets(scene: &Scene) -> Column {
for (name, _) in row { for (name, _) in row {
let chosen = current == *name; let chosen = current == *name;
let lit = scene.island.lit(Spot::Preset(name));
let player = scene.player.name().to_string(); let player = scene.player.name().to_string();
let identity = scene.player.identity().to_string(); let identity = scene.player.identity().to_string();
// a hovered preset brightens: grey ones toward white, the chosen pink one toward its flame
let fill = if chosen { Color::blend(PINK, FLAME, lit * 0.6) } else { Color::blend(CHIP, CHIP_HOVER, lit) };
buttons.push(Box::new( buttons.push(Box::new(
Rectangle::new() Rectangle::new()
.width(PRESET_W) .width(PRESET_W)
.height(PRESET_H) .height(PRESET_H)
.radius(8.0) .radius(8.0)
.fill(if chosen { PINK } else { CHIP }) .fill(fill)
.align_child(Center, Center) .align_child(Center, Center)
.cursor(Pointer) .cursor(Pointer)
.scale(1.0 + 0.05 * lit)
.on_hover(hovering(Spot::Preset(name)))
.on_click(move |_| { .on_click(move |_| {
Sound::preset(&player, &identity, name); Sound::preset(&player, &identity, name);
}) })
@@ -989,25 +1128,26 @@ fn menu_panel(scene: &Scene, menu: Menu) -> Rectangle {
Menu::Output => { Menu::Output => {
let current = scene.sound.channel(scene.player.name()).and_then(|channel| channel.output.as_ref()).map(|sink| sink.name.clone()); let current = scene.sound.channel(scene.player.name()).and_then(|channel| channel.output.as_ref()).map(|sink| sink.name.clone());
for sink in scene.sound.outputs() { for (row, sink) in scene.sound.outputs().iter().enumerate() {
let player = scene.player.name().to_string(); let player = scene.player.name().to_string();
let identity = scene.player.identity().to_string(); let identity = scene.player.identity().to_string();
let device = sink.name.clone(); let device = sink.name.clone();
let glyph = if sink.bluetooth { Glyph::Bluetooth } else { Glyph::Speaker }; let glyph = if sink.bluetooth { Glyph::Bluetooth } else { Glyph::Speaker };
let current = current.as_deref() == Some(sink.name.as_str());
rows.push(Box::new(menu_row(&parse::middle_ellipsis(sink_label(sink), MENU_LETTERS), current.as_deref() == Some(sink.name.as_str()), glyph, move || { rows.push(Box::new(menu_row(&parse::middle_ellipsis(sink_label(sink), MENU_LETTERS), current, glyph, row, scene.island, move || {
Sound::set_output(&player, &identity, &device); Sound::set_output(&player, &identity, &device);
}))); })));
} }
} }
Menu::Player => { Menu::Player => {
for other in scene.players { for (row, other) in scene.players.iter().enumerate() {
let name = other.name().to_string(); let name = other.name().to_string();
let glyph = if other.playing() { Glyph::Play } else { Glyph::Pause }; let glyph = if other.playing() { Glyph::Play } else { Glyph::Pause };
rows.push(Box::new(menu_row(other.identity(), other.name() == scene.player.name(), glyph, move || { rows.push(Box::new(menu_row(other.identity(), other.name() == scene.player.name(), glyph, row, scene.island, move || {
Island::write().selected = Some(name.clone()); Island::write().selected = Some(name.clone());
}))); })));
} }
@@ -1033,19 +1173,21 @@ fn sink_label(sink: &Sink) -> &str {
if sink.description.is_empty() { &sink.name } else { &sink.description } if sink.description.is_empty() { &sink.name } else { &sink.description }
} }
fn menu_row(label: &str, current: bool, glyph: Glyph, choose: impl Fn() + 'static) -> Rectangle { fn menu_row(label: &str, current: bool, glyph: Glyph, row: usize, island: &Island, choose: impl Fn() + 'static) -> Rectangle {
Rectangle::new() Rectangle::new()
.width(Parent) .width(Parent)
.height(MENU_ROW_H) .height(MENU_ROW_H)
.radius(6.0) .radius(6.0)
.fill(if current { CHIP } else { Color::TRANSPARENT }) .fill(Color::blend(if current { CHIP } else { CLEAR }, CHIP_HOVER, island.lit(Spot::Row(row))))
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 }) .padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
.align_child(Start, Center) .align_child(Start, Center)
.cursor(Pointer) .cursor(Pointer)
.translate(4.0 * island.lit(Spot::Row(row)), 0.0)
.on_hover(hovering(Spot::Row(row)))
.on_click(move |_| { .on_click(move |_| {
choose(); choose();
Island::write().menu = None; Island::write().set_menu(None);
}) })
.child( .child(
Row::new(children![ Row::new(children![