diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8200375a..97b17388 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -15,6 +15,32 @@ use serde_json::json; use crate::SlideApp; use crate::theme::{self, Tokens}; +mod context_menu; +mod guides; +mod ime; +mod input; +mod media; +mod overlays; +mod rulers; +mod scroll; + +use context_menu::context_menu; +use guides::{draw_grid, draw_guides, grid_line_step}; +pub use ime::ime_area; +use ime::ime_output; +#[cfg(test)] +use input::resize_cursor; +pub(crate) use input::{page_flick, trackpad_twist}; +use input::{pointer, trackpad_scroll, wheel_slide_target}; +use media::{gif_buttons, media_bar, media_frames}; +use overlays::overlays; +#[cfg(test)] +pub(crate) use overlays::text_marks; +#[cfg(test)] +pub(crate) use rulers::ruler_steps; +use rulers::{ruler, ruler_markers}; +use scroll::{scroll_bar, visible_region}; + /// Slide-to-screen mapping. #[derive(Clone, Copy, Debug)] pub struct Xf { @@ -35,39 +61,6 @@ fn mods(ui: &Ui) -> Mods { ui.input(|i| Mods { shift: i.modifiers.shift, alt: i.modifiers.alt, cmd: i.modifiers.command || i.modifiers.ctrl }) } -/// A two-finger twist on the trackpad. macOS delivers it as [`egui::Event::Rotate`]; -/// other platforms leave the delta at zero. -/// -/// The selected shapes turn with the fingers. The whole twist is one undo step. Pinch-zoom -/// and two-finger scrolling are left as they are. -pub(crate) fn trackpad_twist(app: &mut SlideApp, ctx: &egui::Context) { - if ctx.will_discard() { - // Multi-pass layout runs this function again with the input already consumed. Acting on - // the discarded pass would start the twist and the replay would immediately end it, - // splitting one gesture into two undo steps. - return; - } - let delta = ctx.input(|i| i.rotation_delta()); - let delta = if delta.is_finite() { delta.clamp(-std::f32::consts::PI, std::f32::consts::PI) } else { 0.0 }; - let turning = delta.abs() > 1e-4; - let blocked = app.show.is_some() - || app.backstage_open() - || app.dialog.as_ref().is_some_and(|d| d.modal()) - || app.palette.is_some() - || app.session.tool.dragging(); - let over = app.canvas_rect.is_some_and(|r| ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p))); - let rotatable = app.session.active().is_some_and(|d| { - d.selection.text.is_none() && d.selection.shapes.iter().any(|id| d.shape(*id).is_some_and(|sh| !sh.locked && sh.xfrm.is_some())) - }); - if !blocked && turning && rotatable && (app.twisting || over) { - let _ = app.run("shape.rotate", json!({"by": f64::from(delta.to_degrees()), "gesture": "move"})); - app.twisting = true; - } else if app.twisting { - let _ = app.run("shape.rotate", json!({"gesture": "end", "ids": []})); - app.twisting = false; - } -} - /// 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) }; @@ -90,59 +83,6 @@ pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) { (r, scale) } -/// A trackpad flick or mouse-wheel notch that should turn the page. -/// -/// The stronger axis wins, so a sideways swipe is not read as a vertical scroll. Negative -/// movement — the content moves left or up, which is a swipe left or up with natural scrolling — -/// is the next slide. A nudge under 18 points is ignored so a resting finger does not change slides. -pub(crate) fn page_flick(wheel: egui::Vec2) -> Option { - const MIN_FLICK: f32 = 18.0; - let along = if wheel.x.abs() > wheel.y.abs() { wheel.x } else { wheel.y }; - (along.abs() > MIN_FLICK).then_some(along < 0.0) -} - -/// Discrete wheel input must not share the trackpad's gesture cooldown. Keep all the steps, -/// including several notches delivered in one frame, and clamp at each end of the deck. -/// -/// High-resolution and free-spin wheels send fractions of a line (0.125 per event on Windows), so -/// the delta is accumulated in `carry` and each whole line is one slide; a change of direction -/// drops what was left over. Returns the target, `None` when no wheel event was read. -fn wheel_slide_target(events: &[egui::Event], current: usize, slides: usize, carry: &mut f32) -> Option { - let last = slides.checked_sub(1)?; - let mut target = current.min(last); - let mut handled = false; - for event in events { - if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event - && matches!(unit, egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page) - && !modifiers.ctrl - && !modifiers.command - && !modifiers.shift - && delta.y.is_finite() - && delta.y.abs() > 0.0 - && delta.y.abs() >= delta.x.abs() - { - if *carry * delta.y < 0.0 { - *carry = 0.0; - } - *carry += delta.y; - // The small slack keeps sums such as 10 × 0.1 from falling just short of a line. - let whole = (carry.abs() + 1e-4).floor(); - *carry -= whole.copysign(*carry); - let steps = whole.min(slides as f32) as usize; - target = if delta.y < 0.0 { target.saturating_add(steps).min(last) } else { target.saturating_sub(steps) }; - handled = true; - } - } - handled.then_some(target) -} - -fn trackpad_scroll(events: &[egui::Event]) -> egui::Vec2 { - events.iter().fold(egui::Vec2::ZERO, |sum, event| match event { - egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers, .. } if !modifiers.ctrl && !modifiers.command => sum + *delta, - _ => sum, - }) -} - /// Paint the editing canvas, including rulers, selection handles, overlays, and current /// zoom/scroll state. pub fn show(app: &mut SlideApp, ui: &mut Ui) { @@ -389,172 +329,11 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } } -/// Thickness of the canvas scroll bars, in points. -const SCROLL_BAR: f32 = 10.0; - -/// The canvas scroll bar along `axis` (0 across, 1 down): its track, its thumb for `offset`, and -/// how far the thumb can travel. `None` when the slide fits that way. `inset` keeps the rulers' -/// strips clear. -fn scroll_bar(avail: Rect, inset: f32, overflow: egui::Vec2, offset: egui::Vec2, axis: usize) -> Option<(Rect, Rect, f32)> { - let over = overflow[axis]; - if !over.is_finite() || over <= 0.0 { - return None; - } - // Both bars showing leave the corner between them empty. - let corner = if overflow[1 - axis] > 0.0 { SCROLL_BAR } else { 0.0 }; - let track = if axis == 0 { - Rect::from_min_max(pos2(avail.min.x + inset, avail.max.y - SCROLL_BAR), pos2(avail.max.x - corner, avail.max.y)) - } else { - Rect::from_min_max(pos2(avail.max.x - SCROLL_BAR, avail.min.y + inset), pos2(avail.max.x, avail.max.y - corner)) - }; - let len = track.size()[axis].max(0.0); - let view = avail.size()[axis]; - let thumb_len = (len * view / (view + over)).max(SCROLL_BAR * 2.0).min(len); - let travel = (len - thumb_len).max(0.0); - let at = travel * (offset[axis] / over).clamp(0.0, 1.0); - let mut thumb = track; - thumb.min[axis] = track.min[axis] + at; - thumb.max[axis] = thumb.min[axis] + thumb_len; - Some((track, thumb, travel)) -} - -/// 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 { - 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. -/// -/// This only records the screen rect for this pass. [`crate::SlideApp`] observes the committed -/// text, owner and cause once at the end of the pass, after every IME event and editor command -/// has settled. -fn ime_output(app: &mut 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.backstage_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(); - app.ime_pending = Some(to_global * r); -} - -/// 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.scope_id().with(("gif_button", g.id.0)), Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, if g.paused { "Play animated GIF" } else { "Pause animated GIF" })); - 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), clip.crop); - } - } -} - -/// 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 }; - let uid = d.uid; - 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(uid, &doc, id, &clip, crate::media::Owner::Editor), - Some(crate::media::BarAction::Seek(t)) => app.media.seek(uid, &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(uid, &doc, id, &clip, (pos + dt).max(0.0), crate::media::Owner::Editor); - } - None => {} - } -} - fn master_tex( ctx: &egui::Context, doc: &deckcraft_model::Presentation, @@ -579,1037 +358,6 @@ fn master_tex( Some((crate::textures::upload_editor(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region)) } -/// The ruler's numbered step in points and the ticks per step: quarter inches, or half -/// centimetres. Any other unit (only a hand-edited `prefs.json` gives one) keeps inches, since a -/// tick per point or pixel would be a solid bar. -pub(crate) fn ruler_steps(unit: deckcraft_geom::Unit) -> (f64, u32) { - match unit { - deckcraft_geom::Unit::Centimeters => (unit.points_per_unit(), 2), - _ => (72.0, 4), - } -} - -fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, unit: deckcraft_geom::Unit, 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 (step, sub) = ruler_steps(unit); - let tick = step as f32 * scale / sub as f32; - let len = |k: u32| { - if k.is_multiple_of(sub) { - 8.0 - } else if k.is_multiple_of(2) { - 5.0 - } else { - 3.0 - } - }; - let mut k = 0; - loop { - let x = slide.min.x + k as f32 * tick; - if x > slide.max.x { - break; - } - let h = len(k); - p.line_segment([pos2(x, top.max.y - h), pos2(x, top.max.y)], Stroke::new(1.0, t.text_faint)); - if k.is_multiple_of(sub) { - p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, format!("{}", k / sub), theme::font(8.0), t.text_dim); - } - k += 1; - } - let mut k = 0; - loop { - let y = slide.min.y + k as f32 * tick; - if y > slide.max.y { - break; - } - let w = len(k); - p.line_segment([pos2(left.max.x - w, y), pos2(left.max.x, y)], Stroke::new(1.0, t.text_faint)); - k += 1; - } -} - -/// What is being dragged on the ruler. -#[derive(Clone, Copy, Debug, PartialEq)] -enum RulerDrag { - /// First-line indent (the top triangle). - First, - /// Hanging indent (the bottom triangle): moves the left indent, the first line stays put. - Hanging, - /// Left indent (the box under the hanging triangle): moves both together. - Left, - /// The tab stop that was at this position (pt from the text area's left). - Tab(f64), -} - -/// The paragraph's indents and tab stops on the horizontal ruler while text is being edited, -/// draggable as in PowerPoint; a click on the ruler adds a left tab, dragging a tab off the -/// ruler removes it. -fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect, xf: Xf, t: &Tokens) { - if app.session.active().is_none_or(|d| d.selection.text.is_none()) { - return; - } - let Ok(info) = app.session.execute("format.ruler", &json!({})) else { return }; - if info["rotated"].as_bool().unwrap_or(true) { - return; - } - let f = |k: &str| info[k].as_f64().filter(|v| v.is_finite()).unwrap_or(0.0); - let (x0, width, ml, ind) = (f("x"), f("width"), f("marginLeft"), f("indent")); - let tabs: Vec<(f64, String)> = info["tabs"] - .as_array() - .map(|a| a.iter().filter_map(|t| Some((t["pos"].as_f64()?, t["align"].as_str().unwrap_or("l").to_string()))).collect()) - .unwrap_or_default(); - let top = Rect::from_min_max(pos2(avail.min.x + 16.0, avail.min.y), pos2(avail.max.x, avail.min.y + 16.0)); - let sx = |q: f64| xf.to_screen(Point::new(x0 + q, 0.0)).x; - let to_q = |x: f32| ((xf.to_slide(pos2(x, 0.0)).x - x0).clamp(0.0, width.max(0.0)) * 2.0).round() / 2.0; - // The text area's span, outlined on the ruler. - let area = Rect::from_min_max(pos2(sx(0.0), top.min.y + 2.0), pos2(sx(width), top.max.y - 1.0)).intersect(top); - p.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, t.text_faint.gamma_multiply(0.5)), egui::StrokeKind::Inside); - - let resp = ui.interact(top, ui.scope_id().with("ruler_markers"), Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Text ruler")); - let id = ui.scope_id().with("ruler_drag"); - let mut drag: Option = ui.data(|d| d.get_temp::(id)); - let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); - if resp.drag_started() - && let Some(at) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) - { - let near = |q: f64| (sx(q) - at.x).abs() <= 5.0; - let y = at.y - top.min.y; - drag = if let Some((pos, _)) = tabs.iter().find(|(pos, _)| near(*pos) && y >= 8.0) { - Some(RulerDrag::Tab(*pos)) - } else if y < 6.0 && near(ml + ind) { - Some(RulerDrag::First) - } else if (6.0..11.0).contains(&y) && near(ml) { - Some(RulerDrag::Hanging) - } else if y >= 11.0 && near(ml) { - Some(RulerDrag::Left) - } else { - None - }; - ui.data_mut(|d| match drag { - Some(v) => { - d.insert_temp(id, v); - } - None => { - d.remove::(id); - } - }); - } - // Where each marker is drawn: the dragged one follows the pointer. - let live = drag.zip(pointer.filter(|_| resp.dragged())).map(|(d, at)| (d, to_q(at.x), at.y > top.max.y + 20.0)); - let (mut first, mut left) = (ml + ind, ml); - match live { - Some((RulerDrag::First, q, _)) => first = q, - Some((RulerDrag::Hanging, q, _)) => left = q, - Some((RulerDrag::Left, q, _)) => { - first = q + ind; - left = q; - } - _ => {} - } - let col = t.text_dim; - let tri = |x: f32, y0: f32, down: bool| { - let (a, b) = if down { (y0, y0 + 5.0) } else { (y0 + 5.0, y0) }; - p.add(egui::Shape::convex_polygon(vec![pos2(x - 4.0, a), pos2(x + 4.0, a), pos2(x, b)], t.card, Stroke::new(1.0, col))); - }; - tri(sx(first), top.min.y + 1.0, true); - tri(sx(left), top.min.y + 6.0, false); - p.rect( - Rect::from_min_max(pos2(sx(left) - 4.0, top.min.y + 11.5), pos2(sx(left) + 4.0, top.max.y - 0.5)), - CornerRadius::ZERO, - t.card, - Stroke::new(1.0, col), - egui::StrokeKind::Inside, - ); - for (pos, align) in &tabs { - let (q, gone) = match live { - Some((RulerDrag::Tab(from), q, off)) if (from - pos).abs() < 0.5 => (q, off), - _ => (*pos, false), - }; - if gone { - continue; - } - tab_glyph(p, pos2(sx(q), top.max.y - 2.0), align, t.text); - } - // The tab selector in the ruler's corner: a click cycles left, center, right, decimal; a click - // on the ruler then sets that kind of stop. - let kind_id = ui.scope_id().with("ruler_tab_kind"); - let mut kind: usize = ui.data(|d| d.get_temp(kind_id)).unwrap_or(0); - let corner = Rect::from_min_size(avail.min, vec2(16.0, 16.0)); - let cr = ui.interact(corner, ui.scope_id().with("ruler_tab_selector"), Sense::click()); - const KINDS: [(&str, &str); 4] = [("l", "Left Tab"), ("ctr", "Center Tab"), ("r", "Right Tab"), ("dec", "Decimal Tab")]; - if cr.clicked() { - kind = (kind + 1) % KINDS.len(); - ui.data_mut(|d| d.insert_temp(kind_id, kind)); - } - let (kind_align, kind_label) = KINDS[kind % KINDS.len()]; - cr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, kind_label)); - let cr = cr.on_hover_text(format!("{kind_label} (click to change)")); - p.rect_stroke( - corner.shrink(2.0), - CornerRadius::same(2), - Stroke::new(1.0, if cr.hovered() { t.accent } else { t.text_faint }), - egui::StrokeKind::Inside, - ); - tab_glyph(p, pos2(corner.center().x - 1.0, corner.center().y + 3.0), kind_align, t.text); - if drag.is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); - } - - // Commit on release; a click on bare ruler adds a left tab. - if resp.drag_stopped() { - if let (Some(d), Some(at)) = (drag, pointer) { - let q = to_q(at.x); - let r = match d { - RulerDrag::First => app.run("format.paragraph", json!({"indentFirst": q - ml})), - RulerDrag::Hanging => app.run("format.paragraph", json!({"indentLeft": q, "indentFirst": ml + ind - q})), - RulerDrag::Left => app.run("format.paragraph", json!({"indentLeft": q})), - RulerDrag::Tab(from) if at.y > top.max.y + 20.0 => app.run("format.tabs", json!({"remove": from})), - RulerDrag::Tab(from) => app.run("format.tabs", json!({"move": {"from": from, "to": q}})), - }; - if let Err(e) = r { - app.set_status(e); - } - } - ui.data_mut(|d| d.remove::(id)); - } else if resp.clicked() - && let Some(at) = resp.interact_pointer_pos() - && at.x >= area.min.x - && at.x <= area.max.x - { - let near_tab = tabs.iter().any(|(pos, _)| (sx(*pos) - at.x).abs() <= 5.0); - if resp.double_clicked() || near_tab { - // A double-click on a stop opens the Tabs dialog, as in PowerPoint. - if resp.double_clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("tabs")); - } - } else { - let _ = app.run("format.tabs", json!({"add": {"pos": to_q(at.x), "align": kind_align}})); - } - } -} - -/// A tab-stop mark with its foot at `at`: └ left, ┴ center, ┘ right, ┴ with a dot decimal. -fn tab_glyph(p: &egui::Painter, at: Pos2, align: &str, col: Color32) { - let (x, y) = (at.x, at.y); - let st = Stroke::new(1.5, col); - p.line_segment([pos2(x, y - 6.0), pos2(x, y)], st); - match align { - "r" => { - p.line_segment([pos2(x - 5.0, y), pos2(x, y)], st); - } - "ctr" => { - p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); - } - "dec" => { - p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); - p.circle_filled(pos2(x + 3.0, y - 4.0), 1.0, col); - } - _ => { - p.line_segment([pos2(x, y), pos2(x + 5.0, y)], st); - } - } -} - -/// The presentation's drawing guides; the one being dragged at its new place, labelled with its -/// distance from the slide's centre in points. -fn draw_guides(app: &SlideApp, painter: &egui::Painter, xf: Xf, slide: Rect, t: &Tokens) { - let Some(st) = app.session.active() else { return }; - let size = st.doc.slide_size; - let drag = app.session.tool.guide_drag; - for (i, g) in st.doc.guides.iter().enumerate() { - let (pos, active) = match drag { - Some((di, p)) if di == i => (p, true), - _ => (g.pos, app.session.tool.guide_hover == Some(i)), - }; - let col = if active { t.accent } else { Color32::from_rgba_unmultiplied(120, 120, 120, 170) }; - let stroke = Stroke::new(1.0, col); - let (a, b) = if g.vertical { - let x = xf.to_screen(Point::new(pos, 0.0)).x; - (pos2(x, slide.min.y), pos2(x, slide.max.y)) - } else { - let y = xf.to_screen(Point::new(0.0, pos)).y; - (pos2(slide.min.x, y), pos2(slide.max.x, y)) - }; - // Dashed, like PowerPoint's guides. - let len = (b - a).length(); - let dir = (b - a) / len.max(1.0); - let mut d = 0.0; - while d < len { - painter.line_segment([a + dir * d, a + dir * (d + 4.0).min(len)], stroke); - d += 7.0; - } - if drag.is_some_and(|(di, _)| di == i) { - let centre = if g.vertical { size.width / 2.0 } else { size.height / 2.0 }; - let off = pos - centre; - let side = match (g.vertical, off < 0.0, off > 0.0) { - (true, true, _) => " left of centre", - (true, _, true) => " right of centre", - (false, true, _) => " above centre", - (false, _, true) => " below centre", - _ => " (centre)", - }; - let label = format!("{:.0} pt{side}", off.abs()); - let at = if g.vertical { pos2(a.x + 6.0, slide.min.y + 6.0) } else { pos2(slide.min.x + 6.0, a.y + 4.0) }; - let galley = painter.layout_no_wrap(label, theme::font(11.0), t.text); - let r = Rect::from_min_size(at, galley.size()).expand(3.0); - painter.rect_filled(r, CornerRadius::same(3), t.card); - painter.galley(at, galley, t.text); - } - } -} - -fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { - if crate::eyedropper::canvas(app, ui, resp, xf) { - return; - } - if app.anim_preview_active() { - // A click on the slide ends the preview, then selects as usual. - if resp.hovered() && ui.input(|i| i.pointer.primary_pressed()) { - app.stop_anim_preview(); - } else { - return; - } - } - 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 }; - // The rulers have their own controls; a press there mustn't reach the slide (and end text - // editing). A drag already under way keeps going over them. - if app.ui.ruler && !app.session.tool.dragging() && (pos.y < resp.rect.min.y + 16.0 || pos.x < resp.rect.min.x + 16.0) { - return; - } - let p = xf.to_slide(pos); - let pressure = reported_pressure(ui); - let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol, pressure }; - let primary = ui.input(|i| i.pointer.primary_pressed()); - if resp.hovered() && !app.session.tool.dragging() { - let _ = app.session.pointer(ev(PointerKind::Move)); - } - let secondary = resp.hovered() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Secondary)); - let r = if secondary && !app.session.tool.dragging() { - // Select what's under the pointer before the context menu opens (on release). - app.session.pointer(ev(PointerKind::ContextClick)) - } else 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. The Format and Animation Painters override everything: their whole point is to - // tell the user the tool is armed and waiting for a click (#830). - let painter_armed = app.session.painter.is_some() || app.session.anim_painter.is_some(); - let cursor = if painter_armed { - egui::CursorIcon::Copy - } else { - 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 { id, handle })) => resize_cursor(&app.session, *id, *handle), - (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias, - (_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn, - (_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow, - (_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | 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 { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move, - _ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) { - Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal, - Some(_) => egui::CursorIcon::ResizeVertical, - None => 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) { - // Spelling squiggles under misspelt words (checked in the background). - if let Some(st) = app.session.active() { - let lines = app.squiggles.update(st, Some(ui.ctx())); - let scale = (xf.to_screen(Point::new(1.0, 0.0)) - xf.to_screen(Point::new(0.0, 0.0))).length(); - for (a, b) in lines { - crate::squiggles::paint(painter, xf.to_screen(a), xf.to_screen(b), scale); - } - } - 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 app.session.tool.editing_points(st) == Some(sh.id) { - // Edit Points: the outline's vertices are the handles. - // No selection box: the outline and its vertices are what's edited. - if let Some((path, index)) = app.session.tool.edit_vertex - && let Some((hin, hout, v)) = deckcraft_engine::cmd::points::slide_handles(&st.doc, &st.selection, sh, path, index) - { - for h in [hin, hout].into_iter().flatten() { - painter.line_segment([xf.to_screen(v), xf.to_screen(h)], sel_stroke); - painter.rect( - egui::Rect::from_center_size(xf.to_screen(h), egui::vec2(6.0, 6.0)), - 0.0, - Color32::WHITE, - Stroke::new(1.0, t.selection), - egui::StrokeKind::Middle, - ); - } - } - for q in deckcraft_engine::cmd::points::slide_vertices(&st.doc, &st.selection, sh).into_iter().flatten() { - let c = xf.to_screen(q); - painter.rect( - egui::Rect::from_center_size(c, egui::vec2(7.0, 7.0)), - 0.0, - Color32::BLACK, - Stroke::new(1.0, Color32::WHITE), - egui::StrokeKind::Middle, - ); - } - continue; - } - 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))); - } - for hd in deckcraft_engine::tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { - 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, in a shape or a table cell. - if let Some(ts) = &editing { - for quad in text_marks(st, ts).iter().flat_map(|m| &m.selection) { - painter.add(egui::Shape::convex_polygon(quad.iter().map(|q| xf.to_screen(*q)).collect(), 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) { - // In the colour of the text being typed: a black caret is lost on a dark slide. - let color = deckcraft_engine::cmd::text::caret_color(st, ts).map_or(t.caret, theme::to_color32); - painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, color)); - } - } - } - // Table cell editing: outline the cell (merged cells as drawn). - if let Some(ts) = &editing - && let (Some((r, c)), Some(sh)) = (ts.cell, st.shape(ts.shape)) - && let Some(f) = deckcraft_engine::cmd::table::table_frame(st, sh) - && let Some(cr) = f.cell_rect(r, c) - { - let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)] - .map(|q| xf.to_screen(f.affine * q)); - poly(painter, pts, Stroke::new(2.0, t.accent)); - } - // Shade the selected table cells in the text-selection color, each merged cell as it is drawn. - if let Some((id, ..)) = st.selection.cells - && st.selection.text.is_none() - && let Some(sh) = st.shape(id) - && let deckcraft_model::ShapeKind::Table(table) = &sh.kind - && let Some(f) = deckcraft_engine::cmd::table::table_frame(st, sh) - { - use deckcraft_engine::cmd::table::{in_range, selected_range}; - let range = selected_range(st.selection.cells, id, table); - for (_, _, cr) in f.cells.iter().filter(|(r, c, _)| range.is_some() && in_range(range, *r, *c)) { - let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)]; - painter.add(egui::Shape::convex_polygon(pts.iter().map(|q| xf.to_screen(f.affine * *q)).collect(), t.text_selection, Stroke::NONE)); - } - } - // 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 = 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 = &app.session.tool.ink_preview; - let legacy = deckcraft_engine::model::ink_pressure_is_legacy(pts); - let c = theme::to_color32(*color); - let c = if mode == "highlighter" { c.gamma_multiply(0.45) } else { c }; - if legacy || mode == "highlighter" { - // One line, so see-through highlighter ink doesn't darken where segments meet. - let line: Vec = pts.iter().map(|(x, y, _)| xf.to_screen(Point::new(*x, *y))).collect(); - painter.add(egui::Shape::line(line, Stroke::new((*width as f32 * xf.scale).max(1.0), c))); - } else { - for pair in pts.windows(2) { - let [q0, q1] = pair else { continue }; - let a = deckcraft_engine::model::ink_sample_width(*width, q0.2, legacy); - let b = deckcraft_engine::model::ink_sample_width(*width, q1.2, legacy); - let sw = ((a + b) * 0.5 * f64::from(xf.scale)).max(1.0) as f32; - let p0 = xf.to_screen(Point::new(q0.0, q0.1)); - let p1 = xf.to_screen(Point::new(q1.0, q1.1)); - painter.add(egui::Shape::line(vec![p0, p1], Stroke::new(sw, c))); - } - } - } -} - -/// Pressure from a touch or pen this frame (`0..=1`). A mouse reports none. -fn reported_pressure(ui: &Ui) -> Option { - ui.input(|i| { - i.events.iter().rev().find_map(|e| match e { - egui::Event::Touch { force: Some(f), phase, .. } - if matches!(phase, egui::TouchPhase::Start | egui::TouchPhase::Move) && f.is_finite() => - { - Some(f.clamp(0.0, 1.0)) - } - _ => None, - }) - }) -} - -/// The text selection (quads) of the text being edited, in slide space: in a shape or a table -/// cell, wherever its text is drawn. The caret is `cmd::text::caret_line`. -pub(crate) struct TextMarks { - pub selection: Vec<[Point; 4]>, -} - -pub(crate) fn text_marks(st: &deckcraft_engine::DocState, ts: &deckcraft_engine::TextSel) -> Option { - let (l, m) = deckcraft_engine::cmd::text::text_frame(st, ts)?; - let pos = |p: (usize, usize)| deckcraft_text::Pos { para: p.0, ch: p.1 }; - let (s0, s1) = ts.ordered(); - let selection = l - .selection_rects(pos(s0), pos(s1)) - .into_iter() - .map(|r| [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| m * q)) - .collect(); - Some(TextMarks { selection }) -} - -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 preset == deckcraft_engine::tools::DRAW_TABLE { - // A border inside a table (flat), or the outline of a new table. - let (a, b) = (xf.to_screen(Point::new(x.x, x.y)), xf.to_screen(Point::new(x.x + x.w, x.y + x.h))); - if x.w < 0.5 || x.h < 0.5 { - p.line_segment([a, b], Stroke::new(1.5, t.accent)); - } else { - p.rect_stroke(Rect::from_two_pos(a, b), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); - } - return; - } - 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) { - // Right-clicking a vertex in Edit Points picks it for the point menu. - if resp.secondary_clicked() - && let Some(Hit::Vertex { path, index, .. }) = app.session.tool.hover.clone() - { - app.session.tool.edit_vertex = Some((path, index)); - } - resp.context_menu(|ui| { - let editing = app.session.active().and_then(|d| app.session.tool.editing_points(d).and_then(|id| d.shape(id).cloned())); - if let Some(sh) = editing { - if let Some((path, index)) = app.session.tool.edit_segment { - let cur = deckcraft_engine::cmd::points::segment_kind_of(&sh, path, index); - for (l, k) in [("Straight Segment", "straight"), ("Curved Segment", "curved")] { - if ui.radio(cur == Some(k), l).clicked() { - let _ = app.run("shape.segmentType", json!({"path": path, "segment": index, "type": k})); - ui.close(); - } - } - // Each of these renumbers the points, so the picked segment is dropped. - if ui.button("Add Point").clicked() { - let t = app.session.tool.edit_segment_t.unwrap_or(0.5); - let _ = app.run("shape.addPoint", json!({"path": path, "segment": index, "t": t})); - app.session.tool.edit_segment = None; - ui.close(); - } - let closed = deckcraft_engine::cmd::points::closed_at(&sh, path, index); - if ui.add_enabled(closed == Some(true), egui::Button::new("Delete Segment")).clicked() { - let _ = app.run("shape.deleteSegment", json!({"path": path, "segment": index})); - app.session.tool.edit_segment = None; - ui.close(); - } - // Open Path opens the outline at the segment's start point. - let (label, id, at) = match closed { - Some(false) => ("Close Path", "shape.closePath", index), - _ => ("Open Path", "shape.openPath", deckcraft_engine::cmd::points::segment_start_of(&sh, path, index).unwrap_or(index)), - }; - if ui.button(label).clicked() { - let _ = app.run(id, json!({"path": path, "index": at})); - app.session.tool.edit_segment = None; - ui.close(); - } - ui.separator(); - } else if let Some((path, index)) = app.session.tool.edit_vertex { - let cur = deckcraft_engine::cmd::points::kind_of(&sh, path, index); - for (l, k) in [("Smooth Point", "smooth"), ("Straight Point", "straight"), ("Corner Point", "corner")] { - if ui.radio(cur == Some(k), l).clicked() { - let _ = app.run("shape.pointType", json!({"path": path, "index": index, "type": k})); - ui.close(); - } - } - if ui.button("Delete Point").clicked() { - let _ = app.run("shape.deletePoint", json!({"path": path, "index": index})); - app.session.tool.edit_vertex = None; - ui.close(); - } - let (label, id) = match deckcraft_engine::cmd::points::closed_at(&sh, path, index) { - Some(false) => ("Close Path", "shape.closePath"), - _ => ("Open Path", "shape.openPath"), - }; - if ui.button(label).clicked() { - let _ = app.run(id, json!({"path": path, "index": index})); - // The points are numbered from the new start now. - app.session.tool.edit_vertex = None; - ui.close(); - } - ui.separator(); - } - if ui.button("Exit Edit Points").clicked() { - let _ = app.run("shape.editPoints", json!({"on": false})); - ui.close(); - } - return; - } - // A misspelt word under the pointer: its suggestions, then Ignore All and Add to Dictionary. - if let Some(w) = app.squiggles.menu_word.clone() { - if let Some(from) = w.delete_from { - if ui.button(egui::RichText::new("Delete Repeated Word").strong()).clicked() { - let cut: String = format!("{}{}", " ".repeat(w.start - from), w.word); - // The gap is spaces; take it from the text so tabs or no-break spaces match. - let gap = app - .session - .active() - .and_then(|st| match w.tag { - crate::squiggles::Tag::Shape { id, para } => st.shape(id)?.text.as_ref()?.paragraphs.get(para).map(|p| p.text()), - crate::squiggles::Tag::Cell { id, row, col, para } => match &st.shape(id)?.kind { - deckcraft_model::ShapeKind::Table(tb) => tb.cell(row, col)?.text.paragraphs.get(para).map(|p| p.text()), - _ => None, - }, - crate::squiggles::Tag::Notes { .. } => None, - }) - .map(|t| t.chars().skip(from).take(w.start + w.word.chars().count() - from).collect::()) - .unwrap_or(cut); - let mut p = json!({"para": 0, "start": from, "word": gap, "to": ""}); - if let Some(o) = p.as_object_mut() { - match w.tag { - crate::squiggles::Tag::Shape { id, para } => { - o.insert("shape".into(), json!(id)); - o.insert("para".into(), json!(para)); - } - crate::squiggles::Tag::Cell { id, row, col, para } => { - o.insert("shape".into(), json!(id)); - o.insert("row".into(), json!(row)); - o.insert("col".into(), json!(col)); - o.insert("para".into(), json!(para)); - } - crate::squiggles::Tag::Notes { .. } => {} - } - } - let _ = app.run("review.spellingChange", p); - app.squiggles.menu_word = None; - ui.close(); - } - } else if w.suggestions.is_empty() { - ui.label(egui::RichText::new("(no spelling suggestions)").weak()); - } - for to in &w.suggestions { - if ui.button(egui::RichText::new(to).strong()).clicked() { - let p = match w.tag { - crate::squiggles::Tag::Shape { id, para } => json!({"shape": id, "para": para, "start": w.start, "word": w.word, "to": to}), - crate::squiggles::Tag::Cell { id, row, col, para } => { - json!({"shape": id, "row": row, "col": col, "para": para, "start": w.start, "word": w.word, "to": to}) - } - crate::squiggles::Tag::Notes { .. } => json!({}), - }; - let _ = app.run("review.spellingChange", p); - app.squiggles.menu_word = None; - ui.close(); - } - } - if ui.button("Ignore All").clicked() { - app.squiggles.ignore(&w.word); - app.squiggles.menu_word = None; - ui.close(); - } - if ui.button("Add to Dictionary").clicked() { - if let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w.word})) { - app.set_status(e.to_string()); - } - app.squiggles.menu_word = None; - ui.close(); - } - ui.separator(); - } - 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(); - // 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() { - 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() { - let _ = app.run(id, json!({})); - ui.close(); - } - } - if ui.button("Link…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); - ui.close(); - } - ui.separator(); - if ui.button("Format Shape…").clicked() { - let _ = app.run("view.pane", json!({"pane": "format"})); - ui.close(); - } - } else if has_sel { - if ui.button("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() - { - let _ = app.run("shape.editPoints", json!({"on": true})); - ui.close(); - } - if ui.button("Font…").clicked() { - let _ = app.run("format.fontDialog", json!({})); - ui.close(); - } - if ui.button("Paragraph…").clicked() { - let _ = app.run("format.paragraphDialog", 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(); - } - 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() { - let _ = app.run("app.changePictureDialog", json!({})); - 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("Size and Position…").clicked() { - let _ = app.run("shape.sizePosition", 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(); - } - } - }); - ui.menu_button("Grid and Guides", |ui| { - let mut on = app.ui.guides; - if ui.checkbox(&mut on, "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() { - let _ = app.run("guide.add", json!({"vertical": vertical})); - let _ = app.run("view.guides", json!({"on": true})); - 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(); - } - } - }); -} - -/// Screen-point distance between grid lines. `spacing` is the grid spacing in slide points; -/// `scale` maps slide points onto the screen. `None` when the grid would not be a finite set of -/// lines (bad inputs, or so fine that a line would land on every pixel). -fn grid_line_step(spacing: f64, scale: f32) -> Option { - if !spacing.is_finite() || !(1.0..=288.0).contains(&spacing) || !scale.is_finite() || scale <= 0.0 { - return None; - } - let step = (spacing as f32) * scale; - if !step.is_finite() || step < 1.0 { - return None; - } - Some(step) -} - -fn draw_grid(painter: &egui::Painter, slide: Rect, step: f32) { - let stroke = Stroke::new(0.5, Color32::from_black_alpha(40)); - // A denser grid than this paints a grey slab and costs a frame. Skip it. - if !grid_fits(slide.width(), step) || !grid_fits(slide.height(), step) { - return; - } - let mut x = slide.min.x + step; - while x < slide.max.x { - painter.line_segment([pos2(x, slide.min.y), pos2(x, slide.max.y)], stroke); - x += step; - } - let mut y = slide.min.y + step; - while y < slide.max.y { - painter.line_segment([pos2(slide.min.x, y), pos2(slide.max.x, y)], stroke); - y += step; - } -} - -fn grid_fits(span: f32, step: f32) -> bool { - span.is_finite() && span > 0.0 && step >= 1.0 && span / step <= 800.0 -} - -/// The resize cursor for `handle` of shape `id`: the handle's resize axis is computed in the -/// shape's own space and mapped through its transform, so a rotated or flipped shape shows the -/// diagonal that matches where the drag actually goes (#745). Without a transform the mapping -/// reduces to the classic NW/NE/E/SE/S/SW/W assignment. -fn resize_cursor(session: &deckcraft_engine::Session, id: deckcraft_model::ShapeId, handle: u8) -> egui::CursorIcon { - // The resize handles are drawn from the shape's effective box (a placeholder inherits it - // from its layout), so the cursor follows the same one. - let Some(d) = session.active() else { return egui::CursorIcon::Default }; - let Some(sh) = d.shape(id) else { return egui::CursorIcon::Default }; - let x = deckcraft_engine::cmd::xfrm_of(&d.doc, &d.selection, sh); - // The resize axis in the shape's own coordinates, as (dx, dy) from the box centre toward - // the handle. Corners resize along the diagonal, edge handles along their edge. The - // direction is normalised - the diagonal at 45 degrees whatever the shape's proportions - - // so a wide rectangle still shows a diagonal cursor on its corners. - let (dx, dy): (f64, f64) = match handle { - 0 => (-1.0, -1.0), - 1 => (0.0, -1.0), - 2 => (1.0, -1.0), - 3 => (1.0, 0.0), - 4 => (1.0, 1.0), - 5 => (0.0, 1.0), - 6 => (-1.0, 1.0), - _ => (-1.0, 0.0), - }; - // Map through the linear part of the transform (rotation, flips): two points along the - // axis, the difference is the slide-space direction. - let centre = Point::new(x.x + x.w / 2.0, x.y + x.h / 2.0); - let a = x.affine(); - let p0 = a * centre; - let p1 = a * Point::new(centre.x + dx, centre.y + dy); - let v = p1 - p0; - if !v.x.is_finite() || !v.y.is_finite() || (v.x.abs() < 1e-12 && v.y.abs() < 1e-12) { - return egui::CursorIcon::Default; - } - // Angle in [0, 180): 0° = right (horizontal), 45° = down-right (NW-SE), 90° = down - // (vertical), 135° = down-left (NE-SW). Pick the closest of the four. - let mut deg = v.y.atan2(v.x).to_degrees(); - if deg < 0.0 { - deg += 180.0; - } - if (22.5..67.5).contains(°) { - egui::CursorIcon::ResizeNwSe - } else if (67.5..112.5).contains(°) { - egui::CursorIcon::ResizeVertical - } else if (112.5..157.5).contains(°) { - egui::CursorIcon::ResizeNeSw - } else { - egui::CursorIcon::ResizeHorizontal - } -} - pub(super) mod preview; pub mod squiggles; #[cfg(test)] diff --git a/crates/ui-egui/src/canvas/context_menu.rs b/crates/ui-egui/src/canvas/context_menu.rs new file mode 100644 index 00000000..260838eb --- /dev/null +++ b/crates/ui-egui/src/canvas/context_menu.rs @@ -0,0 +1,282 @@ +use super::*; + +pub(super) fn context_menu(app: &mut SlideApp, resp: &egui::Response) { + // Right-clicking a vertex in Edit Points picks it for the point menu. + if resp.secondary_clicked() + && let Some(Hit::Vertex { path, index, .. }) = app.session.tool.hover.clone() + { + app.session.tool.edit_vertex = Some((path, index)); + } + resp.context_menu(|ui| { + let editing = app.session.active().and_then(|d| app.session.tool.editing_points(d).and_then(|id| d.shape(id).cloned())); + if let Some(sh) = editing { + if let Some((path, index)) = app.session.tool.edit_segment { + let cur = deckcraft_engine::cmd::points::segment_kind_of(&sh, path, index); + for (l, k) in [("Straight Segment", "straight"), ("Curved Segment", "curved")] { + if ui.radio(cur == Some(k), l).clicked() { + let _ = app.run("shape.segmentType", json!({"path": path, "segment": index, "type": k})); + ui.close(); + } + } + // Each of these renumbers the points, so the picked segment is dropped. + if ui.button("Add Point").clicked() { + let t = app.session.tool.edit_segment_t.unwrap_or(0.5); + let _ = app.run("shape.addPoint", json!({"path": path, "segment": index, "t": t})); + app.session.tool.edit_segment = None; + ui.close(); + } + let closed = deckcraft_engine::cmd::points::closed_at(&sh, path, index); + if ui.add_enabled(closed == Some(true), egui::Button::new("Delete Segment")).clicked() { + let _ = app.run("shape.deleteSegment", json!({"path": path, "segment": index})); + app.session.tool.edit_segment = None; + ui.close(); + } + // Open Path opens the outline at the segment's start point. + let (label, id, at) = match closed { + Some(false) => ("Close Path", "shape.closePath", index), + _ => ("Open Path", "shape.openPath", deckcraft_engine::cmd::points::segment_start_of(&sh, path, index).unwrap_or(index)), + }; + if ui.button(label).clicked() { + let _ = app.run(id, json!({"path": path, "index": at})); + app.session.tool.edit_segment = None; + ui.close(); + } + ui.separator(); + } else if let Some((path, index)) = app.session.tool.edit_vertex { + let cur = deckcraft_engine::cmd::points::kind_of(&sh, path, index); + for (l, k) in [("Smooth Point", "smooth"), ("Straight Point", "straight"), ("Corner Point", "corner")] { + if ui.radio(cur == Some(k), l).clicked() { + let _ = app.run("shape.pointType", json!({"path": path, "index": index, "type": k})); + ui.close(); + } + } + if ui.button("Delete Point").clicked() { + let _ = app.run("shape.deletePoint", json!({"path": path, "index": index})); + app.session.tool.edit_vertex = None; + ui.close(); + } + let (label, id) = match deckcraft_engine::cmd::points::closed_at(&sh, path, index) { + Some(false) => ("Close Path", "shape.closePath"), + _ => ("Open Path", "shape.openPath"), + }; + if ui.button(label).clicked() { + let _ = app.run(id, json!({"path": path, "index": index})); + // The points are numbered from the new start now. + app.session.tool.edit_vertex = None; + ui.close(); + } + ui.separator(); + } + if ui.button("Exit Edit Points").clicked() { + let _ = app.run("shape.editPoints", json!({"on": false})); + ui.close(); + } + return; + } + // A misspelt word under the pointer: its suggestions, then Ignore All and Add to Dictionary. + if let Some(w) = app.squiggles.menu_word.clone() { + if let Some(from) = w.delete_from { + if ui.button(egui::RichText::new("Delete Repeated Word").strong()).clicked() { + let cut: String = format!("{}{}", " ".repeat(w.start - from), w.word); + // The gap is spaces; take it from the text so tabs or no-break spaces match. + let gap = app + .session + .active() + .and_then(|st| match w.tag { + crate::squiggles::Tag::Shape { id, para } => st.shape(id)?.text.as_ref()?.paragraphs.get(para).map(|p| p.text()), + crate::squiggles::Tag::Cell { id, row, col, para } => match &st.shape(id)?.kind { + deckcraft_model::ShapeKind::Table(tb) => tb.cell(row, col)?.text.paragraphs.get(para).map(|p| p.text()), + _ => None, + }, + crate::squiggles::Tag::Notes { .. } => None, + }) + .map(|t| t.chars().skip(from).take(w.start + w.word.chars().count() - from).collect::()) + .unwrap_or(cut); + let mut p = json!({"para": 0, "start": from, "word": gap, "to": ""}); + if let Some(o) = p.as_object_mut() { + match w.tag { + crate::squiggles::Tag::Shape { id, para } => { + o.insert("shape".into(), json!(id)); + o.insert("para".into(), json!(para)); + } + crate::squiggles::Tag::Cell { id, row, col, para } => { + o.insert("shape".into(), json!(id)); + o.insert("row".into(), json!(row)); + o.insert("col".into(), json!(col)); + o.insert("para".into(), json!(para)); + } + crate::squiggles::Tag::Notes { .. } => {} + } + } + let _ = app.run("review.spellingChange", p); + app.squiggles.menu_word = None; + ui.close(); + } + } else if w.suggestions.is_empty() { + ui.label(egui::RichText::new("(no spelling suggestions)").weak()); + } + for to in &w.suggestions { + if ui.button(egui::RichText::new(to).strong()).clicked() { + let p = match w.tag { + crate::squiggles::Tag::Shape { id, para } => json!({"shape": id, "para": para, "start": w.start, "word": w.word, "to": to}), + crate::squiggles::Tag::Cell { id, row, col, para } => { + json!({"shape": id, "row": row, "col": col, "para": para, "start": w.start, "word": w.word, "to": to}) + } + crate::squiggles::Tag::Notes { .. } => json!({}), + }; + let _ = app.run("review.spellingChange", p); + app.squiggles.menu_word = None; + ui.close(); + } + } + if ui.button("Ignore All").clicked() { + app.squiggles.ignore(&w.word); + app.squiggles.menu_word = None; + ui.close(); + } + if ui.button("Add to Dictionary").clicked() { + if let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w.word})) { + app.set_status(e.to_string()); + } + app.squiggles.menu_word = None; + ui.close(); + } + ui.separator(); + } + 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(); + // 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() { + 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() { + let _ = app.run(id, json!({})); + ui.close(); + } + } + if ui.button("Link…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); + ui.close(); + } + ui.separator(); + if ui.button("Format Shape…").clicked() { + let _ = app.run("view.pane", json!({"pane": "format"})); + ui.close(); + } + } else if has_sel { + if ui.button("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() + { + let _ = app.run("shape.editPoints", json!({"on": true})); + ui.close(); + } + if ui.button("Font…").clicked() { + let _ = app.run("format.fontDialog", json!({})); + ui.close(); + } + if ui.button("Paragraph…").clicked() { + let _ = app.run("format.paragraphDialog", 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(); + } + 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() { + let _ = app.run("app.changePictureDialog", json!({})); + 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("Size and Position…").clicked() { + let _ = app.run("shape.sizePosition", 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(); + } + } + }); + ui.menu_button("Grid and Guides", |ui| { + let mut on = app.ui.guides; + if ui.checkbox(&mut on, "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() { + let _ = app.run("guide.add", json!({"vertical": vertical})); + let _ = app.run("view.guides", json!({"on": true})); + 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(); + } + } + }); +} diff --git a/crates/ui-egui/src/canvas/guides.rs b/crates/ui-egui/src/canvas/guides.rs new file mode 100644 index 00000000..be23d2ee --- /dev/null +++ b/crates/ui-egui/src/canvas/guides.rs @@ -0,0 +1,85 @@ +use super::*; + +/// The presentation's drawing guides; the one being dragged at its new place, labelled with its +/// distance from the slide's centre in points. +pub(super) fn draw_guides(app: &SlideApp, painter: &egui::Painter, xf: Xf, slide: Rect, t: &Tokens) { + let Some(st) = app.session.active() else { return }; + let size = st.doc.slide_size; + let drag = app.session.tool.guide_drag; + for (i, g) in st.doc.guides.iter().enumerate() { + let (pos, active) = match drag { + Some((di, p)) if di == i => (p, true), + _ => (g.pos, app.session.tool.guide_hover == Some(i)), + }; + let col = if active { t.accent } else { Color32::from_rgba_unmultiplied(120, 120, 120, 170) }; + let stroke = Stroke::new(1.0, col); + let (a, b) = if g.vertical { + let x = xf.to_screen(Point::new(pos, 0.0)).x; + (pos2(x, slide.min.y), pos2(x, slide.max.y)) + } else { + let y = xf.to_screen(Point::new(0.0, pos)).y; + (pos2(slide.min.x, y), pos2(slide.max.x, y)) + }; + // Dashed, like PowerPoint's guides. + let len = (b - a).length(); + let dir = (b - a) / len.max(1.0); + let mut d = 0.0; + while d < len { + painter.line_segment([a + dir * d, a + dir * (d + 4.0).min(len)], stroke); + d += 7.0; + } + if drag.is_some_and(|(di, _)| di == i) { + let centre = if g.vertical { size.width / 2.0 } else { size.height / 2.0 }; + let off = pos - centre; + let side = match (g.vertical, off < 0.0, off > 0.0) { + (true, true, _) => " left of centre", + (true, _, true) => " right of centre", + (false, true, _) => " above centre", + (false, _, true) => " below centre", + _ => " (centre)", + }; + let label = format!("{:.0} pt{side}", off.abs()); + let at = if g.vertical { pos2(a.x + 6.0, slide.min.y + 6.0) } else { pos2(slide.min.x + 6.0, a.y + 4.0) }; + let galley = painter.layout_no_wrap(label, theme::font(11.0), t.text); + let r = Rect::from_min_size(at, galley.size()).expand(3.0); + painter.rect_filled(r, CornerRadius::same(3), t.card); + painter.galley(at, galley, t.text); + } + } +} + +/// Screen-point distance between grid lines. `spacing` is the grid spacing in slide points; +/// `scale` maps slide points onto the screen. `None` when the grid would not be a finite set of +/// lines (bad inputs, or so fine that a line would land on every pixel). +pub(super) fn grid_line_step(spacing: f64, scale: f32) -> Option { + if !spacing.is_finite() || !(1.0..=288.0).contains(&spacing) || !scale.is_finite() || scale <= 0.0 { + return None; + } + let step = (spacing as f32) * scale; + if !step.is_finite() || step < 1.0 { + return None; + } + Some(step) +} + +pub(super) fn draw_grid(painter: &egui::Painter, slide: Rect, step: f32) { + let stroke = Stroke::new(0.5, Color32::from_black_alpha(40)); + // A denser grid than this paints a grey slab and costs a frame. Skip it. + if !grid_fits(slide.width(), step) || !grid_fits(slide.height(), step) { + return; + } + let mut x = slide.min.x + step; + while x < slide.max.x { + painter.line_segment([pos2(x, slide.min.y), pos2(x, slide.max.y)], stroke); + x += step; + } + let mut y = slide.min.y + step; + while y < slide.max.y { + painter.line_segment([pos2(slide.min.x, y), pos2(slide.max.x, y)], stroke); + y += step; + } +} + +fn grid_fits(span: f32, step: f32) -> bool { + span.is_finite() && span > 0.0 && step >= 1.0 && span / step <= 800.0 +} diff --git a/crates/ui-egui/src/canvas/ime.rs b/crates/ui-egui/src/canvas/ime.rs new file mode 100644 index 00000000..ef752efa --- /dev/null +++ b/crates/ui-egui/src/canvas/ime.rs @@ -0,0 +1,43 @@ +use super::*; + +/// 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 { + 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. +/// +/// This only records the screen rect for this pass. [`crate::SlideApp`] observes the committed +/// text, owner and cause once at the end of the pass, after every IME event and editor command +/// has settled. +pub(super) fn ime_output(app: &mut 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.backstage_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(); + app.ime_pending = Some(to_global * r); +} diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs new file mode 100644 index 00000000..4cfd6218 --- /dev/null +++ b/crates/ui-egui/src/canvas/input.rs @@ -0,0 +1,237 @@ +use super::*; + +/// A two-finger twist on the trackpad. macOS delivers it as [`egui::Event::Rotate`]; +/// other platforms leave the delta at zero. +/// +/// The selected shapes turn with the fingers. The whole twist is one undo step. Pinch-zoom +/// and two-finger scrolling are left as they are. +pub(crate) fn trackpad_twist(app: &mut SlideApp, ctx: &egui::Context) { + if ctx.will_discard() { + // Multi-pass layout runs this function again with the input already consumed. Acting on + // the discarded pass would start the twist and the replay would immediately end it, + // splitting one gesture into two undo steps. + return; + } + let delta = ctx.input(|i| i.rotation_delta()); + let delta = if delta.is_finite() { delta.clamp(-std::f32::consts::PI, std::f32::consts::PI) } else { 0.0 }; + let turning = delta.abs() > 1e-4; + let blocked = app.show.is_some() + || app.backstage_open() + || app.dialog.as_ref().is_some_and(|d| d.modal()) + || app.palette.is_some() + || app.session.tool.dragging(); + let over = app.canvas_rect.is_some_and(|r| ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p))); + let rotatable = app.session.active().is_some_and(|d| { + d.selection.text.is_none() && d.selection.shapes.iter().any(|id| d.shape(*id).is_some_and(|sh| !sh.locked && sh.xfrm.is_some())) + }); + if !blocked && turning && rotatable && (app.twisting || over) { + let _ = app.run("shape.rotate", json!({"by": f64::from(delta.to_degrees()), "gesture": "move"})); + app.twisting = true; + } else if app.twisting { + let _ = app.run("shape.rotate", json!({"gesture": "end", "ids": []})); + app.twisting = false; + } +} + +/// A trackpad flick or mouse-wheel notch that should turn the page. +/// +/// The stronger axis wins, so a sideways swipe is not read as a vertical scroll. Negative +/// movement — the content moves left or up, which is a swipe left or up with natural scrolling — +/// is the next slide. A nudge under 18 points is ignored so a resting finger does not change slides. +pub(crate) fn page_flick(wheel: egui::Vec2) -> Option { + const MIN_FLICK: f32 = 18.0; + let along = if wheel.x.abs() > wheel.y.abs() { wheel.x } else { wheel.y }; + (along.abs() > MIN_FLICK).then_some(along < 0.0) +} + +/// Discrete wheel input must not share the trackpad's gesture cooldown. Keep all the steps, +/// including several notches delivered in one frame, and clamp at each end of the deck. +/// +/// High-resolution and free-spin wheels send fractions of a line (0.125 per event on Windows), so +/// the delta is accumulated in `carry` and each whole line is one slide; a change of direction +/// drops what was left over. Returns the target, `None` when no wheel event was read. +pub(super) fn wheel_slide_target(events: &[egui::Event], current: usize, slides: usize, carry: &mut f32) -> Option { + let last = slides.checked_sub(1)?; + let mut target = current.min(last); + let mut handled = false; + for event in events { + if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event + && matches!(unit, egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page) + && !modifiers.ctrl + && !modifiers.command + && !modifiers.shift + && delta.y.is_finite() + && delta.y.abs() > 0.0 + && delta.y.abs() >= delta.x.abs() + { + if *carry * delta.y < 0.0 { + *carry = 0.0; + } + *carry += delta.y; + // The small slack keeps sums such as 10 × 0.1 from falling just short of a line. + let whole = (carry.abs() + 1e-4).floor(); + *carry -= whole.copysign(*carry); + let steps = whole.min(slides as f32) as usize; + target = if delta.y < 0.0 { target.saturating_add(steps).min(last) } else { target.saturating_sub(steps) }; + handled = true; + } + } + handled.then_some(target) +} + +pub(super) fn trackpad_scroll(events: &[egui::Event]) -> egui::Vec2 { + events.iter().fold(egui::Vec2::ZERO, |sum, event| match event { + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers, .. } if !modifiers.ctrl && !modifiers.command => sum + *delta, + _ => sum, + }) +} + +pub(super) fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { + if crate::eyedropper::canvas(app, ui, resp, xf) { + return; + } + if app.anim_preview_active() { + // A click on the slide ends the preview, then selects as usual. + if resp.hovered() && ui.input(|i| i.pointer.primary_pressed()) { + app.stop_anim_preview(); + } else { + return; + } + } + 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 }; + // The rulers have their own controls; a press there mustn't reach the slide (and end text + // editing). A drag already under way keeps going over them. + if app.ui.ruler && !app.session.tool.dragging() && (pos.y < resp.rect.min.y + 16.0 || pos.x < resp.rect.min.x + 16.0) { + return; + } + let p = xf.to_slide(pos); + let pressure = reported_pressure(ui); + let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol, pressure }; + let primary = ui.input(|i| i.pointer.primary_pressed()); + if resp.hovered() && !app.session.tool.dragging() { + let _ = app.session.pointer(ev(PointerKind::Move)); + } + let secondary = resp.hovered() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Secondary)); + let r = if secondary && !app.session.tool.dragging() { + // Select what's under the pointer before the context menu opens (on release). + app.session.pointer(ev(PointerKind::ContextClick)) + } else 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. The Format and Animation Painters override everything: their whole point is to + // tell the user the tool is armed and waiting for a click (#830). + let painter_armed = app.session.painter.is_some() || app.session.anim_painter.is_some(); + let cursor = if painter_armed { + egui::CursorIcon::Copy + } else { + 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 { id, handle })) => resize_cursor(&app.session, *id, *handle), + (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias, + (_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn, + (_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow, + (_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | 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 { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move, + _ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) { + Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal, + Some(_) => egui::CursorIcon::ResizeVertical, + None => egui::CursorIcon::Default, + }, + } + }; + if resp.hovered() { + ui.ctx().set_cursor_icon(cursor); + } + if app.session.tool.dragging() { + ui.ctx().request_repaint(); + } +} + +/// Pressure from a touch or pen this frame (`0..=1`). A mouse reports none. +fn reported_pressure(ui: &Ui) -> Option { + ui.input(|i| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::Touch { force: Some(f), phase, .. } + if matches!(phase, egui::TouchPhase::Start | egui::TouchPhase::Move) && f.is_finite() => + { + Some(f.clamp(0.0, 1.0)) + } + _ => None, + }) + }) +} + +/// The resize cursor for `handle` of shape `id`: the handle's resize axis is computed in the +/// shape's own space and mapped through its transform, so a rotated or flipped shape shows the +/// diagonal that matches where the drag actually goes (#745). Without a transform the mapping +/// reduces to the classic NW/NE/E/SE/S/SW/W assignment. +pub(super) fn resize_cursor(session: &deckcraft_engine::Session, id: deckcraft_model::ShapeId, handle: u8) -> egui::CursorIcon { + // The resize handles are drawn from the shape's effective box (a placeholder inherits it + // from its layout), so the cursor follows the same one. + let Some(d) = session.active() else { return egui::CursorIcon::Default }; + let Some(sh) = d.shape(id) else { return egui::CursorIcon::Default }; + let x = deckcraft_engine::cmd::xfrm_of(&d.doc, &d.selection, sh); + // The resize axis in the shape's own coordinates, as (dx, dy) from the box centre toward + // the handle. Corners resize along the diagonal, edge handles along their edge. The + // direction is normalised - the diagonal at 45 degrees whatever the shape's proportions - + // so a wide rectangle still shows a diagonal cursor on its corners. + let (dx, dy): (f64, f64) = match handle { + 0 => (-1.0, -1.0), + 1 => (0.0, -1.0), + 2 => (1.0, -1.0), + 3 => (1.0, 0.0), + 4 => (1.0, 1.0), + 5 => (0.0, 1.0), + 6 => (-1.0, 1.0), + _ => (-1.0, 0.0), + }; + // Map through the linear part of the transform (rotation, flips): two points along the + // axis, the difference is the slide-space direction. + let centre = Point::new(x.x + x.w / 2.0, x.y + x.h / 2.0); + let a = x.affine(); + let p0 = a * centre; + let p1 = a * Point::new(centre.x + dx, centre.y + dy); + let v = p1 - p0; + if !v.x.is_finite() || !v.y.is_finite() || (v.x.abs() < 1e-12 && v.y.abs() < 1e-12) { + return egui::CursorIcon::Default; + } + // Angle in [0, 180): 0° = right (horizontal), 45° = down-right (NW-SE), 90° = down + // (vertical), 135° = down-left (NE-SW). Pick the closest of the four. + let mut deg = v.y.atan2(v.x).to_degrees(); + if deg < 0.0 { + deg += 180.0; + } + if (22.5..67.5).contains(°) { + egui::CursorIcon::ResizeNwSe + } else if (67.5..112.5).contains(°) { + egui::CursorIcon::ResizeVertical + } else if (112.5..157.5).contains(°) { + egui::CursorIcon::ResizeNeSw + } else { + egui::CursorIcon::ResizeHorizontal + } +} diff --git a/crates/ui-egui/src/canvas/media.rs b/crates/ui-egui/src/canvas/media.rs new file mode 100644 index 00000000..a859beb8 --- /dev/null +++ b/crates/ui-egui/src/canvas/media.rs @@ -0,0 +1,75 @@ +use super::*; + +/// Play/pause on an animated GIF under the pointer or selected on its own. +pub(super) 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.scope_id().with(("gif_button", g.id.0)), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, if g.paused { "Play animated GIF" } else { "Pause animated GIF" })); + 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})); + } +} + +/// Video playing in the editor: its current frame over the poster. +pub(super) 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), clip.crop); + } + } +} + +/// The play/pause and timeline bar under a selected audio or video (PowerPoint shows it on +/// selection or hover). +pub(super) fn media_bar(app: &mut SlideApp, ui: &mut Ui, xf: Xf) { + let Some(d) = app.session.active() else { return }; + let uid = d.uid; + 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(uid, &doc, id, &clip, crate::media::Owner::Editor), + Some(crate::media::BarAction::Seek(t)) => app.media.seek(uid, &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(uid, &doc, id, &clip, (pos + dt).max(0.0), crate::media::Owner::Editor); + } + None => {} + } +} diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs new file mode 100644 index 00000000..94c731b8 --- /dev/null +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -0,0 +1,283 @@ +use super::*; + +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); + } +} + +pub(super) fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &Tokens) { + // Spelling squiggles under misspelt words (checked in the background). + if let Some(st) = app.session.active() { + let lines = app.squiggles.update(st, Some(ui.ctx())); + let scale = (xf.to_screen(Point::new(1.0, 0.0)) - xf.to_screen(Point::new(0.0, 0.0))).length(); + for (a, b) in lines { + crate::squiggles::paint(painter, xf.to_screen(a), xf.to_screen(b), scale); + } + } + 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 app.session.tool.editing_points(st) == Some(sh.id) { + // Edit Points: the outline's vertices are the handles. + // No selection box: the outline and its vertices are what's edited. + if let Some((path, index)) = app.session.tool.edit_vertex + && let Some((hin, hout, v)) = deckcraft_engine::cmd::points::slide_handles(&st.doc, &st.selection, sh, path, index) + { + for h in [hin, hout].into_iter().flatten() { + painter.line_segment([xf.to_screen(v), xf.to_screen(h)], sel_stroke); + painter.rect( + egui::Rect::from_center_size(xf.to_screen(h), egui::vec2(6.0, 6.0)), + 0.0, + Color32::WHITE, + Stroke::new(1.0, t.selection), + egui::StrokeKind::Middle, + ); + } + } + for q in deckcraft_engine::cmd::points::slide_vertices(&st.doc, &st.selection, sh).into_iter().flatten() { + let c = xf.to_screen(q); + painter.rect( + egui::Rect::from_center_size(c, egui::vec2(7.0, 7.0)), + 0.0, + Color32::BLACK, + Stroke::new(1.0, Color32::WHITE), + egui::StrokeKind::Middle, + ); + } + continue; + } + 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))); + } + for hd in deckcraft_engine::tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { + 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, in a shape or a table cell. + if let Some(ts) = &editing { + for quad in text_marks(st, ts).iter().flat_map(|m| &m.selection) { + painter.add(egui::Shape::convex_polygon(quad.iter().map(|q| xf.to_screen(*q)).collect(), 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) { + // In the colour of the text being typed: a black caret is lost on a dark slide. + let color = deckcraft_engine::cmd::text::caret_color(st, ts).map_or(t.caret, theme::to_color32); + painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, color)); + } + } + } + // Table cell editing: outline the cell (merged cells as drawn). + if let Some(ts) = &editing + && let (Some((r, c)), Some(sh)) = (ts.cell, st.shape(ts.shape)) + && let Some(f) = deckcraft_engine::cmd::table::table_frame(st, sh) + && let Some(cr) = f.cell_rect(r, c) + { + let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)] + .map(|q| xf.to_screen(f.affine * q)); + poly(painter, pts, Stroke::new(2.0, t.accent)); + } + // Shade the selected table cells in the text-selection color, each merged cell as it is drawn. + if let Some((id, ..)) = st.selection.cells + && st.selection.text.is_none() + && let Some(sh) = st.shape(id) + && let deckcraft_model::ShapeKind::Table(table) = &sh.kind + && let Some(f) = deckcraft_engine::cmd::table::table_frame(st, sh) + { + use deckcraft_engine::cmd::table::{in_range, selected_range}; + let range = selected_range(st.selection.cells, id, table); + for (_, _, cr) in f.cells.iter().filter(|(r, c, _)| range.is_some() && in_range(range, *r, *c)) { + let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)]; + painter.add(egui::Shape::convex_polygon(pts.iter().map(|q| xf.to_screen(f.affine * *q)).collect(), t.text_selection, Stroke::NONE)); + } + } + // 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 = 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 = &app.session.tool.ink_preview; + let legacy = deckcraft_engine::model::ink_pressure_is_legacy(pts); + let c = theme::to_color32(*color); + let c = if mode == "highlighter" { c.gamma_multiply(0.45) } else { c }; + if legacy || mode == "highlighter" { + // One line, so see-through highlighter ink doesn't darken where segments meet. + let line: Vec = pts.iter().map(|(x, y, _)| xf.to_screen(Point::new(*x, *y))).collect(); + painter.add(egui::Shape::line(line, Stroke::new((*width as f32 * xf.scale).max(1.0), c))); + } else { + for pair in pts.windows(2) { + let [q0, q1] = pair else { continue }; + let a = deckcraft_engine::model::ink_sample_width(*width, q0.2, legacy); + let b = deckcraft_engine::model::ink_sample_width(*width, q1.2, legacy); + let sw = ((a + b) * 0.5 * f64::from(xf.scale)).max(1.0) as f32; + let p0 = xf.to_screen(Point::new(q0.0, q0.1)); + let p1 = xf.to_screen(Point::new(q1.0, q1.1)); + painter.add(egui::Shape::line(vec![p0, p1], Stroke::new(sw, c))); + } + } + } +} + +/// The text selection (quads) of the text being edited, in slide space: in a shape or a table +/// cell, wherever its text is drawn. The caret is `cmd::text::caret_line`. +pub(crate) struct TextMarks { + pub selection: Vec<[Point; 4]>, +} + +pub(crate) fn text_marks(st: &deckcraft_engine::DocState, ts: &deckcraft_engine::TextSel) -> Option { + let (l, m) = deckcraft_engine::cmd::text::text_frame(st, ts)?; + let pos = |p: (usize, usize)| deckcraft_text::Pos { para: p.0, ch: p.1 }; + let (s0, s1) = ts.ordered(); + let selection = l + .selection_rects(pos(s0), pos(s1)) + .into_iter() + .map(|r| [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| m * q)) + .collect(); + Some(TextMarks { selection }) +} + +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 preset == deckcraft_engine::tools::DRAW_TABLE { + // A border inside a table (flat), or the outline of a new table. + let (a, b) = (xf.to_screen(Point::new(x.x, x.y)), xf.to_screen(Point::new(x.x + x.w, x.y + x.h))); + if x.w < 0.5 || x.h < 0.5 { + p.line_segment([a, b], Stroke::new(1.5, t.accent)); + } else { + p.rect_stroke(Rect::from_two_pos(a, b), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + return; + } + 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); +} diff --git a/crates/ui-egui/src/canvas/rulers.rs b/crates/ui-egui/src/canvas/rulers.rs new file mode 100644 index 00000000..bb8cb71a --- /dev/null +++ b/crates/ui-egui/src/canvas/rulers.rs @@ -0,0 +1,235 @@ +use super::*; + +/// The ruler's numbered step in points and the ticks per step: quarter inches, or half +/// centimetres. Any other unit (only a hand-edited `prefs.json` gives one) keeps inches, since a +/// tick per point or pixel would be a solid bar. +pub(crate) fn ruler_steps(unit: deckcraft_geom::Unit) -> (f64, u32) { + match unit { + deckcraft_geom::Unit::Centimeters => (unit.points_per_unit(), 2), + _ => (72.0, 4), + } +} + +pub(super) fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, unit: deckcraft_geom::Unit, 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 (step, sub) = ruler_steps(unit); + let tick = step as f32 * scale / sub as f32; + let len = |k: u32| { + if k.is_multiple_of(sub) { + 8.0 + } else if k.is_multiple_of(2) { + 5.0 + } else { + 3.0 + } + }; + let mut k = 0; + loop { + let x = slide.min.x + k as f32 * tick; + if x > slide.max.x { + break; + } + let h = len(k); + p.line_segment([pos2(x, top.max.y - h), pos2(x, top.max.y)], Stroke::new(1.0, t.text_faint)); + if k.is_multiple_of(sub) { + p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, format!("{}", k / sub), theme::font(8.0), t.text_dim); + } + k += 1; + } + let mut k = 0; + loop { + let y = slide.min.y + k as f32 * tick; + if y > slide.max.y { + break; + } + let w = len(k); + p.line_segment([pos2(left.max.x - w, y), pos2(left.max.x, y)], Stroke::new(1.0, t.text_faint)); + k += 1; + } +} + +/// What is being dragged on the ruler. +#[derive(Clone, Copy, Debug, PartialEq)] +enum RulerDrag { + /// First-line indent (the top triangle). + First, + /// Hanging indent (the bottom triangle): moves the left indent, the first line stays put. + Hanging, + /// Left indent (the box under the hanging triangle): moves both together. + Left, + /// The tab stop that was at this position (pt from the text area's left). + Tab(f64), +} + +/// The paragraph's indents and tab stops on the horizontal ruler while text is being edited, +/// draggable as in PowerPoint; a click on the ruler adds a left tab, dragging a tab off the +/// ruler removes it. +pub(super) fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect, xf: Xf, t: &Tokens) { + if app.session.active().is_none_or(|d| d.selection.text.is_none()) { + return; + } + let Ok(info) = app.session.execute("format.ruler", &json!({})) else { return }; + if info["rotated"].as_bool().unwrap_or(true) { + return; + } + let f = |k: &str| info[k].as_f64().filter(|v| v.is_finite()).unwrap_or(0.0); + let (x0, width, ml, ind) = (f("x"), f("width"), f("marginLeft"), f("indent")); + let tabs: Vec<(f64, String)> = info["tabs"] + .as_array() + .map(|a| a.iter().filter_map(|t| Some((t["pos"].as_f64()?, t["align"].as_str().unwrap_or("l").to_string()))).collect()) + .unwrap_or_default(); + let top = Rect::from_min_max(pos2(avail.min.x + 16.0, avail.min.y), pos2(avail.max.x, avail.min.y + 16.0)); + let sx = |q: f64| xf.to_screen(Point::new(x0 + q, 0.0)).x; + let to_q = |x: f32| ((xf.to_slide(pos2(x, 0.0)).x - x0).clamp(0.0, width.max(0.0)) * 2.0).round() / 2.0; + // The text area's span, outlined on the ruler. + let area = Rect::from_min_max(pos2(sx(0.0), top.min.y + 2.0), pos2(sx(width), top.max.y - 1.0)).intersect(top); + p.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, t.text_faint.gamma_multiply(0.5)), egui::StrokeKind::Inside); + + let resp = ui.interact(top, ui.scope_id().with("ruler_markers"), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Text ruler")); + let id = ui.scope_id().with("ruler_drag"); + let mut drag: Option = ui.data(|d| d.get_temp::(id)); + let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); + if resp.drag_started() + && let Some(at) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + let near = |q: f64| (sx(q) - at.x).abs() <= 5.0; + let y = at.y - top.min.y; + drag = if let Some((pos, _)) = tabs.iter().find(|(pos, _)| near(*pos) && y >= 8.0) { + Some(RulerDrag::Tab(*pos)) + } else if y < 6.0 && near(ml + ind) { + Some(RulerDrag::First) + } else if (6.0..11.0).contains(&y) && near(ml) { + Some(RulerDrag::Hanging) + } else if y >= 11.0 && near(ml) { + Some(RulerDrag::Left) + } else { + None + }; + ui.data_mut(|d| match drag { + Some(v) => { + d.insert_temp(id, v); + } + None => { + d.remove::(id); + } + }); + } + // Where each marker is drawn: the dragged one follows the pointer. + let live = drag.zip(pointer.filter(|_| resp.dragged())).map(|(d, at)| (d, to_q(at.x), at.y > top.max.y + 20.0)); + let (mut first, mut left) = (ml + ind, ml); + match live { + Some((RulerDrag::First, q, _)) => first = q, + Some((RulerDrag::Hanging, q, _)) => left = q, + Some((RulerDrag::Left, q, _)) => { + first = q + ind; + left = q; + } + _ => {} + } + let col = t.text_dim; + let tri = |x: f32, y0: f32, down: bool| { + let (a, b) = if down { (y0, y0 + 5.0) } else { (y0 + 5.0, y0) }; + p.add(egui::Shape::convex_polygon(vec![pos2(x - 4.0, a), pos2(x + 4.0, a), pos2(x, b)], t.card, Stroke::new(1.0, col))); + }; + tri(sx(first), top.min.y + 1.0, true); + tri(sx(left), top.min.y + 6.0, false); + p.rect( + Rect::from_min_max(pos2(sx(left) - 4.0, top.min.y + 11.5), pos2(sx(left) + 4.0, top.max.y - 0.5)), + CornerRadius::ZERO, + t.card, + Stroke::new(1.0, col), + egui::StrokeKind::Inside, + ); + for (pos, align) in &tabs { + let (q, gone) = match live { + Some((RulerDrag::Tab(from), q, off)) if (from - pos).abs() < 0.5 => (q, off), + _ => (*pos, false), + }; + if gone { + continue; + } + tab_glyph(p, pos2(sx(q), top.max.y - 2.0), align, t.text); + } + // The tab selector in the ruler's corner: a click cycles left, center, right, decimal; a click + // on the ruler then sets that kind of stop. + let kind_id = ui.scope_id().with("ruler_tab_kind"); + let mut kind: usize = ui.data(|d| d.get_temp(kind_id)).unwrap_or(0); + let corner = Rect::from_min_size(avail.min, vec2(16.0, 16.0)); + let cr = ui.interact(corner, ui.scope_id().with("ruler_tab_selector"), Sense::click()); + const KINDS: [(&str, &str); 4] = [("l", "Left Tab"), ("ctr", "Center Tab"), ("r", "Right Tab"), ("dec", "Decimal Tab")]; + if cr.clicked() { + kind = (kind + 1) % KINDS.len(); + ui.data_mut(|d| d.insert_temp(kind_id, kind)); + } + let (kind_align, kind_label) = KINDS[kind % KINDS.len()]; + cr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, kind_label)); + let cr = cr.on_hover_text(format!("{kind_label} (click to change)")); + p.rect_stroke( + corner.shrink(2.0), + CornerRadius::same(2), + Stroke::new(1.0, if cr.hovered() { t.accent } else { t.text_faint }), + egui::StrokeKind::Inside, + ); + tab_glyph(p, pos2(corner.center().x - 1.0, corner.center().y + 3.0), kind_align, t.text); + if drag.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + + // Commit on release; a click on bare ruler adds a left tab. + if resp.drag_stopped() { + if let (Some(d), Some(at)) = (drag, pointer) { + let q = to_q(at.x); + let r = match d { + RulerDrag::First => app.run("format.paragraph", json!({"indentFirst": q - ml})), + RulerDrag::Hanging => app.run("format.paragraph", json!({"indentLeft": q, "indentFirst": ml + ind - q})), + RulerDrag::Left => app.run("format.paragraph", json!({"indentLeft": q})), + RulerDrag::Tab(from) if at.y > top.max.y + 20.0 => app.run("format.tabs", json!({"remove": from})), + RulerDrag::Tab(from) => app.run("format.tabs", json!({"move": {"from": from, "to": q}})), + }; + if let Err(e) = r { + app.set_status(e); + } + } + ui.data_mut(|d| d.remove::(id)); + } else if resp.clicked() + && let Some(at) = resp.interact_pointer_pos() + && at.x >= area.min.x + && at.x <= area.max.x + { + let near_tab = tabs.iter().any(|(pos, _)| (sx(*pos) - at.x).abs() <= 5.0); + if resp.double_clicked() || near_tab { + // A double-click on a stop opens the Tabs dialog, as in PowerPoint. + if resp.double_clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("tabs")); + } + } else { + let _ = app.run("format.tabs", json!({"add": {"pos": to_q(at.x), "align": kind_align}})); + } + } +} + +/// A tab-stop mark with its foot at `at`: └ left, ┴ center, ┘ right, ┴ with a dot decimal. +fn tab_glyph(p: &egui::Painter, at: Pos2, align: &str, col: Color32) { + let (x, y) = (at.x, at.y); + let st = Stroke::new(1.5, col); + p.line_segment([pos2(x, y - 6.0), pos2(x, y)], st); + match align { + "r" => { + p.line_segment([pos2(x - 5.0, y), pos2(x, y)], st); + } + "ctr" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + } + "dec" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + p.circle_filled(pos2(x + 3.0, y - 4.0), 1.0, col); + } + _ => { + p.line_segment([pos2(x, y), pos2(x + 5.0, y)], st); + } + } +} diff --git a/crates/ui-egui/src/canvas/scroll.rs b/crates/ui-egui/src/canvas/scroll.rs new file mode 100644 index 00000000..ff3b072c --- /dev/null +++ b/crates/ui-egui/src/canvas/scroll.rs @@ -0,0 +1,46 @@ +use super::*; + +/// Thickness of the canvas scroll bars, in points. +const SCROLL_BAR: f32 = 10.0; + +/// The canvas scroll bar along `axis` (0 across, 1 down): its track, its thumb for `offset`, and +/// how far the thumb can travel. `None` when the slide fits that way. `inset` keeps the rulers' +/// strips clear. +pub(super) fn scroll_bar(avail: Rect, inset: f32, overflow: egui::Vec2, offset: egui::Vec2, axis: usize) -> Option<(Rect, Rect, f32)> { + let over = overflow[axis]; + if !over.is_finite() || over <= 0.0 { + return None; + } + // Both bars showing leave the corner between them empty. + let corner = if overflow[1 - axis] > 0.0 { SCROLL_BAR } else { 0.0 }; + let track = if axis == 0 { + Rect::from_min_max(pos2(avail.min.x + inset, avail.max.y - SCROLL_BAR), pos2(avail.max.x - corner, avail.max.y)) + } else { + Rect::from_min_max(pos2(avail.max.x - SCROLL_BAR, avail.min.y + inset), pos2(avail.max.x, avail.max.y - corner)) + }; + let len = track.size()[axis].max(0.0); + let view = avail.size()[axis]; + let thumb_len = (len * view / (view + over)).max(SCROLL_BAR * 2.0).min(len); + let travel = (len - thumb_len).max(0.0); + let at = travel * (offset[axis] / over).clamp(0.0, 1.0); + let mut thumb = track; + thumb.min[axis] = track.min[axis] + at; + thumb.max[axis] = thumb.min[axis] + thumb_len; + Some((track, thumb, travel)) +} + +/// The canvas `avail` in texture pixels (`pixel`: one pixel's size in points) from the slide's +/// top-left corner. +pub(super) 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, + } +}