362 lines
9.1 KiB
Rust
362 lines
9.1 KiB
Rust
use image::GenericImageView;
|
|
use core::ops::RangeInclusive;
|
|
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 {
|
|
LyricChanged(String),
|
|
TimestampChanged(String),
|
|
LineAdvanced,
|
|
}
|
|
|
|
impl LyricEvent {
|
|
pub fn into_msg(self, line_no: usize) -> Message {
|
|
Message::LyricEvent {
|
|
kind: self,
|
|
line_no,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Editing {
|
|
pub fn new(mut song: Box<dyn FormatReader>) -> (Self, Command<Message>) {
|
|
let cover = extract_cover(song.as_mut());
|
|
|
|
let theme = cover.as_ref()
|
|
.map(|cover| {
|
|
Theme::from_palette(
|
|
Palette::generate(cover)
|
|
)
|
|
}).unwrap_or_else(Theme::default);
|
|
|
|
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) {
|
|
|
|
let mut pieces = text.trim_end()
|
|
.split('\n')
|
|
.map(str::trim);
|
|
|
|
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());
|
|
|
|
let pieces = pieces
|
|
.collect::<Vec<_>>()
|
|
.into_iter()
|
|
.map(str::to_owned)
|
|
.map(Lyric::new_with_value);
|
|
let n_pieces = pieces.size_hint().0;
|
|
|
|
self.lyrics.splice((line_no + 1)..(line_no + 1), pieces);
|
|
|
|
self.lyrics[line_no + n_pieces].select();
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Lyric {
|
|
pub fn new() -> Self {
|
|
Self::new_with_value(String::with_capacity(70))
|
|
}
|
|
|
|
pub fn new_with_value(val: String) -> Self {
|
|
Lyric {
|
|
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 is_selected(&self) -> bool {
|
|
self.main_state.is_focused() || self.timestamp_state.is_focused()
|
|
}
|
|
|
|
pub fn select(&mut self) {
|
|
self.main_state.focus();
|
|
self.main_state.move_cursor_to_end();
|
|
}
|
|
|
|
pub fn deselect(&mut self) {
|
|
self.main_state.unfocus();
|
|
self.timestamp_state.unfocus();
|
|
}
|
|
|
|
pub fn set_timestamp(&mut self, timestamp: Duration) {
|
|
self.timestamp = timestamp;
|
|
let seconds = timestamp.as_secs();
|
|
let minutes = seconds / 60;
|
|
let seconds = seconds % 60;
|
|
let millis = timestamp.as_millis() % 1000;
|
|
self.timestamp_raw = format!("{}:{:02}.{:03}", minutes, seconds, millis);
|
|
}
|
|
|
|
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<char> = '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.ends_with('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)
|
|
}
|
|
}
|