diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 6c7ebe681..b98681c4c 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -875,6 +875,8 @@ fn main() -> std::process::ExitCode { app.session.swatch_libraries.set_user_dir(swatches); let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string())); app.session.style_libraries.set_user_dir(styles); + // With the User Defined libraries listed, the persistent library panels reopen. + vectorcraft_ui_egui::panels::library_panel::restore(&mut app); // So do the Libraries panel's libraries; runs without preferences (agents' test // runs) keep theirs for the session only, never touching the user's. if prefs_enabled() { diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index a4152784d..561d67b02 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -305,12 +305,20 @@ pub(crate) fn panel_tabs( /// A panel popped out next to the icon column: an icon panel, or one of the tabbed group's panels /// while the dock is collapsed. Dragging its title floats it. pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) { - let Some((id, label)) = app.ui.open_panel.as_deref().and_then(|p| crate::state::all_panels().find(|(q, _)| *q == p)) else { return }; + let open = app.ui.open_panel.as_deref(); + let Some(id) = + open.and_then(|p| crate::state::all_panels().map(|(q, _)| q).find(|q| *q == p).or_else(|| crate::panels::library_panel::intern(p))) + else { + return; + }; let tab = DockTab::from_id(id); // Expanded, the group shows its panels in the dock itself; a floating panel shows in its group. if tab.is_some() && !app.ui.dock_collapsed || floating::group_of(&app.ui, id).is_some() { return; } + // A library panel (in a collapsed dock) is titled by its library's name. + let label = crate::panels::library_panel::tab_label(app, id) + .unwrap_or_else(|| crate::state::all_panels().find(|(q, _)| *q == id).map_or(id, |(_, label)| tl!(label)).to_string()); let t = Tokens::get(ctx); let screen = ctx.content_rect(); // Pinned by its right edge, 6 points left of the icon column wherever the dock's width (or its @@ -338,12 +346,10 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.set_width(width); let (strip, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover()); ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker); - let title = Rect::from_min_size( - strip.min, - vec2(ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0), - ); + let title = + Rect::from_min_size(strip.min, vec2(ui.painter().layout_no_wrap(label.clone(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0)); ui.painter().rect_filled(title, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel); - ui.painter().text(title.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, tl!(label), theme::semibold(12.0), t.text); + ui.painter().text(title.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, &label, theme::semibold(12.0), t.text); let close = Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0)); let cr = ui.interact(close, ui.id().with("close-panel"), Sense::click()); icons::paint(ui, "chevrons-right", close, if cr.hovered() { t.text } else { t.text_dim }); diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index cd5ec9f6d..a302536c5 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -122,9 +122,15 @@ pub(crate) fn group_rect_id(first: &str) -> Id { Id::new(("floating-group-rect", first)) } -/// Panel `id` (`window.panel`) as a static string and its English label, if it is a panel. +/// Panel `id` (`window.panel`) as a static string and its English label, if it is a panel (a +/// library panel's label is its id: [`label`] gives its library's name). fn panel(id: &str) -> Option<(&'static str, &'static str)> { - all_panels().find(|(p, _)| *p == id) + all_panels().find(|(p, _)| *p == id).or_else(|| crate::panels::library_panel::intern(id).map(|p| (p, p))) +} + +/// Panel `id`'s tab label: its English label, or a library panel's library name. +fn label(app: &VectorcraftApp, id: &str) -> String { + crate::panels::library_panel::tab_label(app, id).unwrap_or_else(|| panel(id).map_or(id, |(_, label)| label).to_string()) } /// The floating group holding panel `id`. @@ -816,10 +822,7 @@ fn show_set(app: &mut VectorcraftApp, ctx: &egui::Context, set: &[usize], action let tabs_width: f32 = g .ids .iter() - .map(|id| { - let label = panel(id).map_or(*id, |(_, label)| label); - ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0 - }) + .map(|id| ui.painter().layout_no_wrap(tl!(&label(app, id)).to_string(), theme::semibold(12.0), t.text).size().x + 24.0) .sum(); g.ids.iter().map(|id| dock::panel_width(id)).fold(tabs_width + 32.0, f32::max) }) @@ -882,7 +885,8 @@ fn draw_groups( let lone = g.ids.len() == 1; let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)); let tab_area = Rect::from_min_max(strip.min, pos2((menu.left() - 4.0).max(strip.left()), strip.bottom())); - let names: Vec<_> = g.ids.iter().map(|&id| (id, panel(id).map_or(id, |(_, label)| label))).collect(); + let labels: Vec = g.ids.iter().map(|id| label(app, id)).collect(); + let names: Vec<_> = g.ids.iter().zip(&labels).map(|(&id, label)| (id, label.as_str())).collect(); let selected = g.ids.iter().position(|&id| id == g.active).unwrap_or(0); let out = dock::panel_tabs(ui, &format!("native-set-tabs-{}", g.gi), tab_area, &names, selected, theme::semibold(12.0), false); for (j, resp) in out.responses { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index cdf67d3c2..e71cd258e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5125,3 +5125,5 @@ theme Appearance Външен вид @msg name is empty името е празно @msg no symbol named `{n}` няма символ с име `{n}` Enable Guides for 9-Slice Scaling Включване на водачи за 9-slice мащабиране + Persistent Постоянна + Library Panel Options Опции на панела с библиотека diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bd03381a3..58c5877af 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5149,3 +5149,9 @@ mark-style Japanese Japonské @msg a linked image can't become a gradient mesh: embed it first propojený obraz nelze převést na síť přechodu: nejprve jej vložte @msg the image has no usable size or position obraz nemá použitelnou velikost ani polohu @msg the image's pixels can't be read pixely obrazu nelze přečíst + Persistent Trvalé + Library Panel Options Možnosti panelu knihovny +@msg Persistent libraries not found: {files} Trvalé knihovny nebyly nalezeny: {files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` není panel knihovny (viz window.swatchLibrary) +@msg library panel `{given}` isn't open panel knihovny `{given}` není otevřený +@msg too many library panels were opened in this session v této relaci bylo otevřeno příliš mnoho panelů knihoven diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 16b1b98c4..9cf789ac5 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5149,3 +5149,5 @@ swatch Bright Violet Kraftig violet Text Orientation Tekstretning Thread Text Sammenkæd tekst Typing Indtastning + Persistent Vedvarende + Library Panel Options Indstillinger for bibliotekspanel diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fe5f94a01..3fe4c7acf 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4724,3 +4724,9 @@ effect Regular Regelmäßig @msg a linked image can't become a gradient mesh: embed it first ein verknüpftes Bild kann kein Verlaufsgitter werden: Betten Sie es zuerst ein @msg the image has no usable size or position das Bild hat keine verwendbare Größe oder Position @msg the image's pixels can't be read die Pixel des Bildes können nicht gelesen werden + Persistent Dauerhaft + Library Panel Options Optionen für das Bibliotheksbedienfeld +@msg Persistent libraries not found: {files} Dauerhafte Bibliotheken nicht gefunden: {files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` ist kein Bibliotheksbedienfeld (siehe window.swatchLibrary) +@msg library panel `{given}` isn't open Bibliotheksbedienfeld `{given}` ist nicht geöffnet +@msg too many library panels were opened in this session in dieser Sitzung wurden zu viele Bibliotheksbedienfelder geöffnet diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 06ce80612..8784f0e01 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5101,3 +5101,9 @@ effect Points Σημεία @msg a linked image can't become a gradient mesh: embed it first μια συνδεδεμένη εικόνα δεν μπορεί να γίνει πλέγμα διαβάθμισης: ενσωματώστε την πρώτα @msg the image has no usable size or position η εικόνα δεν έχει χρησιμοποιήσιμο μέγεθος ή θέση @msg the image's pixels can't be read τα εικονοστοιχεία της εικόνας δεν μπορούν να διαβαστούν + Persistent Μόνιμη + Library Panel Options Επιλογές πίνακα βιβλιοθήκης +@msg Persistent libraries not found: {files} Δεν βρέθηκαν μόνιμες βιβλιοθήκες: {files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) Το `{given}` δεν είναι πίνακας βιβλιοθήκης (δείτε window.swatchLibrary) +@msg library panel `{given}` isn't open ο πίνακας βιβλιοθήκης `{given}` δεν είναι ανοιχτός +@msg too many library panels were opened in this session άνοιξαν πάρα πολλοί πίνακες βιβλιοθηκών σε αυτή τη συνεδρία diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 73e10270a..45c28febb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5091,3 +5091,9 @@ mark-style Japanese Japonesas @msg a linked image can't become a gradient mesh: embed it first una imagen enlazada no puede convertirse en una malla de degradado: incrústela primero @msg the image has no usable size or position la imagen no tiene un tamaño ni una posición utilizables @msg the image's pixels can't be read no se pueden leer los píxeles de la imagen + Persistent Persistente + Library Panel Options Opciones del panel de biblioteca +@msg Persistent libraries not found: {files} No se encontraron bibliotecas persistentes: {files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` no es un panel de biblioteca (consulte window.swatchLibrary) +@msg library panel `{given}` isn't open el panel de biblioteca `{given}` no está abierto +@msg too many library panels were opened in this session se abrieron demasiados paneles de biblioteca en esta sesión diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 4fceb5391..81aa0e5aa 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5113,3 +5113,9 @@ mark-style Japanese Japanilainen @msg a linked image can't become a gradient mesh: embed it first linkitetystä kuvasta ei voi tehdä liukuväriverkkoa: upota se ensin @msg the image has no usable size or position kuvalla ei ole käyttökelpoista kokoa tai sijaintia @msg the image's pixels can't be read kuvan pikseleitä ei voi lukea + Persistent Pysyvä + Library Panel Options Kirjastopaneelin asetukset +@msg Persistent libraries not found: {files} Pysyviä kirjastoja ei löytynyt: {files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` ei ole kirjastopaneeli (katso window.swatchLibrary) +@msg library panel `{given}` isn't open kirjastopaneeli `{given}` ei ole auki +@msg too many library panels were opened in this session tässä istunnossa on avattu liian monta kirjastopaneelia diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6448eceea..dd4263fd8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5148,3 +5148,9 @@ theme Appearance Apparence @msg a linked image can't become a gradient mesh: embed it first une image liée ne peut pas devenir un filet de dégradé : incorporez-la d’abord @msg the image has no usable size or position l’image n’a ni taille ni position utilisables @msg the image's pixels can't be read impossible de lire les pixels de l’image + Persistent Persistante + Library Panel Options Options du panneau de bibliothèque +@msg Persistent libraries not found: {files} Bibliothèques persistantes introuvables : {files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` n’est pas un panneau de bibliothèque (voir window.swatchLibrary) +@msg library panel `{given}` isn't open le panneau de bibliothèque `{given}` n’est pas ouvert +@msg too many library panels were opened in this session trop de panneaux de bibliothèque ont été ouverts dans cette session diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 38ab36981..38c20cab4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5135,3 +5135,9 @@ imageTrace Outline Körvonal @msg a linked image can't become a gradient mesh: embed it first csatolt kép nem alakítható színátmenetes hálóvá: előbb ágyazza be @msg the image has no usable size or position a képnek nincs használható mérete vagy helyzete @msg the image's pixels can't be read a kép képpontjai nem olvashatók + Persistent Állandó + Library Panel Options Könyvtárpanel beállításai +@msg Persistent libraries not found: {files} Nem találhatók állandó könyvtárak: {files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) A(z) `{given}` nem könyvtárpanel (lásd: window.swatchLibrary) +@msg library panel `{given}` isn't open a(z) `{given}` könyvtárpanel nincs megnyitva +@msg too many library panels were opened in this session ebben a munkamenetben túl sok könyvtárpanel nyílt meg diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f1baabca3..201c96ba7 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5149,3 +5149,5 @@ swatch Bright Violet Violet cerah Text Orientation Orientasi teks Thread Text Untai teks Typing Mengetik + Persistent Persisten + Library Panel Options Opsi panel pustaka diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8a9619eae..0fe7f7209 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4762,3 +4762,9 @@ lighting Light Luce @msg a linked image can't become a gradient mesh: embed it first un’immagine collegata non può diventare una trama sfumata: incorporala prima @msg the image has no usable size or position l’immagine non ha dimensioni o posizione utilizzabili @msg the image's pixels can't be read impossibile leggere i pixel dell’immagine + Persistent Persistente + Library Panel Options Opzioni pannello libreria +@msg Persistent libraries not found: {files} Librerie persistenti non trovate: {files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` non è un pannello libreria (vedi window.swatchLibrary) +@msg library panel `{given}` isn't open il pannello libreria `{given}` non è aperto +@msg too many library panels were opened in this session in questa sessione sono stati aperti troppi pannelli libreria diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d1e893b4f..962caa0a0 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4803,3 +4803,9 @@ lighting Light ライト @msg a linked image can't become a gradient mesh: embed it first リンクされた画像はグラデーションメッシュにできません: 先に埋め込んでください @msg the image has no usable size or position 画像に使用できるサイズまたは位置がありません @msg the image's pixels can't be read 画像のピクセルを読み取れません + Persistent 永続 + Library Panel Options ライブラリパネルオプション +@msg Persistent libraries not found: {files} 永続ライブラリが見つかりません: {files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` はライブラリパネルではありません (window.swatchLibrary を参照) +@msg library panel `{given}` isn't open ライブラリパネル `{given}` は開いていません +@msg too many library panels were opened in this session このセッションで開いたライブラリパネルが多すぎます diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index eb4609c32..4becd3292 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4726,3 +4726,9 @@ lighting Light 광원 @msg a linked image can't become a gradient mesh: embed it first 연결된 이미지는 그라디언트 메시로 만들 수 없습니다: 먼저 포함하십시오 @msg the image has no usable size or position 이미지에 사용할 수 있는 크기나 위치가 없습니다 @msg the image's pixels can't be read 이미지의 픽셀을 읽을 수 없습니다 + Persistent 지속 + Library Panel Options 라이브러리 패널 옵션 +@msg Persistent libraries not found: {files} 지속 라이브러리를 찾을 수 없습니다: {files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}`은(는) 라이브러리 패널이 아닙니다(window.swatchLibrary 참조) +@msg library panel `{given}` isn't open 라이브러리 패널 `{given}`이(가) 열려 있지 않습니다 +@msg too many library panels were opened in this session 이 세션에서 라이브러리 패널을 너무 많이 열었습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 73d78bfdf..fca56ff67 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5149,3 +5149,5 @@ swatch Bright Violet Felviolet Text Orientation Tekstrichting Thread Text Tekst koppelen Typing Typen + Persistent Permanent + Library Panel Options Opties voor bibliotheekpaneel diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 08192d321..00e128a37 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5001,3 +5001,9 @@ mark-style Japanese Japońskie @msg a linked image can't become a gradient mesh: embed it first połączony obraz nie może stać się siatką gradientu: najpierw go osadź @msg the image has no usable size or position obraz nie ma użytecznego rozmiaru ani położenia @msg the image's pixels can't be read nie można odczytać pikseli obrazu + Persistent Trwały + Library Panel Options Opcje panelu biblioteki +@msg Persistent libraries not found: {files} Nie znaleziono trwałych bibliotek: {files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` nie jest panelem biblioteki (zobacz window.swatchLibrary) +@msg library panel `{given}` isn't open panel biblioteki `{given}` nie jest otwarty +@msg too many library panels were opened in this session w tej sesji otwarto zbyt wiele paneli bibliotek diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 75369c2c9..42db059fb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5066,3 +5066,9 @@ mark-style Japanese Japonesas @msg a linked image can't become a gradient mesh: embed it first uma imagem vinculada não pode virar uma malha de degradê: incorpore-a primeiro @msg the image has no usable size or position a imagem não tem tamanho nem posição utilizáveis @msg the image's pixels can't be read não é possível ler os pixels da imagem + Persistent Persistente + Library Panel Options Opções do Painel de Biblioteca +@msg Persistent libraries not found: {files} Bibliotecas persistentes não encontradas: {files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` não é um painel de biblioteca (consulte window.swatchLibrary) +@msg library panel `{given}` isn't open o painel de biblioteca `{given}` não está aberto +@msg too many library panels were opened in this session foram abertos painéis de biblioteca demais nesta sessão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index aefc19aee..f3172f779 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4758,3 +4758,9 @@ lighting Light Свет @msg a linked image can't become a gradient mesh: embed it first связанное изображение нельзя превратить в сетчатый градиент: сначала встройте его @msg the image has no usable size or position у изображения нет пригодного размера или положения @msg the image's pixels can't be read не удаётся прочитать пиксели изображения + Persistent Постоянная + Library Panel Options Параметры панели библиотеки +@msg Persistent libraries not found: {files} Постоянные библиотеки не найдены: {files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` — не панель библиотеки (см. window.swatchLibrary) +@msg library panel `{given}` isn't open панель библиотеки `{given}` не открыта +@msg too many library panels were opened in this session в этом сеансе открыто слишком много панелей библиотек diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index e13931186..9c9249909 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5105,3 +5105,9 @@ theme Appearance Utseende @msg a linked image can't become a gradient mesh: embed it first en länkad bild kan inte bli ett övertoningsnät: bädda in den först @msg the image has no usable size or position bilden har ingen användbar storlek eller position @msg the image's pixels can't be read bildens pixlar kan inte läsas + Persistent Beständig + Library Panel Options Alternativ för bibliotekspanelen +@msg Persistent libraries not found: {files} Beständiga bibliotek hittades inte: {files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` är inte en bibliotekspanel (se window.swatchLibrary) +@msg library panel `{given}` isn't open bibliotekspanelen `{given}` är inte öppen +@msg too many library panels were opened in this session för många bibliotekspaneler har öppnats i den här sessionen diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 90faf5e27..d63b0b27e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4742,3 +4742,9 @@ lighting Light Світло @msg a linked image can't become a gradient mesh: embed it first зв’язане зображення не можна перетворити на сітчастий градієнт: спочатку вбудуйте його @msg the image has no usable size or position зображення не має придатного розміру чи положення @msg the image's pixels can't be read не вдається прочитати пікселі зображення + Persistent Постійна + Library Panel Options Параметри панелі бібліотеки +@msg Persistent libraries not found: {files} Постійні бібліотеки не знайдено: {files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` — не панель бібліотеки (див. window.swatchLibrary) +@msg library panel `{given}` isn't open панель бібліотеки `{given}` не відкрита +@msg too many library panels were opened in this session у цьому сеансі відкрито забагато панелей бібліотек diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 703a62194..88dbab7b6 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4742,3 +4742,9 @@ theme Appearance Giao diện @msg a linked image can't become a gradient mesh: embed it first không thể biến ảnh liên kết thành lưới chuyển sắc: hãy nhúng ảnh trước @msg the image has no usable size or position ảnh không có kích thước hoặc vị trí dùng được @msg the image's pixels can't be read không đọc được các điểm ảnh của ảnh + Persistent Lưu giữ + Library Panel Options Tùy chọn bảng thư viện +@msg Persistent libraries not found: {files} Không tìm thấy thư viện lưu giữ: {files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` không phải là bảng thư viện (xem window.swatchLibrary) +@msg library panel `{given}` isn't open bảng thư viện `{given}` chưa được mở +@msg too many library panels were opened in this session đã mở quá nhiều bảng thư viện trong phiên này diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 71910103e..bfc4ad1d4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4985,3 +4985,9 @@ lighting Light 光源 @msg a linked image can't become a gradient mesh: embed it first 链接的图像无法转换为渐变网格:请先嵌入它 @msg the image has no usable size or position 图像没有可用的尺寸或位置 @msg the image's pixels can't be read 无法读取图像的像素 + Persistent 持久 + Library Panel Options 库面板选项 +@msg Persistent libraries not found: {files} 找不到持久库:{files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` 不是库面板 (请参阅 window.swatchLibrary) +@msg library panel `{given}` isn't open 库面板 `{given}` 未打开 +@msg too many library panels were opened in this session 本次会话中打开的库面板过多 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c339769b0..21a188c4f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4058,3 +4058,5 @@ lighting Light 光源 @msg name is empty 名稱為空 @msg no symbol named `{n}` 沒有名為 `{n}` 的符號 Enable Guides for 9-Slice Scaling 啟用 9-slice 縮放參考線 + Persistent 持久 + Library Panel Options 資料庫面板選項 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 65f4c4397..1ea705cbf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1387,6 +1387,7 @@ impl VectorcraftApp { if self.ui.toolbar && self.ui.screen_mode < 3 { toolbar::show(self, ui); } + panels::library_panel::tidy(self); if self.ui.dock && self.ui.screen_mode < 3 { dock::show(self, ui); } @@ -1398,7 +1399,6 @@ impl VectorcraftApp { }); dock::floating_panel(self, &ctx); floating::show(self, &ctx); - panels::library_panel::show_window(self, &ctx); dialogs::show(self, &ctx); palette::show(self, &ctx); if self.custom_titlebar { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 788e58248..690cbc883 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -495,13 +495,19 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.swatchLibrary", "Swatch Library", "", - "{library: id or name (see swatch.library.list) | null (close)} open the read-only library panel on a swatch library (UI state `library_panel`); clicking a swatch there runs swatch.library.add with apply → {open, name, count}", + "{library: id or name (see swatch.library.list) | null (close every swatch library panel)} open the swatch library in a read-only panel of its own, floating (panel id `library:swatches:`, which window.panel.* moves, docks and closes), or bring its panel forward when it is open; clicking a swatch there runs swatch.library.add with apply. ui.libraryPanel sets its view, find field and Persistent (ui.inspect: ui.library_panels) → {open: id, panel, name, count}", ), ( "window.swatchLibrary.other", "Other Library…", "", - "{path?} (default: pick a file) load a .vcswatches, .gpl, .ase or .acb (color book) library, or another document's swatches (engine: swatch.library.load), and open it in the library panel", + "{path?} (default: pick a file) load a .vcswatches, .gpl, .ase or .acb (color book) library, or another document's swatches (engine: swatch.library.load), and open it in its library panel", + ), + ( + "ui.libraryPanel", + "Library Panel Options", + "", + "{panel: a library panel's id (`library:swatches:pastels`, as window.swatchLibrary answers), library?: id or name of another library of its kind to show in the panel (Previous / Next Library; one open in a panel of its own is brought forward instead), view?: smallThumb|mediumThumb|largeThumb|smallList|largeList, showFind?: bool, persistent?: bool (reopen the panel when the app starts)} set an open library panel, or a closed persistent one's view, Show Find Field and Persistent; kept with the preferences (ui.inspect: ui.library_panels) → {panel, kind, library, view, showFind, persistent, open}", ), ( "ui.saveSwatchLibrary", @@ -589,13 +595,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.graphicStyleLibrary", "Graphic Style Library", "", - "{library: id or name (see graphicStyle.libraries) | null (close)} open the read-only library panel on a graphic style library (UI state `library_panel`, kind graphicStyles); clicking a style there runs graphicStyle.addFromLibrary with apply (Alt: add) → {open, name, count}", + "{library: id or name (see graphicStyle.libraries) | null (close every graphic style library panel)} open the graphic style library in a read-only panel of its own, floating (panel id `library:graphicStyles:`), or bring its panel forward when it is open; clicking a style there runs graphicStyle.addFromLibrary with apply (Alt: add). ui.libraryPanel sets its view, find field and Persistent → {open: id, panel, name, count}", ), ( "window.graphicStyleLibrary.other", "Other Library…", "", - "{path?} (default: pick a file) load a .vcstyles library, or another document's graphic styles (engine: graphicStyle.loadLibrary), and open it in the library panel", + "{path?} (default: pick a file) load a .vcstyles library, or another document's graphic styles (engine: graphicStyle.loadLibrary), and open it in its library panel", ), ( "ui.saveGraphicStyleLibrary", @@ -992,10 +998,14 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ]; /// Canonical panel id for `window.panel`: a dock tab's or an icon panel's id or display label, -/// matched case-insensitively (`"Layers"`, `"swatches"`, `"Color Guide"`). +/// matched case-insensitively (`"Layers"`, `"swatches"`, `"Color Guide"`), or a library panel's id +/// as it is (`library:swatches:pastels`). pub(crate) fn normalize_panel(input: &str) -> Option<&'static str> { let name = input.trim(); - crate::state::all_panels().find(|(id, label)| name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label)).map(|(id, _)| id) + crate::state::all_panels() + .find(|(id, label)| name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label)) + .map(|(id, _)| id) + .or_else(|| crate::panels::library_panel::intern(input)) } /// An optional `{key?: bool}` param: none when it is omitted or null (the commands then toggle). @@ -1405,6 +1415,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::library_panel::float(app, p), (Some(p), None) => { app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) }; app.ui.dock = true; @@ -1538,6 +1550,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::flattener_preview::command(app, p), "window.graphicStyleLibrary" => crate::panels::graphic_styles::open_library(app, p), + "ui.libraryPanel" => crate::panels::library_panel::command(app, p), "window.graphicStyleLibrary.other" => crate::panels::graphic_styles::other_library(app, s("path")), "ui.saveGraphicStyleLibrary" => { let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 7e1df99c4..a90c4bf5c 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -625,6 +625,13 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { display .floating .retain(|group| !app.ui.floating_panels.iter().any(|native| decorated(native) && native.panels.iter().any(|id| group.panels.contains(id)))); + // Library panels are titled by their libraries' names. + let library_labels: Vec<(String, String)> = layout + .panels() + .into_iter() + .filter_map(|panel| crate::panels::library_panel::tab_label(app, panel).map(|label| (panel.clone(), label))) + .collect(); + let library_label = |panel: &str| library_labels.iter().find(|(p, _)| p == panel).map(|(_, label)| label.clone()); // Floating groups lie on the canvas, not on the dock's card: reserve a fill under each one // before the dock draws it, sized once it is placed, so the canvas doesn't show through (#2421). let fills: Vec<_> = display @@ -646,8 +653,16 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { let info = crate::state::DockTab::from_id(panel) .map(|tab| tab.info()) .or_else(|| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == panel).copied()); - if let Some((_, label, icon)) = info - && crate::widgets::icon_button(ui, icon, tl!(label), app.ui.open_panel.as_ref() == Some(panel), 30.0).clicked() + let label = info.map(|(_, label, _)| tl!(label).to_string()).or_else(|| library_label(panel)); + if let Some(label) = label + && crate::widgets::icon_button( + ui, + info.map_or("library", |(_, _, icon)| icon), + &label, + app.ui.open_panel.as_ref() == Some(panel), + 30.0, + ) + .clicked() { app.ui.open_panel = if app.ui.open_panel.as_ref() == Some(panel) { None } else { Some(panel.clone()) }; } @@ -675,7 +690,13 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { ui, &display, &style, - |id| crate::state::all_panels().find(|p| p.0 == id).map(|p| tl!(p.1).to_string()).unwrap_or_else(|| id.clone()), + |id| { + crate::state::all_panels() + .find(|p| p.0 == id) + .map(|p| tl!(p.1).to_string()) + .or_else(|| library_label(id)) + .unwrap_or_else(|| id.clone()) + }, |_| craft_ui::docking::Permissions::default(), |_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() }, &mut content, diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index f0a556d51..01b6ca9ba 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -640,3 +640,35 @@ fn floating_group_paints_an_opaque_background() { assert!(opaque_at(pos2(690.0, 210.0)), "tab strip is transparent"); assert!(opaque_at(pos2(690.0, 610.0)), "body is transparent"); } + +/// Library panels (#2603) float in the shared layout titled by their libraries' names, and their +/// menu's Persistent item keeps them for the next launch. +#[test] +fn library_panels_float_with_their_names_and_a_persistent_menu_item() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + for (library, x) in [("pastels", 40), ("brights", 420)] { + let panel = format!("library:swatches:{library}"); + app.run("window.swatchLibrary", json!({"library": library})).unwrap(); + app.run("window.panel.float", json!({"panel": panel, "x": x, "y": 100})).unwrap(); + } + let mut h = harness(app, vec2(1280.0, 900.0)); + for name in ["Pastels", "Brights"] { + assert!(h.query_all_by_label(name).next().is_some(), "the {name} tab"); + } + let click = |h: &mut Harness<'static, VectorcraftApp>, at: Pos2| { + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + }; + let menu = h.query_all_by_label("Panel menu").find(|node| node.rect().center().x < 400.0).expect("the Pastels panel's menu").rect().center(); + click(&mut h, menu); + let item = h.query_all_by_label(" Persistent").next().expect("Persistent is in the panel menu").rect().center(); + click(&mut h, item); + let persistent: Vec<&str> = h.state().ui.library_panels.iter().filter(|p| p.persistent).map(|p| p.id.as_str()).collect(); + assert_eq!(persistent, ["pastels"]); +} diff --git a/crates/ui-egui/src/panels/color_guide.rs b/crates/ui-egui/src/panels/color_guide.rs index 8de5ff1eb..459d5a3ab 100644 --- a/crates/ui-egui/src/panels/color_guide.rs +++ b/crates/ui-egui/src/panels/color_guide.rs @@ -103,7 +103,7 @@ fn limit_menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } ui.separator(); - chosen = library_panel::library_items(ui, &colour_libraries(app), Some(&cur)).or(chosen); + chosen = library_panel::library_items(ui, &colour_libraries(app), &|id| id == cur).or(chosen); if let Some(key) = chosen { set_limit(app, &json!({ "library": key })).ok(); clear_selection(ui.ctx()); diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index 6e488ca65..d20e54a22 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -6,7 +6,7 @@ //! to the art it is dropped on (`canvas::panel_drop`); art dragged off the canvas or the Appearance //! panel's thumbnail dropped on the panel becomes a new style. Holding the right button on a style //! shows it large, on the selected object when there is one. The Control bar's Style picker lists -//! the same tiles ([`picker`]). Graphic style libraries open in the library panel +//! the same tiles ([`picker`]). Graphic style libraries open in library panels //! ([`GraphicStyleLibraries`]). use std::cell::RefCell; @@ -479,7 +479,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let has_sel = selection_len(app) > 0; let linked = app.session.selection_graphic_style().is_some(); widgets::bottom_bar(ui, |ui| { - let open = app.ui.library_panel.as_ref().is_some_and(|o| o.kind == GraphicStyleLibraries::KIND); + let open = library_panel::any_open::(&app.ui); let lr = widgets::icon_button(ui, "library", GraphicStyleLibraries::MENU, open, 24.0); egui::Popup::menu(&lr).show(|ui| { ui.set_min_width(200.0); @@ -681,13 +681,15 @@ fn describe(g: &GraphicStyle) -> String { parts.join(", ") } -/// Graphic style libraries in the library panel (engine: `graphicStyle.libraries`, +/// Graphic style libraries in library panels (engine: `graphicStyle.libraries`, /// `graphicStyle.addFromLibrary`…). pub(crate) struct GraphicStyleLibraries; impl LibraryKind for GraphicStyleLibraries { const KIND: &'static str = "graphicStyles"; const OPEN: &'static str = "window.graphicStyleLibrary"; + const LIST: &'static str = "graphicStyle.libraries"; + const LOAD: &'static str = "graphicStyle.loadLibrary"; const MENU: &'static str = "Graphic Style Libraries Menu"; const ADD: &'static str = "Add to Graphic Styles"; /// Styles need room to show their effects. @@ -702,6 +704,12 @@ impl LibraryKind for GraphicStyleLibraries { fn get(app: &VectorcraftApp, id: &str) -> Option<(String, Self::Lib)> { stylelib::library(&app.session, id).map(|(info, lib)| (info.name, shown_library(lib))) } + fn find(app: &VectorcraftApp, key: &str) -> Option<(String, String, usize)> { + stylelib::library(&app.session, key).map(|(info, lib)| (info.id, info.name, lib.len())) + } + fn path(app: &VectorcraftApp, id: &str) -> Option { + app.session.style_libraries.path(id).map(str::to_string) + } fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); lib.iter().filter(|s| found(&s.style().name)).map(|s| Row { name: &s.style().name, item: Some(s) }).collect() @@ -740,15 +748,13 @@ impl LibraryKind for GraphicStyleLibraries { } } -/// `window.graphicStyleLibrary {library}`: open a graphic style library in the library panel -/// (`library` null closes it). +/// `window.graphicStyleLibrary {library}`: open a graphic style library in its own panel +/// (`library` null closes every graphic style library panel). pub(crate) fn open_library(app: &mut VectorcraftApp, p: &Value) -> Result { - library_panel::open_command::(app, p, "graphicStyle.libraries", |app, key| { - stylelib::library(&app.session, key).map(|(info, lib)| (info.id, info.name, lib.len())) - }) + library_panel::open_command::(app, p) } -/// `graphicStyle.loadLibrary` params, then open the library in the panel. +/// `graphicStyle.loadLibrary` params, then open the library in its panel. pub(crate) fn load_library(app: &mut VectorcraftApp, params: Value) -> Result { let r = app.run("graphicStyle.loadLibrary", params)?; open_library(app, &json!({ "library": r["library"] })) diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index ae02d1cbf..345408428 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -53,7 +53,7 @@ fn library_label(app: &VectorcraftApp, key: &str) -> String { fn library_list(app: &VectorcraftApp, ui: &mut Ui, current: &str) -> Option { let document = menu_item(ui, swatches::DOCUMENT_SWATCHES, true, current == DOCUMENT_SWATCHES).then(|| DOCUMENT_SWATCHES.to_string()); ui.separator(); - library_panel::library_items(ui, &swatches::colour_libraries(app), Some(current)).or(document) + library_panel::library_items(ui, &swatches::colour_libraries(app), &|id| id == current).or(document) } /// Panel state: the preset name, the current parameters, the view and the last result's counts. diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 0d0c1f2af..32c30bd04 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -1,30 +1,62 @@ -//! The library panel: a floating, read-only view of one library (swatch and graphic style -//! libraries; any kind of library implementing [`LibraryKind`]) with thumbnail and list views, a -//! find field, the libraries menu and previous / next arrows. A plain click on an item adds it to -//! the document and applies it; Shift and Cmd/Ctrl-clicks select items (and folders) for Add to …. +//! Library panels: read-only views of one library each (swatch and graphic style libraries; any +//! kind of library implementing [`LibraryKind`]) with thumbnail and list views, a find field, the +//! libraries menu and previous / next arrows. A plain click on an item adds it to the document and +//! applies it; Shift and Cmd/Ctrl-clicks select items (and folders) for Add to …. //! -//! Which library is open lives in `UiState::library_panel` (`window.swatchLibrary` and -//! `window.graphicStyleLibrary` open one), so agents can open and read it; the view, find text and -//! selection are panel state. +//! As in Illustrator (, +//! "Swatch libraries"), each open library is a panel of its own, `library::` +//! ([`panel_id`]), that floats, tabs, docks and closes as any panel of the shared docking system: +//! `window.swatchLibrary` and `window.graphicStyleLibrary` open one, or bring it forward when it is +//! open. `UiState::library_panels` keeps each one's view, Show Find Field and Persistent +//! (`ui.libraryPanel`); a persistent panel reopens when the app starts ([`restore`]). The find text +//! and the selection are panel state. -use egui::{CornerRadius, Rect, Sense, Ui, vec2}; +use std::sync::{Mutex, PoisonError}; + +use egui::{Rect, Sense, Ui}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use vectorcraft_engine::cmd::swatchlib::LibraryInfo; use super::swatches::{View, click_selection, folder_tile, list_row, list_row_name, tile_grid}; use super::{pstate, set_pstate}; -use crate::theme::{self, Tokens}; +use crate::VectorcraftApp; +use crate::state::UiState; use crate::widgets::{self, menu_item}; -use crate::{VectorcraftApp, icons}; -/// The library open in the library panel. +/// What every library panel's id starts with. +pub const PREFIX: &str = "library:"; + +/// The most library panels one session opens: their ids are interned ([`intern`]). +const MAX_PANELS: usize = 256; + +/// A library panel's settings: one per library opened this session, and the persistent ones +/// (also once closed), most recently opened or brought forward last. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct OpenLibrary { +pub struct LibraryPanel { /// The kind of library ([`LibraryKind::KIND`], e.g. "swatches"). pub kind: String, /// The library's id. pub id: String, + /// The file a loaded library was read from, which [`restore`] reads it from again. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + /// The view picked from the panel menu (none: the kind's, [`LibraryKind::VIEW`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub view: Option, + /// Show Find Field is off. + #[serde(default)] + pub hide_find: bool, + /// Persistent: the panel reopens when the app starts. + #[serde(default)] + pub persistent: bool, +} + +impl LibraryPanel { + /// The panel's id. + pub fn panel(&self) -> String { + panel_id(&self.kind, &self.id) + } } /// A library as the libraries menu lists it. @@ -41,17 +73,21 @@ pub(crate) struct Row<'a, I> { pub item: Option<&'a I>, } -/// One kind of library the panel shows. +/// One kind of library the panels show. pub(crate) trait LibraryKind { - /// `OpenLibrary::kind`, and the prefix of the panel's state keys. + /// `LibraryPanel::kind`, and the middle part of its panels' ids. const KIND: &'static str; - /// The UI command that opens a library in the panel (`{library: id}`). + /// The UI command that opens a library's panel (`{library: id}`). const OPEN: &'static str; + /// The engine command listing the libraries, for error messages. + const LIST: &'static str; + /// The engine command loading a library from a file (`{path}` → `{library}`). + const LOAD: &'static str; /// The libraries menu's tooltip ("Swatch Libraries Menu"). const MENU: &'static str; /// What adding the selection is called ("Add to Swatches"). const ADD: &'static str; - /// The view the panel shows until another is picked. + /// The view a panel shows until another is picked. const VIEW: View = View::MediumThumb; /// A library's contents, cheap to clone. type Lib; @@ -60,6 +96,10 @@ pub(crate) trait LibraryKind { fn list(app: &VectorcraftApp) -> Vec; /// Library `id`: its name and contents. fn get(app: &VectorcraftApp, id: &str) -> Option<(String, Self::Lib)>; + /// Library `key` (an id or a name). + fn find(app: &VectorcraftApp, key: &str) -> Found; + /// The file library `id` was read from, if any. + fn path(app: &VectorcraftApp, id: &str) -> Option; /// The rows matching the find field's `query` (lower case; empty: all). fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec>; /// An item's thumbnail in `r`. @@ -78,31 +118,236 @@ pub(crate) trait LibraryKind { fn menu_tail(_app: &mut VectorcraftApp, _ui: &mut Ui) {} } -/// The state key `key` of `K`'s panel. -fn key(key: &str) -> String { - format!("{}-library-{key}", K::KIND) +/// A library looked up by its id or name: its id, name and item count. +pub(crate) type Found = Option<(String, String, usize)>; + +/// What code that knows a library panel by its id alone needs of its kind. +#[derive(Clone, Copy)] +pub(crate) struct Kind { + pub kind: &'static str, + list: &'static str, + load: &'static str, + view: View, + name: fn(&VectorcraftApp, &str) -> Option, + find: fn(&VectorcraftApp, &str) -> Found, + path: fn(&VectorcraftApp, &str) -> Option, + body: fn(&mut VectorcraftApp, &mut Ui, &str), + menu: fn(&mut VectorcraftApp, &mut Ui, &str), } -/// The view of `K`'s panel. -fn view(ctx: &egui::Context) -> View { - pstate::>(ctx, &key::("view")).unwrap_or(K::VIEW) -} - -/// The interaction id of the tile or row of `name`. -pub(crate) fn tile_id(name: &str) -> egui::Id { - egui::Id::new(("library-tile", K::KIND, name)) -} - -/// Draw the open library panel, if any. -pub fn show_window(app: &mut VectorcraftApp, ctx: &egui::Context) { - let Some(open) = app.ui.library_panel.clone() else { return }; - match open.kind.as_str() { - super::swatches::SwatchLibraries::KIND => window::(app, ctx, &open.id), - super::graphic_styles::GraphicStyleLibraries::KIND => window::(app, ctx, &open.id), - _ => app.ui.library_panel = None, +fn kind_of() -> Kind { + Kind { + kind: K::KIND, + list: K::LIST, + load: K::LOAD, + view: K::VIEW, + name: |app, id| K::get(app, id).map(|(name, _)| name), + find: K::find, + path: K::path, + body: body::, + menu: panel_menu::, } } +/// The kind of library `kind` names. +fn kind(kind: &str) -> Option { + [kind_of::(), kind_of::()].into_iter().find(|k| k.kind == kind) +} + +/// The id of the panel of library `id` of `kind` (`library:swatches:pastels`). +pub fn panel_id(kind: &str, id: &str) -> String { + format!("{PREFIX}{kind}:{id}") +} + +/// The kind and library id of library panel `panel`. +pub(crate) fn parse(panel: &str) -> Option<(Kind, &str)> { + let (k, id) = panel.strip_prefix(PREFIX)?.split_once(':')?; + if id.is_empty() || panel.len() > 1024 { + return None; + } + Some((kind(k)?, id)) +} + +/// Library panel `panel` as the `&'static str` the docking code keeps panel ids as +/// ([`crate::menus::normalize_panel`]); `None` when it isn't a library panel's id. +/// shortcut: each id is leaked once, at most `MAX_PANELS` of them (more libraries than one session +/// opens); make the docking code keep owned ids if that ever binds. +pub(crate) fn intern(panel: &str) -> Option<&'static str> { + parse(panel)?; + static IDS: Mutex> = Mutex::new(Vec::new()); + let mut ids = IDS.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(id) = ids.iter().find(|id| **id == panel) { + return Some(id); + } + if ids.len() >= MAX_PANELS { + return None; + } + let id: &'static str = Box::leak(panel.into()); + ids.push(id); + Some(id) +} + +/// [`intern`], failing as a command does. +fn registered(panel: &str) -> Result<&'static str, String> { + intern(panel).ok_or_else(|| "too many library panels were opened in this session".into()) +} + +/// Is panel `panel` open (in the dock or floating)? +pub(crate) fn is_open(ui: &UiState, panel: &str) -> bool { + ui.docking.as_ref().is_some_and(|l| l.panels().iter().any(|p| p.as_str() == panel)) || crate::floating::group_of(ui, panel).is_some() +} + +/// The open library panels. +fn open_panels(ui: &UiState) -> Vec { + let mut out: Vec = vec![]; + let docked = ui.docking.iter().flat_map(|l| l.panels()); + for p in docked.chain(ui.floating_panels.iter().flat_map(|g| &g.panels)) { + if p.starts_with(PREFIX) && !out.contains(p) { + out.push(p.clone()); + } + } + out +} + +/// Is a library panel of kind `K` open? +pub(crate) fn any_open(ui: &UiState) -> bool { + open_panels(ui).iter().any(|p| parse(p).is_some_and(|(k, _)| k.kind == K::KIND)) +} + +/// The library panel opened or brought forward last that is open. +#[cfg(test)] +pub(crate) fn last_open(ui: &UiState) -> Option<&LibraryPanel> { + ui.library_panels.iter().rev().find(|p| is_open(ui, &p.panel())) +} + +fn settings<'a>(ui: &'a UiState, kind: &str, id: &str) -> Option<&'a LibraryPanel> { + ui.library_panels.iter().find(|p| p.kind == kind && p.id == id) +} + +/// The settings of library `id`'s panel, made when it has none. +fn settings_mut<'a>(app: &'a mut VectorcraftApp, k: Kind, id: &str) -> Option<&'a mut LibraryPanel> { + if settings(&app.ui, k.kind, id).is_none() { + let path = if id.starts_with("loaded/") { (k.path)(app, id) } else { None }; + app.ui.library_panels.push(LibraryPanel { kind: k.kind.into(), id: id.into(), path, view: None, hide_find: false, persistent: false }); + } + app.ui.library_panels.iter_mut().find(|p| p.kind == k.kind && p.id == id) +} + +/// Library panel `panel`'s tab label: its library's name (a built-in one's in the UI language). +pub(crate) fn tab_label(app: &VectorcraftApp, panel: &str) -> Option { + let (k, id) = parse(panel)?; + (k.name)(app, id).map(|name| library_name(id, &name).to_string()) +} + +/// Library panel `panel`'s contents (the dock or its floating group draws its tab and menu button). +pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut Ui, panel: &str) { + if let Some((k, id)) = parse(panel) { + (k.body)(app, ui, id); + } +} + +/// Library panel `panel`'s (≡) menu items. +pub(crate) fn menu(app: &mut VectorcraftApp, ui: &mut Ui, panel: &str) { + if let Some((k, id)) = parse(panel) { + (k.menu)(app, ui, id); + } +} + +/// Per frame, before the panels are drawn: close the panels of libraries that are gone (a User +/// Defined library whose file was deleted). +pub fn tidy(app: &mut VectorcraftApp) { + for panel in open_panels(&app.ui) { + if parse(&panel).is_some_and(|(k, id)| (k.name)(app, id).is_none()) { + close(app, &panel); + } + } +} + +fn close(app: &mut VectorcraftApp, panel: &str) { + if let Err(e) = app.run("window.panel.close", json!({ "panel": panel })) { + app.status(e); + } +} + +/// At startup, after the preferences are restored: the persistent library panels reopen, where the +/// saved layout has them or floating, their libraries read again (a loaded one from its file; one +/// that can't be found is left out with a note in the status bar); the others closed with the app. +pub fn restore(app: &mut VectorcraftApp) { + let saved = std::mem::take(&mut app.ui.library_panels); + let mut missing = vec![]; + for mut p in saved.into_iter().filter(|p| p.persistent).take(MAX_PANELS) { + let Some(k) = kind(&p.kind) else { continue }; + if (k.name)(app, &p.id).is_none() + && let Some(path) = p.path.clone() + // A file now in the user library folder loads as that User Defined library. + && let Ok(id) = app.run(k.load, json!({ "path": path })).map(|r| r["library"].as_str().unwrap_or_default().to_string()) + && id != p.id + && rename(&mut app.ui, &p.panel(), &panel_id(k.kind, &id)).is_ok() + { + p.id = id; + } + match (k.name)(app, &p.id) { + Some(_) => app.ui.library_panels.push(p), + None => missing.push(p.path.unwrap_or(p.id)), + } + } + let kept: Vec = app.ui.library_panels.iter().map(LibraryPanel::panel).collect(); + for panel in open_panels(&app.ui).into_iter().filter(|p| !kept.contains(p)) { + close(app, &panel); + } + app.ui.docking_hidden.retain(|p, _| !p.starts_with(PREFIX) || kept.contains(p)); + for panel in kept.into_iter().filter(|p| !is_open(&app.ui, p)).collect::>() { + if let Err(e) = float(app, &panel) { + missing.push(format!("{panel} ({e})")); + } + } + if !missing.is_empty() { + let files = missing.join(", "); + app.status(format!("Persistent libraries not found: {files}")); + } +} + +/// Replace panel `from` with panel `to` (not open) where it is: in the dock or floating group it +/// is in, and as the place it goes back to. +fn rename(ui: &mut UiState, from: &str, to: &str) -> Result<(), String> { + fn swap(v: &mut Value, from: &str, to: &str) { + match v { + Value::String(s) if s == from => *s = to.into(), + Value::Array(a) => a.iter_mut().for_each(|v| swap(v, from, to)), + Value::Object(o) => o.values_mut().for_each(|v| swap(v, from, to)), + _ => {} + } + } + // Panel ids are the only strings of a layout and a location. + fn renamed(t: &T, from: &str, to: &str) -> Result { + let mut v = serde_json::to_value(t).map_err(|e| e.to_string())?; + swap(&mut v, from, to); + serde_json::from_value(v).map_err(|e| e.to_string()) + } + registered(to)?; + let docking = ui.docking.as_ref().map(|l| renamed(l, from, to)).transpose()?; + let mut hidden = std::collections::BTreeMap::new(); + for (p, at) in &ui.docking_hidden { + hidden.insert(if p == from { to.to_string() } else { p.clone() }, renamed(at, from, to)?); + } + ui.docking = docking; + ui.docking_hidden = hidden; + for p in ui.floating_panels.iter_mut().flat_map(|g| g.panels.iter_mut()).chain(ui.open_panel.as_mut()).filter(|p| p.as_str() == from) { + *p = to.into(); + } + Ok(()) +} + +/// The interaction id of the tile or row of `name` in panel `panel`. +pub(crate) fn tile_id(panel: &str, name: &str) -> egui::Id { + egui::Id::new(("library-tile", panel, name)) +} + +/// The state key `key` of panel `panel`. +fn key(panel: &str, key: &str) -> String { + format!("{panel}-{key}") +} + /// The submenu of the libraries menu that lists libraries of `category` (`None`: the top level). pub(crate) fn submenu(category: &str) -> Option<&'static str> { match category { @@ -125,22 +370,105 @@ pub(crate) fn library_name<'a>(id: &str, name: &'a str) -> &'a str { } /// The opener of library `K`'s libraries (`window.swatchLibrary {library}`): open library `library` -/// (an id or a name; `list`: the command listing them) in the panel, `null` closing it. `found` -/// looks a library up: its id, name and item count. → {open, name, count} -pub(crate) fn open_command( - app: &mut VectorcraftApp, - p: &Value, - list: &str, - found: impl FnOnce(&VectorcraftApp, &str) -> Option<(String, String, usize)>, -) -> Result { +/// (an id or a name) in its own panel, floating, or bring its panel forward when it is open; +/// `null` closes every panel of `K`'s libraries. → {open, panel, name, count} +pub(crate) fn open_command(app: &mut VectorcraftApp, p: &Value) -> Result { let Some(key) = p.get("library").filter(|v| !v.is_null()) else { - app.ui.library_panel = None; + for panel in open_panels(&app.ui).into_iter().filter(|p| parse(p).is_some_and(|(k, _)| k.kind == K::KIND)) { + app.run("window.panel.close", json!({ "panel": panel }))?; + } return Ok(json!({ "open": null })); }; + let list = K::LIST; let key = key.as_str().ok_or_else(|| format!("`library` is a library id or name (see {list})"))?; - let (id, name, count) = found(app, key).ok_or_else(|| format!("no library `{key}` (see {list})"))?; - app.ui.library_panel = Some(OpenLibrary { kind: K::KIND.into(), id: id.clone() }); - Ok(json!({"open": id, "name": name, "count": count})) + let (id, name, count) = K::find(app, key).ok_or_else(|| format!("no library `{key}` (see {list})"))?; + let panel = panel_id(K::KIND, &id); + registered(&panel)?; + // Brought forward, it is the one opened last. + let at = app.ui.library_panels.iter().position(|q| q.kind == K::KIND && q.id == id); + if let Some(s) = at.map(|i| app.ui.library_panels.remove(i)) { + app.ui.library_panels.push(s); + } + settings_mut(app, kind_of::(), &id); + if is_open(&app.ui, &panel) { + app.run("window.panel.activate", json!({ "panel": panel }))?; + } else { + float(app, &panel)?; + } + Ok(json!({"open": id, "panel": panel, "name": name, "count": count})) +} + +/// Float library panel `panel`, as tall as its body in the kind's view. +pub(crate) fn float(app: &mut VectorcraftApp, panel: &str) -> Result { + let large = parse(panel).is_some_and(|(k, _)| k.view == View::LargeThumb); + app.run("window.panel.float", json!({ "panel": panel, "height": if large { 400 } else { 340 } })) +} + +/// `ui.libraryPanel {panel, library?, view?, showFind?, persistent?}`: show another library in an +/// open library panel (Previous / Next Library; one open in a panel of its own is brought forward +/// instead), and set a panel's view, Show Find Field and Persistent (also a closed persistent one's). +/// → its settings +pub(crate) fn command(app: &mut VectorcraftApp, p: &Value) -> Result { + let given = p.get("panel").and_then(Value::as_str).unwrap_or_default(); + let (k, id) = parse(given).ok_or_else(|| format!("`{given}` isn't a library panel (see window.swatchLibrary)"))?; + let mut id = id.to_string(); + let open = is_open(&app.ui, given); + let closed = || format!("library panel `{given}` isn't open"); + if !open && !settings(&app.ui, k.kind, &id).is_some_and(|s| s.persistent) { + return Err(closed()); + } + let view = match p.get("view") { + None | Some(Value::Null) => None, + Some(v) => Some( + serde_json::from_value::(v.clone()) + .map_err(|_| "`view` is smallThumb, mediumThumb, largeThumb, smallList or largeList".to_string())?, + ), + }; + let show_find = crate::menus::opt_bool(p, "showFind")?; + let persistent = crate::menus::opt_bool(p, "persistent")?; + if let Some(key) = p.get("library").filter(|v| !v.is_null()) { + let list = k.list; + let key = key.as_str().ok_or_else(|| format!("`library` is a library id or name (see {list})"))?; + let (next, ..) = (k.find)(app, key).ok_or_else(|| format!("no library `{key}` (see {list})"))?; + // Only an open panel shows another library. + if !open { + return Err(closed()); + } + let to = panel_id(k.kind, &next); + if is_open(&app.ui, &to) { + app.run("window.panel.activate", json!({ "panel": to }))?; + } else if next != id { + rename(&mut app.ui, given, &to)?; + app.ui.library_panels.retain(|q| !(q.kind == k.kind && q.id == next)); + let path = if next.starts_with("loaded/") { (k.path)(app, &next) } else { None }; + if let Some(s) = settings_mut(app, k, &id) { + (s.id, s.path) = (next.clone(), path); + } + } + id = next; + } + let s = settings_mut(app, k, &id).ok_or_else(closed)?; + s.view = view.or(s.view); + s.hide_find = show_find.map_or(s.hide_find, |show| !show); + s.persistent = persistent.unwrap_or(s.persistent); + let s = s.clone(); + Ok(json!({ + "panel": s.panel(), + "kind": s.kind, + "library": s.id, + "view": s.view.unwrap_or(k.view), + "showFind": !s.hide_find, + "persistent": s.persistent, + "open": is_open(&app.ui, &s.panel()), + })) +} + +/// Run `ui.libraryPanel` on panel `panel` with `params`. +fn set(app: &mut VectorcraftApp, panel: &str, mut params: Value) { + params["panel"] = json!(panel); + if let Err(e) = app.run("ui.libraryPanel", params) { + app.status(e); + } } /// Other Library…: the library file at `path`, else one picked in an open dialog; `None` when the @@ -167,73 +495,28 @@ pub(crate) fn user_slot(id: &str, prefix: &str, libraries: Vec) -> libraries.into_iter().filter(|l| l.category == "user").nth(n.checked_sub(1)?) } -/// The panel's window: a tab strip with the library's name, the panel menu and a close button, then -/// the body. A library that no longer exists closes it. -fn window(app: &mut VectorcraftApp, ctx: &egui::Context, id: &str) { - let Some((name, lib)) = K::get(app, id) else { - app.ui.library_panel = None; - return; - }; - let t = Tokens::get(ctx); - let mut open = true; - // Left of the icon panels' pop-out, movable. - let at = egui::pos2((ctx.content_rect().right() - 880.0).max(8.0), 140.0); - let area = - egui::Area::new(egui::Id::new(("library-panel", K::KIND))).order(egui::Order::Foreground).default_pos(at).movable(true).constrain(true); - area.show(ctx, |ui| { - egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| { - ui.set_width(256.0); - let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover()); - ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker); - let name = library_name(id, &name); - let label = egui::RichText::new(name).font(theme::semibold(12.0)); - let galley = ui.painter().layout_no_wrap(name.to_string(), theme::semibold(12.0), t.text); - let tab = Rect::from_min_size(strip.min, vec2((galley.size().x + 24.0).min(190.0), 26.0)); - ui.painter().rect_filled(tab, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel); - ui.put(tab.shrink2(vec2(12.0, 0.0)), egui::Label::new(label.color(t.text)).truncate().selectable(false)); - let close = Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0)); - let cr = ui.interact(close, ui.id().with("close-library"), Sense::click()).on_hover_text(tl!("Close")); - icons::paint(ui, "x", close, if cr.hovered() { t.text } else { t.text_dim }); - open = !cr.clicked(); - let menu = Rect::from_center_size(strip.right_center() - vec2(34.0, 0.0), vec2(16.0, 16.0)); - let mr = ui.interact(menu, ui.id().with("library-menu"), Sense::click()).on_hover_text(tl!("Panel menu")); - icons::paint(ui, "menu", menu.shrink(1.0), if mr.hovered() { t.text_strong } else { t.text_dim }); - egui::Popup::menu(&mr).show(|ui| { - ui.set_min_width(200.0); - panel_menu::(app, ui, id); - }); - egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| { - ui.set_width(236.0); - body::(app, ui, id, &lib); - }); - }); - }); - if !open { - app.ui.library_panel = None; - } +/// Test frames: the panel opened last, as the dock or a floating group draws its body. +#[cfg(test)] +pub(crate) fn show_window(app: &mut VectorcraftApp, ctx: &egui::Context) { + let Some(panel) = last_open(&app.ui).map(LibraryPanel::panel) else { return }; + let area = egui::Area::new(egui::Id::new("test-library-panel")).fixed_pos(egui::pos2(20.0, 20.0)); + area.show(ctx, |ui| crate::dock::panel_body(app, ui, &panel, 256.0, 400.0)); } -/// The selected items and folders of library `id` (a selection in another library is dropped). -fn selection(ctx: &egui::Context, id: &str) -> Vec { - let (lib, sel): (String, Vec) = pstate(ctx, &key::("selected")); - if lib == id { sel } else { vec![] } +/// The selected items and folders of panel `panel`. +fn selection(ctx: &egui::Context, panel: &str) -> Vec { + pstate(ctx, &key(panel, "selected")) } -fn set_selection(ctx: &egui::Context, id: &str, sel: Vec) { - set_pstate(ctx, &key::("selected"), (id.to_string(), sel)); -} - -/// Find field, thumbnails or list rows, and the bottom bar. -fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, lib: &K::Lib) { - let view = view::(ui.ctx()); - let query = if pstate::(ui.ctx(), &key::("hide-find")) { - String::new() - } else { - widgets::search_field(ui, egui::Id::new(key::("find")), tl!("Find")) - }; +/// Find field, thumbnails or list rows, and the bottom bar of library `id`'s panel. +fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { + let Some((_, lib)) = K::get(app, id) else { return }; + let panel = panel_id(K::KIND, id); + let (view, hide_find) = settings(&app.ui, K::KIND, id).map_or((K::VIEW, false), |s| (s.view.unwrap_or(K::VIEW), s.hide_find)); + let query = if hide_find { String::new() } else { widgets::search_field(ui, egui::Id::new(key(&panel, "find")), tl!("Find")) }; ui.add_space(4.0); - let rows = K::rows(lib, &query.trim().to_lowercase()); - let selected = selection::(ui.ctx(), id); + let rows = K::rows(&lib, &query.trim().to_lowercase()); + let selected = selection(ui.ctx(), &panel); let is_sel = |n: &str| selected.iter().any(|s| s == n); let mut clicked: Option<(String, bool, egui::Modifiers)> = None; let mut click = |ui: &Ui, resp: egui::Response, row: &Row, tip: &dyn Fn() -> String| { @@ -243,13 +526,13 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, lib: &K }; widgets::list_box(ui, |ui| { let max_height = if view == View::LargeThumb { 280.0 } else { 220.0 }; - egui::ScrollArea::vertical().id_salt(key::("scroll")).max_height(max_height).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt(key(&panel, "scroll")).max_height(max_height).show(ui, |ui| { ui.set_width(ui.available_width()); if rows.is_empty() { widgets::dim_label(ui, if query.trim().is_empty() { tl!("This library is empty.") } else { tl!("No matches.") }); } else if view.is_list() { for row in &rows { - let (r, resp, chip) = list_row(ui, tile_id::(row.name), view, is_sel(row.name)); + let (r, resp, chip) = list_row(ui, tile_id(&panel, row.name), view, is_sel(row.name)); match row.item { Some(item) => { K::draw(app, ui, chip, item, false, false); @@ -263,7 +546,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, lib: &K } } else { for (row, cell) in tile_grid(ui, &rows, view, |r| r.item.is_none()) { - let resp = ui.interact(cell, tile_id::(row.name), Sense::click()); + let resp = ui.interact(cell, tile_id(&panel, row.name), Sense::click()); match row.item { Some(item) => K::draw(app, ui, cell, item, is_sel(row.name), resp.hovered()), None => folder_tile(ui, cell, is_sel(row.name)), @@ -277,8 +560,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, lib: &K // A plain click on an item also adds and applies it; folders and modifier clicks only select. let only_select = folder || m.shift || m.command; let order: Vec<&str> = rows.iter().map(|r| r.name).collect(); - let sel = click_selection(ui, &key::("anchor"), selected, &order, &name, m); - set_selection::(ui.ctx(), id, sel); + let sel = click_selection(ui, &key(&panel, "anchor"), selected, &order, &name, m); + set_pstate(ui.ctx(), &key(&panel, "selected"), sel); if !only_select { K::click(app, ui, id, &name); } @@ -294,7 +577,14 @@ fn neighbour(app: &VectorcraftApp, id: &str, step: isize) -> Opt all.into_iter().nth((i + step).rem_euclid(n) as usize).map(|l| l.id) } -/// Open library `id` in the panel. +/// Show the library `step` places after (or before) `id` in its panel. +fn step(app: &mut VectorcraftApp, id: &str, step: isize) { + if let Some(next) = neighbour::(app, id, step) { + set(app, &panel_id(K::KIND, id), json!({ "library": next })); + } +} + +/// Open library `id` in its panel. fn open(app: &mut VectorcraftApp, id: &str) { if let Err(e) = app.run(K::OPEN, json!({ "library": id })) { app.status(e); @@ -303,18 +593,16 @@ fn open(app: &mut VectorcraftApp, id: &str) { /// Libraries menu, previous / next library, and Add to … for the selection. fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { - let selected = selection::(ui.ctx(), id); + let selected = selection(ui.ctx(), &panel_id(K::KIND, id)); widgets::bottom_bar(ui, |ui| { let r = widgets::icon_button(ui, "library", K::MENU, false, 24.0); egui::Popup::menu(&r).show(|ui| { ui.set_min_width(200.0); library_menu::(app, ui); }); - for (icon, tip, step) in [("chevron-left", tl!("Previous Library"), -1), ("chevron-right", tl!("Next Library"), 1)] { - if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() - && let Some(next) = neighbour::(app, id, step) - { - open::(app, &next); + for (icon, tip, s) in [("chevron-left", tl!("Previous Library"), -1), ("chevron-right", tl!("Next Library"), 1)] { + if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() { + step::(app, id, s); } } ui.add_space((ui.available_width() - 28.0).max(0.0)); @@ -324,54 +612,56 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { }); } -/// The library panel's (≡) menu. +/// A library panel's (≡) menu. fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { - let selected = selection::(ui.ctx(), id); + let panel = panel_id(K::KIND, id); + let selected = selection(ui.ctx(), &panel); if menu_item(ui, K::ADD, !selected.is_empty(), false) { K::add(app, id, selected); } ui.separator(); - let view = view::(ui.ctx()); + let s = settings(&app.ui, K::KIND, id); + let (view, hide_find, persistent) = s.map_or((K::VIEW, false, false), |s| (s.view.unwrap_or(K::VIEW), s.hide_find, s.persistent)); for (v, label) in View::ALL { if menu_item(ui, label, true, v == view) { - set_pstate(ui.ctx(), &key::("view"), Some(v)); + set(app, &panel, json!({ "view": v })); } } ui.separator(); - let hidden: bool = pstate(ui.ctx(), &key::("hide-find")); - if menu_item(ui, tl!("Show Find Field"), true, !hidden) { - set_pstate(ui.ctx(), &key::("hide-find"), !hidden); + if menu_item(ui, tl!("Show Find Field"), true, !hide_find) { + set(app, &panel, json!({ "showFind": hide_find })); } ui.separator(); - for (label, step) in [(tl!("Previous Library"), -1), (tl!("Next Library"), 1)] { - if menu_item(ui, label, true, false) - && let Some(next) = neighbour::(app, id, step) - { - open::(app, &next); + for (label, s) in [(tl!("Previous Library"), -1), (tl!("Next Library"), 1)] { + if menu_item(ui, label, true, false) { + step::(app, id, s); } } + if menu_item(ui, tl!("Persistent"), true, persistent) { + set(app, &panel, json!({ "persistent": !persistent })); + } if menu_item(ui, tl!("Close Library"), true, false) { - app.ui.library_panel = None; + close(app, &panel); } } -/// The libraries menu (the panel's library button, the Swatches panel's): every library by -/// submenu, the open one checked, between `K`'s head and tail items. +/// The libraries menu (a library panel's library button, the Swatches panel's): every library by +/// submenu, the open ones checked, between `K`'s head and tail items. pub(crate) fn library_menu(app: &mut VectorcraftApp, ui: &mut Ui) { K::menu_head(app, ui); - let current = app.ui.library_panel.as_ref().filter(|o| o.kind == K::KIND).map(|o| o.id.clone()); - if let Some(id) = library_items(ui, &K::list(app), current.as_deref()) { + let shown = open_panels(&app.ui); + if let Some(id) = library_items(ui, &K::list(app), &|id| shown.contains(&panel_id(K::KIND, id))) { open::(app, &id); } K::menu_tail(app, ui); } -/// Menu rows for `libs`: the top-level ones, then a submenu per [`LibraryRef::submenu`], with -/// `current` checked. Returns the id of the one clicked. -pub(crate) fn library_items(ui: &mut Ui, libs: &[LibraryRef], current: Option<&str>) -> Option { +/// Menu rows for `libs`: the top-level ones, then a submenu per [`LibraryRef::submenu`], those +/// `checked` says checked. Returns the id of the one clicked. +pub(crate) fn library_items(ui: &mut Ui, libs: &[LibraryRef], checked: &dyn Fn(&str) -> bool) -> Option { let mut chosen = None; let mut item = |ui: &mut Ui, l: &LibraryRef| { - if widgets::menu_item_name(ui, library_name(&l.id, &l.name), true, current == Some(l.id.as_str())) { + if widgets::menu_item_name(ui, library_name(&l.id, &l.name), true, checked(&l.id)) { chosen = Some(l.id.clone()); } }; @@ -398,7 +688,7 @@ pub(crate) fn library_items(ui: &mut Ui, libs: &[LibraryRef], current: Option<&s #[cfg(test)] mod tests { - use egui::{Event, Modifiers, PointerButton, Pos2}; + use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; use serde_json::Value; use vectorcraft_color::Paint; use vectorcraft_engine::Session; @@ -427,12 +717,17 @@ mod tests { out.textures_delta.clear(); } - /// Click the tile or row of `name` with `modifiers` (frames from `time` to `time + 0.5`; a new - /// window takes a sizing frame first). + /// The panel opened last. + fn shown(app: &VectorcraftApp) -> String { + last_open(&app.ui).expect("a library panel is open").panel() + } + + /// Click the tile or row of `name` in the panel opened last with `modifiers` (frames from + /// `time` to `time + 0.5`; a new window takes a sizing frame first). fn click(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, modifiers: Modifiers, time: f64) { frame(app, ctx, vec![], Modifiers::NONE, time); frame(app, ctx, vec![], Modifiers::NONE, time + 0.25); - let at = ctx.read_response(tile_id::(name)).unwrap_or_else(|| panic!("no tile for {name}")).rect.center(); + let at = ctx.read_response(tile_id(&shown(app), name)).unwrap_or_else(|| panic!("no tile for {name}")).rect.center(); let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }; frame(app, ctx, vec![Event::PointerMoved(at), button(true), button(false)], modifiers, time + 0.5); } @@ -446,8 +741,8 @@ mod tests { let mut app = app(); let ctx = context(); let r = app.run("window.swatchLibrary", json!({"library": "Pastels"})).unwrap(); - assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("pastels"), Some(24))); - assert_eq!(app.ui.library_panel, Some(OpenLibrary { kind: "swatches".into(), id: "pastels".into() })); + assert_eq!((r["open"].as_str(), r["count"].as_u64(), r["panel"].as_str()), (Some("pastels"), Some(24), Some("library:swatches:pastels"))); + assert_eq!(last_open(&app.ui).map(|p| (p.kind.as_str(), p.id.as_str())), Some(("swatches", "pastels"))); let undo = undo_len(&app); click(&mut app, &ctx, "Pastel Teal", Modifiers::NONE, 0.0); let d = &app.session.doc().unwrap().doc; @@ -459,10 +754,10 @@ mod tests { let blue = app.session.doc().unwrap().doc.swatch("Soft Blue").and_then(|w| w.paint.color()).unwrap(); assert_eq!(app.session.paint.stroke.color(), Some(blue)); assert!(app.session.fill_active, "Alt keeps the active proxy"); - // A library that doesn't exist closes the panel; null closes it. + // A library that doesn't exist is an error; null closes the panel. assert!(app.run("window.swatchLibrary", json!({"library": "nope"})).is_err()); app.run("window.swatchLibrary", json!({"library": null})).unwrap(); - assert!(app.ui.library_panel.is_none()); + assert!(last_open(&app.ui).is_none()); } #[test] @@ -472,17 +767,17 @@ mod tests { app.run("window.swatchLibrary", json!({"library": "earth-tones"})).unwrap(); click(&mut app, &ctx, "Clay 1", Modifiers::COMMAND, 0.0); click(&mut app, &ctx, "Ochre", Modifiers::NONE, 2.0); - assert_eq!(selection::(&ctx, "earth-tones"), ["Ochre"], "a folder click selects its group"); + assert_eq!(selection(&ctx, "library:swatches:earth-tones"), ["Ochre"], "a folder click selects its group"); click(&mut app, &ctx, "Clay 3", Modifiers::COMMAND, 4.0); assert!(app.session.doc().unwrap().doc.swatch("Clay 3").is_none(), "modifier clicks only select"); - let sel = selection::(&ctx, "earth-tones"); + let sel = selection(&ctx, "library:swatches:earth-tones"); SwatchLibraries::add(&mut app, "earth-tones", sel); let d = &app.session.doc().unwrap().doc; assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Ochre").map(|g| g.swatches.len()), Some(6)); assert!(d.swatches.iter().any(|w| w.name == "Clay 3")); - // The selection belongs to its library. + // The selection belongs to its library's panel. app.run("window.swatchLibrary", json!({"library": "brights"})).unwrap(); - assert!(selection::(&ctx, "brights").is_empty()); + assert!(selection(&ctx, "library:swatches:brights").is_empty()); } #[test] @@ -494,12 +789,12 @@ mod tests { let order: Vec = SwatchLibraries::rows(&lib, "").iter().map(|r| r.name.to_string()).collect(); click(&mut app, &ctx, &order[1], Modifiers::NONE, 0.0); click(&mut app, &ctx, &order[4], Modifiers::SHIFT, 2.0); - assert_eq!(selection::(&ctx, "pastels"), order[1..=4], "the range starts at the plain click"); + assert_eq!(selection(&ctx, "library:swatches:pastels"), order[1..=4], "the range starts at the plain click"); // A plain click after a Cmd/Ctrl-click moves the anchor from the Cmd/Ctrl-clicked item. click(&mut app, &ctx, &order[8], Modifiers::COMMAND, 4.0); click(&mut app, &ctx, &order[2], Modifiers::NONE, 6.0); click(&mut app, &ctx, &order[3], Modifiers::SHIFT, 8.0); - assert_eq!(selection::(&ctx, "pastels"), order[2..=3]); + assert_eq!(selection(&ctx, "library:swatches:pastels"), order[2..=3]); let d = &app.session.doc().unwrap().doc; assert!(d.swatch(&order[2]).is_some(), "a plain click still adds the swatch"); assert!(d.swatch(&order[3]).is_none(), "a Shift-click only selects"); @@ -510,12 +805,13 @@ mod tests { let mut app = app(); let ctx = context(); app.run("window.swatchLibrary", json!({"library": "web-safe-216"})).unwrap(); - set_pstate(&ctx, &key::("view"), Some(View::SmallList)); - ctx.data_mut(|d| d.insert_temp(egui::Id::new(key::("find")), "#FF00".to_string())); + let panel = "library:swatches:web-safe-216"; + app.run("ui.libraryPanel", json!({"panel": panel, "view": "smallList"})).unwrap(); + ctx.data_mut(|d| d.insert_temp(egui::Id::new(key(panel, "find")), "#FF00".to_string())); frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.0); frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.5); - assert!(ctx.read_response(tile_id::("#FF0033")).is_some()); - assert!(ctx.read_response(tile_id::("#00FF00")).is_none()); + assert!(ctx.read_response(tile_id(panel, "#FF0033")).is_some()); + assert!(ctx.read_response(tile_id(panel, "#00FF00")).is_none()); let first = SWATCH_IDS[0]; let last = SwatchLibraries::list(&app).pop().unwrap().id; assert_eq!(last, "radial-glows", "gradient libraries come after the swatch libraries"); @@ -585,7 +881,7 @@ mod tests { assert!(app.ui.dialog.is_none()); assert_eq!(user_items(&app), [("Loud".to_string(), "window.userSwatchLibrary1".to_string())]); app.run("window.userSwatchLibrary1", json!({})).unwrap(); - assert_eq!(app.ui.library_panel.as_ref().map(|o| o.id.as_str()), Some("user/Loud.vcswatches")); + assert_eq!(last_open(&app.ui).map(|p| p.id.as_str()), Some("user/Loud.vcswatches")); let (_, lib) = SwatchLibraries::get(&app, "user/Loud.vcswatches").unwrap(); assert_eq!((lib.groups.len(), lib.len()), (1, 5), "only the selected group"); let _ = std::fs::remove_dir_all(dir); @@ -625,7 +921,7 @@ mod tests { ); // Opening a palette file opens it in the library panel. crate::io::open_bytes(&mut app, "Saved.gpl", text.as_bytes(), None).unwrap(); - let open = app.ui.library_panel.clone().unwrap(); + let open = last_open(&app.ui).cloned().unwrap(); assert!(open.id.starts_with("loaded/"), "{open:?}"); assert_eq!(app.session.documents().len(), 1, "not opened as a document"); let other = SwatchLibraries::list(&app).into_iter().find(|l| l.id == open.id).unwrap(); @@ -688,10 +984,160 @@ mod tests { // A file opened on the web has no path; open_bytes passes its bytes as dataBase64. let mut app = app(); crate::io::open_bytes(&mut app, "Brand.ase", &vectorcraft_testkit::ase::sample(), None).unwrap(); - let open = app.ui.library_panel.clone().unwrap(); + let open = last_open(&app.ui).cloned().unwrap(); assert_eq!(open.id, "loaded/Brand.ase"); let (_, lib) = SwatchLibraries::get(&app, &open.id).unwrap(); assert_eq!((lib.len(), lib.groups.len()), (4, 1)); assert_eq!(app.session.documents().len(), 1, "not opened as a document"); } + + fn docking(app: &VectorcraftApp) -> &craft_ui::docking::Layout { + app.ui.docking.as_ref().expect("library panels live in the shared layout") + } + + fn panel_of(reply: Value) -> String { + reply["panel"].as_str().expect("the reply names the panel").to_string() + } + + fn groups(app: &VectorcraftApp) -> Vec> { + docking(app).floating.iter().map(|g| g.panels.clone()).collect() + } + + #[test] + fn each_library_opens_in_a_panel_of_its_own() { + let mut app = app(); + // Without the shared layout yet, `window.panel` floats a library panel too. + app.run("window.panel", json!({"panel": "library:swatches:earth-tones"})).unwrap(); + assert!(is_open(&app.ui, "library:swatches:earth-tones")); + app.run("window.panel.close", json!({"panel": "library:swatches:earth-tones"})).unwrap(); + let pastels = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); + let brights = panel_of(app.run("window.swatchLibrary", json!({"library": "Brights"})).unwrap()); + let styles = panel_of(app.run("window.graphicStyleLibrary", json!({"library": "hand-drawn"})).unwrap()); + assert_eq!([&pastels, &brights, &styles], ["library:swatches:pastels", "library:swatches:brights", "library:graphicStyles:hand-drawn"]); + assert_eq!(groups(&app), [vec![pastels.clone()], vec![brights.clone()], vec![styles.clone()]], "each floats on its own"); + assert_ne!(docking(&app).floating[0].rect, docking(&app).floating[1].rect, "one beside another"); + assert_eq!(tab_label(&app, &brights).as_deref(), Some("Brights")); + // Opening an open library brings its panel forward. + app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); + assert_eq!(groups(&app).len(), 3); + assert_eq!(last_open(&app.ui).map(LibraryPanel::panel), Some(pastels.clone())); + // They group, dock and close as any panel, and `window.panel` shows one again. + app.run("window.panel.float", json!({"panel": brights, "onto": pastels})).unwrap(); + assert!(groups(&app).contains(&vec![pastels.clone(), brights.clone()]), "{:?}", groups(&app)); + app.run("window.panel.dock", json!({"panel": styles})).unwrap(); + assert!(craft_ui::docking::Layout { root: docking(&app).root.clone(), floating: vec![] }.contains(&styles)); + app.run("window.panel.close", json!({"panel": brights})).unwrap(); + assert!(!is_open(&app.ui, &brights) && is_open(&app.ui, &pastels)); + app.run("window.panel", json!({"panel": brights})).unwrap(); + assert!(is_open(&app.ui, &brights)); + assert!(crate::panel_docking::valid(docking(&app))); + // `library: null` closes the panels of its kind. + app.run("window.swatchLibrary", json!({"library": null})).unwrap(); + assert!(!any_open::(&app.ui) && is_open(&app.ui, &styles)); + // What isn't a library panel's id. + for bad in ["library:nope:x", "library:swatches:", "library:swatches", "Library:swatches:pastels"] { + assert!(crate::menus::normalize_panel(bad).is_none(), "{bad}"); + } + assert!(app.run("ui.libraryPanel", json!({"panel": "layers"})).is_err()); + assert!(app.run("ui.libraryPanel", json!({"panel": pastels, "persistent": true})).is_err(), "closed and not persistent"); + assert!(app.run("ui.libraryPanel", json!({"panel": styles, "view": "huge"})).is_err()); + } + + /// The app restarted with the preferences `app` saves. + fn restarted(app: &VectorcraftApp) -> VectorcraftApp { + let saved: crate::UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + let mut next = VectorcraftApp::new(Session::new(), Default::default()); + next.ui = saved.sanitized(); + restore(&mut next); + next + } + + #[test] + fn persistent_panels_reopen_at_startup_with_their_settings() { + let dir = std::env::temp_dir().join(format!("vc-persistent-libraries-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let gpl = dir.join("Brand.gpl"); + std::fs::write(&gpl, "GIMP Palette\nName: Brand\n#\n255 0 0\tRed\n").unwrap(); + let mut app = app(); + let pastels = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); + let brights = panel_of(app.run("window.swatchLibrary", json!({"library": "brights"})).unwrap()); + let brand = panel_of(app.run("window.swatchLibrary.other", json!({"path": gpl.to_string_lossy()})).unwrap()); + let r = app.run("ui.libraryPanel", json!({"panel": pastels, "persistent": true, "view": "largeList", "showFind": false})).unwrap(); + assert_eq!((r["persistent"].as_bool(), r["view"].as_str(), r["showFind"].as_bool()), (Some(true), Some("largeList"), Some(false))); + app.run("ui.libraryPanel", json!({"panel": brand, "persistent": true})).unwrap(); + app.run("window.panel.float", json!({"panel": pastels, "x": 40, "y": 90})).unwrap(); + // A persistent panel reopens even when it was closed. + app.run("window.panel.close", json!({"panel": brand})).unwrap(); + let next = restarted(&app); + assert!(is_open(&next.ui, &pastels) && is_open(&next.ui, &brand), "{}", next.ui.status); + assert!(!is_open(&next.ui, &brights), "a panel that isn't persistent closed with the app"); + let group = docking(&next).floating.iter().find(|g| g.panels.contains(&pastels)).unwrap(); + assert_eq!(group.rect[..2], [40.0, 90.0], "where it was"); + let s = settings(&next.ui, "swatches", "pastels").unwrap(); + assert_eq!((s.view, s.hide_find, s.persistent), (Some(View::LargeList), true, true)); + let (_, id) = parse(&brand).unwrap(); + assert!(SwatchLibraries::get(&next, id).is_some_and(|(_, lib)| lib.swatch("Red").is_some()), "read again from its file"); + assert!(next.ui.status.is_empty(), "{}", next.ui.status); + assert!(next.ui.docking_hidden.keys().all(|p| p != &brights)); + // Unchecked, a panel no longer reopens; a file that is gone is left out with a note. + let mut next = next; + next.run("ui.libraryPanel", json!({"panel": pastels, "persistent": false})).unwrap(); + std::fs::remove_file(&gpl).unwrap(); + let last = restarted(&next); + assert!(!is_open(&last.ui, &pastels) && !is_open(&last.ui, &brand)); + assert!(last.ui.status.contains("Brand.gpl"), "{}", last.ui.status); + assert!(last.ui.library_panels.is_empty()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn previous_and_next_show_the_neighbouring_library_in_the_same_panel() { + let mut app = app(); + let first = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); + app.run("window.panel.float", json!({"panel": first, "x": 50, "y": 60})).unwrap(); + app.run("ui.libraryPanel", json!({"panel": first, "persistent": true, "view": "smallList"})).unwrap(); + step::(&mut app, "pastels", 1); + let next = panel_id("swatches", SWATCH_IDS[5]); + assert!(!is_open(&app.ui, &first) && is_open(&app.ui, &next), "{}", app.ui.status); + let group = docking(&app).floating.iter().find(|g| g.panels.contains(&next)).unwrap(); + assert_eq!((group.panels.len(), group.rect[0], group.rect[1]), (1, 50.0, 60.0), "in its place"); + let s = settings(&app.ui, "swatches", SWATCH_IDS[5]).unwrap(); + assert_eq!((s.view, s.persistent), (Some(View::SmallList), true), "the panel keeps its settings"); + assert!(settings(&app.ui, "swatches", "pastels").is_none()); + assert!(crate::panel_docking::valid(docking(&app))); + // A library open in a panel of its own is brought forward instead. + app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); + let r = app.run("ui.libraryPanel", json!({"panel": next, "library": "Pastels"})).unwrap(); + assert_eq!(r["panel"], json!(first)); + assert!(is_open(&app.ui, &next) && is_open(&app.ui, &first)); + } + + #[test] + fn a_panel_closes_when_its_library_is_gone() { + let dir = std::env::temp_dir().join(format!("vc-gone-library-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Gone.gpl"), "GIMP Palette\nName: Gone\n#\n0 0 0\tInk\n").unwrap(); + let mut app = app(); + app.session.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let panel = panel_of(app.run("window.swatchLibrary", json!({"library": "user/Gone.gpl"})).unwrap()); + tidy(&mut app); + assert!(is_open(&app.ui, &panel)); + std::fs::remove_file(dir.join("Gone.gpl")).unwrap(); + app.session.swatch_libraries.rescan(); + tidy(&mut app); + assert!(!is_open(&app.ui, &panel)); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn the_panel_menu_has_persistent() { + let mut app = app(); + let panel = panel_of(app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap()); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| menu(app, ui, &panel)); + for item in ["Add to Swatches", "Small List View", "Show Find Field", "Previous Library", "Persistent", "Close Library"] { + assert!(text.contains(item), "{item}: {text}"); + } + } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index b996134e6..5f534dc05 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -178,6 +178,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { links::ID => links::show(app, ui), asset_export::ID => asset_export::show(app, ui), css_properties::ID => css_properties::show(app, ui), + id if library_panel::parse(id).is_some() => library_panel::show(app, ui, id), _ => { dim_label(ui, tl!("This panel is on the roadmap (see the parity plan).")); } @@ -228,6 +229,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool css_properties::ID => css_properties::menu(app, ui), variables::ID => variables::menu(app, ui), "actions" => actions::menu(app, ui), + id if library_panel::parse(id).is_some() => library_panel::menu(app, ui, id), _ => return false, } true diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index e45af6b40..33ac5b4b1 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -22,8 +22,9 @@ use crate::theme::Tokens; use crate::widgets::{self, PanelDrag, SwatchRows, menu_item, swatch_tile}; use crate::{VectorcraftApp, icons}; -/// View modes of the swatch list (and of the library panel). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +/// View modes of the swatch list (and of the library panels: `ui.libraryPanel {view}`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] pub enum View { SmallThumb, #[default] @@ -927,7 +928,7 @@ fn delete(app: &mut VectorcraftApp, names: Vec, now: bool) { fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, sel: &[String]) { let kind: Kind = pstate(ui.ctx(), "swatch-kind"); widgets::bottom_bar(ui, |ui| { - let open = app.ui.library_panel.as_ref().is_some_and(|o| o.kind == SwatchLibraries::KIND); + let open = library_panel::any_open::(&app.ui); let lr = widgets::icon_button(ui, "library", SwatchLibraries::MENU, open, 24.0); egui::Popup::menu(&lr).show(|ui| { ui.set_min_width(200.0); @@ -1107,12 +1108,14 @@ pub(crate) fn limit_key(app: &VectorcraftApp, l: &str) -> Result swatchlib::library(&app.session, l).map(|(info, _)| info.id).ok_or_else(|| format!("no swatch library `{l}` (see swatch.library.list)")) } -/// Swatch libraries in the library panel (engine: `swatch.library.*`). +/// Swatch libraries in library panels (engine: `swatch.library.*`). pub(crate) struct SwatchLibraries; impl LibraryKind for SwatchLibraries { const KIND: &'static str = "swatches"; const OPEN: &'static str = "window.swatchLibrary"; + const LIST: &'static str = "swatch.library.list"; + const LOAD: &'static str = "swatch.library.load"; const MENU: &'static str = "Swatch Libraries Menu"; const ADD: &'static str = "Add to Swatches"; type Lib = Arc; @@ -1124,6 +1127,12 @@ impl LibraryKind for SwatchLibraries { fn get(app: &VectorcraftApp, id: &str) -> Option<(String, Self::Lib)> { swatchlib::library(&app.session, id).map(|(info, lib)| (info.name, lib)) } + fn find(app: &VectorcraftApp, key: &str) -> Option<(String, String, usize)> { + swatchlib::library(&app.session, key).map(|(info, lib)| (info.id, info.name, lib.len())) + } + fn path(app: &VectorcraftApp, id: &str) -> Option { + app.session.swatch_libraries.path(id).map(str::to_string) + } /// Ungrouped swatches, then each colour group's folder and swatches (a group whose name /// matches shows all of them), as in the Swatches panel. fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { @@ -1189,15 +1198,13 @@ fn add_from_library(app: &mut VectorcraftApp, params: Value) { } } -/// `window.swatchLibrary {library}`: open a swatch library in the library panel (`library` null -/// closes it). +/// `window.swatchLibrary {library}`: open a swatch library in its own panel (`library` null closes +/// every swatch library panel). pub(crate) fn open_library(app: &mut VectorcraftApp, p: &Value) -> Result { - library_panel::open_command::(app, p, "swatch.library.list", |app, key| { - swatchlib::library(&app.session, key).map(|(info, lib)| (info.id, info.name, lib.len())) - }) + library_panel::open_command::(app, p) } -/// `swatch.library.load` params, then open the library in the panel. +/// `swatch.library.load` params, then open the library in its panel. pub(crate) fn load_library(app: &mut VectorcraftApp, params: Value) -> Result { let r = app.run("swatch.library.load", params)?; open_library(app, &json!({ "library": r["library"] })) diff --git a/crates/ui-egui/src/panels/tests_style_libraries.rs b/crates/ui-egui/src/panels/tests_style_libraries.rs index 31165c03e..a77681938 100644 --- a/crates/ui-egui/src/panels/tests_style_libraries.rs +++ b/crates/ui-egui/src/panels/tests_style_libraries.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::style_libs::STYLE_LIBRARIES; use vectorcraft_engine::Session; use super::graphic_styles::GraphicStyleLibraries; -use super::library_panel::{self, LibraryKind, OpenLibrary}; +use super::library_panel::{self, LibraryKind, LibraryPanel, last_open}; use crate::VectorcraftApp; fn app_with(services: crate::Services) -> VectorcraftApp { @@ -52,7 +52,7 @@ fn click(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, modifiers: M for t in [time, time + 0.25] { frame(ctx, vec![], Modifiers::NONE, t, |ui| library_panel::show_window(app, ui.ctx())); } - let id = library_panel::tile_id::(name); + let id = library_panel::tile_id(&last_open(&app.ui).map(LibraryPanel::panel).unwrap_or_default(), name); let at = ctx.read_response(id).unwrap_or_else(|| panic!("no tile for {name}")).rect.center(); let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }; frame(ctx, vec![Event::PointerMoved(at), button(true), button(false)], modifiers, time + 0.5, |ui| library_panel::show_window(app, ui.ctx())); @@ -69,13 +69,13 @@ fn clicking_a_library_style_adds_and_applies_it_in_one_step() { let rect = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 80, "height": 40})).unwrap()["id"].as_u64().unwrap(); let r = app.run("window.graphicStyleLibrary", json!({"library": "Shadows and Glows"})).unwrap(); assert_eq!((r["open"].as_str(), r["name"].as_str()), (Some("shadows-glows"), Some("Shadows and Glows"))); - assert_eq!(app.ui.library_panel, Some(OpenLibrary { kind: "graphicStyles".into(), id: "shadows-glows".into() })); + assert_eq!(last_open(&app.ui).map(|p| (p.kind.as_str(), p.id.as_str())), Some(("graphicStyles", "shadows-glows"))); let undo = undo_len(&app); click(&mut app, &ctx, "Halo", Modifiers::NONE, 0.0); let (g, linked) = app.session.selection_graphic_style().expect("applied to the selection"); assert_eq!((g.name.as_str(), linked), ("Halo", true)); assert_eq!(undo_len(&app), undo + 1, "added and applied as one undo step"); - let tile = ctx.read_response(library_panel::tile_id::("Halo")).unwrap().rect; + let tile = ctx.read_response(library_panel::tile_id("library:graphicStyles:shadows-glows", "Halo")).unwrap().rect; assert_eq!(tile.width(), 30.0, "styles open in large thumbnails"); // Alt-click adds the style's appearance on top, unlinked. let fills = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(rect)).unwrap().appearance.items.len(); @@ -98,7 +98,7 @@ fn clicking_a_library_style_adds_and_applies_it_in_one_step() { assert!(std::sync::Arc::ptr_eq(&lib, &GraphicStyleLibraries::get(&app, "shadows-glows").unwrap().1)); assert!(app.run("window.graphicStyleLibrary", json!({"library": "nope"})).is_err()); app.run("window.graphicStyleLibrary", json!({"library": null})).unwrap(); - assert!(app.ui.library_panel.is_none()); + assert!(last_open(&app.ui).is_none()); } #[test] @@ -153,7 +153,7 @@ fn save_dialog_writes_user_libraries_that_the_window_menu_lists() { assert!(app.ui.dialog.is_none()); assert_eq!(user_items(&app), [("Warm".to_string(), "window.userGraphicStyleLibrary1".to_string())]); app.run("window.userGraphicStyleLibrary1", json!({})).unwrap(); - assert_eq!(app.ui.library_panel.as_ref().map(|o| o.id.as_str()), Some("user/Warm.vcstyles")); + assert_eq!(last_open(&app.ui).map(|p| p.id.as_str()), Some("user/Warm.vcstyles")); let (name, lib) = GraphicStyleLibraries::get(&app, "user/Warm.vcstyles").unwrap(); assert_eq!((name.as_str(), lib.len()), ("Warm", 1), "only the selected style"); let _ = std::fs::remove_dir_all(dir); @@ -179,7 +179,7 @@ fn saving_to_a_file_and_opening_a_vcstyles_file() { assert!(path.ends_with(".vcstyles"), "{path}"); // Opening it opens it in the library panel, not as a document. crate::io::open_bytes(&mut app, "Saved.vcstyles", &bytes, None).unwrap(); - let open = app.ui.library_panel.clone().unwrap(); + let open = last_open(&app.ui).cloned().unwrap(); assert_eq!(open.kind, GraphicStyleLibraries::KIND); assert_eq!(app.session.documents().len(), 1); let other = GraphicStyleLibraries::list(&app).into_iter().find(|l| l.id == open.id).unwrap(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 86d98c3c9..3e825ee91 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -394,9 +394,11 @@ pub struct UiState { /// Color Guide panel: variation kind, steps and amount (Color Guide Options). #[serde(default)] pub color_guide: vectorcraft_color::harmony::GuideOptions, - /// The library open in the library panel (Window → Swatch Libraries). + /// The library panels' settings (Window → Swatch Libraries, Graphic Style Libraries): of each + /// library opened, and of the persistent ones, which reopen at startup, also once closed + /// ([`crate::panels::library_panel`], `ui.libraryPanel`). #[serde(default)] - pub library_panel: Option, + pub library_panels: Vec, /// Flattener Preview panel: highlight, overprints, preset and options (`ui.flattenerPreview`). #[serde(default)] pub flattener_preview: crate::panels::flattener_preview::Settings, @@ -642,7 +644,7 @@ impl FloatingPanels { pub fn sanitize(groups: &mut Vec, toolbar_pos: &mut Option<[f32; 2]>) { let mut seen = std::collections::BTreeSet::new(); groups.retain_mut(|g| { - g.panels.retain(|id| all_panels().any(|(p, _)| p == id) && seen.insert(id.clone())); + g.panels.retain(|id| crate::menus::normalize_panel(id) == Some(id.as_str()) && seen.insert(id.clone())); g.active = g.active.min(g.panels.len().saturating_sub(1)); !g.panels.is_empty() }); @@ -769,7 +771,7 @@ impl Default for UiState { favorite_fonts: vec![], engine_prefs: Value::Null, color_guide: Default::default(), - library_panel: None, + library_panels: vec![], flattener_preview: Default::default(), color_guide_limit: String::new(), align_to: Default::default(), diff --git a/crates/ui-egui/src/tests_paintchips.rs b/crates/ui-egui/src/tests_paintchips.rs index b28ad83e3..8845000e9 100644 --- a/crates/ui-egui/src/tests_paintchips.rs +++ b/crates/ui-egui/src/tests_paintchips.rs @@ -319,6 +319,6 @@ fn the_popover_swatch_libraries_button_opens_the_libraries_menu() { let earth = menu_item_rect(&shapes, "Earth Tones").expect("the menu lists the built-in libraries"); assert!(earth.top() > defaults.top()); click(&mut app, &ctx, earth.center(), Modifiers::NONE, control_bar); - let open = app.ui.library_panel.as_ref().map(|o| (o.kind.clone(), o.id.clone())); + let open = crate::panels::library_panel::last_open(&app.ui).map(|o| (o.kind.clone(), o.id.clone())); assert_eq!(open, Some(("swatches".into(), "earth-tones".into()))); } diff --git a/crates/ui-egui/src/tests_picks.rs b/crates/ui-egui/src/tests_picks.rs index da9da921a..2c071fa2f 100644 --- a/crates/ui-egui/src/tests_picks.rs +++ b/crates/ui-egui/src/tests_picks.rs @@ -12,7 +12,7 @@ use serde_json::{Value, json}; use vectorcraft_engine::Session; use crate::panels::graphic_styles::GraphicStyleLibraries; -use crate::panels::library_panel::library_menu; +use crate::panels::library_panel::{self, library_menu}; use crate::panels::swatches::SwatchLibraries; use crate::picks::{self, PickRequest}; use crate::state::Dialog; @@ -232,10 +232,10 @@ fn other_swatch_library_asks_off_the_ui_thread() { click(&mut app, "Other Library…", library_menu::); assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)])); poll(&mut app); - assert!(app.ui.library_panel.is_none() && app.ui.status.is_empty(), "{}", app.ui.status); + assert!(library_panel::last_open(&app.ui).is_none() && app.ui.status.is_empty(), "{}", app.ui.status); shown.answer(&[&file]); poll(&mut app); - let open = app.ui.library_panel.clone().unwrap_or_else(|| panic!("no library open: {}", app.ui.status)); + let open = library_panel::last_open(&app.ui).cloned().unwrap_or_else(|| panic!("no library open: {}", app.ui.status)); assert_eq!(open.kind, "swatches"); let (_, lib) = vectorcraft_engine::cmd::swatchlib::library(&app.session, &open.id).unwrap(); assert!(lib.swatch("Signal").is_some(), "{}", open.id); @@ -255,7 +255,7 @@ fn other_graphic_style_library_asks_off_the_ui_thread() { assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)])); shown.answer(&[&file]); poll(&mut app); - let open = app.ui.library_panel.clone().unwrap_or_else(|| panic!("no library open: {}", app.ui.status)); + let open = library_panel::last_open(&app.ui).cloned().unwrap_or_else(|| panic!("no library open: {}", app.ui.status)); assert_eq!(open.kind, "graphicStyles"); let (_, lib) = vectorcraft_engine::cmd::stylelib::library(&app.session, &open.id).unwrap(); assert!(lib.style("Poster Look").is_some(), "{}", open.id); diff --git a/crates/ui-egui/src/tests_place.rs b/crates/ui-egui/src/tests_place.rs index 381d08817..3f35e81e6 100644 --- a/crates/ui-egui/src/tests_place.rs +++ b/crates/ui-egui/src/tests_place.rs @@ -168,7 +168,7 @@ fn a_dropped_swatch_library_opens_in_the_library_panel() { let gpl = temp_file("dropped.gpl", b"GIMP Palette\nName: Dropped\n#\n255 0 0\tRed\n 0 128 255\tSky\n"); frame(&mut app, &ctx, vec![], &[&gpl], false); let id = format!("loaded/{gpl}"); - assert_eq!(app.ui.library_panel.as_ref().map(|o| o.id.as_str()), Some(id.as_str()), "{}", app.ui.status); + assert_eq!(crate::panels::library_panel::last_open(&app.ui).map(|o| o.id.as_str()), Some(id.as_str()), "{}", app.ui.status); let lib = app.session.execute("swatch.library.get", &json!({"library": id})).unwrap(); let names: Vec<&str> = lib["swatches"].as_array().unwrap().iter().filter_map(|w| w["name"].as_str()).collect(); assert_eq!((lib["name"].as_str(), names), (Some("Dropped"), vec!["Red", "Sky"])); diff --git a/docs/mcp.md b/docs/mcp.md index 1feeaaa8f..cde63dac5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1399,6 +1399,34 @@ with); without `path` it returns `{data}`, and `user: true` saves into the user file, or another document's graphic styles, as a library to add from; a file in the user library folder, or a file with the same extension and bytes as one there, loads as that User Defined library. +## Library panels + +Each swatch or graphic style library opens in a read-only panel of its own (#2603), as in Illustrator, and +several can be open at once. `window.swatchLibrary {library}` and `window.graphicStyleLibrary {library}` +(remote mode, `invoke_menu`) open one floating, or bring its panel forward when it is already open, and answer +`{open, panel, name, count}`; `library: null` closes every panel of that kind. The panel id is +`library::` (`library:swatches:pastels`, `library:graphicStyles:hand-drawn`, +`library:swatches:loaded/C:/Palettes/Brand.ase`), and the panel commands take it like any other panel's: +`window.panel.float {panel, onto?, x?, y?}` groups or moves it, `window.panel.dock {panel}` docks it, +`window.panel.close {panel}` closes it. `window.swatchLibrary.other {path}` and `window.graphicStyleLibrary.other +{path}` load a file and open it the same way. + +`ui.libraryPanel {panel, library?, view?, showFind?, persistent?}` sets one panel: `library` shows another library +of its kind in that panel (what Previous Library and Next Library do; a library open in a panel of its own is +brought forward instead), `view` is `smallThumb`, `mediumThumb`, `largeThumb`, `smallList` or `largeList`, +`showFind` shows or hides the find field, and `persistent: true` is the panel menu's Persistent: the panel +reopens when the app starts, with its view and find field, where the saved layout had it (or floating). A +library loaded from a file is read again from its path; one whose file is gone is left out, and the status bar +says which. A closed persistent panel can still be set, so `persistent: false` stops it reopening. The reply +is `{panel, kind, library, view, showFind, persistent, open}`, and `ui.inspect` lists every panel's settings +under `ui.library_panels`. + +```json +{"name":"invoke_menu","arguments":{"command":"window.swatchLibrary","params":{"library":"pastels"}}} +{"name":"invoke_menu","arguments":{"command":"ui.libraryPanel","params":{"panel":"library:swatches:pastels","view":"smallList","persistent":true}}} +{"name":"invoke_menu","arguments":{"command":"window.panel.close","params":{"panel":"library:swatches:pastels"}}} +``` + ## Libraries The Libraries panel's libraries hold graphics, colours and character and paragraph styles that any document can use. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4de19cebb..d97382388 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (library panels: one per library, dockable and tabbable, with Persistent, #2603; the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to, @@ -223,6 +223,7 @@ panel-menu commands such as Break Link to Symbol), so they can be given shortcut | Blend panel (30.7) | partial | Window › Blend: Make, Spacing, Orientation, Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, running the Object › Blend commands; the easing and color controls are missing | | SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) | | Workspaces (save, reset, switch) | done | | +| Swatch and graphic style library panels: each library opens in a panel of its own, several at once, that floats, groups, docks and closes like any panel; opening an open library brings its panel forward; Previous / Next Library change the library in place; the panel menu's Persistent reopens the panel at startup with its view and find field (a library loaded from a file is read again, a missing file is skipped with a status-bar note) | done | #2603; panel ids `library::`, `ui.libraryPanel {panel, library?, view?, showFind?, persistent?}`; the library panels have no sort option, as before | | DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; native CIRCLE/ARC export and CNC preset remain pending | | macOS save sheet: choose the format from a menu | done | #866; the save panel is VectorCraft's own `NSSavePanel` with its Format menu set before the sheet opens (`apps/vectorcraft/src/mac_save_format.rs`); #1855 had withdrawn the menu that was added after the sheet began, which made Save As, Save a Copy and the first Save show no sheet | | Preferences: every option acts | partial | ~16 stored but unused (#394) | @@ -242,6 +243,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, # | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Library panels (#2603): each swatch or graphic style library opens in its own dockable, tabbable panel, several at once; the panel menu's Persistent reopens a panel at startup with its view and find field, a library loaded from a file read again from its path; scores unchanged (estimated) | | 2026-10-17 | minor | Selection tool: clicking the red out port of overflowing area type, then a closed path, empty canvas or a dragged rectangle threads the text there (#2572 in part); Escape cancels. Scores and hours unchanged. | | 2026-10-17 | minor | Group Selection picks one path of a compound path (the outer shape or a hole) and moves it on its own; a second click adds the compound path (#2441, in part) | | 2026-10-16 | minor | The Move dialog opens on the offset of the last drag or Move and with the checkboxes it was last confirmed with; scores unchanged |