diff --git a/src/app.rs b/src/app.rs index 35639d5..84300c8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,4 @@ +use crate::lyrics::LyricEvent; use crate::controls::ControlsEvent; use crate::load_song::load_song; use crate::editor::Editor; @@ -25,11 +26,10 @@ pub struct DelyriumApp { #[derive(Clone, Debug)] pub enum Message { - LyricChanged { + LyricEvent { line_no: usize, - new_value: String, + kind: LyricEvent, }, - LineAdvanced(usize), PasteSent, Tick, PromptForFile, @@ -62,14 +62,9 @@ impl Application for DelyriumApp { fn update(&mut self, message: Message, clipboard: &mut Clipboard) -> Command{ let mut command = None; match message { - Message::LyricChanged { line_no, new_value } => { + Message::LyricEvent { line_no, kind } => { if let Some(lyrics) = self.lyrics_component.as_mut() { - lyrics.update_line(new_value, line_no); - } - }, - Message::LineAdvanced(current_line) => { - if let Some(lyrics) = self.lyrics_component.as_mut() { - lyrics.advance_line(current_line); + lyrics.handle_lyric_event(line_no, kind); } }, Message::PasteSent => { diff --git a/src/editor.rs b/src/editor.rs index 2f26ebd..54ec467 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1,3 +1,4 @@ +use crate::lyrics::LyricEvent; use iced::Command; use iced::Image; use iced::image::Handle; @@ -63,6 +64,9 @@ impl Editor { 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) + } pub fn update_line(&mut self, new_content: String, line_no: usize) { self.lyrics.update_line(new_content, line_no); } @@ -79,7 +83,7 @@ impl Editor { fn calculate_margin_width(&self) -> u32 { let (w, _h) = self.dimensions; - let body_size = (w / 5).max(450); + let body_size = (w / 5).max(550); w.saturating_sub(body_size) / 2 } diff --git a/src/lyrics.rs b/src/lyrics.rs index 6c2844a..64e82de 100644 --- a/src/lyrics.rs +++ b/src/lyrics.rs @@ -1,3 +1,8 @@ +use iced_native::text_input::Value; +use core::ops::RangeInclusive; +use core::time::Duration; +use iced::Row; +use iced::Text; use iced::Container; use iced::Length; use iced::Element; @@ -7,6 +12,23 @@ 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; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LyricEvent { + LyricChanged(String), + TimestampChanged(String), + LineAdvanced, +} + +impl LyricEvent { + fn to_msg(self, line_no: usize) -> Message { + Message::LyricEvent { + kind: self, + line_no, + } + } +} pub struct Lyrics { lines: Vec, @@ -47,6 +69,20 @@ impl Lyrics { self.lines[line_no + n_pieces].select(); } + pub fn handle_event(&mut self, line_no: usize, kind: LyricEvent) { + 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) + }, + } + } + pub fn update_line(&mut self, new_content: String, line_no: usize) { self.lines[line_no].value = new_content; } @@ -106,8 +142,11 @@ impl Lyrics { #[derive(Clone, Debug)] pub struct Lyric { - state: text_input::State, + main_state: text_input::State, + timestamp_state: text_input::State, pub value: String, + pub timestamp: Duration, + timestamp_raw: String, } impl Lyric { @@ -117,44 +156,189 @@ impl Lyric { pub fn new_with_value(val: String) -> Self { Lyric { - state: text_input::State::new(), + main_state: text_input::State::new(), + timestamp_state: text_input::State::new(), + timestamp: Duration::ZERO, + timestamp_raw: String::from("0:00.000"), value: val, } } pub fn view(&mut self, show_placeholder: bool, line_no: usize, theme: Theme) -> Element { + let is_focused = self.is_selected(); + let placeholder = if show_placeholder { "Paste some lyrics to get started" - } else if self.state.is_focused() { + } else if is_focused { "..." } else { "" }; - let size = if self.state.is_focused() { 30 } else { 20 }; + let size = if is_focused { 30 } else { 20 }; - TextInput::new( - &mut self.state, + let timestamp_input = TextInput::new( + &mut self.timestamp_state, + "", + &self.timestamp_raw, + move|new_value| LyricEvent::TimestampChanged(new_value).to_msg(line_no), + ) + .style(theme) + .size(size - 5) + .width(Length::Units(97)) + .on_submit(LyricEvent::LineAdvanced.to_msg(line_no)) + .into(); + + let text_input = TextInput::new( + &mut self.main_state, placeholder, &self.value, - move|new_value| Message::LyricChanged { line_no, new_value }, + move|new_value| LyricEvent::LyricChanged(new_value).to_msg(line_no), ) .style(theme) .size(size) - .width(Length::Units(350)) - .on_submit(Message::LineAdvanced(line_no)) + .width(Length::Fill) + .on_submit(LyricEvent::LineAdvanced.to_msg(line_no)) + .into(); + + let l_bracket = Text::new("[") + .size(size) + .into(); + let r_bracket = Text::new("]") + .size(size) + .into(); + + Row::with_children(vec![l_bracket, timestamp_input, r_bracket, text_input]) + .width(Length::Units(400)) + .align_items(Align::Center) .into() } pub fn select(&mut self) { - self.state.focus(); - self.state.move_cursor_to_end(); + self.main_state.focus(); + self.main_state.move_cursor_to_end(); } pub fn is_selected(&self) -> bool { - self.state.is_focused() + self.main_state.is_focused() || self.timestamp_state.is_focused() } pub fn deselect(&mut self) { - self.state.unfocus(); + self.main_state.unfocus(); + self.timestamp_state.unfocus(); + } + + pub (crate) fn timestamp_update(&mut self, newval: String) { + if let Some((shift, validated)) = Self::clean_timestamp(newval) { + self.timestamp = dbg!(Self::parse_validated_timestamp(&validated)); + self.timestamp_raw = validated; + if shift != 0 { + self.timestamp_state.move_cursor_to( + match self.timestamp_state.cursor().state(&Value::new(&self.timestamp_raw)) { + State::Index(p) => ((p as isize) + shift) as usize, + State::Selection { start, ..} => { + // Should be impossible, but lets handle it anyway + ((start as isize) + shift) as usize + }, + } + ); + } + } + } + + fn clean_timestamp(mut raw: String) -> Option<(isize, String)> { + // Rules: + // - [R0] Must have exactly 1 colon (:) + // - [R1] Must have exactly 1 period (.) + // - [R2] The period must follow the colon + // - [R3] No characters outside 0-9, colon, and period + // - [R4] Unnecessary leading zeros besides normal padding are trimmed + // - [R5] Each section is padded to the appropriate length (reversed for millis) + const VALID_CHARS: RangeInclusive = '0'..=':'; + const MIN_DIGIT_COUNTS: [usize; 3] = [1, 2, 3]; + let mut colon_count = 0; + let mut period_count = 0; + let mut digit_counts = [0; 3]; + for c in raw.chars() { + match c { + ':' => { + colon_count += 1; + if colon_count > 1 { + return None; // Rejected [R0] + } + }, + '.' => { + period_count += 1; + if colon_count == 0 /* [R2] */ || period_count > 1 /* [R1] */ { + return None; // Rejected + } + }, + _ if VALID_CHARS.contains(&c) || c == '.' => { + let section = colon_count + period_count; + digit_counts[section] += 1; + }, + _ => { + return None; // Rejected [R3] + } + } + } + + if period_count == 0 { + return None; //Rejected [R1] + } + + let mut i = 0; + let mut cursor_shift = 0; + for section in 0..3 { + while digit_counts[section] < MIN_DIGIT_COUNTS[section] { + // [R5] + if section == 2 { + raw.push('0'); + } else { + raw.insert(i, '0'); + cursor_shift += 1; + } + digit_counts[section] += 1; + } + + while + digit_counts[section] > MIN_DIGIT_COUNTS[section] + && if section == 2 { + raw.chars().next_back().unwrap() == '0' + } else { + raw.chars().nth(i).unwrap() == '0' + } + { + // [R4] + if section == 2 { + raw.truncate(raw.len() - 1); + } else { + raw.remove(i); + cursor_shift -= 1; + } + digit_counts[section] -= 1; + } + + i += digit_counts[section] + 1; + } + + Some((cursor_shift, raw)) + } + + fn parse_validated_timestamp(s: &str) -> Duration { + let (minutes, s) = s.split_at(s.find(':').expect( + "parse_validated_timestamp received a timestamp without a :" + )); + let (seconds, millis) = s.split_at(s.find('.').expect( + "parse_validated_timestamp received a timestamp without a . after the :" + )); + + let minutes: u64 = minutes.parse() + .expect("parse_validated_timestamp received an invalid number of minutes"); + let seconds: u64 = seconds[1..].parse() + .expect("parse_validated_timestamp received an invalid number of seconds"); + let millis: u32 = millis[1..4].parse() + .expect("parse_validated_timestamp received an invalid number of millis"); + + Duration::new(seconds + minutes * 60, millis * 1_000_000) } }