417 lines
12 KiB
Rust
417 lines
12 KiB
Rust
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<Step>);
|
|
|
|
impl Script {
|
|
fn from_file(path: &Path) -> Result<Script, serde_yml::Error> {
|
|
let mut buf: Vec<u8> = 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<Item = &'a Step> {
|
|
self.0.iter()
|
|
}
|
|
|
|
fn len(&self) -> usize {
|
|
self.0.len()
|
|
}
|
|
}
|
|
|
|
impl Default for Script {
|
|
fn default() -> Self {
|
|
Self(vec![])
|
|
}
|
|
}
|
|
|
|
impl Index<usize> 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<usize>,
|
|
top: Option<Step>,
|
|
middle: Option<Step>,
|
|
bottom: Option<Step>,
|
|
}
|
|
|
|
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<dyn Animation> {
|
|
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<RefCell<CyberScreenState>>,
|
|
// 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<RefCell<HashMap<Position, Box<dyn Animation>>>>,
|
|
}
|
|
|
|
#[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<CyberScreenPrivate>) @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::<String>("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 window = gtk::ApplicationWindow::new(app);
|
|
let screen = CyberScreen::new(script.read().unwrap().clone());
|
|
|
|
let events = EventControllerKey::new();
|
|
|
|
events.connect_key_released({
|
|
let app = app.clone();
|
|
let window = window.clone();
|
|
let screen = screen.clone();
|
|
move |_, key, _, _| {
|
|
let name = key
|
|
.name()
|
|
.map(|s| s.as_str().to_owned())
|
|
.unwrap_or("".to_owned());
|
|
match name.as_ref() {
|
|
"Right" => screen.next_page(),
|
|
"q" => app.quit(),
|
|
"Escape" => window.unfullscreen(),
|
|
_ => {}
|
|
}
|
|
}
|
|
});
|
|
|
|
window.add_controller(events);
|
|
|
|
window.set_child(Some(&screen));
|
|
window.set_width_request(800);
|
|
window.set_height_request(600);
|
|
window.present();
|
|
|
|
window.connect_maximized_notify(|window| {
|
|
window.fullscreen();
|
|
});
|
|
|
|
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();
|
|
}
|