Massive refactor to match the Elm architecture better

This commit is contained in:
Emi Simpson 2022-01-22 10:39:18 -05:00
parent 31d59fd0ee
commit 415b4e7fc2
Signed by: Emi
GPG Key ID: A12F2C2FFDC3D847
9 changed files with 945 additions and 1246 deletions

1339
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,7 @@ exoquant = "0.2.0"
image = "0.23.14" image = "0.23.14"
# Display windows & graphics # Display windows & graphics
iced_native = "0.4.0" #iced_native = "0.4.0"
# Native file dialogs # Native file dialogs
rfd = "0.6.3" rfd = "0.6.3"
@ -31,12 +31,20 @@ version = "0.3.0"
[dependencies.iced] [dependencies.iced]
# Display windows & graphics # Display windows & graphics
features = ["canvas", "image"] features = ["canvas", "image"]
version = "0.3.0" git = "https://github.com/iced-rs/iced.git"
[dependencies.iced_native]
# Native-display only GUI features
git = "https://github.com/iced-rs/iced.git"
[dependencies.iced_lazy]
# Responsive widget design
git = "https://github.com/iced-rs/iced.git"
[dependencies.iced_futures] [dependencies.iced_futures]
# Display windows & graphics # Display windows & graphics
features = ["smol"] features = ["smol"]
version = "0.3.0" git = "https://github.com/iced-rs/iced.git"
[dependencies.rodio] [dependencies.rodio]
# Playing audio # Playing audio

View File

@ -1,13 +1,10 @@
use crate::lyrics::LyricEvent; use crate::editor::view_editor;
use crate::file_select::view_fileselector;
use crate::model::Model;
use crate::controls::ControlsEvent; use crate::controls::ControlsEvent;
use crate::load_song::load_song;
use crate::editor::Editor;
use crate::file_select::FileSelector;
use rfd::AsyncFileDialog;
use std::path::PathBuf; use std::path::PathBuf;
use core::time::Duration; use core::time::Duration;
use iced::Subscription; use iced::Subscription;
use iced::Clipboard;
use iced::Command; use iced::Command;
use iced::Application; use iced::Application;
use iced::Element; use iced::Element;
@ -17,12 +14,9 @@ use iced_native::subscription;
use iced_native::keyboard; use iced_native::keyboard;
use iced_native::window; use iced_native::window;
use iced_native::event::Event; use iced_native::event::Event;
use crate::model::editing::LyricEvent;
pub struct DelyriumApp { pub struct DelyriumApp(Model);
lyrics_component: Option<Editor>,
file_selector: FileSelector,
size: (u32, u32),
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum Message { pub enum Message {
@ -31,10 +25,10 @@ pub enum Message {
kind: LyricEvent, kind: LyricEvent,
}, },
PasteSent, PasteSent,
PasteRead(String),
Tick, Tick,
PromptForFile, PromptForFile,
FileOpened(PathBuf), FileOpened(PathBuf),
Resized(u32, u32),
ControlsEvent(ControlsEvent), ControlsEvent(ControlsEvent),
Null, Null,
} }
@ -46,11 +40,7 @@ impl Application for DelyriumApp {
fn new(_: Self::Flags) -> (Self, Command<Message>) { fn new(_: Self::Flags) -> (Self, Command<Message>) {
( (
DelyriumApp { DelyriumApp(Model::DEFAULT),
lyrics_component: None,
file_selector: FileSelector::default(),
size: (0, 0),
},
Command::none(), Command::none(),
) )
} }
@ -59,75 +49,18 @@ impl Application for DelyriumApp {
String::from("Delyrium") String::from("Delyrium")
} }
fn update(&mut self, message: Message, clipboard: &mut Clipboard) -> Command<Message>{ fn update(&mut self, message: Message) -> Command<Message>{
let mut command = None; self.0.update(message)
match message {
Message::LyricEvent { line_no, kind } => {
if let Some(lyrics) = self.lyrics_component.as_mut() {
lyrics.handle_lyric_event(line_no, kind);
}
},
Message::PasteSent => {
if let Some(lyrics) = self.lyrics_component.as_mut() {
#[allow(clippy::or_fun_call)] // This is a const
let clip_text = clipboard.read().unwrap_or(String::new());
let clip_pasted_len = clip_text.chars()
.filter(|c| *c != '\r' && *c != '\n')
.count();
let line = lyrics.current_line_mut().1;
line.value.truncate(line.value.len() - clip_pasted_len);
lyrics.insert_text(clip_text);
}
},
Message::Tick => {
if self.lyrics_component.is_none() {
self.file_selector.tick();
}
},
Message::FileOpened(path) => {
println!("File opened! {}", path.display());
let song = load_song(&path).unwrap().format;
let (editor, cmd) = Editor::new(song, self.size);
self.lyrics_component = Some(editor);
command = Some(cmd);
},
Message::PromptForFile => {
let task = async {
let handle = AsyncFileDialog::new()
.add_filter("Song Files", &["mp3", "flac", "ogg", "opus", "wav", "mkv"])
.set_title("Select a song")
.pick_file()
.await;
if let Some(h) = handle {
Message::FileOpened(h.path().to_owned())
} else {
Message::Null
}
};
command = Some(task.into());
},
Message::Resized(w, h) => {
self.size = (w, h);
if let Some(lyrics) = self.lyrics_component.as_mut() {
lyrics.notify_resized(w, h);
}
},
Message::ControlsEvent(e) => {
if let Some(lyrics) = self.lyrics_component.as_mut() {
lyrics.handle_controls_event(e);
}
},
Message::Null => { },
}
command.unwrap_or_else(Command::none)
} }
fn view(&mut self) -> Element<Message> { fn view(&mut self) -> Element<Message> {
if let Some(lyrics) = self.lyrics_component.as_mut() { match &mut self.0 {
lyrics.view() Model::Editing(editing) => {
} else { view_editor(editing)
self.file_selector.view() },
Model::FilePicker { tick } => {
view_fileselector(*tick)
}
} }
} }
@ -138,8 +71,8 @@ impl Application for DelyriumApp {
match (key_code, modifiers) { match (key_code, modifiers) {
( (
keyboard::KeyCode::V, keyboard::KeyCode::V,
keyboard::Modifiers { control, .. } modifiers
) if control => { ) if modifiers.control() => {
Some(Message::PasteSent) Some(Message::PasteSent)
} }
_ => { None } _ => { None }
@ -148,15 +81,12 @@ impl Application for DelyriumApp {
Event::Window(window::Event::FileDropped(path)) => { Event::Window(window::Event::FileDropped(path)) => {
Some(Message::FileOpened(path)) Some(Message::FileOpened(path))
}, },
Event::Window(window::Event::Resized{width,height}) => {
Some(Message::Resized(width,height))
},
_ => { None } _ => { None }
} }
}); });
let is_animating = if let Some(editor) = &self.lyrics_component { let is_animating = if let Model::Editing(e) = &self.0 {
editor.is_animating() e.is_animating()
} else { } else {
true true
}; };

