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"
# Display windows & graphics
iced_native = "0.4.0"
#iced_native = "0.4.0"
# Native file dialogs
rfd = "0.6.3"
@ -31,12 +31,20 @@ version = "0.3.0"
[dependencies.iced]
# Display windows & graphics
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]
# Display windows & graphics
features = ["smol"]
version = "0.3.0"
git = "https://github.com/iced-rs/iced.git"
[dependencies.rodio]
# 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::load_song::load_song;
use crate::editor::Editor;
use crate::file_select::FileSelector;
use rfd::AsyncFileDialog;
use std::path::PathBuf;
use core::time::Duration;
use iced::Subscription;
use iced::Clipboard;
use iced::Command;
use iced::Application;
use iced::Element;
@ -17,12 +14,9 @@ use iced_native::subscription;
use iced_native::keyboard;
use iced_native::window;
use iced_native::event::Event;
use crate::model::editing::LyricEvent;
pub struct DelyriumApp {
lyrics_component: Option<Editor>,
file_selector: FileSelector,
size: (u32, u32),
}
pub struct DelyriumApp(Model);
#[derive(Clone, Debug)]
pub enum Message {
@ -31,10 +25,10 @@ pub enum Message {
kind: LyricEvent,
},
PasteSent,
PasteRead(String),
Tick,
PromptForFile,
FileOpened(PathBuf),
Resized(u32, u32),
ControlsEvent(ControlsEvent),
Null,
}
@ -46,11 +40,7 @@ impl Application for DelyriumApp {
fn new(_: Self::Flags) -> (Self, Command<Message>) {
(
DelyriumApp {
lyrics_component: None,
file_selector: FileSelector::default(),
size: (0, 0),
},
DelyriumApp(Model::DEFAULT),
Command::none(),
)
}
@ -59,75 +49,18 @@ impl Application for DelyriumApp {
String::from("Delyrium")
}
fn update(&mut self, message: Message, clipboard: &mut Clipboard) -> Command<Message>{
let mut command = None;
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 update(&mut self, message: Message) -> Command<Message>{
self.0.update(message)
}
fn view(&mut self) -> Element<Message> {
if let Some(lyrics) = self.lyrics_component.as_mut() {
lyrics.view()
} else {
self.file_selector.view()
match &mut self.0 {
Model::Editing(editing) => {
view_editor(editing)
},
Model::FilePicker { tick } => {
view_fileselector(*tick)
}
}
}
@ -138,8 +71,8 @@ impl Application for DelyriumApp {
match (key_code, modifiers) {
(
keyboard::KeyCode::V,
keyboard::Modifiers { control, .. }
) if control => {
modifiers
) if modifiers.control() => {
Some(Message::PasteSent)
}
_ => { None }
@ -148,15 +81,12 @@ impl Application for DelyriumApp {
Event::Window(window::Event::FileDropped(path)) => {
Some(Message::FileOpened(path))
},
Event::Window(window::Event::Resized{width,height}) => {
Some(Message::Resized(width,height))
},
_ => { None }
}
});
let is_animating = if let Some(editor) = &self.lyrics_component {
editor.is_animating()
let is_animating = if let Model::Editing(e) = &self.0 {
e.is_animating()
} else {
true
};

View File

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

View File

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

View File

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

View File

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

View File

@ -1,19 +1,44 @@
use iced::Space;
use iced_native::text_input::Value;
use image::GenericImageView;
use core::ops::RangeInclusive;
use core::time::Duration;
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::Value;
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)]
pub enum LyricEvent {
@ -23,7 +48,7 @@ pub enum LyricEvent {
}
impl LyricEvent {
fn into_msg(self, line_no: usize) -> Message {
pub fn into_msg(self, line_no: usize) -> Message {
Message::LyricEvent {
kind: self,
line_no,
@ -31,20 +56,128 @@ impl LyricEvent {
}
}
pub struct Lyrics {
lines: Vec<Lyric>,
scroll_state: scrollable::State,
}
impl Editing {
pub fn new(mut song: Box<dyn FormatReader>) -> (Self, Command<Message>) {
let cover = extract_cover(song.as_mut());
impl Lyrics {
pub fn new() -> Lyrics {
let mut lyric = Lyric::new();
lyric.select();
let theme = cover.as_ref()
.map(|cover| {
Theme::from_palette(
Palette::generate(cover)
)
}).unwrap_or_else(Theme::default);
Self {
lines: vec![lyric],
scroll_state: scrollable::State::new(),
let cover = cover.map(|cover| {
#[cfg(not(debug_assertions))]
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) {
@ -53,107 +186,24 @@ impl Lyrics {
.split('\n')
.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.value.push_str(pieces.next().unwrap());
current_line.deselect();
current_line.value.push_str(pieces.next().unwrap());
let pieces = pieces
.collect::<Vec<_>>()
.into_iter()
.map(str::to_owned)
.map(Lyric::new_with_value);
let n_pieces = pieces.size_hint().0;
let pieces = pieces
.collect::<Vec<_>>()
.into_iter()
.map(str::to_owned)
.map(Lyric::new_with_value);
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();
}
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())
},
self.lyrics[line_no + n_pieces].select();
}
}
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 {
@ -171,66 +221,8 @@ impl Lyric {
}
}
pub fn view(&mut self, show_placeholder: bool, line_no: usize, theme: Theme) -> Element<Message> {
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 is_selected(&self) -> bool {
self.main_state.is_focused() || self.timestamp_state.is_focused()
}
pub fn select(&mut self) {
@ -238,10 +230,6 @@ impl Lyric {
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) {
self.main_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)
},
}
}
}