Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod sound_editor;
pub mod sound_grid;
pub mod sound_tile;
pub mod theme;
pub mod tile_layout;
pub mod volume;
pub mod waveform;

Expand Down
43 changes: 37 additions & 6 deletions src/ui/sound_grid.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use iced::widget::{button, column, container, mouse_area, row, text};
use iced::widget::{Space, button, column, container, mouse_area, row, text};
use iced::{Element, Length, mouse};

use crate::app::Message;
use crate::state::{SlotMap, SoundEntry, SoundMetaStore};
use crate::ui::sound_tile::{self, SoundTileData};
use crate::ui::theme::{self, Hh, Theme};
use crate::ui::tile_layout;

#[derive(Clone, Copy)]
pub struct SlotCtx<'a> {
Expand All @@ -28,6 +29,10 @@ struct TileCtx<'a> {
sound_meta: &'a SoundMetaStore,
}

fn missing_tile_slots(tiles_in_row: usize, columns: usize) -> usize {
columns.saturating_sub(tiles_in_row)
}

pub fn view_grid<'a>(
sounds: &[&'a SoundEntry],
playing: Option<&str>,
Expand All @@ -54,11 +59,13 @@ pub fn view_grid<'a>(
shortcuts_active: grid.shortcuts_active,
sound_meta: grid.sound_meta,
};
// Keep invalid callers from reaching slice::chunks(0).
let columns = grid.columns.max(1);

let rows: Vec<Element<'a, Message>> = sounds
.chunks(grid.columns)
.chunks(columns)
.map(|chunk| {
let tiles: Vec<Element<'a, Message>> = chunk
let mut tiles: Vec<Element<'a, Message>> = chunk
.iter()
.map(|sound| {
let is_playing = playing == Some(sound.id.as_str());
Expand All @@ -71,19 +78,27 @@ pub fn view_grid<'a>(
})
.collect();

tiles.extend((0..missing_tile_slots(chunk.len(), columns)).map(|_| {
Space::new()
.width(Length::Fill)
.height(tile_layout::tile_slot_height())
.into()
}));

let r = tiles
.into_iter()
.fold(row![].spacing(theme::space::LG), |r, t| r.push(t));
.fold(row![].spacing(theme::space::LG), |r, t| r.push(t))
.width(Length::Fill);

r.into()
})
.collect();

let grid = rows
let grid_column = rows
.into_iter()
.fold(column![].spacing(theme::space::LG), |c, r| c.push(r));

grid.width(Length::Fill).into()
grid_column.width(Length::Fill).into()
}

fn tile_data(sound: &SoundEntry, ctx: TileCtx<'_>) -> SoundTileData {
Expand Down Expand Up @@ -255,3 +270,19 @@ pub fn context_menu_overlay<'a>(
.height(Length::Fill)
.into()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn incomplete_rows_reserve_all_missing_tile_slots() {
// Iced view rendering is intentionally not unit-tested here; this pins
// the pure filler-slot contract used by the grid rows above.
assert_eq!(missing_tile_slots(0, 5), 5);
assert_eq!(missing_tile_slots(1, 5), 4);
assert_eq!(missing_tile_slots(2, 5), 3);
assert_eq!(missing_tile_slots(5, 5), 0);
assert_eq!(missing_tile_slots(6, 5), 0);
}
}
31 changes: 21 additions & 10 deletions src/ui/sound_tile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ use iced::{Event, window};

use crate::app::Message;
use crate::ui::theme::{self, Hh, Theme, Tone};
use crate::ui::tile_layout;

mod animation;
mod effects;

pub const PLACEHOLDER_GRAPHIC: &str = "\u{1f50a}";
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
const ROTATION_STEPS_PER_DEGREE: u64 = 1_000;

