use cairo::{ Context, FontSlant, FontWeight, Format, ImageSurface, LineCap, LinearGradient, Pattern, TextExtents, }; use glib::Object; use gtk::{prelude::*, subclass::prelude::*, EventControllerKey}; use std::{ cell::RefCell, rc::Rc, sync::{Arc, RwLock}, time::{Duration, Instant}, }; const WIDTH: i32 = 1600; const HEIGHT: i32 = 600; #[derive(Clone, Copy, Debug)] pub enum State { Running { last_update: Instant, deadline: Instant, timeout: Option, }, Paused { time_remaining: Duration, timeout: Option, }, } impl State { fn new(countdown: Duration) -> Self { Self::Paused { time_remaining: countdown, timeout: None, } } fn start(&mut self) { if let Self::Paused { time_remaining, timeout, } = self { *self = Self::Running { last_update: Instant::now(), deadline: Instant::now() + *time_remaining, timeout: *timeout, }; } } fn pause(&mut self) { if let Self::Running { deadline, timeout, .. } = self { *self = Self::Paused { time_remaining: *deadline - Instant::now(), timeout: *timeout, } } } fn start_pause(&mut self) { match self { Self::Running { .. } => self.pause(), Self::Paused { .. } => self.start(), } } fn run(&mut self, now: Instant) { if let Self::Running { last_update, deadline, timeout, } = self { *last_update = now; if let Some(ref mut timeout) = timeout { // TODO: figure out the actual number of frames timeout.tick(1); } if *last_update > *deadline && timeout.is_none() { *timeout = Some(TimeoutAnimation { intensity: 1., duration: 1., ascending: false, }); } } } } #[derive(Clone, Copy, Debug)] pub struct TimeoutAnimation { intensity: f64, duration: f64, ascending: bool, } impl TimeoutAnimation { fn tick(&mut self, frames_elapsed: u8) { let step_size = 1. / (self.duration * 60.); if self.ascending { self.intensity += step_size * frames_elapsed as f64; if self.intensity > 1. { self.intensity = 1.0; self.ascending = false; } } else { self.intensity -= step_size * frames_elapsed as f64; if self.intensity < 0. { self.intensity = 0.0; self.ascending = true; } } } } pub struct SplashPrivate { text: Rc>, background: Rc>, time_extents: Rc>>, width: Rc>, height: Rc>, state: Rc>, } impl SplashPrivate { fn set_text(&self, text: String) { *self.text.borrow_mut() = text; } fn set_state(&self, state: State) { *self.state.borrow_mut() = state; } fn redraw_background(&self) { let pen = GlowPen::new( *self.width.borrow(), *self.height.borrow(), 2., 8., (0.7, 0., 1.), ); let background = ImageSurface::create(Format::Rgb24, *self.width.borrow(), *self.height.borrow()) .unwrap(); let context = Context::new(background).unwrap(); context.push_group(); context.set_source_rgb(0., 0., 0.); let _ = context.paint(); context.select_font_face("Alegreya Sans SC", FontSlant::Normal, FontWeight::Bold); { context.set_source_rgb(0.7, 0., 1.); let hashtag = "#CodingTogether"; context.set_font_size(64.); let extents = context.text_extents(hashtag).unwrap(); context.move_to(20., extents.height() + 40.); let _ = context.show_text(hashtag); AsymLine { orientation: gtk::Orientation::Horizontal, start_x: 10., start_y: extents.height() + 10., start_length: 0., height: extents.height() / 2., total_length: extents.width() + extents.height() / 2., invert: false, } .draw(&pen); pen.stroke(); AsymLine { orientation: gtk::Orientation::Horizontal, start_x: 20., start_y: extents.height() + 60., start_length: extents.width(), height: extents.height() / 2., total_length: extents.width() + extents.height() / 2., invert: false, } .draw(&pen); pen.stroke(); } { context.set_font_size(128.); let center_x = *self.width.borrow() as f64 / 2.; let center_y = *self.height.borrow() as f64 / 2.; let title_extents = context.text_extents(&self.text.borrow()).unwrap(); let title_width = title_extents.width(); let title_height = title_extents.height(); { let start_length = center_x - title_width / 2. - title_height - 20.; let title_cutout = AsymLineCutout { orientation: gtk::Orientation::Horizontal, start_x: 20., start_y: center_y - 20. - title_height / 2., start_length, total_length: *self.width.borrow() as f64 - 120., cutout_length: title_width, height: title_height, invert: false, }; title_cutout.draw(&pen); pen.stroke(); } { let title_baseline_x = center_x - title_width / 2.; let title_baseline_y = center_y - 20.; let gradient = LinearGradient::new( title_baseline_x, title_baseline_y - title_height, title_baseline_x, title_baseline_y, ); gradient.add_color_stop_rgb(0.2, 0.7, 0.0, 1.0); gradient.add_color_stop_rgb(0.8, 0.2, 0.0, 1.0); context.move_to(title_baseline_x, title_baseline_y); let _ = context.set_source(gradient); let _ = context.show_text(&self.text.borrow()); } } { AsymLine { orientation: gtk::Orientation::Horizontal, start_x: 100., start_y: *self.height.borrow() as f64 / 2. + 100., start_length: 400., height: 50., total_length: 650., invert: true, } .draw(&pen); pen.stroke(); } { context.set_source_rgb(0.7, 0., 1.); AsymLine { orientation: gtk::Orientation::Horizontal, start_x: *self.width.borrow() as f64 / 2. + 100., start_y: *self.height.borrow() as f64 / 2. + 200., start_length: 600., height: 50., total_length: 650., invert: false, } .draw(&pen); pen.stroke(); } let tracery = pen.finish(); let _ = context.set_source(tracery); let _ = context.paint(); let background = context.pop_group().unwrap(); *self.background.borrow_mut() = background; } } #[glib::object_subclass] impl ObjectSubclass for SplashPrivate { const NAME: &'static str = "Splash"; type Type = Splash; type ParentType = gtk::DrawingArea; fn new() -> SplashPrivate { // Set up a default plain black background let background = ImageSurface::create(Format::Rgb24, WIDTH, HEIGHT).unwrap(); let context = Context::new(background).unwrap(); context.push_group(); context.set_source_rgb(0., 0., 0.); let _ = context.paint(); let background = context.pop_group().unwrap(); SplashPrivate { text: Rc::new(RefCell::new(String::from(""))), background: Rc::new(RefCell::new(background)), time_extents: Rc::new(RefCell::new(None)), width: Rc::new(RefCell::new(WIDTH)), height: Rc::new(RefCell::new(HEIGHT)), state: Rc::new(RefCell::new(State::new(Duration::ZERO))), } } } impl ObjectImpl for SplashPrivate {} impl WidgetImpl for SplashPrivate {} impl DrawingAreaImpl for SplashPrivate {} glib::wrapper! { pub struct Splash(ObjectSubclass) @extends gtk::DrawingArea, gtk::Widget; } impl Splash { pub fn new(text: String, state: State) -> Self { let s: Self = Object::builder().build(); s.set_width_request(WIDTH); s.set_height_request(HEIGHT); s.imp().set_text(text); s.imp().set_state(state); s.imp().redraw_background(); s.set_draw_func({ let s = s.clone(); move |_, context, width, height| { let background = s.imp().background.borrow(); let _ = context.set_source(&*background); let _ = context.paint(); let state = *s.imp().state.borrow(); let time = match state { State::Running { deadline, .. } => deadline - Instant::now(), State::Paused { time_remaining, .. } => time_remaining, }; let minutes = time.as_secs() / 60; let seconds = time.as_secs() % 60; let center_x = width as f64 / 2.; let center_y = height as f64 / 2.; { context.select_font_face( "Alegreya Sans SC", FontSlant::Normal, FontWeight::Bold, ); context.set_font_size(128.); let time = format!("{:02}' {:02}\"", minutes, seconds); let time_extents = context.text_extents(&time).unwrap(); let mut saved_extents = s.imp().time_extents.borrow_mut(); if saved_extents.is_none() { *saved_extents = Some(time_extents); } let time_baseline_x = center_x - time_extents.width() / 2.; let time_baseline_y = center_y + 100.; let gradient = LinearGradient::new( time_baseline_x, time_baseline_y - time_extents.height(), time_baseline_x, time_baseline_y, ); let (running, timeout_animation) = match state { State::Running { timeout, .. } => (true, timeout), State::Paused { timeout, .. } => (false, timeout), }; match timeout_animation { Some(ref animation) => { gradient.add_color_stop_rgba(0.2, 0.2, 0.0, 1.0, animation.intensity); gradient.add_color_stop_rgba(0.8, 0.7, 0.0, 1.0, animation.intensity); let _ = context.set_source(gradient); } None => { if running { gradient.add_color_stop_rgb(0.2, 0.2, 0.0, 1.0); gradient.add_color_stop_rgb(0.8, 0.7, 0.0, 1.0); let _ = context.set_source(gradient); } else { context.set_source_rgb(0.3, 0.3, 0.3); } } } context.move_to(time_baseline_x, time_baseline_y); let _ = context.show_text(&time); }; if let Some(extents) = *s.imp().time_extents.borrow() { context.set_source_rgb(0.7, 0.0, 1.0); let time_meter = SlashMeter { orientation: gtk::Orientation::Horizontal, start_x: center_x + extents.width() / 2. + 50., start_y: center_y + 100., count: 5, fill_count: minutes as u8, height: 60., length: 100., }; time_meter.draw(context); } } }); s.connect_resize(|s, width, height| { *s.imp().width.borrow_mut() = width; *s.imp().height.borrow_mut() = height; s.imp().redraw_background(); }); s } pub fn set_state(&self, state: State) { self.imp().set_state(state); self.queue_draw(); } } struct AsymLineCutout { orientation: gtk::Orientation, start_x: f64, start_y: f64, start_length: f64, total_length: f64, cutout_length: f64, height: f64, invert: bool, } impl AsymLineCutout { fn draw(&self, pen: &impl Pen) { let dodge = if self.invert { self.height } else { -self.height }; match self.orientation { gtk::Orientation::Horizontal => { pen.move_to(self.start_x, self.start_y); pen.line_to(self.start_x + self.start_length, self.start_y); pen.line_to( self.start_x + self.start_length + self.height, self.start_y + dodge, ); pen.line_to( self.start_x + self.start_length + self.height + self.cutout_length, self.start_y + dodge, ); pen.line_to( self.start_x + self.start_length + self.height + self.cutout_length + (self.height / 2.), self.start_y + dodge / 2., ); pen.line_to(self.total_length, self.start_y + dodge / 2.); } gtk::Orientation::Vertical => { pen.move_to(self.start_x, self.start_y); pen.line_to(self.start_x, self.start_y + self.start_length); pen.line_to( self.start_x + dodge, self.start_y + self.start_length + self.height, ); pen.line_to( self.start_x + dodge, self.start_y + self.start_length + self.height + self.cutout_length, ); pen.line_to( self.start_x + dodge / 2., self.start_y + self.start_length + self.height + self.cutout_length + (self.height / 2.), ); pen.line_to(self.start_x + dodge / 2., self.total_length); } _ => panic!("unknown orientation"), } } } struct AsymLine { orientation: gtk::Orientation, start_x: f64, start_y: f64, start_length: f64, height: f64, total_length: f64, invert: bool, } impl AsymLine { fn draw(&self, pen: &impl Pen) { let dodge = if self.invert { self.height } else { -self.height }; match self.orientation { gtk::Orientation::Horizontal => { pen.move_to(self.start_x, self.start_y); pen.line_to(self.start_x + self.start_length, self.start_y); pen.line_to( self.start_x + self.start_length + self.height, self.start_y + dodge, ); pen.line_to(self.start_x + self.total_length, self.start_y + dodge); } gtk::Orientation::Vertical => {} _ => panic!("unknown orientation"), } } } struct SlashMeter { orientation: gtk::Orientation, start_x: f64, start_y: f64, count: u8, fill_count: u8, height: f64, length: f64, } impl SlashMeter { fn draw(&self, context: &Context) { match self.orientation { gtk::Orientation::Horizontal => { let angle: f64 = 0.8; let run = self.height / angle.tan(); let width = self.length / (self.count as f64 * 2.); for c in 0..self.count { context.set_line_width(1.); let start_x = self.start_x + c as f64 * width * 2.; context.move_to(start_x, self.start_y); context.line_to(start_x + run, self.start_y - self.height); context.line_to(start_x + run + width, self.start_y - self.height); context.line_to(start_x + width, self.start_y); context.line_to(start_x, self.start_y); if c < self.fill_count { let _ = context.fill(); } else { let _ = context.stroke(); } } } gtk::Orientation::Vertical => {} _ => panic!("unknown orientation"), } } } trait Pen { fn move_to(&self, x: f64, y: f64); fn line_to(&self, x: f64, y: f64); fn stroke(&self); fn finish(self) -> Pattern; } struct GlowPen { blur_context: Context, draw_context: Context, } impl GlowPen { fn new( width: i32, height: i32, line_width: f64, blur_line_width: f64, color: (f64, f64, f64), ) -> Self { let blur_context = Context::new(ImageSurface::create(Format::Rgb24, width, height).unwrap()).unwrap(); blur_context.set_line_width(blur_line_width); blur_context.set_source_rgba(color.0, color.1, color.2, 0.5); blur_context.push_group(); blur_context.set_line_cap(LineCap::Round); let draw_context = Context::new(ImageSurface::create(Format::Rgb24, width, height).unwrap()).unwrap(); draw_context.set_line_width(line_width); draw_context.set_source_rgb(color.0, color.1, color.2); draw_context.push_group(); draw_context.set_line_cap(LineCap::Round); Self { blur_context, draw_context, } } } impl Pen for GlowPen { fn move_to(&self, x: f64, y: f64) { self.blur_context.move_to(x, y); self.draw_context.move_to(x, y); } fn line_to(&self, x: f64, y: f64) { self.blur_context.line_to(x, y); self.draw_context.line_to(x, y); } fn stroke(&self) { self.blur_context.stroke().expect("to draw the blur line"); self.draw_context .stroke() .expect("to draw the regular line"); } fn finish(self) -> Pattern { let foreground = self.draw_context.pop_group().unwrap(); self.blur_context.set_source(foreground).unwrap(); self.blur_context.paint().unwrap(); self.blur_context.pop_group().unwrap() } } fn main() { let app = gtk::Application::builder() .application_id("com.luminescent-dreams.cyberpunk-splash") .flags(gio::ApplicationFlags::HANDLES_OPEN) .build(); app.add_main_option( "title", glib::char::Char::from(b't'), glib::OptionFlags::IN_MAIN, glib::OptionArg::String, "", None, ); app.add_main_option( "countdown", glib::char::Char::from(b'c'), glib::OptionFlags::IN_MAIN, glib::OptionArg::String, "", None, ); let state = Arc::new(RwLock::new(State::new(Duration::from_secs(5 * 60)))); let title = Arc::new(RwLock::new("".to_owned())); app.connect_command_line(|_, _args| { println!("connect_command_line"); 1 }); app.connect_handle_local_options({ let title = title.clone(); let state = state.clone(); move |_, options| { println!("connect_handle_local_options"); *title.write().unwrap() = options.lookup::("title").unwrap().unwrap(); let countdown = match options.lookup::("countdown") { Ok(Some(countdown_str)) => { let parts = countdown_str.split(':').collect::>(); match parts.len() { 2 => { let minutes = parts[0].parse::().unwrap(); let seconds = parts[1].parse::().unwrap(); Duration::from_secs(minutes * 60 + seconds) } 1 => { let seconds = parts[1].parse::().unwrap(); Duration::from_secs(seconds) } _ => Duration::from_secs(300), } } _ => Duration::from_secs(300), }; match *state.write().unwrap() { State::Running { ref mut deadline, .. } => *deadline = Instant::now() + countdown, State::Paused { ref mut time_remaining, .. } => *time_remaining = countdown, } -1 } }); app.connect_open(move |app, files, args| { println!("called open"); println!("files: {}", files.len()); println!("args: {}", args); app.activate(); }); app.connect_activate(move |app| { let (gtk_tx, gtk_rx) = gtk::glib::MainContext::channel::(gtk::glib::Priority::DEFAULT); let window = gtk::ApplicationWindow::new(app); window.present(); let splash = Splash::new(title.read().unwrap().clone(), *state.read().unwrap()); window.set_child(Some(&splash)); window.connect_maximized_notify(|window| { window.fullscreen(); }); let keyboard_events = EventControllerKey::new(); keyboard_events.connect_key_released({ let window = window.clone(); let state = state.clone(); move |_, key, _, _| { let name = key .name() .map(|s| s.as_str().to_owned()) .unwrap_or("".to_owned()); match name.as_ref() { "Escape" => window.unfullscreen(), "space" => state.write().unwrap().start_pause(), _ => {} } } }); window.add_controller(keyboard_events); gtk_rx.attach(None, move |state| { splash.set_state(state); glib::ControlFlow::Continue }); std::thread::spawn({ let state = state.clone(); move || { state.write().unwrap().start(); loop { std::thread::sleep(Duration::from_millis(1000 / 60)); state.write().unwrap().run(Instant::now()); let _ = gtk_tx.send(*state.read().unwrap()); } } }); }); app.run(); }