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:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+99
View File
@@ -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),
),
)
}
}
+77
View File
@@ -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
}
}
+99
View File
@@ -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);
}
}
+109
View File
@@ -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))
}
}
+235
View File
@@ -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),
),
),
)
}
}
+85
View File
@@ -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),
}
}
+315
View File
@@ -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(&copy) {
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()
}
}
+171
View File
@@ -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)]);
}
}