use std::{ cell::RefCell, collections::HashMap, fs::File, io::Read, ops::Index, path::Path, rc::Rc, sync::{Arc, RwLock}, time::{Duration, Instant}, }; use cairo::{Context, Rectangle}; use cyberpunk::{AsymLine, AsymLineCutout, GlowPen, Pen, Text}; use glib::{GString, Object}; use gtk::{ glib::{self, Propagation}, prelude::*, subclass::prelude::*, EventControllerKey, }; use serde::{Deserialize, Serialize}; const FPS: u64 = 60; const PURPLE: (f64, f64, f64) = (0.7, 0., 1.); #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] #[serde(rename_all = "lowercase")] enum Position { Top, Middle, Bottom, } #[derive(Serialize, Deserialize, Debug, Clone)] struct Step { text: String, position: Position, transition: Duration, } #[derive(Serialize, Deserialize, Debug, Clone)] struct Script(Vec); impl Script { fn from_file(path: &Path) -> Result { let mut buf: Vec = Vec::new(); let mut f = File::open(path).unwrap(); f.read_to_end(&mut buf).unwrap(); let script = serde_yml::from_slice(&buf)?; Ok(Self(script)) } fn iter<'a>(&'a self) -> impl Iterator { self.0.iter() } fn len(&self) -> usize { self.0.len() } } impl Default for Script { fn default() -> Self { Self(vec![]) } } impl Index for Script { type Output = Step; fn index(&self, index: usize) -> &Self::Output { &self.0[index] } } struct Fade { text: String, position: Position, duration: Duration, start_time: Instant, } trait Animation { fn position(&self) -> Position; fn tick(&self, now: Instant, context: &Context, width: f64); } impl Animation for Fade { fn position(&self) -> Position { self.position.clone() } fn tick(&self, now: Instant, context: &Context, width: f64) { let total_frames = self.duration.as_secs() * FPS; let alpha_rate: f64 = 1. / total_frames as f64; let frames = (now - self.start_time).as_secs_f64() * FPS as f64; let alpha = alpha_rate * frames as f64; let text_display = Text::new(self.text.clone(), context, 64., width); let _ = context.move_to(0., text_display.extents().height()); let _ = context.set_source_rgba(PURPLE.0, PURPLE.1, PURPLE.2, alpha); text_display.draw(); } } struct CrossFade { old_text: String, new_text: String, position: Position, duration: Duration, start_time: Instant, } impl Animation for CrossFade { fn position(&self) -> Position { self.position.clone() } fn tick(&self, now: Instant, context: &Context, width: f64) { let total_frames = self.duration.as_secs() * FPS; let alpha_rate: f64 = 1. / total_frames as f64; let frames = (now - self.start_time).as_secs_f64() * FPS as f64; let alpha = alpha_rate * frames as f64; let text_display = Text::new(self.old_text.clone(), context, 64., width); let _ = context.move_to(0., text_display.extents().height()); let _ = context.set_source_rgba(PURPLE.0, PURPLE.1, PURPLE.2, 1. - alpha); text_display.draw(); let text_display = Text::new(self.new_text.clone(), context, 64., width); let _ = context.move_to(0., text_display.extents().height()); let _ = context.set_source_rgba(PURPLE.0, PURPLE.1, PURPLE.2, alpha); text_display.draw(); } } #[derive(Debug)] pub struct CyberScreenState { script: Script, idx: Option, top: Option, middle: Option, bottom: Option, } impl Default for CyberScreenState { fn default() -> Self { Self { script: Script(vec![]), idx: None, top: None, middle: None, bottom: None, } } } impl CyberScreenState { fn new(script: Script) -> CyberScreenState { let mut s = CyberScreenState::default(); s.script = script; s } fn next_page(&mut self) -> Box { let idx = match self.idx { None => 0, Some(idx) => { if idx < self.script.len() { idx + 1 } else { idx } } }; self.idx = Some(idx); let step = self.script[idx].clone(); let (old, new) = match step.position { Position::Top => { let old = self.top.replace(step.clone()); (old, step) } Position::Middle => { let old = self.middle.replace(step.clone()); (old, step) } Position::Bottom => { let old = self.bottom.replace(step.clone()); (old, step) } }; match old { Some(old) => Box::new(CrossFade { old_text: old.text.clone(), new_text: new.text.clone(), position: new.position, duration: new.transition, start_time: Instant::now(), }), None => Box::new(Fade { text: new.text.clone(), position: new.position, duration: new.transition, start_time: Instant::now(), }), } } } #[derive(Default)] pub struct CyberScreenPrivate { state: Rc>, // For crossfading to work, I have to detect that there is an old animation in a position, and // replace it with the new one. animations: Rc>>>, } #[glib::object_subclass] impl ObjectSubclass for CyberScreenPrivate { const NAME: &'static str = "CyberScreen"; type Type = CyberScreen; type ParentType = gtk::DrawingArea; } impl ObjectImpl for CyberScreenPrivate {} impl WidgetImpl for CyberScreenPrivate {} impl DrawingAreaImpl for CyberScreenPrivate {} impl CyberScreenPrivate { fn set_script(&self, script: Script) { *self.state.borrow_mut() = CyberScreenState::new(script); } fn next_page(&self) { let transition = self.state.borrow_mut().next_page(); self.animations .borrow_mut() .insert(transition.position(), transition); } } glib::wrapper! { pub struct CyberScreen(ObjectSubclass) @extends gtk::DrawingArea, gtk::Widget; } impl CyberScreen { fn new(script: Script) -> Self { let s: Self = Object::builder().build(); s.imp().set_script(script); s.set_draw_func({ let s = s.clone(); move |_, context, width, height| { let now = Instant::now(); let _ = context.set_source_rgb(0., 0., 0.); let _ = context.paint(); let pen = GlowPen::new(width, height, 2., 8., (0.7, 0., 1.)); AsymLineCutout { orientation: gtk::Orientation::Horizontal, start_x: 25., start_y: height as f64 / 7., start_length: width as f64 / 3., cutout_length: width as f64 / 3. - 100., height: 50., end_length: width as f64 / 3. - 50., invert: false, } .draw(&pen); pen.stroke(); AsymLine { orientation: gtk::Orientation::Horizontal, start_x: width as f64 / 4., start_y: height as f64 * 6. / 7., start_length: width as f64 * 2. / 3. - 25., height: 50., end_length: 0., invert: false, } .draw(&pen); pen.stroke(); let tracery = pen.finish(); let _ = context.set_source(tracery); let _ = context.paint(); let mut animations = s.imp().animations.borrow_mut(); let lr_margin = 50.; let max_width = width as f64 - lr_margin * 2.; let region_height = height as f64 / 5.; if let Some(animation) = animations.get(&Position::Top) { let y = height as f64 * 1. / 5.; let surface = context .target() .create_for_rectangle(Rectangle::new(20., y, max_width, region_height)) .unwrap(); let ctx = Context::new(&surface).unwrap(); animation.tick(now, &ctx, max_width); } if let Some(animation) = animations.get(&Position::Middle) { let y = height as f64 * 2. / 5.; let surface = context .target() .create_for_rectangle(Rectangle::new(20., y, max_width, region_height)) .unwrap(); let ctx = Context::new(&surface).unwrap(); animation.tick(now, &ctx, max_width); } if let Some(animation) = animations.get(&Position::Bottom) { let y = height as f64 * 3. / 5.; let surface = context .target() .create_for_rectangle(Rectangle::new(20., y, max_width, region_height)) .unwrap(); let ctx = Context::new(&surface).unwrap(); animation.tick(now, &ctx, max_width); } } }); s } fn next_page(&self) { self.imp().next_page(); self.queue_draw(); } } fn main() { let script = Arc::new(RwLock::new(Script::default())); let app = gtk::Application::builder() .application_id("com.luminescent-dreams.cyberpunk-slideshow") .build(); app.add_main_option( "script", glib::char::Char::from(b's'), glib::OptionFlags::IN_MAIN, glib::OptionArg::String, "", None, ); app.connect_handle_local_options({ let script = script.clone(); move |_, options| { if let Some(script_path) = options.lookup::("script").unwrap() { let mut script = script.write().unwrap(); *script = Script::from_file(Path::new(&script_path)).unwrap(); -1 } else { 1 } } }); app.connect_activate(move |app| { let screen = CyberScreen::new(script.read().unwrap().clone()); let events = EventControllerKey::new(); events.connect_key_released({ let screen = screen.clone(); move |_, key, _, _| { if key.name() == Some(GString::from("Right")) { screen.next_page(); } } }); let window = gtk::ApplicationWindow::new(app); window.add_controller(events); window.set_child(Some(&screen)); window.set_width_request(800); window.set_height_request(600); window.present(); let _ = glib::spawn_future_local({ let screen = screen.clone(); async move { loop { screen.queue_draw(); async_std::task::sleep(Duration::from_millis(1000 / FPS)).await; } } }); }); app.run(); }