Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
817 files changed
+143101
No files matched your search
@@ -0,0 +1,99 @@
|
||||
//! The audience screen: the slide alone, full screen.
|
||||
|
||||
use ely_gpui_component::{
|
||||
feedback::InlineMessage,
|
||||
primitives::{FocusScope, Severity},
|
||||
};
|
||||
use gpui::{
|
||||
Context, Entity, FocusHandle, InteractiveElement as _, IntoElement, MouseButton,
|
||||
ParentElement as _, Render, Styled as _, Window, div,
|
||||
};
|
||||
|
||||
use super::{Show, stage};
|
||||
|
||||
pub struct Audience {
|
||||
pub show: Entity<Show>,
|
||||
pub focus: FocusHandle,
|
||||
}
|
||||
|
||||
impl Audience {
|
||||
pub fn new(show: Entity<Show>, cx: &mut Context<Self>) -> Self {
|
||||
cx.observe(&show, |_, _, cx| cx.notify()).detach();
|
||||
Audience {
|
||||
show,
|
||||
focus: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Audience {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if self.show.update(cx, |show, cx| show.media_tick(cx)) {
|
||||
window.request_animation_frame();
|
||||
}
|
||||
self.show.update(cx, |show, _| show.free(0, window));
|
||||
let show = self.show.read(cx);
|
||||
let room = window.viewport_size();
|
||||
let size = stage::fit(show.slide, (room.width, room.height));
|
||||
let origin = ((room.width - size.0) / 2.0, (room.height - size.1) / 2.0);
|
||||
if show.moving() {
|
||||
window.request_animation_frame();
|
||||
}
|
||||
let shown = stage::stage(show, size, cx);
|
||||
// Alone, the audience screen says what failed.
|
||||
let notice = (show.notice.clone())
|
||||
.filter(|_| !show.presenter)
|
||||
.map(|why| {
|
||||
div()
|
||||
.absolute()
|
||||
.bottom_4()
|
||||
.left_4()
|
||||
.child(InlineMessage::new(Severity::Danger, why))
|
||||
});
|
||||
let (keys, click, back, moved) = (
|
||||
self.show.clone(),
|
||||
self.show.clone(),
|
||||
self.show.clone(),
|
||||
self.show.clone(),
|
||||
);
|
||||
// Keys reach ancestors of the focused scope.
|
||||
div()
|
||||
.size_full()
|
||||
.on_key_down(move |event, _, cx| {
|
||||
if keys.update(cx, |show, cx| show.key(&event.keystroke, cx)) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
.child(
|
||||
FocusScope::new(&self.focus).root().size_full().child(
|
||||
div()
|
||||
.id("show-audience")
|
||||
.relative()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.bg(gpui::black())
|
||||
.on_mouse_down(MouseButton::Left, move |event, _, cx| {
|
||||
let x = (event.position.x - origin.0) / size.0;
|
||||
let y = (event.position.y - origin.1) / size.1;
|
||||
click.update(cx, |show, cx| match show.media_at((x, y)) {
|
||||
Some(at) => show.toggle_media(at, cx),
|
||||
None => show.next(cx),
|
||||
})
|
||||
})
|
||||
.on_mouse_down(MouseButton::Right, move |_, _, cx| {
|
||||
back.update(cx, |show, cx| show.previous(cx))
|
||||
})
|
||||
.on_mouse_move(move |event, _, cx| {
|
||||
let x = (event.position.x - origin.0) / size.0;
|
||||
let y = (event.position.y - origin.1) / size.1;
|
||||
let inside = (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y);
|
||||
moved.update(cx, |show, cx| show.point(inside.then_some((x, y)), cx))
|
||||
})
|
||||
.child(shown)
|
||||
.children(notice),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! The Slide Show menu and a running show's keys.
|
||||
|
||||
use ely_ui::commands;
|
||||
use gpui::{
|
||||
App, Context, Div, InteractiveElement as _, KeyBinding, Keystroke, Menu, MenuItem, actions,
|
||||
};
|
||||
|
||||
use super::Show;
|
||||
use crate::deck::Deck;
|
||||
|
||||
actions!(show, [StartShow, StartShowHere, CheckShow]);
|
||||
|
||||
/// Slide numbers past this many digits are not typed.
|
||||
const DIGITS: usize = 6;
|
||||
|
||||
/// The Slide Show menu.
|
||||
pub fn menu(cx: &App) -> Menu {
|
||||
let label = |key: &str| Deck::text(cx, key, &[]);
|
||||
Menu::new(label("menu.play")).items([
|
||||
MenuItem::action(label("show.play.from_start"), StartShow),
|
||||
MenuItem::action(label("show.play.from_here"), StartShowHere),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action(label("show.check.menu"), CheckShow),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn bind_keys(cx: &mut App) {
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("f5", StartShow, Some(commands::CONTEXT)),
|
||||
KeyBinding::new("shift-f5", StartShowHere, Some(commands::CONTEXT)),
|
||||
]);
|
||||
}
|
||||
|
||||
pub fn bind(root: Div, cx: &mut Context<Deck>) -> Div {
|
||||
root.on_action(cx.listener(|deck, _: &StartShow, _, cx| deck.start_show(false, cx)))
|
||||
.on_action(cx.listener(|deck, _: &StartShowHere, _, cx| deck.start_show(true, cx)))
|
||||
.on_action(cx.listener(|deck, _: &CheckShow, _, cx| deck.check_show(cx)))
|
||||
}
|
||||
|
||||
impl Show {
|
||||
/// Show keys; true when taken.
|
||||
pub fn key(&mut self, keystroke: &Keystroke, cx: &mut Context<Self>) -> bool {
|
||||
let held = &keystroke.modifiers;
|
||||
if held.platform || held.control || held.alt {
|
||||
return false;
|
||||
}
|
||||
match keystroke.key.as_str() {
|
||||
"right" | "down" | "space" | "pagedown" | "n" => self.next(cx),
|
||||
"enter" if self.typed.is_empty() => self.next(cx),
|
||||
"enter" => {
|
||||
let number = self.typed.parse().expect("typed digits only");
|
||||
self.jump(number, cx);
|
||||
}
|
||||
"left" | "up" | "pageup" | "backspace" | "p" => self.previous(cx),
|
||||
"home" => self.jump(1, cx),
|
||||
"end" => {
|
||||
let last = (0..self.slides.len()).rev().find(|at| !self.slides[*at].hidden);
|
||||
self.jump(last.map_or(0, |at| at + 1), cx);
|
||||
}
|
||||
"b" | "." => self.toggle_black(cx),
|
||||
"l" => self.toggle_laser(cx),
|
||||
"escape" if !self.typed.is_empty() => {
|
||||
self.typed.clear();
|
||||
cx.notify();
|
||||
}
|
||||
"escape" => self.close(cx),
|
||||
digit if digit.len() == 1 && digit.chars().all(|c| c.is_ascii_digit()) => {
|
||||
if self.typed.len() < DIGITS {
|
||||
self.typed.push_str(digit);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! The shown and next slides' layers, from the copy.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use ely_office::prelude::*;
|
||||
use gpui::Context;
|
||||
|
||||
use super::{Show, Stack};
|
||||
use crate::preview::pictures;
|
||||
|
||||
/// Reads at most this many slides at once.
|
||||
const AT_ONCE: usize = 2;
|
||||
|
||||
impl Show {
|
||||
pub fn stack(&self, part: usize) -> Option<&Stack> {
|
||||
self.layers.get(&part)
|
||||
}
|
||||
|
||||
fn wanted(&self) -> Vec<usize> {
|
||||
self.part().into_iter().chain(self.upcoming()).collect()
|
||||
}
|
||||
|
||||
/// Reads wanted layers, drops the rest.
|
||||
pub(super) fn fetch(&mut self, cx: &mut Context<Self>) {
|
||||
let wanted = self.wanted();
|
||||
let gone = &mut self.gone;
|
||||
self.layers.retain(|part, stack| {
|
||||
let kept = wanted.contains(part);
|
||||
if !kept {
|
||||
gone.push(stack.clone());
|
||||
}
|
||||
kept
|
||||
});
|
||||
for part in wanted {
|
||||
if self.layers.contains_key(&part)
|
||||
|| self.asking.contains(&part)
|
||||
|| self.asking.len() >= AT_ONCE
|
||||
{
|
||||
continue;
|
||||
}
|
||||
self.asking.insert(part);
|
||||
log::info!("show: reads the layers of slide {part}");
|
||||
let pixels = self.pixels;
|
||||
ely_lok::read_off(
|
||||
&self.worker,
|
||||
&self.doc,
|
||||
move |worker, doc| doc.slide_layers(worker, part, pixels),
|
||||
move |show: &mut Self, read, cx| {
|
||||
show.asking.remove(&part);
|
||||
match read {
|
||||
Ok(layers) if show.wanted().contains(&part) => {
|
||||
if let Some(old) = show.layers.insert(part, Arc::new(pictures(layers))) {
|
||||
show.gone.push(old);
|
||||
}
|
||||
if show.part() == Some(part) {
|
||||
show.arrived(cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
Ok(_) => log::info!("show: slide {part} is no longer wanted"),
|
||||
Err(error) => return show.fail(&format!("the layers of slide {part}: {error}"), cx),
|
||||
}
|
||||
show.fetch(cx);
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends the show and says why in the deck.
|
||||
pub(super) fn fail(&mut self, why: &str, cx: &mut Context<Self>) {
|
||||
log::error!("show: {why}");
|
||||
let (deck, why) = (self.deck.clone(), why.to_string());
|
||||
self.close(cx);
|
||||
cx.defer(move |cx| {
|
||||
if let Some(deck) = deck.upgrade() {
|
||||
deck.update(cx, |deck, cx| deck.engine_failed(&why, cx));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Show {
|
||||
/// Frees, in window `which`, layers nothing draws any more.
|
||||
pub(super) fn free(&mut self, which: usize, window: &mut gpui::Window) {
|
||||
// A stack the leaving slide still holds waits.
|
||||
let (free, held): (Vec<_>, Vec<_>) = self.gone.drain(..).partition(|stack| Arc::strong_count(stack) == 1);
|
||||
self.gone = held;
|
||||
for stack in free {
|
||||
for (_, image) in stack.iter() {
|
||||
self.retired[0].push(image.clone());
|
||||
if self.presenter {
|
||||
self.retired[1].push(image.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
self.retired[which].release_all(window);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Audio and video on the shown slide, by a click.
|
||||
|
||||
use ely_platform::media::Player;
|
||||
use gpui::{Context, SurfaceSource};
|
||||
|
||||
use super::Show;
|
||||
use crate::deck::Deck;
|
||||
|
||||
/// The media object playing in the show.
|
||||
pub struct Playing {
|
||||
part: usize,
|
||||
index: usize,
|
||||
player: Player,
|
||||
pub frame: Option<SurfaceSource>,
|
||||
}
|
||||
|
||||
impl Show {
|
||||
/// The shown slide's media under a point, as shares.
|
||||
pub fn media_at(&self, (x, y): (f32, f32)) -> Option<usize> {
|
||||
let part = self.part()?;
|
||||
let (w, h) = self.slide;
|
||||
self.slides[part].media.iter().position(|media| {
|
||||
let (left, top, width, height) = media.bounds;
|
||||
let (x, y) = (x * w, y * h);
|
||||
(left as f32..(left + width) as f32).contains(&x)
|
||||
&& (top as f32..(top + height) as f32).contains(&y)
|
||||
})
|
||||
}
|
||||
|
||||
/// Stops the media and tells the presenter why.
|
||||
fn media_failed(&mut self, reason: &str, cx: &mut Context<Self>) {
|
||||
log::warn!("show: media failed: {reason}");
|
||||
self.media = None;
|
||||
self.notice = Some(Deck::text(cx, "show.media.failed", &[("reason", reason)]));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Plays, pauses or replays media `at` on the shown slide.
|
||||
pub fn toggle_media(&mut self, at: usize, cx: &mut Context<Self>) {
|
||||
let Some(part) = self.part() else {
|
||||
return;
|
||||
};
|
||||
if let Some(playing) = self.media.as_ref().filter(|p| (p.part, p.index) == (part, at)) {
|
||||
match (playing.player.playing(), playing.player.ended()) {
|
||||
(true, _) => playing.player.pause(),
|
||||
(false, true) => {
|
||||
playing.player.seek(0.0);
|
||||
playing.player.play();
|
||||
}
|
||||
(false, false) => playing.player.play(),
|
||||
}
|
||||
return cx.notify();
|
||||
}
|
||||
let media = &self.slides[part].media[at];
|
||||
let opened = ely_lok::file_path(&media.file)
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|path| Player::open(&path).map_err(|error| error.to_string()));
|
||||
match opened {
|
||||
Ok(player) => {
|
||||
log::info!("show: plays media {at} of slide {part}");
|
||||
player.play();
|
||||
self.media = Some(Playing {
|
||||
part,
|
||||
index: at,
|
||||
player,
|
||||
frame: None,
|
||||
});
|
||||
self.notice = None;
|
||||
cx.notify();
|
||||
}
|
||||
Err(error) => self.media_failed(&error, cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the newest frame; true while frames may change.
|
||||
pub fn media_tick(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let Some(playing) = self.media.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let repeat = self.slides[playing.part].media[playing.index].repeat;
|
||||
let ticked = playing.player.tick(repeat);
|
||||
if let Some(frame) = playing.player.frame() {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
playing.frame = Some(frame.into());
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
match frame {}
|
||||
}
|
||||
self.media_seen(ticked, cx)
|
||||
}
|
||||
|
||||
/// Draws on, or stops and says why.
|
||||
pub fn media_seen(&mut self, ticked: Result<bool, String>, cx: &mut Context<Self>) -> bool {
|
||||
ticked.unwrap_or_else(|reason| {
|
||||
self.media_failed(&reason, cx);
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
/// The playing video's frame and its bounds as shares.
|
||||
pub fn media_frame(&self) -> Option<(SurfaceSource, (f32, f32, f32, f32))> {
|
||||
let playing = self.media.as_ref()?;
|
||||
let (left, top, width, height) = self.slides[playing.part].media[playing.index].bounds;
|
||||
let (w, h) = self.slide;
|
||||
let share = (left as f32 / w, top as f32 / h, width as f32 / w, height as f32 / h);
|
||||
Some((playing.frame.clone()?, share))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! The presenter's screen: slide, next slide, notes, time.
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ToggleButton, ToggleItem},
|
||||
feedback::InlineMessage,
|
||||
primitives::Severity,
|
||||
primitives::FocusScope,
|
||||
theme::{ActiveTheme as _, ControlSize, TextSize},
|
||||
};
|
||||
use std::{cell::Cell, rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Context, Entity, FocusHandle, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Pixels, Render, StatefulInteractiveElement as _, Styled as _, Task, Window,
|
||||
canvas, div,
|
||||
};
|
||||
|
||||
use super::{Show, stage};
|
||||
use crate::deck::Deck;
|
||||
|
||||
pub struct Presenter {
|
||||
pub show: Entity<Show>,
|
||||
pub focus: FocusHandle,
|
||||
/// Where the current slide was last drawn.
|
||||
shown_at: Rc<Cell<Bounds<Pixels>>>,
|
||||
_tick: Task<()>,
|
||||
}
|
||||
|
||||
impl Presenter {
|
||||
pub fn new(show: Entity<Show>, cx: &mut Context<Self>) -> Self {
|
||||
cx.observe(&show, |_, _, cx| cx.notify()).detach();
|
||||
// The clock moves once a second.
|
||||
let tick = cx.spawn(async |this, cx| {
|
||||
loop {
|
||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||
if this.update(cx, |_, cx| cx.notify()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
Presenter {
|
||||
show,
|
||||
focus: cx.focus_handle(),
|
||||
shown_at: Rc::default(),
|
||||
_tick: tick,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `m:ss`, or `h:mm:ss` past an hour.
|
||||
pub fn clock(seconds: u64) -> String {
|
||||
let (h, m, s) = (seconds / 3600, seconds / 60 % 60, seconds % 60);
|
||||
match h {
|
||||
0 => format!("{m}:{s:02}"),
|
||||
_ => format!("{h}:{m:02}:{s:02}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn button(
|
||||
show: &Entity<Show>,
|
||||
(id, label): (&'static str, gpui::SharedString),
|
||||
act: fn(&mut Show, &mut Context<Show>),
|
||||
) -> Button {
|
||||
let show = show.clone();
|
||||
Button::new(id, label)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| show.update(cx, act))
|
||||
}
|
||||
|
||||
fn toggle(
|
||||
show: &Entity<Show>,
|
||||
(id, label, on): (&'static str, gpui::SharedString, bool),
|
||||
act: fn(&mut Show, &mut Context<Show>),
|
||||
) -> ToggleButton {
|
||||
let show = show.clone();
|
||||
ToggleButton::new(id, ToggleItem::new(id).label(label), on)
|
||||
.size(ControlSize::Sm)
|
||||
.on_toggle(move |_, _, cx| show.update(cx, act))
|
||||
}
|
||||
|
||||
impl Presenter {
|
||||
fn bar(&self, cx: &App) -> impl IntoElement {
|
||||
let show = self.show.read(cx);
|
||||
let label = |key: &str| Deck::text(cx, key, &[]);
|
||||
let place = match (show.part(), show.typed.is_empty()) {
|
||||
(_, false) => Deck::text(cx, "show.play.going", &[("number", &show.typed)]),
|
||||
(Some(part), true) => Deck::text(
|
||||
cx,
|
||||
"show.play.place",
|
||||
&[
|
||||
("number", &(part + 1).to_string()),
|
||||
("count", &show.slides.len().to_string()),
|
||||
],
|
||||
),
|
||||
(None, true) => label("show.play.ended"),
|
||||
};
|
||||
let place = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(place)
|
||||
.children(show.notice.clone().map(|why| InlineMessage::new(Severity::Danger, why)));
|
||||
let now = jiff::Zoned::now().strftime("%H:%M").to_string();
|
||||
let row = || div().flex().items_center().gap_2();
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_4()
|
||||
.child(
|
||||
row()
|
||||
.child(button(
|
||||
&self.show,
|
||||
("show-play-back", label("show.play.back")),
|
||||
Show::previous,
|
||||
))
|
||||
.child(button(
|
||||
&self.show,
|
||||
("show-play-next", label("show.play.next")),
|
||||
Show::next,
|
||||
))
|
||||
.child(place),
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.child(clock(show.began.elapsed().as_secs()))
|
||||
.child(div().text_color(cx.theme().palette().fg_muted).child(now)),
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.child(toggle(
|
||||
&self.show,
|
||||
("show-play-black", label("show.play.black"), show.black),
|
||||
Show::toggle_black,
|
||||
))
|
||||
.child(toggle(
|
||||
&self.show,
|
||||
("show-play-laser", label("show.play.laser"), show.laser),
|
||||
Show::toggle_laser,
|
||||
))
|
||||
.child(button(
|
||||
&self.show,
|
||||
("show-play-end", label("show.play.end")),
|
||||
Show::close,
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Presenter {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
self.show.update(cx, |show, _| show.free(1, window));
|
||||
let show = self.show.read(cx);
|
||||
let room = window.viewport_size();
|
||||
if show.moving() {
|
||||
window.request_animation_frame();
|
||||
}
|
||||
let theme = cx.theme();
|
||||
let big = stage::fit(show.slide, (room.width * 0.6, room.height * 0.8));
|
||||
let small = stage::fit(show.slide, (room.width * 0.3, room.height * 0.35));
|
||||
let next = match show.upcoming() {
|
||||
Some(part) => stage::still(show, part, small, cx),
|
||||
None => div().w(small.0).h(small.1).into_any_element(),
|
||||
};
|
||||
let notes = show.part().map(|part| show.slides[part].notes.clone());
|
||||
let (moved, keys) = (self.show.clone(), self.show.clone());
|
||||
let (seen, store) = (self.shown_at.clone(), self.shown_at.clone());
|
||||
let current = div()
|
||||
.id("show-presenter-slide")
|
||||
.relative()
|
||||
.on_mouse_move(move |event, _, cx| {
|
||||
let (at, bounds) = (event.position, seen.get());
|
||||
let x = (at.x - bounds.origin.x) / bounds.size.width;
|
||||
let y = (at.y - bounds.origin.y) / bounds.size.height;
|
||||
let inside = (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y);
|
||||
moved.update(cx, |show, cx| show.point(inside.then_some((x, y)), cx))
|
||||
})
|
||||
.child(stage::stage(show, big, cx))
|
||||
.child(
|
||||
canvas(move |bounds, _, _| store.set(bounds), |_, _, _, _| {})
|
||||
.absolute()
|
||||
.size_full(),
|
||||
);
|
||||
let side = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.child(next)
|
||||
.child(
|
||||
div()
|
||||
.id("show-notes")
|
||||
.flex_1()
|
||||
.overflow_y_scroll()
|
||||
.text_size(theme.text_size(TextSize::Lg))
|
||||
.children(notes),
|
||||
);
|
||||
// Keys reach ancestors of the focused scope.
|
||||
div()
|
||||
.size_full()
|
||||
.on_key_down(move |event, _, cx| {
|
||||
if keys.update(cx, |show, cx| show.key(&event.keystroke, cx)) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
.child(
|
||||
FocusScope::new(&self.focus)
|
||||
.root()
|
||||
.size_full()
|
||||
.bg(theme.palette().surface)
|
||||
.text_color(theme.palette().fg)
|
||||
.font_family(theme.font_family.clone())
|
||||
.child(
|
||||
div()
|
||||
.id("show-presenter")
|
||||
.size_full()
|
||||
.p_6()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.child(self.bar(cx))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_6()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.child(current)
|
||||
.child(side),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! The shown slide at a size: transition, effects, black, laser.
|
||||
|
||||
use ely_gpui_component::theme::ActiveTheme as _;
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement as _, Pixels, Styled as _, div, px};
|
||||
|
||||
use super::{Show, steps};
|
||||
use crate::preview::{compose, transition};
|
||||
|
||||
/// The laser dot's width as a share of the slide.
|
||||
const DOT: f32 = 0.012;
|
||||
|
||||
/// The largest slide size within `room`.
|
||||
pub fn fit(slide: (f32, f32), room: (Pixels, Pixels)) -> (Pixels, Pixels) {
|
||||
let scale = (f32::from(room.0) / slide.0).min(f32::from(room.1) / slide.1);
|
||||
(px(slide.0 * scale), px(slide.1 * scale))
|
||||
}
|
||||
|
||||
fn black((w, h): (Pixels, Pixels)) -> AnyElement {
|
||||
div().w(w).h(h).bg(gpui::black()).into_any_element()
|
||||
}
|
||||
|
||||
/// The show's slide now, as the audience sees it.
|
||||
pub fn stage(show: &Show, (w, h): (Pixels, Pixels), cx: &App) -> AnyElement {
|
||||
let Some(part) = show.part().filter(|_| !show.black) else {
|
||||
return black((w, h));
|
||||
};
|
||||
let Some(stack) = show.stack(part) else {
|
||||
return black((w, h));
|
||||
};
|
||||
let paper = ely_lok::paper(cx);
|
||||
let t = show.entered.elapsed().as_secs_f64();
|
||||
let slide = &show.slides[part];
|
||||
let shown = match &show.leaving {
|
||||
Some(leaving) if t < slide.transition.duration => {
|
||||
let p = (t / slide.transition.duration) as f32;
|
||||
let frame = transition::frame(&slide.transition.preset, p);
|
||||
div()
|
||||
.bg(gpui::black())
|
||||
.size_full()
|
||||
.child(compose::transition((Some(compose::left((&leaving.stack, &leaving.runs), (show.slide, leaving.at), (w, h), paper)), stack), &frame, (w, h), paper))
|
||||
.into_any_element()
|
||||
}
|
||||
_ => {
|
||||
let runs = steps::runs(&slide.effects, &show.clicks);
|
||||
div()
|
||||
.bg(paper)
|
||||
.size_full()
|
||||
.child(compose::effects(stack, &runs, show.slide, t, (w, h)))
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
let dot = (show.pointer)
|
||||
.filter(|_| show.laser)
|
||||
.map(|(x, y)| {
|
||||
let side = w * DOT;
|
||||
div()
|
||||
.absolute()
|
||||
.left(w * x - side / 2.0)
|
||||
.top(h * y - side / 2.0)
|
||||
.size(side)
|
||||
.rounded_full()
|
||||
.bg(cx.theme().palette().danger)
|
||||
});
|
||||
let video = show.media_frame().map(|(frame, (x, y, vw, vh))| {
|
||||
crate::media::video(frame, (gpui::point(w * x, h * y), gpui::point(w * vw, h * vh)))
|
||||
});
|
||||
div()
|
||||
.relative()
|
||||
.w(w)
|
||||
.h(h)
|
||||
.overflow_hidden()
|
||||
.child(shown)
|
||||
.children(video)
|
||||
.children(dot)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// A slide as it first shows, still.
|
||||
pub fn still(show: &Show, part: usize, size: (Pixels, Pixels), cx: &App) -> AnyElement {
|
||||
match show.stack(part) {
|
||||
Some(stack) => compose::slide(stack, |visible| visible, size, ely_lok::paper(cx))
|
||||
.into_any_element(),
|
||||
None => black(size),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Starting a show: a copy read, screens picked, windows open.
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::PathBuf,
|
||||
rc::Rc,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
data_display::DescriptionList,
|
||||
forms::{Choice, Select, Switch},
|
||||
overlays::Dialog as Card,
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_lok::{Doc, Worker, show::ShowSlide};
|
||||
use ely_office::prelude::*;
|
||||
use ely_platform::AppId;
|
||||
use gpui::{
|
||||
AnyElement, AnyWindowHandle, App, AppContext as _, Context, Entity, EntityId, IntoElement,
|
||||
ParentElement as _, PlatformDisplay, Window, WindowBounds, WindowOptions,
|
||||
};
|
||||
|
||||
use super::{Show, audience::Audience, presenter::Presenter};
|
||||
use crate::deck::Deck;
|
||||
use crate::dialog::{Dialog, edit};
|
||||
|
||||
/// Layer pixels never pass this width.
|
||||
const WIDEST: f32 = 1920.0;
|
||||
|
||||
/// A show waiting for its screens.
|
||||
pub struct Starting {
|
||||
tab: u64,
|
||||
slides: Vec<ShowSlide>,
|
||||
first: usize,
|
||||
/// The copy the show plays, and its file.
|
||||
doc: Doc,
|
||||
copy: PathBuf,
|
||||
/// The audience's screen, by place in the list.
|
||||
audience: usize,
|
||||
presenter: bool,
|
||||
}
|
||||
|
||||
/// Closes and deletes the show's copy, off the window.
|
||||
pub fn discard(worker: Arc<Mutex<Worker>>, doc: Doc, copy: PathBuf, cx: &App) {
|
||||
cx.background_spawn(async move {
|
||||
if let Err(error) = worker.lock().expect("the worker lock").close_doc(doc) {
|
||||
log::error!("show: the copy did not close: {error}");
|
||||
}
|
||||
if let Err(error) = std::fs::remove_file(©) {
|
||||
log::warn!("show: {} stays: {error}", copy.display());
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Saves `doc` as the show's copy and reads it.
|
||||
fn copy(
|
||||
worker: &mut Worker,
|
||||
doc: &Doc,
|
||||
(copy, scheme): (&std::path::Path, Option<&'static str>),
|
||||
) -> io::Result<(usize, Doc, Vec<ShowSlide>)> {
|
||||
let current = doc.sheets(worker)?.current;
|
||||
let url = ely_lok::file_url(copy);
|
||||
doc.save_as(worker, &url, "odp")?;
|
||||
let shown = worker.load(&url)?;
|
||||
let read = |worker: &mut Worker| -> io::Result<Vec<ShowSlide>> {
|
||||
if let Some(scheme) = scheme {
|
||||
worker.run(&shown, "view.theme", &[scheme])?;
|
||||
}
|
||||
ely_lok::show::slide_show(&worker.values(&shown, ely_lok::show::SLIDE_SHOW)?)
|
||||
};
|
||||
match read(worker) {
|
||||
Ok(slides) => Ok((current, shown, slides)),
|
||||
Err(error) => {
|
||||
worker.close_doc(shown)?;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn options(display: &Rc<dyn PlatformDisplay>) -> WindowOptions {
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Fullscreen(display.bounds())),
|
||||
display_id: Some(display.id()),
|
||||
titlebar: None,
|
||||
focus: true,
|
||||
app_id: Some(AppId::Show.slug().into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens one show window; its root scope holds the keys.
|
||||
pub fn show_window(
|
||||
show: &Entity<Show>,
|
||||
display: &Rc<dyn PlatformDisplay>,
|
||||
presenter: bool,
|
||||
cx: &mut App,
|
||||
) -> gpui::Result<AnyWindowHandle> {
|
||||
let show = show.clone();
|
||||
Ok(match presenter {
|
||||
true => cx
|
||||
.open_window(options(display), |window, cx| {
|
||||
let view = cx.new(|cx| Presenter::new(show, cx));
|
||||
window.focus(&view.read(cx).focus.clone(), cx);
|
||||
view
|
||||
})?
|
||||
.into(),
|
||||
false => cx
|
||||
.open_window(options(display), |window, cx| {
|
||||
let view = cx.new(|cx| Audience::new(show, cx));
|
||||
window.focus(&view.read(cx).focus.clone(), cx);
|
||||
view
|
||||
})?
|
||||
.into(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Deck {
|
||||
/// Plays a copy from the first or shown slide.
|
||||
pub fn start_show(&mut self, here: bool, cx: &mut Context<Self>) {
|
||||
if self.show.is_some() || self.show_starting {
|
||||
return log::info!("show: already playing or starting");
|
||||
}
|
||||
let Some(id) = self.core.active else {
|
||||
return;
|
||||
};
|
||||
self.show_starting = true;
|
||||
log::info!("show: starts, here {here}");
|
||||
if !self.settled(id, cx) {
|
||||
return self.when_settled(id, move |deck, _, cx| deck.read_show(id, here, cx));
|
||||
}
|
||||
self.read_show(id, here, cx);
|
||||
}
|
||||
|
||||
fn read_show(&mut self, id: u64, here: bool, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.tab(id) else {
|
||||
self.show_starting = false;
|
||||
return;
|
||||
};
|
||||
let scheme = tab.view.read(cx).dark().map(ely_lok::scheme);
|
||||
let path = tab.work.join(format!("show-{}.odp", self.shows));
|
||||
self.shows += 1;
|
||||
let into = path.clone();
|
||||
let worker = self.core.worker.clone();
|
||||
self.read_in(
|
||||
id,
|
||||
move |worker, doc| copy(worker, doc, (&into, scheme)),
|
||||
move |deck, read, _, cx| {
|
||||
deck.show_starting = false;
|
||||
let (current, doc, slides) = match read {
|
||||
Ok(read) => read,
|
||||
Err(error) => return deck.engine_failed(&format!("the show: {error}"), cx),
|
||||
};
|
||||
let first = match here {
|
||||
true => Some(current).filter(|at| *at < slides.len()),
|
||||
false => slides.iter().position(|slide| !slide.hidden),
|
||||
};
|
||||
let Some(first) = first.filter(|_| deck.core.active == Some(id)) else {
|
||||
discard(worker, doc, path, cx);
|
||||
if deck.core.active != Some(id) {
|
||||
return log::info!("show: tab {id} is no longer shown");
|
||||
}
|
||||
let text = Deck::text(cx, "show.play.none", &[]);
|
||||
return deck.toast(text, Severity::Warning, cx);
|
||||
};
|
||||
let starting = Starting {
|
||||
tab: id,
|
||||
slides,
|
||||
first,
|
||||
doc,
|
||||
copy: path,
|
||||
audience: 0,
|
||||
presenter: true,
|
||||
};
|
||||
match cx.displays().len() {
|
||||
1 => deck.open_show(starting, cx),
|
||||
_ => {
|
||||
deck.show_starting = true;
|
||||
deck.dialog = Some(Dialog::Show(starting));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn open_show(&mut self, starting: Starting, cx: &mut Context<Self>) {
|
||||
self.show_starting = false;
|
||||
let worker = self.core.worker.clone();
|
||||
let displays = cx.displays();
|
||||
let (Some(tab), Some(audience)) = (self.tab(starting.tab), displays.get(starting.audience))
|
||||
else {
|
||||
log::warn!("show: the tab or screen {} is gone", starting.audience);
|
||||
return discard(worker, starting.doc, starting.copy, cx);
|
||||
};
|
||||
let presenter = (displays.iter())
|
||||
.find(|display| display.id() != audience.id())
|
||||
.filter(|_| starting.presenter);
|
||||
let slide = crate::design::mm100(tab.extra.size);
|
||||
let slide = (slide.0 as f32, slide.1 as f32);
|
||||
let room = audience.bounds().size;
|
||||
let fitted = super::stage::fit(slide, (room.width, room.height));
|
||||
let width = (f32::from(fitted.0) * 2.0).min(WIDEST).round() as u32;
|
||||
let pixels = (width, (width as f32 * slide.1 / slide.0).round() as u32);
|
||||
let deck = cx.weak_entity();
|
||||
let played = (worker, starting.doc, starting.copy);
|
||||
let show = cx.new(|cx| {
|
||||
Show::new(deck, played, (starting.slides, starting.first), (slide, pixels), cx)
|
||||
});
|
||||
let mut windows = Vec::new();
|
||||
// The audience window opens last, so it is in front.
|
||||
let screens = presenter.map(|d| (d, true)).into_iter().chain([(audience, false)]);
|
||||
for (display, presenter) in screens {
|
||||
match show_window(&show, display, presenter, cx) {
|
||||
Ok(handle) => windows.push(handle),
|
||||
Err(error) => {
|
||||
show.update(cx, |show, cx| {
|
||||
show.windows = windows;
|
||||
show.close(cx);
|
||||
});
|
||||
return self.engine_failed(&format!("the show window: {error}"), cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
log::info!("show: {} windows open", windows.len());
|
||||
show.update(cx, |show, _| {
|
||||
show.presenter = windows.len() > 1;
|
||||
show.windows = windows;
|
||||
});
|
||||
self.show = Some(show);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Show `id` closed its windows.
|
||||
pub fn show_ended(&mut self, id: EntityId, cx: &mut Context<Self>) {
|
||||
if self.show.as_ref().is_some_and(|show| show.entity_id() == id) {
|
||||
self.show = None;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks screens before the show opens.
|
||||
pub fn show_dialog(&self, starting: &Starting, cx: &mut Context<Self>) -> AnyElement {
|
||||
let this = cx.entity();
|
||||
let label = |key: &str| Deck::text(cx, key, &[]);
|
||||
let screens: Vec<Choice> = (cx.displays().iter().enumerate())
|
||||
.map(|(at, display)| {
|
||||
let size = display.bounds().size;
|
||||
let name = Deck::text(
|
||||
cx,
|
||||
"show.play.screen",
|
||||
&[
|
||||
("number", &(at + 1).to_string()),
|
||||
("width", &f32::from(size.width).round().to_string()),
|
||||
("height", &f32::from(size.height).round().to_string()),
|
||||
],
|
||||
);
|
||||
Choice::new(at.to_string(), name)
|
||||
})
|
||||
.collect();
|
||||
let (pick, flip) = (this.clone(), this.clone());
|
||||
let screen = Select::new("show-play-screen", screens)
|
||||
.label(label("show.play.audience"))
|
||||
.selected(starting.audience.to_string())
|
||||
.on_change(move |value, _, cx| {
|
||||
let at = value.parse().expect("screens go by number");
|
||||
edit(&pick, cx, |dialog, _| {
|
||||
if let Dialog::Show(starting) = dialog {
|
||||
starting.audience = at;
|
||||
}
|
||||
})
|
||||
});
|
||||
let presenter = Switch::new("show-play-presenter", starting.presenter)
|
||||
.name(label("show.play.presenter"))
|
||||
.on_change(move |on, _, cx| {
|
||||
edit(&flip, cx, |dialog, _| {
|
||||
if let Dialog::Show(starting) = dialog {
|
||||
starting.presenter = on;
|
||||
}
|
||||
})
|
||||
});
|
||||
let rows = DescriptionList::new()
|
||||
.item(label("show.play.audience"), screen)
|
||||
.item(label("show.play.presenter"), presenter);
|
||||
let closing = this.clone();
|
||||
let dismiss = move |_: &mut Window, cx: &mut App| {
|
||||
closing.update(cx, |deck, cx| {
|
||||
if let Some(Dialog::Show(starting)) = deck.dialog.take() {
|
||||
deck.show_starting = false;
|
||||
discard(deck.core.worker.clone(), starting.doc, starting.copy, cx);
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
};
|
||||
let go = label("show.play.start");
|
||||
Card::new("show-play", label("show.play.title"), dismiss)
|
||||
.child(rows)
|
||||
.action(ely_office::cancel(cx))
|
||||
.action(move |_| {
|
||||
Button::new("show-play-go", go)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.on_click(move |_, _, cx| {
|
||||
this.update(cx, |deck, cx| {
|
||||
if let Some(Dialog::Show(starting)) = deck.dialog.take() {
|
||||
deck.open_show(starting, cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Where a show stands, and where each key takes it.
|
||||
|
||||
use ely_lok::show::{Effect, ShowSlide};
|
||||
|
||||
use crate::preview::timeline::{Run, length, schedule};
|
||||
|
||||
/// Effects in click groups; group 0 runs on entry.
|
||||
pub fn groups(effects: &[Effect]) -> Vec<Vec<Effect>> {
|
||||
let mut groups = vec![Vec::new()];
|
||||
for effect in effects {
|
||||
if effect.start == "click" {
|
||||
groups.push(Vec::new());
|
||||
}
|
||||
groups.last_mut().expect("one group").push(effect.clone());
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// A slide and its clicks done, or the end.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Place {
|
||||
Slide { part: usize, clicks: usize },
|
||||
End,
|
||||
}
|
||||
|
||||
/// The next place on a click.
|
||||
pub fn next(slides: &[ShowSlide], place: Place) -> Place {
|
||||
match place {
|
||||
Place::Slide { part, clicks } if clicks + 1 < groups(&slides[part].effects).len() => {
|
||||
Place::Slide {
|
||||
part,
|
||||
clicks: clicks + 1,
|
||||
}
|
||||
}
|
||||
Place::Slide { part, .. } => (part + 1..slides.len())
|
||||
.find(|at| !slides[*at].hidden)
|
||||
.map_or(Place::End, |part| Place::Slide { part, clicks: 0 }),
|
||||
Place::End => Place::End,
|
||||
}
|
||||
}
|
||||
|
||||
/// The previous slide, fully built; the first stays.
|
||||
pub fn previous(slides: &[ShowSlide], place: Place) -> Place {
|
||||
let from = match place {
|
||||
Place::Slide { part, .. } => part,
|
||||
Place::End => slides.len(),
|
||||
};
|
||||
let built = |part: usize| Place::Slide {
|
||||
part,
|
||||
clicks: groups(&slides[part].effects).len() - 1,
|
||||
};
|
||||
match (0..from).rev().find(|at| !slides[*at].hidden) {
|
||||
Some(part) => built(part),
|
||||
None => place,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs from entry: group `n` starts at `started[n]` seconds.
|
||||
pub fn runs(effects: &[Effect], started: &[f64]) -> Vec<Run> {
|
||||
(groups(effects).iter().zip(started))
|
||||
.flat_map(|(group, at)| {
|
||||
schedule(group).into_iter().map(move |run| Run {
|
||||
begin: run.begin + at,
|
||||
end: run.end + at,
|
||||
..run
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A click group starts now or once all before end.
|
||||
pub fn click_at(effects: &[Effect], clicks: &[f64], now: f64) -> f64 {
|
||||
let before = clicks.iter().copied().fold(now, f64::max);
|
||||
length(&runs(effects, clicks)).max(before)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ely_lok::show::Transition;
|
||||
|
||||
fn effect(start: &str) -> Effect {
|
||||
Effect {
|
||||
preset: "ooo-entrance-fade-in".into(),
|
||||
label: String::new(),
|
||||
class: "entrance".into(),
|
||||
target: Some(0),
|
||||
hash: start.into(),
|
||||
start: start.into(),
|
||||
duration: 1.0,
|
||||
delay: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn slide(hidden: bool, effects: Vec<Effect>) -> ShowSlide {
|
||||
ShowSlide {
|
||||
hidden,
|
||||
notes: String::new(),
|
||||
transition: Transition {
|
||||
preset: String::new(),
|
||||
duration: 0.0,
|
||||
auto: false,
|
||||
time: 0.0,
|
||||
},
|
||||
effects,
|
||||
media: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_and_after_join_the_click_before() {
|
||||
let sizes = |effects: &[Effect]| groups(effects).iter().map(Vec::len).collect::<Vec<_>>();
|
||||
assert_eq!(sizes(&[]), [0]);
|
||||
assert_eq!(sizes(&[effect("with"), effect("click")]), [1, 1]);
|
||||
assert_eq!(sizes(&[effect("click"), effect("after"), effect("click")]), [0, 2, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_play_effects_then_skip_hidden_slides_to_the_end() {
|
||||
let deck = [
|
||||
slide(false, vec![effect("click")]),
|
||||
slide(true, Vec::new()),
|
||||
slide(false, Vec::new()),
|
||||
];
|
||||
let mut place = Place::Slide { part: 0, clicks: 0 };
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..4 {
|
||||
place = next(&deck, place);
|
||||
seen.push(place);
|
||||
}
|
||||
assert_eq!(
|
||||
seen,
|
||||
[
|
||||
Place::Slide { part: 0, clicks: 1 },
|
||||
Place::Slide { part: 2, clicks: 0 },
|
||||
Place::End,
|
||||
Place::End
|
||||
]
|
||||
);
|
||||
assert_eq!(previous(&deck, Place::End), Place::Slide { part: 2, clicks: 0 });
|
||||
assert_eq!(
|
||||
previous(&deck, Place::Slide { part: 2, clicks: 0 }),
|
||||
Place::Slide { part: 0, clicks: 1 },
|
||||
"back to a built slide, past the hidden one"
|
||||
);
|
||||
let first = Place::Slide { part: 0, clicks: 0 };
|
||||
assert_eq!(previous(&deck, first), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quick_click_waits_for_the_groups_and_transition_before() {
|
||||
let long = |start| Effect {
|
||||
duration: 10.0,
|
||||
..effect(start)
|
||||
};
|
||||
let effects = [long("click"), long("click")];
|
||||
assert_eq!(click_at(&effects, &[0.0], 0.1), 0.1, "nothing runs yet");
|
||||
assert_eq!(click_at(&effects, &[0.0, 0.1], 0.2), 10.1, "after the first");
|
||||
assert_eq!(click_at(&effects, &[2.0], 0.5), 2.0, "after the transition");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_group_runs_from_its_own_click() {
|
||||
let effects = [effect("with"), effect("click"), effect("after")];
|
||||
let times: Vec<(f64, f64)> = runs(&effects, &[0.0, 5.0])
|
||||
.iter()
|
||||
.map(|r| (r.begin, r.end))
|
||||
.collect();
|
||||
assert_eq!(times, [(0.0, 1.0), (5.0, 6.0), (6.0, 7.0)]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user