mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
Swatch groups and the shared preset-browser folders could only be clicked, so there was no way to put them in the order the user wants. Dragging a folder header now mirrors the dock's per-group tab reorder (#2272): an accent insertion line shows where it will land, and the order is stored by name in PresetUi (UI state, groupOrder) so it saves and reloads with the rest of the state, reading as leniently as tabOrder. Because the folders share one browser, Gradients, Patterns, Styles and Shapes get it too. Tests: unit tests for the reorder and lenient/persisted order, plus an egui_kittest drag of a group header in the Swatches panel.
This commit is contained in:
1 parent
ad5fb53f68
commit
ae0e5eb731
2 files changed
+201
-6
No files matched your search
@@ -37,15 +37,71 @@ pub struct PresetUi {
|
||||
pub current_tool_only: bool,
|
||||
/// Custom Shape tool: the shape it draws.
|
||||
pub custom_shape: String,
|
||||
/// Folder/group order per panel, by name (#3658), like the dock's `tabOrder` (#2272). A panel
|
||||
/// with no entry keeps the library's order; unknown names are ignored and groups missing from
|
||||
/// a list follow it in library order. Saved with the UI state.
|
||||
#[serde(rename = "groupOrder", alias = "group_order", deserialize_with = "lenient_group_order", skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub group_order: BTreeMap<String, Vec<String>>,
|
||||
/// In-place rename: (panel, preset or "group:<name>", text).
|
||||
#[serde(skip)]
|
||||
pub renaming: Option<(String, String, String)>,
|
||||
}
|
||||
|
||||
/// Read `groupOrder` keeping what makes sense: non-object values, non-list entries and non-string
|
||||
/// names are dropped instead of failing the whole saved UI state.
|
||||
const MAX_GROUP_ORDER: usize = 10_000;
|
||||
fn lenient_group_order<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BTreeMap<String, Vec<String>>, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let mut out = BTreeMap::new();
|
||||
for (panel, names) in v.as_object().into_iter().flatten() {
|
||||
let Some(names) = names.as_array() else { continue };
|
||||
out.insert(panel.clone(), names.iter().filter_map(Value::as_str).take(MAX_GROUP_ORDER).map(str::to_owned).collect());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl PresetUi {
|
||||
pub fn shape(&self) -> &str {
|
||||
if self.custom_shape.is_empty() { tl!("Heart") } else { &self.custom_shape }
|
||||
}
|
||||
|
||||
/// Display order of the folders for `panel`, as library indices: the stored order first
|
||||
/// (names that no longer exist are dropped), then groups missing from it in library order.
|
||||
/// Mirrors the dock's per-group tab order (#2272, #3658).
|
||||
pub(crate) fn display_order(&self, panel: &str, names: &[String]) -> Vec<usize> {
|
||||
let mut out: Vec<usize> = Vec::with_capacity(names.len());
|
||||
for name in self.group_order.get(panel).into_iter().flatten() {
|
||||
if let Some(i) = names.iter().position(|n| n == name)
|
||||
&& !out.contains(&i)
|
||||
{
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
for i in 0..names.len() {
|
||||
if !out.contains(&i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Move folder `from` so it is drawn before `before` (`None` = last) for `panel`. The
|
||||
/// library's own order is stored as no entry, as the dock does for its tabs.
|
||||
pub(crate) fn move_group(&mut self, panel: &str, names: &[String], from: &str, before: Option<&str>) {
|
||||
if before == Some(from) {
|
||||
return;
|
||||
}
|
||||
let mut order: Vec<String> = self.display_order(panel, names).into_iter().filter_map(|i| names.get(i).cloned()).collect();
|
||||
let Some(at) = order.iter().position(|n| n == from) else { return };
|
||||
let moved = order.remove(at);
|
||||
let to = before.and_then(|b| order.iter().position(|n| n == b)).unwrap_or(order.len());
|
||||
order.insert(to, moved);
|
||||
if order.as_slice() == names {
|
||||
self.group_order.remove(panel);
|
||||
} else {
|
||||
self.group_order.insert(panel.to_string(), order);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PANELS: [&str; 6] =
|
||||
@@ -319,15 +375,21 @@ pub(crate) fn browser(
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut headers: Vec<(Rect, String)> = Vec::new();
|
||||
let mut headers: Vec<(usize, Rect, String)> = Vec::new();
|
||||
let mut dropped: Option<(String, Pos2)> = None;
|
||||
// Folder drag (#3658): the order the folders are drawn in, and what the pointer is doing this
|
||||
// frame (the insertion line and the drop are resolved once every header is known).
|
||||
let mut drag: Option<(usize, Pos2)> = None;
|
||||
let mut released: Option<(usize, Pos2)> = None;
|
||||
let group_names: Vec<String> = groups.iter().map(|g| g.name.clone()).collect();
|
||||
let display = st.display_order(panel, &group_names);
|
||||
egui::ScrollArea::vertical().id_salt(("preset-scroll", panel)).max_height(max_h).auto_shrink([false, true]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 2.0);
|
||||
for (gi, g) in groups.iter().enumerate() {
|
||||
for &gi in &display {
|
||||
let Some(g) = groups.get(gi) else { continue };
|
||||
let open = group_open(st, panel, gi, &g.name);
|
||||
let (hr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
if hresp.hovered() {
|
||||
let (hr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag());
|
||||
if hresp.hovered() || hresp.dragged() {
|
||||
ui.painter().rect_filled(hr, 2.0, t.hover.gamma_multiply(0.5));
|
||||
}
|
||||
let tri = Rect::from_center_size(pos2(hr.left() + 9.0, hr.center().y), vec2(12.0, 12.0));
|
||||
@@ -351,7 +413,18 @@ pub(crate) fn browser(
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
headers.push((hr, g.name.clone()));
|
||||
if hresp.dragged()
|
||||
&& let Some(p) = ui.ctx().pointer_interact_pos()
|
||||
{
|
||||
drag = Some((gi, p));
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
}
|
||||
if hresp.drag_stopped()
|
||||
&& let Some(p) = ui.ctx().pointer_interact_pos()
|
||||
{
|
||||
released = Some((gi, p));
|
||||
}
|
||||
headers.push((gi, hr, g.name.clone()));
|
||||
if !open {
|
||||
continue;
|
||||
}
|
||||
@@ -434,9 +507,32 @@ pub(crate) fn browser(
|
||||
});
|
||||
}
|
||||
}
|
||||
// Insertion line while a folder is dragged by its header.
|
||||
if let Some((from, p)) = drag {
|
||||
let before = group_drop_before(&headers, from, p.y);
|
||||
let anchor = match before {
|
||||
Some(i) => headers.iter().find(|(x, _, _)| *x == i).map(|(_, r, _)| *r),
|
||||
None => headers.last().map(|(_, r, _)| *r),
|
||||
};
|
||||
if let Some(r) = anchor {
|
||||
let y = if before.is_some() { r.top() - 1.0 } else { r.bottom() + 1.0 };
|
||||
ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent));
|
||||
}
|
||||
}
|
||||
});
|
||||
// Apply the drop now every header rect is known; the next frame draws the new order.
|
||||
if let Some((from, p)) = released {
|
||||
let before = group_drop_before(&headers, from, p.y);
|
||||
let from_name = groups.get(from).map(|g| g.name.clone());
|
||||
let before_name = before.and_then(|i| groups.get(i).map(|g| g.name.clone()));
|
||||
if let Some(from_name) = from_name {
|
||||
st.move_group(panel, &group_names, &from_name, before_name.as_deref());
|
||||
}
|
||||
}
|
||||
let recorded: Vec<(String, Rect)> = headers.iter().map(|(_, r, n)| (n.clone(), *r)).collect();
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(("preset-group-headers", panel)), recorded));
|
||||
if let Some((key, pos)) = dropped {
|
||||
if let Some((_, g)) = headers.iter().find(|(r, _)| r.contains(pos)) {
|
||||
if let Some((_, _, g)) = headers.iter().find(|(_, r, _)| r.contains(pos)) {
|
||||
ev.push(Ev::Move(key, g.clone()));
|
||||
} else if canvas.contains(pos) {
|
||||
ev.push(Ev::Drop(key, pos));
|
||||
@@ -468,6 +564,25 @@ pub(crate) fn browser(
|
||||
ev
|
||||
}
|
||||
|
||||
/// The folder (by library index) a header dragged to `y` lands before (`None` = last). `headers`
|
||||
/// is in display order; mirrors `dock::drop_before`.
|
||||
fn group_drop_before(headers: &[(usize, Rect, String)], from: usize, y: f32) -> Option<usize> {
|
||||
let hit = headers.iter().find(|(_, r, _)| y < r.center().y).map(|(i, _, _)| *i);
|
||||
match hit {
|
||||
Some(i) if i == from => Some(from),
|
||||
Some(i) => {
|
||||
let after_self = headers.iter().position(|(x, _, _)| *x == from).zip(headers.iter().position(|(x, _, _)| *x == i)).is_some_and(|(a, b)| b == a + 1);
|
||||
if after_self { Some(from) } else { Some(i) }
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Folder header rects drawn last frame (name → rect), in display order; for tests and automation.
|
||||
pub fn last_group_headers(ctx: &egui::Context, panel: &str) -> Vec<(String, Rect)> {
|
||||
ctx.data(|d| d.get_temp(egui::Id::new(("preset-group-headers", panel)))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Shared handling of the generic events through `<prefix>.edit` / `.new`.
|
||||
fn generic(app: &mut PhotocraftApp, panel: &str, prefix: &str, e: &Ev, new_params: Value) -> bool {
|
||||
let edit = format!("{prefix}.edit");
|
||||
@@ -1169,6 +1284,50 @@ mod tests {
|
||||
assert!(matches!(l.content, photocraft_doc::LayerContent::Shape(_)));
|
||||
}
|
||||
|
||||
/// #3658: a group/folder drag reorders by name, stores only a real reorder, and falls back to
|
||||
/// the library's order when the saved order matches it or names a group that's gone.
|
||||
#[test]
|
||||
fn moving_a_folder_reorders_like_the_dock_tabs() {
|
||||
let names: Vec<String> = ["Grays", "Pastel", "Pure", "Dark"].iter().map(|s| s.to_string()).collect();
|
||||
let mut st = PresetUi::default();
|
||||
assert_eq!(st.display_order("swatches", &names), vec![0, 1, 2, 3]);
|
||||
st.move_group("swatches", &names, "Pastel", None);
|
||||
assert_eq!(st.group_order.get("swatches").unwrap(), &["Grays", "Pure", "Dark", "Pastel"]);
|
||||
st.move_group("swatches", &names, "Pastel", Some("Grays"));
|
||||
assert_eq!(st.group_order.get("swatches").unwrap(), &["Pastel", "Grays", "Pure", "Dark"]);
|
||||
assert_eq!(st.display_order("swatches", &names), vec![1, 0, 2, 3]);
|
||||
// Back to the library's own order: no entry, as the dock stores its default tab order.
|
||||
st.move_group("swatches", &names, "Pastel", Some("Pure"));
|
||||
assert!(!st.group_order.contains_key("swatches"), "a no-op order is not stored");
|
||||
// Out-of-range names, moves onto themselves and unknown `before` names change nothing bad.
|
||||
for (from, before) in [("Nope", None), ("Grays", Some("Grays"))] {
|
||||
let mut m = PresetUi::default();
|
||||
m.move_group("swatches", &names, from, before);
|
||||
assert!(m.group_order.is_empty(), "{from} {before:?}");
|
||||
assert_eq!(m.display_order("swatches", &names), vec![0, 1, 2, 3]);
|
||||
}
|
||||
// A missing group in a saved order follows the rest in library order.
|
||||
let mut m = PresetUi::default();
|
||||
m.group_order.insert("swatches".into(), vec!["Dark".into(), "Grays".into()]);
|
||||
assert_eq!(m.display_order("swatches", &names), vec![3, 0, 1, 2]);
|
||||
}
|
||||
|
||||
/// #3658: the folder order persists with the UI state and reads leniently (an old state has no
|
||||
/// field, and garbage in one never fails the whole state).
|
||||
#[test]
|
||||
fn folder_order_persists_and_reads_leniently() {
|
||||
let old: PresetUi = serde_json::from_value(json!({"sizes": {"swatches": 22.0}})).unwrap();
|
||||
assert!(old.group_order.is_empty(), "an older state without the field still loads");
|
||||
let mut st = PresetUi::default();
|
||||
st.group_order.insert("swatches".into(), vec!["Dark".into(), "Grays".into()]);
|
||||
let back: PresetUi = serde_json::from_value(serde_json::to_value(&st).unwrap()).unwrap();
|
||||
assert_eq!(back.group_order.get("swatches").unwrap(), &["Dark", "Grays"]);
|
||||
for bad in [json!("x"), json!(7), json!({"swatches": "paths"}), json!({"swatches": [1, null, {"a": 1}]})] {
|
||||
let back: PresetUi = serde_json::from_value(json!({"groupOrder": bad})).unwrap();
|
||||
assert!(back.group_order.get("swatches").is_none_or(Vec::is_empty), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: preset drops mapped the pointer through the raw widget rect, ignoring the
|
||||
/// rulers' 16 px inset — a dropped shape landed 8 screen px from the pointer, and a drop on
|
||||
/// the ruler strip counted as a canvas drop. Drops now map through, and target, the same
|
||||
|
||||
@@ -286,6 +286,20 @@ mod tests {
|
||||
h.run_steps(1);
|
||||
}
|
||||
|
||||
/// Drag from `from` to `to` in steps, as when a user drags a group header.
|
||||
fn drag(h: &mut Harness<'_, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2) {
|
||||
h.event(egui::Event::PointerMoved(from));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
for i in 1..=6 {
|
||||
h.event(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 6.0)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// The first swatch: below the first group header (22 px), after the 8 px grid inset.
|
||||
fn first_cell(h: &Harness<'_, PhotocraftApp>) -> egui::Pos2 {
|
||||
h.ctx.input(|i| i.viewport_rect()).min + vec2(8.0 + 12.0 + 8.0, 22.0 + 2.0 + 12.0 + 8.0)
|
||||
@@ -397,4 +411,26 @@ mod tests {
|
||||
assert_eq!(out[0].0, "out/Swatches.ase");
|
||||
assert!(out[0].1.starts_with(b"ASEF"));
|
||||
}
|
||||
|
||||
/// #3658: dragging a group header reorders the folders, and the new order sticks in the UI
|
||||
/// state (UI state is serde, so it saves and reloads with the rest of the panel state).
|
||||
#[test]
|
||||
fn dragging_a_group_header_reorders_and_persists() {
|
||||
let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let mut h = Harness::builder().with_size(vec2(300.0, 4000.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel(app, ui), app);
|
||||
h.run_steps(2);
|
||||
let headers = crate::preset_panels::last_group_headers(&h.ctx, PANEL);
|
||||
assert!(headers.len() >= 2, "the default library has folders: {headers:?}");
|
||||
let names: Vec<String> = headers.iter().map(|(n, _)| n.clone()).collect();
|
||||
let (first, first_r) = &headers[0];
|
||||
let (last, last_r) = headers.last().unwrap();
|
||||
// Drag the last folder's header above the first; the insertion lands before it.
|
||||
drag(&mut h, last_r.center(), egui::pos2(first_r.center().x, first_r.top() + 1.0));
|
||||
let order = h.state().ui.presets_ui.display_order(PANEL, &names);
|
||||
assert_eq!(order.len(), names.len());
|
||||
assert_eq!(names.get(order[0]).map(String::as_str), Some(last.as_str()), "the dragged folder moved to the front: {order:?}");
|
||||
assert_eq!(names.get(order[1]).map(String::as_str), Some(first.as_str()));
|
||||
// The reorder is remembered by name (not by the positions it happened to have).
|
||||
assert_eq!(h.state().ui.presets_ui.group_order.get(PANEL).and_then(|o| o.first()), Some(last));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user