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