#[derive(Debug, Clone, PartialEq)]
pub struct SoundTileData {
Expand Down Expand Up @@ -52,8 +54,11 @@ pub fn seed_from_sound_id(id: &str) -> u64 {
}

pub fn rotation_degrees(seed: u64) -> f32 {
let bucket = (seed % 6_001) as f32;
bucket / 1_000.0 - 3.0
let bucket_count =
(tile_layout::IDLE_MAX_ROTATION_DEGREES * 2.0 * ROTATION_STEPS_PER_DEGREE as f32) as u64
+ 1;
let bucket = (seed % bucket_count) as f32;
bucket / ROTATION_STEPS_PER_DEGREE as f32 - tile_layout::IDLE_MAX_ROTATION_DEGREES
}

pub fn tone_from_seed(seed: u64) -> Tone {
Expand Down Expand Up @@ -117,17 +122,13 @@ impl<Message> canvas::Program<Message> for SoundTile {
pub fn view<'a>(data: SoundTileData, theme: Theme, is_playing: bool) -> Element<'a, Message> {
canvas::Canvas::new(SoundTile::new(data, theme, is_playing))
.width(Length::Fill)
.height(theme::component::SOUND_TILE_H)
.height(tile_layout::tile_slot_height())
.into()
}

impl SoundTile {
fn paint(&self, frame: &mut canvas::Frame, size: Size, hover_progress: f32) {
let inset = 6.0;
let tile_size = Size::new(
(size.width - inset * 2.0).max(0.0),
(size.height - inset * 2.0).max(0.0),
);
let tile_size = tile_layout::fitted_tile_size(size);
let center = Point::new(size.width / 2.0, size.height / 2.0);
let rotation = animation::hover_rotation_degrees(self.data.seed, hover_progress);

Expand Down Expand Up @@ -322,12 +323,22 @@ mod tests {

assert_eq!(first, second);
assert!(
(-3.0..=3.0).contains(&first),
"rotation {first} was outside the +/-3 degree range"
(-tile_layout::IDLE_MAX_ROTATION_DEGREES..=tile_layout::IDLE_MAX_ROTATION_DEGREES)
.contains(&first),
"rotation {first} exceeded the tile layout clearance"
);
}
}

#[test]
fn rotation_degrees_uses_tile_layout_clearance_bound() {
assert_eq!(rotation_degrees(0), -tile_layout::IDLE_MAX_ROTATION_DEGREES);
assert_eq!(
rotation_degrees(6_000),
tile_layout::IDLE_MAX_ROTATION_DEGREES
);
}

#[test]
fn seed_from_sound_id_uses_stable_fallback_for_non_hex_ids() {
let airhorn = seed_from_sound_id("airhorn");
Expand Down
21 changes: 16 additions & 5 deletions src/ui/sound_tile/animation.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
use std::time::{Duration, Instant};

use crate::ui::tile_layout;

pub const HOVER_ANIMATION_DURATION: Duration = Duration::from_millis(150);
const HOVER_ROTATION_SCALE: f32 = 8.0 / 3.0;

pub fn hover_rotation_degrees(seed: u64, hover_progress: f32) -> f32 {
let scale = 1.0 + (HOVER_ROTATION_SCALE - 1.0) * hover_progress.clamp(0.0, 1.0);
let scale = tile_layout::MAX_ROTATION_DEGREES / tile_layout::IDLE_MAX_ROTATION_DEGREES;
let scale = 1.0 + (scale - 1.0) * hover_progress.clamp(0.0, 1.0);
super::rotation_degrees(seed) * scale
}

Expand Down Expand Up @@ -105,13 +107,22 @@ mod tests {
fn hover_rotation_preserves_idle_rotation_at_zero_progress() {
let seed = 6_000;

assert_near(hover_rotation_degrees(seed, 0.0), 3.0);
assert_near(
hover_rotation_degrees(seed, 0.0),
tile_layout::IDLE_MAX_ROTATION_DEGREES,
);
}

#[test]
fn hover_rotation_amplifies_idle_range_to_eight_degrees() {
assert_near(hover_rotation_degrees(6_000, 1.0), 8.0);
assert_near(hover_rotation_degrees(0, 1.0), -8.0);
assert_near(
hover_rotation_degrees(6_000, 1.0),
tile_layout::MAX_ROTATION_DEGREES,
);
assert_near(
hover_rotation_degrees(0, 1.0),
-tile_layout::MAX_ROTATION_DEGREES,
);
}

#[test]
Expand Down
140 changes: 140 additions & 0 deletions src/ui/tile_layout.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
use iced::Size;

use crate::ui::theme;

const TILE_INSET: f32 = 6.0;
// Covers a four-column grid near a 2560px-wide window; wider cells shrink the
// drawn tile instead of increasing row height or drawing into neighboring rows.
const ROTATION_CLEARANCE_WIDTH: f32 = 600.0;
pub const IDLE_MAX_ROTATION_DEGREES: f32 = 3.0;
pub const MAX_ROTATION_DEGREES: f32 = 8.0;

pub fn tile_slot_height() -> f32 {
theme::component::SOUND_TILE_H.max(
rotated_bounds(
Size::new(ROTATION_CLEARANCE_WIDTH, visible_tile_height()),
MAX_ROTATION_DEGREES,
)
.height
.ceil(),
)
}

pub fn fitted_tile_size(slot: Size) -> Size {
let mut height = visible_tile_height().min((slot.height - TILE_INSET * 2.0).max(0.0));
let width = (slot.width - TILE_INSET * 2.0)
.min(max_width_inside_slot(slot, height))
.max(0.0);
height = height.min(max_height_inside_slot(slot, width)).max(0.0);

Size::new(width, height)
}

fn visible_tile_height() -> f32 {
(theme::component::SOUND_TILE_H - TILE_INSET * 2.0).max(0.0)
}

fn max_width_inside_slot(slot: Size, height: f32) -> f32 {
let radians = MAX_ROTATION_DEGREES.to_radians();
let sin = radians.sin();
let cos = radians.cos();

// Degenerate guards keep future zero/near-90 degree constants finite.
let by_width = if cos <= f32::EPSILON {
slot.width
} else {
(slot.width - height * sin) / cos
};
let by_height = if sin <= f32::EPSILON {
slot.width
} else {
(slot.height - height * cos) / sin
};

by_width.min(by_height)
}

fn max_height_inside_slot(slot: Size, width: f32) -> f32 {
let radians = MAX_ROTATION_DEGREES.to_radians();
let sin = radians.sin();
let cos = radians.cos();

// Degenerate guards keep future zero/near-90 degree constants finite.
let by_width = if sin <= f32::EPSILON {
slot.height
} else {
(slot.width - width * cos) / sin
};
let by_height = if cos <= f32::EPSILON {
slot.height
} else {
(slot.height - width * sin) / cos
};

by_width.min(by_height)
}

fn rotated_bounds(size: Size, degrees: f32) -> Size {
let radians = degrees.abs().to_radians();
let sin = radians.sin();
let cos = radians.cos();

Size::new(
size.width * cos + size.height * sin,
size.width * sin + size.height * cos,
)
}

#[cfg(test)]
mod tests {
use super::*;

const EPSILON: f32 = 0.01;

#[test]
fn tile_slot_height_reserves_rotation_clearance() {
assert!(tile_slot_height() > theme::component::SOUND_TILE_H);
}

#[test]
fn tile_slot_height_reserves_hover_rotation_clearance() {
let required = rotated_bounds(
Size::new(ROTATION_CLEARANCE_WIDTH, visible_tile_height()),
MAX_ROTATION_DEGREES,
)
.height
.ceil();

assert_eq!(tile_slot_height(), required);
}

#[test]
fn fitted_tile_size_keeps_rotated_bounds_inside_slot() {
for width in [6.0, 120.0, 360.0, 720.0] {
let slot = Size::new(width, tile_slot_height());
let tile = fitted_tile_size(slot);
let bounds = rotated_bounds(tile, MAX_ROTATION_DEGREES);

assert!(
bounds.width <= slot.width + EPSILON,
"rotated width {} exceeded slot width {}",
bounds.width,
slot.width
);
assert!(
bounds.height <= slot.height + EPSILON,
"rotated height {} exceeded slot height {}",
bounds.height,
slot.height
);
}
}

#[test]
fn fitted_tile_size_keeps_visible_tile_height_stable() {
let slot = Size::new(360.0, tile_slot_height());
let tile = fitted_tile_size(slot);

assert!((tile.height - visible_tile_height()).abs() < EPSILON);
}
}