logging & loading & directories

main
aprzn 2 years ago
parent 0562aae2c0
commit 055e849e1c

394
'

@ -0,0 +1,394 @@
#![feature(iter_array_chunks)]
#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
#![allow(dead_code)]
mod gd;
mod music;
use eframe::egui;
use reqwest::blocking as req;
use std::boxed::Box;
use std::collections::VecDeque;
use std::error::Error;
use std::fs::File;
use std::io::Write;
use std::mem;
use thiserror::Error;
struct PipeDash {
project_dirs: directories::ProjectDirs,
msg_queue: VecDeque<Message>,
selected_level: Option<usize>,
level_list: Vec<gd::OuterLevel>,
loaded_song: Option<Song>,
loaded_level_checksum: Option<(gd::OuterLevel, md5::Digest)>,
editor: Editor,
error: Option<Box<dyn Error>>,
}
#[derive(Debug, PartialEq, Eq)]
enum Color {
Orange,
Yellow,
Green,
}
#[derive(Debug)]
enum Message {
LevelSelected(usize),
CloseError,
LoadLevel,
}
struct Editor {
state: EditorState,
data: GdlData,
}
struct EditorState {
scroll_pos: f32,
pts_per_second: f32, // zoom level
subdivisions: f32,
}
struct GdlData {
green_lines: music::Lines,
orange_lines: music::Lines,
yellow_lines: music::Lines,
beat_rate: music::BeatRate,
time_signatures: music::TimeSignature,
}
struct Song {
name: String,
id: i32,
decoder: rodio::Decoder<File>,
}
#[derive(Error, Debug)]
enum SongError {
#[error("Not a Newgrounds song")]
NotNewgrounds,
#[error("Song mp3 couldn't be downloaded")]
MissingFile(#[from] std::io::Error),
#[error("Couldn't decode mp3 file")]
BrokenSong(#[from] rodio::decoder::DecoderError),
#[error("Couldn't access song data on servers")]
GdServerError(#[from] gd::SongRequestError),
#[error("Couldn't fetch song from Newgrounds")]
NgServerError(#[from] reqwest::Error),
#[error("Missing download link")]
MissingLink,
}
struct BeatRateWidget<'a> {
state: &'a mut EditorState,
beat_rate: &'a mut music::BeatRate,
}
struct TimeSignatureWidget<'a> {
state: &'a mut EditorState,
time_signatures: &'a mut music::TimeSignature,
}
struct LinesWidget<'a> {
state: &'a mut EditorState,
lines: &'a mut music::Lines,
color: Color,
}
struct WaveformWidget<'a> {
state: &'a mut EditorState,
song: &'a Song,
}
fn allocate_editor_space(ui: &mut egui::Ui) -> (egui::Rect, egui::Response) {
let max_rect = ui.max_rect();
let preferred_size = egui::Vec2::new(max_rect.size().x, 60.0);
ui.allocate_exact_size(preferred_size, egui::Sense::click_and_drag())
}
impl From<Color> for eframe::epaint::Color32 {
fn from(rhs: Color) -> Self {
match rhs {
Color::Green => Self::from_rgb(0, 255, 0),
Color::Orange => Self::from_rgb(255, 127, 0),
Color::Yellow => Self::from_rgb(255, 255, 0),
}
}
}
impl Song {
pub fn try_new(gd_song: &gd::Song) -> Result<Self, SongError> {
if let &gd::Song::Newgrounds { id } = gd_song {
let song_response = gd_song.get_response();
let song_path = gd::save_path().join(format!("{id}.mp3"));
let (file, name) = match (File::open(&song_path), song_response) {
(Ok(file), response) => {
let name = response
.ok()
.and_then(|response| response.name().map(Into::into))
.unwrap_or_default();
(file, name)
}
(Err(_), Ok(response)) => {
let song_blob = response
.download_link()
.ok_or(SongError::MissingLink)
.and_then(|link| Ok(req::get(link)?.bytes()?))?;
let mut file = File::open(&song_path)?;
file.write_all(&song_blob)?;
(file, response.name().unwrap_or("").into())
}
(Err(err), Err(_)) => return Err(err.into()),
};
let decoder = rodio::Decoder::new_mp3(file)?;
Ok(Self { name, id, decoder })
} else {
Err(SongError::NotNewgrounds)
}
}
}
impl Editor {
pub fn beat_rate_widget(&mut self) -> BeatRateWidget {
BeatRateWidget {
state: &mut self.state,
beat_rate: &mut self.data.beat_rate,
}
}
pub fn time_signature_widget(&mut self) -> TimeSignatureWidget {
TimeSignatureWidget {
state: &mut self.state,
time_signatures: &mut self.data.time_signatures,
}
}
pub fn lines_widget(&mut self, col: Color) -> LinesWidget {
LinesWidget {
state: &mut self.state,
lines: match col {
Color::Green => &mut self.data.green_lines,
Color::Yellow => &mut self.data.yellow_lines,
Color::Orange => &mut self.data.orange_lines,
},
color: col,
}
}
pub fn waveform_widget<'a>(&'a mut self, song: &'a Song) -> WaveformWidget {
WaveformWidget {
state: &mut self.state,
song,
}
}
}
impl<'a> egui::Widget for BeatRateWidget<'a> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
let (rect, res) = allocate_editor_space(ui);
// handle interactions
// draw widget
if ui.is_rect_visible(rect) {
ui.painter()
.rect_filled(rect, 0f32, eframe::epaint::Color32::from_gray(0));
}
res
}
}
impl<'a> egui::Widget for TimeSignatureWidget<'a> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
// 1. choose size
let max_rect = ui.max_rect();
let preferred_size = egui::Vec2::new(max_rect.size().x, 60.0);
// 2. allocate space
let (rect, res) = ui.allocate_exact_size(preferred_size, egui::Sense::click_and_drag());
// 3. handle interactions
// 4. draw widget
if ui.is_rect_visible(rect) {
ui.painter()
.rect_filled(rect, 0f32, eframe::epaint::Color32::from_gray(0));
}
res
}
}
impl<'a> egui::Widget for LinesWidget<'a> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
// 1. choose size
let max_rect = ui.max_rect();
let preferred_size = egui::Vec2::new(max_rect.size().x, 60.0);
// 2. allocate space
let (rect, res) = ui.allocate_exact_size(preferred_size, egui::Sense::click_and_drag());
// 3. handle interactions
// 4. draw widget
if ui.is_rect_visible(rect) {
ui.painter()
.rect_filled(rect, 0f32, eframe::epaint::Color32::from_gray(0));
}
res
}
}
impl<'a> egui::Widget for WaveformWidget<'a> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
// 1. choose size
let max_rect = ui.max_rect();
let preferred_size = egui::Vec2::new(max_rect.size().x, 60.0);
// 2. allocate space
let (rect, res) = ui.allocate_exact_size(preferred_size, egui::Sense::click_and_drag());
// 3. handle interactions
// 4. draw widget
if ui.is_rect_visible(rect) {
ui.painter()
.rect_filled(rect, 0f32, eframe::epaint::Color32::from_gray(0));
}
res
}
}
impl PipeDash {
fn new(_cc: &eframe::CreationContext) -> Self {
Self {
selected_level: None,
msg_queue: VecDeque::new(),
level_list: gd::OuterLevel::load_all(),
loaded_song: None,
loaded_level_checksum: None,
error: None,
editor: Editor {
state: EditorState {
scroll_pos: 0f32,
pts_per_second: 5f32,
subdivisions: 4.0,
},
data: GdlData {
beat_rate: music::StaticBeatRate::from_bpm(120f32).into(),
time_signatures: music::StaticTimeSignature::new(4, 4).into(),
green_lines: music::Lines::new(),
orange_lines: music::Lines::new(),
yellow_lines: music::Lines::new(),
},
},
}
}
fn side_panel(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
egui::SidePanel::left("level_picker")
.default_width(100f32)
.show(ctx, |ui| {
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
if ui
.add_enabled(
self.selected_level.is_some(),
egui::Button::new("Load Level"),
)
.clicked()
{
self.msg_queue.push_back(Message::LoadLevel);
}
egui::ScrollArea::vertical().show(ui, |ui| {
ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui| {
for (idx, level) in self.level_list.iter().enumerate() {
if ui
.selectable_label(
self.selected_level == Some(idx),
level.display_name(),
)
.clicked()
{
self.msg_queue.push_back(Message::LevelSelected(idx));
}
}
})
});
});
});
}
fn center_panel(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.vertical_centered_justified(|ui| {
ui.label(
egui::RichText::new(match &self.loaded_song {
Some(song) => &song.name,
None => "No song loaded...",
})
.size(32.0),
);
ui.label(
egui::RichText::new(match &self.loaded_song {
Some(song) => song.id.to_string(),
None => "No song loaded...".into(),
})
.size(20.0),
);
if let Some(song) = &self.loaded_song {
ui.add(self.editor.time_signature_widget());
ui.add(self.editor.beat_rate_widget());
ui.add(self.editor.lines_widget(Color::Green));
ui.add(self.editor.lines_widget(Color::Orange));
ui.add(self.editor.lines_widget(Color::Yellow));
ui.add(self.editor.waveform_widget(song));
}
});
});
}
fn handle_message(&mut self, message: Message) {
match message {
Message::LevelSelected(idx) => self.selected_level = Some(idx),
Message::CloseError => self.error = None,
Message::LoadLevel => {
// Load song & GdlData
let level = self
.selected_level
.and_then(|idx| self.level_list.get(idx))
.unwrap() // will not panic. selected_level range is same as level_list...
.clone(); // ...length - 1; message will not be sent if selected_level is none
todo!();
}
}
}
fn handle_messages(&mut self) {
for message in mem::take(&mut self.msg_queue) {
println!("{message:?}");
self.handle_message(message);
}
}
}
impl eframe::App for PipeDash {
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
ctx.set_pixels_per_point(2f32);
if let Some(boxed_err) = &self.error {
egui::CentralPanel::default().show(ctx, |ui| {
ui.label(boxed_err.to_string());
if ui.button("Close").clicked() {
self.msg_queue.push_back(Message::CloseError);
}
});
} else {
self.side_panel(ctx, frame);
self.center_panel(ctx, frame);
}
self.handle_messages();
}
}
fn main() {
let app: PipeDash;
let opts = eframe::NativeOptions::default();
eframe::run_native("PipeDash", opts, Box::new(|cc| Box::new(PipeDash::new(cc))));
}

