Libraries panel: items show as icons or in a list, toggled beside the library picker

The list view gives colours and graphics a row each with the item's name (a colour's hex value too, where it fits); items drag and drop by row there. Graphic thumbnails are shrunk on the CPU to the size they are drawn at, as egui-wgpu doesn't mipmap textures and thin strokes broke up in small tiles.
This commit is contained in:
Dan Green
2026-10-24 03:09:06 +07:00
committed by bflatastic
parent 8bdc60b995
commit 632598ba17
2 changed files with 168 additions and 34 deletions
+165 -32
View File
@@ -14,6 +14,9 @@
//! group (`library.moveItem` with `before`/`after`), or, dropped elsewhere on a group or on the //! group (`library.moveItem` with `before`/`after`), or, dropped elsewhere on a group or on the
//! ungrouped items, goes into that group or out of its own; a group dragged by its header goes //! ungrouped items, goes into that group or out of its own; a group dragged by its header goes
//! before or after another group (`library.moveGroup`). //! before or after another group (`library.moveGroup`).
//!
//! The buttons beside the library picker show the items as icons (colour chips and graphic tiles,
//! the default) or in a list, one row each with its name (a colour's hex value too).
use std::collections::HashMap; use std::collections::HashMap;
@@ -30,6 +33,12 @@ use crate::{VectorcraftApp, icons};
const TILE: f32 = 56.0; const TILE: f32 = 56.0;
/// The size of a colour chip. /// The size of a colour chip.
const CHIP: f32 = 22.0; const CHIP: f32 = 22.0;
/// The height of a colour's or style's row in the list view.
const ROW: f32 = 22.0;
/// The size of a graphic's thumbnail in the list view (its row is a little taller).
const LIST_TILE: f32 = 28.0;
/// The panel state that holds whether the items show in a list (else as icons).
const LIST_VIEW: &str = "library-list-view";
/// The height under the list the bottom bar takes (its divider and buttons) until it has been /// The height under the list the bottom bar takes (its divider and buttons) until it has been
/// measured. /// measured.
const BAR: f32 = 42.0; const BAR: f32 = 42.0;
@@ -116,6 +125,8 @@ struct Items<'a> {
fill_active: bool, fill_active: bool,
/// What a drag in progress holds of this library. /// What a drag in progress holds of this library.
grab: Option<Grab>, grab: Option<Grab>,
/// Whether the items show in a list (else colours and graphics as icons).
list: bool,
} }
impl Items<'_> { impl Items<'_> {
@@ -144,11 +155,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
return; return;
}; };
let mut act: Option<Act> = None; let mut act: Option<Act> = None;
// The library picker. let list: bool = super::pstate(ui.ctx(), LIST_VIEW);
let names: Vec<&str> = libs.all().iter().map(|(_, l)| l.name.as_str()).collect(); // The library picker, and the list / icon view toggles at its right.
if let Some((id, _)) = widgets::dropdown_names(ui, "lib-picker", &lib.name, &names, ui.available_width()).and_then(|i| libs.all().get(i)) { ui.horizontal(|ui| {
act = Some(Act::Run("library.setCurrent", json!({ "library": id }))); let names: Vec<&str> = libs.all().iter().map(|(_, l)| l.name.as_str()).collect();
} let width = (ui.available_width() - 2.0 * (24.0 + ui.spacing().item_spacing.x)).max(60.0);
if let Some((id, _)) = widgets::dropdown_names(ui, "lib-picker", &lib.name, &names, width).and_then(|i| libs.all().get(i)) {
act = Some(Act::Run("library.setCurrent", json!({ "library": id })));
}
if widgets::icon_button(ui, "dc-list-view", tl!("Show List View"), list, 24.0).clicked() {
super::set_pstate(ui.ctx(), LIST_VIEW, true);
}
if widgets::icon_button(ui, "dc-grid-view", tl!("Show Thumbnail View"), !list, 24.0).clicked() {
super::set_pstate(ui.ctx(), LIST_VIEW, false);
}
});
ui.add_space(4.0); ui.add_space(4.0);
let empty = lib.items().next().is_none() && lib.groups.is_empty(); let empty = lib.items().next().is_none() && lib.groups.is_empty();
let query = if empty { String::new() } else { widgets::search_field(ui, egui::Id::new("library-search"), tl!("Search")) }; let query = if empty { String::new() } else { widgets::search_field(ui, egui::Id::new("library-search"), tl!("Search")) };
@@ -156,7 +177,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let searching = !query.is_empty(); let searching = !query.is_empty();
let shown = |name: &str| !searching || name.to_lowercase().contains(&query); let shown = |name: &str| !searching || name.to_lowercase().contains(&query);
let grab = egui::DragAndDrop::payload::<PanelDrag>(ui.ctx()).and_then(|d| grabbed(&d, &lib_id)); let grab = egui::DragAndDrop::payload::<PanelDrag>(ui.ctx()).and_then(|d| grabbed(&d, &lib_id));
let items = Items { library: &lib_id, lib, groups: lib.group_index(), fill_active, grab }; let items = Items { library: &lib_id, lib, groups: lib.group_index(), fill_active, grab, list };
// The items shown in each section: the ungrouped ones first, then each group's. // The items shown in each section: the ungrouped ones first, then each group's.
let mut counts = vec![0usize; lib.groups.len() + 1]; let mut counts = vec![0usize; lib.groups.len() + 1];
for (kind, key, _) in lib.items().filter(|(_, _, name)| shown(name)) { for (kind, key, _) in lib.items().filter(|(_, _, name)| shown(name)) {
@@ -333,21 +354,29 @@ impl Items<'_> {
let colors: Vec<_> = lib.colors.iter().filter(|c| keep(ItemKind::Color, &c.name, &c.name)).collect(); let colors: Vec<_> = lib.colors.iter().filter(|c| keep(ItemKind::Color, &c.name, &c.name)).collect();
if !colors.is_empty() { if !colors.is_empty() {
heading(ui, tl!("Colors")); heading(ui, tl!("Colors"));
ui.horizontal_wrapped(|ui| { self.cells(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
for c in colors { for c in colors {
let (r, resp) = ui.allocate_exact_size(vec2(CHIP, CHIP), Sense::click_and_drag());
let paint = Paint::solid(c.color); let paint = Paint::solid(c.color);
widgets::swatch_tile(ui, r, &paint, false, false); let (r, resp) = self.cell(ui, CHIP, ROW);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); let chip = if self.list {
let hex = c.color.to_hex();
let detail = (!hex.eq_ignore_ascii_case(&c.name)).then_some(hex.as_str());
list_row(ui, r, &resp, &c.name, detail);
Rect::from_center_size(r.left_center() + vec2(10.0, 0.0), vec2(14.0, 14.0))
} else {
r
};
widgets::swatch_tile(ui, chip, &paint, false, false);
ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
widgets::drag_source(ui, &resp, || PanelDrag::Paint { widgets::drag_source(ui, &resp, || PanelDrag::Paint {
paint: paint.clone(), paint: paint.clone(),
params: json!({ "color": c.color }), params: json!({ "color": c.color }),
rows: None, rows: None,
library: Some((self.library.to_string(), c.name.clone())), library: Some((self.library.to_string(), c.name.clone())),
}); });
self.drop_next_to(ui, &resp, ItemKind::Color, &c.name, false, act, over); self.drop_next_to(ui, &resp, ItemKind::Color, &c.name, self.list, act, over);
let resp = resp.on_hover_text(&c.name); // The list shows the name; a chip says it when hovered.
let resp = if self.list { resp } else { resp.on_hover_text(&c.name) };
if resp.clicked() { if resp.clicked() {
let to = if self.fill_active { "fill" } else { "stroke" }; let to = if self.fill_active { "fill" } else { "stroke" };
*act = Some(Act::Run("library.use", json!({ "kind": "fillColor", "item": c.name, "to": to }))); *act = Some(Act::Run("library.use", json!({ "kind": "fillColor", "item": c.name, "to": to })));
@@ -366,13 +395,10 @@ impl Items<'_> {
} }
heading(ui, title); heading(ui, title);
for st in list { for st in list {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::click_and_drag());
if resp.hovered() { list_row(ui, r, &resp, &st.name, None);
ui.painter().rect_filled(r, 0.0, t.hover);
}
let icon = if kind == ItemKind::ParaStyle { "pilcrow" } else { "type" }; let icon = if kind == ItemKind::ParaStyle { "pilcrow" } else { "type" };
icons::paint(ui, icon, Rect::from_center_size(r.left_center() + vec2(10.0, 0.0), vec2(14.0, 14.0)), t.icon); icons::paint(ui, icon, Rect::from_center_size(r.left_center() + vec2(10.0, 0.0), vec2(14.0, 14.0)), t.icon);
ui.painter().text(r.left_center() + vec2(24.0, 0.0), egui::Align2::LEFT_CENTER, &st.name, egui::FontId::proportional(12.5), t.text);
widgets::drag_source(ui, &resp, || PanelDrag::LibraryItem { library: self.library.to_string(), kind, item: st.name.clone() }); widgets::drag_source(ui, &resp, || PanelDrag::LibraryItem { library: self.library.to_string(), kind, item: st.name.clone() });
self.drop_next_to(ui, &resp, kind, &st.name, true, act, over); self.drop_next_to(ui, &resp, kind, &st.name, true, act, over);
let resp = resp.on_hover_text(tl!("Click to apply to the selected type")); let resp = resp.on_hover_text(tl!("Click to apply to the selected type"));
@@ -386,16 +412,22 @@ impl Items<'_> {
let graphics: Vec<_> = lib.graphics.iter().filter(|g| keep(ItemKind::Graphic, &g.id, &g.name)).collect(); let graphics: Vec<_> = lib.graphics.iter().filter(|g| keep(ItemKind::Graphic, &g.id, &g.name)).collect();
if !graphics.is_empty() { if !graphics.is_empty() {
heading(ui, tl!("Graphics")); heading(ui, tl!("Graphics"));
ui.horizontal_wrapped(|ui| { self.cells(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
for g in graphics { for g in graphics {
let (r, resp) = ui.allocate_exact_size(vec2(TILE, TILE), Sense::click_and_drag()); let (r, resp) = self.cell(ui, TILE, LIST_TILE + 4.0);
tile(ui, r, self.library, g); let thumb = if self.list {
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.border }), StrokeKind::Inside); list_row(ui, r, &resp, &g.name, None);
Rect::from_min_size(r.left_center() + vec2(3.0, -LIST_TILE / 2.0), vec2(LIST_TILE, LIST_TILE))
} else {
r
};
tile(ui, thumb, self.library, g);
let edge = if resp.hovered() && !self.list { t.accent } else { t.border };
ui.painter().rect_stroke(thumb, 0.0, Stroke::new(1.0, edge), StrokeKind::Inside);
// Dragged onto the canvas, a copy is placed where it is dropped. // Dragged onto the canvas, a copy is placed where it is dropped.
widgets::drag_source(ui, &resp, || PanelDrag::LibraryGraphic { library: self.library.to_string(), item: g.id.clone() }); widgets::drag_source(ui, &resp, || PanelDrag::LibraryGraphic { library: self.library.to_string(), item: g.id.clone() });
self.drop_next_to(ui, &resp, ItemKind::Graphic, &g.id, false, act, over); self.drop_next_to(ui, &resp, ItemKind::Graphic, &g.id, self.list, act, over);
let resp = resp.on_hover_text(&g.name); let resp = if self.list { resp } else { resp.on_hover_text(&g.name) };
let place = || Act::Run("library.use", json!({ "kind": "graphic", "item": g.id })); let place = || Act::Run("library.use", json!({ "kind": "graphic", "item": g.id }));
if resp.double_clicked() { if resp.double_clicked() {
*act = Some(place()); *act = Some(place());
@@ -413,6 +445,26 @@ impl Items<'_> {
} }
} }
/// The cells `add` lays out: rows stacked downwards in the list view, else chips and tiles
/// running across and wrapping.
fn cells(&self, ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
if self.list {
add(ui);
} else {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
add(ui);
});
}
}
/// The next item's cell: a `side`-point square in the icon view, else a row `row` points tall
/// across the list.
fn cell(&self, ui: &mut Ui, side: f32, row: f32) -> (Rect, egui::Response) {
let size = if self.list { vec2(ui.available_width(), row) } else { vec2(side, side) };
ui.allocate_exact_size(size, Sense::click_and_drag())
}
/// An item of `kind` dragged over item `key`'s cell `resp` lands just before or after it, by /// An item of `kind` dragged over item `key`'s cell `resp` lands just before or after it, by
/// the pointer's half (`row`: rows stack downwards; else chips and tiles run across), in its /// the pointer's half (`row`: rows stack downwards; else chips and tiles run across), in its
/// group: an accent line shows where, and `over` is set. Dropped on itself it stays. /// group: an accent line shows where, and `over` is set. Dropped on itself it stays.
@@ -569,6 +621,26 @@ fn section_drop(ui: &Ui, items: &Items, grab: &Grab, sections: &[(Option<usize>,
} }
} }
/// An item's row `r` in the list (`resp`: its response): highlighted while hovered, `name` after
/// the item's icon and `detail` dimmed at the right, where the name leaves room for it.
fn list_row(ui: &Ui, r: Rect, resp: &egui::Response, name: &str, detail: Option<&str>) {
let t = Tokens::get(ui.ctx());
let painter = ui.painter_at(r);
if resp.hovered() {
painter.rect_filled(r, 0.0, t.hover);
}
let left = if r.height() > ROW { LIST_TILE + 10.0 } else { 24.0 };
let font = egui::FontId::proportional(12.5);
let name = painter.text(r.left_center() + vec2(left, 0.0), egui::Align2::LEFT_CENTER, name, font.clone(), t.text);
if let Some(d) = detail {
let d = painter.layout_no_wrap(d.to_string(), font, t.text_dim);
let at = r.right_center() - vec2(6.0 + d.size().x, d.size().y / 2.0);
if at.x > name.right() + 12.0 {
painter.galley(at, d, t.text_dim);
}
}
}
/// A section heading in the list. /// A section heading in the list.
fn heading(ui: &mut Ui, text: &str) { fn heading(ui: &mut Ui, text: &str) {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
@@ -579,7 +651,10 @@ fn heading(ui: &mut Ui, text: &str) {
/// Graphic `g`'s thumbnail on white in `r`. /// Graphic `g`'s thumbnail on white in `r`.
fn tile(ui: &Ui, r: Rect, library: &str, g: &LibraryGraphic) { fn tile(ui: &Ui, r: Rect, library: &str, g: &LibraryGraphic) {
ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE);
if let Some(tex) = thumbnail(ui.ctx(), library, g) { // As many pixels as the tile shows (the art fits inside a 3-point margin).
let px = (r.width().max(r.height()) - 6.0) * ui.ctx().pixels_per_point();
let px = if px.is_finite() { px.ceil().clamp(8.0, 256.0) as u32 } else { 256 };
if let Some(tex) = thumbnail(ui.ctx(), library, g, px) {
let size = tex.size_vec2(); let size = tex.size_vec2();
let k = ((r.width() - 6.0) / size.x).min((r.height() - 6.0) / size.y).min(1.0); let k = ((r.width() - 6.0) / size.x).min((r.height() - 6.0) / size.y).min(1.0);
let fit = Rect::from_center_size(r.center(), size * k); let fit = Rect::from_center_size(r.center(), size * k);
@@ -587,21 +662,27 @@ fn tile(ui: &Ui, r: Rect, library: &str, g: &LibraryGraphic) {
} }
} }
/// The texture of graphic `g`'s PNG thumbnail, decoded once. /// A thumbnail texture's library, graphic id, thumbnail length (for a changed graphic) and size.
fn thumbnail(ctx: &egui::Context, library: &str, g: &LibraryGraphic) -> Option<egui::TextureHandle> { type ThumbKey = (String, String, usize, u32);
/// The texture of graphic `g`'s PNG thumbnail at most `px` pixels a side, made once for each size.
/// It is shrunk here, smoothly: the GPU draws it without mipmaps, so a texture much larger than its
/// tile would break thin strokes up.
fn thumbnail(ctx: &egui::Context, library: &str, g: &LibraryGraphic, px: u32) -> Option<egui::TextureHandle> {
thread_local! { thread_local! {
static CACHE: crate::graphics::TexCache<HashMap<(String, String, usize), Option<egui::TextureHandle>>> = crate::graphics::TexCache::default(); static CACHE: crate::graphics::TexCache<HashMap<ThumbKey, Option<egui::TextureHandle>>> = crate::graphics::TexCache::default();
} }
let key = (library.to_string(), g.id.clone(), g.thumbnail.len()); let key = (library.to_string(), g.id.clone(), g.thumbnail.len(), px);
if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) { if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) {
return t; return t;
} }
let tex = vectorcraft_format::base64_decode(&g.thumbnail) let tex = vectorcraft_format::base64_decode(&g.thumbnail)
.and_then(|png| image::load_from_memory_with_format(&png, image::ImageFormat::Png).ok()) .and_then(|png| image::load_from_memory_with_format(&png, image::ImageFormat::Png).ok())
.map(|img| { .map(|img| {
let img = if img.width().max(img.height()) > px { img.resize(px, px, image::imageops::FilterType::Triangle) } else { img };
let rgba = img.to_rgba8(); let rgba = img.to_rgba8();
let image = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw()); let image = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw());
ctx.load_texture(format!("library-{library}-{}", g.id), image, egui::TextureOptions::LINEAR) ctx.load_texture(format!("library-{library}-{}-{px}", g.id), image, egui::TextureOptions::LINEAR)
}); });
CACHE.with(|c| { CACHE.with(|c| {
let mut c = c.borrow_mut(); let mut c = c.borrow_mut();
@@ -734,7 +815,10 @@ mod tests {
assert_eq!((lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len()), (1, 1, 1, 1)); assert_eq!((lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len()), (1, 1, 1, 1));
// The thumbnail decodes. // The thumbnail decodes.
let ctx = egui::Context::default(); let ctx = egui::Context::default();
assert!(thumbnail(&ctx, "Brand", &lib.graphics[0]).is_some()); assert!(thumbnail(&ctx, "Brand", &lib.graphics[0], 256).is_some());
// Shrunk to the size it is drawn at.
let small = thumbnail(&ctx, "Brand", &lib.graphics[0], 40).unwrap();
assert_eq!(small.size().into_iter().max(), Some(40), "{:?}", small.size());
} }
/// The panel's text: every label it shows in one frame (`query` typed in the search field). /// The panel's text: every label it shows in one frame (`query` typed in the search field).
@@ -882,6 +966,55 @@ mod tests {
assert!(!texts.iter().any(|(t, _)| t == "Drop here to take it out of its group")); assert!(!texts.iter().any(|(t, _)| t == "Drop here to take it out of its group"));
} }
/// The list toggle beside the picker shows the colours and graphics as rows with their names
/// (a colour's hex value too), the icon toggle as chips and tiles again; in the list an item
/// dropped on another's top half lands before it.
#[test]
fn the_list_view_shows_names_and_takes_drops_by_row() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("library.create", json!({"name": "Brand"})).unwrap();
for (name, color) in [("Ocean Blue", "#0044aa"), ("Sun Yellow", "#ffcc00")] {
app.run("library.add", json!({"kind": "fillColor", "color": color, "name": name})).unwrap();
}
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [id]})).unwrap();
app.run("library.add", json!({"kind": "graphic", "name": "Badge"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let shown = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t.eq_ignore_ascii_case(s));
let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]);
assert!(!shown(&texts, "Ocean Blue") && !shown(&texts, "Badge"), "icons by default: {texts:?}");
// The list toggle: the first of the two buttons at the right of the picker's row.
let row = at(&texts, "Brand");
let list_button = egui::pos2(260.0 - 8.0 - 24.0 - ctx.global_style().spacing.item_spacing.x - 12.0, row.center().y);
let click =
|pressed| egui::Event::PointerButton { pos: list_button, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame_at(&mut app, &ctx, 600.0, vec![egui::Event::PointerMoved(list_button), click(true)]);
frame_at(&mut app, &ctx, 600.0, vec![click(false)]);
assert!(crate::panels::pstate::<bool>(&ctx, LIST_VIEW), "the list toggle was clicked");
let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]);
for s in ["Ocean Blue", "#0044aa", "Sun Yellow", "#ffcc00", "Badge"] {
assert!(shown(&texts, s), "{s} in the list: {texts:?}");
}
// Sun Yellow onto Ocean Blue's top half: first.
let ocean = at(&texts, "Ocean Blue");
let drag = PanelDrag::Paint {
paint: Paint::solid(vectorcraft_color::Color::from_hex("#ffcc00").unwrap()),
params: json!({}),
rows: None,
library: Some(("Brand".into(), "Sun Yellow".into())),
};
drop_at(&mut app, &ctx, drag, egui::pos2(ocean.center().x, ocean.top() - 3.0));
let got = app.run("library.get", json!({})).unwrap();
let names: Vec<&str> = got["colors"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Sun Yellow", "Ocean Blue"]);
// Back to icons: no names.
crate::panels::set_pstate(&ctx, LIST_VIEW, false);
let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]);
assert!(!shown(&texts, "Ocean Blue"), "{texts:?}");
}
/// A long list scrolls inside the panel (its bottom bar stays in the window), and a drag held /// A long list scrolls inside the panel (its bottom bar stays in the window), and a drag held
/// near the list's bottom edge scrolls it. /// near the list's bottom edge scrolls it.
#[test] #[test]
File diff suppressed because one or more lines are too long