Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ packaging/env.sh
/packaging/linux/flatpak/cargo-sources.json
/build-flatpak/
/.flatpak-builder/
out-ru-i18n/
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/deckcraft-web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
web-sys = { version = "0.3", features = ["Window", "Navigator", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
9 changes: 5 additions & 4 deletions apps/deckcraft-web/src/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub fn start() {
}
let inbox: Inbox = Inbox::default();
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
app.services.system_locale = web_sys::window().and_then(|w| w.navigator().language());
let q = query();
if !q.contains("blank") {
if let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
Expand Down Expand Up @@ -118,10 +119,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
let dialog = match purpose {
"picture" => rfd::AsyncFileDialog::new().add_filter("Pictures", PICTURE_EXTS),
"audio" => rfd::AsyncFileDialog::new().add_filter("Audio", AUDIO_EXTS),
"video" => rfd::AsyncFileDialog::new().add_filter("Video", VIDEO_EXTS),
_ => rfd::AsyncFileDialog::new().add_filter("Presentations", PRESENTATION_EXTS),
"picture" => rfd::AsyncFileDialog::new().add_filter(deckcraft_ui_egui::i18n::tr("Pictures"), PICTURE_EXTS),
"audio" => rfd::AsyncFileDialog::new().add_filter(deckcraft_ui_egui::i18n::tr("Audio"), AUDIO_EXTS),
"video" => rfd::AsyncFileDialog::new().add_filter(deckcraft_ui_egui::i18n::tr("Video"), VIDEO_EXTS),
_ => rfd::AsyncFileDialog::new().add_filter(deckcraft_ui_egui::i18n::tr("Presentations"), PRESENTATION_EXTS),
};
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = dialog.pick_file().await else {
Expand Down
1 change: 1 addition & 0 deletions apps/deckcraft/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ default-run = "deckcraft"
workspace = true

[dependencies]
sys-locale = "0.3.2"
deckcraft-engine = { workspace = true }
deckcraft-ui-egui = { workspace = true }
deckcraft-media = { workspace = true }
Expand Down
14 changes: 8 additions & 6 deletions apps/deckcraft/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,21 +92,22 @@ fn save_prefs(app: &SlideApp) {

fn services() -> Services {
Services {
system_locale: sys_locale::get_locale(),
pick_open: Some(Box::new(|purpose: &str| {
let d = rfd::FileDialog::new();
let d = match purpose {
"picture" => d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff"]),
"picture" => d.add_filter(deckcraft_ui_egui::i18n::tr("Pictures"), &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff"]),
"audio" => d.add_filter(
"Audio",
deckcraft_ui_egui::i18n::tr("Audio"),
&["wav", "mp3", "m4a", "m4b", "aac", "flac", "ogg", "oga", "opus", "aif", "aiff", "aifc", "caf", "wma", "weba", "mka"],
),
"video" => d.add_filter("Video", &["mp4", "m4v", "mov", "webm", "mkv", "wmv"]),
"video" => d.add_filter(deckcraft_ui_egui::i18n::tr("Video"), &["mp4", "m4v", "mov", "webm", "mkv", "wmv"]),
"theme" => d.add_filter("Themes and presentations", &["thmx", "pptx", "potx", "ppsx"]).add_filter("Office Theme", &["thmx"]),
_ => d
.add_filter("Presentations", &["deckcraft", "pptx", "potx", "ppsx"])
.add_filter(deckcraft_ui_egui::i18n::tr("Presentations"), &["deckcraft", "pptx", "potx", "ppsx"])
.add_filter("DeckCraft", &["deckcraft"])
.add_filter("PowerPoint", &["pptx", "potx", "ppsx"])
.add_filter("Outline", &["txt", "md"]),
.add_filter(deckcraft_ui_egui::i18n::tr("Outline"), &["txt", "md"]),
};
d.pick_file().map(|p| p.to_string_lossy().to_string())
})),
Expand Down Expand Up @@ -243,7 +244,8 @@ fn main() -> eframe::Result {
if let Ok(v) = app.session.execute("file.recovery.open", &serde_json::json!({}))
&& let Some(n) = v.get("opened").and_then(|o| o.as_array()).map(Vec::len).filter(|n| *n > 0)
{
app.set_status(format!("Recovered {n} unsaved presentation{} from the last session", if n == 1 { "" } else { "s" }));
deckcraft_ui_egui::i18n::set_current(app.ui.interface_language.resolve(app.services.system_locale.as_deref()));
app.set_status(deckcraft_ui_egui::i18n::recovered(n));
}
}
app.integrated_titlebar = cfg!(target_os = "macos");
Expand Down
9 changes: 6 additions & 3 deletions crates/render/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ pub struct RenderOpts<'a> {
pub scale: f64,
/// Editor view: placeholder prompts and dashed placeholder outlines.
pub edit: bool,
/// Optional interface prompt provider; custom layout prompts and document text stay intact.
pub placeholder_prompt: Option<fn(PhType) -> &'static str>,
/// Draw shapes marked hidden (Selection pane) — the editor hides them like the show does.
pub show_hidden: bool,
/// Per-shape animation state.
Expand Down Expand Up @@ -245,6 +247,7 @@ impl Default for RenderOpts<'_> {
RenderOpts {
scale: 1.0,
edit: false,
placeholder_prompt: None,
show_hidden: false,
state: None,
skip: &[],
Expand Down Expand Up @@ -1331,7 +1334,7 @@ impl Renderer {
}
} else if f.opts.edit && s.ph.is_some() {
let kind = s.ph_type().unwrap_or(PhType::Body);
let prompt_body = prompt_body(rctx, s, kind);
let prompt_body = prompt_body(rctx, s, kind, f.opts.placeholder_prompt.map(|prompt| prompt(kind)));
if !prompt_body.is_empty() {
let grey = Rgba::rgb(0x59, 0x59, 0x59);
draw_text(ctx, rctx, s, &prompt_body, tr, m, &f.fields, Some(grey));
Expand Down Expand Up @@ -1995,7 +1998,7 @@ fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>
}

/// The prompt shown in an empty placeholder, formatted like its content would be.
fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody {
fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType, interface_prompt: Option<&str>) -> TextBody {
// Layout/master prompt text when the layout supplies a custom one.
let (lp, _) = resolve::parents(rctx, s);
let custom = lp.and_then(|l| l.ph.as_ref().filter(|p| p.has_custom_prompt).and(l.text.as_ref())).filter(|t| !t.is_empty());
Expand All @@ -2005,7 +2008,7 @@ fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody {
if matches!(rctx.owner, resolve::Owner::Layout | resolve::Owner::Master) {
return TextBody::default();
}
let txt = kind.prompt();
let txt = interface_prompt.unwrap_or_else(|| kind.prompt());
if txt.is_empty() || kind.is_footer_kind() {
return TextBody::default();
}
Expand Down
22 changes: 22 additions & 0 deletions crates/render/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,28 @@ fn hyperlinks_draw_in_the_hyperlink_colour_underlined() {
assert_eq!(diff(&own, &px(&run_deck(RunProps { fill: red, underline: Some("sng".into()), ..Default::default() }))), 0);
}

#[test]
fn interface_placeholder_prompts_preserve_custom_prompts_and_document_data() {
let mut p = Presentation::default();
let slide = p.slides.first().unwrap();
let shape = slide.shapes.iter().find(|shape| shape.ph_type() == Some(PhType::Title) || shape.ph_type() == Some(PhType::CtrTitle)).unwrap();
let c = Ctx::for_slide(&p, slide).unwrap();
let before = p.clone();
assert_eq!(prompt_body(&c, shape, PhType::Title, Some("Натисніть, щоб додати заголовок")).text(), "Натисніть, щоб додати заголовок");
assert_eq!(p, before);

let layout_id = slide.layout;
let master = Arc::make_mut(p.masters.first_mut().unwrap());
let layout = master.layouts.iter_mut().find(|layout| layout.id == layout_id).unwrap();
let shape = layout.shapes.iter_mut().find(|shape| shape.ph_type() == Some(PhType::Title) || shape.ph_type() == Some(PhType::CtrTitle)).unwrap();
shape.ph.as_mut().unwrap().has_custom_prompt = true;
shape.text = Some(TextBody::from_text("Click to add title"));
let slide = p.slides.first().unwrap();
let shape = slide.shapes.iter().find(|shape| shape.ph_type() == Some(PhType::Title) || shape.ph_type() == Some(PhType::CtrTitle)).unwrap();
let c = Ctx::for_slide(&p, slide).unwrap();
assert_eq!(prompt_body(&c, shape, PhType::Title, Some("Натисніть, щоб додати заголовок")).text(), "Click to add title");
}

#[test]
fn a_linear_gradient_ramps_like_powerpoint() {
// Black to white, left to right: PowerPoint draws 105 a quarter of the way and 186 half way
Expand Down
1 change: 1 addition & 0 deletions crates/render/src/transition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ fn copy_opts<'a>(o: &RenderOpts<'a>) -> RenderOpts<'a> {
RenderOpts {
scale: o.scale,
edit: o.edit,
placeholder_prompt: o.placeholder_prompt,
show_hidden: o.show_hidden,
state: o.state,
skip: o.skip,
Expand Down
56 changes: 28 additions & 28 deletions crates/ui-egui/src/canvas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let Some(st) = app.session.active() else { return };
if st.doc.slides.is_empty() && st.selection.target == Target::Slides {
let resp = ui.allocate_rect(avail, Sense::click());
ui.painter().text(avail.center(), Align2::CENTER_CENTER, "Click to add first slide", theme::font(22.0), t.text_dim);
ui.painter().text(avail.center(), Align2::CENTER_CENTER, crate::i18n::tr("Click to add first slide"), theme::font(22.0), t.text_dim);
if resp.clicked() {
let _ = app.run("slide.new", json!({"layout": "title"}));
}
Expand Down Expand Up @@ -454,7 +454,7 @@ fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
};
p.line_segment([pos2(x, top.max.y - h), pos2(x, top.max.y)], Stroke::new(1.0, t.text_faint));
if k % 4 == 0 {
p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, format!("{}", k / 4), theme::font(8.0), t.text_dim);
p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, (k / 4).to_string(), theme::font(8.0), t.text_dim);
}
k += 1;
}
Expand Down Expand Up @@ -1175,7 +1175,7 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
}
let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty());
for (l, id) in [("Cut", "edit.cut"), ("Copy", "edit.copy"), ("Paste", "edit.paste")] {
if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() {
if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(crate::i18n::tr(l))).clicked() {
let _ = app.run(id, json!({}));
ui.close();
}
Expand All @@ -1184,92 +1184,92 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
// Right-clicking the text being edited keeps it (`PointerKind::ContextClick`): text items.
let editing = app.session.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.notes));
if editing {
if ui.button("Paragraph…").clicked() {
if ui.button(crate::i18n::tr("Paragraph…")).clicked() {
app.dialog = Some(crate::dialogs::Dialog::new("paragraph"));
ui.close();
}
for (l, id) in [("Bullets", "format.bullets"), ("Numbering", "format.numbering")] {
if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() {
if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(crate::i18n::tr(l))).clicked() {
let _ = app.run(id, json!({}));
ui.close();
}
}
if ui.button("Link…").clicked() {
if ui.button(crate::i18n::tr("Link…")).clicked() {
app.dialog = Some(crate::dialogs::Dialog::new("hyperlink"));
ui.close();
}
ui.separator();
if ui.button("Format Shape…").clicked() {
if ui.button(crate::i18n::tr("Format Shape…")).clicked() {
let _ = app.run("view.pane", json!({"pane": "format"}));
ui.close();
}
} else if has_sel {
if ui.button("Edit Text").clicked() {
if ui.button(crate::i18n::tr("Edit Text")).clicked() {
let _ = app.run("text.edit", json!({}));
ui.close();
}
if crate::ribbon::enabled(app, "shape.editPoints")
&& app.session.active().is_some_and(|d| d.selection.shapes.len() == 1 && d.selected_shapes().iter().all(|s| !s.is_line()))
&& ui.button("Edit Points").clicked()
&& ui.button(crate::i18n::tr("Edit Points")).clicked()
{
let _ = app.run("shape.editPoints", json!({"on": true}));
ui.close();
}
if ui.button("Font…").clicked() {
if ui.button(crate::i18n::tr("Font…")).clicked() {
let _ = app.run("format.fontDialog", json!({}));
ui.close();
}
if ui.button("Paragraph…").clicked() {
if ui.button(crate::i18n::tr("Paragraph…")).clicked() {
let _ = app.run("format.paragraphDialog", json!({}));
ui.close();
}
ui.menu_button("Bring to Front", |ui| {
ui.menu_button(crate::i18n::tr("Bring to Front"), |ui| {
crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({}));
crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({}));
});
ui.menu_button("Send to Back", |ui| {
ui.menu_button(crate::i18n::tr("Send to Back"), |ui| {
crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendToBack, "Send to Back", "arrange.sendToBack", json!({}));
crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendBackward, "Send Backward", "arrange.sendBackward", json!({}));
});
ui.menu_button("Group", |ui| {
ui.menu_button(crate::i18n::tr("Group"), |ui| {
crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({}));
crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({}));
});
let locked = app.session.active().is_some_and(|d| d.selected_shapes().iter().all(|s| s.locked));
if ui.button(if locked { "Unlock" } else { "Lock" }).clicked() {
if ui.button(if locked { crate::i18n::tr("Unlock") } else { crate::i18n::tr("Lock") }).clicked() {
let _ = app.run("shape.lock", json!({"locked": !locked}));
ui.close();
}
let picture = app
.session
.active()
.is_some_and(|d| d.selected_shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })));
if picture && ui.button("Change Picture…").clicked() {
if picture && ui.button(crate::i18n::tr("Change Picture…")).clicked() {
let _ = app.run("app.changePictureDialog", json!({}));
ui.close();
}
if ui.button("Link…").clicked() {
if ui.button(crate::i18n::tr("Link…")).clicked() {
app.dialog = Some(crate::dialogs::Dialog::new("hyperlink"));
ui.close();
}
if ui.button("Edit Alt Text…").clicked() {
if ui.button(crate::i18n::tr("Edit Alt Text…")).clicked() {
app.dialog = Some(crate::dialogs::Dialog::new("altText"));
ui.close();
}
if ui.button("Set as Default Shape").clicked() {
if ui.button(crate::i18n::tr("Set as Default Shape")).clicked() {
let _ = app.run("shape.setDefault", json!({}));
ui.close();
}
if ui.button("Size and Position…").clicked() {
if ui.button(crate::i18n::tr("Size and Position…")).clicked() {
let _ = app.run("shape.sizePosition", json!({}));
ui.close();
}
if ui.button("Format Shape…").clicked() {
if ui.button(crate::i18n::tr("Format Shape…")).clicked() {
let _ = app.run("view.pane", json!({"pane": "format"}));
ui.close();
}
} else {
ui.menu_button("Layout", |ui| {
ui.menu_button(crate::i18n::tr("Layout"), |ui| {
let layouts: Vec<(String, u32)> = app
.session
.active()
Expand All @@ -1282,28 +1282,28 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
}
}
});
ui.menu_button("Grid and Guides", |ui| {
ui.menu_button(crate::i18n::tr("Grid and Guides"), |ui| {
let mut on = app.ui.guides;
if ui.checkbox(&mut on, "Guides").clicked() {
if ui.checkbox(&mut on, crate::i18n::tr("Guides")).clicked() {
let _ = app.run("view.guides", json!({"on": on}));
}
for (label, vertical) in [("Add Vertical Guide", true), ("Add Horizontal Guide", false)] {
if ui.button(label).clicked() {
if ui.button(crate::i18n::tr(label)).clicked() {
let _ = app.run("guide.add", json!({"vertical": vertical}));
let _ = app.run("view.guides", json!({"on": true}));
ui.close();
}
}
});
if ui.button("Reset Slide").clicked() {
if ui.button(crate::i18n::tr("Reset Slide")).clicked() {
let _ = app.run("slide.reset", json!({}));
ui.close();
}
if ui.button("Format Background…").clicked() {
if ui.button(crate::i18n::tr("Format Background…")).clicked() {
let _ = app.run("view.pane", json!({"pane": "background"}));
ui.close();
}
if ui.button("New Comment").clicked() {
if ui.button(crate::i18n::tr("New Comment")).clicked() {
app.dialog = Some(crate::dialogs::Dialog::new("comment"));
ui.close();
}
Expand Down
Loading