54
Cargo.lock generated

@ -690,6 +690,15 @@ dependencies = [
"syn",
]
[[package]]
name = "directories"
version = "4.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f51c5d4ddabd36886dd3e1438cb358cdcb0d7c499cb99cb4ac2e38e18b5cb210"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs"
version = "4.0.0"
@ -826,6 +835,12 @@ dependencies = [
"web-sys",
]
[[package]]
name = "either"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91"
[[package]]
name = "emath"
version = "0.20.0"
@ -1350,6 +1365,15 @@ version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30e22bd8629359895450b59ea7a776c850561b96a3b1d31321c1949d9e6c9146"
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.5"
@ -1813,6 +1837,15 @@ dependencies = [
"syn",
]
[[package]]
name = "num_threads"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44"
dependencies = [
"libc",
]
[[package]]
name = "objc"
version = "0.2.7"
@ -2028,14 +2061,18 @@ version = "0.1.0"
dependencies = [
"base64 0.21.0",
"chrono",
"directories",
"eframe",
"flate2",
"gd_plist",
"home",
"itertools",
"log",
"md5",
"ordered-float",
"reqwest",
"rodio",
"simplelog",
"thiserror",
"urlencoding",
]
@ -2412,6 +2449,17 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3"
[[package]]
name = "simplelog"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48dfff04aade74dd495b007c831cd6f4e0cee19c344dd9dc0884c0289b70a786"
dependencies = [
"log",
"termcolor",
"time 0.3.17",
]
[[package]]
name = "slab"
version = "0.4.7"
@ -2574,9 +2622,9 @@ dependencies = [
[[package]]
name = "termcolor"
version = "1.2.0"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6"
checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755"
dependencies = [
"winapi-util",
]
@ -2619,6 +2667,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a561bf4617eebd33bca6434b988f39ed798e527f51a1e797d0ee4f61c0a38376"
dependencies = [
"itoa",
"libc",
"num_threads",
"serde",
"time-core",
"time-macros",

@ -18,3 +18,7 @@ thiserror = "1.0.38"
reqwest = {version = "0.11.14", features = [ "blocking" ]}
urlencoding = "2.1.2"
md5 = "0.7.0"
itertools = "0.10.5"
directories = "4.0.1"
simplelog = "0.12.0"
log = "0.4.17"

@ -1,8 +1,11 @@
use crate::music::Lines;
use base64::engine::{general_purpose::URL_SAFE, Engine};
use chrono::Duration;
use eframe::egui::TextFormat;
use eframe::epaint::text::LayoutJob;
use flate2::read::GzDecoder;
use gd_plist::Value;
use itertools::Itertools;
use reqwest::blocking as req;
use std::fs::File;
use std::io::{Cursor, Read};
@ -10,11 +13,6 @@ use std::num::ParseIntError;
use std::path::PathBuf;
use thiserror::Error;
struct User {
name: String,
id: Option<u64>,
}
pub enum Song {
Official { id: i32 /*k8*/ },
Newgrounds { id: i32 /*k45*/ },
@ -35,8 +33,16 @@ pub struct OuterLevel {
revision: Option<i64>, // k46
}
#[derive(Debug)]
pub struct InnerLevel(String);
#[derive(Debug, Default)]
pub struct RawLinesTriplet {
orange: Lines<Duration>, // 0.8
yellow: Lines<Duration>, // 0.9
green: Lines<Duration>, // 1.0
}
#[derive(Debug, Error)]
pub enum SongRequestError {
#[error("Request failed")]
@ -48,16 +54,50 @@ pub enum SongRequestError {
}
impl InnerLevel {
pub fn try_from_encoded_ils(ils: &str) -> Option<Self> {
let b64 = URL_SAFE.decode(ils).ok()?;
pub fn try_from_encoded_ils(encoded_ils: &str) -> Option<Self> {
let b64 = URL_SAFE.decode(encoded_ils).ok()?;
let mut decoder = GzDecoder::<&[u8]>::new(b64.as_ref());
let mut ils = String::new();
decoder.read_to_string(&mut ils).ok()?;
Some(Self(ils.into()))
Some(Self(ils))
}
pub fn get_property<'a>(&'a self, key: &str) -> Option<&'a str> {
self.0
.split(';')
.next()?
.split(',')
.tuples()
.find(|(k, _)| k == &key)
.map(|(_, v)| v)
}
pub fn hash(self) -> md5::Digest {
md5::compute(self.0)
pub fn get_lines(&self) -> RawLinesTriplet {
let mut lines = RawLinesTriplet::default();
self.0
.split('~')
.tuples()
.for_each(|(timestamp, color_code)| {
let Ok(code_num) = color_code.parse::<f64>().map(|x| (10f64 * x).round() as i32) else {
log::info!("{} could not be parsed", color_code);
return;
};
let Ok(duration) = timestamp.parse::<f64>().map(|t| Duration::nanoseconds((t * 1_000_000_000f64) as i64)) else {
log::info!("{} could not be parsed", timestamp);
return;
};
match code_num {
8 => {lines.orange.insert(duration);},
9 => {lines.yellow.insert(duration);},
10 => {lines.green.insert(duration);},
_ => {log::info!("{} at timestamp {} was invalid", code_num, timestamp)}
};
});
lines
}
pub fn hash(&self) -> md5::Digest {
md5::compute(self.0.clone())
}
}
@ -112,7 +152,11 @@ impl SongResponse {
self.0[8].as_deref().and_then(|s| s.parse().ok())
}
pub fn download_link(&self) -> Option<String> {
self.0[9].as_deref().and_then(|url| urlencoding::decode(url).ok().map(std::borrow::Cow::into_owned))
self.0[9].as_deref().and_then(|url| {
urlencoding::decode(url)
.ok()
.map(std::borrow::Cow::into_owned)
})
}
}
@ -148,10 +192,12 @@ impl OuterLevel {
.as_dictionary()
.unwrap()
.iter()
.find(|(key, val)| key.as_str() != "_isArr" && {
let props = val.as_dictionary().unwrap();
props.get("k2").unwrap().as_string().unwrap() == self.name
&& props.get("k46").and_then(|rev| rev.as_signed_integer()) == self.revision
.find(|(key, val)| {
key.as_str() != "_isArr" && {
let props = val.as_dictionary().unwrap();
props.get("k2").unwrap().as_string().unwrap() == self.name
&& props.get("k46").and_then(|rev| rev.as_signed_integer()) == self.revision
}
})
.unwrap()
.1
@ -177,8 +223,7 @@ impl OuterLevel {
},
);
job
}
else {
} else {
let mut job = LayoutJob::default();
job.append(&self.name, 0f32, TextFormat::default());
job
@ -279,7 +324,7 @@ fn get_local_level_plist() -> Value {
let mut decoder = GzDecoder::<&[u8]>::new(data_post_b64.as_ref());
let mut plist = String::new();
if decoder.read_to_string(&mut plist).is_err() {
println!("Warning: Game save likely corrupted (gzip decode failed)");
log::warn!("Game save likely corrupted (gzip decode failed)");
}
Value::from_reader(Cursor::new(plist)).unwrap()
}

@ -9,11 +9,15 @@ use eframe::egui;
use reqwest::blocking as req;
use std::boxed::Box;
use std::collections::VecDeque;
use std::error::Error;
use std::fs::File;
use std::io::Write;
use thiserror::Error;
use std::error::Error;
use std::mem;
use thiserror::Error;
fn project_dirs() -> directories::ProjectDirs {
directories::ProjectDirs::from("xyz", "interestingzinc", "pipedash").expect("Home dir missing?")
}
struct PipeDash {
msg_queue: VecDeque<Message>,
@ -22,7 +26,7 @@ struct PipeDash {
loaded_song: Option<Song>,
loaded_level_checksum: Option<(gd::OuterLevel, md5::Digest)>,
editor: Editor,
error: Option<Box<dyn Error>>
error: Option<Box<dyn Error>>,
}
#[derive(Debug, PartialEq, Eq)]
@ -144,7 +148,7 @@ impl Song {
};
let decoder = rodio::Decoder::new_mp3(file)?;
Ok(Self { name, id, decoder })
} else {
Err(SongError::NotNewgrounds)
@ -152,7 +156,6 @@ impl Song {
}
}
impl Editor {
pub fn beat_rate_widget(&mut self) -> BeatRateWidget {
BeatRateWidget {
@ -283,7 +286,13 @@ impl PipeDash {
.default_width(100f32)
.show(ctx, |ui| {
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
if ui.add_enabled(self.selected_level.is_some(), egui::Button::new("Load Level")).clicked() {
if ui
.add_enabled(
self.selected_level.is_some(),
egui::Button::new("Load Level"),
)
.clicked()
{
self.msg_queue.push_back(Message::LoadLevel);
}
egui::ScrollArea::vertical().show(ui, |ui| {
@ -341,20 +350,20 @@ impl PipeDash {
Message::CloseError => self.error = None,
Message::LoadLevel => {
// Load song & GdlData
let level = self.selected_level
let level = self
.selected_level
.and_then(|idx| self.level_list.get(idx))
.unwrap() // will not panic. selected_level range is same as level_list...
.clone(); // ...length - 1; message will not be sent if selected_level is none
.unwrap() // will not panic. selected_level range is same as level_list...
.clone(); // ...length - 1; message will not be sent if selected_level is none
todo!();
},
}
}
}
fn handle_messages(&mut self) {
for message in mem::take(&mut self.msg_queue) {
println!("{message:?}");
log::info!("{message:?}");
self.handle_message(message);
}
}
@ -381,6 +390,13 @@ impl eframe::App for PipeDash {
}
fn main() {
simplelog::WriteLogger::init(
simplelog::LevelFilter::Info,
simplelog::Config::default(),
File::create(project_dirs().data_local_dir().join("run.log"))
.expect("file creation failed?"),
)
.map_err(|e| println!("Logging uninitialized"));
let app: PipeDash;
let opts = eframe::NativeOptions::default();
eframe::run_native("PipeDash", opts, Box::new(|cc| Box::new(PipeDash::new(cc))));

@ -25,9 +25,12 @@ pub struct StaticTimeSignature {
denominator: u32,
}
#[derive(Default)]
pub struct Lines {
positions: BTreeSet<BeatPosition>,
#[derive(Debug)]
pub struct Lines<T = BeatPosition>
where
T: Ord,
{
positions: BTreeSet<T>,
}
impl StaticBeatRate {
@ -133,22 +136,40 @@ impl TimeSignature {
}
}
impl Lines {
impl<T> Default for Lines<T>
where
T: Ord,
{
fn default() -> Self {
Self {
positions: BTreeSet::new(),
}
}
}
impl<T> Lines<T>
where
T: Ord,
{
pub fn new() -> Self {
Self::default()
Default::default()
}
pub fn insert(&mut self, pos: BeatPosition) -> bool {
pub fn insert(&mut self, pos: T) -> bool {
self.positions.insert(pos)
}
pub fn remove(&mut self, pos: BeatPosition) -> bool {
pub fn remove(&mut self, pos: T) -> bool {
self.positions.remove(&pos)
}
pub fn get_positions(&mut self) -> &BTreeSet<BeatPosition> {
pub fn get_positions(&self) -> &BTreeSet<T> {
&self.positions
}
pub fn empty(&self) -> bool {
self.positions.is_empty()
}
}
#[cfg(test)]

Loading…
Cancel
Save