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:
+176
-34
@@ -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<f32>,
|
||||
seek_shown_until: Option<Instant>,
|
||||
|
||||
// each hovered spot's highlight, fading between 0 and 1
|
||||
hover: HashMap<Spot, Animation>,
|
||||
}
|
||||
|
||||
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<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
|
||||
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![
|
||||
|
||||
Reference in New Issue
Block a user