mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 03:56:50 -06:00
refactor(ui-egui): split canvas.rs into canvas/{input,scroll,ime,media,rulers,guides,overlays,context_menu}.rs
This commit is contained in:
+26
-1278
File diff suppressed because it is too large
Load Diff
@@ -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::<String>())
|
||||
.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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<f32> {
|
||||
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
|
||||
}
|
||||
@@ -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<Rect> {
|
||||
let st = app.session.active()?;
|
||||
if let Some(ts) = &st.selection.text {
|
||||
if ts.notes {
|
||||
return None;
|
||||
}
|
||||
if let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
|
||||
return Some(Rect::from_two_pos(xf.to_screen(top), xf.to_screen(bot)).expand2(vec2(1.0, 0.0)));
|
||||
}
|
||||
let sh = st.shape(ts.shape)?;
|
||||
return Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)));
|
||||
}
|
||||
if st.selection.shapes.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let sh = st.shape(*st.selection.shapes.first()?)?;
|
||||
if sh.is_line() {
|
||||
return None;
|
||||
}
|
||||
Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)))
|
||||
}
|
||||
|
||||
/// Turn the input method on for the slide while it takes typing, with its candidate window at
|
||||
/// the insertion point (#101). Without this egui leaves it off, and a Korean keyboard types
|
||||
/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own.
|
||||
///
|
||||
/// 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);
|
||||
}
|
||||
@@ -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<bool> {
|
||||
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<usize> {
|
||||
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<f32> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
@@ -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<Point> = if app.session.tool.path_smooth && path.len() > 2 {
|
||||
let d = deckcraft_engine::cmd::shape::freeform_path(path, false, true);
|
||||
let mut v = vec![];
|
||||
let bp = deckcraft_render::parse_path(&d);
|
||||
deckcraft_geom::preset_flatten(&bp, &mut |el| match el {
|
||||
deckcraft_geom::PathEl::MoveTo(q) | deckcraft_geom::PathEl::LineTo(q) => v.push(q),
|
||||
_ => {}
|
||||
});
|
||||
v
|
||||
} else {
|
||||
path.clone()
|
||||
};
|
||||
painter.add(egui::Shape::line(pts.iter().map(|q| xf.to_screen(*q)).collect(), Stroke::new(1.5, t.accent)));
|
||||
if app.session.tool.path_closing
|
||||
&& let Some(s0) = path.first()
|
||||
{
|
||||
painter.circle(xf.to_screen(*s0), 5.0, t.accent, Stroke::new(1.5, t.handle_fill));
|
||||
}
|
||||
}
|
||||
// Connection sites of the shape under a connector end, and the site it would glue to.
|
||||
for q in &app.session.tool.sites {
|
||||
let c = xf.to_screen(*q);
|
||||
painter.circle(c, 3.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke));
|
||||
}
|
||||
if let Some(g) = app.session.tool.glue {
|
||||
painter.circle(xf.to_screen(g), 5.0, t.accent, Stroke::new(1.5, t.handle_fill));
|
||||
}
|
||||
if app.session.tool.ink_preview.len() > 1
|
||||
&& let ToolKind::Ink { color, width, mode } = &app.session.tool.kind
|
||||
{
|
||||
let pts = &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<Pos2> = 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<TextMarks> {
|
||||
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);
|
||||
}
|
||||
@@ -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<RulerDrag> = ui.data(|d| d.get_temp::<RulerDrag>(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::<RulerDrag>(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::<RulerDrag>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user