mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
insertion line); Bring Forward / Send Backward arrows; Show All / Hide All; per-row eye and padlock; double-click or F2 renames in place; Ctrl+click toggles, Shift+click selects a run; arrows, Home, End and Esc in the list; right-click menu. Lock/Unlock on the canvas menu, greyed handles on locked objects. egui_kittest tests drive the pane. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
700 lines
32 KiB
Rust
700 lines
32 KiB
Rust
//! The slide canvas: the rendered slide, zoom and fit, pointer input routed to the engine's tools,
|
|
//! and the overlays drawn on top (selection box and handles, smart guides, marquee, drawing
|
|
//! previews, the text caret and text selection).
|
|
//!
|
|
//! As in PowerPoint's Normal view, shapes, pictures and text that sit partly or wholly off the
|
|
//! slide are drawn on the area around it (the pasteboard), where they can be selected and moved;
|
|
//! the slide show, exports and thumbnails clip to the slide.
|
|
|
|
use deckcraft_engine::tools::{Hit, box_handles};
|
|
use deckcraft_engine::{Mods, PointerEvent, PointerKind, Target, ToolKind};
|
|
use deckcraft_geom::{Point, Xfrm};
|
|
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
|
|
use serde_json::json;
|
|
|
|
use crate::SlideApp;
|
|
use crate::theme::{self, Tokens};
|
|
|
|
/// Slide-to-screen mapping.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct Xf {
|
|
pub origin: Pos2,
|
|
pub scale: f32,
|
|
}
|
|
|
|
impl Xf {
|
|
pub fn to_screen(&self, p: Point) -> Pos2 {
|
|
pos2(self.origin.x + p.x as f32 * self.scale, self.origin.y + p.y as f32 * self.scale)
|
|
}
|
|
pub fn to_slide(&self, p: Pos2) -> Point {
|
|
Point::new(((p.x - self.origin.x) / self.scale) as f64, ((p.y - self.origin.y) / self.scale) as f64)
|
|
}
|
|
}
|
|
|
|
fn mods(ui: &Ui) -> Mods {
|
|
ui.input(|i| Mods { shift: i.modifiers.shift, alt: i.modifiers.alt, cmd: i.modifiers.command || i.modifiers.ctrl })
|
|
}
|
|
|
|
/// The slide's rect on screen for the current zoom.
|
|
pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) {
|
|
let Some(d) = app.session.active() else { return (avail, 1.0) };
|
|
let (sw, sh) = (d.doc.slide_size.width as f32, d.doc.slide_size.height as f32);
|
|
let margin = 28.0;
|
|
let fit = ((avail.width() - margin * 2.0) / sw).min((avail.height() - margin * 2.0) / sh).max(0.05);
|
|
let scale = app.ui.zoom.unwrap_or(fit);
|
|
let size = vec2(sw * scale, sh * scale);
|
|
let mut r = Rect::from_center_size(avail.center(), size);
|
|
if size.x > avail.width() {
|
|
r = Rect::from_min_size(pos2(avail.min.x + margin, r.min.y), size);
|
|
}
|
|
if size.y > avail.height() {
|
|
r = Rect::from_min_size(pos2(r.min.x, avail.min.y + margin), size);
|
|
}
|
|
(r, scale)
|
|
}
|
|
|
|
pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
|
let t = Tokens::get(ui.ctx());
|
|
let avail = ui.available_rect_before_wrap();
|
|
app.canvas_rect = Some(avail);
|
|
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);
|
|
if resp.clicked() {
|
|
let _ = app.run("slide.new", json!({"layout": "title"}));
|
|
}
|
|
return;
|
|
}
|
|
let (content_rect, scale) = layout(app, avail);
|
|
// Scroll when zoomed beyond the window.
|
|
let scroll_id = ui.id().with("canvas_scroll");
|
|
let mut offset: egui::Vec2 = ui.data_mut(|d| d.get_temp(scroll_id).unwrap_or_default());
|
|
let overflow = vec2((content_rect.width() + 56.0 - avail.width()).max(0.0), (content_rect.height() + 56.0 - avail.height()).max(0.0));
|
|
let resp = ui.allocate_rect(avail, Sense::click_and_drag());
|
|
let (wheel, zoom_delta, pinch_pos) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos()));
|
|
if resp.hovered() {
|
|
if (zoom_delta - 1.0).abs() > 1e-3 {
|
|
let cur = app.ui.zoom.unwrap_or(scale);
|
|
app.ui.zoom = Some((cur * zoom_delta).clamp(0.1, 4.0));
|
|
let _ = pinch_pos;
|
|
} else if overflow != egui::Vec2::ZERO {
|
|
offset -= wheel;
|
|
} else if wheel.y.abs() > 18.0 && app.session.tool.marquee.is_none() && !app.session.tool.dragging() {
|
|
// Wheel over a fitted slide pages through slides (like PowerPoint).
|
|
let id = if wheel.y < 0.0 { "slide.next" } else { "slide.previous" };
|
|
let gate = ui.id().with("wheel_gate");
|
|
let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(0.0));
|
|
let now = ui.input(|i| i.time);
|
|
if now - last > 0.35 {
|
|
ui.data_mut(|d| d.insert_temp(gate, now));
|
|
let _ = app.run(id, json!({}));
|
|
}
|
|
}
|
|
}
|
|
offset.x = offset.x.clamp(0.0, overflow.x);
|
|
offset.y = offset.y.clamp(0.0, overflow.y);
|
|
ui.data_mut(|d| d.insert_temp(scroll_id, offset));
|
|
let slide_rect = content_rect.translate(-offset);
|
|
app.slide_rect = Some(slide_rect);
|
|
app.canvas_scale = scale;
|
|
let xf = Xf { origin: slide_rect.min, scale };
|
|
let painter = ui.painter_at(avail);
|
|
// Shadow + slide.
|
|
painter.rect_filled(slide_rect.translate(vec2(0.0, 1.5)).expand(1.0), CornerRadius::same(2), t.shadow);
|
|
let ppp = ui.ctx().pixels_per_point();
|
|
let px = ((slide_rect.width() * ppp).round().max(1.0) as u32, (slide_rect.height() * ppp).round().max(1.0) as u32);
|
|
let now = ui.input(|i| i.time);
|
|
let gifs = match app.session.active() {
|
|
Some(d) if d.selection.target == Target::Slides => app.media.gifs(&d.doc, d.selection.slide, now, &app.session.gif_paused),
|
|
_ => vec![],
|
|
};
|
|
crate::media::gif_repaint(ui.ctx(), &gifs);
|
|
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
|
|
let st = app.session.active();
|
|
// One texture pixel in screen points, and the canvas in pixels from the slide's corner.
|
|
let pixel = vec2(slide_rect.width() / px.0 as f32, slide_rect.height() / px.1 as f32);
|
|
let visible = visible_region(avail, slide_rect, pixel);
|
|
let tex = match st.map(|d| (d.selection.target, d)) {
|
|
Some((Target::Slides, d)) => {
|
|
let idx = d.selection.slide;
|
|
let doc = d.doc.clone();
|
|
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×)
|
|
}
|
|
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible),
|
|
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible),
|
|
None => None,
|
|
};
|
|
if let Some((tex, region)) = tex {
|
|
let at = Rect::from_min_size(
|
|
slide_rect.min + vec2(region.x as f32 * pixel.x, region.y as f32 * pixel.y),
|
|
vec2(region.w as f32 * pixel.x, region.h as f32 * pixel.y),
|
|
);
|
|
app.canvas_region = Some(at);
|
|
painter.image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
|
}
|
|
app.perf.render_ms = app.textures.last_render_ms;
|
|
media_frames(app, &painter, xf);
|
|
if app.ui.gridlines {
|
|
let step = 72.0 / 2.0 * scale;
|
|
let mut x = slide_rect.min.x + step;
|
|
while x < slide_rect.max.x {
|
|
painter.line_segment([pos2(x, slide_rect.min.y), pos2(x, slide_rect.max.y)], Stroke::new(0.5, Color32::from_black_alpha(40)));
|
|
x += step;
|
|
}
|
|
let mut y = slide_rect.min.y + step;
|
|
while y < slide_rect.max.y {
|
|
painter.line_segment([pos2(slide_rect.min.x, y), pos2(slide_rect.max.x, y)], Stroke::new(0.5, Color32::from_black_alpha(40)));
|
|
y += step;
|
|
}
|
|
}
|
|
if app.ui.guides {
|
|
let c = slide_rect.center();
|
|
painter.line_segment(
|
|
[pos2(c.x, slide_rect.min.y), pos2(c.x, slide_rect.max.y)],
|
|
Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)),
|
|
);
|
|
painter.line_segment(
|
|
[pos2(slide_rect.min.x, c.y), pos2(slide_rect.max.x, c.y)],
|
|
Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)),
|
|
);
|
|
}
|
|
if app.ui.ruler {
|
|
ruler(&painter, avail, slide_rect, scale, &t);
|
|
}
|
|
pointer(app, ui, &resp, xf);
|
|
overlays(app, ui, &painter, xf, &t);
|
|
ime_output(app, ui, xf);
|
|
media_bar(app, ui, xf);
|
|
gif_buttons(app, ui, xf, &gifs);
|
|
context_menu(app, &resp);
|
|
}
|
|
|
|
/// The canvas `avail` in texture pixels (`pixel`: one pixel's size in points) from the slide's
|
|
/// top-left corner.
|
|
fn visible_region(avail: Rect, slide: Rect, pixel: egui::Vec2) -> deckcraft_render::Region {
|
|
let px = |v: f32, k: f32| if v.is_finite() && k > 0.0 { (v / k) as f64 } else { 0.0 };
|
|
let (x0, y0) = (px(avail.min.x - slide.min.x, pixel.x).floor(), px(avail.min.y - slide.min.y, pixel.y).floor());
|
|
let (x1, y1) = (px(avail.max.x - slide.min.x, pixel.x).ceil(), px(avail.max.y - slide.min.y, pixel.y).ceil());
|
|
let lim = i32::MAX as f64 / 4.0;
|
|
let (x0, y0) = (x0.clamp(-lim, lim), y0.clamp(-lim, lim));
|
|
deckcraft_render::Region {
|
|
x: x0 as i32,
|
|
y: y0 as i32,
|
|
w: (x1 - x0).clamp(1.0, u32::MAX as f64) as u32,
|
|
h: (y1 - y0).clamp(1.0, u32::MAX as f64) as u32,
|
|
}
|
|
}
|
|
|
|
/// Where an input method composes for the slide (#101), on screen: at the insertion point while
|
|
/// editing text, or over the one selected shape (typing on it starts editing). `None` when there is
|
|
/// nothing to type into.
|
|
pub fn ime_area(app: &SlideApp, xf: Xf) -> Option<Rect> {
|
|
let st = app.session.active()?;
|
|
if let Some(ts) = &st.selection.text {
|
|
if ts.notes {
|
|
return None;
|
|
}
|
|
if let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
|
|
return Some(Rect::from_two_pos(xf.to_screen(top), xf.to_screen(bot)).expand2(vec2(1.0, 0.0)));
|
|
}
|
|
let sh = st.shape(ts.shape)?;
|
|
return Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)));
|
|
}
|
|
if st.selection.shapes.len() != 1 {
|
|
return None;
|
|
}
|
|
let sh = st.shape(*st.selection.shapes.first()?)?;
|
|
if sh.is_line() {
|
|
return None;
|
|
}
|
|
Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)))
|
|
}
|
|
|
|
/// Turn the input method on for the slide while it takes typing, with its candidate window at
|
|
/// the insertion point (#101). Without this egui leaves it off, and a Korean keyboard types
|
|
/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own.
|
|
fn ime_output(app: &SlideApp, ui: &Ui, xf: Xf) {
|
|
let ctx = ui.ctx();
|
|
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.home_open() {
|
|
return;
|
|
}
|
|
let Some(r) = ime_area(app, xf) else { return };
|
|
let to_global = ctx.layer_transform_to_global(ui.layer_id()).unwrap_or_default();
|
|
let r = to_global * r;
|
|
ctx.output_mut(|o| {
|
|
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: false });
|
|
});
|
|
}
|
|
|
|
/// Play/pause on an animated GIF under the pointer or selected on its own.
|
|
fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) {
|
|
let Some(d) = app.session.active() else { return };
|
|
if d.selection.text.is_some() || app.session.tool.dragging() {
|
|
return;
|
|
}
|
|
let only = if d.selection.shapes.len() == 1 { d.selection.shapes.first().copied() } else { None };
|
|
let pointer = ui.input(|i| i.pointer.hover_pos());
|
|
let mut toggle = None;
|
|
for g in gifs {
|
|
let r = screen_rect(xf, &g.rect);
|
|
if only != Some(g.id) && !pointer.is_some_and(|p| r.contains(p)) {
|
|
continue;
|
|
}
|
|
let b = crate::media::gif_button_rect(r);
|
|
// Sensing drags too keeps the press from reaching the canvas (selecting or moving the GIF).
|
|
let resp = ui.interact(b, ui.id().with(("gif_button", g.id.0)), Sense::click_and_drag());
|
|
crate::media::paint_gif_button(ui.painter(), b, g.paused, resp.hovered());
|
|
if resp.clicked() {
|
|
toggle = Some(g.id);
|
|
}
|
|
}
|
|
if let Some(id) = toggle {
|
|
let _ = app.run("media.gifPlay", json!({"id": id}));
|
|
}
|
|
}
|
|
|
|
/// Screen rect of a slide-space box (rotation ignored).
|
|
fn screen_rect(xf: Xf, x: &Xfrm) -> Rect {
|
|
Rect::from_min_max(xf.to_screen(Point::new(x.x, x.y)), xf.to_screen(Point::new(x.x + x.w, x.y + x.h)))
|
|
}
|
|
|
|
/// Video playing in the editor: its current frame over the poster.
|
|
fn media_frames(app: &SlideApp, painter: &egui::Painter, xf: Xf) {
|
|
let Some(d) = app.session.active() else { return };
|
|
if d.selection.target != Target::Slides || !app.media.any_active() {
|
|
return;
|
|
}
|
|
for (id, x, clip) in crate::media::slide_media(&d.doc, d.selection.slide) {
|
|
if clip.video {
|
|
crate::media::paint_frame(painter, &app.media, id, screen_rect(xf, &x));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The play/pause and timeline bar under a selected audio or video (PowerPoint shows it on
|
|
/// selection or hover).
|
|
fn media_bar(app: &mut SlideApp, ui: &mut Ui, xf: Xf) {
|
|
let Some(d) = app.session.active() else { return };
|
|
if d.selection.target != Target::Slides || d.selection.text.is_some() || app.session.tool.dragging() {
|
|
return;
|
|
}
|
|
let pointer = ui.input(|i| i.pointer.hover_pos());
|
|
let media = crate::media::slide_media(&d.doc, d.selection.slide);
|
|
let pick = media.iter().find(|(id, _, _)| d.selection.shapes.len() == 1 && d.selection.shapes[0] == *id).or_else(|| {
|
|
media.iter().find(|(id, x, _)| {
|
|
let r = screen_rect(xf, x);
|
|
app.media.status(*id).is_some() || pointer.is_some_and(|p| r.expand2(vec2(0.0, 40.0)).contains(p))
|
|
})
|
|
});
|
|
let Some((id, x, clip)) = pick.cloned() else { return };
|
|
let doc = d.doc.clone();
|
|
let r = screen_rect(xf, &x);
|
|
let w = r.width().max(230.0);
|
|
let bar = Rect::from_min_size(pos2(r.center().x - w / 2.0, r.max.y + 6.0), vec2(w, 26.0));
|
|
let status = app.media.status(id);
|
|
match crate::media::control_bar(ui, bar, status.as_ref(), &clip) {
|
|
Some(crate::media::BarAction::Toggle) => app.media.toggle(&doc, id, &clip, crate::media::Owner::Editor),
|
|
Some(crate::media::BarAction::Seek(t)) => app.media.seek(&doc, id, &clip, t, crate::media::Owner::Editor),
|
|
Some(crate::media::BarAction::Nudge(dt)) => {
|
|
let pos = status.map(|s| s.position).unwrap_or(0.0);
|
|
app.media.seek(&doc, id, &clip, (pos + dt).max(0.0), crate::media::Owner::Editor);
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
|
|
fn master_tex(
|
|
ctx: &egui::Context,
|
|
doc: &deckcraft_model::Presentation,
|
|
master: usize,
|
|
layout: Option<usize>,
|
|
px: (u32, u32),
|
|
visible: deckcraft_render::Region,
|
|
) -> Option<(egui::TextureHandle, deckcraft_render::Region)> {
|
|
let scale = px.0 as f64 / doc.slide_size.width.max(1.0);
|
|
let region = deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible);
|
|
let img = deckcraft_render::render_layout(
|
|
doc,
|
|
master,
|
|
layout,
|
|
&deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() },
|
|
);
|
|
Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR), region))
|
|
}
|
|
|
|
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
|
|
let top = Rect::from_min_max(avail.min, pos2(avail.max.x, avail.min.y + 16.0));
|
|
let left = Rect::from_min_max(avail.min, pos2(avail.min.x + 16.0, avail.max.y));
|
|
p.rect_filled(top, CornerRadius::ZERO, t.card);
|
|
p.rect_filled(left, CornerRadius::ZERO, t.card);
|
|
let inch = 72.0 * scale;
|
|
let mut k = 0;
|
|
loop {
|
|
let x = slide.min.x + k as f32 * inch / 4.0;
|
|
if x > slide.max.x {
|
|
break;
|
|
}
|
|
let h = if k % 4 == 0 {
|
|
8.0
|
|
} else if k % 2 == 0 {
|
|
5.0
|
|
} else {
|
|
3.0
|
|
};
|
|
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);
|
|
}
|
|
k += 1;
|
|
}
|
|
let mut k = 0;
|
|
loop {
|
|
let y = slide.min.y + k as f32 * inch / 4.0;
|
|
if y > slide.max.y {
|
|
break;
|
|
}
|
|
let w = if k % 4 == 0 {
|
|
8.0
|
|
} else if k % 2 == 0 {
|
|
5.0
|
|
} else {
|
|
3.0
|
|
};
|
|
p.line_segment([pos2(left.max.x - w, y), pos2(left.max.x, y)], Stroke::new(1.0, t.text_faint));
|
|
k += 1;
|
|
}
|
|
}
|
|
|
|
fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
|
|
let tol = (4.0 / xf.scale) as f64;
|
|
let m = mods(ui);
|
|
let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return };
|
|
let p = xf.to_slide(pos);
|
|
let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol };
|
|
let primary = ui.input(|i| i.pointer.primary_pressed());
|
|
if resp.hovered() && !app.session.tool.dragging() {
|
|
let _ = app.session.pointer(ev(PointerKind::Move));
|
|
}
|
|
let r = if resp.triple_clicked() {
|
|
app.session.pointer(ev(PointerKind::TripleClick))
|
|
} else if resp.double_clicked() {
|
|
app.session.pointer(ev(PointerKind::DoubleClick))
|
|
} else if primary && resp.hovered() && !resp.secondary_clicked() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)) {
|
|
app.session.pointer(ev(PointerKind::Down))
|
|
} else if resp.dragged_by(egui::PointerButton::Primary) || (app.session.tool.dragging() && ui.input(|i| i.pointer.primary_down())) {
|
|
app.session.pointer(ev(PointerKind::Drag))
|
|
} else if app.session.tool.dragging() && ui.input(|i| i.pointer.primary_released() || !i.pointer.primary_down()) {
|
|
app.session.pointer(ev(PointerKind::Up))
|
|
} else {
|
|
Ok(serde_json::Value::Null)
|
|
};
|
|
if let Err(e) = r {
|
|
app.set_status(e.to_string());
|
|
}
|
|
// Cursor.
|
|
let cursor = match (&app.session.tool.kind, &app.session.tool.hover) {
|
|
(ToolKind::Shape { .. }, _) => egui::CursorIcon::Crosshair,
|
|
(ToolKind::TextBox, _) => egui::CursorIcon::Text,
|
|
(ToolKind::Ink { .. }, _) => egui::CursorIcon::Crosshair,
|
|
(_, Some(Hit::Resize { handle, .. })) => match handle {
|
|
0 | 4 => egui::CursorIcon::ResizeNwSe,
|
|
2 | 6 => egui::CursorIcon::ResizeNeSw,
|
|
1 | 5 => egui::CursorIcon::ResizeVertical,
|
|
_ => egui::CursorIcon::ResizeHorizontal,
|
|
},
|
|
(_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias,
|
|
(_, Some(Hit::Adjust { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand,
|
|
(_, Some(Hit::Shape { text: true, .. })) => {
|
|
if app.session.active().is_some_and(|d| d.selection.text.is_some()) {
|
|
egui::CursorIcon::Text
|
|
} else {
|
|
egui::CursorIcon::Move
|
|
}
|
|
}
|
|
(_, Some(Hit::Shape { .. })) => egui::CursorIcon::Move,
|
|
_ => egui::CursorIcon::Default,
|
|
};
|
|
if resp.hovered() {
|
|
ui.ctx().set_cursor_icon(cursor);
|
|
}
|
|
if app.session.tool.dragging() {
|
|
ui.ctx().request_repaint();
|
|
}
|
|
}
|
|
|
|
fn poly(p: &egui::Painter, pts: [Pos2; 4], stroke: Stroke) {
|
|
for i in 0..4 {
|
|
p.line_segment([pts[i], pts[(i + 1) % 4]], stroke);
|
|
}
|
|
}
|
|
|
|
fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &Tokens) {
|
|
let Some(st) = app.session.active() else { return };
|
|
let sel_stroke = Stroke::new(1.0, t.selection);
|
|
let editing = st.selection.text.clone();
|
|
for id in &st.selection.shapes {
|
|
let Some(sh) = st.shape(*id) else { continue };
|
|
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
|
|
let a = x.affine();
|
|
let corners = [Point::new(0.0, 0.0), Point::new(x.w, 0.0), Point::new(x.w, x.h), Point::new(0.0, x.h)].map(|q| xf.to_screen(a * q));
|
|
if sh.is_line() {
|
|
let (p0, p1) = (xf.to_screen(a * Point::new(0.0, 0.0)), xf.to_screen(a * Point::new(x.w, x.h)));
|
|
let glued = match sh.kind {
|
|
deckcraft_model::ShapeKind::Connector { start, end } => [start.is_some(), end.is_some()],
|
|
_ => [false, false],
|
|
};
|
|
for (q, g) in [p0, p1].into_iter().zip(glued) {
|
|
// Glued ends are filled with the accent, like attached connector ends.
|
|
let fill = if g { t.accent } else { t.handle_fill };
|
|
painter.circle(q, 4.5, fill, Stroke::new(1.0, if g { t.handle_fill } else { t.handle_stroke }));
|
|
}
|
|
continue;
|
|
}
|
|
let editing_this = editing.as_ref().is_some_and(|e| e.shape == *id && !e.notes);
|
|
if editing_this {
|
|
// Dashed box while editing text.
|
|
for i in 0..4 {
|
|
let (s, e) = (corners[i], corners[(i + 1) % 4]);
|
|
painter.add(egui::Shape::dashed_line(&[s, e], Stroke::new(1.0, t.selection), 4.0, 3.0));
|
|
}
|
|
} else {
|
|
poly(painter, corners, sel_stroke);
|
|
}
|
|
if sh.locked && !editing_this {
|
|
// Locked: greyed handles that don't respond, no rotate or adjust handles.
|
|
for h in box_handles(&x).0 {
|
|
painter.circle(xf.to_screen(h), 4.0, t.text_faint, Stroke::new(1.0, t.handle_fill));
|
|
}
|
|
continue;
|
|
}
|
|
if st.selection.shapes.len() == 1 || !editing_this {
|
|
let (hs, rot) = box_handles(&x);
|
|
let rot_s = xf.to_screen(rot);
|
|
if !editing_this && sh.ph.as_ref().is_none_or(|_| true) {
|
|
let top_mid = xf.to_screen(hs[1]);
|
|
painter.line_segment([top_mid, rot_s], sel_stroke);
|
|
rotate_handle(painter, rot_s, t);
|
|
}
|
|
for h in hs {
|
|
painter.circle(xf.to_screen(h), 4.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke));
|
|
}
|
|
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
|
|
for hd in &geo.handles {
|
|
let q = xf.to_screen(a * hd.pos);
|
|
painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00)));
|
|
}
|
|
}
|
|
}
|
|
// Caret / text selection.
|
|
if let Some(ts) = &editing
|
|
&& !ts.notes
|
|
&& ts.cell.is_none()
|
|
&& let Some(sh) = st.shape(ts.shape)
|
|
&& let Some(l) = deckcraft_engine::cmd::text::layout_for(st, ts)
|
|
{
|
|
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
|
|
// Where the text is drawn: flips don't mirror it, vertical text is turned.
|
|
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
|
let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);
|
|
let (s0, s1) = ts.ordered();
|
|
for r in l.selection_rects(deckcraft_text::Pos { para: s0.0, ch: s0.1 }, deckcraft_text::Pos { para: s1.0, ch: s1.1 }) {
|
|
let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q));
|
|
painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE));
|
|
}
|
|
// An input method's composition: underlined in place, thicker under the part being converted.
|
|
for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) {
|
|
painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(if thick { 2.0 } else { 1.0 }, t.caret));
|
|
}
|
|
if !ts.is_range() {
|
|
let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6;
|
|
ui.ctx().request_repaint_after(std::time::Duration::from_millis(300));
|
|
// An empty placeholder shows the caret where typing begins too.
|
|
if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
|
|
painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret));
|
|
}
|
|
}
|
|
}
|
|
// Table cell editing: outline the cell.
|
|
if let Some(ts) = &editing
|
|
&& let (Some((r, c)), Some(sh)) = (ts.cell, st.shape(ts.shape))
|
|
&& let deckcraft_model::ShapeKind::Table(tb) = &sh.kind
|
|
{
|
|
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
|
|
let heights = deckcraft_engine::tools::table_rows(st, tb);
|
|
let y0: f64 = heights.iter().take(r).sum();
|
|
let x0: f64 = tb.cols.iter().take(c).sum();
|
|
let (w, h) = (tb.cols.get(c).copied().unwrap_or(0.0), heights.get(r).copied().unwrap_or(0.0));
|
|
let a = x.affine();
|
|
let pts = [Point::new(x0, y0), Point::new(x0 + w, y0), Point::new(x0 + w, y0 + h), Point::new(x0, y0 + h)].map(|q| xf.to_screen(a * q));
|
|
poly(painter, pts, Stroke::new(2.0, t.accent));
|
|
}
|
|
// Smart guides, marquee, previews.
|
|
for g in &app.session.tool.guides {
|
|
let (a, b) =
|
|
if g.vertical { (Point::new(g.pos, g.from), Point::new(g.pos, g.to)) } else { (Point::new(g.from, g.pos), Point::new(g.to, g.pos)) };
|
|
painter.add(egui::Shape::dashed_line(&[xf.to_screen(a), xf.to_screen(b)], Stroke::new(1.0, t.guide), 5.0, 3.0));
|
|
}
|
|
if let Some(m) = app.session.tool.marquee {
|
|
let r = Rect::from_two_pos(xf.to_screen(Point::new(m.x0, m.y0)), xf.to_screen(Point::new(m.x1, m.y1)));
|
|
painter.rect(
|
|
r,
|
|
CornerRadius::ZERO,
|
|
Color32::from_rgba_unmultiplied(80, 130, 230, 30),
|
|
Stroke::new(1.0, Color32::from_rgb(80, 130, 230)),
|
|
egui::StrokeKind::Inside,
|
|
);
|
|
}
|
|
if let Some(pv) = app.session.tool.preview {
|
|
draw_preview(app, painter, xf, pv, t);
|
|
}
|
|
// Freeform being drawn.
|
|
let path = &app.session.tool.path_preview;
|
|
if path.len() > 1 {
|
|
let pts: Vec<Point> = if app.session.tool.path_smooth && path.len() > 2 {
|
|
let d = deckcraft_engine::cmd::shape::freeform_path(path, false, true);
|
|
let mut v = vec![];
|
|
let bp = deckcraft_render::parse_path(&d);
|
|
deckcraft_geom::preset_flatten(&bp, &mut |el| match el {
|
|
deckcraft_geom::PathEl::MoveTo(q) | deckcraft_geom::PathEl::LineTo(q) => v.push(q),
|
|
_ => {}
|
|
});
|
|
v
|
|
} else {
|
|
path.clone()
|
|
};
|
|
painter.add(egui::Shape::line(pts.iter().map(|q| xf.to_screen(*q)).collect(), Stroke::new(1.5, t.accent)));
|
|
if app.session.tool.path_closing
|
|
&& let Some(s0) = path.first()
|
|
{
|
|
painter.circle(xf.to_screen(*s0), 5.0, t.accent, Stroke::new(1.5, t.handle_fill));
|
|
}
|
|
}
|
|
// Connection sites of the shape under a connector end, and the site it would glue to.
|
|
for q in &app.session.tool.sites {
|
|
let c = xf.to_screen(*q);
|
|
painter.circle(c, 3.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke));
|
|
}
|
|
if let Some(g) = app.session.tool.glue {
|
|
painter.circle(xf.to_screen(g), 5.0, t.accent, Stroke::new(1.5, t.handle_fill));
|
|
}
|
|
if app.session.tool.ink_preview.len() > 1
|
|
&& let ToolKind::Ink { color, width, mode } = &app.session.tool.kind
|
|
{
|
|
let pts: Vec<Pos2> = app.session.tool.ink_preview.iter().map(|(x, y, _)| xf.to_screen(Point::new(*x, *y))).collect();
|
|
let c = theme::to_color32(*color);
|
|
let c = if mode == "highlighter" { c.gamma_multiply(0.45) } else { c };
|
|
painter.add(egui::Shape::line(pts, Stroke::new((*width as f32 * xf.scale).max(1.0), c)));
|
|
}
|
|
}
|
|
|
|
fn rotate_handle(p: &egui::Painter, c: Pos2, t: &Tokens) {
|
|
p.circle_filled(c, 6.0, t.handle_fill);
|
|
p.add(egui::Shape::Path(egui::epaint::PathShape {
|
|
points: (0..=20)
|
|
.map(|i| {
|
|
let a = -0.4 + i as f32 / 20.0 * 5.2;
|
|
pos2(c.x + 4.0 * a.cos(), c.y + 4.0 * a.sin())
|
|
})
|
|
.collect(),
|
|
closed: false,
|
|
fill: Color32::TRANSPARENT,
|
|
stroke: Stroke::new(1.3, t.handle_stroke).into(),
|
|
}));
|
|
}
|
|
|
|
fn draw_preview(app: &SlideApp, p: &egui::Painter, xf: Xf, x: Xfrm, t: &Tokens) {
|
|
let preset = match &app.session.tool.kind {
|
|
ToolKind::Shape { preset } => preset.clone(),
|
|
_ => "rect".into(),
|
|
};
|
|
if deckcraft_geom::preset::is_line_like(&preset) {
|
|
let a = x.affine();
|
|
p.line_segment([xf.to_screen(a * Point::new(0.0, 0.0)), xf.to_screen(a * Point::new(x.w, x.h))], Stroke::new(1.5, t.accent));
|
|
return;
|
|
}
|
|
let r = Rect::from_two_pos(xf.to_screen(Point::new(x.x, x.y)), xf.to_screen(Point::new(x.x + x.w, x.y + x.h)));
|
|
if r.width() < 1.0 || r.height() < 1.0 {
|
|
return;
|
|
}
|
|
crate::ribbon::paint_preset(p, r, &preset, t.accent.gamma_multiply(0.25), t.accent);
|
|
}
|
|
|
|
fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
|
|
resp.context_menu(|ui| {
|
|
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() {
|
|
let _ = app.run(id, json!({}));
|
|
ui.close();
|
|
}
|
|
}
|
|
ui.separator();
|
|
if has_sel {
|
|
if ui.button("Edit Text").clicked() {
|
|
let _ = app.run("text.edit", json!({}));
|
|
ui.close();
|
|
}
|
|
ui.menu_button("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| {
|
|
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| {
|
|
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() {
|
|
let _ = app.run("shape.lock", json!({"locked": !locked}));
|
|
ui.close();
|
|
}
|
|
if ui.button("Link…").clicked() {
|
|
app.dialog = Some(crate::dialogs::Dialog::new("hyperlink"));
|
|
ui.close();
|
|
}
|
|
if ui.button("Edit Alt Text…").clicked() {
|
|
app.dialog = Some(crate::dialogs::Dialog::new("altText"));
|
|
ui.close();
|
|
}
|
|
if ui.button("Set as Default Shape").clicked() {
|
|
let _ = app.run("shape.setDefault", json!({}));
|
|
ui.close();
|
|
}
|
|
if ui.button("Format Shape…").clicked() {
|
|
let _ = app.run("view.pane", json!({"pane": "format"}));
|
|
ui.close();
|
|
}
|
|
} else {
|
|
ui.menu_button("Layout", |ui| {
|
|
let layouts: Vec<(String, u32)> = app
|
|
.session
|
|
.active()
|
|
.and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id.0)).collect()))
|
|
.unwrap_or_default();
|
|
for (n, id) in layouts {
|
|
if ui.button(n).clicked() {
|
|
let _ = app.run("slide.layout", json!({"layout": id}));
|
|
ui.close();
|
|
}
|
|
}
|
|
});
|
|
if ui.button("Reset Slide").clicked() {
|
|
let _ = app.run("slide.reset", json!({}));
|
|
ui.close();
|
|
}
|
|
if ui.button("Format Background…").clicked() {
|
|
let _ = app.run("view.pane", json!({"pane": "background"}));
|
|
ui.close();
|
|
}
|
|
if ui.button("New Comment").clicked() {
|
|
app.dialog = Some(crate::dialogs::Dialog::new("comment"));
|
|
ui.close();
|
|
}
|
|
}
|
|
});
|
|
}
|