diff --git a/Cargo.lock b/Cargo.lock index aad34a8..c2646dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1305,6 +1305,7 @@ dependencies = [ "ical", "image", "lazy-regex", + "lazy_static", "native-tls", "serde", "toml", diff --git a/Cargo.toml b/Cargo.toml index 1276843..b995acc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,4 @@ lazy-regex = "2.4.1" directories-next = "2.0.0" toml = "0.5.11" serde = { version = "1.0.152", features = ["derive"]} +lazy_static = "1.4.0" \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 62274a9..71e1945 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,45 +1,17 @@ -use std::ops::Add; +use std::{ops::Add, rc::Rc, cell::{Cell, RefCell}, f64::consts::E}; -use eframe::{egui, CreationContext, epaint::text::TextWrapping}; -use chrono::{Datelike, Timelike, Utc, NaiveDate, Weekday, IsoWeek, Duration, DateTime, NaiveDateTime, Days, Local}; -use egui::{Color32, ColorImage, TextureHandle, TextureOptions, Rect, text::LayoutJob, Visuals, Stroke}; -use crate::{timetable::{Timetable, get_timetable, Event, EventCategory}, config::ConfigStorage}; +use eframe::{egui, CreationContext}; +use chrono::{Datelike, NaiveDate, Weekday, IsoWeek, Duration, Days, Local}; +use egui::{ColorImage, TextureOptions}; +use crate::{timetable::{Timetable, Event, TimetableGetter, GetTimetableError}, config::{ConfigStore, Config}, events_table::EventsTable}; -struct EventsTableStyle { - highlight_color: Color32, - bg_fill: Color32, - fg_stroke: egui::Stroke -} +use crate::utils::load_image_from_memory; -/* -let highlight_color = style.visuals.selection.bg_fill; -let bg_fill = style.visuals.widgets.noninteractive.bg_fill; //Color32::from_rgb(18, 18, 18); -let fg_stroke = style.visuals.widgets.active.fg_stroke; +const MAX_FUTURE_WEEKS: u64 = 4 * 12; -let light_bg_col = highlight_color; // shift_color(bg_col, 1.5); // Color32::from_rgb(30, 30, 30); -let dark_bg_col = shift_color(bg_fill, 0.5); // style.visuals.widgets.noninteractive.bg_stroke.color; Color32::from_rgb(11, 8, 8); -//let fg_col = Color32::from_rgb(252, 232, 195); -let now_line_col = shift_color(highlight_color, 1.5); // fg_stroke.color; // shift_color(highlight_color, 1.5); // Color32::from_rgb(44, 120, 191); -*/ -impl EventsTableStyle { - fn from_visuals(visuals: &Visuals) -> Self { - let bg_fill = visuals.widgets.noninteractive.bg_fill; - Self { - highlight_color: visuals.selection.bg_fill, - bg_fill, - fg_stroke: visuals.widgets.active.fg_stroke, - } - } - - #[inline] - fn dark_bg_fill(&self) -> Color32 { - shift_color(self.bg_fill, 0.5) - } - - #[inline] - fn now_line_fill(&self) -> Color32 { - shift_color(self.highlight_color, 1.5) - } +lazy_static! { + pub static ref BREAK_IMAGE: ColorImage = load_image_from_memory(include_bytes!("../assets/break-area.png")) + .expect("Failed to decode break area texture") as ColorImage; } struct AppAssets { @@ -47,131 +19,19 @@ struct AppAssets { } pub struct MainApp { - pub vidko: Option, - pub timetable: Option, shown_week: IsoWeek, shown_events: Vec, + timetable_getter: Box, + pub timetable: Option, + + config_store: Box, + config: Option, + assets: Option, - storage: ConfigStorage -} + vidko_textfield: String, -fn count_minutes(time: &str) -> u32 { - let (time_h, time_m) = time.split_once(":").unwrap(); - return 60*time_h.parse::().unwrap() + time_m.parse::().unwrap(); -} - -fn get_category_bg(category: EventCategory) -> Color32 { - match category { - EventCategory::Default => Color32::GRAY, - EventCategory::Yellow => Color32::from_rgb(251, 184, 41), - } -} - -fn is_bright_color(color: Color32) -> bool { - return color.r() + color.g() + color.b() > 128 * 3 -} - -fn load_image_from_memory(image_data: &[u8]) -> Result { - let image = image::load_from_memory(image_data)?; - let size = [image.width() as _, image.height() as _]; - let image_buffer = image.to_rgba8(); - let pixels = image_buffer.as_flat_samples(); - Ok(ColorImage::from_rgba_unmultiplied( - size, - pixels.as_slice(), - )) -} - -fn draw_repeating_texture(ui: &mut egui::Ui, texture: &TextureHandle, rect: Rect, tint: Color32) { - use egui::*; - let painter = ui.painter(); - - let texture_size = texture.size_vec2(); - let horizontal_count = rect.width()/texture_size[0]; - let vertical_count = rect.height()/texture_size[1]; - - let draw_tile = |ix: u32, iy: u32, scale: Vec2| { - painter.image( - texture.id(), - Rect::from_min_size( - rect.left_top() + texture_size * vec2(ix as f32, iy as f32), - texture_size * scale - ), - Rect::from_min_max(pos2(0.0, 0.0), scale.to_pos2()), - tint - ); - }; - - { - let scale = vec2(1.0, 1.0); - for ix in 0..horizontal_count.floor() as u32 { - for iy in 0..vertical_count.floor() as u32 { - draw_tile(ix, iy, scale); - } - } - } - - // right edge - { - let scale = vec2(horizontal_count % 1.0, 1.0); - let ix = horizontal_count.floor() as u32; - for iy in 0..vertical_count.floor() as u32 { - draw_tile(ix, iy, scale); - } - } - - // bottom edge - { - let scale = vec2(1.0, vertical_count % 1.0); - let iy = vertical_count.floor() as u32; - for ix in 0..horizontal_count.floor() as u32 { - draw_tile(ix, iy, scale); - } - } - - // left bottom corner - { - let scale = vec2(horizontal_count % 1.0, vertical_count % 1.0); - draw_tile(horizontal_count.floor() as u32, vertical_count.floor() as u32, scale); - } -} - -fn show_event_card(ui: &mut egui::Ui, event: &Event, mut rect: Rect) { - use egui::*; - let margin = 6.0; - let border_size = 4.0; - let text_size = egui::TextStyle::Body.resolve(ui.style()).size; - let text_color = Color32::BLACK; - - rect.set_width(rect.width().max(text_size*6.0)); - - let painter = ui.painter(); - let color = get_category_bg(event.category); - let rounding = Rounding::from(5.0); - let border_color = color.linear_multiply(1.25); - painter.rect_filled(rect, rounding, color); - painter.rect_stroke(rect.shrink(border_size/2.0), rounding, (border_size, border_color)); - - ui.allocate_ui_at_rect(rect.shrink(margin), |ui| { - let font = FontId::proportional(text_size * 0.8); - let summary_format = TextFormat { - color: text_color, - font_id: font.clone(), - ..TextFormat::default() - }; - let module_name = event.module_name.as_ref().unwrap_or(&event.summary); - let mut job = LayoutJob::single_section(module_name.to_string(), summary_format); - job.wrap = TextWrapping { - max_rows: 2, - ..Default::default() - }; - ui.label(job); - - ui.add_space(text_size*0.2); - let time_label = format!("{} - {}", event.start_time.format("%H:%M"), event.end_time.format("%H:%M")); - ui.label(RichText::new(time_label).color(text_color).font(font)); - }); + screen: Option>> } #[inline] @@ -180,15 +40,6 @@ fn is_weekend(time: NaiveDate) -> bool { return day == Weekday::Sat || day == Weekday::Sun; } -#[inline] -fn shift_color(color: Color32, amount: f32) -> Color32 { - return Color32::from_rgb( - (color.r() as f32 * amount) as u8, - (color.g() as f32 * amount) as u8, - (color.b() as f32 * amount) as u8 - ); -} - fn get_current_week() -> IsoWeek { let now = Local::now(); if is_weekend(now.date_naive()) { @@ -198,212 +49,169 @@ fn get_current_week() -> IsoWeek { } } -#[inline] -fn show_events_table_header( - ui: &mut egui::Ui, - style: &EventsTableStyle, - rect: Rect, - week: IsoWeek -) { - use egui::*; - let painter = ui.painter(); - painter.rect_filled(rect, Rounding::none(), style.dark_bg_fill()); +fn get_future_week(week_offset: u64) -> IsoWeek { + let now_week = get_current_week(); + let year = now_week.year(); + let week = now_week.week(); + let week_date = NaiveDate::from_isoywd_opt(now_week.year(), now_week.week(), Weekday::Mon).expect("Invalid week or year given"); + week_date.checked_add_days(Days::new(7 * week_offset)).unwrap().iso_week() +} - let column_width = rect.width()/5.0; - let header_size = rect.height(); - let text_size = egui::TextStyle::Body.resolve(ui.style()).size; +trait Screen { + fn show(&mut self, app: &mut MainApp, ctx: &egui::Context); +} - // Draw day names - for (i, name) in ["Pir", "Ant", "Tre", "Ket", "Pen"].iter().enumerate() { - let offset = column_width * (i as f32 + 0.5); +#[derive(Default)] +struct MainScreen {} +impl Screen for MainScreen { + fn show(&mut self, app: &mut MainApp, ctx: &egui::Context) { + use egui::*; + egui::CentralPanel::default() + .frame(Frame::none()) + .show(ctx, |ui| { + if ctx.input().key_pressed(Key::D) && app.shown_week < get_future_week(MAX_FUTURE_WEEKS) { + app.shift_shown_week(1); + } + if ctx.input().key_pressed(Key::A) && get_current_week() < app.shown_week { + app.shift_shown_week(-1); + } + if ctx.input().key_pressed(Key::S) { + app.set_shown_week(get_current_week()); + } + if ctx.input().key_pressed(Key::F2) { + if ctx.style().visuals.dark_mode { + ctx.set_visuals(egui::Visuals::light()); + } else { + ctx.set_visuals(egui::Visuals::dark()); + } + } - painter.text( - rect.left_top() + vec2(offset, header_size/2.5), - Align2::CENTER_CENTER, - name, - FontId::monospace(text_size*1.2), - style.fg_stroke.color - ); - } - - // Draw dates - let year = week.year(); - let week = week.week(); - let mut week_date = NaiveDate::from_isoywd_opt(year, week, Weekday::Mon).expect("Invalid week or year given"); - for i in 1..=5 { - let offset = column_width * i as f32; - - painter.text( - rect.left_top() + vec2(offset-3.0, header_size-3.0), - Align2::RIGHT_BOTTOM, - week_date.format("%m-%d").to_string(), - FontId::proportional(text_size*0.85), - style.fg_stroke.color - ); - week_date = week_date.add(Duration::days(1)); + let mut events_table = EventsTable::new(&app.shown_events); + events_table.week = Some(app.shown_week); + events_table.now = Some(Local::now().naive_local()); + events_table.break_texture = Some(app.assets.as_ref().unwrap().break_texture.clone()); + ui.add(events_table); + }); } } -#[inline] -fn show_events_table_body( - ui: &mut egui::Ui, - style: &EventsTableStyle, - assets: &AppAssets, - rect: Rect, - week: IsoWeek, - now: NaiveDateTime, - events: &[Event] -) { - use egui::*; - - let painter = ui.painter(); - let column_width = rect.width()/5.0; - let column_gap = 3.0; - - let timestamps = ["9:00", "10:30", "11:00", "12:30", "13:30", "15:00", "15:30", "17:00"]; - let timestamps_mins = timestamps.map(count_minutes); - let total_minutes = timestamps_mins.last().unwrap() - timestamps_mins.first().unwrap(); - let minute_to_pixel_scale = rect.height()/total_minutes as f32; - - // draw bg - painter.rect_filled( - rect, - Rounding::none(), - style.bg_fill - ); - - // Highlight current day column - if now.iso_week() == week && !is_weekend(now.date()) { - let days_from_monday = now.weekday().num_days_from_monday() as f32; - let rect = Rect::from_min_max( - rect.left_top() + vec2(column_width * days_from_monday, 0.0), - rect.left_bottom() + vec2(column_width * (1.0+days_from_monday), 0.0) - ); - painter.rect_filled(rect, Rounding::none(), style.highlight_color); - } - - // Draw gaps between columns - for i in 1..5 { - let offset = column_width * i as f32; - painter.line_segment([ - rect.left_top() + vec2(offset, 0.0), - rect.left_bottom() + vec2(offset, 0.0) - ], (column_gap, style.dark_bg_fill())) - } - - // Mark break times - for i in (1..timestamps_mins.len()-1).step_by(2) { - let from = (timestamps_mins[i] - timestamps_mins[0]) as f32 * minute_to_pixel_scale; - let to = (timestamps_mins[i+1] - timestamps_mins[0]) as f32 * minute_to_pixel_scale; - draw_repeating_texture( - ui, - &assets.break_texture, - Rect::from_min_size( - rect.left_top() + vec2(0.0, from), - vec2(rect.width(), to - from) - ), - style.dark_bg_fill() - ); - } - - // Draw event cards - for event in events { - let day = event.date.weekday().num_days_from_monday() as usize; - let duration = (event.end_time - event.start_time).num_minutes() as f32; - let start_time = event.start_time.hour()*60 + event.start_time.minute() - timestamps_mins[0]; - let event_rect = Rect::from_min_size( - rect.left_top() + vec2(column_width*day as f32, start_time as f32*minute_to_pixel_scale), - vec2(column_width, duration*minute_to_pixel_scale) - ).shrink2(vec2(10.0, 0.0)); - show_event_card(ui, event, event_rect); - } - - // now line - let painter = ui.painter(); - let current_time = now.minute() + now.hour() * 60 - timestamps_mins[0]; - if current_time > 0 && current_time < *timestamps_mins.last().unwrap_or(&0) && !is_weekend(now.date()) { - let offset = current_time as f32 * minute_to_pixel_scale; - let points = [ - rect.left_top() + vec2(0.0, offset), - rect.right_top() + vec2(0.0, offset) - ]; - let thickness = 2.0; - let border_size = 2.0; - painter.line_segment(points, (thickness + 2.0 * border_size, style.dark_bg_fill())); - painter.line_segment(points, (thickness, style.highlight_color)); - } +#[derive(Default)] +struct VidkoScreen { + vidko_textfield: String, + get_error: Option } +impl Screen for VidkoScreen { + fn show(&mut self, app: &mut MainApp, ctx: &egui::Context) { + use egui::*; -fn show_events_table( - assets: &AppAssets, - ui: &mut egui::Ui, - rect: Rect, - shown_week: IsoWeek, - now: NaiveDateTime, - events: &[Event] -) { - use egui::*; - - let header_size = 50.0; - - //let now = now.checked_add_days(Days::new(1)).unwrap(); - let style = EventsTableStyle::from_visuals(&ui.style().visuals); - - show_events_table_header( - ui, &style, - Rect::from_min_size( - rect.left_top(), - vec2(rect.width(), header_size) - ), - shown_week - ); - - show_events_table_body( - ui, &style, assets, - Rect::from_min_max( - rect.left_top() + vec2(0.0, header_size), - rect.right_bottom() - ), - shown_week, - now, - events - ); - + egui::CentralPanel::default() + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.label("Įveskite savo vidko kodą"); + ui.horizontal(|ui| { + ui.label("Vidko: "); + ui.text_edit_singleline(&mut self.vidko_textfield); + }); + if ui.button("Įvesti").clicked() { + match app.timetable_getter.get(&self.vidko_textfield) { + Ok(timetable) => { + if app.config.is_none() { + app.config = Some(Config::default()); + } + app.config.as_mut().unwrap().vidko = Some(self.vidko_textfield.clone()); + app.set_timetable(timetable); + app.switch_to_main(); + }, + Err(e) => { + self.get_error = Some(e); + }, + } + } + if self.get_error.is_some() { + ui.colored_label(Color32::RED, "Netinkamas kodas"); + } + }); + }); + } } impl MainApp { - pub fn new(storage: ConfigStorage) -> MainApp { - MainApp { - vidko: None, + pub fn new(config_store: Box, timetable_getter: Box) -> Self { + Self { timetable: None, shown_week: get_current_week(), shown_events: vec![], assets: None, - storage + config_store, + config: None, + vidko_textfield: String::new(), + timetable_getter, + screen: None } } - pub fn on_creation(&mut self, cc: &CreationContext) { - self.storage.attempt_load(); - self.vidko = self.storage.config.vidko_code.clone(); + pub fn init(&mut self, cc: &CreationContext) { - let break_image = load_image_from_memory(include_bytes!("../assets/break-area.png")).expect("Failed to decode break area texture"); - let texture_handle = cc.egui_ctx.load_texture("break-area", break_image, TextureOptions::LINEAR); + let texture_handle = cc.egui_ctx.load_texture("break-area", BREAK_IMAGE.clone(), TextureOptions::LINEAR); self.assets = Some(AppAssets { break_texture: texture_handle }); + + self.config = match self.config_store.load() { + Ok(config) => Some(config), + Err(_) => None, + }; + + if self.vidko().is_none() { + self.switch_to_vidko(); + } else { + if self.refresh_timetable().is_err() { + self.switch_to_vidko(); + } else { + self.switch_to_main(); + } + } } - pub fn refresh_timetable(&mut self) { - if self.vidko.is_none() { - self.shown_events = vec![]; - self.timetable = None; - return; + fn switch_to_main(&mut self) { + self.screen = Some(Rc::new(RefCell::new(MainScreen::default()))) + } + + fn switch_to_vidko(&mut self) { + self.screen = Some(Rc::new(RefCell::new(VidkoScreen::default()))); + } + + #[inline] + pub fn vidko(&self) -> Option<&str> { + if let Some(config) = &self.config { + return config.vidko.as_deref(); + } + None + } + + pub fn refresh_timetable(&mut self) -> Result<(), GetTimetableError> { + let vidko; + { + if self.vidko().is_none() { + self.shown_events = vec![]; + self.timetable = None; + return Err(GetTimetableError::NotFound); + } + vidko = self.vidko().unwrap(); } - let timetable = get_timetable(self.vidko.as_ref().unwrap()); - if timetable.is_err() { return; } - let timetable = timetable.unwrap(); + let timetable = match self.timetable_getter.get(vidko) { + Ok(timetable) => timetable, + Err(e) => return Err(e), + }; + + self.shown_events = timetable.by_week(self.shown_week); + self.timetable = Some(timetable); + + Ok(()) + } + + pub fn set_timetable(&mut self, timetable: Timetable) { self.shown_events = timetable.by_week(self.shown_week); self.timetable = Some(timetable); } @@ -435,42 +243,18 @@ impl MainApp { impl eframe::App for MainApp { fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { - self.storage.attempt_save(); + if let Some(config) = &self.config { + self.config_store.save(config).unwrap(); + } } - fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { - use egui::*; + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - //ctx.set_visuals(egui::Visuals::light()); - egui::CentralPanel::default() - .frame(Frame::none()) - .show(ctx, |ui| { - if ctx.input().key_pressed(Key::D) { - self.shift_shown_week(1); - } - if ctx.input().key_pressed(Key::A) { - self.shift_shown_week(-1); - } - if ctx.input().key_pressed(Key::S) { - self.set_shown_week(get_current_week()); - } - if ctx.input().key_pressed(Key::F2) { - if ctx.style().visuals.dark_mode { - ctx.set_visuals(egui::Visuals::light()); - } else { - ctx.set_visuals(egui::Visuals::dark()); - } - } - - let rect = ui.allocate_rect(ui.min_rect(), Sense::hover()).rect; - show_events_table( - self.assets.as_ref().unwrap(), - ui, - rect, - self.shown_week, - Local::now().naive_local(), - &self.shown_events - ); - }); + if let Some(screen) = self.screen.clone() { + screen.borrow_mut().show(self, ctx) + } else { + // TODO: show error screen + todo!() + } } } diff --git a/src/config.rs b/src/config.rs index 1390271..cb0764c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,64 +1,115 @@ -use std::{path::{Path, PathBuf}, fs}; +use std::{path::{Path, PathBuf}, fs, io, error::Error, fmt}; use directories_next::ProjectDirs; -use eframe::Storage; -use egui::util::cache::CacheStorage; use serde::{Deserialize, Serialize}; - -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Clone)] pub struct Config { - pub vidko_code: Option + pub vidko: Option } - impl Default for Config { fn default() -> Self { - Self { - vidko_code: Some("E0000".into()) + Self { vidko: None } + } +} + +#[derive(Debug)] +pub enum LoadConfigError { + NotFound, + FileError(io::Error), + TomlError(toml::de::Error) +} +impl Error for LoadConfigError {} +impl fmt::Display for LoadConfigError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + LoadConfigError::FileError(e) => write!(f, "File error: {}", e), + LoadConfigError::TomlError(e) => write!(f, "Toml error: {}", e), + LoadConfigError::NotFound => write!(f, "Not found"), } } } -pub struct ConfigStorage { - pub config: Config, - - config_file: Option +#[derive(Debug)] +pub enum SaveConfigError { + FileError(io::Error), + TomlError(toml::ser::Error) +} +impl Error for SaveConfigError {} +impl fmt::Display for SaveConfigError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + SaveConfigError::FileError(e) => write!(f, "File error: {}", e), + SaveConfigError::TomlError(e) => write!(f, "Toml error: {}", e), + } + } } -impl Default for ConfigStorage { +pub trait ConfigStore { + fn load(&self) -> Result; + fn save(&self, config: &Config) -> Result<(), SaveConfigError>; +} + + + +pub struct TomlConfigStore { + filename: PathBuf +} +impl TomlConfigStore { + fn new(filename: &Path) -> Self { + Self { + filename: filename.into() + } + } +} +impl Default for TomlConfigStore { fn default() -> Self { - let project_dirs = ProjectDirs::from("", "", "KTU Timetable").expect("Failed to determine project directories"); + let project_dirs = ProjectDirs::from("", "", "KTU Timetable").expect("Failed to determine home directory"); let config_dir = project_dirs.config_dir(); - Self { - config: Config::default(), - config_file: Some(config_dir.join("config.toml")) + Self::new(&config_dir.join("config.toml")) + } +} +impl ConfigStore for TomlConfigStore { + fn load(&self) -> Result { + let config_str = fs::read_to_string(&self.filename) + .map_err(|e| LoadConfigError::FileError(e))?; + + toml::from_str(&config_str) + .map_err(|e| LoadConfigError::TomlError(e)) + } + + fn save(&self, config: &Config) -> Result<(), SaveConfigError> { + let directory = Path::parent(&self.filename).unwrap(); + if !Path::is_dir(directory) { + fs::create_dir_all(directory) + .map_err(|e| SaveConfigError::FileError(e))?; } + + let config_str = toml::to_string_pretty(config) + .map_err(|e| SaveConfigError::TomlError(e))?; + + fs::write(&self.filename, config_str) + .map_err(|e| SaveConfigError::FileError(e))?; + + Ok(()) } } -impl ConfigStorage { - pub fn memory() -> Self { - let mut config = Self::default(); - config.config_file = None; - config - } - pub fn attempt_load(&mut self) { - if self.config_file.is_none() { return; } - let config_file = self.config_file.as_ref().unwrap(); - let config_str = fs::read_to_string(config_file); - if let Err(_) = config_str { - fs::write(config_file, toml::to_string_pretty(&Config::default()).unwrap()).unwrap(); - } - let config_str = config_str.unwrap(); - self.config = toml::from_str(&config_str).unwrap_or_default(); - } - - pub fn attempt_save(&self) { - if self.config_file.is_none() { return; } - let config_file = self.config_file.as_ref().unwrap(); - let config_str = toml::to_string_pretty(&self.config).unwrap(); - fs::write(config_file, config_str).unwrap(); +pub struct MemoryConfigStore { + config: Option +} +impl MemoryConfigStore { + pub fn new(config: Config) -> Self { + Self { config: Some(config) } } } +impl ConfigStore for MemoryConfigStore { + fn load(&self) -> Result { + self.config.clone().ok_or(LoadConfigError::NotFound) + } + fn save(&self, _config: &Config) -> Result<(), SaveConfigError> { + Ok(()) + } +} \ No newline at end of file diff --git a/src/events_table.rs b/src/events_table.rs new file mode 100644 index 0000000..de9c6fd --- /dev/null +++ b/src/events_table.rs @@ -0,0 +1,338 @@ +use std::ops::Add; + +use eframe::{egui, epaint::text::TextWrapping}; +use chrono::{Datelike, Timelike, NaiveDate, Weekday, IsoWeek, Duration, NaiveDateTime, Local}; +use egui::{Color32, TextureHandle, Rect, text::LayoutJob, Visuals, Stroke, Widget}; +use crate::timetable::{Event, EventCategory}; + +pub struct EventsTable<'a> { + pub break_texture: Option, + + pub week: Option, + pub now: Option, + pub events: &'a [Event] +} + +fn count_minutes(time: &str) -> u32 { + let (time_h, time_m) = time.split_once(":").unwrap(); + return 60*time_h.parse::().unwrap() + time_m.parse::().unwrap(); +} + +fn get_category_bg(category: EventCategory) -> Color32 { + match category { + EventCategory::Default => Color32::GRAY, + EventCategory::Yellow => Color32::from_rgb(251, 184, 41), + } +} + +fn draw_repeating_texture(ui: &mut egui::Ui, texture: Option<&TextureHandle>, rect: Rect, tint: Color32) { + use egui::*; + let painter = ui.painter(); + + if texture.is_none() { + painter.rect(rect, Rounding::none(), tint, (1.0, tint)); + return; + } + + let texture = texture.unwrap(); + let texture_size = texture.size_vec2(); + let horizontal_count = rect.width()/texture_size[0]; + let vertical_count = rect.height()/texture_size[1]; + + let draw_tile = |ix: u32, iy: u32, scale: Vec2| { + painter.image( + texture.id(), + Rect::from_min_size( + rect.left_top() + texture_size * vec2(ix as f32, iy as f32), + texture_size * scale + ), + Rect::from_min_max(pos2(0.0, 0.0), scale.to_pos2()), + tint + ); + }; + + { + let scale = vec2(1.0, 1.0); + for ix in 0..horizontal_count.floor() as u32 { + for iy in 0..vertical_count.floor() as u32 { + draw_tile(ix, iy, scale); + } + } + } + + // right edge + { + let scale = vec2(horizontal_count % 1.0, 1.0); + let ix = horizontal_count.floor() as u32; + for iy in 0..vertical_count.floor() as u32 { + draw_tile(ix, iy, scale); + } + } + + // bottom edge + { + let scale = vec2(1.0, vertical_count % 1.0); + let iy = vertical_count.floor() as u32; + for ix in 0..horizontal_count.floor() as u32 { + draw_tile(ix, iy, scale); + } + } + + // left bottom corner + { + let scale = vec2(horizontal_count % 1.0, vertical_count % 1.0); + draw_tile(horizontal_count.floor() as u32, vertical_count.floor() as u32, scale); + } +} + +#[inline] +fn is_weekend(time: NaiveDate) -> bool { + let day = time.weekday(); + return day == Weekday::Sat || day == Weekday::Sun; +} + +#[inline] +fn shift_color(color: Color32, amount: f32) -> Color32 { + return Color32::from_rgb( + (color.r() as f32 * amount) as u8, + (color.g() as f32 * amount) as u8, + (color.b() as f32 * amount) as u8 + ); +} + +const HEADER_SIZE: f32 = 50.0; + +impl<'a> EventsTable<'a> { + pub fn new(events: &'a [Event]) -> Self { + Self { + break_texture: None, + week: None, + now: None, + events + } + } + + #[inline] + fn fg_stroke(&self, visuals: &Visuals) -> Stroke { + visuals.widgets.active.fg_stroke + } + + #[inline] + fn bg_fill(&self, visuals: &Visuals) -> Color32 { + visuals.widgets.noninteractive.bg_fill + } + + #[inline] + fn highlight_color(&self, visuals: &Visuals) -> Color32 { + visuals.selection.bg_fill + } + + #[inline] + fn dark_bg_fill(&self, visuals: &Visuals) -> Color32 { + shift_color(self.bg_fill(visuals), 0.5) + } + + fn show_event(&self, ui: &mut egui::Ui, event: &Event, mut rect: Rect) { + use egui::*; + let margin = 6.0; + let border_size = 4.0; + let text_size = egui::TextStyle::Body.resolve(ui.style()).size; + let text_color = Color32::BLACK; + + rect.set_width(rect.width().max(text_size*6.0)); + + let painter = ui.painter(); + let color = get_category_bg(event.category); + let rounding = Rounding::from(5.0); + let border_color = color.linear_multiply(1.25); + painter.rect_filled(rect, rounding, color); + painter.rect_stroke(rect.shrink(border_size/2.0), rounding, (border_size, border_color)); + + ui.allocate_ui_at_rect(rect.shrink(margin), |ui| { + let font = FontId::proportional(text_size * 0.8); + let summary_format = TextFormat { + color: text_color, + font_id: font.clone(), + ..TextFormat::default() + }; + let module_name = event.module_name.as_ref().unwrap_or(&event.summary); + let mut job = LayoutJob::single_section(module_name.to_string(), summary_format); + job.wrap = TextWrapping { + max_rows: 2, + ..Default::default() + }; + ui.label(job); + + ui.add_space(text_size*0.2); + let time_label = format!("{} - {}", event.start_time.format("%H:%M"), event.end_time.format("%H:%M")); + ui.label(RichText::new(time_label).color(text_color).font(font)); + }); + } + + fn show_header(&self, ui: &mut egui::Ui, rect: Rect, week: IsoWeek) { + use egui::*; + let painter = ui.painter(); + let visuals = ui.visuals(); + painter.rect_filled(rect, Rounding::none(), self.dark_bg_fill(visuals)); + + let column_width = rect.width()/5.0; + let header_size = rect.height(); + let text_size = egui::TextStyle::Body.resolve(ui.style()).size; + let text_color = self.fg_stroke(visuals).color; + + // Draw day names + for (i, name) in ["Pir", "Ant", "Tre", "Ket", "Pen"].iter().enumerate() { + let offset = column_width * (i as f32 + 0.5); + + painter.text( + rect.left_top() + vec2(offset, header_size/2.5), + Align2::CENTER_CENTER, + name, + FontId::monospace(text_size*1.2), + text_color + ); + } + + // Draw dates + let year = week.year(); + let week = week.week(); + let mut week_date = NaiveDate::from_isoywd_opt(year, week, Weekday::Mon).expect("Invalid week or year given"); + for i in 1..=5 { + let offset = column_width * i as f32; + + painter.text( + rect.left_top() + vec2(offset-3.0, header_size-3.0), + Align2::RIGHT_BOTTOM, + week_date.format("%m-%d").to_string(), + FontId::proportional(text_size*0.85), + text_color + ); + week_date = week_date.add(Duration::days(1)); + } + } + + fn show_body( + &self, + ui: &mut egui::Ui, + rect: Rect, + week: IsoWeek, + now: NaiveDateTime + ) { + use egui::*; + + let painter = ui.painter(); + let column_width = rect.width()/5.0; + let column_gap = 3.0; + + let visuals = ui.visuals(); + let bg_fill = self.bg_fill(visuals); + let dark_bg_fill = self.dark_bg_fill(visuals); + let highlight_color = self.highlight_color(visuals); + + let timestamps = ["9:00", "10:30", "11:00", "12:30", "13:30", "15:00", "15:30", "17:00"]; + let timestamps_mins = timestamps.map(count_minutes); + let total_minutes = timestamps_mins.last().unwrap() - timestamps_mins.first().unwrap(); + let minute_to_pixel_scale = rect.height()/total_minutes as f32; + + // draw bg + painter.rect_filled( + rect, + Rounding::none(), + bg_fill + ); + + // Highlight current day column + if now.iso_week() == week && !is_weekend(now.date()) { + let days_from_monday = now.weekday().num_days_from_monday() as f32; + let rect = Rect::from_min_max( + rect.left_top() + vec2(column_width * days_from_monday, 0.0), + rect.left_bottom() + vec2(column_width * (1.0+days_from_monday), 0.0) + ); + painter.rect_filled(rect, Rounding::none(), highlight_color); + } + + // Draw gaps between columns + for i in 1..5 { + let offset = column_width * i as f32; + painter.line_segment([ + rect.left_top() + vec2(offset, 0.0), + rect.left_bottom() + vec2(offset, 0.0) + ], (column_gap, dark_bg_fill)) + } + + // Mark break times + for i in (1..timestamps_mins.len()-1).step_by(2) { + let from = (timestamps_mins[i] - timestamps_mins[0]) as f32 * minute_to_pixel_scale; + let to = (timestamps_mins[i+1] - timestamps_mins[0]) as f32 * minute_to_pixel_scale; + draw_repeating_texture( + ui, + self.break_texture.as_ref(), + Rect::from_min_size( + rect.left_top() + vec2(0.0, from), + vec2(rect.width(), to - from) + ), + dark_bg_fill + ); + } + + // Draw event cards + for event in self.events { + let day = event.date.weekday().num_days_from_monday() as usize; + let duration = (event.end_time - event.start_time).num_minutes() as f32; + let start_time = event.start_time.hour()*60 + event.start_time.minute() - timestamps_mins[0]; + let event_rect = Rect::from_min_size( + rect.left_top() + vec2(column_width*day as f32, start_time as f32*minute_to_pixel_scale), + vec2(column_width, duration*minute_to_pixel_scale) + ).shrink2(vec2(10.0, 0.0)); + self.show_event(ui, event, event_rect); + } + + // now line + let painter = ui.painter(); + let current_time = now.minute() + now.hour() * 60 - timestamps_mins[0]; + if current_time > 0 && current_time < *timestamps_mins.last().unwrap_or(&0) && !is_weekend(now.date()) { + let offset = current_time as f32 * minute_to_pixel_scale; + let points = [ + rect.left_top() + vec2(0.0, offset), + rect.right_top() + vec2(0.0, offset) + ]; + let thickness = 2.0; + let border_size = 2.0; + painter.line_segment(points, (thickness + 2.0 * border_size, dark_bg_fill)); + painter.line_segment(points, (thickness, highlight_color)); + } + } +} + +impl<'a> Widget for EventsTable<'a> { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + use egui::*; + + let response = ui.allocate_rect(ui.min_rect(), Sense::hover()); + let rect = response.rect; + + let now = self.now.unwrap_or(Local::now().naive_local()); + let week = self.week.unwrap_or(now.iso_week()); + + self.show_header( + ui, + Rect::from_min_size( + rect.left_top(), + vec2(rect.width(), HEADER_SIZE) + ), + week + ); + + self.show_body( + ui, + Rect::from_min_max( + rect.left_top() + vec2(0.0, HEADER_SIZE), + rect.right_bottom() + ), + week, + now + ); + + response + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 3430477..63c52af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,19 @@ -#![windows_subsystem = "windows"] +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod timetable; mod app; mod config; +mod events_table; +mod utils; + +#[macro_use] +extern crate lazy_static; use app::MainApp; -use config::ConfigStorage; +use chrono::{Local, NaiveDate, NaiveTime}; +use config::{MemoryConfigStore, Config, TomlConfigStore}; use eframe::egui; +use timetable::{DummyTimetableGetter, Timetable, Event, BlockingTimetableGetter}; // TODO: use lazy_static!() to load assets // TODO: convert events_table to egui widget @@ -14,10 +21,29 @@ use eframe::egui; // TODO: Settings menu // TODO: use "confy" for config loading? // TODO: refactor persistence +// TODO: Setup pipeline fn main() -> Result<(), ureq::Error> { - let mut config_storage = ConfigStorage::default(); - config_storage.config.vidko_code = Some("E1810".into()); + let config_store = TomlConfigStore::default(); + // let config_store = MemoryConfigStore::new(Config { + // vidko: None//Some("E1810".into()) + // }); + + let timetable_getter = BlockingTimetableGetter::default(); + // let timetable_getter = DummyTimetableGetter::new(Timetable { + // events: vec![ + // Event { + // category: timetable::EventCategory::Default, + // date: NaiveDate::from_ymd_opt(2023, 1, 30).unwrap(), + // start_time: NaiveTime::from_hms_opt(9, 0, 0).unwrap(), + // end_time: NaiveTime::from_hms_opt(10, 30, 0).unwrap(), + // description: "Foobarbaz".into(), + // summary: "P123B123 Dummy module".into(), + // location: "XI r.-521".into(), + // module_name: Some("Dummy module".into()) + // } + // ] + // }); let mut native_options = eframe::NativeOptions::default(); native_options.decorated = true; @@ -32,14 +58,13 @@ fn main() -> Result<(), ureq::Error> { width: 32, height: 32, }); - let mut app = MainApp::new(config_storage); + let mut app = MainApp::new(Box::new(config_store), Box::new(timetable_getter)); eframe::run_native( "KTU timetable", native_options, Box::new(move |cc| { - app.on_creation(cc); - app.refresh_timetable(); + app.init(cc); Box::new(app) }) ); diff --git a/src/timetable.rs b/src/timetable.rs index 32eb3bc..5ab526d 100644 --- a/src/timetable.rs +++ b/src/timetable.rs @@ -2,7 +2,7 @@ use ical::property::Property; use std::{error::Error, fmt}; use std::io::BufReader; use chrono::{NaiveDate, NaiveTime, IsoWeek, Datelike}; -use lazy_regex::{regex, regex_captures}; +use lazy_regex::{regex_captures}; #[derive(Debug, Clone, Copy)] pub enum EventCategory { @@ -23,11 +23,9 @@ pub struct Event { pub module_name: Option } -// TODO: Make errors more descriptive - -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Timetable { - events: Vec + pub events: Vec } #[derive(Debug)] @@ -53,73 +51,100 @@ impl Error for GetTimetableError {} impl fmt::Display for GetTimetableError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "Failed to get timetable") + match self { + GetTimetableError::NotFound => write!(f, "Timetable not found"), + } } } fn guess_module_name(summary: &str) -> Option { - let captures = regex_captures!(r"^P\d{3}B\d{3} (.+)", summary); + let captures = regex_captures!(r"^\w\d{3}\w\d{3} (.+)", summary); if let Some((_, module_name)) = captures { return Some(module_name.into()); } None } -pub fn get_timetable(vidko: &str) -> Result { - fn find_property<'a>(properties: &'a [Property], name: &str) -> Result<&'a Property, GetTimetableError> { - for prop in properties { - if prop.name == name { - return Ok(prop); +pub trait TimetableGetter { + fn get(&self, vidko: &str) -> Result; +} + +#[derive(Default)] +pub struct BlockingTimetableGetter {} +impl TimetableGetter for BlockingTimetableGetter { + fn get(&self, vidko: &str) -> Result { + fn find_property<'a>(properties: &'a [Property], name: &str) -> Result<&'a Property, GetTimetableError> { + for prop in properties { + if prop.name == name { + return Ok(prop); + } } + panic!("Property '{}' not found", name); } - panic!("Property '{}' not found", name); - } - let resp = ureq::get(&format!("https://uais.cr.ktu.lt/ktuis/tv_rprt2.ical1?p={}&t=basic.ics", vidko)) - .call() - .map_err(|_| GetTimetableError::NotFound)?; + let resp = ureq::get(&format!("https://uais.cr.ktu.lt/ktuis/tv_rprt2.ical1?p={}&t=basic.ics", vidko)) + .call() + .map_err(|_| GetTimetableError::NotFound)?; - let mut reader = ical::IcalParser::new(BufReader::new(resp.into_reader())); - let cal = reader.next(); - if cal.is_none() { - return Err(GetTimetableError::NotFound) - } - let cal = cal.unwrap().unwrap(); + let mut reader = ical::IcalParser::new(BufReader::new(resp.into_reader())); + let cal = reader.next(); + if cal.is_none() { + return Err(GetTimetableError::NotFound) + } + let cal = cal.unwrap().unwrap(); + if cal.events.is_empty() { + return Err(GetTimetableError::NotFound); + } - let mut timetable = Timetable { events: vec![] }; - for event in cal.events { - let category_prop = find_property(&event.properties, "CATEGORIES")?; - let start_prop = find_property(&event.properties, "DTSTART")?; - let end_prop = find_property(&event.properties, "DTEND")?; - let description_prop = find_property(&event.properties, "DESCRIPTION")?; - let summary_prop = find_property(&event.properties, "SUMMARY")?; - let location_prop = find_property(&event.properties, "LOCATION")?; + let mut timetable = Timetable { events: vec![] }; + for event in cal.events { + let category_prop = find_property(&event.properties, "CATEGORIES")?; + let start_prop = find_property(&event.properties, "DTSTART")?; + let end_prop = find_property(&event.properties, "DTEND")?; + let description_prop = find_property(&event.properties, "DESCRIPTION")?; + let summary_prop = find_property(&event.properties, "SUMMARY")?; + let location_prop = find_property(&event.properties, "LOCATION")?; - let mut category = EventCategory::Default; - if let Some(category_value) = &category_prop.value { - if category_value == "Yellow Category" { - category = EventCategory::Yellow; + let mut category = EventCategory::Default; + if let Some(category_value) = &category_prop.value { + if category_value == "Yellow Category" { + category = EventCategory::Yellow; + } } + let start_str = start_prop.value.clone().unwrap(); + let end_str = end_prop.value.clone().unwrap(); + let (start_date, start_time) = start_str.split_once('T').unwrap(); + let (_end_date, end_time) = end_str.split_once('T').unwrap(); + let summary = summary_prop.value.clone().unwrap(); + + timetable.events.push(Event { + category, + date: NaiveDate::parse_from_str(start_date, "%Y%m%d").unwrap(), + start_time: NaiveTime::parse_from_str(start_time, "%H%M%S").unwrap(), + end_time: NaiveTime::parse_from_str(end_time, "%H%M%S").unwrap(), + description: description_prop.value.clone().unwrap(), + module_name: guess_module_name(&summary), + summary, + location: location_prop.value.clone().unwrap() + }) } - let start_str = start_prop.value.clone().unwrap(); - let end_str = end_prop.value.clone().unwrap(); - let (start_date, start_time) = start_str.split_once('T').unwrap(); - let (_end_date, end_time) = end_str.split_once('T').unwrap(); - let summary = summary_prop.value.clone().unwrap(); - timetable.events.push(Event { - category, - date: NaiveDate::parse_from_str(start_date, "%Y%m%d").unwrap(), - start_time: NaiveTime::parse_from_str(start_time, "%H%M%S").unwrap(), - end_time: NaiveTime::parse_from_str(end_time, "%H%M%S").unwrap(), - description: description_prop.value.clone().unwrap(), - module_name: guess_module_name(&summary), - summary, - location: location_prop.value.clone().unwrap() - }) + timetable.events.sort_by_key(|event| (event.date, event.start_time)); + + Ok(timetable) } +} - timetable.events.sort_by_key(|event| (event.date, event.start_time)); - - Ok(timetable) +pub struct DummyTimetableGetter { + timetable: Timetable +} +impl DummyTimetableGetter { + pub fn new(timetable: Timetable) -> Self { + Self { timetable } + } +} +impl TimetableGetter for DummyTimetableGetter { + fn get(&self, vidko: &str) -> Result { + Ok(self.timetable.clone()) + } } \ No newline at end of file diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..fd60961 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,12 @@ +use egui::ColorImage; + +pub fn load_image_from_memory(image_data: &[u8]) -> Result { + let image = image::load_from_memory(image_data)?; + let size = [image.width() as _, image.height() as _]; + let image_buffer = image.to_rgba8(); + let pixels = image_buffer.as_flat_samples(); + Ok(ColorImage::from_rgba_unmultiplied( + size, + pixels.as_slice(), + )) +} \ No newline at end of file