diff --git a/src/gui/common.rs b/src/gui/common.rs index 99b2ccb..a7ca536 100644 --- a/src/gui/common.rs +++ b/src/gui/common.rs @@ -1,5 +1,15 @@ +use std::{ + cell::RefCell, + rc::Rc, + sync::{Arc, Mutex}, + thread, + time::Duration, +}; + use egui::{self, InputState, Key::*}; +use crate::parser::{HtmlItem, HtmlLink, HtmlLoader, HtmlParser}; + const NUM_KEYS: [egui::Key; 10] = [Num0, Num1, Num2, Num3, Num4, Num5, Num6, Num7, Num8, Num9]; /// Return None if number is not pressed @@ -12,3 +22,358 @@ pub fn input_to_num(input: &InputState) -> Option { None } + +pub trait PageDraw<'a, T: HtmlParser + TelePager + Send + 'static> { + fn draw(&mut self); + fn new(ui: &'a mut egui::Ui, ctx: &'a mut GuiContext) -> Self; +} + +pub trait TelePager { + fn to_full_page(page: &TelePage) -> String; + fn to_page_str(page: &TelePage) -> String; + fn from_page_str(page: &str) -> TelePage; +} + +#[derive(Clone, Copy)] +pub struct TelePage { + pub page: i32, + pub sub_page: i32, +} + +impl TelePage { + pub fn new(page: i32, sub_page: i32) -> Self { + Self { page, sub_page } + } +} + +pub struct TeleHistory { + pages: Vec, + current: usize, +} + +impl TeleHistory { + pub fn new(first_page: TelePage) -> Self { + Self { + pages: vec![first_page], + current: 0, + } + } + + /// Trucks current history to the current page + pub fn add(&mut self, page: TelePage) { + self.current += 1; + self.pages.truncate(self.current); + self.pages.push(page); + } + + pub fn prev(&mut self) -> Option { + if self.current > 0 { + self.current -= 1; + return Some(*self.pages.get(self.current).unwrap()); + } + + None + } + + // Go to previous page and truncate the current history + pub fn prev_trunc(&mut self) -> Option { + if self.current > 0 { + self.pages.truncate(self.current); + self.current -= 1; + return Some(*self.pages.get(self.current).unwrap()); + } + + None + } + + pub fn next(&mut self) -> Option { + if self.current < self.pages.len() - 1 { + self.current += 1; + return Some(*self.pages.get(self.current).unwrap()); + } + + None + } +} + +pub struct GuiWorker { + running: Arc>, + timer: Arc>, + /// How often refresh should happen in seconds + interval: Arc>, + should_refresh: Arc>, +} + +impl GuiWorker { + pub fn new(interval: u64) -> Self { + Self { + running: Arc::new(Mutex::new(false)), + should_refresh: Arc::new(Mutex::new(false)), + timer: Arc::new(Mutex::new(0)), + interval: Arc::new(Mutex::new(interval)), + } + } + + pub fn start(&mut self) { + *self.running.lock().unwrap() = true; + let running = self.running.clone(); + let timer = self.timer.clone(); + let interval = self.interval.clone(); + let should_refresh = self.should_refresh.clone(); + thread::spawn(move || { + while *running.lock().unwrap() { + thread::sleep(Duration::from_secs(1)); + let mut refresh = should_refresh.lock().unwrap(); + // Only incerement timeres when there's no refresh happening + if !*refresh { + let mut timer = timer.lock().unwrap(); + let new_time = *timer + 1; + let interval = *interval.lock().unwrap(); + if new_time >= interval { + *timer = 0; + *refresh = true; + } else { + *timer = new_time; + } + } + } + }); + } + + pub fn stop(&mut self) { + *self.timer.lock().unwrap() = 0; + *self.running.lock().unwrap() = false; + } + + pub fn set_interval(&mut self, interval: u64) { + *self.timer.lock().unwrap() = 0; + *self.interval.lock().unwrap() = interval; + } + + pub fn should_refresh(&self) -> bool { + *self.should_refresh.lock().unwrap() + } + + pub fn use_refresh(&mut self) { + *self.should_refresh.lock().unwrap() = false; + } +} + +impl Drop for GuiWorker { + fn drop(&mut self) { + self.stop(); + } +} + +impl Default for GuiWorker { + fn default() -> Self { + // 5 minutes + Self::new(300) + } +} + +pub enum FetchState { + /// No fetch has been done, so the state is uninitialised + Init, + InitFailed, + Fetching, + // TODO: error codes + Error, + Complete(T), +} + +pub trait IGuiCtx { + fn handle_input(&mut self, input: &InputState); + fn draw(&mut self, ui: &mut egui::Ui); + fn set_refresh_interval(&mut self, interval: u64); + fn stop_refresh_interval(&mut self); + fn return_from_error_page(&mut self); + fn load_current_page(&mut self); + fn load_page(&mut self, page: &str, add_to_history: bool); +} + +pub struct GuiContext { + pub egui: egui::Context, + pub state: Arc>>, + pub current_page: TelePage, + pub history: TeleHistory, + pub page_buffer: Vec, + pub worker: Option, +} + +impl GuiContext { + pub fn new(egui: egui::Context) -> Self { + let current_page = TelePage::new(100, 1); + + Self { + egui, + current_page, + state: Arc::new(Mutex::new(FetchState::Init)), + page_buffer: Vec::with_capacity(3), + history: TeleHistory::new(current_page), + worker: None, + } + } + + /// Used for testing/dev only + #[allow(dead_code)] + pub fn from_file(egui: egui::Context, file: &str) -> Self { + let current_page = TelePage::new(100, 1); + let pobj = HtmlLoader::new(file); + let parser = T::new(); + let completed = parser.parse(pobj).unwrap(); + + Self { + egui, + current_page, + state: Arc::new(Mutex::new(FetchState::Complete(completed))), + page_buffer: Vec::with_capacity(3), + history: TeleHistory::new(current_page), + worker: None, + } + } + + pub fn handle_input(&mut self, input: &InputState) { + // Ignore input while fetching + match *self.state.lock().unwrap() { + FetchState::Complete(_) => {} + _ => return, + }; + + if let Some(num) = input_to_num(input) { + if self.page_buffer.len() < 3 { + self.page_buffer.push(num); + } + + if self.page_buffer.len() == 3 { + let page_num = self.page_buffer.iter().fold(0, |acum, val| acum * 10 + val); + self.page_buffer.clear(); + self.load_page(&T::to_page_str(&TelePage::new(page_num, 1)), true); + } + } + + // prev + if input.pointer.button_released(egui::PointerButton::Extra1) { + if let Some(page) = self.history.prev() { + self.current_page = page; + self.load_current_page(); + } + } + + // next + if input.pointer.button_released(egui::PointerButton::Extra2) { + if let Some(page) = self.history.next() { + self.current_page = page; + self.load_current_page(); + } + } + } + + pub fn draw(&mut self, _ui: &mut egui::Ui) { + if let Some(worker) = &mut self.worker { + if worker.should_refresh() { + worker.use_refresh(); + self.load_current_page(); + } + } + } + + pub fn set_refresh_interval(&mut self, interval: u64) { + if let Some(worker) = &mut self.worker { + worker.set_interval(interval); + } else { + let mut worker = GuiWorker::new(interval); + worker.start(); + self.worker = Some(worker); + } + } + + pub fn stop_refresh_interval(&mut self) { + self.worker = None; + } + + pub fn return_from_error_page(&mut self) { + if let Some(page) = self.history.prev_trunc() { + self.current_page = page; + self.load_current_page(); + } + } + + pub fn load_current_page(&mut self) { + let page = T::to_page_str(&self.current_page); + self.load_page(&page, false); + } + + pub fn load_page(&mut self, page: &str, add_to_history: bool) { + let ctx = self.egui.clone(); + let state = self.state.clone(); + let page = T::from_page_str(page); + + self.current_page = page; + if add_to_history { + self.history.add(self.current_page) + } + + thread::spawn(move || { + let is_init = matches!( + *state.lock().unwrap(), + FetchState::Init | FetchState::InitFailed + ); + + *state.lock().unwrap() = FetchState::Fetching; + let site = &T::to_full_page(&page); + log::info!("Load page: {}", site); + let new_state = match Self::fetch_page(site) { + Ok(parser) => FetchState::Complete(parser), + Err(_) => { + if is_init { + FetchState::InitFailed + } else { + FetchState::Error + } + } + }; + + *state.lock().unwrap() = new_state; + ctx.request_repaint(); + }); + } + + fn fetch_page(site: &str) -> Result { + let body = reqwest::blocking::get(site).map_err(|_| ())?; + let body = body.text().map_err(|_| ())?; + let teletext = T::new() + .parse(HtmlLoader { page_data: body }) + .map_err(|_| ())?; + Ok(teletext) + } +} + +impl HtmlItem { + pub fn add_to_ui( + &self, + ui: &mut egui::Ui, + ctx: Rc>>, + ) { + match self { + HtmlItem::Link(link) => { + link.add_to_ui(ui, ctx); + } + HtmlItem::Text(text) => { + ui.label(text); + } + } + } +} + +impl HtmlLink { + pub fn add_to_ui( + &self, + ui: &mut egui::Ui, + ctx: Rc>>, + ) { + if ui.link(&self.inner_text).clicked() { + ctx.borrow_mut().load_page(&self.url, true); + } + } +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 95ea330..be249c9 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1,9 +1,12 @@ use std::time::Duration; mod common; +mod yle_image; mod yle_text; use egui::{Color32, FontFamily, FontId, Style, TextStyle, Ui}; +use self::common::{GuiContext, IGuiCtx}; +use self::yle_image::GuiYleImageContext; use self::yle_text::GuiYleTextContext; #[derive(Default, serde::Deserialize, serde::Serialize)] @@ -19,10 +22,36 @@ fn def_color_opt(color: [u8; 3]) -> OptionSetting<[u8; 3]> { } } +#[derive(serde::Deserialize, serde::Serialize)] +enum Pages { + YleText, + YleImage, +} + +impl Pages { + fn to_gui(&self, egui: &egui::Context) -> Box { + match self { + Self::YleImage => { + Box::new(GuiYleImageContext::new(GuiContext::new(egui.clone()))) as Box + } + Self::YleText => { + Box::new(GuiYleTextContext::new(GuiContext::new(egui.clone()))) as Box + } + } + } +} + +impl Default for Pages { + fn default() -> Self { + Self::YleText + } +} + #[derive(serde::Deserialize, serde::Serialize)] #[serde(default)] // if we add new fields, give them default values when deserializing old state struct TeleTextSettings { font_size: f32, + open_page: Pages, link_color: OptionSetting<[u8; 3]>, text_color: OptionSetting<[u8; 3]>, background_color: OptionSetting<[u8; 3]>, @@ -31,7 +60,7 @@ struct TeleTextSettings { impl TeleTextSettings { /// Initialize all settings, should be used when app is initalised - fn init_all(&self, ctx: &egui::Context, page: &mut GuiYleTextContext) { + fn init_all(&self, ctx: &egui::Context, page: &mut Box) { self.set_colors(ctx); self.set_font_size(ctx); self.set_refresh_interval(page); @@ -93,7 +122,7 @@ impl TeleTextSettings { ctx.set_style(style); } - fn set_refresh_interval(&self, page: &mut GuiYleTextContext) { + fn set_refresh_interval(&self, page: &mut Box) { if self.refresh_interval.is_used { page.set_refresh_interval(self.refresh_interval.value); } else { @@ -106,6 +135,7 @@ impl Default for TeleTextSettings { fn default() -> Self { Self { font_size: 12.5, + open_page: Default::default(), link_color: def_color_opt([17, 159, 244]), text_color: def_color_opt([255, 255, 255]), background_color: def_color_opt([0, 0, 0]), @@ -122,7 +152,7 @@ impl Default for TeleTextSettings { #[serde(default)] // if we add new fields, give them default values when deserializing old state pub struct TeleTextApp { #[serde(skip)] - page: Option, + page: Option>, #[serde(skip)] settings_open: bool, settings: TeleTextSettings, @@ -158,8 +188,13 @@ impl TeleTextApp { TeleTextSettings::default() }; - let mut page = GuiYleTextContext::new(ctx.egui_ctx.clone()); - settings.init_all(&ctx.egui_ctx, &mut page); + /* let mut page = Box::new(GuiYleImageContext::new(GuiContext::new( + ctx.egui_ctx.clone(), + ))) as Box; */ + let mut page = settings.open_page.to_gui(&ctx.egui_ctx); + let page_ref = &mut page as &mut Box; + + settings.init_all(&ctx.egui_ctx, page_ref); Self { page: Some(page), @@ -183,7 +218,7 @@ impl eframe::App for TeleTextApp { } = self; egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { - top_menu_bar(ui, frame, settings_open); + top_menu_bar(ui, ctx, frame, settings_open, page, settings); }); // .input() locks ctx so we need to copy the data to avoid locks @@ -208,17 +243,39 @@ impl eframe::App for TeleTextApp { } } -fn top_menu_bar(ui: &mut Ui, frame: &mut eframe::Frame, open: &mut bool) { - // TODO: hide bar after Settigns etc is clicked +fn top_menu_bar( + ui: &mut Ui, + egui: &egui::Context, + frame: &mut eframe::Frame, + open: &mut bool, + page: &mut Option>, + settings: &mut TeleTextSettings, +) { egui::menu::bar(ui, |ui| { ui.menu_button("File", |ui| { + ui.menu_button("Reader", |ui| { + if ui.button("Yle Text").clicked() { + settings.open_page = Pages::YleText; + *page = Some(Pages::YleText.to_gui(egui)); + ui.close_menu(); + } + + if ui.button("Yle Image").clicked() { + settings.open_page = Pages::YleImage; + *page = Some(Pages::YleImage.to_gui(egui)); + ui.close_menu(); + } + }); + if ui.button("Settings").clicked() { *open = true; + ui.close_menu(); } ui.separator(); if ui.button("Quit").clicked() { frame.close(); + ui.close_menu(); } }); }); @@ -228,7 +285,7 @@ fn settings_window( ui: &mut Ui, ctx: &egui::Context, settings: &mut TeleTextSettings, - page: &mut GuiYleTextContext, + page: &mut Box, ) { if ui .add(egui::Slider::new(&mut settings.font_size, 8.0..=48.0).text("Font size")) diff --git a/src/gui/yle_image.rs b/src/gui/yle_image.rs new file mode 100644 index 0000000..a27726e --- /dev/null +++ b/src/gui/yle_image.rs @@ -0,0 +1,283 @@ +use std::{cell::RefCell, ops::Deref, rc::Rc}; + +use egui::{FontId, InputState, RichText, TextStyle}; +use egui_extras::RetainedImage; + +use crate::parser::{HtmlLink, HtmlText, YleImage}; + +use super::common::{FetchState, GuiContext, IGuiCtx, PageDraw, TelePage, TelePager}; + +pub struct GuiYleImage<'a> { + ui: &'a mut egui::Ui, + ctx: Rc>>, + panel_width: f32, + char_width: f32, + is_small: bool, +} + +impl<'a> GuiYleImage<'a> { + fn get_page_str(&self) -> String { + let page_buf = &self.ctx.borrow().page_buffer; + let page_num = if !page_buf.is_empty() { + let mut page_str = "---".as_bytes().to_vec(); + for (idx, num) in page_buf.iter().enumerate() { + page_str[idx] = b'0' + (*num as u8); + } + String::from_utf8(page_str.to_vec()).unwrap() + } else { + self.ctx.borrow().current_page.page.to_string() + }; + + format!("P{}", page_num) + } + + fn draw_header_small(&mut self, title: &HtmlText) { + // align with page navigation + let chw = self.char_width; + let page_len = chw * 4.0; + let title_len = (title.chars().count() as f32) * chw; + let title_space = self.panel_width - title_len - page_len; + let page = self.get_page_str(); + + self.ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.label(page); + ui.add_space(title_space); + ui.label(title.clone()); + }); + } + + fn draw_header_normal(&mut self, title: &HtmlText) { + // align with page navigation + let chw = self.char_width; + let nav_length = chw * 69.0; + let nav_start = (self.panel_width / 2.0) - (nav_length / 2.0); + let page_len = chw * 4.0; + let time_len = chw * 15.0; + let title = format!("{} YLE TEKSTI-TV", title); + let title_len = (title.chars().count() as f32) * chw; + + let title_space = (nav_length / 2.0) - (title_len / 2.0) - page_len; + let time_space = nav_length - title_space - page_len - title_len - time_len; + + let page = self.get_page_str(); + + self.ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.add_space(nav_start); + ui.label(page); + ui.add_space(title_space); + ui.label(title.clone()); + ui.add_space(time_space); + let now = chrono::Local::now(); + ui.label(now.format("%d.%m. %H:%M:%S").to_string()); + }); + } + + fn draw_header(&mut self, title: &HtmlText) { + if self.is_small { + self.draw_header_small(title); + } else { + self.draw_header_normal(title); + } + } + + fn draw_image(&mut self, image: &[u8]) { + self.ui + .with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { + let image = RetainedImage::from_image_bytes("debug_name", image).unwrap(); + image.show_max_size(ui, ui.available_size()); + }); + } + + fn draw_page_navigation_small(&mut self, navigation: &[Option]) { + let mut body_font = TextStyle::Body.resolve(self.ui.style()); + body_font.size *= 3.0; + let arrow_width = self.ui.fonts().glyph_width(&body_font, 'W'); + let chars_len = arrow_width * 4.0 + self.char_width * 9.0; + let page_nav_start = (self.panel_width / 2.0) - (chars_len / 2.0); + let ctx = &self.ctx; + self.ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.add_space(page_nav_start); + for (idx, item) in navigation.iter().enumerate() { + let icon = match idx { + 0 => "←", + 1 => "↑", + 2 => "↓", + 3 => "→", + _ => "?", + }; + + let icon_text = RichText::new(icon).font(FontId::monospace(body_font.size)); + match item { + Some(link) => { + if ui.link(icon_text).clicked() { + ctx.borrow_mut().load_page(&link.url, true); + }; + } + None => { + ui.label(icon_text); + } + } + + if idx < 3 { + ui.label(" | "); + } + } + }); + } + + fn draw_page_navigation_normal(&mut self, navigation: &[Option]) { + // "Edellinen sivu | Edellinen alasivu | Seuraava alasivu | Seuraava sivu" is 69 char + let valid_link: Vec<&HtmlLink> = navigation.iter().filter_map(|n| n.as_ref()).collect(); + let text_len = valid_link.iter().fold(0.0, |acum, val| { + acum + val.inner_text.chars().count() as f32 + }); + let page_nav_start = (self.panel_width / 2.0) - (self.char_width * text_len / 2.0); + let ctx = &self.ctx; + self.ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.add_space(page_nav_start); + for (idx, item) in valid_link.iter().enumerate() { + item.add_to_ui(ui, ctx.clone()); + if idx < valid_link.len() - 1 { + ui.label(" | "); + } + } + }); + } + + fn draw_page_navigation(&mut self, navigation: &[Option]) { + if self.is_small { + self.draw_page_navigation_small(navigation); + } else { + self.draw_page_navigation_normal(navigation); + } + } +} + +impl<'a> PageDraw<'a, YleImage> for GuiYleImage<'a> { + fn draw(&mut self) { + let ctx = &self.ctx; + let state = self.ctx.borrow().state.clone(); + + match state.lock().unwrap().deref() { + FetchState::Complete(page) => { + self.draw_header(&page.title); + self.draw_image(&page.image); + self.draw_page_navigation(&page.botton_navigation); + } + FetchState::Fetching => { + self.ui + .with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { + ui.label("Loading..."); + }); + } + FetchState::Error => { + self.ui + .with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { + ui.label("Load failed..."); + if ui.link("Return to previous page").clicked() { + ctx.borrow_mut().return_from_error_page(); + } + }); + } + FetchState::InitFailed => { + self.ui + .with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { + ui.label("Load failed..."); + if ui.link("Try again").clicked() { + ctx.borrow_mut().load_current_page(); + } + }); + } + FetchState::Init => { + self.ui + .with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { + ui.label("Opening..."); + }); + ctx.borrow_mut().load_current_page(); + } + }; + } + + fn new(ui: &'a mut egui::Ui, ctx: &'a mut GuiContext) -> Self { + let panel_width = ui.available_width(); + let body_font = TextStyle::Body.resolve(ui.style()); + let char_width = ui.fonts().glyph_width(&body_font, 'W'); + // Aligned with page navigation + let nav_len = char_width * 69.0; + let is_small = nav_len + 4.0 > panel_width; + + Self { + ui, + ctx: Rc::new(RefCell::new(ctx)), + char_width, + panel_width, + is_small, + } + } +} + +pub struct GuiYleImageContext { + ctx: GuiContext, +} + +impl GuiYleImageContext { + pub fn new(ctx: GuiContext) -> Self { + Self { ctx } + } +} + +impl IGuiCtx for GuiYleImageContext { + fn handle_input(&mut self, input: &InputState) { + self.ctx.handle_input(input) + } + + fn draw(&mut self, ui: &mut egui::Ui) { + self.ctx.draw(ui); + GuiYleImage::new(ui, &mut self.ctx).draw(); + } + + fn set_refresh_interval(&mut self, interval: u64) { + self.ctx.set_refresh_interval(interval) + } + + fn stop_refresh_interval(&mut self) { + self.ctx.stop_refresh_interval() + } + + fn return_from_error_page(&mut self) { + self.ctx.return_from_error_page() + } + + fn load_current_page(&mut self) { + self.ctx.load_current_page() + } + + fn load_page(&mut self, page: &str, add_to_history: bool) { + self.ctx.load_page(page, add_to_history) + } +} + +impl TelePager for YleImage { + fn to_full_page(page: &TelePage) -> String { + // https://yle.fi/aihe/yle-ttv/json?P=100_0001 + format!( + "https://yle.fi/aihe/yle-ttv/json?P={}_{:04}", + page.page, page.sub_page + ) + } + + fn to_page_str(page: &TelePage) -> String { + format!("{}_{:04}", page.page, page.sub_page) + } + + fn from_page_str(page: &str) -> TelePage { + let current_page = page[0..3].parse::().unwrap(); + let sub_page = page[4..8].parse::().unwrap(); + + TelePage::new(current_page, sub_page) + } +} diff --git a/src/gui/yle_text.rs b/src/gui/yle_text.rs index 1dafe9f..4dbebd7 100644 --- a/src/gui/yle_text.rs +++ b/src/gui/yle_text.rs @@ -1,117 +1,13 @@ -use std::{ - cell::RefCell, - ops::Deref, - rc::Rc, - sync::{Arc, Mutex}, - thread, - time::Duration, -}; - -use crate::parser::{ - HtmlItem, HtmlLink, HtmlLoader, HtmlParser, HtmlText, TeleText, MIDDLE_TEXT_MAX_LEN, -}; -use egui::{FontId, InputState, RichText, TextStyle}; - -use super::common::input_to_num; - -impl HtmlItem { - fn add_to_ui(&self, ui: &mut egui::Ui, ctx: Rc>) { - match self { - HtmlItem::Link(link) => { - link.add_to_ui(ui, ctx); - } - HtmlItem::Text(text) => { - ui.label(text); - } - } - } -} - -impl HtmlLink { - fn add_to_ui(&self, ui: &mut egui::Ui, ctx: Rc>) { - if ui.link(&self.inner_text).clicked() { - println!("Clicked {}", self.url); - ctx.borrow_mut().load_page(&self.url, true); - } - } -} - -#[derive(Clone, Copy)] -struct TelePage { - page: i32, - sub_page: i32, -} - -impl TelePage { - fn new(page: i32, sub_page: i32) -> Self { - Self { page, sub_page } - } - - fn from_page_str(page: &str) -> Self { - let current_page = page[0..3].parse::().unwrap(); - let sub_page = page[4..8].parse::().unwrap(); - - Self::new(current_page, sub_page) - } - - fn to_page_str(self) -> String { - format!("{}_{:04}.htm", self.page, self.sub_page) - } -} - -struct TeleHistory { - pages: Vec, - current: usize, -} - -impl TeleHistory { - fn new(first_page: TelePage) -> Self { - Self { - pages: vec![first_page], - current: 0, - } - } - - /// Trucks current history to the current page - fn add(&mut self, page: TelePage) { - self.current += 1; - self.pages.truncate(self.current); - self.pages.push(page); - } - - fn prev(&mut self) -> Option { - if self.current > 0 { - self.current -= 1; - return Some(*self.pages.get(self.current).unwrap()); - } - - None - } - - // Go to previous page and truncate the current history - fn prev_trunc(&mut self) -> Option { - if self.current > 0 { - self.pages.truncate(self.current); - self.current -= 1; - return Some(*self.pages.get(self.current).unwrap()); - } +use std::{cell::RefCell, ops::Deref, rc::Rc}; - None - } - - fn next(&mut self) -> Option { - if self.current < self.pages.len() - 1 { - self.current += 1; - return Some(*self.pages.get(self.current).unwrap()); - } +use crate::parser::{HtmlItem, HtmlLink, HtmlText, TeleText, MIDDLE_TEXT_MAX_LEN}; +use egui::{FontId, InputState, RichText, TextStyle}; - None - } -} +use super::common::{FetchState, GuiContext, IGuiCtx, PageDraw, TelePage, TelePager}; -struct GuiYleText<'a> { +pub struct GuiYleText<'a> { ui: &'a mut egui::Ui, - ctx: Rc>, + ctx: Rc>>, panel_width: f32, char_width: f32, is_small: bool, @@ -308,12 +204,13 @@ impl<'a> GuiYleText<'a> { self.draw_bottom_navigation_normal(navigation); } } +} - pub fn draw(&mut self) { +impl<'a> PageDraw<'a, TeleText> for GuiYleText<'a> { + fn draw(&mut self) { let ctx = &self.ctx; let state = self.ctx.borrow().state.clone(); - // TODO: draw all states match state.lock().unwrap().deref() { FetchState::Complete(page) => { self.draw_header(&page.title); @@ -358,7 +255,7 @@ impl<'a> GuiYleText<'a> { }; } - pub fn new(ui: &'a mut egui::Ui, ctx: &'a mut GuiYleTextContext) -> Self { + fn new(ui: &'a mut egui::Ui, ctx: &'a mut GuiContext) -> Self { let panel_width = ui.available_width(); let body_font = TextStyle::Body.resolve(ui.style()); let char_width = ui.fonts().glyph_width(&body_font, 'W'); @@ -376,229 +273,64 @@ impl<'a> GuiYleText<'a> { } } -enum FetchState { - /// No fetch has been done, so the state is uninitialised - Init, - InitFailed, - Fetching, - // TODO: error codes - Error, - Complete(TeleText), -} - pub struct GuiYleTextContext { - egui: egui::Context, - state: Arc>, - current_page: TelePage, - history: TeleHistory, - page_buffer: Vec, - worker: Option, + ctx: GuiContext, } impl GuiYleTextContext { - pub fn new(egui: egui::Context) -> Self { - let current_page = TelePage::new(100, 1); - - Self { - egui, - current_page, - state: Arc::new(Mutex::new(FetchState::Init)), - page_buffer: Vec::with_capacity(3), - history: TeleHistory::new(current_page), - worker: None, - } + pub fn new(ctx: GuiContext) -> Self { + Self { ctx } } +} - pub fn handle_input(&mut self, input: &InputState) { - // Ignore input while fetching - match *self.state.lock().unwrap() { - FetchState::Complete(_) => {} - _ => return, - }; - - if let Some(num) = input_to_num(input) { - if self.page_buffer.len() < 3 { - self.page_buffer.push(num); - } - - if self.page_buffer.len() == 3 { - let page_num = self.page_buffer.iter().fold(0, |acum, val| acum * 10 + val); - self.page_buffer.clear(); - self.load_page(&TelePage::new(page_num, 1).to_page_str(), true); - } - } - - // prev - if input.pointer.button_released(egui::PointerButton::Extra1) { - if let Some(page) = self.history.prev() { - self.current_page = page; - self.load_current_page(); - } - } - - // next - if input.pointer.button_released(egui::PointerButton::Extra2) { - if let Some(page) = self.history.next() { - self.current_page = page; - self.load_current_page(); - } - } +impl IGuiCtx for GuiYleTextContext { + fn handle_input(&mut self, input: &InputState) { + self.ctx.handle_input(input) } - pub fn draw(&mut self, ui: &mut egui::Ui) { - if let Some(worker) = &mut self.worker { - if worker.should_refresh() { - worker.use_refresh(); - self.load_current_page(); - } - } - - GuiYleText::new(ui, self).draw(); + fn draw(&mut self, ui: &mut egui::Ui) { + self.ctx.draw(ui); + GuiYleText::new(ui, &mut self.ctx).draw(); } - pub fn set_refresh_interval(&mut self, interval: u64) { - if let Some(worker) = &mut self.worker { - worker.set_interval(interval); - } else { - let mut worker = YleTextGuiWorker::new(interval); - worker.start(); - self.worker = Some(worker); - } + fn set_refresh_interval(&mut self, interval: u64) { + self.ctx.set_refresh_interval(interval) } - pub fn stop_refresh_interval(&mut self) { - self.worker = None; + fn stop_refresh_interval(&mut self) { + self.ctx.stop_refresh_interval() } - pub fn return_from_error_page(&mut self) { - if let Some(page) = self.history.prev_trunc() { - self.current_page = page; - self.load_current_page(); - } + fn return_from_error_page(&mut self) { + self.ctx.return_from_error_page() } - pub fn load_current_page(&mut self) { - let page = self.current_page.to_page_str(); - self.load_page(&page, false); + fn load_current_page(&mut self) { + self.ctx.load_current_page() } - pub fn load_page(&mut self, page: &str, add_to_history: bool) { - let ctx = self.egui.clone(); - let state = self.state.clone(); - let page = page.to_string(); - - self.current_page = TelePage::from_page_str(&page); - if add_to_history { - self.history.add(self.current_page) - } - - thread::spawn(move || { - let is_init = matches!( - *state.lock().unwrap(), - FetchState::Init | FetchState::InitFailed - ); - - *state.lock().unwrap() = FetchState::Fetching; - let site = &format!("https://yle.fi/tekstitv/txt/{}", page); - log::info!("Load page: {}", site); - let new_state = match Self::fetch_page(site) { - Ok(parser) => FetchState::Complete(parser), - Err(_) => { - if is_init { - FetchState::InitFailed - } else { - FetchState::Error - } - } - }; - - *state.lock().unwrap() = new_state; - ctx.request_repaint(); - }); - } - - fn fetch_page(site: &str) -> Result { - let body = reqwest::blocking::get(site).map_err(|_| ())?; - let body = body.text().map_err(|_| ())?; - let teletext = TeleText::new() - .parse(HtmlLoader { page_data: body }) - .map_err(|_| ())?; - Ok(teletext) + fn load_page(&mut self, page: &str, add_to_history: bool) { + self.ctx.load_page(page, add_to_history) } } -pub struct YleTextGuiWorker { - running: Arc>, - timer: Arc>, - /// How often refresh should happen in seconds - interval: Arc>, - should_refresh: Arc>, -} - -impl YleTextGuiWorker { - pub fn new(interval: u64) -> Self { - Self { - running: Arc::new(Mutex::new(false)), - should_refresh: Arc::new(Mutex::new(false)), - timer: Arc::new(Mutex::new(0)), - interval: Arc::new(Mutex::new(interval)), - } - } - - pub fn start(&mut self) { - *self.running.lock().unwrap() = true; - let running = self.running.clone(); - let timer = self.timer.clone(); - let interval = self.interval.clone(); - let should_refresh = self.should_refresh.clone(); - thread::spawn(move || { - while *running.lock().unwrap() { - thread::sleep(Duration::from_secs(1)); - let mut refresh = should_refresh.lock().unwrap(); - // Only incerement timeres when there's no refresh happening - if !*refresh { - let mut timer = timer.lock().unwrap(); - let new_time = *timer + 1; - let interval = *interval.lock().unwrap(); - if new_time >= interval { - *timer = 0; - *refresh = true; - } else { - *timer = new_time; - } - } - } - }); - } - - pub fn stop(&mut self) { - *self.timer.lock().unwrap() = 0; - *self.running.lock().unwrap() = false; - } - - pub fn set_interval(&mut self, interval: u64) { - *self.timer.lock().unwrap() = 0; - *self.interval.lock().unwrap() = interval; - } - - pub fn should_refresh(&self) -> bool { - *self.should_refresh.lock().unwrap() +impl TelePager for TeleText { + fn to_full_page(page: &TelePage) -> String { + // https://yle.fi/tekstitv/txt/100_0001.htm + format!( + "https://yle.fi/tekstitv/txt/{}_{:04}.htm", + page.page, page.sub_page + ) } - pub fn use_refresh(&mut self) { - *self.should_refresh.lock().unwrap() = false; - } -} + fn from_page_str(page: &str) -> TelePage { + let current_page = page[0..3].parse::().unwrap(); + let sub_page = page[4..8].parse::().unwrap(); -impl Drop for YleTextGuiWorker { - fn drop(&mut self) { - self.stop(); + TelePage::new(current_page, sub_page) } -} -impl Default for YleTextGuiWorker { - fn default() -> Self { - // 5 minutes - Self::new(300) + fn to_page_str(page: &TelePage) -> String { + format!("{}_{:04}.htm", page.page, page.sub_page) } } diff --git a/src/parser/common.rs b/src/parser/common.rs index 19983c2..5a667ba 100644 --- a/src/parser/common.rs +++ b/src/parser/common.rs @@ -28,13 +28,16 @@ pub fn decode_string(string: &str) -> String { new_string } +#[derive(Debug)] pub enum TagType { Unknown, P, Big, + Div, Pre, Link, Font, + Span, Center, } @@ -55,10 +58,13 @@ pub enum HtmlItem { } pub trait HtmlParser { - type ReturnType; + /* type ReturnType; */ /// Get tag type in the current position - fn get_tag_type(current: &str) -> TagType { + fn get_tag_type(current: &str) -> TagType + where + Self: Sized, + { let mut html = current; if html.starts_with('<') { @@ -71,10 +77,14 @@ pub trait HtmlParser { return TagType::Link; } else if html.starts_with("big") { return TagType::Big; + } else if html.starts_with("div") { + return TagType::Div; } else if html.starts_with("pre") { return TagType::Pre; } else if html.starts_with("font") { return TagType::Font; + } else if html.starts_with("span") { + return TagType::Span; } else if html.starts_with("center") { return TagType::Center; } @@ -86,14 +96,29 @@ pub trait HtmlParser { // when the pattern is stable enough to use /* fn skip_next_pattern(state, pattern: P) {} */ - fn skip_next_char<'a>(state: &'a mut ParseState<'a>, chr: char) -> InnerResult<'a, ()> { + fn skip_next_char<'a>(state: &'a mut ParseState<'a>, chr: char) -> InnerResult<'a, ()> + where + Self: Sized, + { let chr_start = state.current.find(chr).ok_or(ParseErr::InvalidPage)?; let chr_end = chr_start + 1; // char is always + 1 state.current = &state.current[chr_end..]; Ok((state, ())) } - fn skip_next_string<'a>(state: &'a mut ParseState<'a>, string: &str) -> InnerResult<'a, ()> { + fn skip_to_next_char<'a>(state: &'a mut ParseState<'a>, chr: char) -> InnerResult<'a, ()> + where + Self: Sized, + { + let chr_start = state.current.find(chr).ok_or(ParseErr::InvalidPage)?; + state.current = &state.current[chr_start..]; + Ok((state, ())) + } + + fn skip_next_string<'a>(state: &'a mut ParseState<'a>, string: &str) -> InnerResult<'a, ()> + where + Self: Sized, + { let string_start = state.current.find(string).ok_or(ParseErr::InvalidPage)?; let string_end = string_start + string.len(); state.current = &state.current[string_end..]; @@ -104,7 +129,10 @@ pub trait HtmlParser { mut state: &'a mut ParseState<'a>, tag: &str, closing: bool, - ) -> InnerResult<'a, ()> { + ) -> InnerResult<'a, ()> + where + Self: Sized, + { // TODO: Do we need to heap allocate a new string here? let html_tag = if closing { format!("(mut state: &'a mut ParseState<'a>) -> InnerResult<'a, HtmlLink> { + fn parse_current_link<'a>(mut state: &'a mut ParseState<'a>) -> InnerResult<'a, HtmlLink> + where + Self: Sized, + { state = Self::skip_next_string(state, "href=\"")?.0; let url_end = state.current.find('"').ok_or(ParseErr::InvalidPage)?; let url = state.current[..url_end].to_string(); - // Go to the end of the link thag + // Go to the end of the link tag state = Self::skip_next_string(state, ">")?.0; let inner_end = state.current.find('<').ok_or(ParseErr::InvalidPage)?; @@ -135,7 +166,12 @@ pub trait HtmlParser { Ok((state, HtmlLink { url, inner_text })) } - fn parse(self, loader: HtmlLoader) -> ParserResult; + fn parse(self, loader: HtmlLoader) -> ParserResult + where + Self: Sized; + fn new() -> Self + where + Self: Sized; } #[derive(Debug)] diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 8e86012..9494d86 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1,5 +1,7 @@ pub mod common; +pub mod yle_image; pub mod yle_text; pub use common::{HtmlItem, HtmlLink, HtmlLoader, HtmlParser, HtmlText}; +pub use yle_image::YleImage; pub use yle_text::{TeleText, MIDDLE_TEXT_MAX_LEN}; diff --git a/src/parser/yle_image.rs b/src/parser/yle_image.rs new file mode 100644 index 0000000..d86a782 --- /dev/null +++ b/src/parser/yle_image.rs @@ -0,0 +1,176 @@ +use base64::{engine::general_purpose, Engine as _}; + +use super::common::{ + decode_string, HtmlLink, HtmlLoader, HtmlParser, HtmlText, InnerResult, ParseErr, ParseState, + ParserResult, TagType, +}; + +extern crate html_escape; + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct IJMeta { + code: String, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct IJDataPage { + page: String, + subpage: String, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct IJDataInfoPage { + /// e.g. "898" + number: String, + /// e.g. "898_0003" + name: String, + /// e.g. "898/3" + label: String, + /// "?P=898#3" + href: String, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct IJDataInfo { + page: IJDataInfoPage, + aspect_ratio: String, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct IJDataContent { + /// Raw text repesentation of the image + text: String, + /// Image base64 data in html tag + image: String, + image_map: String, + pagination: String, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct IJData { + page: IJDataPage, + info: IJDataInfo, + content: IJDataContent, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct ImageJson { + meta: IJMeta, + /// IJData is in array but there only seems to be on item at a time + data: Vec, +} + +/// Contains the fields of Yle image site +#[derive(Debug)] +pub struct YleImage { + pub title: HtmlText, + pub image: Vec, + pub botton_navigation: Vec>, +} + +impl YleImage { + fn parse_image<'a>(state: &'a mut ParseState<'a>) -> InnerResult<'a, Vec> { + let state = Self::skip_next_string(state, "data:image/png;base64,")?.0; + let image_end = state.current.find('"').ok_or(ParseErr::InvalidPage)?; + let image = general_purpose::STANDARD + .decode(&state.current[..image_end]) + .unwrap(); + + Ok((state, image)) + } + + fn parse_bottom_nav_link<'a>(mut state: &'a mut ParseState<'a>) -> InnerResult<'a, HtmlLink> { + state = Self::skip_next_string(state, "data-yle-ttv-page-name=\"")?.0; + let url_end = state.current.find('"').ok_or(ParseErr::InvalidPage)?; + let url = state.current[..url_end].to_string(); + + // Go to the end of the link tag + state = Self::skip_next_char(state, '>')?.0; + let inner_text = if state.current.starts_with('<') { + // Skip the span open + state = Self::skip_next_char(state, '>')?.0; + let span_end = state.current.find('<').ok_or(ParseErr::InvalidPage)?; + let span_inner = &state.current[..span_end]; + // Skip the span close + state = Self::skip_next_char(state, '>')?.0; + let link_start = state.current.find('<').ok_or(ParseErr::InvalidPage)?; + let span_out = decode_string(&state.current[..link_start]); + format!("{} {}", span_inner.trim(), span_out.trim()) + } else { + // Get the string before span + let span_start = state.current.find('<').ok_or(ParseErr::InvalidPage)?; + let span_out = decode_string(&state.current[..span_start]); + // Skip the span open + state = Self::skip_next_char(state, '>')?.0; + let span_end = state.current.find('<').ok_or(ParseErr::InvalidPage)?; + let span_inner = &state.current[..span_end]; + format!("{} {}", span_out.trim(), span_inner.trim()) + }; + + state = Self::skip_next_tag(state, "a", true)?.0; + Ok((state, HtmlLink { url, inner_text })) + } + + fn parse_bottom_navigation<'a>( + mut state: &'a mut ParseState<'a>, + ) -> InnerResult<'a, Vec>> { + // state = Self::skip_next_string(state, "js-yle-ttv-pagination")?.0; + + let mut nav_links: Vec> = Vec::new(); + while !state.current.is_empty() { + state = Self::skip_next_char(state, '<')?.0; + let tag = Self::get_tag_type(state.current); + match tag { + // Spans without link are the hidden navs + TagType::Span => { + state = Self::skip_next_tag(state, "span", true)?.0; + nav_links.push(None); + } + // Div is the text page input, but we hadle it in title, like in text version + TagType::Div => { + state = Self::skip_next_tag(state, "form", true)?.0; + state = Self::skip_next_tag(state, "div", true)?.0; + } + // Links contain the actual pages + TagType::Link => { + let (new_state, link) = Self::parse_bottom_nav_link(state)?; + state = new_state; + nav_links.push(Some(link)); + } + // Everything else is invalid + _ => return Err(ParseErr::InvalidPage), + } + + state.current = if let Some(chr_start) = state.current.find('<') { + &state.current[chr_start..] + } else { + "" + }; + } + + Ok((state, nav_links)) + } +} + +impl HtmlParser for YleImage { + // type ReturnType = Self; + fn new() -> Self { + Self { + title: "".into(), + image: Vec::new(), + botton_navigation: Vec::new(), + } + } + + fn parse(mut self, loader: HtmlLoader) -> ParserResult { + let json: ImageJson = + serde_json::from_str(&loader.page_data).map_err(|_| ParseErr::InvalidPage)?; + self.title = json.data[0].info.page.label.clone(); + let mut state = ParseState::new(&json.data[0].content.image); + self.image = Self::parse_image(&mut state)?.1; + let mut state = ParseState::new(&json.data[0].content.pagination); + self.botton_navigation = Self::parse_bottom_navigation(&mut state)?.1; + + Ok(self) + } +} diff --git a/src/parser/yle_text.rs b/src/parser/yle_text.rs index 399c1d5..a8d2d72 100644 --- a/src/parser/yle_text.rs +++ b/src/parser/yle_text.rs @@ -198,8 +198,10 @@ impl TeleText { Ok((state, links)) } +} - pub fn new() -> TeleText { +impl HtmlParser for TeleText { + fn new() -> TeleText { TeleText { title: "".to_string(), page_navigation: vec![], @@ -208,12 +210,8 @@ impl TeleText { middle_rows: vec![], } } -} - -impl HtmlParser for TeleText { - type ReturnType = Self; - fn parse(mut self, loader: HtmlLoader) -> ParserResult { + fn parse(mut self, loader: HtmlLoader) -> ParserResult { let mut state = ParseState::new(&loader.page_data); let (state, title) = Self::parse_title(&mut state)?; self.title = title;