Files
wordcraft/crates/engine/src/cmd/objects/select.rs
T

304 lines
12 KiB
Rust

use super::{CmdError, CmdResult, Float, GraphicKind, InlineObject, Pos, Selection, Session, Spin, StoryRef, Value, Wrap, json, p};
pub(super) fn has_picture(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Image { .. })) => None,
_ => Some("select a picture first"),
}
}
pub(super) fn has_object(s: &Session) -> Option<&'static str> {
if selected(s).is_some() { None } else { Some("select a picture or shape first") }
}
pub(super) fn has_floating(s: &Session) -> Option<&'static str> {
// A canvas member moves inside its canvas.
if crate::cmd::canvas::selected_member(s).is_some() {
return None;
}
match selected(s) {
Some((_, InlineObject::Graphic { .. })) => Some(FROZEN),
Some((_, o)) if o.is_floating() => None,
_ => Some("select a floating picture or shape first"),
}
}
/// Stories whose objects can be moved, resized and arranged: the body and headers/footers.
/// Objects in table cells (deeper paths), notes and text boxes stay where their text is.
pub(super) fn movable_story(s: &Session, story: StoryRef) -> bool {
match story {
StoryRef::Body => true,
StoryRef::Part(id) => {
s.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer))
}
}
}
/// Charts and diagrams are not written back on save yet, so their size, position and wrapping stay
/// as imported: they can be selected and deleted, not moved, resized or arranged.
pub(super) const FROZEN: &str = "charts and diagrams can't be moved or resized yet";
/// `has_movable` for Size: charts and SmartArt WordCraft can edit are laid out again at their
/// new size.
pub(super) fn has_sizable(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Graphic { graphic, .. })) if graphic.chart.is_some() || graphic.smart_art.is_some() => None,
_ => has_movable(s),
}
}
/// `has_object` for the geometry commands: a chart or diagram is frozen.
pub(super) fn has_movable(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Graphic { .. })) => Some(FROZEN),
_ => has_object(s),
}
}
/// `has_movable` for what places an object in the text (wrapping, position, alignment, Move with
/// Text): not for a member picked inside a drawing canvas, which the canvas places.
pub(super) fn has_placeable(s: &Session) -> Option<&'static str> {
if crate::cmd::canvas::selected_member(s).is_some() {
return Some("a shape inside a drawing canvas is placed by the canvas");
}
has_movable(s)
}
/// Word's own ink in a group is kept in the form a group member takes; it can't stand on its own
/// yet, so such a group stays whole.
pub(super) const INK_GROUP: &str = "a group holding Word ink can't be ungrouped yet";
/// Does group `o` hold a member that can't stand on its own (Word's ink)?
pub(super) fn holds_ink(o: &InlineObject) -> bool {
matches!(o, InlineObject::Group { children, .. } if children.iter().any(|c| matches!(&c.obj, InlineObject::Graphic { graphic, .. } if graphic.kind == GraphicKind::Ink)))
}
pub(super) fn has_group(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, o)) if o.is_canvas() => Some("a drawing canvas can't be ungrouped"),
Some((_, o)) if holds_ink(&o) => Some(INK_GROUP),
Some((_, InlineObject::Group { .. })) => None,
_ => Some("select a group first"),
}
}
/// Shape Fill and Shape Outline: a shape, or a Drawing Canvas's background and border.
pub(crate) fn has_shape_or_canvas(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, o)) if o.is_canvas() => None,
_ => has_shape(s),
}
}
pub(super) fn can_group(s: &Session) -> Option<&'static str> {
if picked_objects(s).len() >= 2 { None } else { Some("select two or more pictures or shapes first (Shift+click)") }
}
pub(super) fn has_shape(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Shape { .. })) => None,
_ => Some("select a shape first"),
}
}
/// The selection when it is exactly one picture, shape or text box (its U+FFFC), rather than
/// text: what clicking an object selects.
pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> {
let (a, b) = s.sel.ordered();
if a.story != b.story || a.path != b.path || b.off != a.off + wordcraft_doc::para::OBJ.len_utf8() {
return None;
}
let o = s.doc.para_at(&a)?.object_at(a.off)?;
o.is_drawing().then_some((a, o))
}
/// The first picture/shape in the selection, or just before a collapsed caret. With a canvas
/// member picked ([`crate::cmd::canvas::selected_member`]), that member, at its canvas's position:
/// the commands that change it go through [`edit_obj`], which changes the member.
pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
selected_all(s, 1).into_iter().next()
}
/// The pictures and shapes in the selection (at most `max`), or the one just before a collapsed
/// caret.
pub fn selected_all(s: &Session, max: usize) -> Vec<(Pos, InlineObject)> {
if let Some((pos, _, member)) = crate::cmd::canvas::selected_member(s) {
return vec![(pos, member.clone())];
}
let mut out = Vec::new();
let (a, b) = s.sel.ordered();
let max = if a == b { 1 } else { max };
let story = a.story;
let paths = if a == b { vec![a.path.clone()] } else { s.doc.paths_between(&a, &b) };
for path in paths {
let Some(p) = s.doc.para(story, &path) else { continue };
for off in p.object_offsets() {
let inside = if a == b {
off + wordcraft_doc::para::OBJ.len_utf8() == a.off || off == a.off
} else {
(path != a.path || off >= a.off) && (path != b.path || off < b.off)
};
if !inside {
continue;
}
if let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) {
out.push((Pos { story, path: path.clone(), off }, o.clone()));
if out.len() >= max {
return out;
}
}
}
}
out
}
/// Smallest width/height an object can be resized to, points (a text box keeps room for a line).
pub fn min_size(o: &InlineObject) -> f32 {
if matches!(o, InlineObject::Shape { story: Some(_), .. }) { 18.0 } else { 4.0 }
}
/// Select the object whose character is at `pos`.
pub(super) fn select_object(s: &mut Session, pos: &Pos) {
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() } };
}
pub(super) fn obj_size(o: &InlineObject) -> (f32, f32) {
o.frame().map_or((0.0, 0.0), |(w, h, _)| (w, h))
}
/// Alt Text: the selected object's description, title and decorative mark, in one undo step.
pub(super) fn alt_text(s: &mut Session, v: &Value) -> CmdResult {
let (text, title, decorative) = (p::str(v, "text"), p::str(v, "title"), p::bool(v, "decorative"));
if text.is_none() && title.is_none() && decorative.is_none() {
return Err(CmdError::Params("`text`, `title` or `decorative` is required".into()));
}
with_obj(s, |o| {
if let Some(t) = text {
o.set_alt_text(t);
}
if let Some(f) = o.float_mut() {
if let Some(t) = title {
f.alt.title = wordcraft_doc::para::cap_alt(t);
}
if let Some(d) = decorative {
f.alt.decorative = d;
}
}
})
}
pub(super) fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdResult {
let (pos, _) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
let o = edit_obj(s, &pos, f)?;
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
}
/// Change the object at `pos`; returns it afterwards. With a member of the canvas at `pos`
/// picked, the member changes.
pub(crate) fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result<InlineObject, CmdError> {
if let Some(r) = crate::cmd::canvas::edit_member_object(s, pos, &f) {
return r;
}
let para = s.doc.para_mut(pos.story, &pos.path)?;
let o = para.object_at_mut(pos.off).ok_or_else(|| CmdError::Failed("object vanished".into()))?;
f(o);
let o = o.clone();
para.touch();
Ok(o)
}
pub(super) fn edit_float(s: &mut Session, pos: &Pos, f: impl Fn(&mut Float)) -> Result<InlineObject, CmdError> {
edit_obj(s, pos, |o| {
if let Some(float) = o.float_mut() {
f(float);
}
})
}
pub(super) fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult {
with_obj(s, |o| {
if let Some(float) = o.float_mut() {
let was_inline = float.wrap == Wrap::Inline;
f(float);
// Word's distance from text for a newly wrapped object: 0.125" at the sides.
if was_inline && float.wrap != Wrap::Inline && float.dist == 0.0 {
float.dist = 9.0;
}
}
})
}
/// The selected pictures, shapes, text boxes and groups: the one the selection holds, then
/// those added to it ([`Session::also_selected`]), each once, in the body.
pub fn picked_objects(s: &Session) -> Vec<Pos> {
let Some((first, _)) = object_selection(s) else { return Vec::new() };
let mut out = vec![first];
for p in &s.also_selected {
if !out.contains(p) && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing) {
out.push(p.clone());
}
}
out
}
/// The object a `select.addObject` item names: a position, or a Selection Pane index.
pub(super) fn object_ref(s: &Session, v: &Value) -> Result<Pos, CmdError> {
let pos = match v.as_u64() {
Some(n) => all_objects(s).into_iter().nth(usize::try_from(n).unwrap_or(usize::MAX)).map(|(p, _)| p),
None => crate::cmd::parse_pos(v),
};
pos.filter(|p| movable_story(s, p.story) && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing))
.ok_or_else(|| CmdError::Params("no picture or shape there".into()))
}
/// A group's members as it shows them in its frame `[x, y, w, h]`: each member's unturned frame
/// and the turn it is drawn with (its own inside the group's). The group's turn, about its
/// centre, moves each member's centre. Empty for anything but a group.
pub(super) fn shown_members(group: &InlineObject, [x, y, w, h]: [f32; 4]) -> Vec<([f32; 4], Spin, &InlineObject)> {
let outer = group.frame().map(|(_, _, f)| f.spin()).unwrap_or_default();
let [x, y, w, h] = [x, y, w, h].map(wordcraft_geom::finite);
let (gx, gy) = (x + w / 2.0, y + h / 2.0);
group
.group_rects(x, y, w, h)
.into_iter()
.map(|([mx, my, mw, mh], c)| {
let own = c.frame().map(|(_, _, f)| f.spin()).unwrap_or_default();
let (cx, cy) = outer.apply(gx, gy, mx + mw / 2.0, my + mh / 2.0);
([cx - mw / 2.0, cy - mh / 2.0, mw, mh], own.within(outer), c)
})
.collect()
}
pub(crate) fn all_objects(s: &Session) -> Vec<(Pos, InlineObject)> {
let mut v = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
for off in p.object_offsets() {
if let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) {
v.push((Pos { story: StoryRef::Body, path: path.clone(), off }, o.clone()));
}
}
}
v
}
pub(super) fn objects_list(s: &mut Session, _: &Value) -> CmdResult {
Ok(Value::Array(
all_objects(s)
.into_iter()
.enumerate()
.map(|(i, (pos, o))| {
let (kind, name) = match &o {
InlineObject::Image { alt, .. } => ("picture", if alt.is_empty() { format!("Picture {}", i + 1) } else { alt.clone() }),
InlineObject::Shape { kind, .. } => ("shape", format!("{kind:?} {}", i + 1)),
InlineObject::Graphic { graphic, .. } => ("graphic", format!("{:?} {}", graphic.kind, i + 1)),
InlineObject::Group { .. } => ("group", format!("Group {}", i + 1)),
_ => ("object", format!("Object {}", i + 1)),
};
let hidden = o.frame().is_some_and(|(_, _, f)| f.hidden);
json!({"index": i, "kind": kind, "name": name, "pos": crate::cmd::pos_json(&pos), "size": obj_size(&o), "hidden": hidden})
})
.collect(),
))
}