View File

@ -26,7 +26,7 @@ pub enum ControlsEvent {
DurationDiscovered(Duration), DurationDiscovered(Duration),
} }
enum ErrorState { pub enum ErrorState {
Error(PlayerError), Error(PlayerError),
NoError { NoError {
player: Player, player: Player,
@ -36,9 +36,7 @@ enum ErrorState {
use ErrorState::*; use ErrorState::*;
pub struct Controls { pub struct Controls(pub ErrorState);
error_state: ErrorState,
}
impl Controls { impl Controls {
pub fn new(song: Box<dyn FormatReader>) -> (Self, Command<Message>) { pub fn new(song: Box<dyn FormatReader>) -> (Self, Command<Message>) {
@ -50,17 +48,16 @@ impl Controls {
|d| Message::ControlsEvent(ControlsEvent::DurationDiscovered(d)), |d| Message::ControlsEvent(ControlsEvent::DurationDiscovered(d)),
); );
(Controls { (
error_state: NoError { Controls(NoError {
has_device: player.has_output_device(), has_device: player.has_output_device(),
player, player,
} }),
}, duration_cmd) duration_cmd
)
}, },
Err(e) => { Err(e) => {
(Controls { (Controls(Error(e)), Command::none())
error_state: Error(e)
}, Command::none())
} }
} }
} }
@ -72,7 +69,7 @@ impl Controls {
} }
pub fn handle_event(&mut self, event: ControlsEvent) { pub fn handle_event(&mut self, event: ControlsEvent) {
if let NoError { player, has_device } = &mut self.error_state { if let NoError { player, has_device } = &mut self.0 {
let result = match event { let result = match event {
ControlsEvent::SeekPosition(pos) => player.seek_percentage(pos), ControlsEvent::SeekPosition(pos) => player.seek_percentage(pos),
ControlsEvent::TogglePlay => player.toggle_play(), ControlsEvent::TogglePlay => player.toggle_play(),
@ -88,14 +85,14 @@ impl Controls {
*has_device = now_has_device; *has_device = now_has_device;
}, },
Err(e) => { Err(e) => {
self.error_state = Error(e); self.0 = Error(e);
}, },
}; };
} }
} }
pub fn is_playing(&self) -> bool { pub fn is_playing(&self) -> bool {
if let NoError { player, has_device: true } = &self.error_state { if let NoError { player, has_device: true } = &self.0 {
player.is_playing() player.is_playing()
} else { } else {
false false
@ -107,7 +104,7 @@ impl Controls {
/// If there was an error, this will be `None`. In all other cases, this will return /// If there was an error, this will be `None`. In all other cases, this will return
/// `Some`. /// `Some`.
pub fn position(&self) -> Option<Duration> { pub fn position(&self) -> Option<Duration> {
if let NoError { player, .. } = &self.error_state { if let NoError { player, .. } = &self.0 {
Some(player.position()) Some(player.position())
} else { } else {
None None
@ -119,7 +116,7 @@ impl Program<Message> for (&Controls, Theme) {
fn draw(&self, bounds: Rectangle<f32>, _cursor: Cursor) -> Vec<Geometry> { fn draw(&self, bounds: Rectangle<f32>, _cursor: Cursor) -> Vec<Geometry> {
let mut frame = Frame::new(bounds.size()); let mut frame = Frame::new(bounds.size());
match &self.0.error_state { match &self.0.0 {
NoError { player, has_device: true } => { NoError { player, has_device: true } => {
let mut background = self.1.text_color; let mut background = self.1.text_color;
background.a = 0.2; background.a = 0.2;

View File

@ -1,136 +1,135 @@
use crate::lyrics::LyricEvent; use crate::model::editing::Lyric;
use iced::Command; use iced::Text;
use iced::Image; use crate::model::editing::LyricEvent;
use iced::image::Handle; use iced::widget::text_input::TextInput;
use image::DynamicImage; use iced::Alignment;
use crate::controls::ControlsEvent; use iced::widget::scrollable::{self, Scrollable};
use iced::Space;
use iced::Canvas;
use crate::styles::{Theme, FONT_VG5000};
use crate::controls::Controls; use crate::controls::Controls;
use crate::lyrics::Lyric; use crate::model::Editing;
use crate::lyrics::Lyrics; use iced::Image;
use crate::app::Message; use crate::app::Message;
use iced::Element; use iced::Element;
use crate::styles::Theme;
use iced::Container; use iced::Container;
use iced::Row; use iced::Row;
use crate::palette::Palette;
use crate::load_song::extract_cover;
use iced::Length; use iced::Length;
use image::imageops::FilterType;
use symphonia::core::formats::FormatReader; pub fn view_editor(editing: &mut Editing) -> Element<Message> {
let row = if let Some(margin_bg) = &editing.cached_resized_bg {
use image::GenericImageView; let (img1, img2) = (
pub struct Editor { Image::new(margin_bg.clone())
lyrics: Lyrics, .width(Length::FillPortion(1))
theme: Theme, .height(Length::Fill),
controls: Controls, Image::new(margin_bg.clone())
bg_img: DynamicImage, .width(Length::FillPortion(1))
cached_resized_bg: Option<Handle>, .height(Length::Fill),
dimensions: (u32, u32), );
Row::new()
.push(img1)
.push(view_progress(&editing.controls, editing.theme))
.push(view_lyrics(&mut editing.lyrics, &mut editing.scroll_state, editing.theme))
.push(img2)
} else {
Row::new()
.push(view_progress(&editing.controls, editing.theme))
.push(view_lyrics(&mut editing.lyrics, &mut editing.scroll_state, editing.theme))
};
Container::new(row)
.style(editing.theme)
.height(Length::Fill)
.into()
} }
impl Editor { pub fn view_lyrics<'a>(lyrics: &'a mut [Lyric], scroll_state: &'a mut scrollable::State, theme: Theme) -> Element<'a, Message> {
pub fn new(mut song: Box<dyn FormatReader>, size: (u32, u32)) -> (Self, Command<Message>) { let is_sole_line = lyrics.len() == 1;
let cover = extract_cover(song.as_mut()); let spacers = (
Space::new(Length::Fill, Length::Units(30)),
Space::new(Length::Fill, Length::Units(30)),
);
let theme = cover.as_ref() let scroller = lyrics.iter_mut()
.map(|cover| { .enumerate()
Theme::from_palette( .map(|(i, l)| view_lyric(l, is_sole_line, i, theme))
Palette::generate(cover) .fold(Scrollable::new(scroll_state).push(spacers.0), |s, l| s.push(l))
) .push(spacers.1)
}).unwrap_or_else(Theme::default); .width(Length::Fill)
.align_items(Alignment::Center);
let cover = cover.expect("TODO"); Container::new(scroller)
.height(Length::Fill)
#[cfg(not(debug_assertions))] .width(Length::Units(400))
let cover = cover.blur((cover.width() / 100) as f32); .center_y()
.into()
let bg_img = DynamicImage::ImageBgra8(cover.into_bgra8()); }
let (controls, cmd) = Controls::new(song); pub fn view_lyric(lyric: &mut Lyric, show_placeholder: bool, line_no: usize, theme: Theme) -> Row<Message> {
let mut editor = Self { const SMALL_SIZE: u16 = 20;
lyrics: Lyrics::new(), const LARGE_SIZE: u16 = 25;
dimensions: size, const TIMESTAMP_W: u16 = 67;
cached_resized_bg: None, const LINE_HEIGHT: u16 = 26;
controls, theme, bg_img, const TOTAL_W: u16 = 400;
};
let is_focused = lyric.is_selected();
editor.notify_resized(size.0, size.1);
let placeholder = if show_placeholder {
(editor, cmd) "Paste some lyrics to get started"
} } else if is_focused {
"..."
// TODO: work on untangling this mess } else { "" };
pub fn handle_controls_event(&mut self, event: ControlsEvent) {
self.controls.handle_event(event) let size = if is_focused { LARGE_SIZE } else { SMALL_SIZE };
}
pub fn insert_text(&mut self, text: String) { let timestamp_input = TextInput::new(
self.lyrics.insert_text(text); &mut lyric.timestamp_state,
} "",
pub fn handle_lyric_event(&mut self, line_no: usize, kind: LyricEvent) { &lyric.timestamp_raw,
self.lyrics.handle_event(line_no, kind, || self.controls.position()) move|new_value| LyricEvent::TimestampChanged(new_value).into_msg(line_no),
} )
pub fn current_line_mut(&mut self) -> (usize, &mut Lyric) { .style(theme)
self.lyrics.current_line_mut() .size(SMALL_SIZE)
} .width(Length::Units(TIMESTAMP_W))
.on_submit(LyricEvent::LineAdvanced.into_msg(line_no))
pub fn is_animating(&self) -> bool { .font(FONT_VG5000)
self.controls.is_playing() .into();
}
let text_input = TextInput::new(
fn calculate_margin_width(&self) -> u32 { &mut lyric.main_state,
let (w, _h) = self.dimensions; placeholder,
let body_size = (w / 5).max(550); &lyric.value,
move|new_value| LyricEvent::LyricChanged(new_value).into_msg(line_no),
w.saturating_sub(body_size) / 2 )
} .style(theme.active_lyric(is_focused))
.size(size)
pub fn notify_resized(&mut self, w: u32, h: u32) { .width(Length::Fill)
self.dimensions = (w, h); .on_submit(LyricEvent::LineAdvanced.into_msg(line_no))
.font(FONT_VG5000)
let (_w, h) = self.dimensions; .into();
let margin_w = self.calculate_margin_width();
let l_bracket = Text::new("[")
self.cached_resized_bg = if margin_w != 0 { .size(SMALL_SIZE)
let resized_bg = self.bg_img.resize_to_fill(margin_w, h, FilterType::Nearest); .color(theme.reduced_text_color())
.font(FONT_VG5000)
Some(Handle::from_pixels( .into();
resized_bg.width(), let r_bracket = Text::new("] ")
resized_bg.height(), .size(SMALL_SIZE)
resized_bg.into_bgra8().into_raw() .color(theme.reduced_text_color())
)) .font(FONT_VG5000)
} else { None }; .into();
}
Row::with_children(vec![l_bracket, timestamp_input, r_bracket, text_input])
pub fn view(&mut self) -> Element<Message> { .width(Length::Units(TOTAL_W))
.height(Length::Units(LINE_HEIGHT))
let row = if let Some(margin_bg) = &self.cached_resized_bg { .align_items(Alignment::Center)
let (w, _h) = self.dimensions; }
let (img1, img2) = ( pub fn view_progress(controls: &Controls, theme: Theme) -> Canvas<Message, (&Controls, Theme)> {
Image::new(margin_bg.clone()) Canvas::new((&*controls, theme))
.width(Length::Units(w as u16)) .width(Length::Units(50))
.height(Length::Fill), .height(Length::Fill)
Image::new(margin_bg.clone())
.width(Length::Units(w as u16))
.height(Length::Fill),
);
Row::new()
.push(img1)
.push(self.controls.view_progress(self.theme))
.push(self.lyrics.view(self.theme))
.push(img2)
} else {
Row::new()
.push(self.controls.view_progress(self.theme))
.push(self.lyrics.view(self.theme))
};
Container::new(row)
.style(self.theme)
.height(Length::Fill)
.into()
}
} }

View File

@ -15,8 +15,7 @@ use iced::mouse;
use iced::Color; use iced::Color;
use iced::Rectangle; use iced::Rectangle;
use iced::Length; use iced::Length;
use iced::HorizontalAlignment; use iced::alignment::{Vertical, Horizontal};
use iced::VerticalAlignment;
use iced::widget::canvas::{self, Canvas}; use iced::widget::canvas::{self, Canvas};
/* RYGCBM /* RYGCBM
@ -45,26 +44,16 @@ const FONT_MR_PIXEL: Font = Font::External {
bytes: include_bytes!("../fonts/mister-pixel/mister-pixel.otf"), bytes: include_bytes!("../fonts/mister-pixel/mister-pixel.otf"),
}; };
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub fn view_fileselector(tick: usize) -> Element<'static, Message> {
pub struct FileSelector { Canvas::new(tick)
tick: usize, .width(Length::Fill)
.height(Length::Fill)
.into()
} }
impl FileSelector { impl Program<Message> for usize {
pub fn tick(&mut self) {
self.tick = (self.tick + 1) % MAX_TICKS;
}
pub fn view(&mut self) -> Element<Message> {
Canvas::new(self)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
}
impl Program<Message> for FileSelector {
fn draw(&self, bounds: Rectangle, _cursor: Cursor) -> Vec<Geometry> { fn draw(&self, bounds: Rectangle, _cursor: Cursor) -> Vec<Geometry> {
let tick = self;
let offset_per_index = MAX_TICKS / COLORS.len(); let offset_per_index = MAX_TICKS / COLORS.len();
const TEXT_RECT_W: f32 = 350.; const TEXT_RECT_W: f32 = 350.;
@ -93,7 +82,7 @@ impl Program<Message> for FileSelector {
.enumerate() .enumerate()
.map(|(index, color)| { .map(|(index, color)| {
let size = let size =
((self.tick + offset_per_index * index) % MAX_TICKS) as f32 / ((tick + offset_per_index * index) % MAX_TICKS) as f32 /
MAX_TICKS as f32; MAX_TICKS as f32;
let top_left = interpolate(text_rect.0, Point::ORIGIN, size); let top_left = interpolate(text_rect.0, Point::ORIGIN, size);
@ -114,8 +103,8 @@ impl Program<Message> for FileSelector {
frame.fill_text( frame.fill_text(
Text { Text {
content: String::from("select a song to start"), content: String::from("select a song to start"),
horizontal_alignment: HorizontalAlignment::Center, horizontal_alignment: Horizontal::Center,
vertical_alignment: VerticalAlignment::Center, vertical_alignment: Vertical::Center,
size: 32., size: 32.,
color: Color::WHITE, color: Color::WHITE,
font: FONT_MR_PIXEL, font: FONT_MR_PIXEL,

View File

@ -3,13 +3,13 @@ use iced::settings::Settings;
mod palette; mod palette;
mod app; mod app;
mod lyrics;
mod styles; mod styles;
mod file_select; mod file_select;
mod load_song; mod load_song;
mod editor; mod editor;
mod player; mod player;
mod controls; mod controls;
mod model;
fn main() { fn main() {
app::DelyriumApp::run(Settings::default()).unwrap(); app::DelyriumApp::run(Settings::default()).unwrap();

View File

@ -1,19 +1,44 @@
use iced::Space; use image::GenericImageView;
use iced_native::text_input::Value;
use core::ops::RangeInclusive; use core::ops::RangeInclusive;
use core::time::Duration; use iced_native::widget::text_input::Value;
use iced::Row;
use iced::Text;
use iced::Container;
use iced::Length;
use iced::Element;
use crate::styles::{Theme, FONT_VG5000};
use crate::app::Message;
use iced::widget::text_input::{self, TextInput};
use iced::widget::scrollable::{self, Scrollable};
use iced::Align;
use iced_native::widget::text_input::cursor::State; use iced_native::widget::text_input::cursor::State;
use iced::image::Handle;
use crate::palette::Palette;
use crate::load_song::extract_cover;
use iced::Command;
use crate::app::Message;
use std::time::Duration;
use crate::styles::Theme;
use crate::controls::Controls;
use image::DynamicImage;
use symphonia::core::formats::FormatReader;
use iced::widget::text_input;
use iced::widget::scrollable;
use iced::clipboard;
use iced_lazy::responsive;
pub struct Editing {
pub lyrics: Vec<Lyric>,
pub bg_img: Option<DynamicImage>,
pub controls: Controls,
pub theme: Theme,
/* UI state */
pub cached_resized_bg: Option<Handle>,
pub scroll_state: scrollable::State,
pub image_responsiveness_state: (responsive::State, responsive::State),
}
pub struct Lyric {
pub value: String,
pub timestamp: Duration,
pub timestamp_raw: String,
/* UI state */
pub main_state: text_input::State,
pub timestamp_state: text_input::State,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum LyricEvent { pub enum LyricEvent {
@ -23,7 +48,7 @@ pub enum LyricEvent {
} }
impl LyricEvent { impl LyricEvent {
fn into_msg(self, line_no: usize) -> Message { pub fn into_msg(self, line_no: usize) -> Message {
Message::LyricEvent { Message::LyricEvent {
kind: self, kind: self,
line_no, line_no,
@ -31,20 +56,128 @@ impl LyricEvent {
} }
} }
pub struct Lyrics { impl Editing {
lines: Vec<Lyric>, pub fn new(mut song: Box<dyn FormatReader>) -> (Self, Command<Message>) {
scroll_state: scrollable::State, let cover = extract_cover(song.as_mut());
}
impl Lyrics { let theme = cover.as_ref()
pub fn new() -> Lyrics { .map(|cover| {
let mut lyric = Lyric::new(); Theme::from_palette(
lyric.select(); Palette::generate(cover)
)
}).unwrap_or_else(Theme::default);
Self { let cover = cover.map(|cover| {
lines: vec![lyric], #[cfg(not(debug_assertions))]
scroll_state: scrollable::State::new(), let cover = cover.blur((cover.width() / 100) as f32);
DynamicImage::ImageBgra8(cover.into_bgra8())
});
let cached_resized_bg = cover.as_ref().map(|cover| Handle::from_pixels(
cover.width(),
cover.height(),
cover.to_bgra8().into_raw(),
));
let (controls, cmd) = Controls::new(song);
let mut lyrics = Vec::with_capacity(70);
lyrics.push(Lyric::new());
let editor = Self {
scroll_state: scrollable::State::default(),
image_responsiveness_state: Default::default(),
bg_img: cover,
controls, theme, cached_resized_bg, lyrics,
};
(editor, cmd)
}
pub fn update(&mut self, message: Message) -> Command<Message> {
let mut command = None;
match message {
Message::LyricEvent { line_no, kind: LyricEvent::LyricChanged(newval) } => {
self.lyrics[line_no].value = newval
},
Message::LyricEvent { line_no, kind: LyricEvent::TimestampChanged(newval) } => {
self.lyrics[line_no].timestamp_update(newval)
},
Message::LyricEvent { line_no, kind: LyricEvent::LineAdvanced } => {
self.advance_line(line_no, self.controls.position())
},
Message::PasteSent => {
command = Some(clipboard::read(|clip|
if let Some(clip) = clip {
Message::PasteRead(clip)
} else {
Message::Null
}
));
},
Message::PasteRead(clip_text) => {
let clip_pasted_len = clip_text.chars()
.filter(|c| *c != '\r' && *c != '\n')
.count();
if let Some(line) = self.current_line_mut() {
line.value.truncate(line.value.len() - clip_pasted_len);
self.insert_text(clip_text);
}
},
Message::ControlsEvent(e) => {
self.controls.handle_event(e)
},
Message::Null |
Message::PromptForFile |
Message::FileOpened(..) |
Message::Tick => { },
} }
command.unwrap_or_else(Command::none)
}
pub fn advance_line(&mut self, current_line: usize, timestamp: Option<Duration>) {
let new_line = current_line + 1;
let line = if new_line == self.lyrics.len() {
self.insert_line(new_line, None)
} else {
self.lyrics.get_mut(new_line)
.expect("Unexpected .advance_line with index beyond # of lines")
};
line.select();
let previous_line = self.lyrics.get_mut(current_line).unwrap();
previous_line.deselect();
if let Some(timestamp) = timestamp {
previous_line.set_timestamp(timestamp);
}
}
pub fn insert_line(&mut self, index: usize, content: Option<String>) -> &mut Lyric {
self.lyrics.insert(index, match content {
Some(content) => Lyric::new_with_value(content),
None => Lyric::new(),
});
self.lyrics.get_mut(index).unwrap()
}
pub fn current_line(&self) -> Option<usize> {
self.lyrics
.iter()
.position(Lyric::is_selected)
}
pub fn current_line_mut(&mut self) -> Option<&mut Lyric> {
self.lyrics
.iter_mut()
.find(|l| l.is_selected())
}
pub fn is_animating(&self) -> bool {
self.controls.is_playing()
} }
pub fn insert_text(&mut self, text: String) { pub fn insert_text(&mut self, text: String) {
@ -53,107 +186,24 @@ impl Lyrics {
.split('\n') .split('\n')
.map(str::trim); .map(str::trim);
let (line_no, current_line) = self.current_line_mut(); if let Some(line_no) = self.current_line() {
let current_line = self.lyrics.get_mut(line_no).unwrap();
current_line.deselect(); current_line.deselect();
current_line.value.push_str(pieces.next().unwrap()); current_line.value.push_str(pieces.next().unwrap());
let pieces = pieces let pieces = pieces
.collect::<Vec<_>>() .collect::<Vec<_>>()
.into_iter() .into_iter()
.map(str::to_owned) .map(str::to_owned)
.map(Lyric::new_with_value); .map(Lyric::new_with_value);
let n_pieces = pieces.size_hint().0; let n_pieces = pieces.size_hint().0;
self.lines.splice((line_no + 1)..(line_no + 1), pieces); self.lyrics.splice((line_no + 1)..(line_no + 1), pieces);
self.lines[line_no + n_pieces].select(); self.lyrics[line_no + n_pieces].select();
}
pub fn handle_event(&mut self, line_no: usize, kind: LyricEvent, timestamp: impl Fn() -> Option<Duration>) {
match kind {
LyricEvent::LyricChanged(newval) => {
self.update_line(newval, line_no)
},
LyricEvent::TimestampChanged(newval) => {
self.lines[line_no].timestamp_update(newval)
},
LyricEvent::LineAdvanced => {
self.advance_line(line_no, timestamp())
},
} }
} }
pub fn update_line(&mut self, new_content: String, line_no: usize) {
self.lines[line_no].value = new_content;
}
pub fn advance_line(&mut self, current_line: usize, timestamp: Option<Duration>) {
let new_line = current_line + 1;
let line = if new_line == self.lines.len() {
self.insert_line(new_line, None)
} else {
self.lines.get_mut(new_line)
.expect("Unexpected .advance_line with index beyond # of lines")
};
line.select();
let previous_line = self.lines.get_mut(current_line).unwrap();
previous_line.deselect();
if let Some(timestamp) = timestamp {
previous_line.set_timestamp(timestamp);
}
}
pub fn insert_line(&mut self, index: usize, content: Option<String>) -> &mut Lyric {
self.lines.insert(index, match content {
Some(content) => Lyric::new_with_value(content),
None => Lyric::new(),
});
self.lines.get_mut(index).unwrap()
}
pub fn current_line_mut(&mut self) -> (usize, &mut Lyric) {
self.lines
.iter_mut()
.enumerate()
.find(|(_, l)| l.is_selected())
.expect("no line currently selected")
}
pub fn view(&mut self, theme: Theme) -> Element<Message> {
let is_sole_line = self.lines.len() == 1;
let spacers = (
Space::new(Length::Fill, Length::Units(30)),
Space::new(Length::Fill, Length::Units(30)),
);
let scroller = self.lines.iter_mut()
.enumerate()
.map(|(i, l)| l.view(is_sole_line, i, theme))
.fold(Scrollable::new(&mut self.scroll_state).push(spacers.0), |s, l| s.push(l))
.push(spacers.1)
.width(Length::Fill)
.align_items(Align::Center);
Container::new(scroller)
.height(Length::Fill)
.width(Length::Fill)
.center_y()
.into()
}
}
#[derive(Clone, Debug)]
pub struct Lyric {
main_state: text_input::State,
timestamp_state: text_input::State,
pub value: String,
pub timestamp: Duration,
timestamp_raw: String,
} }
impl Lyric { impl Lyric {
@ -171,66 +221,8 @@ impl Lyric {
} }
} }
pub fn view(&mut self, show_placeholder: bool, line_no: usize, theme: Theme) -> Element<Message> { pub fn is_selected(&self) -> bool {
self.main_state.is_focused() || self.timestamp_state.is_focused()
const SMALL_SIZE: u16 = 20;
const LARGE_SIZE: u16 = 25;
const TIMESTAMP_W: u16 = 67;
const LINE_HEIGHT: u16 = 26;
const TOTAL_W: u16 = 400;
let is_focused = self.is_selected();
let placeholder = if show_placeholder {
"Paste some lyrics to get started"
} else if is_focused {
"..."
} else { "" };
let size = if is_focused { LARGE_SIZE } else { SMALL_SIZE };
let timestamp_input = TextInput::new(
&mut self.timestamp_state,
"",
&self.timestamp_raw,
move|new_value| LyricEvent::TimestampChanged(new_value).into_msg(line_no),
)
.style(theme)
.size(SMALL_SIZE)
.width(Length::Units(TIMESTAMP_W))
.on_submit(LyricEvent::LineAdvanced.into_msg(line_no))
.font(FONT_VG5000)
.into();
let text_input = TextInput::new(
&mut self.main_state,
placeholder,
&self.value,
move|new_value| LyricEvent::LyricChanged(new_value).into_msg(line_no),
)
.style(theme.active_lyric(is_focused))
.size(size)
.width(Length::Fill)
.on_submit(LyricEvent::LineAdvanced.into_msg(line_no))
.font(FONT_VG5000)
.into();
let l_bracket = Text::new("[")
.size(SMALL_SIZE)
.color(theme.reduced_text_color())
.font(FONT_VG5000)
.into();
let r_bracket = Text::new("] ")
.size(SMALL_SIZE)
.color(theme.reduced_text_color())
.font(FONT_VG5000)
.into();
Row::with_children(vec![l_bracket, timestamp_input, r_bracket, text_input])
.width(Length::Units(TOTAL_W))
.height(Length::Units(LINE_HEIGHT))
.align_items(Align::Center)
.into()
} }
pub fn select(&mut self) { pub fn select(&mut self) {
@ -238,10 +230,6 @@ impl Lyric {
self.main_state.move_cursor_to_end(); self.main_state.move_cursor_to_end();
} }
pub fn is_selected(&self) -> bool {
self.main_state.is_focused() || self.timestamp_state.is_focused()
}
pub fn deselect(&mut self) { pub fn deselect(&mut self) {
self.main_state.unfocus(); self.main_state.unfocus();
self.timestamp_state.unfocus(); self.timestamp_state.unfocus();

61
src/model/mod.rs Normal file
View File

@ -0,0 +1,61 @@
pub mod editing;
use rfd::FileHandle;
use rfd::AsyncFileDialog;
use crate::load_song::load_song;
use iced::Command;
use crate::app::Message;
pub use editing::Editing;
// We allow a large enum variant here because this is only used in one place, so it's okay
// to reserve the extra stack space for when FilePicker becomes Editing
#[allow(clippy::large_enum_variant)]
pub enum Model {
FilePicker { tick: usize },
Editing(Editing),
}
const MAX_TICKS: usize = 900;
impl Model {
pub const DEFAULT: Self = Model::FilePicker { tick: 0 };
pub fn update(&mut self, message: Message) -> Command<Message> {
match self {
Self::FilePicker { tick } => {
match message {
Message::Tick => {
*tick = (*tick + 1) % MAX_TICKS;
Command::none()
},
Message::FileOpened(path) => {
println!("File opened! {}", path.display());
let song = load_song(&path).unwrap().format;
let (editing, cmd) = Editing::new(song);
*self = Self::Editing(editing);
cmd
},
Message::PromptForFile => {
let show_dialog = AsyncFileDialog::new()
.add_filter("Song Files", &["mp3", "flac", "ogg", "opus", "wav", "mkv"])
.set_title("Select a song")
.pick_file();
let to_message = |handle: Option<FileHandle>| if let Some(h) = handle {
Message::FileOpened(h.path().to_owned())
} else {
Message::Null
};
Command::perform(show_dialog, to_message)
},
_ => { Command::none() }
}
},
Self::Editing(model) => {
model.update(message)
},
}
}
}