Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 6917984329 Fix #903: Reselect repeats a Same command after Deselect
Select › Reselect repeated the last selection command, but a Select Same
command needs a selected object to compare with, so after Deselect it
failed with "nothing selected". Reselect now remembers the objects that
were selected when the command ran and, with nothing selected, takes the
reference from those still in the document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:55:37 +07:00
6 changed files with 76 additions and 34 deletions

No files matched your search

-5
View File
@@ -45,8 +45,3 @@ sync_github_name = false
real_name = "Ahmed Moussa"
display_name = "Ahmed Moussa"
sync_github_name = true
[people.vbasky]
real_name = "Vikram Bhaskaran"
display_name = "Vikram Bhaskaran"
sync_github_name = false
+2 -1
View File
@@ -246,8 +246,9 @@ fn candidates(s: &Session, f: impl Fn(&Node) -> bool) -> Result<Vec<NodeId>> {
fn finish_same(s: &mut Session, cmd: &str, ids: Vec<NodeId>) -> Result<Value> {
let n = ids.len();
let from = s.doc()?.selection.objects.clone();
s.select(|_, sel| sel.set(ids))?;
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), json!({})));
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), json!({}), from));
Ok(json!({ "count": n }))
}
+16 -3
View File
@@ -216,8 +216,20 @@ fn none(s: &mut Session, _: &Value) -> Result<Value> {
}
fn reselect(s: &mut Session, _: &Value) -> Result<Value> {
let Some((c, p)) = s.doc()?.last_selection_cmd.clone() else { return ok() };
s.execute(&c, &p)
let st = s.doc()?;
let Some((c, p, from)) = st.last_selection_cmd.clone() else { return ok() };
// After Deselect, a Same command takes its reference again from the objects that were selected
// when it ran, those still there (#903).
let from: Vec<NodeId> = from.into_iter().filter(|id| st.doc.node(*id).is_some()).collect();
let restore = st.selection.objects.is_empty() && !from.is_empty();
if restore {
s.select(|_, sel| sel.set(from))?;
}
let r = s.execute(&c, &p);
if restore && r.is_err() {
s.select(|_, sel| sel.clear())?;
}
r
}
fn inverse(s: &mut Session, _: &Value) -> Result<Value> {
@@ -401,8 +413,9 @@ fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &No
for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) {
visit(&st.doc, l, &f, &mut ids);
}
let from = st.selection.objects.clone();
s.select(|_, sel| sel.set(ids.iter().copied()))?;
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), p.clone()));
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), p.clone(), from));
Ok(json!({ "count": ids.len() }))
}
+3 -2
View File
@@ -126,8 +126,9 @@ pub struct DocState {
pub undo_group: Option<UndoGroup>,
/// For Object → Transform → Transform Again (⌘D).
pub last_transform: Option<(Affine, bool)>,
/// Selection saved by Select → Reselect.
pub last_selection_cmd: Option<(String, Value)>,
/// What Select → Reselect repeats: the last selection command, its params and the objects
/// selected when it ran (a Same command's reference objects).
pub last_selection_cmd: Option<(String, Value, Vec<NodeId>)>,
/// Process-unique id of this open document (tab indices shift when tabs close).
pub uid: u64,
/// View Opacity Mask (Alt-click the mask thumbnail): the masked object whose mask the canvas
+12
View File
@@ -149,6 +149,18 @@ fn select_same_fill() {
let r = s.execute("select.same.fillColor", &json!({})).unwrap();
assert_eq!(r["count"], 2);
assert!(!s.doc().unwrap().selection.contains(a));
// Reselect after Deselect repeats it from the same reference object (#903).
let blue = s.doc().unwrap().selection.objects.clone();
s.execute("select.none", &json!({})).unwrap();
s.execute("select.reselect", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, blue);
// With its reference object gone, it fails as Select Same does, and selects nothing.
s.execute("select.none", &json!({})).unwrap();
for id in &blue {
s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap();
}
assert!(s.execute("select.reselect", &json!({})).is_err());
assert!(s.doc().unwrap().selection.objects.is_empty());
}
#[test]
+43 -23
View File
@@ -187,10 +187,37 @@ pub(crate) fn typed_unit(ctx: &egui::Context, unit: Unit) -> Unit {
/// without a unit is in `unit` ([`typed_unit`]: in points in a picas field with Numbers Without
/// Units Are Points on).
pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<f64>, unit: Unit, width: f32) -> Option<f64> {
let t = Tokens::get(ui.ctx());
let id = ui.id().with(id);
let shown = value.map(|v| unit.format(v)).unwrap_or_default();
take_dialog_focus(ui, id, &shown);
let (mut buf, resp, rect) = recessed_text(ui, id, &shown, width, 1);
let mut buf: String = ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.clone());
let editing = ui.memory(|m| m.has_focus(id));
if !editing {
buf = shown.clone();
}
take_dialog_focus(ui, id, &buf);
let (rect, resp) = ui
.allocate_ui_with_layout(vec2(width, 26.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(width);
let framed = egui::Frame::NONE
.fill(t.input)
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
.corner_radius(CornerRadius::same(2))
.inner_margin(egui::Margin::symmetric(6, 4))
.show(ui, |ui| {
ui.add(
egui::TextEdit::singleline(&mut buf)
.id(id)
.frame(egui::Frame::NONE)
.desired_width(width - 14.0)
.min_size(vec2(width - 14.0, 0.0))
.font(egui::FontId::proportional(12.5))
.text_color(t.text_strong),
)
});
(framed.response.rect, framed.inner)
})
.inner;
select_all_on_focus(ui, &resp, &buf);
// ↑/↓ and the wheel step in the field's unit; a drag on its label scrubs it.
let read = typed_unit(ui.ctx(), unit);
@@ -322,32 +349,27 @@ fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize
let t = Tokens::get(ui.ctx());
let editing = ui.memory(|m| m.has_focus(id));
let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.to_string()) } else { shown.to_string() };
// The box is exactly as tall as the other controls of a row (26 points a line), its text
// centred in it: a frame around the text came out a point taller and stood out next to
// spinners and dropdowns (#900).
let height = 26.0 + 16.0 * (rows.max(1) - 1) as f32;
let layout = if rows > 1 { egui::Layout::top_down(egui::Align::Min) } else { egui::Layout::left_to_right(egui::Align::Center) };
let (rect, resp) = ui
.allocate_ui_with_layout(vec2(width, height), layout, |ui| {
ui.set_min_size(vec2(width, height));
let rect = ui.max_rect();
ui.painter().rect(rect, 2.0, t.input, Stroke::new(1.0, if editing { t.accent } else { t.input_border }), StrokeKind::Inside);
let margin = egui::Margin { left: 7, right: 7, top: if rows > 1 { 4 } else { 0 }, bottom: 0 };
let resp = egui::Frame::NONE
.inner_margin(margin)
.allocate_ui_with_layout(vec2(width, height), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(width);
let framed = egui::Frame::NONE
.fill(t.input)
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
.corner_radius(CornerRadius::same(2))
.inner_margin(egui::Margin::symmetric(6, 4))
.show(ui, |ui| {
let edit = if rows > 1 { egui::TextEdit::multiline(&mut buf).desired_rows(rows) } else { egui::TextEdit::singleline(&mut buf) };
ui.add(
edit.id(id)
.frame(egui::Frame::NONE)
.margin(egui::Margin::ZERO)
.desired_width(width - 14.0)
.min_size(vec2(width - 14.0, 0.0))
.font(egui::FontId::proportional(12.5))
.text_color(t.text_strong),
)
})
.inner;
(rect, resp)
});
(framed.response.rect, framed.inner)
})
.inner;
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
@@ -1104,8 +1126,7 @@ fn spin_generic(
let h = 26.0;
let enabled = ui.is_enabled();
ui.scope(|ui| {
// The spinner, the field and the chevron share their borders: one box in three parts.
ui.spacing_mut().item_spacing.x = -1.0;
ui.spacing_mut().item_spacing.x = 0.0;
let (sr, sresp) = ui.allocate_exact_size(vec2(16.0, h), Sense::click());
ui.painter().rect_filled(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, t.input);
ui.painter().rect_stroke(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
@@ -1133,7 +1154,7 @@ fn spin_generic(
let nv = if up.contains(p) { v + step } else { v - step };
out = Some(SpinPick::Value(nv.max(min)));
}
let fw = if presets.is_empty() { width - 15.0 } else { width - 34.0 };
let fw = if presets.is_empty() { width - 16.0 } else { width - 36.0 };
if let Some(v) = field(ui, fw) {
out = Some(SpinPick::Value(v.max(min)));
}
@@ -1771,9 +1792,8 @@ fn chevron_cell(ui: &mut Ui, h: f32) -> Response {
pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: &str, presets: &[&str], width: f32) -> Option<String> {
let mut out = None;
ui.scope(|ui| {
// The field and the chevron share a border.
ui.spacing_mut().item_spacing.x = -1.0;
out = text_field(ui, &id, Some(value), width - 19.0, 1);
ui.spacing_mut().item_spacing.x = 0.0;
out = text_field(ui, &id, Some(value), width - 20.0, 1);
let resp = chevron_cell(ui, 26.0);
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(width - 10.0);