From fc90260d5d4984df992b287a84d1c49cbc215e86 Mon Sep 17 00:00:00 2001 From: server2 Date: Tue, 6 Oct 2026 16:38:18 +0200 Subject: [PATCH] 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 --- src/island.rs | 210 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 176 insertions(+), 34 deletions(-) diff --git a/src/island.rs b/src/island.rs index 9547d7c..8e8050b 100644 --- a/src/island.rs +++ b/src/island.rs @@ -1,9 +1,10 @@ +use std::collections::HashMap; use std::f32::consts::{PI, TAU}; use std::thread; use std::time::{Duration, Instant}; 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, Pointer, Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack, Start, Text, Vertical, Weight, Widget, Zone, children, shapes, @@ -23,6 +24,10 @@ const OPEN_MS: u64 = 560; const PILL_GONE: f32 = 0.18; 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 const COMPACT_W: f32 = 220.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 CHIP: Color = Color::rgba(255, 255, 255, 18); 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 EDGE: Color = Color::rgba(255, 255, 255, 26); const SURFACE_BOTTOM: Color = Color::rgb(13, 10, 18); @@ -101,6 +109,20 @@ pub enum Menu { 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 { // 0 is the compact pill, 1 the open card; everything in between is drawn from it 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 scrub: Option, seek_shown_until: Option, + + // each hovered spot's highlight, fading between 0 and 1 + hover: HashMap, } impl Service for Island { @@ -125,6 +150,7 @@ impl Service for Island { menu: None, scrub: None, seek_shown_until: None, + hover: HashMap::new(), } } @@ -132,6 +158,57 @@ impl Service for Island { 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) { + 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 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)); @@ -174,6 +251,9 @@ pub fn view() -> LayerWindow { 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 * 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(); } - 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 radius = mix(COMPACT_H / 2.0, WIDE_RADIUS, grow); @@ -213,7 +293,7 @@ pub fn view() -> LayerWindow { .height(height) .radius(radius) .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 @@ -221,7 +301,16 @@ pub fn view() -> LayerWindow { * with the card coming in. no clip anywhere: neither face ever overflows the shape */ 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 { shape }; @@ -230,6 +319,8 @@ pub fn view() -> LayerWindow { .align_child(Center, Start) .on_click(|_| tap()) .on_hover(|inside| { + hovering(Spot::Island)(inside); + if !inside { collapse(); } @@ -277,14 +368,19 @@ fn collapse() { let mut island = Island::write(); island.expanded = false; - island.menu = None; + island.set_menu(None); 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) { 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, }; - Island::write().menu = menu; + Island::write().set_menu(menu); } ["preset", name] => { let Some((player, identity)) = shown_player else { @@ -571,7 +667,7 @@ fn side(scene: &Scene) -> Column { .gap(8.0) .width(SIDE_W), timeline(scene), - controls(player), + controls(player, scene.island), ]) .justify(SpaceBetween) .width(SIDE_W) @@ -591,20 +687,25 @@ fn chips(scene: &Scene) -> Row { 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() .width(output_w) .height(CHIP_H) .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 }) .align_child(Start, Center) .cursor(Pointer) + .scale(1.0 + 0.04 * output_lit) .on_click(|_| toggle_menu(Menu::Output)) + .on_hover(hovering(Spot::Output)) .child( Row::new(children![ 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(), - icon(Glyph::Chevron, 8.0, DIM), + icon(Glyph::Chevron, 8.0, Color::blend(DIM, TEXT, output_lit)), ]) .gap(6.0) .align(Center), @@ -614,15 +715,20 @@ fn chips(scene: &Scene) -> Row { .width(player_w) .height(CHIP_H) .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 }) .align_child(Start, Center) .cursor(Pointer) + .scale(1.0 + 0.04 * player_lit) .on_click(|_| toggle_menu(Menu::Player)) + .on_hover(hovering(Spot::Player)) .child( - Row::new(children![Text::new(player_label).size(11.0).color(DIM).elide(), icon(Glyph::Chevron, 8.0, DIM)]) - .gap(6.0) - .align(Center), + Row::new(children![ + Text::new(player_label).size(11.0).color(Color::blend(DIM, TEXT, player_lit)).elide(), + 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) @@ -748,24 +854,38 @@ fn clock(seconds: f32) -> String { format!("{:02}:{:02}", seconds / 60, seconds % 60) } -fn controls(player: &MediaPlayer) -> Row { +fn controls(player: &MediaPlayer, island: &Island) -> Row { let previous = player.clone(); let toggle = player.clone(); let next = player.clone(); - // pause is two tall pink bars, play a pink triangle - let middle = if player.playing() { - let bar = || Rectangle::new().width(10.0).height(30.0).radius(3.0).fill(PINK); + /* + * hovered, play/pause swells, gains a halo and its pink warms toward white; + * 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 { - 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![ - button(icon(Glyph::Previous, 18.0, TEXT)).on_click(move |_| previous.previous()), - middle.cursor(Pointer).on_click(move |_| toggle.play_pause()), - button(icon(Glyph::Next, 18.0, TEXT)).on_click(move |_| next.next()), + button(Glyph::Previous, island.lit(Spot::Previous)).on_hover(hovering(Spot::Previous)).on_click(move |_| previous.previous()), + glowing(middle, lit, 0.55).cursor(Pointer).on_hover(hovering(Spot::PlayPause)).on_click(move |_| toggle.play_pause()), + button(Glyph::Next, island.lit(Spot::Next)).on_hover(hovering(Spot::Next)).on_click(move |_| next.next()), ]) .gap(26.0) .align(Center) @@ -774,8 +894,19 @@ fn controls(player: &MediaPlayer) -> Row { .height(CONTROLS_H) } -fn button(glyph: Canvas) -> Rectangle { - Rectangle::new().width(40.0).height(40.0).radius(Full).align_child(Center, Center).cursor(Pointer).child(glyph) +// a round icon button: hovered, it swells, its icon turns pink and a soft pink halo grows around it +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 { @@ -798,9 +929,11 @@ fn eq_header(scene: &Scene) -> Row { .width(VOLUME_CHIP_W) .height(22.0) .radius(6.0) - .fill(CHIP) + .fill(Color::blend(CHIP, CHIP_HOVER, scene.island.lit(Spot::Volume))) .align_child(Center, Center) .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_click(move |_| Sound::toggle_mute(&muting)) .child( @@ -951,18 +1084,24 @@ fn presets(scene: &Scene) -> Column { for (name, _) in row { let chosen = current == *name; + let lit = scene.island.lit(Spot::Preset(name)); let player = scene.player.name().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( Rectangle::new() .width(PRESET_W) .height(PRESET_H) .radius(8.0) - .fill(if chosen { PINK } else { CHIP }) + .fill(fill) .align_child(Center, Center) .cursor(Pointer) + .scale(1.0 + 0.05 * lit) + .on_hover(hovering(Spot::Preset(name))) .on_click(move |_| { Sound::preset(&player, &identity, name); }) @@ -989,25 +1128,26 @@ fn menu_panel(scene: &Scene, menu: Menu) -> Rectangle { Menu::Output => { 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 identity = scene.player.identity().to_string(); let device = sink.name.clone(); 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); }))); } } Menu::Player => { - for other in scene.players { + for (row, other) in scene.players.iter().enumerate() { let name = other.name().to_string(); 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()); }))); } @@ -1033,19 +1173,21 @@ fn sink_label(sink: &Sink) -> &str { 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() .width(Parent) .height(MENU_ROW_H) .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 }) .align_child(Start, Center) .cursor(Pointer) + .translate(4.0 * island.lit(Spot::Row(row)), 0.0) + .on_hover(hovering(Spot::Row(row))) .on_click(move |_| { choose(); - Island::write().menu = None; + Island::write().set_menu(None); }) .child( Row::new(children![