551 lines
17 KiB
Rust
551 lines
17 KiB
Rust
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<TimeoutAnimation>,
|
|
},
|
|
Paused {
|
|
time_remaining: Duration,
|
|
timeout: Option<TimeoutAnimation>,
|
|
},
|
|
}
|
|
|
|
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<RefCell<String>>,
|
|
background: Rc<RefCell<Pattern>>,
|
|
time_extents: Rc<RefCell<Option<TextExtents>>>,
|
|
width: Rc<RefCell<i32>>,
|
|
height: Rc<RefCell<i32>>,
|
|
|
|
state: Rc<RefCell<State>>,
|
|
}
|
|
|
|
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<SplashPrivate>) @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();
|
|
}
|
|
}
|
|
|
|
|
|
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::<String>("title").unwrap().unwrap();
|
|
let countdown = match options.lookup::<String>("countdown") {
|
|
Ok(Some(countdown_str)) => {
|
|
let parts = countdown_str.split(':').collect::<Vec<&str>>();
|
|
match parts.len() {
|
|
2 => {
|
|
let minutes = parts[0].parse::<u64>().unwrap();
|
|
let seconds = parts[1].parse::<u64>().unwrap();
|
|
Duration::from_secs(minutes * 60 + seconds)
|
|
}
|
|
1 => {
|
|
let seconds = parts[1].parse::<u64>().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::<State>(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();
|
|
}
|