Merge PR #1500: rename a swatch in place

crates/ui-egui/src/panels/swatches.rs: kept main's row marks and color_target_or_defaults, added the in-place rename field before them; tests find rows by names_swatch (main's longer accessible names); chapter 23 doc comment moved onto the main test.
This commit is contained in:
aiongg
2026-10-30 23:14:56 +00:00
2 changed files with 231 additions and 1 deletions
+1
View File
@@ -490,6 +490,7 @@ Newest first.
- **2026-10-20** · Drawing a frame or shape shows its width and height in the document's ruler units, to two decimals and on two lines ("W: 6.08 cm", "H: 6.08 cm"), as InDesign does; it was always whole points. No readiness percentage change.
- **2026-10-20** · Type tool: a drag that starts in an empty frame draws a new text frame over it, as in InDesign; a click still turns the frame into a text frame. No readiness percentage change.
- **2026-10-20** · Measures in centimetres show four decimals, as InDesign's fields do (a 1p gutter reads 0.4233 cm, not 0.423 cm). No readiness percentage change.
- **2026-10-21** · Swatches panel: a swatch is renamed in place, as in InDesign: click it, then click its name again (a slow double-click), type, and Enter (Escape keeps the name). Everything that uses it follows; a double-click still opens Swatch Options, and [None], [Registration], [Paper] and [Black] keep their names. Found in the Learn Skills Daily course, chapter 23. No readiness percentage change.
- **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
- **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.
+230 -1
View File
@@ -11,6 +11,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
// Another document (switched to, or moved to this one's place by a close) drops a rename in
// place: a swatch of the same name there isn't the one being renamed.
let uid = st.uid;
if ui.data(|d| d.get_temp::<u64>(egui::Id::unique(RENAME_DOC))) != Some(uid) {
ui.data_mut(|d| {
d.remove_temp::<(String, String)>(egui::Id::unique(RENAMING));
d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK));
d.insert_temp(egui::Id::unique(RENAME_DOC), uid);
});
}
// With nothing selected, the proxy, Tint field and highlighted row are those of new shapes.
let target = super::color_target_or_defaults(app);
header(app, ui, &doc, target.as_ref());
@@ -155,6 +165,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
/// Temp-data key of the swatch last clicked (Delete Swatch removes it).
const PICKED: &str = "swatches_picked";
/// Temp-data key of the swatch renamed in place and its name being typed.
const RENAMING: &str = "swatches_renaming";
/// Temp-data key of a click on the picked swatch's name: (swatch, time). Unless it turns into a
/// double-click (Swatch Options), it starts renaming.
const RENAME_CLICK: &str = "swatches_rename_click";
/// Temp-data key of the document (its uid) the panel last showed: a rename belongs to it.
const RENAME_DOC: &str = "swatches_rename_doc";
/// The Swatches panel's top row, as InDesign's: a Fill/Stroke proxy (click: the other in front),
/// Formatting Affects Container / Text, and the tint of the colour in front. `target` is
@@ -244,6 +261,47 @@ fn folder_button(ui: &mut egui::Ui, tip: &str) -> egui::Response {
})
}
/// The name field of a swatch being renamed in place, over `rect`; false when `sw` isn't. Enter or
/// a click elsewhere renames it (`swatch.options`), Escape leaves it as it was.
fn rename_field(app: &mut DesignApp, ui: &mut egui::Ui, sw: &designcraft_color::swatch::Swatch, rect: egui::Rect) -> bool {
let key = egui::Id::unique(RENAMING);
let Some((name, mut text)) = ui.data(|d| d.get_temp::<(String, String)>(key)).filter(|(n, _)| *n == sw.name) else { return false };
let id = egui::Id::unique(("swatch_rename", &sw.name));
let edit = egui::TextEdit::singleline(&mut text).id(id).font(egui::FontId::proportional(12.5)).margin(vec2(2.0, 1.0));
let resp = ui.put(rect, edit);
if !resp.has_focus() && !resp.lost_focus() {
// The field opens with the name selected.
resp.request_focus();
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) {
state
.cursor
.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count()))));
state.store(ui.ctx(), id);
}
}
if resp.lost_focus() {
ui.data_mut(|d| d.remove_temp::<(String, String)>(key));
let to = text.trim();
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !to.is_empty() && to != name {
match app.run("swatch.options", json!({"name": name, "to": to})) {
// Still picked under its new name, unless the click that ended the rename picked
// another swatch.
Ok(_) => ui.data_mut(|d| {
if d.get_temp::<String>(egui::Id::unique(PICKED)).is_none_or(|p| p == name) {
d.insert_temp(egui::Id::unique(PICKED), to.to_string());
}
}),
Err(e) => {
app.status_error("Swatch Options", e);
}
}
}
} else {
ui.data_mut(|d| d.insert_temp(key, (name, text)));
}
true
}
/// The swatch's chip following the pointer while the row is dragged.
fn paint_drag_chip(ui: &egui::Ui, doc: &designcraft_doc::Document, name: &str, p: egui::Pos2) {
let t = Tokens::get(ui.ctx());
@@ -436,7 +494,10 @@ fn swatch_row(
let (c, g) = crate::widgets::swatch_colors(doc, &sw.name, 1.0);
let chip = egui::Rect::from_min_size(row.min + vec2(4.0 + indent, 3.0), vec2(16.0, 16.0));
crate::widgets::paint_chip(ui.painter(), chip, c, g);
ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text);
let name_rect = egui::Rect::from_min_max(row.min + vec2(26.0 + indent, 1.0), egui::pos2(row.max.x - 72.0, row.max.y - 1.0));
if !rename_field(app, ui, sw, name_rect) {
ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text);
}
// InDesign's columns on the right: not editable, colour type, colour mode.
let col = |k: f32| egui::Rect::from_min_size(egui::pos2(row.max.x - 19.0 - 18.0 * k, row.min.y + 4.5), vec2(13.0, 13.0));
if marks.not_editable {
@@ -457,13 +518,37 @@ fn swatch_row(
ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, label, egui::FontId::proportional(10.5), t.text_dim);
}
if resp.clicked() && !resp.double_clicked() {
// A click on the name of the swatch picked already renames it in place, as InDesign's
// (a slow double-click), unless it turns into a double-click.
let picked = ui.data(|d| d.get_temp::<String>(egui::Id::unique(PICKED))).is_some_and(|p| p == sw.name);
// A click on another swatch cancels a rename still waiting.
ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK)));
if picked && !sw.locked && resp.interact_pointer_pos().is_some_and(|p| name_rect.contains(p)) {
let now = ui.input(|i| i.time);
ui.data_mut(|d| d.insert_temp(egui::Id::unique(RENAME_CLICK), (sw.name.clone(), now)));
}
ui.data_mut(|d| d.insert_temp(egui::Id::unique(PICKED), sw.name.clone()));
let _ = super::apply_swatch(app, app.ui.stroke_front, &sw.name, None);
}
// Double-click: Swatch Options (the first click has applied the swatch).
if resp.double_clicked() {
ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK)));
let _ = app.run("app.swatchOptionsDialog", json!({"name": sw.name}));
}
if let Some((name, at)) = ui.data(|d| d.get_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))).filter(|(n, _)| *n == sw.name) {
let wait = ui.ctx().options(|o| o.input_options.max_double_click_delay);
if ui.data(|d| d.get_temp::<String>(egui::Id::unique(PICKED))).is_none_or(|p| p != name) {
// Another swatch picked since: no rename.
ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK)));
} else if ui.input(|i| i.time) - at > wait {
ui.data_mut(|d| {
d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK));
d.insert_temp(egui::Id::unique(RENAMING), (name.clone(), name));
});
} else {
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(wait));
}
}
resp.context_menu(|ui| {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Swatch Options…"))).clicked() {
let _ = app.run("app.swatchOptionsDialog", json!({"name": sw.name}));
@@ -1608,6 +1693,150 @@ mod swatches_header_tests {
assert!(h.state().session.doc().unwrap().doc.swatch("Brand").is_none());
}
/// A rename in place belongs to its document: with another one active, a swatch of the same
/// name there gets no field, and nothing is renamed.
#[test]
fn a_rename_in_place_ends_with_its_document() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let color = json!({"color": {"c": 14, "m": 8, "y": 95, "k": 0}});
let name = app.run("swatch.create", color.clone()).unwrap()["name"].as_str().unwrap().to_string();
let mut h = Harness::builder().with_size(vec2(300.0, 520.0)).build_ui_state(
|ui, app: &mut DesignApp| {
let id = egui::Id::unique("rename_test_fonts");
if ui.data(|d| d.get_temp::<bool>(id)).is_none() {
crate::theme::install_fonts(ui.ctx(), "");
ui.data_mut(|d| d.insert_temp(id, true));
return;
}
super::show(app, ui);
},
app,
);
h.run_steps(3);
let fields = h.query_all_by_role(egui::accesskit::Role::TextInput).count();
let renaming = egui::Id::unique(super::RENAMING);
h.ctx.data_mut(|d| d.insert_temp(renaming, (name.clone(), "Typed".to_string())));
h.run_steps(2);
assert!(h.query_all_by_role(egui::accesskit::Role::TextInput).count() > fields, "the name is a field");
// Another document with a swatch of the same name.
h.state_mut().run("file.new", json!({})).unwrap();
h.state_mut().run("swatch.create", color).unwrap();
h.run_steps(3);
assert!(h.ctx.data(|d| d.get_temp::<(String, String)>(renaming)).is_none(), "the rename is dropped");
let doc = &h.state().session.active().unwrap().doc;
assert!(doc.swatch(&name).is_some() && doc.swatch("Typed").is_none());
}
/// The course's chapter 23: a swatch added as "C=14 M=8 Y=95 K=0" is renamed in the panel by
/// clicking it, then clicking its name again: the name becomes a field, and Enter renames the
/// swatch (and what uses it). A double-click still opens Swatch Options, Escape keeps the name,
/// and [Black] can't be renamed.
#[test]
fn a_second_click_on_the_picked_swatch_renames_it_in_place() {
use egui::accesskit::Role;
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "unassigned"})).unwrap();
let added = app.run("swatch.create", json!({"color": {"c": 14, "m": 8, "y": 95, "k": 0}})).unwrap()["name"].as_str().unwrap().to_string();
assert_eq!(added, "C=14 M=8 Y=95 K=0");
// Frames 50 ms apart: the double-click time is six of them.
let mut h = Harness::builder().with_size(vec2(300.0, 520.0)).with_step_dt(0.05).build_ui_state(
|ui, app: &mut DesignApp| {
let id = egui::Id::unique("test_fonts");
if ui.data(|d| d.get_temp::<bool>(id)).is_none() {
crate::theme::install_fonts(ui.ctx(), "");
ui.data_mut(|d| d.insert_temp(id, true));
return;
}
show(app, ui);
},
app,
);
h.run_steps(3);
// The panel's own fields (Tint); renaming adds one.
let fields = h.query_all_by_role(Role::TextInput).count();
let field = |h: &Harness<'_, DesignApp>| h.query_all_by_role(Role::TextInput).count() > fields;
let has = |h: &Harness<'_, DesignApp>, n: &str| h.state().session.doc().unwrap().doc.swatch(n).is_some();
let name_at = |h: &Harness<'_, DesignApp>, name: &str| {
let row = h.query_all_by(|n| n.label().is_some_and(|l| names_swatch(&l, name))).next().expect("the swatch's row");
row.rect().left_center() + vec2(40.0, 0.0)
};
let click = |h: &mut Harness<'_, DesignApp>, at: egui::Pos2| {
h.hover_at(at);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.step();
};
// A click on the name: applied, and picked.
let at = name_at(&h, &added);
click(&mut h, at);
h.run_steps(10);
assert_eq!(items(h.state())[0].fill.swatch, added);
assert!(!field(&h));
// Again, after the double-click time: the name is a field, with the name selected.
click(&mut h, at);
h.run_steps(10);
assert!(field(&h), "the name is a field");
h.event(egui::Event::Text("Lemon Yellow Sun".into()));
h.step();
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert!(has(&h, "Lemon Yellow Sun") && !has(&h, &added));
assert_eq!(items(h.state())[0].fill.swatch, "Lemon Yellow Sun", "what uses it follows");
// Escape keeps the name.
let at = name_at(&h, "Lemon Yellow Sun");
h.run_steps(10);
click(&mut h, at);
h.run_steps(10);
assert!(field(&h));
h.event(egui::Event::Text("Sour".into()));
h.step();
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(has(&h, "Lemon Yellow Sun") && !field(&h));
// A double-click on it opens Swatch Options, and doesn't rename.
h.run_steps(20);
click(&mut h, at);
click(&mut h, at);
h.run_steps(10);
assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("swatchOptions"));
assert!(!field(&h), "no rename");
// [Black] keeps its name.
h.state_mut().ui.dialog = None;
h.run_steps(20);
let at = name_at(&h, "[Black]");
click(&mut h, at);
h.run_steps(10);
click(&mut h, at);
h.run_steps(10);
assert!(!field(&h));
// A click on another swatch while the rename waits: that one is picked, and nothing
// renames.
let picked = |h: &Harness<'_, DesignApp>| h.ctx.data(|d| d.get_temp::<String>(egui::Id::unique(super::PICKED)));
let (lemon, paper) = (name_at(&h, "Lemon Yellow Sun"), name_at(&h, "[Paper]"));
h.run_steps(20);
click(&mut h, lemon);
h.run_steps(10);
click(&mut h, lemon);
click(&mut h, paper);
h.run_steps(10);
assert!(!field(&h), "the waiting rename is cancelled");
assert_eq!(picked(&h).as_deref(), Some("[Paper]"));
// Renaming, a click on another swatch renames, and picks that one (for Delete Swatch).
click(&mut h, lemon);
h.run_steps(10);
click(&mut h, lemon);
h.run_steps(10);
assert!(field(&h));
h.event(egui::Event::Text("Lemon".into()));
h.step();
click(&mut h, paper);
h.run_steps(5);
assert!(has(&h, "Lemon") && !field(&h));
assert_eq!(picked(&h).as_deref(), Some("[Paper]"), "the swatch clicked stays picked");
}
#[test]
fn the_tint_field_keeps_each_objects_own_swatch() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());