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

+400
View File
@@ -0,0 +1,400 @@
//! Charts on a slide: look and their own table (SHOW-07).
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
feedback::InlineMessage,
forms::{Input, TextInput},
overlays::Dialog as Card,
primitives::Severity,
theme::ControlSize,
};
use ely_lok::{
sheet::Look,
show::{ChartData, Objects, SlideChart},
};
use ely_office::{
chart::{LookFields, field, fresh, look_rows},
in_window,
prelude::*,
};
use gpui::{
AnyElement, App, Context, Entity, IntoElement, ParentElement as _, SharedString, Styled as _,
TaskExt as _, Window, div,
};
use crate::deck::Deck;
use crate::dialog::{Dialog, edit};
/// A new chart's table, as the kernel's sample has it.
const SAMPLE: [[f64; 3]; 2] = [[4.3, 2.5, 3.5], [2.4, 4.4, 1.8]];
/// The chart dialog: look, then its table if any.
pub struct Editing {
pub tab: u64,
/// The marked chart changes; otherwise a new one goes in.
pub changing: bool,
pub look: Look,
pub fields: LookFields,
pub table: Option<Cells>,
pub error: Option<SharedString>,
}
/// A category by series table of fields.
pub struct Cells {
pub categories: Vec<Entity<TextInput>>,
pub series: Vec<(Entity<TextInput>, Vec<Entity<TextInput>>)>,
}
fn number(value: Option<f64>) -> String {
value.map(|n| n.to_string()).unwrap_or_default()
}
impl Editing {
fn new(tab: u64, chart: Option<SlideChart>, window: &mut Window, cx: &mut App) -> Self {
let changing = chart.is_some();
let text = |key: &str, n: usize, cx: &App| {
Deck::text(cx, key, &[("n", &n.to_string())]).to_string()
};
let (look, data) = match chart {
Some(chart) => (chart.look, chart.data),
None => {
let categories = (1..=3)
.map(|n| text("show.chart.category", n, cx))
.collect();
let series = SAMPLE
.iter()
.enumerate()
.map(|(at, values)| {
let name = text("show.chart.series", at + 1, cx);
(name, values.iter().map(|v| Some(*v)).collect())
})
.collect();
(fresh(), Some(ChartData { categories, series }))
}
};
Editing {
tab,
changing,
fields: LookFields::new(&look, window, cx),
look,
table: data.map(|data| Cells {
categories: (data.categories.iter())
.map(|name| field(name, "show.chart.category_name", window, cx))
.collect(),
series: (data.series.iter())
.map(|(name, values)| {
let cells = (values.iter())
.map(|value| field(&number(*value), "show.chart.value", window, cx))
.collect();
(field(name, "show.chart.series_name", window, cx), cells)
})
.collect(),
}),
error: None,
}
}
/// The typed table, or the first bad cell.
fn data(&self, kind: &str, cx: &App) -> Result<Option<ChartData>, SharedString> {
let Some(table) = &self.table else {
return Ok(None);
};
let text = |field: &Entity<TextInput>| field.read(cx).text().trim().to_string();
let categories: Vec<String> = table.categories.iter().map(text).collect();
// A scatter's categories are its X values.
let number = |typed: &str| typed.parse::<f64>().ok().filter(|v| v.is_finite());
if kind == "scatter"
&& let Some(bad) = categories
.iter()
.find(|category| number(category).is_none())
{
return Err(Deck::text(
cx,
"show.chart.x_not_a_number",
&[("category", bad)],
));
}
let mut series = Vec::new();
for (name, cells) in &table.series {
let mut values = Vec::new();
for (at, cell) in cells.iter().enumerate() {
let typed = text(cell);
// An empty cell is a missing point.
if typed.is_empty() {
values.push(None);
continue;
}
let Some(value) = typed.parse::<f64>().ok().filter(|v| v.is_finite()) else {
let args = [("series", text(name)), ("category", categories[at].clone())];
let args: Vec<(&str, &str)> =
args.iter().map(|(k, v)| (*k, v.as_str())).collect();
return Err(Deck::text(cx, "show.chart.not_a_number", &args));
};
values.push(Some(value));
}
series.push((text(name), values));
}
Ok(Some(ChartData { categories, series }))
}
}
impl Deck {
/// Opens the chart dialog: marked chart, else new.
pub fn ask_chart(&mut self, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
let (id, doc) = (tab.id, tab.doc.clone());
let Some(asked) = marked_chart(self) else {
return self.open_chart((id, None), None, cx);
};
ely_lok::read_off(
&self.core.worker,
&doc,
|worker, doc| {
let part = doc.sheets(worker)?.current;
let objects = ely_lok::show::objects(&worker.values(doc, ely_lok::show::OBJECTS)?)?;
let chart =
ely_lok::show::slide_chart(&worker.values(doc, ely_lok::sheet::CHART)?)?;
Ok((marked(part, &objects), chart))
},
move |deck: &mut Self, read: std::io::Result<_>, cx| match read {
// What the read saw must be what was asked for.
Ok((seen, chart)) if seen == Some(asked) => {
deck.open_chart((id, Some(asked)), chart, cx)
}
Ok(_) => log::info!("show: the chart changed before its read came back"),
Err(error) => deck.engine_failed(&format!("the chart: {error}"), cx),
},
cx,
);
}
/// Opens the dialog if tab and marked chart still match.
fn open_chart(
&mut self,
(tab, asked): (u64, Option<Marked>),
chart: Option<SlideChart>,
cx: &mut Context<Self>,
) {
cx.spawn(async move |this, cx| {
in_window(&this, cx, |deck, window, cx| {
let still = deck.core.active == Some(tab)
&& asked.is_none_or(|asked| marked_chart(deck) == Some(asked));
if !still {
return log::info!("show: a chart dialog for tab {tab} came late");
}
let editing = Editing::new(tab, chart, window, cx);
deck.dialog = Some(Dialog::Chart(Box::new(editing)));
cx.notify();
})
})
.detach_and_log_err(cx);
}
/// Inserts or changes the chart the dialog holds.
pub(crate) fn chart_asked(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Chart(mut editing)) = self.dialog.take() else {
return;
};
let look = editing.fields.read(&editing.look, cx);
match editing.data(&look.kind, cx) {
Ok(data) => {
let json = ely_lok::show::chart_json(&look, data.as_ref());
self.run_in(Some(editing.tab), "show.chart", &[&json], cx);
self.force_reread(editing.tab, cx);
}
Err(error) => {
editing.error = Some(error);
self.dialog = Some(Dialog::Chart(editing));
}
}
cx.notify();
}
pub fn chart_dialog(&self, editing: &Editing, cx: &mut Context<Self>) -> AnyElement {
let this = cx.entity();
let label = |key: &str| Deck::text(cx, key, &[]);
let (picking, toggling) = (this.clone(), this.clone());
let (rows, legend) = look_rows(
"show-chart",
DescriptionList::new(),
(&editing.look, &editing.fields),
move |kind, cx| edit(&picking, cx, |dialog, _| chart(dialog).look.kind = kind),
move |on, cx| edit(&toggling, cx, |dialog, _| chart(dialog).look.legend = on),
cx,
);
let (title, go) = match editing.changing {
true => ("office.chart.edit_title", "office.chart.apply"),
false => ("office.chart.insert_title", "office.chart.go"),
};
let closing = this.clone();
let dismiss = move |_: &mut Window, cx: &mut App| {
closing.update(cx, |deck, cx| {
deck.dialog = None;
cx.notify();
})
};
let go = label(go);
Card::new("show-chart", label(title), dismiss)
.child(rows)
.child(legend)
.child(match &editing.table {
Some(cells) => table(&this, cells, cx),
None => InlineMessage::new(Severity::Info, label("show.chart.no_table"))
.into_any_element(),
})
.children(
editing
.error
.clone()
.map(|error| InlineMessage::new(Severity::Danger, error)),
)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("show-chart-go", go)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| this.update(cx, |deck, cx| deck.chart_asked(cx)))
})
.into_any_element()
}
}
/// A marked chart: slide, place in the object list, frame.
type Marked = (usize, usize, (i64, i64, i64, i64));
/// The one marked object on slide `part`, if a chart.
fn marked(part: usize, objects: &Objects) -> Option<Marked> {
let selection = objects
.selection
.as_ref()
.filter(|s| s.kind == "chart" && s.count == 1)?;
let mut picked = objects
.objects
.iter()
.enumerate()
.filter(|(_, o)| o.selected);
let (at, _) = picked.next().filter(|_| picked.next().is_none())?;
Some((part, at, selection.rect))
}
/// The marked chart as the deck last read it.
fn marked_chart(deck: &Deck) -> Option<Marked> {
let tab = deck.active_tab()?;
marked(tab.extra.slides.current, tab.extra.objects.as_ref()?)
}
fn cells(dialog: &mut Dialog) -> &mut Cells {
chart(dialog)
.table
.as_mut()
.expect("the resize buttons need a table")
}
fn chart(dialog: &mut Dialog) -> &mut Editing {
match dialog {
Dialog::Chart(editing) => editing,
_ => unreachable!("a chart dialog"),
}
}
/// Series across, categories down, with grow and shrink buttons.
fn table(this: &Entity<Deck>, editing: &Cells, cx: &App) -> AnyElement {
let cell = |input: &Entity<TextInput>| div().w_20().child(Input::new(input));
let header = div()
.flex()
.gap_1()
.child(div().w_20())
.children(editing.series.iter().map(|(name, _)| cell(name)));
let rows = editing.categories.iter().enumerate().map(|(at, name)| {
div()
.flex()
.gap_1()
.child(cell(name))
.children(editing.series.iter().map(|(_, cells)| cell(&cells[at])))
});
let label = |key: &str| Deck::text(cx, key, &[]);
let grow = |id: &'static str, key: &str, more: bool, across: bool, on: bool| {
let this = this.clone();
Button::new(id, label(key))
.size(ControlSize::Sm)
.disabled(!on)
.on_click(move |_, window, cx| {
edit(&this, cx, |dialog, cx| {
resize(cells(dialog), (more, across), window, cx)
})
})
};
let buttons = div()
.flex()
.gap_1()
.child(grow(
"show-chart-add-row",
"show.chart.add_category",
true,
false,
true,
))
.child(grow(
"show-chart-drop-row",
"show.chart.drop_category",
false,
false,
editing.categories.len() > 1,
))
.child(grow(
"show-chart-add-column",
"show.chart.add_series",
true,
true,
true,
))
.child(grow(
"show-chart-drop-column",
"show.chart.drop_series",
false,
true,
editing.series.len() > 1,
));
div()
.flex()
.flex_col()
.gap_1()
.child(header)
.children(rows)
.child(buttons)
.into_any_element()
}
/// Adds or drops the last category or series.
fn resize(editing: &mut Cells, (more, across): (bool, bool), window: &mut Window, cx: &mut App) {
let blank = |key: &str, window: &mut Window, cx: &mut App| field("", key, window, cx);
match (more, across) {
(true, false) => {
editing
.categories
.push(blank("show.chart.category_name", window, cx));
for (_, cells) in &mut editing.series {
cells.push(blank("show.chart.value", window, cx));
}
}
(false, false) => {
editing.categories.pop();
for (_, cells) in &mut editing.series {
cells.pop();
}
}
(true, true) => {
let cells = (0..editing.categories.len())
.map(|_| blank("show.chart.value", window, cx))
.collect();
editing
.series
.push((blank("show.chart.series_name", window, cx), cells));
}
(false, true) => {
editing.series.pop();
}
}
}
+265
View File
@@ -0,0 +1,265 @@
//! What may go wrong in a show, and where (SHOW-14).
use ely_gpui_component::{
lists::{ListItem, SelectableList},
overlays::Dialog as Card,
primitives::Severity,
};
use ely_lok::show::{Check, PageCheck, ShowSlide};
use ely_office::prelude::*;
use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, SharedString, Window, div};
use crate::deck::Deck;
use crate::dialog::Dialog;
/// What a finding is.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Problem {
Font,
Media,
/// A linked file; `missing` says if it is gone.
Link,
/// Something the kernel cannot show.
Object,
}
/// One thing to fix, and where it is.
#[derive(Clone, Debug, PartialEq)]
pub struct Finding {
pub problem: Problem,
pub name: String,
pub missing: bool,
/// Slide indexes from zero.
pub slides: Vec<usize>,
pub masters: Vec<String>,
}
/// Every page's checks: slides by index, masters by name.
fn pages(check: &Check) -> impl Iterator<Item = (Result<usize, &str>, &PageCheck)> {
let slides = check.slides.iter().enumerate().map(|(at, page)| (Ok(at), page));
slides.chain(check.masters.iter().map(|(name, page)| (Err(name.as_str()), page)))
}
fn add(found: &mut Vec<Finding>, (problem, name, missing): (Problem, &str, bool), at: Result<usize, &str>) {
let index = found.iter().position(|f| f.problem == problem && f.name == name);
let finding = match index {
Some(index) => &mut found[index],
None => {
found.push(Finding {
problem,
name: name.to_string(),
missing,
slides: Vec::new(),
masters: Vec::new(),
});
found.last_mut().expect("just pushed")
}
};
match at {
Ok(slide) if !finding.slides.contains(&slide) => finding.slides.push(slide),
Err(master) if !finding.masters.iter().any(|m| m == master) => {
finding.masters.push(master.to_string())
}
_ => {}
}
}
/// Missing fonts, missing media, links, then broken objects.
pub fn findings(check: &Check, slides: &[ShowSlide], missing_fonts: &[String]) -> Vec<Finding> {
let mut found = Vec::new();
for font in missing_fonts {
let fonts = (Problem::Font, font.as_str(), true);
let mut placed = false;
for (at, _) in pages(check).filter(|(_, page)| page.fonts.contains(font)) {
add(&mut found, fonts, at);
placed = true;
}
// Missing, yet no slide text asks for it.
if !placed {
found.push(Finding {
problem: Problem::Font,
name: font.clone(),
missing: true,
slides: Vec::new(),
masters: Vec::new(),
});
}
}
for (at, slide) in slides.iter().enumerate() {
for media in &slide.media {
match (media.missing, media.link) {
(true, _) => add(&mut found, (Problem::Media, &media.url, true), Ok(at)),
(false, true) => add(&mut found, (Problem::Link, &media.url, false), Ok(at)),
(false, false) => {}
}
}
}
for (at, page) in pages(check) {
for (url, missing) in &page.links {
add(&mut found, (Problem::Link, url, *missing), at);
}
}
for (at, page) in pages(check) {
for name in &page.broken {
add(&mut found, (Problem::Object, name, true), at);
}
}
found
}
impl Deck {
/// Reads every slide and master, then lists what to fix.
pub fn check_show(&mut self, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
let text = Deck::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
let (id, missing) = (tab.id, tab.view.read(cx).missing_fonts().to_vec());
let asked = self.ask_dialog();
log::info!("show: checks the deck");
self.read_in(
id,
|worker, doc| {
let check = ely_lok::show::check(&worker.values(doc, ely_lok::show::SLIDE_CHECK)?)?;
let slides = ely_lok::show::slide_show(&worker.values(doc, ely_lok::show::SLIDE_SHOW)?)?;
Ok((check, slides))
},
move |deck, read, _, cx| match read {
Ok((check, slides)) if deck.dialog_due(asked, id) => {
let found = findings(&check, &slides, &missing);
log::info!("show: the check found {} things", found.len());
deck.dialog = Some(Dialog::Check(found));
cx.notify();
}
Ok(_) => log::info!("show: the check is no longer due"),
Err(error) => deck.engine_failed(&format!("the check: {error}"), cx),
},
cx,
);
}
/// The findings; a row goes to its first slide.
pub fn check_dialog(&self, found: &[Finding], cx: &mut Context<Self>) -> AnyElement {
let this = cx.entity();
let text = |key: &str| Deck::text(cx, key, &[]);
let closing = this.clone();
let dismiss = move |_: &mut Window, cx: &mut App| {
closing.update(cx, |deck, cx| {
deck.dialog = None;
cx.notify();
})
};
let card = Card::new("show-check", text("show.check.title"), dismiss);
if found.is_empty() {
return card.child(div().child(text("show.check.clear"))).into_any_element();
}
let mut list = SelectableList::new("show-check-list").label(text("show.check.title"));
for (at, finding) in found.iter().enumerate() {
let kind = match (finding.problem, finding.missing) {
(Problem::Font, _) => "show.check.font",
(Problem::Media, _) => "show.check.media",
(Problem::Link, true) => "show.check.link_missing",
(Problem::Link, false) => "show.check.link",
(Problem::Object, _) => "show.check.object",
};
let mut places: Vec<String> = (finding.slides.iter())
.map(|slide| Deck::text(cx, "show.check.slide", &[("n", &(slide + 1).to_string())]).to_string())
.collect();
places.extend(finding.masters.iter().map(|name| {
Deck::text(cx, "show.check.master", &[("name", name)]).to_string()
}));
let name = match finding.name.is_empty() {
true => text("show.check.unnamed"),
false => SharedString::from(finding.name.clone()),
};
let place = match places.is_empty() {
true => text("show.check.nowhere").to_string(),
false => places.join(", "),
};
let item = ListItem::new(SharedString::from(format!("show-check-{at}")), name)
.description(format!("{} · {place}", text(kind)));
list = list.row(SharedString::from(at.to_string()), item);
}
let firsts: Vec<Option<usize>> = found.iter().map(|f| f.slides.first().copied()).collect();
let list = list.on_change(move |keys, _, cx| {
let first = keys.first().and_then(|key| key.parse::<usize>().ok()).and_then(|at| firsts[at]);
this.update(cx, |deck, cx| {
if let (Some(part), Some(tab)) = (first, deck.active_tab()) {
let view = tab.view.clone();
view.update(cx, |view, _| view.show_part(part));
deck.dialog = None;
}
cx.notify();
})
});
card.child(list).into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
use ely_lok::show::{Media, Transition};
fn slide(media: Vec<Media>) -> ShowSlide {
ShowSlide {
hidden: false,
notes: String::new(),
transition: Transition {
preset: String::new(),
duration: 0.0,
auto: false,
time: 0.0,
},
effects: Vec::new(),
media,
}
}
fn media(url: &str, link: bool, missing: bool) -> Media {
Media {
index: 0,
url: url.into(),
link,
file: url.into(),
missing,
repeat: false,
bounds: (0, 0, 1, 1),
}
}
#[test]
fn findings_say_what_and_where_in_order() {
let page = |fonts: &[&str], links: &[(&str, bool)], broken: &[&str]| PageCheck {
fonts: fonts.iter().map(|f| f.to_string()).collect(),
links: links.iter().map(|(u, m)| (u.to_string(), *m)).collect(),
broken: broken.iter().map(|b| b.to_string()).collect(),
};
let check = Check {
slides: vec![
page(&["Lost", "Inter"], &[("file:///a.png", true)], &[]),
page(&["Lost"], &[], &["Chart 1"]),
],
masters: vec![("Plain".into(), page(&["Lost"], &[("file:///a.png", true)], &[]))],
};
let slides = [slide(vec![media("file:///clip.mp4", false, true)]), slide(vec![media("file:///b.mp3", true, false)])];
let missing = ["Lost".to_string(), "Gone".to_string()];
let found = findings(&check, &slides, &missing);
let short: Vec<(Problem, &str, Vec<usize>, Vec<String>)> = found
.iter()
.map(|f| (f.problem, f.name.as_str(), f.slides.clone(), f.masters.clone()))
.collect();
assert_eq!(
short,
[
(Problem::Font, "Lost", vec![0, 1], vec!["Plain".to_string()]),
(Problem::Font, "Gone", vec![], vec![]),
(Problem::Media, "file:///clip.mp4", vec![0], vec![]),
(Problem::Link, "file:///b.mp3", vec![1], vec![]),
(Problem::Link, "file:///a.png", vec![0], vec!["Plain".to_string()]),
(Problem::Object, "Chart 1", vec![1], vec![]),
]
);
assert!(found[4].missing && !found[3].missing);
}
}
+506
View File
@@ -0,0 +1,506 @@
//! ElyShow's window: decks over one LOK worker (§10.1).
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
sync::{Arc, Mutex},
};
use ely_gpui_component::{feedback::EmptyState, primitives::IconName, shell::StatusBar};
use ely_lok::{
LokView, Sheets, Worker,
history::Step,
show::{Design, Objects},
};
use ely_office::{Core, Kind, prelude::*};
use ely_platform::AppId;
use ely_ui::{Root, Shell, commands};
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
ParentElement as _, Render, RenderImage, SharedString, Styled as _, Window, div,
};
use crate::design::Panel;
use crate::slides::Strip;
/// Parts, edits, caret and selected frame.
pub type Seen = (u64, u64, bool, Option<ely_lok::Rect>);
/// What ElyShow keeps per deck.
#[derive(Default)]
pub struct Extra {
pub slides: Sheets,
/// The document as the last read saw it.
pub read_at: Option<ely_lok::Doc>,
/// What the view showed at the last read.
pub read: Option<Seen>,
pub reading: bool,
pub design: Option<Design>,
pub objects: Option<Objects>,
pub notes: Option<ely_lok::show::Notes>,
pub remarks: Vec<ely_lok::show::Remark>,
/// The shown slide's transition and effects; none on a master.
pub motion: Option<(ely_lok::show::Transition, Vec<ely_lok::show::Effect>)>,
pub media: Vec<ely_lok::show::Media>,
/// Counts completed reads.
pub reads: u64,
/// The slide size in twips.
pub size: (i64, i64),
/// Undo and redo lists at the last read.
pub history: Option<(Vec<Step>, Vec<Step>)>,
/// A size sent, in 1/100 mm, until read back.
pub size_asked: Option<(i64, i64)>,
/// A transition sent for a slide, until read back.
pub transition_asked: Option<(String, ely_lok::show::Transition, u64)>,
/// Pictures by slide name; stale ones show until repainted.
pub thumbs: HashMap<String, Arc<RenderImage>>,
pub retired: ely_gpui_component::primitives::Retired,
pub stale: HashSet<String>,
/// Pictures that failed; retried after the next read.
pub failed: HashSet<String>,
pub painting: bool,
/// The parts are masters.
pub masters: bool,
/// The scheme the pictures were painted with.
pub dark: Option<bool>,
}
impl Extra {
/// The size to show, in 1/100 mm: asked, else read.
pub fn shown_size(&self) -> (i64, i64) {
self.size_asked
.unwrap_or_else(|| crate::design::mm100(self.size))
}
}
pub struct Deck {
pub core: Core<Deck>,
pub dialog: Option<crate::dialog::Dialog>,
pub strip: Entity<Strip>,
pub panel: Entity<Panel>,
/// The kernel's own blank deck, written once.
pub blank: Option<PathBuf>,
/// The kernel's font families; read once.
pub fonts: Vec<SharedString>,
reading_fonts: bool,
pub guide_drag: Option<crate::guides::Drag>,
pub notes: crate::notes::NotesField,
/// The right column shows comments, not properties.
pub remarks_open: bool,
pub preview: Option<crate::preview::Preview>,
/// Counts preview requests; only the last may play.
pub preview_asked: u64,
pub probe: Option<crate::media::Probe>,
pub playing: Option<crate::media::Playing>,
pub show: Option<Entity<crate::slideshow::Show>>,
/// A show is being read or its screens picked.
pub show_starting: bool,
/// Shows started; each copy has its own file.
pub shows: u64,
/// Reads that end in a dialog; only the last opens.
pub dialog_asked: u64,
/// Dialog kind and tab last seen; a change ends asks.
pub dialog_seen: (Option<std::mem::Discriminant<crate::dialog::Dialog>>, Option<u64>),
pub active_remark: Option<SharedString>,
pub remarks_pane: Entity<crate::remarks::Pane>,
/// What the menus last checked: grid, guides, comments.
pub snap_menus: Option<(bool, bool, bool)>,
/// Pictures of closed tabs and ended previews, freed later.
pub retired: ely_gpui_component::primitives::Retired,
}
impl Host for Deck {
type Extra = Extra;
type Engine = ely_office::Lok;
const KIND: Kind = Kind {
app: AppId::Show,
kept: &["pptx", "odp", "ppt", "fodp"],
save_as: &["pptx", "odp"],
macros: Some(&["pptm", "potm", "ppsm"]),
};
fn core(&self) -> &Core<Self> {
&self.core
}
fn core_mut(&mut self) -> &mut Core<Self> {
&mut self.core
}
fn tab_closed(&mut self, tab: &ely_office::Tab<Extra, ely_office::Lok>, _: &mut Context<Self>) {
self.retire_tab(tab);
}
fn read_at(&self, tab: u64, _: &gpui::App) -> Option<ely_lok::Doc> {
self.tab(tab).and_then(|tab| tab.extra.read_at.clone())
}
fn observed(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
view.update(cx, |view, cx| view.fit_page(cx));
// Pictures follow the kernel's light or dark scheme.
let dark = view.read(cx).dark();
if let Some(tab) = self.tab_mut(id)
&& tab.extra.dark != dark
{
tab.extra.dark = dark;
self.repaint_all(id, cx);
}
self.reread(id, view, cx);
}
}
/// ElyShow's menus after File.
pub fn menus(on: (bool, bool, bool), cx: &App) -> Vec<gpui::Menu> {
let [insert, view] = crate::insert::menus(on, cx);
vec![crate::slides::menu(cx), insert, view, crate::slideshow::menu(cx)]
}
/// The window root with a blank deck.
pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
ely_lok::init(cx);
let (worker, work) = match ely_office::start(app, cx) {
Ok(started) => started,
Err(error) => ely_platform::startup::fail(app, &format!("{error}")),
};
let deck = cx.new(|cx| {
let mut host = Deck::new(worker, work, cx);
// Recovery points, crash return, autosave (§6.2, §6.3).
ely_office::Recovering::start_recovery(&mut host, cx);
<Deck as ely_office::Autosaving>::start_autosave(cx);
host
});
crate::slideshow::bind_keys(cx);
ely_office::bind_zoom_keys(cx);
let menus = crate::deck::menus((false, false, false), cx);
let root = ely_ui::document_root(app, deck.clone().into(), &ely_ui::commands::FILE, menus, window, cx);
deck.update(cx, |deck, cx| {
deck.core.root = root.downgrade();
deck.core.window = Some(window.window_handle());
window.focus(&deck.core.focus, cx);
});
let launched = ely_ui::launch_files(cx);
deck.update(cx, |deck, cx| match launched.is_empty() {
true => deck.new_blank(cx),
false => launched.into_iter().for_each(|path| deck.open_file(path, cx)),
});
let opening = deck.clone();
ely_ui::on_open(cx, move |path, cx| {
opening.update(cx, |deck, cx| deck.open_file(path, cx))
});
let closing = deck.clone();
window.on_window_should_close(cx, move |window, cx| {
closing.update(cx, |deck, cx| deck.should_close(window, cx))
});
root
}
impl Deck {
pub fn new(worker: Arc<Mutex<Worker>>, work: PathBuf, cx: &mut Context<Self>) -> Self {
let deck = cx.weak_entity();
// Closing or switching tabs stops playback.
cx.observe_self(|deck, _| {
deck.check_playing();
deck.watch_dialogs();
})
.detach();
Deck {
core: Core::new(worker, work, cx),
dialog: None,
strip: cx.new(|cx| Strip::new(deck.clone(), cx)),
panel: cx.new(|cx| Panel::new(deck.clone(), cx)),
remarks_pane: cx.new(|_| crate::remarks::Pane { deck }),
blank: None,
fonts: Vec::new(),
reading_fonts: false,
guide_drag: None,
snap_menus: Some((false, false, false)),
notes: Default::default(),
remarks_open: false,
preview: None,
preview_asked: 0,
probe: None,
playing: None,
show: None,
show_starting: false,
shows: 0,
dialog_asked: 0,
retired: Default::default(),
dialog_seen: (None, None),
active_remark: None,
}
}
pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString {
ely_ui::i18n::text(cx, key, args)
}
/// Rereads slides and design when the kernel moves them.
fn reread(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
let read = view.read(cx);
let seen = (
read.cell().sheets,
read.edited(),
read.caret(),
read.graphic(),
);
let Some(tab) = self.tab_mut(id) else {
return;
};
// Numbered commands need the objects read at the latest revision.
let behind = tab.extra.read_at.as_ref().is_some_and(|at| view.read(cx).read_at().newer_than(at));
if tab.extra.reading || tab.extra.read == Some(seen) && !behind {
return;
}
// Edits and leaving text edit dirty the picture.
if tab
.extra
.read
.is_some_and(|(_, edited, caret, _)| edited != seen.1 || caret && !seen.2)
&& let Some(slide) = tab.extra.slides.list.get(tab.extra.slides.current)
{
tab.extra.stale.insert(slide.name.clone());
}
(tab.extra.reading, tab.extra.read) = (true, Some(seen));
let landed = tab.extra.transition_landed(view.read(cx));
let doc = tab.doc.clone();
ely_lok::read_off(
&self.core.worker,
&doc,
|worker, doc| {
let at = worker.now(doc)?;
let slides = doc.sheets(worker)?;
let design = ely_lok::show::design(&worker.values(doc, ely_lok::show::DESIGN)?)?;
let mut steps = |command| ely_lok::history::steps(&worker.values(doc, command)?);
let history = (steps(".uno:Undo")?, steps(".uno:Redo")?);
let objects = ely_lok::show::objects(&worker.values(doc, ely_lok::show::OBJECTS)?)?;
let notes = ely_lok::show::notes(&worker.values(doc, ely_lok::show::NOTES)?)?;
let remarks = ely_lok::show::remarks(&worker.values(doc, ".uno:ViewAnnotations")?)?;
let media = ely_lok::show::media(&worker.values(doc, ely_lok::show::MEDIA)?)?;
let motion = match design.master_view {
true => None,
false => Some((
ely_lok::show::transition(&worker.values(doc, ely_lok::show::TRANSITION)?)?,
ely_lok::show::effects(&worker.values(doc, ely_lok::show::ANIMATIONS)?)?,
)),
};
Ok((
(slides, at),
(design, objects, notes, remarks, (motion, media)),
doc.size(worker)?,
history,
))
},
move |deck: &mut Self, read, cx| {
let Some(tab) = deck.tab_mut(id) else {
return;
};
tab.extra.reading = false;
let view = tab.view.clone();
match read {
Ok(((slides, at), (design, objects, notes, remarks, (motion, media)), size, history)) => {
tab.extra.read_at = Some(at);
log::info!("show: tab {id} has {} parts", slides.list.len());
view.update(cx, |view, _| view.set_part(slides.current));
let names: HashSet<&String> =
slides.list.iter().map(|slide| &slide.name).collect();
tab.extra.keep_pictures(&names);
// The kernel sends no repaint when the mode flips.
if tab.extra.masters != design.master_view {
log::info!("show: tab {id} shows masters {}", design.master_view);
tab.extra.masters = design.master_view;
tab.extra.drop_pictures();
view.update(cx, |view, cx| view.invalidate(cx));
}
// Size, colors and masters touch every picture.
let global =
|design: &Design| (design.theme.clone(), design.masters.clone());
// Undo and redo may reach any slide.
let undone = tab.extra.history.as_ref().is_some_and(|(undo, redo)| {
undo.iter().any(|step| !history.0.contains(step))
|| history.0.iter().any(|step| redo.contains(step))
});
if undone {
log::info!("show: tab {id} undid or redid; repainting all");
}
// Any new step may change the shown slide.
let stepped = tab
.extra
.history
.as_ref()
.is_some_and(|(undo, _)| *undo != history.0);
if let Some(slide) = slides.list.get(slides.current).filter(|_| stepped) {
tab.extra.stale.insert(slide.name.clone());
}
tab.extra.history = Some(history);
if undone
|| tab.extra.size != size
|| tab.extra.design.as_ref().map(global) != Some(global(&design))
{
tab.extra.stale = tab.extra.thumbs.keys().cloned().collect();
}
let read = crate::design::mm100(size);
if tab.extra.size_asked.is_some_and(|(w, h)| {
(w - read.0).abs() <= 50 && (h - read.1).abs() <= 50
}) {
tab.extra.size_asked = None;
}
(tab.extra.slides, tab.extra.design) = (slides, Some(design));
tab.extra.objects = Some(objects);
(tab.extra.notes, tab.extra.remarks) = (Some(notes), remarks);
(tab.extra.motion, tab.extra.media) = (motion, media);
tab.extra.settle_transition(landed);
tab.extra.reads += 1;
tab.extra.size = size;
tab.extra.failed.clear();
deck.paint_next(id, cx);
deck.read_fonts(id, cx);
deck.sync_menus(cx);
}
Err(error) => deck.engine_failed(&error.to_string(), cx),
}
cx.notify();
// The kernel may have moved again meanwhile.
deck.reread(id, &view, cx);
},
cx,
);
}
/// Reads slides and design again once input lands.
pub fn force_reread(&mut self, id: u64, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |deck, _, cx| deck.force_reread(id, cx));
}
let Some(tab) = self.tab_mut(id) else {
return;
};
tab.extra.read = None;
let view = tab.view.clone();
self.reread(id, &view, cx);
}
/// Checks the View menu's snapping to match the shown deck.
pub fn sync_menus(&mut self, cx: &mut Context<Self>) {
let Some(objects) = self.active_tab().and_then(|tab| tab.extra.objects.as_ref()) else {
return;
};
let on = (objects.snap_grid, objects.snap_guides, self.remarks_open);
if self.snap_menus != Some(on) {
self.snap_menus = Some(on);
ely_ui::set_document_menus(AppId::Show, &ely_ui::commands::FILE, menus(on, cx), cx);
}
}
/// Reads the font families once, off the window.
fn read_fonts(&mut self, id: u64, cx: &mut Context<Self>) {
if !self.fonts.is_empty() || self.reading_fonts {
return;
}
let Some(doc) = self.tab_mut(id).map(|tab| tab.doc.clone()) else {
return;
};
self.reading_fonts = true;
ely_lok::read_off(
&self.core.worker,
&doc,
|worker, doc| ely_lok::commands::fonts(&worker.values(doc, ".uno:CharFontName")?),
|deck: &mut Self, read, cx| {
deck.reading_fonts = false;
match read {
Ok(fonts) => deck.fonts = fonts.into_iter().map(Into::into).collect(),
Err(error) => deck.engine_failed(&format!("the font list: {error}"), cx),
}
cx.notify();
},
cx,
);
}
/// Marks every slide's picture stale.
pub fn repaint_all(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(tab) = self.tab_mut(id) else {
return;
};
tab.extra.stale = tab.extra.thumbs.keys().cloned().collect();
self.paint_next(id, cx);
}
fn body(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let Some(tab) = self.active_tab() else {
let title = match self.core.loading.first() {
Some((_, name)) => Self::text(cx, "office.loading", &[("name", name)]),
None => Self::text(cx, "command.blocked.no_document", &[]),
};
return EmptyState::new("show-empty", IconName::Monitor, title).into_any_element();
};
let view = tab.view.clone();
let guides = self.guide_layer(&view, cx);
let marks = self.remark_layer(&view, cx);
let playing = self.media_layer(&view, window, cx);
let preview = self.preview_layer(&view, window, cx);
let notes = self.notes_pane(window, cx);
div()
.size_full()
.flex()
.flex_col()
.children(self.stopped_banner(cx).or_else(|| self.risk_banner(cx)))
.child(
div()
.flex_1()
.min_h_0()
.relative()
.child(view)
.children(guides)
.children(marks)
.children(playing)
.children(preview),
)
.children(notes)
.into_any_element()
}
}
impl Render for Deck {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.release_pictures(window);
let title = self
.active_tab()
.map_or_else(|| AppId::Show.name().into(), |tab| tab.name.clone());
let shown = self.active_tab().is_some();
let state = self.save_state(cx);
let status = StatusBar::new()
.left(ely_gpui_component::shell::StatusBarItem::new("show-state").label(state))
.when_some(self.zoom_control(cx), |status, zoom| status.right(zoom));
let shell = Shell::new(title, &self.core.focus)
.tabs(self.tab_strip(cx))
.left(shown.then(|| self.strip.clone().into()))
.right(shown.then(|| match self.remarks_open {
true => self.remarks_pane.clone().into(),
false => self.panel.clone().into(),
}))
.status(status)
.child(self.body(window, cx));
let root = div()
.size_full()
.key_context(commands::CONTEXT)
.track_focus(&self.core.focus)
.on_action(cx.listener(|deck, _: &commands::New, _, cx| deck.ask_new(cx)))
.on_action(cx.listener(Self::on_open))
.on_action(cx.listener(Self::on_save))
.on_action(cx.listener(Self::on_save_as))
.on_action(cx.listener(Self::on_export))
.on_action(cx.listener(Self::on_print))
.on_action(cx.listener(Self::on_quit))
.on_action(cx.listener(Self::on_close_tab))
.child(shell)
.children(self.core_dialog(cx))
.children(self.dialog_element(cx));
let root = crate::slideshow::bind(crate::insert::bind(crate::slides::bind(root, cx), cx), cx);
Self::bind_zoom(root, cx)
}
}
impl Zooming for Deck {
const FITS: bool = true;
}
+361
View File
@@ -0,0 +1,361 @@
//! The design panel: layout, master, theme, size (SHOW-01, 02).
use std::collections::HashMap;
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
forms::{Choice, NumberInput, Select},
layout::ScrollArea,
};
use ely_lok::show::LAYOUTS;
use ely_office::prelude::*;
use gpui::{
AnyElement, App, Context, IntoElement, ParentElement as _, Render, SharedString, Styled as _,
WeakEntity, Window, div,
};
use crate::deck::Deck;
/// Slide sizes in 1/100 mm.
const SIZES: [(&str, (i64, i64)); 5] = [
("wide", (33_867, 19_050)),
("hd", (28_000, 15_750)),
("standard", (25_400, 19_050)),
("a4", (29_700, 21_000)),
("letter", (27_940, 21_590)),
];
/// Twips to 1/100 mm.
pub fn mm100((w, h): (i64, i64)) -> (i64, i64) {
((w * 127 + 36) / 72, (h * 127 + 36) / 72)
}
/// The preset a 1/100 mm size matches, within 0.5 mm.
fn preset((w, h): (i64, i64)) -> Option<&'static str> {
SIZES
.iter()
.find(|(_, (pw, ph))| (pw - w).abs() <= 50 && (ph - h).abs() <= 50)
.map(|(key, _)| *key)
}
impl Deck {
/// Runs a design command; the next read shows what changed.
pub fn design_command(&mut self, id: &str, values: &[&str], cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
return;
};
self.run_in(Some(tab), id, values, cx);
self.force_reread(tab, cx);
}
/// Resizes the slides; the asked size shows until read back.
pub(crate) fn resize(&mut self, (w, h): (i64, i64), cx: &mut Context<Self>) {
let Some(tab) = self.core.active.and_then(|id| self.tab_mut(id)) else {
return;
};
let (sw, sh) = tab.extra.shown_size();
if (sw - w).abs() <= 2 && (sh - h).abs() <= 2 {
log::info!("show: the slides already measure {w} x {h}");
return;
}
tab.extra.size_asked = Some((w, h));
let (w, h) = (w.to_string(), h.to_string());
self.design_command("slide.size", &[&w, &h, "true"], cx);
}
/// Enters or leaves the master view.
pub(crate) fn masters(&mut self, on: bool, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
let id = tab.id;
self.run_in(
Some(id),
if on { "master.edit" } else { "master.close" },
&[],
cx,
);
self.force_reread(id, cx);
}
}
/// The right column over the active deck.
pub struct Panel {
pub(crate) deck: WeakEntity<Deck>,
/// Per field: value sent, value read, until the next read.
pub(crate) sent: HashMap<String, (i64, Option<i64>)>,
/// The tab and read the sends belong to.
pub(crate) seen: Option<(u64, u64)>,
/// The effect picked in the list.
pub(crate) effect: Option<usize>,
}
impl Panel {
pub fn new(deck: WeakEntity<Deck>, cx: &mut Context<Self>) -> Self {
if let Some(deck) = deck.upgrade() {
cx.observe(&deck, |_, _, cx| cx.notify()).detach();
}
Panel {
deck,
sent: HashMap::new(),
seen: None,
effect: None,
}
}
/// The kernel's font families, once read.
pub(crate) fn fonts(&self, cx: &App) -> Vec<SharedString> {
self.deck
.upgrade()
.map(|deck| deck.read(cx).fonts.clone())
.unwrap_or_default()
}
}
/// Runs `then` on the deck behind `deck`.
pub(crate) fn on_deck(
deck: &WeakEntity<Deck>,
cx: &mut App,
then: impl FnOnce(&mut Deck, &mut Context<Deck>),
) {
if let Some(deck) = deck.upgrade() {
deck.update(cx, then);
}
}
impl Render for Panel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(deck) = self.deck.upgrade() else {
return div();
};
let Some(objects) = deck
.read(cx)
.active_tab()
.and_then(|tab| tab.extra.objects.clone())
else {
return div();
};
let top = match &objects.selection {
Some(selection) => self.object_grid(selection, window, cx).into_any_element(),
None => self.design_view(cx),
};
let list = (!objects.objects.is_empty()).then(|| {
div().px_3().child(DescriptionList::new().stacked().item(
Deck::text(cx, "show.objects", &[]),
self.object_list(&objects.objects, cx),
))
});
div().size_full().child(
ScrollArea::new("show-panel-scroll").vertical().size_full().child(
div()
.flex()
.flex_col()
.gap_3()
.pb_3()
.child(top)
.children(self.motion_view(objects.selection.is_some(), cx))
.children(list),
),
)
}
}
impl Panel {
/// Layout, master, theme and size of the shown slide.
fn design_view(&self, cx: &mut Context<Self>) -> AnyElement {
let label = |key: &str| Deck::text(cx, key, &[]);
let Some(deck) = self.deck.upgrade() else {
return div().into_any_element();
};
let read = deck.read(cx);
let Some(tab) = read.active_tab() else {
return div().into_any_element();
};
let Some(design) = tab.extra.design.clone() else {
return div().into_any_element();
};
let (masters, size) = (tab.extra.masters, tab.extra.shown_size());
let weak = self.deck.clone();
let theme = Select::new(
"show-theme",
design
.themes
.iter()
.map(|name| Choice::new(name.clone(), name.clone())),
)
.label(label("show.design.theme"))
.selected(design.theme.clone())
.on_change({
let weak = weak.clone();
move |value, _, cx| {
let value = value.to_string();
on_deck(&weak, cx, |deck, cx| {
deck.design_command("slide.theme", &[&value], cx)
})
}
});
let mut rows = DescriptionList::new();
if !masters {
let mut layouts: Vec<Choice> = LAYOUTS
.iter()
.map(|n| Choice::new(n.to_string(), label(&format!("show.layout.{n}"))))
.collect();
if !LAYOUTS.contains(&design.layout) {
layouts.push(Choice::new(
design.layout.to_string(),
label("show.layout.other"),
));
}
let layout = Select::new("show-layout", layouts)
.label(label("show.design.layout"))
.selected(design.layout.to_string())
.on_change({
let weak = weak.clone();
move |value, _, cx| {
let value = value.to_string();
on_deck(&weak, cx, |deck, cx| {
deck.design_command("slide.layout", &[&value], cx)
})
}
});
let master = Select::new(
"show-master",
design
.masters
.iter()
.map(|name| Choice::new(name.clone(), name.clone())),
)
.label(label("show.design.master"))
.selected(design.master.clone())
.on_change({
let weak = weak.clone();
move |value, _, cx| {
let value = value.to_string();
on_deck(&weak, cx, |deck, cx| {
deck.design_command("slide.master", &[&value], cx)
})
}
});
rows = rows
.item(label("show.design.layout"), field(layout))
.item(label("show.design.master"), field(master));
}
rows = rows.item(label("show.design.theme"), field(theme));
if !masters {
rows = size_rows(rows, &weak, size, cx);
}
let toggle = {
let weak = weak.clone();
let key = if masters {
"show.masters.close"
} else {
"show.masters.edit"
};
Button::new("show-masters", label(key))
.variant(ButtonVariant::Ghost)
.on_click(move |_, _, cx| on_deck(&weak, cx, |deck, cx| deck.masters(!masters, cx)))
};
let insert = masters.then(|| {
Button::new("show-master-new", label("show.masters.new"))
.variant(ButtonVariant::Ghost)
.on_click(move |_, _, cx| {
on_deck(&weak, cx, |deck, cx| {
deck.run_active("master.insert", &[], cx);
if let Some(id) = deck.core.active {
deck.force_reread(id, cx);
}
})
})
});
div()
.size_full()
.p_3()
.flex()
.flex_col()
.gap_3()
.child(rows)
.child(div().flex().gap_2().child(toggle).children(insert))
.into_any_element()
}
}
fn field(control: impl IntoElement) -> gpui::Div {
div().w_full().max_w_40().child(control)
}
/// The size preset, then width and height in centimetres.
fn size_rows(
rows: DescriptionList,
weak: &WeakEntity<Deck>,
size: (i64, i64),
cx: &App,
) -> DescriptionList {
let label = |key: &str| Deck::text(cx, key, &[]);
let current = preset(size).unwrap_or("custom");
let mut sizes: Vec<Choice> = SIZES
.iter()
.map(|(key, _)| Choice::new(*key, label(&format!("show.size.{key}"))))
.collect();
if current == "custom" {
sizes.push(Choice::new("custom", label("show.size.custom")));
}
let picking = weak.clone();
let presets = Select::new("show-size", sizes)
.label(label("show.design.size"))
.selected(current)
.on_change(move |value, _, cx| {
let Some((_, size)) = SIZES.iter().find(|(key, _)| *key == value.as_ref()) else {
return;
};
let size = *size;
on_deck(&picking, cx, |deck, cx| deck.resize(size, cx))
});
let (w, h) = size;
let side = |id: &'static str, value: i64, width: bool| {
let weak = weak.clone();
let name = label(if width {
"show.design.width"
} else {
"show.design.height"
});
NumberInput::new(id, value as f64 / 1000.0)
.label(name)
.range(1.0, 300.0)
.step(0.1)
.precision(2)
.on_commit(move |cm, _, cx| {
let value = (cm * 1000.0).round() as i64;
// Build on a size still on its way.
on_deck(&weak, cx, |deck, cx| {
let Some((w, h)) = deck.active_tab().map(|tab| tab.extra.shown_size()) else {
return;
};
deck.resize(if width { (value, h) } else { (w, value) }, cx)
})
})
};
rows.item(label("show.design.size"), field(presets))
.item(
label("show.design.width"),
div().w_full().max_w_32().child(side("show-width", w, true)),
)
.item(
label("show.design.height"),
div().w_full().max_w_32().child(side("show-height", h, false)),
)
}
#[cfg(test)]
mod tests {
#[test]
fn sizes_match_presets_by_millimetre() {
assert_eq!(super::preset(super::mm100((15875, 8930))), Some("hd"));
assert_eq!(
super::preset(super::mm100((14401, 10801))),
Some("standard")
);
assert_eq!(super::preset((10000, 10000)), None);
assert_eq!(super::mm100((14400, 10800)), (25400, 19050));
}
}
+88
View File
@@ -0,0 +1,88 @@
//! ElyShow's one open dialog.
use ely_gpui_component::forms::TextInput;
use gpui::{App, Context, Entity, IntoElement as _};
use crate::deck::Deck;
/// What the open dialog asks.
pub enum Dialog {
New(crate::new::Choosing),
Table {
tab: u64,
columns: u16,
rows: u16,
},
Chart(Box<crate::chart::Editing>),
/// A new comment, or an edit of one.
Remark {
tab: u64,
edit: Option<String>,
field: Entity<TextInput>,
},
Show(crate::slideshow::start::Starting),
Output(crate::output::Output),
Check(Vec<crate::check::Finding>),
}
/// Changes the open dialog's state.
pub(crate) fn edit(
this: &Entity<Deck>,
cx: &mut App,
change: impl FnOnce(&mut Dialog, &mut Context<Deck>),
) {
this.update(cx, |deck, cx| {
if let Some(mut dialog) = deck.dialog.take() {
change(&mut dialog, cx);
deck.dialog = Some(dialog);
}
cx.notify();
});
}
impl Deck {
/// A read that will open a dialog; earlier ones lapse.
pub fn ask_dialog(&mut self) -> u64 {
self.dialog_asked += 1;
self.dialog_asked
}
/// A dialog or tab change ends earlier asks.
pub(crate) fn watch_dialogs(&mut self) {
let now = (self.dialog.as_ref().map(std::mem::discriminant), self.core.active);
if self.dialog_seen != now {
self.dialog_seen = now;
self.dialog_asked += 1;
}
}
/// Whether read `asked` may open its dialog now.
pub fn dialog_due(&self, asked: u64, id: u64) -> bool {
self.dialog_asked == asked && self.dialog.is_none() && self.core.active == Some(id)
}
/// The open dialog, drawn.
pub fn dialog_element(&mut self, cx: &mut Context<Self>) -> Option<gpui::AnyElement> {
if matches!(self.dialog, Some(Dialog::New(_))) {
return self.new_dialog(cx);
}
Some(match self.dialog.as_ref()? {
Dialog::New(_) => unreachable!("drawn above"),
Dialog::Table { columns, rows, .. } => self.table_dialog((*columns, *rows), cx),
Dialog::Chart(editing) => self.chart_dialog(editing, cx),
Dialog::Remark { tab, edit, field } => self.remark_dialog((*tab, edit), field, cx),
Dialog::Show(starting) => self.show_dialog(starting, cx),
Dialog::Check(found) => self.check_dialog(found, cx),
Dialog::Output(output) => {
let closing = cx.entity();
let dismiss = move |_: &mut gpui::Window, cx: &mut App| {
closing.update(cx, |deck, cx| {
deck.dialog = None;
cx.notify();
})
};
crate::output::output_dialog(cx.entity(), output, dismiss, cx).into_any_element()
}
})
}
}
+183
View File
@@ -0,0 +1,183 @@
//! Guides over the canvas; LOK paints none (SHOW-06).
use ely_gpui_component::theme::ActiveTheme as _;
use ely_lok::{
LokView,
show::{Guide, GuideKind},
};
use ely_office::prelude::*;
use gpui::{
AnyElement, Context, CursorStyle, Entity, InteractiveElement as _, IntoElement, MouseButton,
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, SharedString, Styled as _,
div, px,
};
use crate::deck::Deck;
/// The strip a pointer grabs a guide by.
const GRIP: f32 = 6.0;
/// A guide being dragged, and where it is now.
#[derive(Clone, Copy)]
pub struct Drag {
pub index: usize,
pub kind: GuideKind,
pub at: Point<Pixels>,
}
fn twips(mm100: i64) -> i32 {
(mm100 * 72 / 127) as i32
}
fn mm100(twips: i32) -> i64 {
i64::from(twips) * 127 / 72
}
impl Deck {
/// The guide lines and their grips, over `view`.
pub fn guide_layer(
&self,
view: &Entity<LokView>,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
let tab = self.active_tab()?;
let guides = tab.extra.objects.as_ref()?.guides.clone();
if guides.is_empty() {
return None;
}
let (w, h) = tab.extra.shown_size();
let lok = view.read(cx);
let top_left = lok.place((0, 0));
let bottom_right = lok.place((twips(w), twips(h)));
let color = cx.theme().colors.accent;
let drag = self.guide_drag;
let places: Vec<Point<Pixels>> = guides
.iter()
.enumerate()
.map(
|(index, guide)| match drag.filter(|drag| drag.index == index) {
Some(drag) => drag.at,
None => lok.place((twips(guide.at.0), twips(guide.at.1))),
},
)
.collect();
let bounds = (top_left, bottom_right);
let lines: Vec<AnyElement> = guides
.iter()
.enumerate()
.map(|(index, guide)| self.guide_line((index, guide), places[index], bounds, color, cx))
.collect();
let mut layer = div().absolute().inset_0().children(lines);
if drag.is_some() {
// Catches the pointer anywhere until release.
layer = layer.child(
div()
.id("show-guide-drag")
.absolute()
.inset_0()
.on_mouse_move(cx.listener(|deck, event: &MouseMoveEvent, _, cx| {
deck.drag_guide(event.position, cx)
}))
.on_mouse_up(
MouseButton::Left,
cx.listener(|deck, _: &MouseUpEvent, _, cx| deck.drop_guide(cx)),
),
);
}
Some(layer.into_any_element())
}
fn guide_line(
&self,
(index, guide): (usize, &Guide),
at: Point<Pixels>,
(from, to): (Point<Pixels>, Point<Pixels>),
color: gpui::Hsla,
cx: &mut Context<Self>,
) -> AnyElement {
let id = SharedString::from(format!("show-guide-{index}"));
let kind = guide.kind;
let grip = div()
.id(id)
.absolute()
.flex()
.items_center()
.justify_center()
.on_mouse_down(
MouseButton::Left,
cx.listener(move |deck, event: &gpui::MouseDownEvent, _, cx| {
let origin = deck.view_origin(cx);
deck.guide_drag = Some(Drag {
index,
kind,
at: event.position - origin,
});
cx.stop_propagation();
cx.notify();
}),
);
let half = px(GRIP / 2.0);
match kind {
GuideKind::Horizontal => grip
.left(from.x)
.w(to.x - from.x)
.top(at.y - half)
.h(px(GRIP))
.cursor(CursorStyle::ResizeUpDown)
.child(div().w_full().h(px(1.0)).bg(color)),
GuideKind::Vertical => grip
.top(from.y)
.h(to.y - from.y)
.left(at.x - half)
.w(px(GRIP))
.cursor(CursorStyle::ResizeLeftRight)
.child(div().h_full().w(px(1.0)).bg(color)),
GuideKind::Point => grip
.left(at.x - half)
.top(at.y - half)
.size(px(GRIP))
.cursor(CursorStyle::Crosshair)
.child(div().size(px(GRIP)).border_1().border_color(color)),
}
.into_any_element()
}
fn view_origin(&self, cx: &Context<Self>) -> Point<Pixels> {
self.active_tab()
.map_or_else(Point::default, |tab| tab.view.read(cx).origin())
}
fn drag_guide(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
let origin = self.view_origin(cx);
if let Some(drag) = self.guide_drag.as_mut() {
drag.at = position - origin;
cx.notify();
}
}
/// Moves the guide where dropped; off the slide deletes it.
fn drop_guide(&mut self, cx: &mut Context<Self>) {
let Some(drag) = self.guide_drag.take() else {
return;
};
let Some(tab) = self.active_tab() else {
return;
};
let (w, h) = tab.extra.shown_size();
let (x, y) = tab.view.read(cx).twips_at(drag.at);
let (x, y) = (mm100(x), mm100(y));
let off = match drag.kind {
GuideKind::Horizontal => !(0..=h).contains(&y),
GuideKind::Vertical => !(0..=w).contains(&x),
GuideKind::Point => !(0..=w).contains(&x) || !(0..=h).contains(&y),
};
let index = drag.index.to_string();
if off {
log::info!("show: guide {index} dragged off the slide");
self.object_command("guide.delete", &[&index, "true"], cx);
} else {
self.object_command("guide.move", &[&index, &x.to_string(), &y.to_string()], cx);
}
cx.notify();
}
}
+152
View File
@@ -0,0 +1,152 @@
//! The Insert and View menus (SHOW-03 to 06).
use ely_office::prelude::*;
use gpui::{App, Context, Div, InteractiveElement as _, Menu, MenuItem, actions};
use crate::deck::Deck;
actions!(
show,
[
InsertTextBox,
InsertRectangle,
InsertEllipse,
InsertTriangle,
InsertLine,
InsertArrow,
InsertConnector,
InsertPicture,
InsertMedia,
InsertTable,
InsertChart,
InsertRemark,
ShowRemarks,
SnapToGrid,
SnapToGuides,
AddHorizontalGuide,
AddVerticalGuide
]
);
/// Insert and View; View checks what is on.
pub fn menus((grid, guides, remarks): (bool, bool, bool), cx: &App) -> [Menu; 2] {
let label = |key: &str| Deck::text(cx, key, &[]);
let insert = Menu::new(label("menu.insert")).items([
MenuItem::action(label("show.insert.text"), InsertTextBox),
MenuItem::separator(),
MenuItem::action(label("show.insert.rectangle"), InsertRectangle),
MenuItem::action(label("show.insert.ellipse"), InsertEllipse),
MenuItem::action(label("show.insert.triangle"), InsertTriangle),
MenuItem::action(label("show.insert.line"), InsertLine),
MenuItem::action(label("show.insert.arrow"), InsertArrow),
MenuItem::action(label("show.insert.connector"), InsertConnector),
MenuItem::separator(),
MenuItem::action(label("show.insert.picture"), InsertPicture),
MenuItem::action(label("show.insert.media"), InsertMedia),
MenuItem::action(label("show.insert.table"), InsertTable),
MenuItem::action(label("show.insert.chart"), InsertChart),
MenuItem::separator(),
MenuItem::action(label("show.insert.remark"), InsertRemark),
]);
let view = Menu::new(label("menu.view")).items([
MenuItem::action(label("show.view.remarks"), ShowRemarks).checked(remarks),
MenuItem::separator(),
MenuItem::action(label("show.view.snap_grid"), SnapToGrid).checked(grid),
MenuItem::action(label("show.view.snap_guides"), SnapToGuides).checked(guides),
MenuItem::separator(),
MenuItem::action(label("show.view.guide_across"), AddHorizontalGuide),
MenuItem::action(label("show.view.guide_down"), AddVerticalGuide),
MenuItem::separator(),
]);
let view = view.items(ely_office::zoom_items(true, cx));
[insert, view]
}
/// Binds an action to an object command.
macro_rules! run {
($root:expr, $cx:expr, $action:ty, $id:literal, $values:expr) => {
$root.on_action(
$cx.listener(|deck, _: &$action, _, cx| deck.object_command($id, $values, cx)),
)
};
}
pub fn bind(root: Div, cx: &mut Context<Deck>) -> Div {
let root = run!(root, cx, InsertTextBox, "draw.text", &["true"]);
let root = run!(
root,
cx,
InsertRectangle,
"draw.shape",
&["rectangle", "true"]
);
let root = run!(root, cx, InsertEllipse, "draw.shape", &["ellipse", "true"]);
let root = run!(
root,
cx,
InsertTriangle,
"draw.shape",
&["isosceles-triangle", "true"]
);
let root = run!(root, cx, InsertLine, "draw.line", &[]);
let root = run!(root, cx, InsertArrow, "draw.arrow", &[]);
let root = run!(root, cx, InsertConnector, "draw.connector", &[]);
let root = run!(root, cx, SnapToGrid, "view.snap_grid", &[]);
let root = run!(root, cx, SnapToGuides, "view.snap_guides", &[]);
root.on_action(cx.listener(|deck, _: &InsertPicture, _, cx| deck.pick_picture(false, cx)))
.on_action(cx.listener(|deck, _: &InsertMedia, _, cx| deck.pick_media(cx)))
.on_action(cx.listener(|deck, _: &InsertTable, _, cx| deck.ask_table(cx)))
.on_action(cx.listener(|deck, _: &InsertChart, _, cx| deck.ask_chart(cx)))
.on_action(
cx.listener(|deck, _: &InsertRemark, window, cx| deck.ask_remark(None, window, cx)),
)
.on_action(cx.listener(|deck, _: &ShowRemarks, _, cx| deck.toggle_remarks(cx)))
.on_action(cx.listener(|deck, _: &AddHorizontalGuide, _, cx| deck.add_guide(false, cx)))
.on_action(cx.listener(|deck, _: &AddVerticalGuide, _, cx| deck.add_guide(true, cx)))
}
impl Deck {
/// Runs an object command; the next read shows the result.
pub fn object_command(&mut self, id: &str, values: &[&str], cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
return;
};
self.run_in(Some(tab), id, values, cx);
self.force_reread(tab, cx);
}
/// Inserts a picture, or replaces the selected one.
pub fn pick_picture(&mut self, replace: bool, cx: &mut Context<Self>) {
self.pick(false, cx, move |deck, path, cx| {
let url = ely_lok::file_url(&path);
let id = if replace {
"image.replace"
} else {
"image.insert"
};
deck.object_command(id, &[&url], cx)
});
}
/// Shows comments, or properties, in the right column.
fn toggle_remarks(&mut self, cx: &mut Context<Self>) {
self.remarks_open = !self.remarks_open;
self.snap_menus = None;
self.sync_menus(cx);
cx.notify();
}
/// A guide through the middle of the slide.
fn add_guide(&mut self, vertical: bool, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
let (w, h) = tab.extra.shown_size();
let (kind, x, y) = if vertical {
("1", w / 2, 0)
} else {
("0", 0, h / 2)
};
self.object_command("guide.add", &[kind, &x.to_string(), &y.to_string()], cx);
}
}
+73
View File
@@ -0,0 +1,73 @@
//! The shown slide's objects, front last (SHOW-06).
use ely_gpui_component::{
lists::{ListItem, SelectableList},
primitives::{Icon, IconName},
};
use ely_lok::show::Object;
use ely_office::prelude::*;
use gpui::{App, Context, IntoElement, SharedString, WeakEntity};
use crate::deck::Deck;
use crate::design::{Panel, on_deck};
/// What the list calls `object`.
fn title(object: &Object, cx: &App) -> SharedString {
if !object.name.is_empty() {
return object.name.clone().into();
}
Deck::text(cx, &format!("show.kind.{}", object.kind), &[])
}
impl Panel {
pub(crate) fn object_list(
&self,
objects: &[Object],
cx: &mut Context<Self>,
) -> impl IntoElement {
let mut list = SelectableList::new("show-objects")
.label(Deck::text(cx, "show.objects", &[]))
.multiple();
let mut chosen = Vec::new();
for (at, object) in objects.iter().enumerate() {
let key = SharedString::from(at.to_string());
let mut item = ListItem::new(
SharedString::from(format!("show-object-{at}")),
title(object, cx),
);
if !object.placeholder.is_empty() {
let key = format!("show.placeholder.{}", object.placeholder);
item = item.description(Deck::text(cx, &key, &[]));
}
if object.locked {
item = item.trailing(Icon::new(IconName::Lock));
}
if object.selected {
chosen.push(key.clone());
}
list = list.row(key, item);
}
let deck: WeakEntity<Deck> = self.deck.clone();
list.selected(chosen).on_change(move |keys, _, cx| {
let picked: Vec<String> = keys.iter().map(ToString::to_string).collect();
on_deck(&deck, cx, |deck, cx| deck.select_objects(&picked, cx))
})
}
}
impl Deck {
/// Selects the objects at `picked`, in order.
pub fn select_objects(&mut self, picked: &[String], cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
return;
};
if picked.is_empty() {
self.run_in(Some(tab), "object.deselect", &["false"], cx);
}
let adds: Vec<[&str; 2]> =
picked.iter().enumerate().map(|(n, at)| [at.as_str(), if n > 0 { "true" } else { "false" }]).collect();
let commands: Vec<(&str, &[&str])> = adds.iter().map(|values| ("object.select", values.as_slice())).collect();
self.run_all_in(tab, &commands, cx);
self.force_reread(tab, cx);
}
}
+29
View File
@@ -0,0 +1,29 @@
mod chart;
mod check;
mod deck;
mod design;
mod dialog;
mod guides;
mod insert;
mod list;
mod media;
mod motion;
mod new;
mod notes;
mod object;
mod output;
mod preview;
mod remarks;
mod slides;
mod slideshow;
mod table;
mod text;
use ely_platform::AppId;
fn main() {
ely_ui::run(AppId::Show, deck::open);
}
#[cfg(test)]
mod tests;
+356
View File
@@ -0,0 +1,356 @@
//! Slide audio and video: insert, play, explain failures (SHOW-10).
use std::{
path::{Path, PathBuf},
time::{Duration, Instant},
};
use ely_gpui_component::{
buttons::Button, data_display::PropertyGroup, feedback::InlineMessage, primitives::Severity,
theme::ControlSize,
};
use ely_lok::{LokView, show::Media};
use ely_office::prelude::*;
use ely_platform::media::{Player, State};
use gpui::{
AnyElement, App, Context, Entity, IntoElement, ParentElement as _, Styled as _, SurfaceSource,
TaskExt as _, Window, div,
};
use crate::deck::Deck;
use crate::design::{Panel, on_deck};
/// How long a file may take to show it plays.
const PROBE: Duration = Duration::from_secs(10);
/// A sound's frame on the slide, 1/100 mm.
const SOUND: (i64, i64) = (2000, 2000);
/// A file checked before it goes in.
pub struct Probe {
tab: u64,
path: PathBuf,
player: Player,
started: Instant,
}
/// The media object playing: tab, slide, place, URL.
pub struct Playing {
tab: u64,
slide: String,
index: usize,
url: String,
player: Player,
frame: Option<SurfaceSource>,
}
/// `m:ss`.
fn clock(seconds: f64) -> String {
let whole = seconds.max(0.0).round() as u64;
format!("{}:{:02}", whole / 60, whole % 60)
}
fn name(path: &Path) -> String {
path.file_name().map_or_else(
|| path.display().to_string(),
|n| n.to_string_lossy().into_owned(),
)
}
impl Deck {
/// Picks a sound or video, checking it plays first.
pub fn pick_media(&mut self, cx: &mut Context<Self>) {
self.pick(false, cx, |deck, path, cx| deck.probe_media(path, cx));
}
fn probe_media(&mut self, path: PathBuf, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
return;
};
let player = match Player::open(&path) {
Ok(player) => player,
Err(error) => return self.media_refused(&path, &error.to_string(), cx),
};
log::info!("show: checks a media file");
let started = Instant::now();
self.probe = Some(Probe {
tab,
path,
player,
started,
});
cx.spawn(async move |this, cx| -> gpui::Result<()> {
loop {
cx.background_executor()
.timer(Duration::from_millis(50))
.await;
if this.update(cx, |deck, cx| deck.probe_tick(cx))? {
return Ok(());
}
}
})
.detach_and_log_err(cx);
}
/// One look at the file being checked; true when settled.
fn probe_tick(&mut self, cx: &mut Context<Self>) -> bool {
let Some(probe) = &self.probe else {
return true;
};
let state = probe.player.state();
if state == State::Loading && probe.started.elapsed() < PROBE {
return false;
}
let probe = self.probe.take().expect("checked above");
match state {
State::Ready { video, .. } => self.insert_media(&probe, video, cx),
State::Failed(reason) => self.media_refused(&probe.path, &reason, cx),
State::Loading => self.media_refused(&probe.path, "it did not load in time", cx),
}
true
}
fn insert_media(&mut self, probe: &Probe, video: Option<(u32, u32)>, cx: &mut Context<Self>) {
let Some(tab) = self.tab_mut(probe.tab) else {
return;
};
let slide = tab.extra.shown_size();
// Pixels at 96 per inch, within most of the slide.
let (w, h) = match video {
Some((w, h)) => {
let (w, h) = (i64::from(w) * 2540 / 96, i64::from(h) * 2540 / 96);
let fit = (slide.0 as f64 * 0.8 / w as f64)
.min(slide.1 as f64 * 0.8 / h as f64)
.min(1.0);
((w as f64 * fit) as i64, (h as f64 * fit) as i64)
}
None => SOUND,
};
let url = ely_lok::file_url(&probe.path);
log::info!("show: inserts media {w} x {h}");
self.run_in(
Some(probe.tab),
"media.insert",
&[&url, "false", &w.to_string(), &h.to_string()],
cx,
);
self.force_reread(probe.tab, cx);
}
fn media_refused(&mut self, path: &Path, reason: &str, cx: &mut Context<Self>) {
log::warn!("show: media refused: {reason}");
let text = Deck::text(
cx,
"show.media.refused",
&[("name", &name(path)), ("reason", reason)],
);
self.toast(text, Severity::Danger, cx);
}
/// The shown slide's name and its media at `index`.
fn here(&self, index: usize) -> Option<(u64, String, Media)> {
let tab = self.active_tab()?;
let slide = tab
.extra
.slides
.list
.get(tab.extra.slides.current)?
.name
.clone();
let media = tab.extra.media.iter().find(|media| media.index == index)?;
Some((tab.id, slide, media.clone()))
}
/// Stops playback unless the same object is still shown.
pub(crate) fn check_playing(&mut self) {
let Some(playing) = &self.playing else {
return;
};
let same = self.here(playing.index).is_some_and(|(tab, slide, media)| {
(tab, slide, media.url) == (playing.tab, playing.slide.clone(), playing.url.clone())
});
if !same {
log::info!("show: the playing media object is gone from view");
playing.player.pause();
self.playing = None;
}
}
/// The media object picked on the shown slide.
pub(crate) fn picked_media(&self) -> Option<(u64, Media)> {
let tab = self.active_tab()?;
let objects = tab.extra.objects.as_ref()?;
let mut picked = objects
.objects
.iter()
.enumerate()
.filter(|(_, o)| o.selected);
let (at, _) = picked.next().filter(|_| picked.next().is_none())?;
let media = tab.extra.media.iter().find(|media| media.index == at)?;
Some((tab.id, media.clone()))
}
/// Plays or pauses the picked media object.
pub fn toggle_media(&mut self, cx: &mut Context<Self>) {
let Some((_, media)) = self.picked_media() else {
return;
};
let Some((tab, slide, _)) = self.here(media.index) else {
return;
};
self.check_playing();
if let Some(playing) = self.playing.as_ref().filter(|p| p.index == media.index) {
match (playing.player.playing(), playing.player.ended()) {
(true, _) => playing.player.pause(),
// A finished clip starts over.
(false, true) => {
playing.player.seek(0.0);
playing.player.play();
}
(false, false) => playing.player.play(),
}
return cx.notify();
}
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 object {}", media.index);
player.play();
self.playing = Some(Playing {
tab,
slide,
index: media.index,
url: media.url.clone(),
player,
frame: None,
});
}
Err(error) => self.media_refused(Path::new(&media.url), &error, cx),
}
cx.notify();
}
/// The playing video over its object; frames while it runs.
pub(crate) fn media_layer(
&mut self,
view: &Entity<LokView>,
window: &mut Window,
cx: &App,
) -> Option<AnyElement> {
self.check_playing();
let index = self.playing.as_ref()?.index;
let tab = self.active_tab()?;
let rect = tab.extra.objects.as_ref()?.objects.get(index)?.rect;
let repeat = tab.extra.media.iter().any(|m| m.index == index && m.repeat);
let playing = self.playing.as_mut()?;
// A failure shows in the panel, from the player's state.
let drawing = matches!(playing.player.tick(repeat), Ok(true));
if let Some(frame) = playing.player.frame() {
#[cfg(target_os = "macos")]
{
playing.frame = Some(frame.into());
}
#[cfg(not(target_os = "macos"))]
match frame {}
}
if drawing {
window.request_animation_frame();
}
let frame = playing.frame.clone()?;
let twips = |mm100: i64| (mm100 * 72 / 127) as i32;
let (x, y, w, h) = rect;
let lok = view.read(cx);
let (top_left, bottom_right) = (
lok.place((twips(x), twips(y))),
lok.place((twips(x + w), twips(y + h))),
);
let bounds = (top_left, bottom_right - top_left);
Some(video(frame, bounds))
}
}
/// A video picture over its object.
#[cfg(target_os = "macos")]
pub(crate) fn video(
frame: SurfaceSource,
(at, size): (gpui::Point<gpui::Pixels>, gpui::Point<gpui::Pixels>),
) -> AnyElement {
gpui::surface(frame)
.absolute()
.left(at.x)
.top(at.y)
.w(size.x)
.h(size.y)
.into_any_element()
}
/// No platform here makes video pictures.
#[cfg(not(target_os = "macos"))]
pub(crate) fn video(
frame: SurfaceSource,
_: (gpui::Point<gpui::Pixels>, gpui::Point<gpui::Pixels>),
) -> AnyElement {
match frame {}
}
impl Panel {
/// Play, where it is, and why it cannot play.
pub(crate) fn media_group(&self, cx: &mut Context<Self>) -> Option<PropertyGroup> {
let deck = self.deck.upgrade()?;
let (tab, media) = deck.read(cx).picked_media()?;
let label = |key: &str| Deck::text(cx, key, &[]);
let group = PropertyGroup::new(label("show.media.group"));
if media.missing {
let what = ely_lok::file_path(&media.url)
.map_or(media.url.clone(), |path| path.display().to_string());
let why = Deck::text(cx, "show.media.missing", &[("path", &what)]);
return Some(group.row(
label("show.media.file"),
InlineMessage::new(Severity::Danger, why),
));
}
let read = deck.read(cx);
let playing = read
.playing
.as_ref()
.filter(|p| (p.tab, p.index) == (tab, media.index));
let (running, state) = match playing {
Some(p) => (
p.player.playing(),
Some((p.player.state(), p.player.position())),
),
None => (false, None),
};
if let Some((State::Failed(reason), _)) = &state {
let why = Deck::text(cx, "show.media.failed", &[("reason", reason)]);
return Some(group.row(
label("show.media.file"),
InlineMessage::new(Severity::Danger, why),
));
}
let weak = self.deck.clone();
let toggle = Button::new(
"show-media-play",
label(if running {
"show.media.pause"
} else {
"show.media.play"
}),
)
.size(ControlSize::Sm)
.on_click(move |_, _, cx| on_deck(&weak, cx, |deck, cx| deck.toggle_media(cx)));
let time = match state {
Some((State::Ready { seconds, .. }, at)) => {
format!("{} / {}", clock(at), clock(seconds))
}
_ => clock(0.0),
};
let row = div()
.flex()
.items_center()
.gap_2()
.child(toggle)
.child(time);
Some(group.row(label("show.media.play"), row))
}
}
+432
View File
@@ -0,0 +1,432 @@
//! The shown slide's transition and effects (SHOW-09).
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
forms::{Choice, NumberInput, Select, Switch},
lists::{ListItem, SelectableList},
menus::{DropdownMenu, Menu, MenuItem},
theme::ControlSize,
};
use ely_lok::show::{EFFECTS, Effect, TRANSITIONS, Transition};
use ely_office::prelude::*;
use gpui::{
AnyElement, App, Context, IntoElement, ParentElement as _, SharedString, Styled as _, div,
};
use serde_json::json;
use crate::deck::{Deck, Extra};
use crate::design::{Panel, on_deck};
/// Ely's name for an effect, else the kernel's.
fn effect_name(effect: &Effect, cx: &App) -> SharedString {
match EFFECTS.contains(&effect.preset.as_str()) {
true => Deck::text(cx, &format!("show.effect.{}", &effect.preset[4..]), &[]),
false => effect.label.clone().into(),
}
}
/// Seconds sent once, though Enter and blur both commit.
pub(crate) fn dedupe(
panel: gpui::Entity<Panel>,
field: impl Into<String>,
now: f64,
send: impl Fn(&mut Deck, f64, &mut Context<Deck>) + Clone + 'static,
) -> impl Fn(f64, &mut gpui::Window, &mut App) + 'static {
let ms = |seconds: f64| (seconds * 1000.0).round() as i64;
let field = field.into();
move |n, _, cx| {
let send = send.clone();
let deliver = move |deck: &mut Deck, cx: &mut Context<Deck>| send(deck, n, cx);
crate::object::commit(&panel, field.clone(), (ms(n), Some(ms(now))), deliver, cx)
}
}
/// A field of one effect on one slide.
pub(crate) fn effect_key(field: &str, slide: &str, (at, effect): (usize, &Effect)) -> String {
format!("effect.{field}.{slide}.{at}.{}", effect.hash)
}
fn number(id: &'static str, label: SharedString, now: f64, range: (f64, f64)) -> NumberInput {
NumberInput::new(id, now)
.label(label)
.range(range.0, range.1)
.step(0.1)
.precision(2)
.size(ControlSize::Sm)
}
impl Extra {
fn slide_name(&self) -> Option<&String> {
Some(&self.slides.list.get(self.slides.current)?.name)
}
/// The transition to show: sent, else read.
pub(crate) fn shown_transition(&self) -> Option<Transition> {
match (&self.transition_asked, self.slide_name()) {
(Some((slide, asked, _)), Some(now)) if slide == now => Some(asked.clone()),
_ => Some(self.motion.as_ref()?.0.clone()),
}
}
/// The input a read may settle: landed before it began.
pub(crate) fn transition_landed(&self, view: &ely_lok::LokView) -> Option<u64> {
let (_, _, seq) = self.transition_asked.as_ref()?;
view.reached(*seq).then_some(*seq)
}
/// Forgets the sent transition once read after it or left.
pub(crate) fn settle_transition(&mut self, landed: Option<u64>) {
let gone = match (&self.transition_asked, self.slide_name()) {
(Some((slide, _, seq)), Some(now)) => slide != now || landed == Some(*seq),
(asked, _) => asked.is_some(),
};
if gone {
self.transition_asked = None;
}
}
}
impl Deck {
/// Edits the transition, atop any unread send.
pub(crate) fn send_transition(
&mut self,
edit: impl FnOnce(&mut Transition),
all: bool,
cx: &mut Context<Self>,
) {
let Some(tab) = self.core.active.and_then(|id| self.tab_mut(id)) else {
return;
};
let (Some(mut next), Some(slide)) = (
tab.extra.shown_transition(),
tab.extra.slide_name().cloned(),
) else {
return;
};
edit(&mut next);
let (id, view) = (tab.id, tab.view.clone());
let json = ely_lok::show::transition_json(&next, all);
self.run_in(Some(id), "slide.transition", &[&json], cx);
let seq = view.read(cx).sent();
if let Some(tab) = self.tab_mut(id) {
tab.extra.transition_asked = Some((slide, next, seq));
}
self.force_reread(id, cx);
}
pub(crate) fn send_effect(
&mut self,
index: usize,
change: serde_json::Value,
cx: &mut Context<Self>,
) {
let mut json = change;
json["index"] = json!(index);
self.design_command("animation.change", &[&json.to_string()], cx);
}
}
impl Panel {
/// The slide's transition and effects; none on a master.
pub(crate) fn motion_view(&self, picked: bool, cx: &mut Context<Self>) -> Option<AnyElement> {
let deck = self.deck.upgrade()?;
let extra = &deck.read(cx).active_tab()?.extra;
let (transition, effects) = (extra.shown_transition()?, extra.motion.clone()?.1);
let slide = extra.slide_name()?.clone();
let rows = self.transition_rows(&transition, &slide, cx);
let effects = self.effect_rows(&effects, (picked, &slide), cx);
Some(
div()
.px_3()
.flex()
.flex_col()
.gap_3()
.child(rows)
.child(effects)
.into_any_element(),
)
}
fn transition_rows(
&self,
now: &Transition,
slide: &str,
cx: &mut Context<Self>,
) -> DescriptionList {
let label = |key: &str| Deck::text(cx, key, &[]);
let weak = self.deck.clone();
let change = move |cx: &mut App, edit: &dyn Fn(&mut Transition)| {
on_deck(&weak, cx, |deck, cx| deck.send_transition(edit, false, cx));
};
let mut choices: Vec<Choice> = (TRANSITIONS.iter())
.map(|id| {
Choice::new(
*id,
label(&format!(
"show.transition.{}",
if id.is_empty() { "none" } else { id }
)),
)
})
.collect();
if !TRANSITIONS.contains(&now.preset.as_str()) {
choices.push(Choice::new(
now.preset.clone(),
label("show.transition.other"),
));
}
let pick = change.clone();
let kind = Select::new("show-transition", choices)
.label(label("show.motion.transition"))
.selected(now.preset.clone())
.on_change(move |value, _, cx| {
let value = value.to_string();
pick(cx, &|t| t.preset = value.clone())
});
let duration = number(
"show-transition-duration",
label("show.motion.duration"),
now.duration,
(0.1, 60.0),
)
.on_commit(dedupe(
cx.entity(),
format!("transition.duration.{slide}"),
now.duration,
|deck, n, cx| deck.send_transition(|t| t.duration = n, false, cx),
));
let flip = change.clone();
let auto = Switch::new("show-transition-auto", now.auto)
.name(label("show.motion.auto"))
.on_change(move |on, _, cx| flip(cx, &|t| t.auto = on));
let mut rows = DescriptionList::new()
.item(label("show.motion.transition"), kind)
.item(label("show.motion.duration"), div().w_full().max_w_32().child(duration))
.item(label("show.motion.auto"), auto);
if now.auto {
let time = number(
"show-transition-time",
label("show.motion.time"),
now.time,
(0.0, 3600.0),
)
.on_commit(dedupe(
cx.entity(),
format!("transition.time.{slide}"),
now.time,
|deck, n, cx| deck.send_transition(|t| t.time = n, false, cx),
));
rows = rows.item(label("show.motion.time"), div().w_full().max_w_32().child(time));
}
let (everyone, preview) = (self.deck.clone(), self.deck.clone());
let buttons = div()
.flex()
.flex_wrap()
.gap_1()
.child(
Button::new("show-transition-all", label("show.motion.all"))
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
on_deck(&everyone, cx, |deck, cx| {
deck.send_transition(|_| {}, true, cx)
})
}),
)
.child(
Button::new(
"show-transition-preview",
label("show.motion.preview_transition"),
)
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
on_deck(&preview, cx, |deck, cx| deck.preview_transition(cx))
}),
);
rows.item(SharedString::default(), buttons)
}
fn effect_rows(
&self,
effects: &[Effect],
(picked, slide): (bool, &str),
cx: &mut Context<Self>,
) -> DescriptionList {
let label = |key: &str| Deck::text(cx, key, &[]);
let current = self.effect.filter(|at| *at < effects.len());
let mut list = SelectableList::new("show-effects").label(label("show.motion.effects"));
for (at, effect) in effects.iter().enumerate() {
let start = label(&format!("show.effect.start.{}", effect.start));
let item = ListItem::new(
SharedString::from(format!("show-effect-{at}")),
effect_name(effect, cx),
)
.description(start);
list = list.row(SharedString::from(at.to_string()), item);
}
let this = cx.entity();
let list = list
.selected(
current
.map(|at| SharedString::from(at.to_string()))
.into_iter()
.collect::<Vec<_>>(),
)
.on_change(move |keys, _, cx| {
let at = keys.first().and_then(|key| key.parse().ok());
this.update(cx, |panel, cx| {
panel.effect = at;
cx.notify();
})
});
let mut menu = Menu::new();
for (n, preset) in EFFECTS.iter().enumerate() {
if n > 0 && EFFECTS[n - 1].split('-').nth(1) != preset.split('-').nth(1) {
menu = menu.separator();
}
let weak = self.deck.clone();
let name = label(&format!("show.effect.{}", &preset[4..]));
menu = menu.item(
MenuItem::new(name)
.disabled(!picked)
.on_click(move |_, cx| {
let json = json!({ "preset": preset }).to_string();
on_deck(&weak, cx, |deck, cx| {
deck.design_command("animation.add", &[&json], cx)
})
}),
);
}
let preview = self.deck.clone();
let head = div()
.flex()
.flex_wrap()
.gap_1()
.child(DropdownMenu::new(
"show-effect-add",
label("show.motion.add"),
menu,
))
.child(
Button::new("show-effect-preview", label("show.motion.preview_effects"))
.size(ControlSize::Sm)
.disabled(effects.is_empty())
.on_click(move |_, _, cx| {
on_deck(&preview, cx, |deck, cx| deck.preview_effects(cx))
}),
);
let mut rows = DescriptionList::new().stacked().item(
label("show.motion.effects"),
div().flex().flex_col().gap_1().child(head).child(list),
);
if let Some(at) = current {
rows = self.effect_controls(rows, (at, &effects[at], slide), effects.len(), cx);
}
rows
}
fn effect_controls(
&self,
rows: DescriptionList,
(at, effect, slide): (usize, &Effect, &str),
count: usize,
cx: &mut Context<Self>,
) -> DescriptionList {
let label = |key: &str| Deck::text(cx, key, &[]);
let send = |change: serde_json::Value| {
let weak = self.deck.clone();
move |cx: &mut App| {
let change = change.clone();
on_deck(&weak, cx, move |deck, cx| deck.send_effect(at, change, cx))
}
};
let starts = ["click", "with", "after"]
.map(|key| Choice::new(key, label(&format!("show.effect.start.{key}"))));
let weak = self.deck.clone();
let start = Select::new("show-effect-start", starts)
.label(label("show.motion.start"))
.selected(effect.start.clone())
.on_change(move |value, _, cx| {
let change = json!({ "start": value.to_string() });
on_deck(&weak, cx, move |deck, cx| deck.send_effect(at, change, cx))
});
let duration = number(
"show-effect-duration",
label("show.motion.duration"),
effect.duration,
(0.01, 60.0),
)
.on_commit(dedupe(
cx.entity(),
effect_key("duration", slide, (at, effect)),
effect.duration,
move |deck, n, cx| deck.send_effect(at, json!({ "duration": n }), cx),
));
let delay = number(
"show-effect-delay",
label("show.motion.delay"),
effect.delay,
(0.0, 60.0),
)
.on_commit(dedupe(
cx.entity(),
effect_key("delay", slide, (at, effect)),
effect.delay,
move |deck, n, cx| deck.send_effect(at, json!({ "delay": n }), cx),
));
let (up, down, gone) = (
send(json!({ "move": -1 })),
send(json!({ "move": 1 })),
send(json!({ "remove": true })),
);
let this = cx.entity();
// The picked effect follows its move.
let follow = |to: Option<usize>| {
let this = this.clone();
move |cx: &mut App| {
this.update(cx, |panel, cx| {
panel.effect = to;
cx.notify();
})
}
};
let (went_up, went_down) = (follow(at.checked_sub(1)), follow(Some(at + 1)));
let buttons = div()
.flex()
.gap_1()
.child(
Button::new("show-effect-up", label("show.motion.up"))
.size(ControlSize::Sm)
.disabled(at == 0)
.on_click(move |_, _, cx| {
up(cx);
went_up(cx)
}),
)
.child(
Button::new("show-effect-down", label("show.motion.down"))
.size(ControlSize::Sm)
.disabled(at + 1 == count)
.on_click(move |_, _, cx| {
down(cx);
went_down(cx)
}),
)
.child(
Button::new("show-effect-remove", label("show.motion.remove"))
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.on_click(move |_, _, cx| {
gone(cx);
this.update(cx, |panel, cx| {
panel.effect = None;
cx.notify();
})
}),
);
rows.item(label("show.motion.start"), start)
.item(label("show.motion.duration"), div().w_full().max_w_32().child(duration))
.item(label("show.motion.delay"), div().w_full().max_w_32().child(delay))
.item(SharedString::default(), buttons)
}
}
+128
View File
@@ -0,0 +1,128 @@
//! New decks: the kernel's blank, or a bundled template (SHOW-02).
use std::{path::PathBuf, rc::Rc};
use ely_gpui_component::{
buttons::Button,
documents::{Template as Pick, TemplatePicker},
overlays::Dialog as Card,
};
use ely_office::{in_window, prelude::*};
use gpui::{
AnyElement, App, AppContext as _, Context, IntoElement, ParentElement as _, TaskExt as _,
Window,
};
use crate::deck::Deck;
use crate::dialog::Dialog;
/// The New dialog's templates: blank first.
pub struct Choosing {
templates: Vec<PathBuf>,
}
fn stem(path: &std::path::Path) -> String {
let name = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or_default();
name.replace('_', " ")
}
impl Deck {
pub fn ask_new(&mut self, cx: &mut Context<Self>) {
match ely_lok::templates("presnt") {
Ok(templates) => {
log::info!("show: {} bundled templates", templates.len());
self.dialog = Some(Dialog::New(Choosing { templates }));
cx.notify();
}
Err(error) => self.engine_failed(&error.to_string(), cx),
}
}
/// A new deck as the kernel makes one.
pub fn new_blank(&mut self, cx: &mut Context<Self>) {
if let Some(blank) = self.blank.clone() {
return self.new_from_file(&blank, cx);
}
let (worker, path) = (self.core.worker.clone(), self.core.work.join("blank.fodp"));
let writing = path.clone();
cx.spawn(async move |this, cx| {
let written = cx
.background_spawn(async move {
let mut worker = worker.lock().expect("the worker lock");
let doc = worker.load("private:factory/simpress")?;
let saved = doc.save_as(&mut worker, &ely_lok::file_url(&writing), "fodp");
worker.close_doc(doc)?;
saved
})
.await;
in_window(&this, cx, |deck, _, cx| match written {
Ok(()) => {
deck.blank = Some(path.clone());
deck.new_from_file(&path, cx)
}
Err(error) => deck.engine_failed(&error.to_string(), cx),
})
})
.detach_and_log_err(cx);
}
pub fn new_dialog(&mut self, cx: &mut Context<Self>) -> Option<AnyElement> {
let Some(Dialog::New(Choosing { templates })) = &self.dialog else {
return None;
};
let this = cx.entity();
let blank = Pick {
name: Deck::text(cx, "show.new.blank", &[]),
icon: "▭".into(),
category: Deck::text(cx, "show.new.category", &[]),
description: Deck::text(cx, "show.new.blank_about", &[]),
markdown: "".into(),
};
let picks: Vec<Pick> = std::iter::once(blank)
.chain(templates.iter().map(|path| Pick {
name: stem(path).into(),
icon: "▭".into(),
category: Deck::text(cx, "show.new.category", &[]),
description: "".into(),
markdown: "".into(),
}))
.collect();
let paths = templates.clone();
let used = this.clone();
let picker =
TemplatePicker::new("show-templates", Rc::new(picks)).on_use(move |ix, _, cx| {
let chosen = ix.checked_sub(1).map(|at| paths[at].clone());
used.update(cx, |deck, cx| {
deck.dialog = None;
match chosen {
Some(template) => deck.new_from_file(&template, cx),
None => deck.new_blank(cx),
}
})
});
let dismiss = {
let this = this.clone();
move |_: &mut Window, cx: &mut App| {
this.update(cx, |deck, cx| {
deck.dialog = None;
cx.notify();
})
}
};
let label = Deck::text(cx, "show.new.from_file", &[]);
let card = Card::new("show-new", Deck::text(cx, "show.new.title", &[]), dismiss)
.child(picker)
.action(move |close| {
Button::new("show-new-file", label).on_click(move |_, window, cx| {
close(window, cx);
this.update(cx, |deck, cx| {
deck.pick(false, cx, |deck, path, cx| deck.new_from_file(&path, cx))
});
})
});
Some(card.into_any_element())
}
}
+133
View File
@@ -0,0 +1,133 @@
//! The speaker notes under the slide (SHOW-08).
use ely_gpui_component::{
feedback::InlineMessage,
forms::{Input, InputEvent, TextInput},
primitives::Severity,
theme::ActiveTheme as _,
};
use ely_office::prelude::*;
use gpui::{
AnyElement, AppContext as _, Context, Entity, Focusable as _, IntoElement, ParentElement as _,
Styled as _, Window, div, px,
};
use crate::deck::Deck;
/// The notes field and its last tab, slide, text.
#[derive(Default)]
pub struct NotesField {
field: Option<Entity<TextInput>>,
shown: Option<(u64, usize, String)>,
/// The state the shown slide number was read in.
at: Option<ely_lok::Doc>,
_events: Option<gpui::Subscription>,
}
impl Deck {
fn notes_field(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Entity<TextInput> {
if let Some(field) = &self.notes.field {
return field.clone();
}
let (label, hint) = (
Deck::text(cx, "show.notes.label", &[]),
Deck::text(cx, "show.notes.hint", &[]),
);
let field = cx.new(|cx| {
TextInput::new(window, cx)
.multi_line(3, 5)
.label(label)
.placeholder(hint)
});
let events = cx.subscribe(&field, |deck, _, event: &InputEvent, cx| {
if matches!(event, InputEvent::Blur) {
deck.commit_notes(cx)
}
});
self.notes = NotesField {
field: Some(field.clone()),
shown: None,
at: None,
_events: Some(events),
};
field
}
/// Sends typed notes to the slide they came from.
pub(crate) fn commit_notes(&mut self, cx: &mut Context<Self>) {
let (Some(field), Some((tab, part, shown))) = (&self.notes.field, &self.notes.shown) else {
return;
};
let (tab, part, text) = (*tab, *part, field.read(cx).text().to_string());
if &text == shown {
return;
}
let Some(at) = self.notes.at.clone() else {
return log::warn!("show: notes for slide {part} have no read state");
};
log::info!("show: notes for slide {part} changed");
self.notes.shown = Some((tab, part, text.clone()));
self.run_on(tab, &at, "notes.set", &[&text, &part.to_string()], cx);
self.force_reread(tab, cx);
}
/// The notes field, showing the read notes unless typing.
pub(crate) fn notes_input(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Entity<TextInput>> {
let tab = self.active_tab()?;
let notes = tab.extra.notes.clone()?;
let (id, part) = (tab.id, notes.part?);
let field = self.notes_field(window, cx);
let typing = field.focus_handle(cx).is_focused(window);
// Typing keeps the state the slide number was read in.
if !typing {
self.notes.at = self.read_at(id, cx);
}
let read = (id, part, notes.text.clone());
if !typing && self.notes.shown.as_ref() != Some(&read) {
field.update(cx, |field, cx| field.set_text(notes.text, cx));
self.notes.shown = Some(read);
}
Some(field)
}
/// The notes pane: the field, or why it cannot edit.
pub fn notes_pane(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
let notes = self.active_tab()?.extra.notes.clone()?;
let pane = div()
.h(px(120.0))
.flex_none()
.p_2()
.border_t_1()
.border_color(cx.theme().colors.border);
if !notes.editable {
let why = Deck::text(cx, "show.notes.none", &[]);
return Some(
pane.child(InlineMessage::new(Severity::Info, why))
.into_any_element(),
);
}
if notes.rich {
let why = Deck::text(cx, "show.notes.rich", &[]);
let text = div()
.text_color(cx.theme().colors.fg_muted)
.child(notes.text);
let column = div()
.flex()
.flex_col()
.gap_1()
.child(InlineMessage::new(Severity::Info, why))
.child(text);
return Some(pane.child(column).into_any_element());
}
let field = self.notes_input(window, cx)?;
Some(pane.child(Input::new(&field)).into_any_element())
}
}
+379
View File
@@ -0,0 +1,379 @@
//! The selected objects' panel (SHOW-03 to 06).
use ely_gpui_component::{
buttons::{Button, ButtonVariant, IconButton},
data_display::{PropertyGrid, PropertyGroup},
forms::{NumberInput, Switch},
primitives::IconName,
theme::ControlSize,
};
use ely_lok::show::Selection;
use ely_office::panel::{self as kernel, palette, row};
use ely_office::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, IntoElement, ParentElement as _, SharedString, Styled as _,
Window, div,
};
use crate::deck::Deck;
use crate::design::{Panel, on_deck};
/// Centimetres: 1/100 mm per cm, step, places.
const CM: Unit = (1000.0, 0.1, 2, twips);
/// The swatches' clear buttons.
pub(crate) const NO_FILL: kernel::Clear = ("show.object.no_fill", "object.fill_none", "0");
pub(crate) const NO_LINE: kernel::Clear = ("show.object.no_line", "object.line_none", "0");
/// 1/100 mm in a point.
const POINT: f64 = 2540.0 / 72.0;
/// Shown units: scale, step, places, then what the kernel takes.
type Unit = (f64, f64, usize, fn(i64) -> i64);
/// LOK reads transform arguments as twips.
pub(crate) fn twips(mm100: i64) -> i64 {
(mm100 * 72 + 63).div_euclid(127)
}
fn same(value: i64) -> i64 {
value
}
impl kernel::Panel for Panel {
fn state(&self, uno: &str, cx: &App) -> Option<SharedString> {
let deck = self.deck.upgrade()?;
let tab = deck.read(cx).active_tab()?;
let view = tab.view.read(cx);
view.state(uno).map(|state| state.to_string().into())
}
fn run(&mut self, id: &str, values: &[&str], cx: &mut Context<Self>) {
on_deck(&self.deck, cx, |deck, cx| {
deck.object_command(id, values, cx)
});
}
}
/// Sends `value` unless it is there or on its way.
pub(crate) fn commit(
this: &Entity<Panel>,
field: impl Into<String>,
(value, now): (i64, Option<i64>),
send: impl FnOnce(&mut Deck, &mut Context<Deck>) + 'static,
cx: &mut App,
) {
let field = field.into();
this.update(cx, |panel, cx| {
let read = panel.deck.upgrade().and_then(|deck| {
deck.read(cx)
.active_tab()
.map(|tab| (tab.id, tab.extra.reads))
});
if panel.seen != read {
panel.sent.clear();
panel.seen = read;
}
let mark = (value, now);
if now == Some(value) || panel.sent.get(&field) == Some(&mark) {
log::info!("show: {field} already holds {value}");
return;
}
panel.sent.insert(field, mark);
on_deck(&panel.deck, cx, send);
});
}
impl Panel {
/// Groups for the selection on the shown slide.
pub(crate) fn object_grid(
&self,
selection: &Selection,
window: &mut Window,
cx: &mut Context<Self>,
) -> PropertyGrid {
let mut grid = PropertyGrid::new("show-object").group(self.geometry(selection, cx));
let kind = selection.kind.as_str();
// Lines and pictures have an outline but no fill.
let fills = !matches!(kind, "image" | "line" | "connector");
grid = grid.group(self.look(selection, fills, cx));
if matches!(kind, "text" | "shape" | "mixed") {
grid = grid.group(self.words(selection, window, cx));
}
if kind == "image" {
grid = grid.group(self.picture(cx));
}
if kind == "table" {
grid = grid.group(self.table_group(cx));
}
if kind == "chart" {
grid = grid.group(self.chart_group(cx));
}
if let Some(group) = (kind == "media").then(|| self.media_group(cx)).flatten() {
grid = grid.group(group);
}
grid.group(self.arrange(selection, cx))
}
/// A number field: kernel units over `scale`, `step`, `places`.
fn field(
&self,
(field, command): (&'static str, &'static str),
(now, (scale, step, places, kernel)): (Option<i64>, Unit),
range: (f64, f64),
cx: &mut Context<Self>,
) -> AnyElement {
let this = cx.entity();
let label = Deck::text(cx, &format!("show.object.{field}"), &[]);
let id = SharedString::from(format!("show-{field}"));
let mut input = NumberInput::new(id, now.unwrap_or(0) as f64 / scale)
.label(label)
.range(range.0, range.1)
.step(step)
.precision(places)
.size(ControlSize::Sm)
.on_commit(move |shown, _, cx| {
let value = (shown * scale).round() as i64;
let send = move |deck: &mut Deck, cx: &mut Context<Deck>| {
deck.object_command(command, &[&kernel(value).to_string()], cx)
};
commit(&this, field, (value, now), send, cx)
});
if now.is_none() {
input = input.blank(Deck::text(cx, "office.props.mixed", &[]));
}
div().w_full().max_w_32().child(input).into_any_element()
}
fn geometry(&self, sel: &Selection, cx: &mut Context<Self>) -> PropertyGroup {
let (x, y, w, h) = sel.rect;
let (cm, at, long) = (CM, (-300.0, 300.0), (0.01, 300.0));
let place = row([
self.field(("x", "object.x"), (Some(x), cm), at, cx),
self.field(("y", "object.y"), (Some(y), cm), at, cx),
]);
let extent = row([
self.field(("width", "object.width"), (Some(w), cm), long, cx),
self.field(("height", "object.height"), (Some(h), cm), long, cx),
]);
let this = cx.entity();
let (now, center) = (sel.rotation, sel.rotation_center);
let label = Deck::text(cx, "show.object.rotation", &[]);
let turn = NumberInput::new("show-rotation", now as f64 / 100.0)
.label(label)
.range(0.0, 359.9)
.step(1.0)
.precision(1)
.size(ControlSize::Sm)
.on_commit(move |degrees, _, cx| {
let angle = (degrees * 100.0).round() as i64 % 36_000;
let values = [angle, twips(center.0), twips(center.1)].map(|n| n.to_string());
commit(
&this,
"rotation",
(angle, Some(now)),
move |deck, cx| {
let values: Vec<&str> = values.iter().map(String::as_str).collect();
deck.object_command("object.rotate", &values, cx)
},
cx,
)
});
let locked = sel.lock_position == Some(true);
let lock = Switch::new("show-lock", locked)
.name(Deck::text(cx, "show.object.lock", &[]))
.on_change({
let deck = self.deck.clone();
move |on, _, cx| {
let on = on.to_string();
on_deck(&deck, cx, |deck, cx| {
deck.object_command("object.lock", &[&on, &on], cx)
})
}
});
let tip = |key: &str| Deck::text(cx, key, &[]);
PropertyGroup::new(tip("show.object.geometry"))
.row(tip("show.object.position"), place)
.row(tip("show.object.size"), extent)
.row(tip("show.object.rotation"), div().w_full().max_w_32().child(turn))
.row(tip("show.object.lock"), lock)
}
fn look(&self, sel: &Selection, fills: bool, cx: &mut Context<Self>) -> PropertyGroup {
let this = cx.entity();
let fill = palette(
&this,
"object.fill",
(sel.fill, "show.object.no_fill"),
Some(NO_FILL),
cx,
);
let line = palette(
&this,
"object.line_color",
(sel.line, "show.object.no_line"),
Some(NO_LINE),
cx,
);
let see = self.field(
("transparency", "object.transparency"),
(sel.transparency, (1.0, 1.0, 0, same)),
(0.0, 100.0),
cx,
);
let width = self.field(
("line_width", "object.line_width"),
(sel.line_width, (POINT, 0.25, 2, same)),
(0.0, 20.0),
cx,
);
let shadow = Switch::new("show-shadow", sel.shadow == Some(true))
.name(Deck::text(cx, "show.object.shadow", &[]))
.on_change({
let deck = self.deck.clone();
move |on, _, cx| {
let on = on.to_string();
on_deck(&deck, cx, |deck, cx| {
deck.object_command("object.shadow", &[&on], cx)
})
}
});
let tip = |key: &str| Deck::text(cx, key, &[]);
let title = if fills {
"show.object.look"
} else {
"show.object.line"
};
let mut group = PropertyGroup::new(tip(title));
if fills {
group = group
.row(tip("show.object.fill"), fill)
.row(tip("show.object.transparency"), see)
.row(tip("show.object.shadow"), shadow);
}
group
.row(tip("show.object.line"), line)
.row(tip("show.object.line_width"), width)
}
fn chart_group(&self, cx: &mut Context<Self>) -> PropertyGroup {
let deck = self.deck.clone();
let change = Button::new("show-chart-edit", Deck::text(cx, "show.chart.edit", &[]))
.size(ControlSize::Sm)
.on_click(move |_, _, cx| on_deck(&deck, cx, |deck, cx| deck.ask_chart(cx)));
let name = Deck::text(cx, "show.kind.chart", &[]);
PropertyGroup::new(name.clone()).row(name, change)
}
fn picture(&self, cx: &mut Context<Self>) -> PropertyGroup {
let deck = self.deck.clone();
let replace = Button::new("show-replace", Deck::text(cx, "show.object.replace", &[]))
.size(ControlSize::Sm)
.on_click(move |_, _, cx| on_deck(&deck, cx, |deck, cx| deck.pick_picture(true, cx)));
let crop = kernel::button(
self,
"image.crop",
"show.object.crop",
"show.object.why_picture",
cx,
);
PropertyGroup::new(Deck::text(cx, "show.object.picture", &[])).row(
Deck::text(cx, "show.object.picture", &[]),
row([replace.into_any_element(), crop]),
)
}
fn arrange(&self, sel: &Selection, cx: &mut Context<Self>) -> PropertyGroup {
let act = |id: &'static str, (icon, key): (Option<IconName>, &str), on: bool| {
let deck = self.deck.clone();
let run = move |_: &gpui::ClickEvent, _: &mut Window, cx: &mut App| {
on_deck(&deck, cx, |deck, cx| deck.object_command(id, &[], cx))
};
let label = Deck::text(cx, key, &[]);
match icon {
Some(icon) => IconButton::new(id, icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(label)
.disabled(!on)
.on_click(run)
.into_any_element(),
None => Button::new(id, label)
.size(ControlSize::Sm)
.disabled(!on)
.on_click(run)
.into_any_element(),
}
};
let many = sel.count >= 3;
let order = row([
act("image.front", (None, "show.object.front"), true),
act("image.forward", (None, "show.object.forward"), true),
act("image.backward", (None, "show.object.backward"), true),
act("image.back", (None, "show.object.back"), true),
]);
let icon = |icon, key| (Some(icon), key);
let align = row([
act(
"object.align_left",
icon(IconName::AlignStartVertical, "show.object.align_left"),
true,
),
act(
"object.align_center",
icon(IconName::AlignCenterVertical, "show.object.align_center"),
true,
),
act(
"object.align_right",
icon(IconName::AlignEndVertical, "show.object.align_right"),
true,
),
act(
"object.align_top",
icon(IconName::AlignStartHorizontal, "show.object.align_top"),
true,
),
act(
"object.align_middle",
icon(IconName::AlignCenterHorizontal, "show.object.align_middle"),
true,
),
act(
"object.align_bottom",
icon(IconName::AlignEndHorizontal, "show.object.align_bottom"),
true,
),
]);
let spread = row([
act(
"object.spread_across",
icon(
IconName::AlignHorizontalDistributeCenter,
"show.object.spread_across",
),
many,
),
act(
"object.spread_down",
icon(
IconName::AlignVerticalDistributeCenter,
"show.object.spread_down",
),
many,
),
]);
let group = row([
act("object.group", (None, "show.object.group"), sel.can_group),
act(
"object.ungroup",
(None, "show.object.ungroup"),
sel.can_ungroup,
),
]);
let tip = |key: &str| Deck::text(cx, key, &[]);
PropertyGroup::new(tip("show.object.arrange"))
.row(tip("show.object.order"), order)
.row(tip("show.object.align_to"), align)
.row(tip("show.object.spread"), spread)
.row(tip("show.object.grouping"), group)
}
}
+358
View File
@@ -0,0 +1,358 @@
//! PDF, slide pictures and handouts, saved or printed (SHOW-13).
use std::{io, path::PathBuf};
use ely_gpui_component::primitives::Severity;
use ely_lok::{Doc, Pdf, Worker};
use ely_office::{in_window, prelude::*};
use ely_ui::commands::{Export, Print};
use gpui::{Context, TaskExt as _, Window};
use crate::deck::Deck;
use crate::dialog::Dialog;
mod dialog;
pub use dialog::output_dialog;
/// Slide pictures are this wide.
const PICTURE: u32 = 1920;
/// What goes out.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Kind {
Slides,
Pictures,
Handouts,
}
/// Which slides, by number from one.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Range {
All,
Current,
Slides(u32, u32),
}
/// The output dialog's choices.
#[derive(Clone, Debug)]
pub struct Output {
pub tab: u64,
pub print: bool,
pub kind: Kind,
pub range: Range,
/// Slides on a handout page.
pub per: u8,
/// The shown slide, from one; none in master view.
pub current: Option<u32>,
/// Each slide, hidden or not.
pub hidden: Vec<bool>,
}
impl Output {
/// First and last slide; None is all.
pub fn pages(&self) -> Option<(u32, u32)> {
match self.range {
Range::All => None,
Range::Current => self.current.map(|at| (at, at)),
Range::Slides(from, to) => Some((from.min(to), from.max(to))),
}
}
}
/// Runs `work` on a saved copy of `doc`.
fn on_copy<T>(
worker: &mut Worker,
doc: &Doc,
(copy, scheme): (&std::path::Path, Option<&'static str>),
work: impl FnOnce(&mut Worker, &Doc) -> io::Result<T>,
) -> io::Result<T> {
let url = ely_lok::file_url(copy);
doc.save_as(worker, &url, "odp")?;
let done = loaded(worker, &url, scheme, work);
std::fs::remove_file(copy)?;
done
}
fn loaded<T>(
worker: &mut Worker,
url: &str,
scheme: Option<&'static str>,
work: impl FnOnce(&mut Worker, &Doc) -> io::Result<T>,
) -> io::Result<T> {
let made = worker.load(url)?;
let done = match scheme {
Some(scheme) => worker.run(&made, "view.theme", &[scheme]).and_then(|()| work(worker, &made)),
None => work(worker, &made),
};
let closed = worker.close_doc(made);
let done = done?;
closed?;
Ok(done)
}
/// Writes slide `part` as a PNG of `size` pixels.
fn picture(worker: &mut Worker, doc: &Doc, part: usize, size: (u32, u32), to: std::fs::File) -> io::Result<()> {
let mut pixels = doc.paint_part(worker, (part, false), size)?;
// The kernel paints BGRA.
for pixel in pixels.chunks_exact_mut(4) {
pixel.swap(0, 2);
}
png(pixels, size, to)
}
/// Encodes RGBA `pixels` as PNG; the last write checked.
fn png(pixels: Vec<u8>, size: (u32, u32), to: impl io::Write) -> io::Result<()> {
use image::ImageEncoder as _;
if pixels.len() != size.0 as usize * size.1 as usize * 4 {
return Err(io::Error::other("the slide picture has the wrong size"));
}
let mut out = io::BufWriter::new(to);
image::codecs::png::PngEncoder::new(&mut out)
.write_image(&pixels, size.0, size.1, image::ExtendedColorType::Rgba8)
.map_err(io::Error::other)?;
io::Write::flush(&mut out)
}
/// The shown slides of `range` and their files.
fn targets(hidden: &[bool], range: Option<(u32, u32)>, (folder, stem): (&std::path::Path, &str)) -> Vec<(usize, PathBuf)> {
let (from, to) = range.unwrap_or((1, hidden.len() as u32));
let width = hidden.len().to_string().len();
(from..=to)
.map(|number| number as usize - 1)
.filter(|part| hidden.get(*part) == Some(&false))
.map(|part| (part, folder.join(format!("{stem}-{:0width$}.png", part + 1))))
.collect()
}
impl Deck {
pub fn on_export(&mut self, _: &Export, _: &mut Window, cx: &mut Context<Self>) {
self.ask_output(false, cx);
}
pub fn on_print(&mut self, _: &Print, _: &mut Window, cx: &mut Context<Self>) {
self.ask_output(true, cx);
}
/// Reads the slides, not masters, then asks what goes out.
fn ask_output(&mut self, print: bool, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
let text = Deck::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
let id = tab.id;
let current = (!tab.extra.masters)
.then(|| u32::try_from(tab.extra.slides.current + 1).expect("a slide number"));
let asked = self.ask_dialog();
self.read_in(
id,
|worker, doc| ely_lok::show::slide_show(&worker.values(doc, ely_lok::show::SLIDE_SHOW)?),
move |deck, read, _, cx| match read {
Ok(slides) if deck.dialog_due(asked, id) => {
deck.dialog = Some(Dialog::Output(Output {
tab: id,
print,
kind: Kind::Slides,
range: Range::All,
per: 3,
current,
hidden: slides.iter().map(|slide| slide.hidden).collect(),
}));
cx.notify();
}
Ok(_) => log::info!("show: the output dialog is no longer due"),
Err(error) => deck.engine_failed(&format!("the slides: {error}"), cx),
},
cx,
);
}
/// Writes what the dialog asks, then saves or prints it.
pub(crate) fn output(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Output(output)) = self.dialog.take() else {
return;
};
cx.notify();
if !self.settled(output.tab, cx) {
let tab = output.tab;
self.dialog = Some(Dialog::Output(output));
return self.when_settled(tab, |deck, _, cx| deck.output(cx));
}
let Some(source) = self.tab(output.tab) else {
return;
};
let work = source.work.clone();
if output.print {
let path = work.join("print.pdf");
let printing = path.clone();
return self.write_pdf(&output, path, cx, move |_, cx| {
ely_office::print_later(printing, cx, |deck, error, cx| deck.output_failed(&error, cx))
});
}
let Some(dir) = ely_office::folder(source) else {
return self.panel_failed("no documents folder", cx);
};
let stem = (source.name.rsplit_once('.'))
.map_or(source.name.to_string(), |(stem, _)| stem.to_string());
if output.kind == Kind::Pictures {
return self.pick(true, cx, move |deck, folder, cx| deck.write_pictures(&output, (folder, stem), cx));
}
let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.pdf")));
cx.spawn(async move |this, cx| {
let chosen = match chosen.await {
Ok(Ok(path)) => Ok(path),
Ok(Err(error)) => Err(error.to_string()),
Err(error) => Err(error.to_string()),
};
in_window(&this, cx, |deck, _, cx| match chosen {
Ok(Some(path)) => deck.export_pdf(&output, path, cx),
Ok(None) => log::info!("show: the export panel was cancelled"),
Err(error) => deck.panel_failed(&error, cx),
})
})
.detach_and_log_err(cx);
}
pub(crate) fn export_pdf(&mut self, output: &Output, path: PathBuf, cx: &mut Context<Self>) {
match self.holder(&path) {
Ok(None) => {}
Ok(Some(_)) => {
let name = ely_office::files::file_name(&path);
let text = Deck::text(cx, "show.output.open", &[("name", &name)]);
return self.toast(text, Severity::Danger, cx);
}
Err(error) => return self.output_failed(&error.to_string(), cx),
}
let (name, opened) = (ely_office::files::file_name(&path), path.clone());
self.write_pdf(output, path, cx, move |deck, cx| {
let text = Deck::text(cx, "show.output.done", &[("name", &name)]);
deck.toast_output(text, opened.clone(), cx);
});
}
/// Writes slides or handouts as a PDF, from a copy.
fn write_pdf(
&mut self,
output: &Output,
path: PathBuf,
cx: &mut Context<Self>,
then: impl FnOnce(&mut Self, &mut Context<Self>) + 'static,
) {
let Some(tab) = self.tab(output.tab) else {
return;
};
let pdf = Pdf {
pages: output.pages(),
handouts: if output.kind == Kind::Handouts { output.per } else { 0 },
..Pdf::default()
};
log::info!("show: writes a PDF ({pdf:?})");
let (url, copy) = (ely_lok::file_url(&path), tab.work.join("output.odp"));
self.read_in(
output.tab,
move |worker, doc| on_copy(worker, doc, (&copy, None), |worker, made| made.export_pdf(worker, &url, &pdf)),
move |deck, written, _, cx| match written {
Ok(()) => then(deck, cx),
Err(error) => deck.output_failed(&error.to_string(), cx),
},
cx,
);
}
/// One PNG a shown slide, light, into `folder`.
pub(crate) fn write_pictures(&mut self, output: &Output, (folder, stem): (PathBuf, String), cx: &mut Context<Self>) {
let Some(tab) = self.tab(output.tab) else {
return;
};
let range = output.pages();
let (w, h) = tab.extra.size;
let size = (PICTURE, (f64::from(PICTURE) * h as f64 / w.max(1) as f64).round() as u32);
let (copy, light) = (tab.work.join("output.odp"), ely_lok::scheme(false));
let (shown, opened) = (folder.display().to_string(), folder.clone());
log::info!("show: writes slide pictures");
self.read_in(
output.tab,
move |worker, doc| {
// Which slides show is the copy's, as it is written.
on_copy(worker, doc, (&copy, Some(light)), |worker, made| {
let slides = ely_lok::show::slide_show(&worker.values(made, ely_lok::show::SLIDE_SHOW)?)?;
let hidden: Vec<bool> = slides.iter().map(|slide| slide.hidden).collect();
let files = targets(&hidden, range, (&folder, &stem));
let paths: Vec<PathBuf> = files.iter().map(|(_, path)| path.clone()).collect();
let claimed = ely_files::claim_all(&paths)?;
let written = (files.iter().zip(claimed))
.try_for_each(|((part, _), file)| picture(worker, made, *part, size, file));
// A failure leaves none of this run's files.
if let Err(error) = &written
&& let Err(left) = files.iter().try_for_each(|(_, path)| std::fs::remove_file(path))
{
return Err(io::Error::other(format!("{error}; then {left}")));
}
written.map(|()| files.len())
})
},
move |deck, written, _, cx| match written {
Ok(count) => {
let count = count.to_string();
let text = Deck::text(cx, "show.output.pictures_done", &[("n", &count), ("folder", &shown)]);
deck.toast_output(text, opened.clone(), cx);
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
let text = Deck::text(cx, "show.output.taken", &[("name", &error.to_string())]);
deck.toast(text, Severity::Danger, cx);
}
Err(error) => deck.output_failed(&error.to_string(), cx),
},
cx,
);
}
fn output_failed(&self, error: &str, cx: &mut Context<Self>) {
let text = Deck::text(cx, "show.output.failed", &[("error", error)]);
self.toast(text, Severity::Danger, cx);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Takes `room` bytes, then the disk is full.
struct Full {
room: usize,
}
impl io::Write for Full {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
match self.room {
0 => Err(io::Error::new(io::ErrorKind::StorageFull, "full")),
room => {
let took = bytes.len().min(room);
self.room -= took;
Ok(took)
}
}
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn a_full_disk_at_the_last_write_is_an_error() {
let pixels = vec![200u8; 64 * 64 * 4];
let mut whole = Vec::new();
png(pixels.clone(), (64, 64), &mut whole).expect("encodes");
let short = Full { room: whole.len() - 1 };
let failed = png(pixels, (64, 64), short).expect_err("one byte short");
assert_eq!(failed.kind(), io::ErrorKind::StorageFull);
}
#[test]
fn targets_skip_hidden_slides_and_pad_numbers() {
let hidden = [false, true, false, false, false, false, false, false, false, false];
let files = targets(&hidden, Some((1, 3)), (std::path::Path::new("/out"), "deck"));
let names: Vec<String> = files.iter().map(|(_, p)| p.display().to_string()).collect();
assert_eq!(names, ["/out/deck-01.png", "/out/deck-03.png"]);
}
}
+137
View File
@@ -0,0 +1,137 @@
//! The output dialog: what, which slides, how many a page.
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
data_display::DescriptionList,
forms::{Choice, NumberInput, Select},
overlays::Dialog as Card,
theme::ControlSize,
};
use gpui::{ParentElement as _, Styled as _, Window, div};
use super::{Kind, Output, Range};
use crate::deck::Deck;
use crate::dialog::Dialog;
/// What goes out, which slides, how many a page.
pub fn output_dialog(
this: gpui::Entity<Deck>,
output: &Output,
dismiss: impl Fn(&mut Window, &mut gpui::App) + 'static,
cx: &gpui::App,
) -> Card {
let text = |key: &str| Deck::text(cx, key, &[]);
let change = |this: &gpui::Entity<Deck>, cx: &mut gpui::App, edit: &dyn Fn(&mut Output)| {
this.update(cx, |deck, cx| {
if let Some(Dialog::Output(output)) = &mut deck.dialog {
edit(output);
}
cx.notify();
})
};
let kinds: &[(Kind, &str)] = match output.print {
true => &[(Kind::Slides, "slides"), (Kind::Handouts, "handouts")],
false => &[(Kind::Slides, "pdf"), (Kind::Pictures, "pictures"), (Kind::Handouts, "handouts")],
};
let now = kinds.iter().find(|(kind, _)| *kind == output.kind).map_or("pdf", |(_, id)| id);
let kind = {
let (this, kinds) = (this.clone(), kinds.to_vec());
kinds
.iter()
.fold(SegmentedControl::new("show-output-kind", now), |control, (_, id)| {
control.segment(*id, text(&format!("show.output.{id}")), None)
})
.size(ControlSize::Sm)
.on_change(move |value, _, cx| {
if let Some((kind, _)) = kinds.iter().find(|(_, id)| *id == value.as_ref()) {
let kind = *kind;
change(&this, cx, &|output| output.kind = kind);
}
})
};
let which = match output.range {
Range::All => "all",
Range::Current => "current",
Range::Slides(..) => "range",
};
let last = output.hidden.len() as u32;
let range = {
let this = this.clone();
let control = SegmentedControl::new("show-output-range", which)
.size(ControlSize::Sm)
.segment("all", text("show.output.all"), None);
// Master view has no current slide.
let control = match output.current {
Some(_) => control.segment("current", text("show.output.current"), None),
None => control,
};
control
.segment("range", text("show.output.range"), None)
.on_change(move |value, _, cx| {
let range = match value.as_ref() {
"all" => Range::All,
"current" => Range::Current,
_ => Range::Slides(1, last),
};
change(&this, cx, &|output| output.range = range);
})
};
let bound = |id: &'static str, value: u32, first: bool| {
// A typed number counts before blur commits it.
let set = move |this: &gpui::Entity<Deck>, value: f64, cx: &mut gpui::App| {
let value = value.round() as u32;
change(this, cx, &|output| {
if let Range::Slides(from, to) = &mut output.range {
*(if first { from } else { to }) = value;
}
})
};
let (typed, committed) = (this.clone(), this.clone());
NumberInput::new(id, f64::from(value))
.label(text(if first { "show.output.from" } else { "show.output.to" }))
.range(1.0, f64::from(last))
.size(ControlSize::Sm)
.on_change(move |value, _, cx| set(&typed, value, cx))
.on_commit(move |value, _, cx| set(&committed, value, cx))
};
let bounds = match output.range {
Range::Slides(from, to) => Some(
div()
.flex()
.gap_2()
.child(div().w_full().max_w_24().child(bound("show-output-from", from, true)))
.child(div().w_full().max_w_24().child(bound("show-output-to", to, false))),
),
_ => None,
};
let per = (output.kind == Kind::Handouts).then(|| {
let this = this.clone();
let choices = [1u8, 2, 3, 4, 6, 9].map(|n| Choice::new(n.to_string(), n.to_string()));
Select::new("show-output-per", choices)
.label(text("show.output.per"))
.selected(output.per.to_string())
.on_change(move |value, _, cx| {
let per: u8 = value.parse().expect("listed numbers");
change(&this, cx, &|output| output.per = per);
})
});
let mut rows = DescriptionList::new()
.item(text("show.output.what"), kind)
.item(text("show.output.which"), div().flex().flex_col().gap_2().child(range).children(bounds));
if let Some(per) = per {
rows = rows.item(text("show.output.per"), per);
}
let (title, go) = match output.print {
true => ("show.output.print_title", "show.output.print"),
false => ("show.output.export_title", "show.output.export"),
};
let go = text(go);
Card::new("show-output", text(title), dismiss)
.child(rows)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("show-output-go", go)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| this.update(cx, |deck, cx| deck.output(cx)))
})
}
+298
View File
@@ -0,0 +1,298 @@
//! Plays a slide's animations or transition over the canvas (SHOW-09).
use std::{sync::Arc, time::Instant};
use ely_lok::{
LokView,
show::{Layers, Part},
};
use ely_office::prelude::*;
use gpui::{
AnyElement, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _,
RenderImage, Styled as _, Window, div,
};
use image::{Frame, ImageBuffer, Rgba};
use crate::deck::Deck;
pub(crate) mod compose;
pub(crate) mod timeline;
pub(crate) mod transition;
/// About this wide; the height keeps the slide's shape.
const WIDTH: u32 = 1280;
/// What a preview plays.
pub enum Playing {
/// The slide's layers and its effects on the clock.
Effects {
layers: Vec<(Part, Arc<RenderImage>)>,
runs: Vec<timeline::Run>,
/// The slide in 1/100 mm.
slide: (f32, f32),
},
/// The previous slide, if any, giving way to this one.
Transition {
from: Option<Vec<(Part, Arc<RenderImage>)>>,
to: Vec<(Part, Arc<RenderImage>)>,
preset: String,
},
}
/// Tab, slide, undo list, edits landed and pending.
type Stamp = (u64, String, Vec<ely_lok::history::Step>, u64, bool);
pub struct Preview {
stamp: Stamp,
pub playing: Playing,
started: Instant,
length: f64,
}
fn picture((width, height): (u32, u32), pixels: Vec<u8>) -> Arc<RenderImage> {
let image = ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, pixels)
.expect("the worker paints the asked size");
Arc::new(RenderImage::new([Frame::new(image)]))
}
/// The layers as pictures.
pub(crate) fn pictures(layers: Layers) -> Vec<(Part, Arc<RenderImage>)> {
let size = layers.size;
(layers.list.into_iter())
.map(|layer| (layer.part, picture(size, layer.pixels)))
.collect()
}
/// The preview's pixel size for a slide of `size`.
fn pixels(size: (i64, i64)) -> (u32, u32) {
let height = (WIDTH as f64 * size.1 as f64 / size.0.max(1) as f64).round();
(WIDTH, height.max(1.0) as u32)
}
impl Deck {
/// The shown slide now; previews play only while it holds.
fn stamp(&self, cx: &gpui::App) -> Option<Stamp> {
let tab = self.active_tab()?;
let slide = tab
.extra
.slides
.list
.get(tab.extra.slides.current)?
.name
.clone();
let history = tab.extra.history.as_ref()?.0.clone();
let view = tab.view.read(cx);
Some((tab.id, slide, history, view.edited(), view.edit_pending()))
}
/// A new preview request; older ones no longer play.
fn ask_preview(&mut self, cx: &gpui::App) -> Option<(u64, Stamp)> {
self.preview_asked += 1;
Some((self.preview_asked, self.stamp(cx)?))
}
/// Whether request `asked` from `stamp` may still play.
fn may_play(&self, asked: u64, stamp: &Stamp, cx: &gpui::App) -> bool {
self.preview_asked == asked && self.stamp(cx).as_ref() == Some(stamp)
}
/// Plays the shown slide's effects from the kernel's layers.
pub fn preview_effects(&mut self, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
let (id, doc, part) = (tab.id, tab.doc.clone(), tab.extra.slides.current);
let Some((_, effects)) = tab.extra.motion.clone() else {
return;
};
let (asked, slide) = (pixels(tab.extra.size), crate::design::mm100(tab.extra.size));
let Some((request, stamp)) = self.ask_preview(cx) else {
return;
};
log::info!("show: previews the effects of slide {part}");
ely_lok::read_off(
&self.core.worker,
&doc,
move |worker, doc| doc.slide_layers(worker, part, asked),
move |deck: &mut Self, read: std::io::Result<Layers>, cx| match read {
Ok(layers) if deck.may_play(request, &stamp, cx) => {
let layers = pictures(layers);
let runs = timeline::schedule(&effects);
let length = timeline::length(&runs);
let slide = (slide.0 as f32, slide.1 as f32);
deck.play(
stamp,
Playing::Effects {
layers,
runs,
slide,
},
length,
cx,
);
}
Ok(_) => log::info!("show: a preview for tab {id} is no longer due"),
Err(error) => deck.engine_failed(&format!("the preview: {error}"), cx),
},
cx,
);
}
/// Plays the shown slide's transition from the one before.
pub fn preview_transition(&mut self, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
let Some((transition, _)) = tab.extra.motion.clone() else {
return;
};
let (id, doc, part) = (tab.id, tab.doc.clone(), tab.extra.slides.current);
let asked = pixels(tab.extra.size);
let Some((request, stamp)) = self.ask_preview(cx) else {
return;
};
log::info!("show: previews the transition into slide {part}");
ely_lok::read_off(
&self.core.worker,
&doc,
move |worker, doc| {
let from = (part > 0)
.then(|| doc.slide_layers(worker, part - 1, asked))
.transpose()?;
Ok((from, doc.slide_layers(worker, part, asked)?))
},
move |deck: &mut Self, read: std::io::Result<(Option<Layers>, Layers)>, cx| match read {
Ok((from, to)) if deck.may_play(request, &stamp, cx) => {
let playing = Playing::Transition {
from: from.map(pictures),
to: pictures(to),
preset: transition.preset.clone(),
};
deck.play(stamp, playing, transition.duration, cx);
}
Ok(_) => log::info!("show: a preview for tab {id} is no longer due"),
Err(error) => deck.engine_failed(&format!("the preview: {error}"), cx),
},
cx,
);
}
fn play(&mut self, stamp: Stamp, playing: Playing, length: f64, cx: &mut Context<Self>) {
let layers = match &playing {
Playing::Effects { layers, .. } => layers.len(),
Playing::Transition { .. } => 2,
};
log::info!("show: plays {layers} layers for {length:.2} s");
self.end_preview();
self.preview = Some(Preview {
stamp,
playing,
started: Instant::now(),
length,
});
cx.notify();
}
/// Ends the preview; its layers wait to be freed.
fn end_preview(&mut self) -> bool {
let Some(preview) = self.preview.take() else {
return false;
};
let pictures: Vec<&Arc<RenderImage>> = match &preview.playing {
Playing::Effects { layers, .. } => layers.iter().map(|(_, image)| image).collect(),
Playing::Transition { from, to, .. } => from.iter().flatten().chain(to).map(|(_, image)| image).collect(),
};
for picture in pictures {
self.retired.push(picture.clone());
}
true
}
/// Drops a preview whose slide or document moved on.
pub(crate) fn drop_stale_preview(&mut self, cx: &gpui::App) {
if (self.preview.as_ref()).is_some_and(|p| self.stamp(cx).as_ref() != Some(&p.stamp)) {
log::info!("show: the slide changed under the preview");
self.end_preview();
}
}
/// Stops a preview at once.
pub fn stop_preview(&mut self, cx: &mut Context<Self>) {
// Reads still out are not wanted either.
self.preview_asked += 1;
if self.end_preview() {
log::info!("show: the preview stops");
cx.notify();
}
}
/// The playing preview over the slide; a press stops it.
pub fn preview_layer(
&mut self,
view: &Entity<LokView>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
let size = self.active_tab()?.extra.size;
self.drop_stale_preview(cx);
let preview = self.preview.as_ref()?;
let t = preview.started.elapsed().as_secs_f64();
// The last frame stays a moment, then the canvas returns.
if t > preview.length + 0.5 {
log::info!("show: the preview ends");
self.end_preview();
return None;
}
window.request_animation_frame();
let lok = view.read(cx);
let (top_left, bottom_right) =
(lok.place((0, 0)), lok.place((size.0 as i32, size.1 as i32)));
let frame = (bottom_right.x - top_left.x, bottom_right.y - top_left.y);
let this = cx.entity();
// A show starts from black; effects play on the page.
let behind = match &preview.playing {
Playing::Transition { .. } => gpui::black(),
Playing::Effects { .. } => ely_lok::paper(cx),
};
let content = match &preview.playing {
Playing::Effects {
layers,
runs,
slide,
} => compose::effects(layers, runs, *slide, t, frame),
Playing::Transition { from, to, preset } => {
let p = if preview.length <= 0.0 {
1.0
} else {
(t / preview.length) as f32
};
let paper = ely_lok::paper(cx);
// Exits are not read; the old slide shows everything.
let from = (from.as_deref())
.map(|from| compose::slide(from, |_| true, frame, paper).into_any_element());
compose::transition(
(from, to),
&transition::frame(preset, p),
frame,
paper,
)
}
};
Some(
div()
.id("show-preview")
.absolute()
.left(top_left.x)
.top(top_left.y)
.w(frame.0)
.h(frame.1)
.overflow_hidden()
.bg(behind)
.on_mouse_down(gpui::MouseButton::Left, move |_, _, cx| {
this.update(cx, |deck, cx| deck.stop_preview(cx))
})
.child(content)
.into_any_element(),
)
}
}
+147
View File
@@ -0,0 +1,147 @@
//! Slides drawn from their layers: effects and transitions.
use std::sync::Arc;
use ely_lok::show::Part;
use gpui::{AnyElement, IntoElement, ParentElement as _, RenderImage, Styled as _, div, img, px};
use super::{timeline, transition};
type Px = gpui::Pixels;
/// A slide's layers, bottom first.
pub type Stack = [(Part, Arc<RenderImage>)];
/// A whole slide on its page; `moving` picks objects.
pub fn slide(
layers: &Stack,
moving: fn(bool) -> bool,
(w, h): (Px, Px),
paper: gpui::Hsla,
) -> gpui::Div {
let shown = layers.iter().filter(|(part, _)| match part {
Part::Moving { visible, .. } => moving(*visible),
_ => true,
});
let pictures = shown.map(|(_, image)| img(image.clone()).absolute().size_full());
div().relative().w(w).h(h).bg(paper).children(pictures)
}
/// A transition frame: old slide as drawn, new unplayed.
pub fn transition(
(from, to): (Option<AnyElement>, &Stack),
frame: &transition::Frame,
(w, h): (Px, Px),
paper: gpui::Hsla,
) -> AnyElement {
let at = |shift: (f32, f32)| {
div()
.absolute()
.left(w * shift.0)
.top(h * shift.1)
.w(w)
.h(h)
};
let old = from.map(|from| at(frame.from_shift).child(from));
let new = frame.to_areas.iter().map(|area| {
let (x0, y0, x1, y1) = *area;
let whole = slide(to, |visible| visible, (w, h), paper);
div()
.absolute()
.left(w * (x0 + frame.to_shift.0))
.top(h * (y0 + frame.to_shift.1))
.w(w * (x1 - x0))
.h(h * (y1 - y0))
.overflow_hidden()
.opacity(frame.to_opacity)
.child(div().absolute().left(-(w * x0)).top(-(h * y0)).child(whole))
});
// Uncover slides the old slide off the new one beneath.
let old = old.map(IntoElement::into_any_element);
let new = new.map(IntoElement::into_any_element);
let pieces: Vec<AnyElement> = match frame.old_on_top {
true => new.chain(old).collect(),
false => old.into_iter().chain(new).collect(),
};
div()
.size_full()
.children(pieces)
.child(
div()
.absolute()
.size_full()
.bg(gpui::black())
.opacity(frame.black),
)
.into_any_element()
}
/// The layers at second `t`, moved as effects say.
pub fn effects(
layers: &[(Part, Arc<RenderImage>)],
runs: &[timeline::Run],
slide: (f32, f32),
t: f64,
(w, h): (Px, Px),
) -> AnyElement {
let pieces = layers.iter().filter_map(|(part, image)| {
let full = div().absolute().left(px(0.0)).top(px(0.0)).w(w).h(h);
match part {
Part::Still => Some(
full.child(img(image.clone()).size_full())
.into_any_element(),
),
Part::Moving {
hash,
visible,
bounds,
} => {
let (x, y) = (bounds.0 as f32 / slide.0, bounds.1 as f32 / slide.1);
let (bw, bh) = (bounds.2 as f32 / slide.0, bounds.3 as f32 / slide.1);
let look = timeline::object_look(runs, hash, *visible, t, y);
look.visible
.then(|| moved(image, look, (x, y, bw, bh), (w, h)))
}
}
});
div().size_full().children(pieces).into_any_element()
}
/// One object's layer, shifted, scaled about its centre and wiped.
fn moved(
image: &Arc<RenderImage>,
look: timeline::Look,
(x, y, bw, bh): (f32, f32, f32, f32),
(w, h): (Px, Px),
) -> AnyElement {
let (cx, cy) = (x + bw / 2.0, y + bh / 2.0);
let s = look.scale.max(0.001);
// Scaling the whole layer about the object's centre.
let left = w * ((cx - cx * s) + look.shift.0);
let top = h * ((cy - cy * s) + look.shift.1);
let clip_top = y + bh * (1.0 - look.reveal);
let picture = img(image.clone()).absolute().left(left).w(w * s).h(h * s);
div()
.absolute()
.left(px(0.0))
.top(h * clip_top)
.w(w)
.h(h * (1.0 - clip_top))
.overflow_hidden()
.opacity(look.opacity)
.child(picture.top(top - h * clip_top))
.into_any_element()
}
/// A left slide, its effects as at second `at`.
pub fn left(
(stack, runs): (&Stack, &[timeline::Run]),
(slide, at): ((f32, f32), f64),
size: (Px, Px),
paper: gpui::Hsla,
) -> AnyElement {
div()
.size_full()
.bg(paper)
.child(effects(stack, runs, slide, at, size))
.into_any_element()
}
+214
View File
@@ -0,0 +1,214 @@
//! When each effect runs, and how its object looks meanwhile.
use ely_lok::show::Effect;
/// How an object looks at one moment.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Look {
pub visible: bool,
pub opacity: f32,
/// Offset as a share of the slide's width and height.
pub shift: (f32, f32),
/// Scale about the object's centre.
pub scale: f32,
/// Share of the object shown, from its bottom up.
pub reveal: f32,
}
impl Look {
pub const SHOWN: Look = Look {
visible: true,
opacity: 1.0,
shift: (0.0, 0.0),
scale: 1.0,
reveal: 1.0,
};
pub const HIDDEN: Look = Look {
visible: false,
..Look::SHOWN
};
}
/// One effect placed on the clock, in seconds.
#[derive(Clone, Debug, PartialEq)]
pub struct Run {
pub hash: String,
pub preset: String,
pub begin: f64,
pub end: f64,
}
/// Places the effects back to back, as a preview plays.
pub fn schedule(effects: &[Effect]) -> Vec<Run> {
let (mut runs, mut last_begin, mut last_end) = (Vec::new(), 0.0f64, 0.0f64);
let mut end_of_all = 0.0f64;
for effect in effects {
// A click waits for all before; previews click at once.
let anchor = match effect.start.as_str() {
"with" => last_begin,
"after" => last_end,
_ => end_of_all,
};
let begin = anchor + effect.delay;
let end = begin + effect.duration.max(0.0);
runs.push(Run {
hash: effect.hash.clone(),
preset: effect.preset.clone(),
begin,
end,
});
(last_begin, last_end) = (begin, end);
end_of_all = end_of_all.max(end);
}
runs
}
/// The ease the kernel's presets mostly use.
fn ease(p: f32) -> f32 {
p * p * (3.0 - 2.0 * p)
}
/// An entrance at progress `p`: 0 hidden, 1 in place.
fn entrance(kind: &str, p: f32, top: f32) -> Look {
let p = ease(p.clamp(0.0, 1.0));
match kind {
"fade-in" => Look {
opacity: p,
..Look::SHOWN
},
// From below the slide's bottom edge.
"fly-in" => Look {
shift: (0.0, (1.0 - p) * (1.0 - top)),
..Look::SHOWN
},
"wipe" => Look {
reveal: p,
..Look::SHOWN
},
"zoom" => Look {
scale: p,
..Look::SHOWN
},
"float" => Look {
opacity: p,
shift: (0.0, (1.0 - p) * 0.1),
..Look::SHOWN
},
// Appear, and effects the preview does not draw.
_ => {
if p > 0.0 {
Look::SHOWN
} else {
Look::HIDDEN
}
}
}
}
/// How `run`'s object looks at second `t`, once started.
pub fn look(run: &Run, t: f64, top: f32) -> Option<Look> {
if t < run.begin {
return None;
}
let span = run.end - run.begin;
let p = if span <= 0.0 {
1.0
} else {
((t - run.begin) / span).min(1.0) as f32
};
let (class, kind) = run
.preset
.strip_prefix("ooo-")
.and_then(|rest| rest.split_once('-'))
.unwrap_or(("other", ""));
Some(match (class, kind) {
("entrance", kind) => entrance(kind, if span <= 0.0 { 1.0 } else { p }, top),
("exit", kind) => {
let mirrored = kind.replace("-out", "-in").replace("disappear", "appear");
let gone = entrance(&mirrored, 1.0 - p, top);
if p >= 1.0 { Look::HIDDEN } else { gone }
}
("emphasis", "grow-and-shrink") => Look {
scale: 1.0 + 0.5 * (1.0 - (2.0 * p - 1.0).abs()),
..Look::SHOWN
},
("emphasis", "blink") => {
if p < 0.5 && p > 0.0 {
Look::HIDDEN
} else {
Look::SHOWN
}
}
_ => Look::SHOWN,
})
}
/// The object's look at `t`, from its last started run.
pub fn object_look(runs: &[Run], hash: &str, visible: bool, t: f64, top: f32) -> Look {
let ran = runs
.iter()
.rev()
.filter(|run| run.hash == hash)
.find_map(|run| look(run, t, top));
ran.unwrap_or(if visible { Look::SHOWN } else { Look::HIDDEN })
}
/// When the last effect ends.
pub fn length(runs: &[Run]) -> f64 {
runs.iter().map(|run| run.end).fold(0.0, f64::max)
}
#[cfg(test)]
mod tests {
use super::*;
fn effect(preset: &str, start: &str, duration: f64, delay: f64) -> Effect {
Effect {
preset: preset.into(),
label: String::new(),
class: String::new(),
target: Some(0),
hash: preset.into(),
start: start.into(),
duration,
delay,
}
}
#[test]
fn clicks_wait_with_joins_and_after_follows() {
let runs = schedule(&[
effect("ooo-entrance-fade-in", "click", 2.0, 0.0),
effect("ooo-entrance-fly-in", "with", 1.0, 0.5),
effect("ooo-entrance-wipe", "after", 1.0, 0.0),
effect("ooo-entrance-zoom", "click", 1.0, 0.0),
]);
let times: Vec<(f64, f64)> = runs.iter().map(|r| (r.begin, r.end)).collect();
assert_eq!(times, [(0.0, 2.0), (0.5, 1.5), (1.5, 2.5), (2.5, 3.5)]);
assert_eq!(length(&runs), 3.5);
}
#[test]
fn entrances_hide_before_and_rest_in_place_after() {
let run = &schedule(&[effect("ooo-entrance-fade-in", "click", 2.0, 1.0)])[0];
let at = |t| object_look(std::slice::from_ref(run), &run.hash, false, t, 0.3);
assert_eq!(at(0.5), Look::HIDDEN);
assert!((at(2.0).opacity - 0.5).abs() < 0.01);
assert_eq!(at(4.0), Look::SHOWN);
}
#[test]
fn exits_end_hidden_and_emphasis_returns() {
let runs = schedule(&[
effect("ooo-exit-fly-out", "click", 1.0, 0.0),
effect("ooo-emphasis-grow-and-shrink", "click", 2.0, 0.0),
]);
let out = |t| object_look(&runs[..1], &runs[0].hash, true, t, 0.5);
assert_eq!(out(0.0), Look::SHOWN);
assert!(out(0.5).shift.1 > 0.0, "on its way down");
assert_eq!(out(1.0), Look::HIDDEN);
let grow = |t| object_look(&runs[1..], &runs[1].hash, true, t, 0.5).scale;
assert!((grow(2.0) - 1.5).abs() < 0.01, "biggest halfway");
assert!((grow(3.0) - 1.0).abs() < 0.01, "back after");
}
}
+148
View File
@@ -0,0 +1,148 @@
//! How two slides meet in each transition Ely offers.
/// Where a slide shows: x0, y0, x1, y1 as shares.
pub type Area = (f32, f32, f32, f32);
const WHOLE: Area = (0.0, 0.0, 1.0, 1.0);
/// One frame: offsets, shown areas and black over all.
#[derive(Clone, Debug, PartialEq)]
pub struct Frame {
pub from_shift: (f32, f32),
pub to_shift: (f32, f32),
pub to_opacity: f32,
/// Where the new slide shows, over the old.
pub to_areas: Vec<Area>,
pub black: f32,
/// The old slide lies over the new one.
pub old_on_top: bool,
}
/// Dissolve's squares a side.
const GRID: usize = 12;
/// The new slide alone, in place.
pub fn done() -> Frame {
Frame {
from_shift: (0.0, 0.0),
to_shift: (0.0, 0.0),
to_opacity: 1.0,
to_areas: vec![WHOLE],
black: 0.0,
old_on_top: false,
}
}
/// The frame of `preset` at progress `p`, 0 to 1.
pub fn frame(preset: &str, p: f32) -> Frame {
let p = p.clamp(0.0, 1.0);
let base = done();
match preset {
"fade-smoothly" => Frame {
to_opacity: p,
..base
},
// Down to black, then up from it.
"fade-through-black" => Frame {
to_opacity: if p < 0.5 { 0.0 } else { 1.0 },
black: 1.0 - (2.0 * p - 1.0).abs(),
..base
},
"push-left" => Frame {
from_shift: (-p, 0.0),
to_shift: (1.0 - p, 0.0),
..base
},
"push-up" => Frame {
from_shift: (0.0, -p),
to_shift: (0.0, 1.0 - p),
..base
},
"cover-left" => Frame {
to_shift: (1.0 - p, 0.0),
..base
},
// The old slide slides away; the new one waits beneath.
"uncover-left" => Frame {
from_shift: (-p, 0.0),
old_on_top: true,
..base
},
"wipe-right" => Frame {
to_areas: vec![(0.0, 0.0, p, 1.0)],
..base
},
"split-vertical-out" => Frame {
to_areas: vec![(0.5 - p / 2.0, 0.0, 0.5 + p / 2.0, 1.0)],
..base
},
"dissolve" => Frame {
to_areas: dissolved(p),
..base
},
// No transition, and ones the preview does not draw.
_ => base,
}
}
/// The new slide's squares shown at `p`, fixed scatter.
fn dissolved(p: f32) -> Vec<Area> {
let count = GRID * GRID;
let shown = (p * count as f32).round() as usize;
let side = 1.0 / GRID as f32;
// A stride coprime with the count visits every square once.
(0..shown)
.map(|n| (n * 89) % count)
.map(|cell| {
let (x, y) = ((cell % GRID) as f32 * side, (cell / GRID) as f32 * side);
(x, y, x + side, y + side)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_offered_transition_ends_on_the_new_slide_alone() {
for preset in ely_lok::show::TRANSITIONS {
let end = frame(preset, 1.0);
assert_eq!(
(end.to_shift, end.to_opacity, end.black),
((0.0, 0.0), 1.0, 0.0),
"{preset}"
);
let covered: f32 = end.to_areas.iter().map(|a| (a.2 - a.0) * (a.3 - a.1)).sum();
assert!((covered - 1.0).abs() < 0.001, "{preset} covers {covered}");
}
}
#[test]
fn only_uncover_lays_the_old_slide_on_top() {
for preset in ely_lok::show::TRANSITIONS {
assert_eq!(
frame(preset, 0.5).old_on_top,
preset == "uncover-left",
"{preset}"
);
}
}
#[test]
fn halfway_frames_mix_both_slides() {
assert_eq!(frame("push-left", 0.5).from_shift, (-0.5, 0.0));
assert_eq!(frame("wipe-right", 0.5).to_areas, [(0.0, 0.0, 0.5, 1.0)]);
assert_eq!(frame("fade-through-black", 0.5).black, 1.0);
let half = frame("dissolve", 0.5).to_areas;
assert_eq!(half.len(), GRID * GRID / 2);
let mut cells: Vec<_> = frame("dissolve", 1.0)
.to_areas
.iter()
.map(|a| ((a.0 * 100.0) as i32, (a.1 * 100.0) as i32))
.collect();
cells.sort();
cells.dedup();
assert_eq!(cells.len(), GRID * GRID, "no square twice");
}
}
+291
View File
@@ -0,0 +1,291 @@
//! Comments on slides, beside the canvas (SHOW-08).
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
collab::{Comment, CommentSidebar, Peer, Thread},
feedback::InlineMessage,
forms::TextInput,
overlays::PromptDialog,
primitives::Severity,
theme::ControlSize,
};
use ely_lok::show::Remark;
use ely_office::prelude::*;
use gpui::{
AnyElement, App, AppContext as _, Context, IntoElement, ParentElement as _, SharedString,
Styled as _, Window, div, prelude::FluentBuilder as _,
};
use crate::deck::Deck;
use crate::dialog::Dialog;
/// Each comment as a thread quoted with its slide.
fn threads(remarks: &[Remark], cx: &App) -> Result<Vec<Thread>, String> {
let authors: Vec<&str> = remarks
.iter()
.map(|remark| remark.author.as_str())
.collect();
remarks
.iter()
.map(|remark| {
let at = remark
.at
.parse::<jiff::civil::DateTime>()
.and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()))
.map_err(|error| format!("a bad comment time {}: {error}", remark.at))?;
let hue = authors
.iter()
.position(|name| *name == remark.author)
.expect("the author is listed");
let slide = (remark.part + 1).to_string();
Ok(Thread {
key: remark.id.clone().into(),
quote: Some(Deck::text(cx, "show.remark.slide", &[("n", &slide)])),
comments: vec![Comment {
author: Peer::new(remark.author.clone(), remark.author.clone(), hue),
at: at.timestamp(),
body: remark.text.clone().into(),
reactions: Vec::new(),
}],
resolved: false,
})
})
.collect()
}
impl Deck {
/// Opens the comment dialog: new, or an edit of `edit`.
pub fn ask_remark(
&mut self,
edit: Option<String>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(tab) = self.active_tab() else {
return;
};
let text = match &edit {
None => String::new(),
Some(id) => match tab.extra.remarks.iter().find(|remark| &remark.id == id) {
Some(remark) => remark.text.clone(),
None => return log::warn!("show: comment {id} went before its edit"),
},
};
let (tab, label) = (tab.id, Deck::text(cx, "show.remark.text", &[]));
let field = cx.new(|cx| {
let mut input = TextInput::new(window, cx).multi_line(3, 8).label(label);
input.set_text(text, cx);
input
});
self.dialog = Some(Dialog::Remark { tab, edit, field });
cx.notify();
}
/// Sends the dialog's text: a new comment or an edit.
pub(crate) fn send_remark(
&mut self,
(tab, edit): (u64, Option<String>),
text: &str,
cx: &mut Context<Self>,
) {
let text = text.trim();
match edit {
None => self.run_in(Some(tab), "comment.insert", &[text], cx),
Some(id) => self.run_in(Some(tab), "remark.edit", &[&id, text], cx),
}
self.force_reread(tab, cx);
cx.notify();
}
pub fn remark_dialog(
&self,
(tab, edit): (u64, &Option<String>),
field: &gpui::Entity<TextInput>,
cx: &mut Context<Self>,
) -> AnyElement {
let this = cx.entity();
let label = |key: &str| Deck::text(cx, key, &[]);
let title = match edit {
None => "show.remark.new_title",
Some(_) => "show.remark.edit_title",
};
let closing = this.clone();
let dismiss = move |_: &mut Window, cx: &mut App| {
closing.update(cx, |deck, cx| {
deck.dialog = None;
cx.notify();
})
};
let (edit, blank) = (edit.clone(), label("show.remark.blank"));
PromptDialog::new("show-remark", label(title), field, dismiss)
.submit(label("show.remark.save"))
.check(move |text| match text.trim().is_empty() {
true => Err(blank.clone()),
false => Ok(()),
})
.on_submit(move |text, _, cx| {
this.update(cx, |deck, cx| {
deck.send_remark((tab, edit.clone()), text, cx)
})
})
.into_any_element()
}
/// The picked comment, while its slide is shown.
pub(crate) fn shown_remark(&self) -> Option<SharedString> {
let tab = self.active_tab()?;
let part = tab.extra.slides.current;
let key = self.active_remark.clone()?;
let here = |remark: &Remark| remark.id == key.as_ref() && remark.part == part;
tab.extra.remarks.iter().any(here).then_some(key)
}
/// Shows comment `key`'s slide and marks it.
pub(crate) fn pick_remark(&mut self, key: &SharedString, cx: &mut Context<Self>) {
self.active_remark = Some(key.clone());
let Some(tab) = self.active_tab() else {
return;
};
if let Some(remark) = tab
.extra
.remarks
.iter()
.find(|remark| remark.id == key.as_ref())
{
let (view, part) = (tab.view.clone(), remark.part);
log::info!("show: comment {key} on slide {part}");
view.update(cx, |view, _| view.show_part(part));
}
cx.notify();
}
/// The comments column.
pub fn remarks_view(&self, cx: &mut Context<Self>) -> AnyElement {
let this = cx.entity();
let remarks = self
.active_tab()
.map(|tab| tab.extra.remarks.clone())
.unwrap_or_default();
let threads = match threads(&remarks, cx) {
Ok(threads) => threads,
Err(error) => {
log::error!("show: {error}");
let message = InlineMessage::new(Severity::Danger, error);
return div().p_2().child(message).into_any_element();
}
};
let active = self.shown_remark();
let act = |id: &'static str,
key: &str,
on: bool,
run: fn(&mut Deck, String, &mut Window, &mut Context<Deck>)| {
let (this, active) = (this.clone(), active.clone());
Button::new(id, Deck::text(cx, key, &[]))
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.disabled(!on)
.on_click(move |_, window, cx| {
let id = active
.clone()
.map(|key| key.to_string())
.unwrap_or_default();
this.update(cx, |deck, cx| run(deck, id, window, cx))
})
};
let some = active.is_some();
let head = div()
.flex()
.flex_wrap()
.gap_1()
.child(act(
"show-remark-add",
"show.remark.add",
true,
|deck, _, window, cx| deck.ask_remark(None, window, cx),
))
.child(act(
"show-remark-edit",
"show.remark.edit",
some,
|deck, id, window, cx| deck.ask_remark(Some(id), window, cx),
))
.child(act(
"show-remark-delete",
"show.remark.delete",
some,
|deck, id, _, cx| deck.delete_remark(&id, cx),
));
let pick = this.clone();
let sidebar = CommentSidebar::new("show-remarks", threads)
.active(active, move |key, _, cx| {
pick.update(cx, |deck, cx| deck.pick_remark(key, cx))
});
div()
.size_full()
.flex()
.flex_col()
.gap_2()
.p_2()
.child(head)
.child(div().flex_1().min_h_0().child(sidebar))
.into_any_element()
}
fn delete_remark(&mut self, id: &str, cx: &mut Context<Self>) {
if let Some(tab) = self.core.active {
self.active_remark = None;
self.run_in(Some(tab), "remark.delete", &[id], cx);
self.force_reread(tab, cx);
}
}
}
/// The comments column as a view of the deck.
pub struct Pane {
pub deck: gpui::WeakEntity<Deck>,
}
impl gpui::Render for Pane {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(deck) = self.deck.upgrade() else {
return div().into_any_element();
};
deck.update(cx, |deck, cx| deck.remarks_view(cx))
}
}
impl Deck {
/// A pin on the shown slide for each comment there.
pub fn remark_layer(
&self,
view: &gpui::Entity<ely_lok::LokView>,
cx: &App,
) -> Option<AnyElement> {
let tab = self.active_tab()?;
let part = tab.extra.slides.current;
let lok = view.read(cx);
let theme = ely_gpui_component::theme::ActiveTheme::theme(cx);
let pins: Vec<AnyElement> = (tab.extra.remarks.iter())
.filter(|remark| remark.part == part)
.map(|remark| {
let (x, y, _, _) = remark.anchor;
let at = lok.place((x, y));
let lit = self
.active_remark
.as_ref()
.is_some_and(|key| key == &remark.id);
div()
.absolute()
.left(at.x)
.top(at.y)
.size(gpui::px(14.0))
.rounded_sm()
.border_1()
.border_color(theme.colors.accent)
.when(lit, |pin| pin.bg(theme.colors.accent))
.into_any_element()
})
.collect();
(!pins.is_empty()).then(|| div().absolute().inset_0().children(pins).into_any_element())
}
}
+306
View File
@@ -0,0 +1,306 @@
//! The slide strip and the Slide menu (SHOW-01).
use std::sync::Arc;
use ely_gpui_component::{
menus::{ContextMenu, Menu as Popup, MenuItem as PopupItem},
primitives::{FocusRing as _, Icon, IconName},
theme::ActiveTheme as _,
};
use ely_office::prelude::*;
use gpui::{
Action, App, Context, Div, InteractiveElement as _, IntoElement, Menu, MenuItem,
ParentElement as _, Render, RenderImage, Role, StatefulInteractiveElement as _, Styled as _,
WeakEntity, Window, actions, div, img, prelude::FluentBuilder as _, px,
};
use image::{Frame, ImageBuffer, Rgba};
use crate::deck::Deck;
actions!(
show,
[
NewSlide,
DuplicateSlide,
DeleteSlide,
HideSlide,
ShowSlide,
MoveSlideUp,
MoveSlideDown,
MoveSlideFirst,
MoveSlideLast
]
);
/// Thumbnail pixels: twice the shown size.
const PICTURE: (u32, u32) = (320, 180);
/// The shown width in pixels.
const WIDTH: f32 = 160.0;
pub fn menu(cx: &App) -> Menu {
let label = |key: &str| Deck::text(cx, key, &[]);
Menu::new(label("menu.slide")).items([
MenuItem::action(label("show.slide.new"), NewSlide),
MenuItem::action(label("show.slide.duplicate"), DuplicateSlide),
MenuItem::action(label("show.slide.delete"), DeleteSlide),
MenuItem::separator(),
MenuItem::action(label("show.slide.hide"), HideSlide),
MenuItem::action(label("show.slide.show"), ShowSlide),
MenuItem::separator(),
MenuItem::action(label("show.slide.up"), MoveSlideUp),
MenuItem::action(label("show.slide.down"), MoveSlideDown),
MenuItem::action(label("show.slide.first"), MoveSlideFirst),
MenuItem::action(label("show.slide.last"), MoveSlideLast),
])
}
/// The same commands on a right click.
fn popup(cx: &App) -> Popup {
let item = |key: &str, action: Box<dyn Action>| {
PopupItem::new(Deck::text(cx, key, &[]))
.on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx))
};
Popup::new()
.item(item("show.slide.new", Box::new(NewSlide)))
.item(item("show.slide.duplicate", Box::new(DuplicateSlide)))
.item(item("show.slide.delete", Box::new(DeleteSlide)))
.separator()
.item(item("show.slide.hide", Box::new(HideSlide)))
.item(item("show.slide.show", Box::new(ShowSlide)))
}
/// Binds an action to a slide command.
macro_rules! run {
($root:expr, $cx:expr, $action:ty, $id:literal) => {
$root.on_action($cx.listener(|deck, _: &$action, _, cx| deck.slide_command($id, cx)))
};
}
pub fn bind(root: Div, cx: &mut Context<Deck>) -> Div {
let root = run!(root, cx, NewSlide, "slide.insert");
let root = run!(root, cx, DuplicateSlide, "slide.duplicate");
let root = run!(root, cx, DeleteSlide, "slide.delete");
let root = run!(root, cx, HideSlide, "slide.hide");
let root = run!(root, cx, ShowSlide, "slide.show");
let root = run!(root, cx, MoveSlideUp, "slide.up");
let root = run!(root, cx, MoveSlideDown, "slide.down");
let root = run!(root, cx, MoveSlideFirst, "slide.first");
run!(root, cx, MoveSlideLast, "slide.last")
}
impl Deck {
/// Runs a slide command; masters take none.
fn slide_command(&mut self, id: &str, cx: &mut Context<Self>) {
if self.active_tab().is_some_and(|tab| tab.extra.masters) {
let text = Deck::text(cx, "show.masters.busy", &[]);
return self.toast(text, ely_gpui_component::primitives::Severity::Warning, cx);
}
self.run_active(id, &[], cx);
}
/// Paints the next missing or stale slide picture.
pub fn paint_next(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(tab) = self.tab(id) else {
return;
};
let extra = &tab.extra;
if extra.painting {
return;
}
let next = extra.slides.list.iter().enumerate().find(|(_, slide)| {
!extra.failed.contains(&slide.name)
&& (!extra.thumbs.contains_key(&slide.name) || extra.stale.contains(&slide.name))
});
let Some((part, slide)) = next else {
return;
};
if !self.settled(id, cx) {
return self.when_settled(id, move |deck, _, cx| deck.paint_next(id, cx));
}
let (name, master) = (slide.name.clone(), extra.masters);
let failing = name.clone();
let doc = tab.doc.clone();
let tab = self.tab_mut(id).expect("the tab is open");
tab.extra.painting = true;
tab.extra.stale.remove(&name);
ely_lok::read_off(
&self.core.worker,
&doc,
move |worker, doc| doc.paint_part(worker, (part, master), PICTURE),
move |deck: &mut Self, painted, cx| {
let Some(tab) = deck.tab_mut(id) else {
return;
};
tab.extra.painting = false;
match painted {
Ok(pixels) => {
let image =
ImageBuffer::<Rgba<u8>, _>::from_raw(PICTURE.0, PICTURE.1, pixels)
.expect("the worker paints the asked size");
let image = Arc::new(RenderImage::new([Frame::new(image)]));
if let Some(old) = tab.extra.thumbs.insert(name, image) {
tab.extra.retired.push(old);
}
}
Err(error) => {
tab.extra.failed.insert(failing);
deck.engine_failed(&error.to_string(), cx)
}
}
cx.notify();
deck.paint_next(id, cx);
},
cx,
);
}
}
/// The left column: one picture per slide or master.
pub struct Strip {
deck: WeakEntity<Deck>,
}
impl Strip {
pub fn new(deck: WeakEntity<Deck>, cx: &mut Context<Self>) -> Self {
if let Some(deck) = deck.upgrade() {
cx.observe(&deck, |_, _, cx| cx.notify()).detach();
}
Strip { deck }
}
}
impl Deck {
/// Frees pictures no longer drawn, in every tab.
pub fn release_pictures(&mut self, window: &mut Window) {
self.retired.release(window);
for tab in &mut self.core.tabs {
tab.extra.retired.release(window);
}
}
/// A closing tab's pictures wait to be freed.
pub fn retire_tab(&mut self, tab: &ely_office::Tab<crate::deck::Extra, ely_office::Lok>) {
for picture in tab.extra.thumbs.values() {
self.retired.push(picture.clone());
}
self.retired.append(tab.extra.retired.clone());
}
}
impl crate::deck::Extra {
/// Pictures of slides no longer there go.
pub fn keep_pictures(&mut self, names: &std::collections::HashSet<&String>) {
let retired = &mut self.retired;
self.thumbs.retain(|name, picture| {
let kept = names.contains(name);
if !kept {
retired.push(picture.clone());
}
kept
});
}
/// Every picture goes, to be painted again.
pub fn drop_pictures(&mut self) {
for (_, picture) in self.thumbs.drain() {
self.retired.push(picture);
}
}
}
impl Render for Strip {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(deck) = self.deck.upgrade() else {
return div();
};
let colors = cx.theme().colors.clone();
let read = deck.read(cx);
let Some(tab) = read.active_tab() else {
return div();
};
let height = WIDTH * PICTURE.1 as f32 / PICTURE.0 as f32;
let hidden = Deck::text(cx, "show.slide.hidden", &[]);
let count = tab.extra.slides.list.len();
let slides = tab
.extra
.slides
.list
.iter()
.enumerate()
.map(|(part, slide)| {
let current = part == tab.extra.slides.current;
let picture = tab.extra.thumbs.get(&slide.name).cloned();
let (view, pressed) = (tab.view.clone(), tab.view.clone());
let label = format!("{} {}", part + 1, slide.name);
div()
.id(("show-slide", part))
.tab_index(0)
.focus_ring(cx)
.flex()
.gap_2()
.items_start()
.p_1()
.cursor_pointer()
.on_click(move |_, _, cx| view.update(cx, |view, _| view.show_part(part)))
.on_mouse_down(gpui::MouseButton::Right, move |_, _, cx| {
pressed.update(cx, |view, _| view.show_part(part))
})
.child(
div()
.w_5()
.text_xs()
.text_color(colors.fg_subtle)
.child((part + 1).to_string()),
)
.child(
div()
.w(px(WIDTH))
.h(px(height))
.relative()
.border_1()
.border_color(if current { colors.focus } else { colors.border })
.when(!slide.visible, |frame| frame.opacity(0.5))
.when_some(picture, |frame, picture| {
frame.child(img(picture).id(("show-picture", part)).size_full())
})
.when(!slide.visible, |frame| {
frame.child(
div()
.absolute()
.top_1()
.right_1()
.child(Icon::new(IconName::EyeOff)),
)
}),
)
.role(Role::ListItem)
.aria_label(label)
.aria_selected(current)
.aria_position_in_set(part + 1)
.aria_size_of_set(count)
.when(!slide.visible, |row| row.aria_description(hidden.clone()))
});
let list = div()
.id("show-strip")
.role(Role::List)
.aria_label(Deck::text(
cx,
if tab.extra.masters {
"show.strip.masters"
} else {
"show.strip.slides"
},
&[],
))
.size_full()
.overflow_y_scroll()
.flex()
.flex_col()
.gap_2()
.p_2()
.children(slides);
div()
.size_full()
.child(ContextMenu::new("show-strip-menu", popup(cx)).child(list))
}
}
+350
View File
@@ -0,0 +1,350 @@
//! The show: full screen, presenter view, keys (SHOW-11, 12).
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use ely_lok::{
Doc, Worker,
show::{Part, ShowSlide},
};
use gpui::{AnyWindowHandle, Context, RenderImage, SharedString, Task, WeakEntity};
use crate::deck::Deck;
use crate::preview::timeline::{self, Run};
use steps::Place;
mod audience;
mod keys;
mod layers;
mod media;
mod presenter;
mod stage;
pub(crate) mod start;
pub(crate) mod steps;
pub use keys::{bind, bind_keys, menu};
/// A slide's layers as pictures, bottom first.
pub type Stack = Arc<Vec<(Part, Arc<RenderImage>)>>;
/// Clicks long done: a slide shown fully built.
const BUILT: f64 = -1.0e6;
/// The slide left, as it looked when left.
pub struct Leaving {
pub stack: Stack,
pub runs: Vec<Run>,
/// Seconds after its entry.
pub at: f64,
}
/// How a step reaches its place.
#[derive(Clone, Copy, PartialEq)]
enum How {
/// The next click group on the same slide.
Click,
/// The next slide, with its transition.
Enter,
/// Straight there: jumps, Home, End.
Jump,
/// Back to a slide, all its effects done.
Back,
}
pub struct Show {
deck: WeakEntity<Deck>,
worker: Arc<Mutex<Worker>>,
/// The copy the show plays; edits never reach it.
doc: Doc,
copy: PathBuf,
pub slides: Vec<ShowSlide>,
/// The slide in 1/100 mm.
pub slide: (f32, f32),
pixels: (u32, u32),
pub place: Place,
/// When each click group began, seconds after entry.
pub clicks: Vec<f64>,
pub entered: Instant,
/// The slide's layers are on their way; its clock waits.
pub waiting: bool,
pub leaving: Option<Leaving>,
pub black: bool,
pub laser: bool,
/// The pointer on the slide, as shares.
pub pointer: Option<(f32, f32)>,
pub began: Instant,
layers: HashMap<usize, Stack>,
/// Stacks no longer wanted; freed once nothing draws them.
gone: Vec<Stack>,
/// Per window, audience then presenter: images to free.
retired: [ely_gpui_component::primitives::Retired; 2],
asking: HashSet<usize>,
/// Digits typed toward a jump.
pub typed: String,
pub windows: Vec<AnyWindowHandle>,
/// The presenter view is open.
pub presenter: bool,
/// What went wrong, for the presenter.
pub notice: Option<SharedString>,
advance: Option<Task<()>>,
/// Lets the left slide go once the transition ends.
release: Option<Task<()>>,
media: Option<media::Playing>,
ended: bool,
}
impl Show {
pub fn new(
deck: WeakEntity<Deck>,
(worker, doc, copy): (Arc<Mutex<Worker>>, Doc, PathBuf),
(slides, first): (Vec<ShowSlide>, usize),
(slide, pixels): ((f32, f32), (u32, u32)),
cx: &mut Context<Self>,
) -> Self {
let mut show = Show {
deck,
worker,
doc,
copy,
slides,
slide,
pixels,
place: Place::Slide {
part: first,
clicks: 0,
},
clicks: vec![0.0],
entered: Instant::now(),
waiting: true,
leaving: None,
black: false,
laser: false,
pointer: None,
began: Instant::now(),
layers: HashMap::new(),
gone: Vec::new(),
retired: Default::default(),
asking: HashSet::new(),
typed: String::new(),
windows: Vec::new(),
presenter: false,
notice: None,
advance: None,
release: None,
media: None,
ended: false,
};
show.fetch(cx);
show.plan_advance(cx);
show
}
pub fn part(&self) -> Option<usize> {
match self.place {
Place::Slide { part, .. } => Some(part),
Place::End => None,
}
}
/// The next slide the show will reach.
pub fn upcoming(&self) -> Option<usize> {
let from = self.part()?;
(from + 1..self.slides.len()).find(|at| !self.slides[*at].hidden)
}
fn go(&mut self, to: Place, how: How, cx: &mut Context<Self>) {
self.typed.clear();
let now = match self.waiting {
true => 0.0,
false => self.entered.elapsed().as_secs_f64(),
};
match (how, to) {
(How::Click, Place::Slide { part, .. }) => {
let at = steps::click_at(&self.slides[part].effects, &self.clicks, now);
self.clicks.push(at);
}
(_, Place::Slide { part, clicks }) => {
let transition = &self.slides[part].transition;
let moving = how == How::Enter && !transition.preset.is_empty();
self.leaving = (self.part())
.filter(|_| moving)
.and_then(|from| {
Some(Leaving {
stack: self.layers.get(&from)?.clone(),
runs: steps::runs(&self.slides[from].effects, &self.clicks),
at: now,
})
});
let lead = match self.leaving {
Some(_) => transition.duration,
None => 0.0,
};
self.entered = Instant::now();
self.waiting = !self.layers.contains_key(&part);
self.clicks = match how {
How::Back => vec![BUILT; clicks + 1],
_ => vec![lead],
};
(self.media, self.notice) = (None, None);
}
(_, Place::End) => {
self.leaving = None;
(self.media, self.notice) = (None, None);
}
}
log::info!("show: goes to {to:?}");
self.place = to;
self.fetch(cx);
self.plan_advance(cx);
self.plan_release(cx);
cx.notify();
}
/// The slide's layers came: its clock starts.
pub(super) fn arrived(&mut self, cx: &mut Context<Self>) {
if std::mem::replace(&mut self.waiting, false) {
log::info!("show: the slide is drawn; its clock starts");
self.entered = Instant::now();
self.plan_advance(cx);
self.plan_release(cx);
}
}
/// Drops the left slide's layers when the transition ends.
fn plan_release(&mut self, cx: &mut Context<Self>) {
self.release = None;
let (Some(part), Some(_)) = (self.part(), &self.leaving) else {
return;
};
if self.waiting {
return;
}
let rest = self.slides[part].transition.duration - self.entered.elapsed().as_secs_f64();
self.release = Some(cx.spawn(async move |this, cx| {
cx.background_executor()
.timer(Duration::from_secs_f64(rest.max(0.0)))
.await;
let dropped = this.update(cx, |show, cx| {
show.leaving = None;
cx.notify();
});
if dropped.is_err() {
log::info!("show: ended before its transition");
}
}));
}
pub fn next(&mut self, cx: &mut Context<Self>) {
if self.place == Place::End {
return self.close(cx);
}
let to = steps::next(&self.slides, self.place);
let how = match (self.part(), to) {
(Some(from), Place::Slide { part, .. }) if from == part => How::Click,
_ => How::Enter,
};
self.go(to, how, cx);
}
pub fn previous(&mut self, cx: &mut Context<Self>) {
self.go(steps::previous(&self.slides, self.place), How::Back, cx);
}
/// Goes to slide `number`, counted from one.
pub fn jump(&mut self, number: usize, cx: &mut Context<Self>) {
if (1..=self.slides.len()).contains(&number) {
let to = Place::Slide {
part: number - 1,
clicks: 0,
};
self.go(to, How::Jump, cx);
} else {
log::info!("show: no slide {number}");
self.typed.clear();
cx.notify();
}
}
pub fn toggle_black(&mut self, cx: &mut Context<Self>) {
self.black = !self.black;
log::info!("show: black screen {}", self.black);
cx.notify();
}
pub fn toggle_laser(&mut self, cx: &mut Context<Self>) {
self.laser = !self.laser;
self.pointer = None;
log::info!("show: laser pointer {}", self.laser);
cx.notify();
}
pub fn point(&mut self, at: Option<(f32, f32)>, cx: &mut Context<Self>) {
if self.laser && self.pointer != at {
self.pointer = at;
cx.notify();
}
}
/// When the slide's effects have all run.
fn settled_at(&self) -> f64 {
let Some(part) = self.part() else {
return 0.0;
};
let runs = steps::runs(&self.slides[part].effects, &self.clicks);
timeline::length(&runs).max(self.clicks.iter().copied().fold(0.0, f64::max))
}
/// Whether frames still change.
pub fn moving(&self) -> bool {
!self.waiting && self.entered.elapsed().as_secs_f64() < self.settled_at()
}
/// Advances by itself after the slide's time.
fn plan_advance(&mut self, cx: &mut Context<Self>) {
self.advance = None;
let Some(part) = self.part() else {
return;
};
let transition = &self.slides[part].transition;
if !transition.auto || self.waiting {
return;
}
let wait = (self.settled_at() - self.entered.elapsed().as_secs_f64()).max(0.0)
+ transition.time;
self.advance = Some(cx.spawn(async move |this, cx| {
cx.background_executor()
.timer(Duration::from_secs_f64(wait))
.await;
if this.update(cx, |show, cx| show.next(cx)).is_err() {
log::info!("show: ended before it advanced");
}
}));
}
/// Closes the windows and the copy.
pub fn close(&mut self, cx: &mut Context<Self>) {
if std::mem::replace(&mut self.ended, true) {
return;
}
log::info!("show: ends");
(self.advance, self.release) = (None, None);
self.media = None;
let (windows, deck, me) = (std::mem::take(&mut self.windows), self.deck.clone(), cx.entity_id());
start::discard(self.worker.clone(), self.doc.clone(), self.copy.clone(), cx);
// A key may come from a window being closed.
cx.defer(move |cx| {
for window in windows {
if let Err(error) = window.update(cx, |_, window, _| window.remove_window()) {
log::error!("show: a window did not close: {error}");
}
}
if let Some(deck) = deck.upgrade() {
deck.update(cx, |deck, cx| deck.show_ended(me, cx));
}
});
}
}
+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)]);
}
}
+128
View File
@@ -0,0 +1,128 @@
//! Tables on a slide (SHOW-07).
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::{DescriptionList, PropertyGroup},
forms::NumberInput,
overlays::Dialog as Card,
};
use ely_office::{
panel::{button, row},
prelude::*,
};
use gpui::{
AnyElement, App, Context, Entity, IntoElement, ParentElement as _, Styled as _, Window, div,
};
use crate::deck::Deck;
use crate::design::Panel;
use crate::dialog::{Dialog, edit};
impl Deck {
pub fn ask_table(&mut self, cx: &mut Context<Self>) {
if let Some(tab) = self.core.active {
self.dialog = Some(Dialog::Table {
tab,
columns: 3,
rows: 3,
});
cx.notify();
}
}
pub(crate) fn table_asked(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Table { tab, columns, rows }) = self.dialog.take() else {
return;
};
let (columns, rows) = (columns.to_string(), rows.to_string());
self.run_in(Some(tab), "table.insert", &[&columns, &rows], cx);
self.force_reread(tab, cx);
cx.notify();
}
pub fn table_dialog(&self, (columns, rows): (u16, u16), cx: &mut Context<Self>) -> AnyElement {
let this = cx.entity();
let label = |key: &str| Deck::text(cx, key, &[]);
let count = |id: &'static str, key: &str, now: u16, max: f64, across: bool| {
// A typed draft counts before blur commits it.
let set = move |this: &Entity<Deck>, n: f64, cx: &mut App| {
edit(this, cx, |dialog, _| {
if let Dialog::Table { columns, rows, .. } = dialog {
*(if across { columns } else { rows }) = n as u16;
}
})
};
let (typed, committed) = (this.clone(), this.clone());
let field = NumberInput::new(id, f64::from(now))
.label(label(key))
.range(1.0, max)
.on_change(move |n, _, cx| set(&typed, n, cx))
.on_commit(move |n, _, cx| set(&committed, n, cx));
div().w_full().max_w_32().child(field)
};
let rows = DescriptionList::new()
.item(
label("show.table.columns"),
count(
"show-table-columns",
"show.table.columns",
columns,
20.0,
true,
),
)
.item(
label("show.table.rows"),
count("show-table-rows", "show.table.rows", rows, 50.0, false),
);
let closing = this.clone();
let dismiss = move |_: &mut Window, cx: &mut App| {
closing.update(cx, |deck, cx| {
deck.dialog = None;
cx.notify();
})
};
let go = label("show.table.go");
Card::new("show-table", label("show.table.title"), dismiss)
.child(rows)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("show-table-go", go)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| this.update(cx, |deck, cx| deck.table_asked(cx)))
})
.into_any_element()
}
}
impl Panel {
/// Rows, columns, cells and the table itself.
pub(crate) fn table_group(&self, cx: &mut Context<Self>) -> PropertyGroup {
let why = "show.table.why";
let rows = row([
button(self, "table.row_above", "show.table.row_above", why, cx),
button(self, "table.row_below", "show.table.row_below", why, cx),
button(self, "table.delete_rows", "show.table.delete", why, cx),
]);
let columns = row([
button(self, "table.column_left", "show.table.column_left", why, cx),
button(
self,
"table.column_right",
"show.table.column_right",
why,
cx,
),
button(self, "table.delete_columns", "show.table.delete", why, cx),
]);
let cells = row([
button(self, "table.merge", "show.table.merge", why, cx),
button(self, "table.delete", "show.table.delete_table", why, cx),
]);
let tip = |key: &str| Deck::text(cx, key, &[]);
PropertyGroup::new(tip("show.table.group"))
.row(tip("show.table.rows"), rows)
.row(tip("show.table.columns"), columns)
.row(tip("show.table.cells"), cells)
}
}
+483
View File
@@ -0,0 +1,483 @@
use std::{
path::Path,
sync::{Arc, Mutex},
};
use ely_office::prelude::*;
use gpui::{AppContext as _, Entity, TestAppContext};
use crate::deck::Deck;
mod motion;
mod objects;
mod output;
mod play;
mod play_more;
mod play_time;
mod review;
mod slides;
/// An unrendered Deck over a fresh worker, if LOK exists.
pub(super) fn deck(cx: &mut TestAppContext, dir: &Path) -> Option<Entity<Deck>> {
let install = ely_lok::install_dir().ok()?;
let worker = ely_lok::Worker::spawn(&install, &dir.join("profile")).expect("the worker");
let work = dir.join("work");
std::fs::create_dir_all(&work).expect("the work dir");
cx.update(ely_gpui_component::init_for_tests);
cx.update(|cx| {
ely_ui::settings::install_defaults(cx);
ely_ui::i18n::install(cx);
});
let worker = Arc::new(Mutex::new(worker));
let window = cx.add_window(|_, _| gpui::Empty);
let deck = cx
.update_window(window.into(), |_, window, app| {
let deck = app.new(|cx| Deck::new(worker, work, cx));
deck.update(app, |deck, _| {
deck.core.window = Some(window.window_handle())
});
deck
})
.expect("the window lives");
Some(deck)
}
/// Steps the scheduler until `done` or ten seconds pass.
#[track_caller]
fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::time::Instant::now() < deadline {
if !cx.executor().tick() {
cx.executor()
.advance_clock(std::time::Duration::from_millis(20));
}
if cx.update(|app| done(app)) {
return;
}
}
panic!("the state never settled");
}
fn update(
cx: &mut TestAppContext,
deck: &Entity<Deck>,
f: impl FnOnce(&mut Deck, &mut gpui::Context<Deck>),
) {
cx.update(|app| deck.update(app, f));
}
/// Slides and painted pictures of the active deck.
fn counts(app: &gpui::App, deck: &Entity<Deck>) -> (usize, usize) {
deck.read(app).active_tab().map_or((0, 0), |tab| {
let painted = tab
.extra
.slides
.list
.iter()
.filter(|slide| {
tab.extra.thumbs.contains_key(&slide.name) && !tab.extra.stale.contains(&slide.name)
})
.count();
(tab.extra.slides.list.len(), painted)
})
}
fn quiet(cx: &TestAppContext, deck: &Entity<Deck>) {
settle(cx, |app| {
deck.read(app).active_tab().is_some_and(|tab| {
tab.view.read(app).settled() && !tab.extra.reading && !tab.extra.painting
})
});
}
#[test]
fn a_blank_deck_grows_and_every_slide_has_a_picture() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = deck(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| deck.new_blank(cx));
settle(&cx, |app| counts(app, &deck) == (1, 1));
let layout = cx.update(|app| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.design.clone())
});
assert_eq!(layout.expect("a design").layout, 0, "a title slide");
update(&mut cx, &deck, |deck, cx| {
deck.run_active("slide.insert", &[], cx)
});
update(&mut cx, &deck, |deck, cx| {
deck.run_active("slide.duplicate", &[], cx)
});
settle(&cx, |app| counts(app, &deck) == (3, 3));
update(&mut cx, &deck, |deck, cx| {
deck.run_active("slide.hide", &[], cx)
});
settle(&cx, |app| {
deck.read(app)
.active_tab()
.is_some_and(|tab| tab.extra.slides.list.iter().any(|slide| !slide.visible))
});
}
#[test]
fn design_changes_reach_the_kernel_and_repaint() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = deck(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| deck.new_blank(cx));
settle(&cx, |app| counts(app, &deck) == (1, 1));
let before = cx.update(|app| {
deck.read(app)
.active_tab()
.expect("a tab")
.extra
.thumbs
.values()
.next()
.cloned()
});
update(&mut cx, &deck, |deck, cx| {
deck.design_command("slide.size", &["25400", "19050", "true"], cx)
});
settle(&cx, |app| {
deck.read(app).active_tab().is_some_and(|tab| {
let (w, h) = tab.extra.size;
(w - 14400).abs() <= 2 && (h - 10800).abs() <= 2
})
});
quiet(&cx, &deck);
let after = cx.update(|app| {
deck.read(app)
.active_tab()
.expect("a tab")
.extra
.thumbs
.values()
.next()
.cloned()
});
assert!(
!Arc::ptr_eq(&before.expect("a picture"), &after.expect("a picture")),
"repainted"
);
update(&mut cx, &deck, |deck, cx| {
deck.design_command("slide.layout", &["20"], cx)
});
settle(&cx, |app| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.design.as_ref())
.is_some_and(|design| design.layout == 20)
});
}
#[test]
fn templates_open_as_untitled_decks() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = deck(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let templates = ely_lok::templates("presnt").expect("bundled templates");
assert!(templates.len() >= 5, "{templates:?}");
update(&mut cx, &deck, |deck, cx| {
deck.new_from_file(&templates[0], cx)
});
settle(&cx, |app| counts(app, &deck).0 >= 1);
let name = cx.update(|app| deck.read(app).active_tab().expect("a tab").name.clone());
assert!(
name.contains("Untitled") || name.contains("未命名"),
"{name}"
);
}
#[test]
fn the_master_view_comes_and_goes() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = deck(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| deck.new_blank(cx));
settle(&cx, |app| counts(app, &deck) == (1, 1));
let masters = |app: &gpui::App| {
deck.read(app)
.active_tab()
.is_some_and(|tab| tab.extra.masters)
};
update(&mut cx, &deck, |deck, cx| deck.masters(true, cx));
settle(&cx, |app| masters(app) && counts(app, &deck) == (1, 1));
let shown = cx.update(|app| {
deck.read(app)
.active_tab()
.expect("a tab")
.extra
.slides
.list[0]
.name
.clone()
});
assert_eq!(shown, "Default", "the strip lists masters");
update(&mut cx, &deck, |deck, cx| deck.masters(false, cx));
settle(&cx, |app| !masters(app) && counts(app, &deck) == (1, 1));
}
/// Each cached picture equals the kernel's fresh one.
fn pictures_match(cx: &mut TestAppContext, deck: &Entity<Deck>) {
quiet(cx, deck);
cx.update(|app| {
let deck = deck.read(app);
let tab = deck.active_tab().expect("a tab");
let mut worker = deck.core.worker.lock().expect("the worker lock");
for (part, slide) in tab.extra.slides.list.iter().enumerate() {
let fresh = tab
.doc
.paint_part(&mut worker, (part, false), (320, 180))
.expect("paints");
let cached = tab.extra.thumbs.get(&slide.name).expect("a picture");
assert_eq!(
cached.as_bytes(0),
Some(&fresh[..]),
"slide {part} is current"
);
}
});
}
#[test]
fn undoing_a_resize_repaints_every_slide() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = deck(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| deck.new_blank(cx));
settle(&cx, |app| counts(app, &deck) == (1, 1));
update(&mut cx, &deck, |deck, cx| {
deck.run_active("slide.duplicate", &[], cx)
});
settle(&cx, |app| counts(app, &deck) == (2, 2));
// Width then height, before either reads back.
update(&mut cx, &deck, |deck, cx| deck.resize((25_400, 15_750), cx));
update(&mut cx, &deck, |deck, cx| {
let (w, _) = deck.active_tab().expect("a tab").extra.shown_size();
deck.resize((w, 19_050), cx)
});
settle(&cx, |app| {
deck.read(app).active_tab().is_some_and(|tab| {
let (w, h) = crate::design::mm100(tab.extra.size);
tab.extra.size_asked.is_none() && (w - 25_400).abs() <= 2 && (h - 19_050).abs() <= 2
})
});
pictures_match(&mut cx, &deck);
update(&mut cx, &deck, |deck, cx| {
deck.run_active("edit.undo", &[], cx)
});
update(&mut cx, &deck, |deck, cx| {
deck.run_active("edit.undo", &[], cx)
});
settle(&cx, |app| {
deck.read(app).active_tab().is_some_and(|tab| {
let (w, h) = crate::design::mm100(tab.extra.size);
(w - 28_000).abs() <= 2 && (h - 15_750).abs() <= 2
})
});
pictures_match(&mut cx, &deck);
}
#[test]
fn a_failed_picture_waits_for_the_next_read() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = deck(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| deck.new_blank(cx));
settle(&cx, |app| counts(app, &deck) == (1, 1));
// The kernel loses the document under the deck.
cx.update(|app| {
let deck = deck.read(app);
let doc = deck.active_tab().expect("a tab").doc.clone();
deck.core
.worker
.lock()
.expect("the worker lock")
.close_doc(doc)
.expect("closes");
});
update(&mut cx, &deck, |deck, cx| {
let id = deck.core.active.expect("a tab");
deck.tab_mut(id).expect("the tab").extra.thumbs.clear();
deck.paint_next(id, cx);
});
cx.run_until_parked();
let failed = cx.update(|app| {
deck.read(app)
.active_tab()
.expect("a tab")
.extra
.failed
.len()
});
assert_eq!(failed, 1, "one failure, then no more requests");
}
/// Undo steps the kernel holds for the active deck.
fn undo_steps(cx: &mut TestAppContext, deck: &Entity<Deck>) -> usize {
quiet(cx, deck);
cx.update(|app| {
let deck = deck.read(app);
let tab = deck.active_tab().expect("a tab");
let mut worker = deck.core.worker.lock().expect("the worker lock");
let json: serde_json::Value =
serde_json::from_str(&worker.values(&tab.doc, ".uno:Undo").expect("reads"))
.expect("parses");
json["actions"].as_array().map_or(0, Vec::len)
})
}
#[test]
fn the_same_size_twice_is_one_step() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = deck(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| deck.new_blank(cx));
settle(&cx, |app| counts(app, &deck) == (1, 1));
let before = undo_steps(&mut cx, &deck);
// Enter, then the blur that follows it.
update(&mut cx, &deck, |deck, cx| deck.resize((30_000, 15_750), cx));
update(&mut cx, &deck, |deck, cx| deck.resize((30_000, 15_750), cx));
settle(&cx, |app| {
deck.read(app)
.active_tab()
.is_some_and(|tab| tab.extra.size_asked.is_none())
});
assert_eq!(undo_steps(&mut cx, &deck), before + 1);
}
/// Two slides whose master shows MasterBrand.
fn branded(cx: &mut TestAppContext, dir: &Path) -> Option<Entity<Deck>> {
let deck = deck(cx, dir)?;
update(cx, &deck, |deck, cx| deck.new_blank(cx));
settle(cx, |app| counts(app, &deck) == (1, 1));
update(cx, &deck, |deck, cx| {
deck.run_active("slide.duplicate", &[], cx)
});
settle(cx, |app| counts(app, &deck) == (2, 2));
let masters = |app: &gpui::App| {
deck.read(app)
.active_tab()
.is_some_and(|tab| tab.extra.masters)
};
update(cx, &deck, |deck, cx| deck.masters(true, cx));
settle(cx, |app| masters(app) && counts(app, &deck) == (1, 1));
cx.update(|app| {
let deck = deck.read(app);
let doc = deck.active_tab().expect("a tab").doc.clone();
let mut worker = deck.core.worker.lock().expect("the worker lock");
worker
.paste(&doc, "text/plain;charset=utf-8", b"MasterBrand")
.expect("pastes");
});
update(cx, &deck, |deck, cx| deck.masters(false, cx));
settle(cx, |app| !masters(app) && counts(app, &deck) == (2, 2));
pictures_match(cx, &deck);
Some(deck)
}
/// Runs both commands in one update.
fn both(cx: &mut TestAppContext, deck: &Entity<Deck>, first: &str, then: &str) {
update(cx, deck, |deck, cx| {
deck.run_active(first, &[], cx);
deck.run_active(then, &[], cx);
});
settle(cx, |app| counts(app, deck) == (2, 2));
pictures_match(cx, deck);
}
#[test]
fn undoing_a_master_edit_repaints_its_slides() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = branded(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| {
deck.run_active("edit.undo", &[], cx)
});
settle(&cx, |app| counts(app, &deck) == (2, 2));
pictures_match(&mut cx, &deck);
both(&mut cx, &deck, "edit.redo", "slide.hide");
}
#[test]
fn an_edit_right_after_undo_still_repaints() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = branded(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
both(&mut cx, &deck, "edit.undo", "slide.hide");
}
#[test]
fn leaving_text_edit_repaints_the_picture() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = deck(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| deck.new_blank(cx));
settle(&cx, |app| counts(app, &deck) == (1, 1));
let caret = |app: &gpui::App| {
deck.read(app)
.active_tab()
.is_some_and(|tab| tab.view.read(app).caret())
};
cx.update(|app| {
let deck = deck.read(app);
let doc = deck.active_tab().expect("a tab").doc.clone();
let mut worker = deck.core.worker.lock().expect("the worker lock");
for kind in [0, 1] {
let click = ely_lok::Mouse {
kind,
x: 7900,
y: 1100,
count: 1,
buttons: 1,
modifiers: 0,
};
worker.post_mouse(&doc, click).expect("clicks");
}
for c in "Plan".chars() {
worker.post_key(&doc, c as u32, 0).expect("types");
}
});
settle(&cx, caret);
quiet(&cx, &deck);
cx.update(|app| {
let deck = deck.read(app);
let doc = deck.active_tab().expect("a tab").doc.clone();
let mut worker = deck.core.worker.lock().expect("the worker lock");
worker.post_key(&doc, 0, 1281).expect("escapes");
});
settle(&cx, |app| !caret(app));
pictures_match(&mut cx, &deck);
}
+398
View File
@@ -0,0 +1,398 @@
//! Transitions, effects and their previews from the deck (C10.4).
use ely_lok::show::{Part, Transition};
use ely_office::prelude::*;
use gpui::{AppContext as _, TestAppContext};
use super::objects::{act, blank};
use super::{settle, update};
use crate::preview::Playing;
#[test]
fn a_transition_and_an_effect_land_and_preview() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let motion = |app: &gpui::App| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.motion.clone())
};
settle(&cx, |app| motion(app).is_some());
let fade = Transition {
preset: "fade-smoothly".into(),
duration: 0.5,
auto: false,
time: 0.0,
};
let json = ely_lok::show::transition_json(&fade, false);
update(&mut cx, &deck, |deck, cx| {
deck.design_command("slide.transition", &[&json], cx)
});
settle(&cx, |app| motion(app).is_some_and(|(t, _)| t == fade));
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
|o| o.selection.is_some(),
);
let add = serde_json::json!({ "preset": "ooo-entrance-fly-in" }).to_string();
update(&mut cx, &deck, |deck, cx| {
deck.design_command("animation.add", &[&add], cx)
});
settle(&cx, |app| {
motion(app).is_some_and(|(_, effects)| effects.len() == 1)
});
let hash = cx.update(|app| motion(app).expect("read").1[0].hash.clone());
update(&mut cx, &deck, |deck, cx| deck.preview_effects(cx));
settle(&cx, |app| deck.read(app).preview.is_some());
let moving =
cx.update(
|app| match &deck.read(app).preview.as_ref().expect("playing").playing {
Playing::Effects { layers, .. } => layers
.iter()
.filter(|(part, _)| matches!(part, Part::Moving { hash: h, .. } if *h == hash))
.count(),
Playing::Transition { .. } => 0,
},
);
assert_eq!(moving, 1, "the shape plays as its own layer");
update(&mut cx, &deck, |deck, cx| deck.stop_preview(cx));
update(&mut cx, &deck, |deck, cx| deck.preview_transition(cx));
settle(&cx, |app| deck.read(app).preview.is_some());
let first = cx.update(
|app| match &deck.read(app).preview.as_ref().expect("playing").playing {
Playing::Transition { from, preset, .. } => (from.is_none(), preset.clone()),
Playing::Effects { .. } => (false, String::new()),
},
);
assert_eq!(
first,
(true, "fade-smoothly".to_string()),
"the first slide comes from black"
);
}
#[test]
fn inserted_media_reads_back_and_is_picked() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures/media/clip.mp4");
let url = ely_lok::file_url(&clip);
update(&mut cx, &deck, |deck, cx| {
let tab = deck.core.active;
deck.run_in(tab, "media.insert", &[&url, "false", "8000", "4500"], cx);
deck.force_reread(tab.expect("a tab"), cx);
});
let media = |app: &gpui::App| {
deck.read(app)
.active_tab()
.map(|tab| tab.extra.media.clone())
.unwrap_or_default()
};
settle(&cx, |app| media(app).len() == 1);
let index = cx.update(|app| media(app)[0].index).to_string();
act(&mut cx, &deck, ("object.select", &[&index, "false"]), |o| {
o.selection.is_some()
});
let picked = cx.update(|app| deck.read(app).picked_media());
assert!(picked.is_some_and(|(_, media)| !media.missing && !media.link));
}
#[test]
fn a_stop_cancels_a_preview_on_its_way_and_a_slide_change_ends_one() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let ready = |app: &gpui::App| {
deck.read(app)
.active_tab()
.is_some_and(|tab| tab.extra.motion.is_some() && tab.extra.history.is_some())
};
settle(&cx, ready);
update(&mut cx, &deck, |deck, cx| {
deck.preview_transition(cx);
deck.stop_preview(cx);
});
for _ in 0..100 {
cx.executor()
.advance_clock(std::time::Duration::from_millis(20));
cx.run_until_parked();
}
assert!(
cx.update(|app| deck.read(app).preview.is_none()),
"the stopped read never plays"
);
update(&mut cx, &deck, |deck, cx| deck.preview_transition(cx));
settle(&cx, |app| deck.read(app).preview.is_some());
update(&mut cx, &deck, |deck, cx| {
let tab = deck.core.active;
deck.run_in(tab, "slide.insert", &[], cx);
deck.force_reread(tab.expect("a tab"), cx);
});
settle(&cx, |app| {
deck.read(app)
.active_tab()
.is_some_and(|tab| tab.extra.slides.current == 1)
});
update(&mut cx, &deck, |deck, cx| deck.drop_stale_preview(cx));
assert!(
cx.update(|app| deck.read(app).preview.is_none()),
"another slide ends it"
);
}
#[test]
fn typing_ends_a_preview_before_any_undo_step() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
act(&mut cx, &deck, ("object.select", &["0", "false"]), |o| {
o.selection.is_some()
});
let view = cx.update(|app| deck.read(app).active_tab().expect("a tab").view.clone());
let typed = |cx: &mut TestAppContext, text: &str| {
let before = cx.update(|app| view.read(app).edited());
cx.update(|app| view.update(app, |view, _| view.insert(text)));
settle(cx, |app| {
view.read(app).edited() > before && view.read(app).settled()
});
};
typed(&mut cx, "A");
let ready = |app: &gpui::App| {
let tab = deck.read(app).active_tab();
tab.is_some_and(|tab| tab.extra.motion.is_some() && tab.extra.history.is_some())
};
settle(&cx, ready);
update(&mut cx, &deck, |deck, cx| deck.preview_transition(cx));
settle(&cx, |app| deck.read(app).preview.is_some());
let undo = |app: &mut gpui::App| deck.read(app).active_tab()?.extra.history.clone();
let steps = cx.update(undo);
typed(&mut cx, "B");
assert_eq!(cx.update(undo), steps, "no undo step yet");
update(&mut cx, &deck, |deck, cx| deck.drop_stale_preview(cx));
assert!(
cx.update(|app| deck.read(app).preview.is_none()),
"the typed text ends it"
);
}
#[test]
fn enter_then_blur_sends_one_duration() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let motion = |app: &gpui::App| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.motion.clone())
};
settle(&cx, |app| motion(app).is_some());
let panel = cx.update(|app| {
let weak = deck.downgrade();
app.new(|cx| crate::design::Panel::new(weak, cx))
});
let now = cx.update(|app| motion(app).expect("read").0);
let send = crate::motion::dedupe(panel, "transition.duration", now.duration, |deck, n, cx| {
deck.send_transition(|t| t.duration = n, false, cx)
});
let steps = super::undo_steps(&mut cx, &deck);
let handle = cx
.update(|app| deck.read(app).core.window)
.expect("a window");
for _ in 0..2 {
cx.update_window(handle, |_, window, app| send(3.5, window, app))
.expect("the window lives");
}
settle(&cx, |app| {
motion(app).is_some_and(|(t, _)| t.duration == 3.5)
});
assert_eq!(
super::undo_steps(&mut cx, &deck),
steps + 1,
"one step for Enter and blur"
);
}
#[test]
fn two_fields_sent_before_a_read_both_land() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let motion = |app: &gpui::App| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.motion.clone())
};
settle(&cx, |app| motion(app).is_some());
update(&mut cx, &deck, |deck, cx| {
deck.send_transition(
|t| (t.auto, t.duration, t.time) = (true, 2.0, 4.0),
false,
cx,
)
});
settle(&cx, |app| {
motion(app).is_some_and(|(t, _)| t.auto && t.time == 4.0)
});
let panel = cx.update(|app| {
let weak = deck.downgrade();
app.new(|cx| crate::design::Panel::new(weak, cx))
});
let duration =
crate::motion::dedupe(panel.clone(), "transition.duration", 2.0, |deck, n, cx| {
deck.send_transition(|t| t.duration = n, false, cx)
});
let time = crate::motion::dedupe(panel, "transition.time", 4.0, |deck, n, cx| {
deck.send_transition(|t| t.time = n, false, cx)
});
let handle = cx
.update(|app| deck.read(app).core.window)
.expect("a window");
cx.update_window(handle, |_, window, app| {
duration(3.5, window, app);
time(7.0, window, app);
})
.expect("the window lives");
settle(&cx, |app| {
let asked = deck
.read(app)
.active_tab()
.map(|t| t.extra.transition_asked.is_none());
asked == Some(true) && motion(app).is_some_and(|(t, _)| t.time == 7.0)
});
let read = cx.update(|app| motion(app).expect("read").0);
assert_eq!((read.duration, read.time), (3.5, 7.0));
}
#[test]
fn an_undone_duration_is_not_sent_again() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let motion = |app: &gpui::App| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.motion.clone())
};
let asked = |app: &gpui::App| {
let tab = deck.read(app).active_tab();
tab.is_some_and(|tab| tab.extra.transition_asked.is_some())
};
settle(&cx, |app| motion(app).is_some());
update(&mut cx, &deck, |deck, cx| {
deck.send_transition(
|t| (t.auto, t.duration, t.time) = (true, 2.0, 4.0),
false,
cx,
)
});
settle(&cx, |app| {
!asked(app) && motion(app).is_some_and(|(t, _)| t.time == 4.0)
});
update(&mut cx, &deck, |deck, cx| {
deck.send_transition(|t| t.duration = 3.5, false, cx);
let tab = deck.core.active;
deck.run_in(tab, "edit.undo", &[], cx);
deck.force_reread(tab.expect("a tab"), cx);
});
settle(&cx, |app| !asked(app));
update(&mut cx, &deck, |deck, cx| {
deck.send_transition(|t| t.time = 7.0, false, cx)
});
settle(&cx, |app| {
!asked(app) && motion(app).is_some_and(|(t, _)| t.time == 7.0)
});
let read = cx.update(|app| motion(app).expect("read").0);
assert_eq!((read.duration, read.time), (2.0, 7.0));
}
#[test]
fn two_effects_take_the_same_value_before_a_read() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let effects = |app: &gpui::App| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.motion.clone())
.map(|(_, effects)| effects)
.unwrap_or_default()
};
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
|o| o.selection.is_some(),
);
let add = serde_json::json!({ "preset": "ooo-entrance-fade-in" }).to_string();
for count in 1..=2 {
update(&mut cx, &deck, |deck, cx| {
deck.design_command("animation.add", &[&add], cx)
});
settle(&cx, |app| effects(app).len() == count);
}
for at in 0..2 {
update(&mut cx, &deck, |deck, cx| {
deck.send_effect(at, serde_json::json!({ "duration": 2.0 }), cx)
});
}
settle(&cx, |app| effects(app).iter().all(|e| e.duration == 2.0));
let panel = cx.update(|app| {
let weak = deck.downgrade();
app.new(|cx| crate::design::Panel::new(weak, cx))
});
let (slide, listed) = cx.update(|app| {
let tab = deck.read(app).active_tab().expect("a tab");
let slide = tab.extra.slides.list[tab.extra.slides.current].name.clone();
(slide, effects(app))
});
let handle = cx
.update(|app| deck.read(app).core.window)
.expect("a window");
let steps = super::undo_steps(&mut cx, &deck);
cx.update_window(handle, |_, window, app| {
for (at, effect) in listed.iter().enumerate() {
let key = crate::motion::effect_key("duration", &slide, (at, effect));
let send = crate::motion::dedupe(panel.clone(), key, 2.0, move |deck, n, cx| {
deck.send_effect(at, serde_json::json!({ "duration": n }), cx)
});
// Enter, then blur.
send(3.5, window, app);
send(3.5, window, app);
}
})
.expect("the window lives");
settle(&cx, |app| effects(app).iter().all(|e| e.duration == 3.5));
assert_eq!(
super::undo_steps(&mut cx, &deck),
steps + 2,
"one step each"
);
}
+293
View File
@@ -0,0 +1,293 @@
use ely_lok::show::{GuideKind, Objects, Paint, Selection};
use ely_office::prelude::*;
use gpui::{Entity, TestAppContext};
use super::{counts, deck, pictures_match, settle, undo_steps, update};
use crate::deck::Deck;
pub(super) fn objects(app: &gpui::App, deck: &Entity<Deck>) -> Option<Objects> {
deck.read(app).active_tab()?.extra.objects.clone()
}
pub(super) fn selection(app: &gpui::App, deck: &Entity<Deck>) -> Option<Selection> {
objects(app, deck)?.selection
}
/// A blank deck, its first picture painted.
pub(super) fn blank(cx: &mut TestAppContext, dir: &std::path::Path) -> Option<Entity<Deck>> {
let deck = deck(cx, dir)?;
update(cx, &deck, |deck, cx| deck.new_blank(cx));
settle(cx, |app| counts(app, &deck) == (1, 1));
Some(deck)
}
/// Runs `id` and waits until `done` holds.
pub(super) fn act(
cx: &mut TestAppContext,
deck: &Entity<Deck>,
(id, values): (&str, &[&str]),
done: impl Fn(&Objects) -> bool,
) {
update(cx, deck, |deck, cx| deck.object_command(id, values, cx));
settle(cx, |app| {
objects(app, deck).is_some_and(|objects| done(&objects))
});
}
#[test]
fn shapes_take_place_look_and_lock_from_the_panel() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let rectangle = ("draw.shape", &["rectangle", "true"][..]);
act(&mut cx, &deck, rectangle, |o| {
o.selection.as_ref().is_some_and(|s| s.kind == "shape")
});
let x = crate::object::twips(3000).to_string();
act(&mut cx, &deck, ("object.x", &[&x]), |o| {
o.selection
.as_ref()
.is_some_and(|s| (s.rect.0 - 3000).abs() <= 1)
});
act(&mut cx, &deck, ("object.fill_none", &["0"]), |o| {
o.selection.as_ref().is_some_and(|s| s.fill == Paint::None)
});
act(&mut cx, &deck, ("object.fill", &["255"]), |o| {
o.selection
.as_ref()
.is_some_and(|s| s.fill == Paint::Color(255))
});
act(&mut cx, &deck, ("object.lock", &["true", "true"]), |o| {
o.selection
.as_ref()
.is_some_and(|s| s.lock_position == Some(true))
});
let center = cx
.update(|app| selection(app, &deck))
.expect("a selection")
.rotation_center;
let twips = crate::object::twips;
let (x, y) = (twips(center.0).to_string(), twips(center.1).to_string());
act(&mut cx, &deck, ("object.lock", &["false", "false"]), |o| {
o.selection
.as_ref()
.is_some_and(|s| s.lock_size == Some(false))
});
act(&mut cx, &deck, ("object.rotate", &["9000", &x, &y]), |o| {
o.selection.as_ref().is_some_and(|s| s.rotation == 9000)
});
// A panel edit repaints the slide's picture.
pictures_match(&mut cx, &deck);
}
#[test]
fn two_objects_group_align_and_ungroup() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let count = |n: usize| move |o: &Objects| o.objects.len() == n;
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
count(3),
);
let roles = cx.update(|app| {
let read = objects(app, &deck).expect("a read");
read.objects
.iter()
.map(|o| o.placeholder.clone())
.collect::<Vec<_>>()
});
assert_eq!(roles, ["title", "body", ""], "placeholders name their role");
act(
&mut cx,
&deck,
("draw.shape", &["ellipse", "true"]),
count(4),
);
update(&mut cx, &deck, |deck, cx| {
deck.select_objects(&["2".into(), "3".into()], cx)
});
settle(&cx, |app| {
selection(app, &deck).is_some_and(|s| s.count == 2 && s.can_group)
});
act(&mut cx, &deck, ("object.align_left", &[]), |o| {
o.objects[2].rect.0 == o.objects[3].rect.0
});
act(&mut cx, &deck, ("object.group", &[]), |o| {
o.selection
.as_ref()
.is_some_and(|s| s.kind == "group" && s.can_ungroup)
});
act(&mut cx, &deck, ("object.ungroup", &[]), count(4));
}
#[test]
fn guides_add_move_and_go() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
act(&mut cx, &deck, ("guide.add", &["1", "5000", "0"]), |o| {
o.guides.len() == 1 && o.guides[0].kind == GuideKind::Vertical
});
act(&mut cx, &deck, ("guide.move", &["0", "7000", "0"]), |o| {
o.guides.first().is_some_and(|guide| guide.at.0 == 7000)
});
// Snapping starts on; the command flips it.
let snapped = cx.update(|app| objects(app, &deck).expect("a read").snap_guides);
act(&mut cx, &deck, ("view.snap_guides", &[]), move |o| o.snap_guides != snapped);
act(&mut cx, &deck, ("guide.delete", &["0", "true"]), |o| {
o.guides.is_empty()
});
}
#[test]
fn autofit_moves_between_its_three_rules() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let fit = |rule: &'static str| {
move |o: &Objects| o.selection.as_ref().is_some_and(|s| s.autofit == rule)
};
act(&mut cx, &deck, ("draw.text", &["true"]), |o| {
o.selection.as_ref().is_some_and(|s| s.kind == "text")
});
let now = cx
.update(|app| selection(app, &deck))
.expect("a box")
.autofit;
assert_eq!(now, "grow", "a new text box grows with its text");
act(&mut cx, &deck, ("text.fit", &["off"]), fit("off"));
act(&mut cx, &deck, ("text.fit", &["shrink"]), fit("shrink"));
act(&mut cx, &deck, ("text.fit", &["grow"]), fit("grow"));
}
#[test]
fn a_repeated_commit_sends_once() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
|o| o.selection.is_some(),
);
let before = undo_steps(&mut cx, &deck);
let (panel, now) = cx.update(|app| {
let deck = deck.read(app);
let rect = deck.active_tab().and_then(|tab| tab.extra.objects.clone());
(
deck.panel.clone(),
rect.and_then(|o| o.selection).expect("a selection").rect.0,
)
});
// Enter, then the blur that follows it.
for _ in 0..2 {
cx.update(|app| {
crate::object::commit(
&panel,
"x",
(now + 1000, Some(now)),
move |deck, cx| {
let x = crate::object::twips(now + 1000).to_string();
deck.object_command("object.x", &[&x], cx)
},
app,
)
});
}
settle(&cx, |app| {
selection(app, &deck).is_some_and(|s| (s.rect.0 - now - 1000).abs() <= 1)
});
assert_eq!(undo_steps(&mut cx, &deck), before + 1);
}
#[test]
fn pictures_insert_replace_and_crop() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let png = |name: &str, rgba| {
let path = dir.path().join(name);
image::RgbaImage::from_pixel(300, 200, image::Rgba(rgba))
.save(&path)
.expect("the png writes");
ely_lok::file_url(&path)
};
let (red, blue) = (
png("red.png", [255, 0, 0, 255]),
png("blue.png", [0, 0, 255, 255]),
);
let picture = |o: &Objects| o.selection.as_ref().is_some_and(|s| s.kind == "image");
act(&mut cx, &deck, ("image.insert", &[&red]), picture);
let steps = undo_steps(&mut cx, &deck);
act(&mut cx, &deck, ("image.replace", &[&blue]), |o| {
o.objects.len() == 3
});
settle(&cx, |app| counts(app, &deck) == (1, 1));
assert_eq!(
undo_steps(&mut cx, &deck),
steps + 1,
"a replace is one step"
);
// The worker confirms crop mode from the kernel's frame.
cx.update(|app| {
let deck = deck.read(app);
let doc = deck.active_tab().expect("a tab").doc.clone();
let mut worker = deck.core.worker.lock().expect("the worker lock");
worker.run(&doc, "image.crop", &[]).expect("crop mode");
});
}
#[test]
fn a_text_box_takes_character_formats() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
act(&mut cx, &deck, ("draw.text", &["true"]), |o| {
o.selection.as_ref().is_some_and(|s| s.kind == "text")
});
let state = |uno: &'static str, want: &'static str| {
let deck = deck.clone();
move |app: &gpui::App| {
deck.read(app)
.active_tab()
.is_some_and(|tab| tab.view.read(app).state(uno) == Some(want))
}
};
update(&mut cx, &deck, |deck, cx| {
deck.object_command("format.bold", &[], cx)
});
settle(&cx, state(".uno:Bold", "true"));
update(&mut cx, &deck, |deck, cx| {
deck.object_command("format.size", &["28"], cx)
});
settle(&cx, state(".uno:FontHeight", "28"));
update(&mut cx, &deck, |deck, cx| {
deck.object_command("list.bullets", &[], cx)
});
settle(&cx, state(".uno:DefaultBullet", "true"));
}
+251
View File
@@ -0,0 +1,251 @@
//! PDF, pictures and handouts from the deck (C10.6).
use std::path::Path;
use ely_office::prelude::*;
use gpui::{AppContext as _, Entity, TestAppContext};
use super::objects::blank;
use super::play::three_slides;
use super::{settle, undo_steps, update};
use crate::deck::Deck;
use crate::output::{Kind, Output, Range};
fn output(cx: &mut TestAppContext, deck: &Entity<Deck>, kind: Kind, range: Range) -> Output {
cx.update(|app| {
let tab = deck.read(app).active_tab().expect("a tab");
Output {
tab: tab.id,
print: false,
kind,
range,
per: 2,
current: Some(1),
hidden: tab.extra.slides.list.iter().map(|slide| !slide.visible).collect(),
}
})
}
/// Pages of a PDF, by pdftotext's page breaks.
fn pages(pdf: &Path) -> Option<usize> {
let out = std::process::Command::new("pdftotext").arg(pdf).arg("-").output().ok()?;
let text = String::from_utf8_lossy(&out.stdout).into_owned();
Some(text.split('\x0c').count() - 1)
}
#[test]
fn slides_and_handouts_go_out_and_the_deck_stays_put() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
let steps = undo_steps(&mut cx, &deck);
let (slides, handouts) = (dir.path().join("slides.pdf"), dir.path().join("handouts.pdf"));
let asked = output(&mut cx, &deck, Kind::Slides, Range::All);
update(&mut cx, &deck, |deck, cx| deck.export_pdf(&asked, slides.clone(), cx));
let asked = output(&mut cx, &deck, Kind::Handouts, Range::Slides(1, 2));
update(&mut cx, &deck, |deck, cx| deck.export_pdf(&asked, handouts.clone(), cx));
settle(&cx, |_| slides.exists() && handouts.exists());
assert_eq!(undo_steps(&mut cx, &deck), steps, "the deck is not touched");
let (Some(slides), Some(handouts)) = (pages(&slides), pages(&handouts)) else {
eprintln!("SKIP: no pdftotext to count pages");
return;
};
assert_eq!(slides, 2, "the hidden slide stays out");
assert_eq!(handouts, 1, "two slides on one handout page");
}
#[test]
fn pictures_are_one_png_a_shown_slide_and_never_overwrite() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
let folder = dir.path().join("out");
std::fs::create_dir_all(&folder).expect("a folder");
let asked = output(&mut cx, &deck, Kind::Pictures, Range::All);
let into = (folder.clone(), "deck".to_string());
update(&mut cx, &deck, |deck, cx| deck.write_pictures(&asked, into, cx));
let written = |folder: &Path| {
let mut names: Vec<String> = std::fs::read_dir(folder)
.expect("reads")
.map(|entry| entry.expect("an entry").file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
};
settle(&cx, |_| written(&folder).len() == 2);
assert_eq!(written(&folder), ["deck-1.png", "deck-2.png"], "slide three is hidden");
let picture = image::open(folder.join("deck-1.png")).expect("a PNG");
assert_eq!(picture.width(), 1920);
let before = std::fs::read(folder.join("deck-1.png")).expect("reads");
let asked = output(&mut cx, &deck, Kind::Pictures, Range::Current);
let into = (folder.clone(), "deck".to_string());
update(&mut cx, &deck, |deck, cx| deck.write_pictures(&asked, into, cx));
for _ in 0..50 {
cx.run_until_parked();
}
assert_eq!(std::fs::read(folder.join("deck-1.png")).expect("reads"), before, "never overwritten");
}
#[test]
fn the_check_lists_font_link_and_object_with_their_slides() {
use crate::check::Problem;
use crate::dialog::Dialog;
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = super::deck(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/show/trouble.fodp");
let path = dir.path().join("trouble.fodp");
std::fs::copy(&fixture, &path).expect("copies");
update(&mut cx, &deck, |deck, cx| deck.open_file(path.clone(), cx));
settle(&cx, |app| {
let tab = deck.read(app).active_tab();
tab.is_some_and(|tab| !tab.view.read(app).missing_fonts().is_empty())
});
update(&mut cx, &deck, |deck, cx| deck.check_show(cx));
settle(&cx, |app| matches!(deck.read(app).dialog, Some(Dialog::Check(_))));
let found = cx.update(|app| match &deck.read(app).dialog {
Some(Dialog::Check(found)) => found.clone(),
_ => unreachable!("settled above"),
});
let short: Vec<(Problem, &str, Vec<usize>)> =
found.iter().map(|f| (f.problem, f.name.as_str(), f.slides.clone())).collect();
assert!(short.contains(&(Problem::Font, "Nowhere Sans", vec![0])), "{short:?}");
assert!(short.contains(&(Problem::Link, "file:///nowhere/gone.png", vec![1])), "{short:?}");
assert!(short.contains(&(Problem::Object, "Empty", vec![2])), "{short:?}");
}
#[cfg(unix)]
#[test]
fn pictures_claim_every_name_first_and_follow_no_link() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
let folder = dir.path().join("out");
std::fs::create_dir_all(&folder).expect("a folder");
// The second name links to a file not there.
std::os::unix::fs::symlink("../unrelated.png", folder.join("deck-2.png")).expect("links");
let asked = output(&mut cx, &deck, Kind::Pictures, Range::All);
let into = (folder.clone(), "deck".to_string());
update(&mut cx, &deck, |deck, cx| deck.write_pictures(&asked, into, cx));
for _ in 0..200 {
cx.run_until_parked();
}
assert!(!dir.path().join("unrelated.png").exists(), "the link is not followed");
assert!(!folder.join("deck-1.png").exists(), "nothing of this run stays");
}
#[test]
fn master_view_exports_slides_and_has_no_current() {
use crate::dialog::Dialog;
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
update(&mut cx, &deck, |deck, cx| deck.masters(true, cx));
settle(&cx, |app| deck.read(app).active_tab().is_some_and(|tab| tab.extra.masters));
let window = cx.update(|app| deck.read(app).core.window.expect("a window"));
cx.update_window(window, |_, window, app| {
deck.update(app, |deck, cx| deck.on_export(&ely_ui::commands::Export, window, cx))
})
.expect("the window lives");
settle(&cx, |app| matches!(deck.read(app).dialog, Some(Dialog::Output(_))));
let (current, hidden) = cx.update(|app| match &deck.read(app).dialog {
Some(Dialog::Output(output)) => (output.current, output.hidden.clone()),
_ => unreachable!("settled above"),
});
assert_eq!((current, hidden), (None, vec![false, false, true]));
}
#[test]
fn a_late_check_never_covers_a_newer_dialog() {
use crate::dialog::Dialog;
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
// The export dialog is open before the check comes back.
let open = output(&mut cx, &deck, Kind::Slides, Range::All);
update(&mut cx, &deck, |deck, cx| {
deck.check_show(cx);
deck.dialog = Some(Dialog::Output(open));
});
for _ in 0..200 {
cx.run_until_parked();
}
assert!(cx.update(|app| matches!(deck.read(app).dialog, Some(Dialog::Output(_)))));
}
#[test]
fn a_late_check_stays_away_after_another_dialog_closes() {
use crate::dialog::Dialog;
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let open = output(&mut cx, &deck, Kind::Slides, Range::All);
update(&mut cx, &deck, |deck, cx| {
deck.check_show(cx);
deck.dialog = Some(Dialog::Output(open));
cx.notify();
});
// The other dialog is cancelled before the check returns.
update(&mut cx, &deck, |deck, cx| {
deck.dialog = None;
cx.notify();
});
for _ in 0..200 {
cx.run_until_parked();
}
assert!(cx.update(|app| deck.read(app).dialog.is_none()), "the old check stays away");
}
#[test]
fn pictures_follow_the_slides_as_written_not_as_asked() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
// Asked while slide three hid; shown again before writing.
let asked = output(&mut cx, &deck, Kind::Pictures, Range::All);
assert_eq!(asked.hidden, [false, false, true]);
update(&mut cx, &deck, |deck, cx| deck.design_command("slide.show", &[], cx));
settle(&cx, |app| {
let tab = deck.read(app).active_tab();
tab.is_some_and(|tab| tab.extra.slides.list.iter().all(|slide| slide.visible))
});
let folder = dir.path().join("out");
std::fs::create_dir_all(&folder).expect("a folder");
let into = (folder.clone(), "deck".to_string());
update(&mut cx, &deck, |deck, cx| deck.write_pictures(&asked, into, cx));
let count = |folder: &Path| std::fs::read_dir(folder).expect("reads").count();
settle(&cx, |_| count(&folder) == 3);
for n in 1..=3 {
let picture = image::open(folder.join(format!("deck-{n}.png"))).expect("a readable PNG");
assert_eq!(picture.width(), 1920);
}
}
+147
View File
@@ -0,0 +1,147 @@
//! The show: keys, hidden slides, black, laser (C10.5).
use ely_office::prelude::*;
use gpui::{Entity, Keystroke, TestAppContext};
use super::objects::{act, blank};
use super::{settle, update};
use crate::deck::Deck;
use crate::slideshow::{Show, steps::Place};
fn press(cx: &mut TestAppContext, show: &Entity<Show>, key: &str) -> bool {
let key = Keystroke::parse(key).expect("a key");
cx.update(|app| show.update(app, |show, cx| show.key(&key, cx)))
}
/// Keys typed into the show's own window.
pub(super) fn typed(cx: &mut TestAppContext, show: &Entity<Show>, keys: &str) {
let window = cx.update(|app| show.read(app).windows[0]);
cx.simulate_keystrokes(window, keys);
}
pub(super) fn place(cx: &mut TestAppContext, show: &Entity<Show>) -> Place {
cx.update(|app| show.read(app).place)
}
/// Slide two: click effect, notes. Three: hidden.
pub(super) fn three_slides(cx: &mut TestAppContext, deck: &Entity<Deck>) {
let slides = |app: &gpui::App| {
let tab = deck.read(app).active_tab();
tab.map(|tab| tab.extra.slides.list.iter().map(|s| s.visible).collect::<Vec<_>>())
};
update(cx, deck, |deck, cx| deck.design_command("slide.insert", &[], cx));
settle(cx, |app| slides(app).is_some_and(|list| list.len() == 2));
act(cx, deck, ("draw.shape", &["rectangle", "true"]), |o| {
o.objects.len() == 3
});
act(cx, deck, ("object.select", &["2", "false"]), |o| {
o.selection.is_some()
});
let add = serde_json::json!({ "preset": "ooo-entrance-fade-in" }).to_string();
let effects = |app: &gpui::App| {
let tab = deck.read(app).active_tab();
tab.and_then(|tab| tab.extra.motion.as_ref().map(|(_, e)| e.len()))
};
update(cx, deck, |deck, cx| deck.design_command("animation.add", &[&add], cx));
settle(cx, |app| effects(app) == Some(1));
update(cx, deck, |deck, cx| {
deck.design_command("notes.set", &["Say hello", "1"], cx);
deck.design_command("slide.insert", &[], cx);
});
settle(cx, |app| slides(app).is_some_and(|list| list.len() == 3));
update(cx, deck, |deck, cx| deck.design_command("slide.hide", &[], cx));
settle(cx, |app| slides(app) == Some(vec![true, true, false]));
}
#[test]
fn a_show_plays_clicks_skips_hidden_and_ends() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
update(&mut cx, &deck, |deck, cx| deck.start_show(false, cx));
settle(&cx, |app| deck.read(app).show.is_some());
let show = cx.update(|app| deck.read(app).show.clone().expect("playing"));
settle(&cx, |app| {
let show = show.read(app);
show.stack(0).is_some() && show.stack(1).is_some()
});
let windows = cx.update(|app| show.read(app).windows.len());
assert_eq!(windows, 1, "one screen: no presenter view");
assert_eq!(place(&mut cx, &show), Place::Slide { part: 0, clicks: 0 });
typed(&mut cx, &show, "right");
assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 0 });
let notes = cx.update(|app| show.read(app).slides[1].notes.clone());
assert_eq!(notes, "Say hello");
press(&mut cx, &show, "space");
assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 1 });
press(&mut cx, &show, "right");
assert_eq!(place(&mut cx, &show), Place::End, "the hidden slide is passed");
press(&mut cx, &show, "left");
assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 1 });
press(&mut cx, &show, "1");
press(&mut cx, &show, "enter");
assert_eq!(place(&mut cx, &show), Place::Slide { part: 0, clicks: 0 });
press(&mut cx, &show, "b");
press(&mut cx, &show, "l");
assert!(cx.update(|app| show.read(app).black && show.read(app).laser));
assert!(!press(&mut cx, &show, "cmd-q"), "app keys pass through");
let before = cx.update(|app| app.windows().len());
typed(&mut cx, &show, "escape");
settle(&cx, |app| deck.read(app).show.is_none());
assert_eq!(cx.update(|app| app.windows().len()), before - 1, "the show window closes");
}
#[test]
fn from_here_starts_on_the_shown_slide_even_hidden() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
update(&mut cx, &deck, |deck, cx| deck.start_show(true, cx));
settle(&cx, |app| deck.read(app).show.is_some());
let show = cx.update(|app| deck.read(app).show.clone().expect("playing"));
assert_eq!(place(&mut cx, &show), Place::Slide { part: 2, clicks: 0 });
press(&mut cx, &show, "right");
assert_eq!(place(&mut cx, &show), Place::End);
press(&mut cx, &show, "right");
settle(&cx, |app| deck.read(app).show.is_none());
}
#[test]
fn a_click_on_media_finds_it_and_elsewhere_does_not() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures/media/clip.mp4");
let url = ely_lok::file_url(&clip);
update(&mut cx, &deck, |deck, cx| {
deck.design_command("media.insert", &[&url, "false", "8000", "4500"], cx)
});
settle(&cx, |app| {
deck.read(app).active_tab().is_some_and(|tab| tab.extra.media.len() == 1)
});
update(&mut cx, &deck, |deck, cx| deck.start_show(true, cx));
settle(&cx, |app| deck.read(app).show.is_some());
let show = cx.update(|app| deck.read(app).show.clone().expect("playing"));
let (bounds, size) = cx.update(|app| {
let show = show.read(app);
(show.slides[0].media[0].bounds, show.slide)
});
let centre = (
(bounds.0 + bounds.2 / 2) as f32 / size.0,
(bounds.1 + bounds.3 / 2) as f32 / size.1,
);
assert_eq!(cx.update(|app| show.read(app).media_at(centre)), Some(0));
assert_eq!(cx.update(|app| show.read(app).media_at((0.01, 0.01))), None);
}
+211
View File
@@ -0,0 +1,211 @@
//! Show starts, copies, windows and timing (C10.5 R1).
use ely_office::prelude::*;
use gpui::{Entity, TestAppContext};
use super::objects::{act, blank};
use super::play::{place, three_slides, typed};
use super::{settle, update};
use crate::deck::Deck;
use crate::preview::timeline::{Look, object_look};
use crate::slideshow::{Show, start::show_window, steps::Place};
fn started(cx: &mut TestAppContext, deck: &Entity<Deck>, here: bool) -> Entity<Show> {
update(cx, deck, |deck, cx| deck.start_show(here, cx));
settle(cx, |app| deck.read(app).show.is_some());
cx.update(|app| deck.read(app).show.clone().expect("playing"))
}
fn bytes(cx: &mut TestAppContext, show: &Entity<Show>, part: usize) -> Vec<u8> {
cx.update(|app| {
let stack = show.read(app).stack(part).expect("read").clone();
stack.iter().flat_map(|(_, image)| image.as_bytes(0).expect("a frame").to_vec()).collect()
})
}
#[test]
fn both_windows_take_keys_and_a_second_start_waits() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
update(&mut cx, &deck, |deck, cx| {
deck.start_show(false, cx);
deck.start_show(false, cx);
});
settle(&cx, |app| deck.read(app).show.is_some());
let show = cx.update(|app| deck.read(app).show.clone().expect("playing"));
let before = cx.update(|app| app.windows().len());
for _ in 0..50 {
cx.run_until_parked();
}
assert_eq!(cx.update(|app| app.windows().len()), before, "one start, one window");
// A presenter window beside the audience, as on two screens.
let presenter = cx.update(|app| {
let display = app.primary_display().expect("a screen");
show_window(&show, &display, true, app).expect("opens")
});
cx.update(|app| show.update(app, |show, _| show.windows.push(presenter)));
cx.simulate_keystrokes(presenter, "right");
assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 0 });
typed(&mut cx, &show, "right");
assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 1 });
}
#[test]
fn from_here_follows_a_slide_picked_just_now() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
update(&mut cx, &deck, |deck, cx| {
let view = deck.active_tab().expect("a tab").view.clone();
view.update(cx, |view, _| view.show_part(1));
deck.start_show(true, cx);
});
settle(&cx, |app| deck.read(app).show.is_some());
let show = cx.update(|app| deck.read(app).show.clone().expect("playing"));
assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 0 });
}
#[test]
fn edits_during_a_show_stay_out_of_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
let show = started(&mut cx, &deck, true);
settle(&cx, |app| show.read(app).stack(2).is_some());
let first = bytes(&mut cx, &show, 2);
act(&mut cx, &deck, ("draw.shape", &["ellipse", "true"]), |o| !o.objects.is_empty());
typed(&mut cx, &show, "1 enter");
settle(&cx, |app| show.read(app).stack(2).is_none());
typed(&mut cx, &show, "3 enter");
settle(&cx, |app| show.read(app).stack(2).is_some());
assert_eq!(bytes(&mut cx, &show, 2), first, "the show plays its copy");
}
#[test]
fn late_layers_and_jumps_leave_no_trace() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
let fade = r#"{"preset":"fade-smoothly","duration":10,"advance":"click","time":0,"all":false}"#;
update(&mut cx, &deck, |deck, cx| {
let view = deck.active_tab().expect("a tab").view.clone();
view.update(cx, |view, _| view.show_part(1));
deck.design_command("slide.transition", &[fade], cx);
});
settle(&cx, |app| {
let tab = deck.read(app).active_tab();
tab.and_then(|t| t.extra.motion.as_ref()).is_some_and(|(t, _)| t.duration == 10.0)
});
let show = started(&mut cx, &deck, false);
settle(&cx, |app| {
let show = show.read(app);
show.stack(0).is_some() && show.stack(1).is_some()
});
typed(&mut cx, &show, "right");
assert!(cx.update(|app| show.read(app).leaving.is_some()), "it fades in");
typed(&mut cx, &show, "2 enter");
assert!(cx.update(|app| show.read(app).leaving.is_none()), "a jump has no transition");
typed(&mut cx, &show, "3 enter 1 enter");
settle(&cx, |app| {
let show = show.read(app);
show.stack(0).is_some() && show.stack(1).is_some()
});
for _ in 0..100 {
cx.run_until_parked();
}
assert!(cx.update(|app| show.read(app).stack(2).is_none()), "slide three came late");
}
#[test]
fn a_finished_exit_stays_gone_as_the_slide_leaves() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
let cover = r#"{"preset":"cover-left","duration":5,"advance":"click","time":0,"all":true}"#;
update(&mut cx, &deck, |deck, cx| deck.design_command("slide.transition", &[cover], cx));
update(&mut cx, &deck, |deck, cx| {
let view = deck.active_tab().expect("a tab").view.clone();
view.update(cx, |view, _| view.show_part(0));
});
settle(&cx, |app| {
let tab = deck.read(app).active_tab();
tab.is_some_and(|t| t.extra.slides.current == 0 && t.extra.objects.is_some())
});
act(&mut cx, &deck, ("draw.shape", &["rectangle", "true"]), |o| o.objects.len() == 3);
act(&mut cx, &deck, ("object.select", &["2", "false"]), |o| {
o.selection.is_some()
});
let exit = serde_json::json!({ "preset": "ooo-exit-disappear" }).to_string();
update(&mut cx, &deck, |deck, cx| deck.design_command("animation.add", &[&exit], cx));
settle(&cx, |app| {
let tab = deck.read(app).active_tab();
tab.and_then(|t| t.extra.motion.as_ref()).is_some_and(|(_, e)| e.len() == 1)
});
let show = started(&mut cx, &deck, false);
settle(&cx, |app| show.read(app).stack(0).is_some());
typed(&mut cx, &show, "right");
std::thread::sleep(std::time::Duration::from_millis(300));
typed(&mut cx, &show, "right");
let (look, hash) = cx.update(|app| {
let show = show.read(app);
let hash = show.slides[0].effects[0].hash.clone();
let leaving = show.leaving.as_ref().expect("covered");
(object_look(&leaving.runs, &hash, true, leaving.at, 0.5), hash)
});
assert!(!hash.is_empty());
assert_eq!(look, Look::HIDDEN, "the exit holds through the transition");
}
#[test]
fn media_that_cannot_play_says_why_and_the_show_goes_on() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures/media/clip.mp4");
let url = ely_lok::file_url(&clip);
update(&mut cx, &deck, |deck, cx| {
deck.design_command("media.insert", &[&url, "false", "8000", "4500"], cx)
});
settle(&cx, |app| {
deck.read(app).active_tab().is_some_and(|tab| tab.extra.media.len() == 1)
});
let show = started(&mut cx, &deck, true);
// Off the main thread the system player refuses.
cx.update(|app| show.update(app, |show, cx| show.toggle_media(0, cx)));
assert!(cx.update(|app| show.read(app).notice.is_some()));
assert!(cx.update(|app| deck.read(app).show.is_some()), "the show goes on");
// A later failure says why the same way.
cx.update(|app| show.update(app, |show, _| show.notice = None));
let seen = cx.update(|app| show.update(app, |show, cx| show.media_seen(Ok(true), cx)));
assert!(seen && cx.update(|app| show.read(app).notice.is_none()));
let failed = Err("Cannot Open".to_string());
let seen = cx.update(|app| show.update(app, |show, cx| show.media_seen(failed, cx)));
let notice = cx.update(|app| show.read(app).notice.clone());
assert!(!seen && notice.is_some_and(|n| n.contains("Cannot Open")));
assert!(cx.update(|app| deck.read(app).show.is_some()), "the show goes on");
}
+129
View File
@@ -0,0 +1,129 @@
//! The show's clock: back, late layers, released slides (C10.5 R2).
use std::time::Duration;
use ely_office::prelude::*;
use gpui::{Entity, TestAppContext};
use super::objects::{act, blank};
use super::play::{place, three_slides, typed};
use super::{settle, update};
use crate::deck::Deck;
use crate::preview::timeline::{Look, object_look};
use crate::slideshow::{Show, steps};
fn started(cx: &mut TestAppContext, deck: &Entity<Deck>) -> Entity<Show> {
update(cx, deck, |deck, cx| deck.start_show(false, cx));
settle(cx, |app| deck.read(app).show.is_some());
let show = cx.update(|app| deck.read(app).show.clone().expect("playing"));
settle(cx, |app| {
let show = show.read(app);
show.stack(0).is_some() && show.stack(1).is_some()
});
show
}
/// Slide one: a rectangle fading in for ten seconds.
fn fading_first(cx: &mut TestAppContext, deck: &Entity<Deck>) {
update(cx, deck, |deck, cx| {
let view = deck.active_tab().expect("a tab").view.clone();
view.update(cx, |view, _| view.show_part(0));
});
settle(cx, |app| {
let tab = deck.read(app).active_tab();
tab.is_some_and(|t| t.extra.slides.current == 0 && t.extra.objects.is_some())
});
act(cx, deck, ("draw.shape", &["rectangle", "true"]), |o| o.objects.len() == 3);
act(cx, deck, ("object.select", &["2", "false"]), |o| o.selection.is_some());
let add = serde_json::json!({ "preset": "ooo-entrance-fade-in" }).to_string();
update(cx, deck, |deck, cx| deck.design_command("animation.add", &[&add], cx));
let effects = |app: &gpui::App| {
let tab = deck.read(app).active_tab();
tab.and_then(|t| t.extra.motion.clone()).map(|(_, e)| e).unwrap_or_default()
};
settle(cx, |app| effects(app).len() == 1);
update(cx, deck, |deck, cx| {
deck.send_effect(0, serde_json::json!({ "start": "with", "duration": 10.0 }), cx)
});
settle(cx, |app| effects(app).first().is_some_and(|e| e.start == "with" && e.duration == 10.0));
}
#[test]
fn back_shows_a_slide_fully_built_even_without_clicks() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
fading_first(&mut cx, &deck);
let show = started(&mut cx, &deck);
typed(&mut cx, &show, "right left");
assert_eq!(place(&mut cx, &show), steps::Place::Slide { part: 0, clicks: 0 });
let (look, moving) = cx.update(|app| {
let show = show.read(app);
let effect = &show.slides[0].effects[0];
let runs = steps::runs(&show.slides[0].effects, &show.clicks);
let t = show.entered.elapsed().as_secs_f64();
(object_look(&runs, &effect.hash, false, t, 0.5), show.moving())
});
assert_eq!((look, moving), (Look::SHOWN, false), "nothing replays");
}
#[test]
fn a_slide_starts_its_clock_when_its_layers_come() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
let show = started(&mut cx, &deck);
// Another tab's read holds the worker a while.
let worker = cx.update(|app| deck.read(app).core.worker.clone());
let (held, hold) = std::sync::mpsc::channel();
let busy = std::thread::spawn(move || {
let _lock = worker.lock().expect("the worker lock");
held.send(()).expect("told");
std::thread::sleep(Duration::from_millis(400));
});
hold.recv().expect("held");
// The read for slide three waits on the lock.
typed(&mut cx, &show, "3 enter");
settle(&cx, |app| show.read(app).stack(2).is_some());
busy.join().expect("let go");
let (waiting, since) = cx.update(|app| {
let show = show.read(app);
(show.waiting, show.entered.elapsed())
});
assert!(!waiting);
assert!(since < Duration::from_millis(300), "the clock began late: {since:?}");
}
#[test]
fn a_finished_transition_lets_the_left_slide_go() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
let fade = r#"{"preset":"fade-smoothly","duration":0.1,"advance":"click","time":0,"all":true}"#;
update(&mut cx, &deck, |deck, cx| deck.design_command("slide.transition", &[fade], cx));
settle(&cx, |app| {
let tab = deck.read(app).active_tab();
tab.and_then(|t| t.extra.motion.as_ref()).is_some_and(|(t, _)| t.duration == 0.1)
});
let show = started(&mut cx, &deck);
typed(&mut cx, &show, "right");
assert!(cx.update(|app| show.read(app).leaving.is_some()));
settle(&cx, |app| show.read(app).leaving.is_none());
let held = cx.update(|app| {
let show = show.read(app);
(0..3).filter(|at| show.stack(*at).is_some()).count()
});
assert!(held <= 2, "only the shown and next slides stay");
}
+273
View File
@@ -0,0 +1,273 @@
//! Regressions for the C10.2 review.
use ely_lok::show::{Objects, Paint};
use gpui::{Entity, TestAppContext};
use super::objects::{act, blank, objects, selection};
use super::undo_steps;
use crate::deck::Deck;
use crate::object::{NO_FILL, NO_LINE, commit, twips};
/// Commits `value` for `field` the way a panel field does.
fn send(
cx: &mut TestAppContext,
deck: &Entity<Deck>,
field: &'static str,
command: &'static str,
value: i64,
now: Option<i64>,
) {
let panel = cx.update(|app| deck.read(app).panel.clone());
cx.update(|app| {
commit(
&panel,
field,
(value, now),
move |deck, cx| deck.object_command(command, &[&value.to_string()], cx),
app,
)
});
}
#[test]
fn clear_buttons_clear_fill_and_outline() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
|o| o.selection.is_some(),
);
for (_, command, value) in [NO_FILL, NO_LINE] {
update(&mut cx, &deck, command, value);
}
super::settle(&cx, |app| {
selection(app, &deck).is_some_and(|s| s.fill == Paint::None && s.line == Paint::None)
});
}
fn update(cx: &mut TestAppContext, deck: &Entity<Deck>, command: &str, value: &str) {
super::update(cx, deck, |deck, cx| {
deck.object_command(command, &[value], cx)
});
}
#[test]
fn a_value_sent_again_after_undo_goes_out() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
|o| o.selection.is_some(),
);
let see = |n: i64| {
move |o: &Objects| {
o.selection
.as_ref()
.is_some_and(|s| s.transparency == Some(n))
}
};
send(
&mut cx,
&deck,
"transparency",
"object.transparency",
50,
Some(0),
);
super::settle(&cx, |app| objects(app, &deck).as_ref().is_some_and(see(50)));
// Undo may drop the selection; pick the shape again.
super::update(&mut cx, &deck, |deck, cx| {
deck.object_command("edit.undo", &[], cx)
});
super::update(&mut cx, &deck, |deck, cx| {
deck.select_objects(&["2".into()], cx)
});
super::settle(&cx, |app| objects(app, &deck).as_ref().is_some_and(see(0)));
send(
&mut cx,
&deck,
"transparency",
"object.transparency",
50,
Some(0),
);
super::settle(&cx, |app| objects(app, &deck).as_ref().is_some_and(see(50)));
}
#[test]
fn twin_objects_each_take_the_same_value() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let count = |n: usize| move |o: &Objects| o.objects.len() == n;
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
count(3),
);
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
count(4),
);
let x = |at: usize, n: i64| move |o: &Objects| (o.objects[at].rect.0 - n).abs() <= 1;
for at in [2, 3] {
super::update(&mut cx, &deck, move |deck, cx| {
deck.select_objects(&[at.to_string()], cx)
});
super::settle(&cx, |app| {
objects(app, &deck).is_some_and(|o| o.objects[at].selected)
});
let now = cx.update(|app| selection(app, &deck)).expect("one").rect.0;
let panel = cx.update(|app| deck.read(app).panel.clone());
cx.update(|app| {
commit(
&panel,
"x",
(3000, Some(now)),
|deck, cx| deck.object_command("object.x", &[&twips(3000).to_string()], cx),
app,
)
});
super::settle(&cx, |app| {
objects(app, &deck).as_ref().is_some_and(x(at, 3000))
});
}
}
#[test]
fn lines_take_an_outline_color_and_width() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
act(&mut cx, &deck, ("draw.line", &[]), |o| {
o.selection.as_ref().is_some_and(|s| s.kind == "line")
});
act(&mut cx, &deck, ("object.line_color", &["16711680"]), |o| {
o.selection
.as_ref()
.is_some_and(|s| s.line == Paint::Color(0xFF0000))
});
act(&mut cx, &deck, ("object.line_width", &["176"]), |o| {
o.selection
.as_ref()
.is_some_and(|s| s.line_width == Some(176))
});
}
#[test]
fn an_empty_list_choice_deselects() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
|o| o.selection.is_some(),
);
super::update(&mut cx, &deck, |deck, cx| deck.select_objects(&[], cx));
super::settle(&cx, |app| {
objects(app, &deck).is_some_and(|o| o.selection.is_none())
});
}
#[test]
fn a_mixed_value_can_be_set_to_zero() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let count = |n: usize| move |o: &Objects| o.objects.len() == n;
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
count(3),
);
act(&mut cx, &deck, ("object.transparency", &["25"]), |o| {
o.selection
.as_ref()
.is_some_and(|s| s.transparency == Some(25))
});
act(
&mut cx,
&deck,
("draw.shape", &["ellipse", "true"]),
count(4),
);
super::update(&mut cx, &deck, |deck, cx| {
deck.select_objects(&["2".into(), "3".into()], cx)
});
super::settle(&cx, |app| {
selection(app, &deck).is_some_and(|s| s.count == 2 && s.transparency.is_none())
});
let steps = undo_steps(&mut cx, &deck);
send(
&mut cx,
&deck,
"transparency",
"object.transparency",
0,
None,
);
super::settle(&cx, |app| {
selection(app, &deck).is_some_and(|s| s.transparency == Some(0))
});
assert_eq!(undo_steps(&mut cx, &deck), steps + 1);
}
#[test]
fn a_mixed_autofit_takes_one_rule_for_all() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let fit = |rule: &'static str| {
move |o: &Objects| o.selection.as_ref().is_some_and(|s| s.autofit == rule)
};
super::update(&mut cx, &deck, |deck, cx| {
deck.select_objects(&["0".into()], cx)
});
act(&mut cx, &deck, ("text.fit", &["shrink"]), fit("shrink"));
super::update(&mut cx, &deck, |deck, cx| {
deck.select_objects(&["1".into()], cx)
});
act(&mut cx, &deck, ("text.fit", &["grow"]), fit("grow"));
super::update(&mut cx, &deck, |deck, cx| {
deck.select_objects(&["0".into(), "1".into()], cx)
});
super::settle(&cx, |app| {
objects(app, &deck).as_ref().is_some_and(fit("mixed"))
});
let steps = undo_steps(&mut cx, &deck);
act(&mut cx, &deck, ("text.fit", &["off"]), fit("off"));
assert_eq!(undo_steps(&mut cx, &deck), steps + 1, "one step for both");
}
+386
View File
@@ -0,0 +1,386 @@
//! Tables, charts, notes and comments from the deck (C10.3).
use ely_gpui_component::forms::TextInput;
use ely_lok::show::Objects;
use ely_office::prelude::*;
use gpui::{AppContext as _, Entity, TestAppContext, Window};
use super::objects::{act, blank, objects};
use super::{settle, undo_steps, update};
use crate::deck::Deck;
use crate::dialog::Dialog;
/// Runs `f` on the deck inside its window.
fn in_window<R>(
cx: &mut TestAppContext,
deck: &Entity<Deck>,
f: impl FnOnce(&mut Deck, &mut Window, &mut gpui::Context<Deck>) -> R,
) -> R {
let handle = cx
.update(|app| deck.read(app).core.window)
.expect("a window");
cx.update_window(handle, |_, window, app| {
deck.update(app, |deck, cx| f(deck, window, cx))
})
.expect("the window lives")
}
fn type_in(cx: &mut TestAppContext, field: &Entity<TextInput>, text: &str) {
let text = text.to_string();
cx.update(|app| field.update(app, |field, cx| field.set_text(text, cx)));
}
fn has(cx: &TestAppContext, deck: &Entity<Deck>, done: impl Fn(&Objects) -> bool) {
settle(cx, |app| objects(app, deck).as_ref().is_some_and(&done));
}
#[test]
fn the_chart_dialog_inserts_edits_and_refuses_words() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let chart_open = |app: &gpui::App| matches!(deck.read(app).dialog, Some(Dialog::Chart(_)));
update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx));
settle(&cx, chart_open);
update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx));
has(&cx, &deck, |o| {
o.selection.as_ref().is_some_and(|s| s.kind == "chart")
});
// Again, with the chart marked: it edits.
update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx));
settle(&cx, chart_open);
let cell = cx.update(|app| match &deck.read(app).dialog {
Some(Dialog::Chart(editing)) => {
assert!(editing.changing, "the marked chart opens for editing");
editing.table.as_ref().expect("a table").series[0].1[0].clone()
}
_ => unreachable!("a chart dialog"),
});
type_in(&mut cx, &cell, "lots");
update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx));
let refused = cx.update(|app| match &deck.read(app).dialog {
Some(Dialog::Chart(editing)) => editing.error.clone(),
_ => None,
});
assert!(refused.is_some(), "a word in a cell keeps the dialog open");
let steps = undo_steps(&mut cx, &deck);
type_in(&mut cx, &cell, "9");
update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx));
settle(&cx, |app| deck.read(app).dialog.is_none());
assert_eq!(undo_steps(&mut cx, &deck), steps + 1, "one step");
let value = cx.update(|app| {
let deck = deck.read(app);
let tab = deck.active_tab().expect("a tab");
let mut worker = deck.core.worker.lock().expect("the worker lock");
let read = worker
.values(&tab.doc, ely_lok::sheet::CHART)
.expect("reads");
ely_lok::show::slide_chart(&read)
.expect("parses")
.expect("a chart")
.data
.expect("a table")
.series[0]
.1[0]
});
assert_eq!(value, Some(9.0));
}
#[test]
fn the_table_dialog_inserts_a_table() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| deck.ask_table(cx));
update(&mut cx, &deck, |deck, cx| deck.table_asked(cx));
has(&cx, &deck, |o| {
o.objects.iter().any(|object| object.kind == "table")
});
}
#[test]
fn typed_notes_reach_the_slide() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let notes = |app: &gpui::App| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.notes.clone())
};
settle(&cx, |app| notes(app).is_some());
let field = in_window(&mut cx, &deck, |deck, window, cx| {
deck.notes_input(window, cx)
});
let field = field.expect("the field");
type_in(&mut cx, &field, "Start with the totals");
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
settle(&cx, |app| {
notes(app).is_some_and(|notes| notes.text == "Start with the totals")
});
}
#[test]
fn a_comment_lands_on_the_shown_slide() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
in_window(&mut cx, &deck, |deck, window, cx| {
deck.ask_remark(None, window, cx)
});
let (tab, edit) = cx.update(|app| match &deck.read(app).dialog {
Some(Dialog::Remark { tab, edit, .. }) => (*tab, edit.clone()),
_ => unreachable!("a comment dialog"),
});
update(&mut cx, &deck, |deck, cx| {
deck.send_remark((tab, edit), " Too much text ", cx)
});
let remarks = |app: &gpui::App| {
deck.read(app)
.active_tab()
.map(|tab| tab.extra.remarks.clone())
.unwrap_or_default()
};
settle(&cx, |app| remarks(app).len() == 1);
let remark = cx.update(|app| remarks(app).remove(0));
assert_eq!((remark.part, remark.text.as_str()), (0, "Too much text"));
}
#[test]
fn notes_typed_before_a_slide_change_stay_on_their_slide() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let notes = |app: &gpui::App| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.notes.clone())
};
update(&mut cx, &deck, |deck, cx| {
let tab = deck.core.active;
deck.run_in(tab, "slide.insert", &[], cx)
});
let shown =
|part: usize| move |app: &gpui::App| notes(app).is_some_and(|n| n.part == Some(part));
settle(&cx, shown(1));
let view = |deck: &mut Deck| deck.active_tab().expect("a tab").view.clone();
update(&mut cx, &deck, |deck, cx| {
view(deck).update(cx, |view, _| view.show_part(0))
});
settle(&cx, shown(0));
let field = in_window(&mut cx, &deck, |deck, window, cx| {
deck.notes_input(window, cx)
});
type_in(&mut cx, &field.expect("the field"), "Only for the first");
// The switch is queued before the blur's commit.
update(&mut cx, &deck, |deck, cx| {
view(deck).update(cx, |view, _| view.show_part(1));
deck.commit_notes(cx);
});
settle(&cx, |app| {
notes(app).is_some_and(|n| n.part == Some(1) && n.text.is_empty())
});
update(&mut cx, &deck, |deck, cx| {
view(deck).update(cx, |view, _| view.show_part(0))
});
settle(&cx, |app| {
notes(app).is_some_and(|n| n.part == Some(0) && n.text == "Only for the first")
});
}
#[test]
fn a_chart_read_that_lands_late_opens_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx));
settle(&cx, |app| {
matches!(deck.read(app).dialog, Some(Dialog::Chart(_)))
});
update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx));
has(&cx, &deck, |o| {
o.selection.as_ref().is_some_and(|s| s.kind == "chart")
});
let first = cx.update(|app| deck.read(app).core.active).expect("a tab");
update(&mut cx, &deck, |deck, cx| deck.new_blank(cx));
settle(&cx, |app| deck.read(app).core.tabs.len() == 2);
let second = cx.update(|app| deck.read(app).core.active).expect("a tab");
update(&mut cx, &deck, |deck, _| deck.core.active = Some(first));
// Another tab comes forward before the read comes back.
update(&mut cx, &deck, |deck, cx| {
deck.ask_chart(cx);
deck.core.active = Some(second);
});
for _ in 0..100 {
cx.executor()
.advance_clock(std::time::Duration::from_millis(20));
cx.run_until_parked();
}
assert!(
cx.update(|app| deck.read(app).dialog.is_none()),
"no dialog for a hidden tab"
);
}
#[test]
fn a_chart_read_never_shows_another_chart_on_the_same_frame() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let charts = |o: &Objects| -> Vec<usize> {
let at = o.objects.iter().enumerate();
at.filter(|(_, o)| o.kind == "chart")
.map(|(at, _)| at)
.collect()
};
let title = |app: &gpui::App| match &deck.read(app).dialog {
Some(Dialog::Chart(editing)) => Some(editing.fields.title.read(app).text().to_string()),
_ => None,
};
for (n, name) in [(1, "First"), (2, "Second")] {
act(&mut cx, &deck, ("object.deselect", &["false"]), |o| {
o.selection.is_none()
});
update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx));
settle(&cx, |app| title(app).is_some());
let field = cx.update(|app| match &deck.read(app).dialog {
Some(Dialog::Chart(editing)) => editing.fields.title.clone(),
_ => unreachable!("a chart dialog"),
});
type_in(&mut cx, &field, name);
update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx));
has(&cx, &deck, |o| charts(o).len() == n);
}
let both = cx.update(|app| charts(&objects(app, &deck).expect("objects")));
let first = both[0].to_string();
act(&mut cx, &deck, ("object.select", &[&first, "false"]), |o| {
o.objects[both[0]].selected
});
// The kernel picks the other chart before the read runs.
let (worker, doc) = cx.update(|app| {
let deck = deck.read(app);
(
deck.core.worker.clone(),
deck.active_tab().expect("a tab").doc.clone(),
)
});
let mut held = worker.lock().expect("the worker lock");
update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx));
let second = both[1].to_string();
let doc = held.now(&doc).expect("the state");
held.run(&doc, "object.select", &[&second, "false"])
.expect("selects");
drop(held);
for _ in 0..100 {
cx.executor()
.advance_clock(std::time::Duration::from_millis(20));
cx.run_until_parked();
}
let shown = cx.update(|app| title(app));
assert!(
shown.is_none() || shown.as_deref() == Some("First"),
"{shown:?}"
);
}
#[test]
fn a_scatter_with_words_for_x_keeps_its_draft_and_names_the_cell() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx));
let open = |app: &gpui::App| matches!(deck.read(app).dialog, Some(Dialog::Chart(_)));
settle(&cx, open);
update(&mut cx, &deck, |deck, cx| {
if let Some(Dialog::Chart(editing)) = &mut deck.dialog {
editing.look.kind = "scatter".into();
}
deck.chart_asked(cx);
});
let error = cx.update(|app| match &deck.read(app).dialog {
Some(Dialog::Chart(editing)) => editing.error.clone(),
_ => None,
});
let first = cx.update(|app| Deck::text(app, "show.chart.category", &[("n", "1")]));
assert!(
error.is_some_and(|e| e.contains(first.as_ref())),
"the dialog stays and names it"
);
let fields = cx.update(|app| match &deck.read(app).dialog {
Some(Dialog::Chart(editing)) => editing.table.as_ref().expect("a table").categories.clone(),
_ => unreachable!("a chart dialog"),
});
for (at, field) in fields.iter().enumerate() {
type_in(&mut cx, field, &(10 * (at + 1)).to_string());
}
update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx));
has(&cx, &deck, |o| {
o.selection.as_ref().is_some_and(|s| s.kind == "chart")
});
}
#[test]
fn a_comment_picked_on_another_slide_shows_its_slide_again() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let tab = cx.update(|app| deck.read(app).core.active).expect("a tab");
update(&mut cx, &deck, |deck, cx| {
deck.send_remark((tab, None), "Here", cx)
});
let remark = |app: &gpui::App| {
let tab = deck.read(app).active_tab()?;
tab.extra.remarks.first().map(|remark| remark.id.clone())
};
settle(&cx, |app| remark(app).is_some());
let key: gpui::SharedString = cx.update(|app| remark(app)).expect("a comment").into();
let current = |app: &gpui::App| {
deck.read(app)
.active_tab()
.map(|tab| tab.extra.slides.current)
};
update(&mut cx, &deck, |deck, cx| deck.pick_remark(&key, cx));
assert_eq!(
cx.update(|app| deck.read(app).shown_remark()),
Some(key.clone())
);
update(&mut cx, &deck, |deck, cx| {
deck.run_in(Some(tab), "slide.insert", &[], cx)
});
settle(&cx, |app| current(app) == Some(1));
assert_eq!(
cx.update(|app| deck.read(app).shown_remark()),
None,
"pickable again"
);
update(&mut cx, &deck, |deck, cx| deck.pick_remark(&key, cx));
settle(&cx, |app| current(app) == Some(0));
assert_eq!(cx.update(|app| deck.read(app).shown_remark()), Some(key));
}
+151
View File
@@ -0,0 +1,151 @@
//! The text group of the object panel (SHOW-03).
use ely_gpui_component::{
data_display::PropertyGroup,
forms::{Choice, Select},
primitives::IconName,
theme::ControlSize,
};
use ely_lok::show::Selection;
use ely_office::panel::{self as kernel, font_box, palette, row, size_menu, toggle};
use gpui::{AnyElement, Context, IntoElement, SharedString, Window};
use crate::deck::Deck;
use crate::design::{Panel, on_deck};
impl Panel {
pub(crate) fn words(
&self,
sel: &Selection,
window: &mut Window,
cx: &mut Context<Self>,
) -> PropertyGroup {
let why = "show.object.why_text";
let effects = row([
toggle(
self,
"format.bold",
(Some(IconName::Bold), ""),
("", why),
cx,
),
toggle(
self,
"format.italic",
(Some(IconName::Italic), ""),
("", why),
cx,
),
toggle(
self,
"format.underline",
(Some(IconName::Underline), ""),
("", why),
cx,
),
toggle(
self,
"format.strikeout",
(Some(IconName::Strikethrough), ""),
("", why),
cx,
),
]);
let align = row([
toggle(
self,
"paragraph.left",
(Some(IconName::AlignLeft), ""),
("", why),
cx,
),
toggle(
self,
"paragraph.center",
(Some(IconName::AlignCenter), ""),
("", why),
cx,
),
toggle(
self,
"paragraph.right",
(Some(IconName::AlignRight), ""),
("", why),
cx,
),
toggle(
self,
"paragraph.justify",
(Some(IconName::AlignJustify), ""),
("", why),
cx,
),
]);
let lists = row([
toggle(
self,
"list.bullets",
(Some(IconName::List), ""),
("", why),
cx,
),
toggle(
self,
"list.numbering",
(Some(IconName::ListOrdered), ""),
("", why),
cx,
),
]);
let this = cx.entity();
let ink = kernel::color_state(self, ".uno:FontColor", cx);
let fonts = self.fonts(cx);
let font = font_box(self, "show-font", &fonts, window, cx);
let size = size_menu(self, "show-size", cx);
let fit = self.fit(&sel.autofit, cx);
let tip = |key: &str| Deck::text(cx, key, &[]);
PropertyGroup::new(tip("show.object.text"))
.row(tip("show.object.font"), font)
.row(tip("show.object.font_size"), size)
.row(tip("show.object.effects"), effects)
.row(
tip("show.object.color"),
palette(
&this,
"format.color",
(ink, "office.props.automatic"),
None,
cx,
),
)
.row(tip("show.object.align"), align)
.row(tip("show.object.lists"), lists)
.row(tip("show.object.autofit"), fit)
}
/// Off, shrink on overflow, or grow the box.
fn fit(&self, now: &str, cx: &mut Context<Self>) -> AnyElement {
let label = |key: &str| Deck::text(cx, key, &[]);
let mut choices: Vec<Choice> = ["off", "shrink", "grow"]
.iter()
.map(|key| Choice::new(*key, label(&format!("show.fit.{key}"))))
.collect();
if now == "mixed" {
choices.push(Choice::new("mixed", label("office.props.mixed")));
}
let deck = self.deck.clone();
Select::new("show-fit", choices)
.label(label("show.object.autofit"))
.selected(SharedString::from(now.to_string()))
.size(ControlSize::Sm)
.on_change(move |to, _, cx| {
let to = to.to_string();
if to != "mixed" {
on_deck(&deck, cx, |deck, cx| {
deck.object_command("text.fit", &[&to], cx)
})
}
})
.into_any_element()
}
}