Merge pull request #2714 from storytold/fix/2603-library-panels

Fix #2603: a panel per swatch library, with Persistent
This commit is contained in:
BFlat
2026-10-17 19:25:40 +07:00
committed by GitHub
42 changed files with 909 additions and 226 deletions
+2
View File
@@ -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() {
+12 -6
View File
@@ -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 });
+11 -7
View File
@@ -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<String> = 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 {
+2
View File
@@ -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 Опции на панела с библиотека
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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 άνοιξαν πάρα πολλοί πίνακες βιβλιοθηκών σε αυτή τη συνεδρία
Can't render this file because it contains an unexpected character in line 1993 and column 19.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 3755 and column 19.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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 このセッションで開いたライブラリパネルが多すぎます
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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 이 세션에서 라이브러리 패널을 너무 많이 열었습니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -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 в этом сеансе открыто слишком много панелей библиотек
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+6
View File
@@ -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 у цьому сеансі відкрито забагато панелей бібліотек
Can't render this file because it contains an unexpected character in line 723 and column 40.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 3936 and column 10.
+6
View File
@@ -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 本次会话中打开的库面板过多
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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 資料庫面板選項
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -1
View File
@@ -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 {
+19 -6
View File
@@ -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:<id>`, 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:<id>`), 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<R
app.ui.dock_tab = tab;
Ok(Value::Null)
}
// A library panel isn't in the icon column: it floats.
(Some(p), None) if crate::panels::library_panel::parse(p).is_some() => 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<R
}
"ui.flattenerPreview" => 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());
+24 -3
View File
@@ -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,
+32
View File
@@ -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"]);
}
+1 -1
View File
@@ -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());
+15 -9
View File
@@ -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::<GraphicStyleLibraries>(&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<String> {
app.session.style_libraries.path(id).map(str::to_string)
}
fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec<Row<'a, LibStyle>> {
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<Value, String> {
library_panel::open_command::<GraphicStyleLibraries>(app, p, "graphicStyle.libraries", |app, key| {
stylelib::library(&app.session, key).map(|(info, lib)| (info.id, info.name, lib.len()))
})
library_panel::open_command::<GraphicStyleLibraries>(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<Value, String> {
let r = app.run("graphicStyle.loadLibrary", params)?;
open_library(app, &json!({ "library": r["library"] }))
+1 -1
View File
@@ -53,7 +53,7 @@ fn library_label(app: &VectorcraftApp, key: &str) -> String {
fn library_list(app: &VectorcraftApp, ui: &mut Ui, current: &str) -> Option<String> {
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.
+610 -164
View File
@@ -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 (<https://helpx.adobe.com/illustrator/using/using-creating-swatches.html>,
//! "Swatch libraries"), each open library is a panel of its own, `library:<kind>:<library id>`
//! ([`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<String>,
/// The view picked from the panel menu (none: the kind's, [`LibraryKind::VIEW`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view: Option<View>,
/// 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<LibraryRef>;
/// 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<String>;
/// The rows matching the find field's `query` (lower case; empty: all).
fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec<Row<'a, Self::Item>>;
/// 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<K: LibraryKind>(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<String>,
find: fn(&VectorcraftApp, &str) -> Found,
path: fn(&VectorcraftApp, &str) -> Option<String>,
body: fn(&mut VectorcraftApp, &mut Ui, &str),
menu: fn(&mut VectorcraftApp, &mut Ui, &str),
}
/// The view of `K`'s panel.
fn view<K: LibraryKind>(ctx: &egui::Context) -> View {
pstate::<Option<View>>(ctx, &key::<K>("view")).unwrap_or(K::VIEW)
}
/// The interaction id of the tile or row of `name`.
pub(crate) fn tile_id<K: LibraryKind>(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::<super::swatches::SwatchLibraries>(app, ctx, &open.id),
super::graphic_styles::GraphicStyleLibraries::KIND => window::<super::graphic_styles::GraphicStyleLibraries>(app, ctx, &open.id),
_ => app.ui.library_panel = None,
fn kind_of<K: LibraryKind>() -> 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::<K>,
menu: panel_menu::<K>,
}
}
/// The kind of library `kind` names.
fn kind(kind: &str) -> Option<Kind> {
[kind_of::<super::swatches::SwatchLibraries>(), kind_of::<super::graphic_styles::GraphicStyleLibraries>()].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<Vec<&'static str>> = 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<String> {
let mut out: Vec<String> = 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<K: LibraryKind>(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<String> {
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<String> = 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::<Vec<_>>() {
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: Serialize + serde::de::DeserializeOwned>(t: &T, from: &str, to: &str) -> Result<T, String> {
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<K: LibraryKind>(
app: &mut VectorcraftApp,
p: &Value,
list: &str,
found: impl FnOnce(&VectorcraftApp, &str) -> Option<(String, String, usize)>,
) -> Result<Value, String> {
/// (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<K: LibraryKind>(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
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::<K>(), &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<Value, String> {
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<Value, String> {
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::<View>(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<LibraryInfo>) ->
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<K: LibraryKind>(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::<K>(app, ui, id);
});
egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
ui.set_width(236.0);
body::<K>(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<K: LibraryKind>(ctx: &egui::Context, id: &str) -> Vec<String> {
let (lib, sel): (String, Vec<String>) = pstate(ctx, &key::<K>("selected"));
if lib == id { sel } else { vec![] }
/// The selected items and folders of panel `panel`.
fn selection(ctx: &egui::Context, panel: &str) -> Vec<String> {
pstate(ctx, &key(panel, "selected"))
}
fn set_selection<K: LibraryKind>(ctx: &egui::Context, id: &str, sel: Vec<String>) {
set_pstate(ctx, &key::<K>("selected"), (id.to_string(), sel));
}
/// Find field, thumbnails or list rows, and the bottom bar.
fn body<K: LibraryKind>(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, lib: &K::Lib) {
let view = view::<K>(ui.ctx());
let query = if pstate::<bool>(ui.ctx(), &key::<K>("hide-find")) {
String::new()
} else {
widgets::search_field(ui, egui::Id::new(key::<K>("find")), tl!("Find"))
};
/// Find field, thumbnails or list rows, and the bottom bar of library `id`'s panel.
fn body<K: LibraryKind>(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::<K>(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<K::Item>, tip: &dyn Fn() -> String| {
@@ -243,13 +526,13 @@ fn body<K: LibraryKind>(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::<K>("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::<K>(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<K: LibraryKind>(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::<K>(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<K: LibraryKind>(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::<K>("anchor"), selected, &order, &name, m);
set_selection::<K>(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<K: LibraryKind>(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<K: LibraryKind>(app: &mut VectorcraftApp, id: &str, step: isize) {
if let Some(next) = neighbour::<K>(app, id, step) {
set(app, &panel_id(K::KIND, id), json!({ "library": next }));
}
}
/// Open library `id` in its panel.
fn open<K: LibraryKind>(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<K: LibraryKind>(app: &mut VectorcraftApp, id: &str) {
/// Libraries menu, previous / next library, and Add to … for the selection.
fn bottom<K: LibraryKind>(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
let selected = selection::<K>(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::<K>(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::<K>(app, id, step)
{
open::<K>(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::<K>(app, id, s);
}
}
ui.add_space((ui.available_width() - 28.0).max(0.0));
@@ -324,54 +612,56 @@ fn bottom<K: LibraryKind>(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
});
}
/// The library panel's (≡) menu.
/// A library panel's (≡) menu.
fn panel_menu<K: LibraryKind>(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
let selected = selection::<K>(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::<K>(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::<K>("view"), Some(v));
set(app, &panel, json!({ "view": v }));
}
}
ui.separator();
let hidden: bool = pstate(ui.ctx(), &key::<K>("hide-find"));
if menu_item(ui, tl!("Show Find Field"), true, !hidden) {
set_pstate(ui.ctx(), &key::<K>("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::<K>(app, id, step)
{
open::<K>(app, &next);
for (label, s) in [(tl!("Previous Library"), -1), (tl!("Next Library"), 1)] {
if menu_item(ui, label, true, false) {
step::<K>(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<K: LibraryKind>(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::<K>(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<String> {
/// 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<String> {
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::<SwatchLibraries>(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::<SwatchLibraries>(&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::<SwatchLibraries>(&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::<SwatchLibraries>(&ctx, "brights").is_empty());
assert!(selection(&ctx, "library:swatches:brights").is_empty());
}
#[test]
@@ -494,12 +789,12 @@ mod tests {
let order: Vec<String> = 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::<SwatchLibraries>(&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::<SwatchLibraries>(&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::<SwatchLibraries>("view"), Some(View::SmallList));
ctx.data_mut(|d| d.insert_temp(egui::Id::new(key::<SwatchLibraries>("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::<SwatchLibraries>("#FF0033")).is_some());
assert!(ctx.read_response(tile_id::<SwatchLibraries>("#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<String> {
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<Vec<String>> {
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::<SwatchLibraries>(&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::<SwatchLibraries>(&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}");
}
}
}
+2
View File
@@ -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
+17 -10
View File
@@ -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<String>, 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::<SwatchLibraries>(&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<String, String>
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<SwatchLibrary>;
@@ -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<String> {
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<Row<'a, Swatch>> {
@@ -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<Value, String> {
library_panel::open_command::<SwatchLibraries>(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::<SwatchLibraries>(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<Value, String> {
let r = app.run("swatch.library.load", params)?;
open_library(app, &json!({ "library": r["library"] }))
@@ -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::<GraphicStyleLibraries>(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::<GraphicStyleLibraries>("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();
+6 -4
View File
@@ -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<crate::panels::library_panel::OpenLibrary>,
pub library_panels: Vec<crate::panels::library_panel::LibraryPanel>,
/// 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<FloatingPanels>, 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(),
+1 -1
View File
@@ -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())));
}
+4 -4
View File
@@ -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::<SwatchLibraries>);
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);
+1 -1
View File
@@ -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"]));
+28
View File
@@ -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:<kind>:<library id>` (`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.
+3 -1
View File
File diff suppressed because one or more lines are too long