Preferences: a search field finds a setting and goes to its category

The Preferences dialog has a Search Preferences field above the category
list. It has the keyboard focus when the dialog opens, and Cmd/Ctrl+F
focuses it again. While it has text, the list shows every setting whose
label, section or category contains it (ignoring case, in the interface
language or in English), or "No settings found". Choosing a setting shows
its category, scrolls to its row and highlights it. Escape in the field
with text clears it; Escape in the empty field closes the dialog.

The dialog's `__search` field holds the text, so `ui.dialog.set` drives
the search too.
This commit is contained in:
dotCooCoo
2026-11-08 12:21:47 +07:00
committed by bflatastic
parent 7a035cd88f
commit aa2aa40166
32 changed files with 400 additions and 14 deletions
+2
View File
@@ -2687,6 +2687,8 @@ digits Malayalam Малаялам
Reset OpenType Features Нулиране на OpenType характеристиките
Reset Panel Нулиране на панела
Reset Preferences Нулиране на настройките
Search Preferences Търсене в настройките
No settings found Няма намерени настройки
Reset Profiles Нулиране на профилите
Reset Rotate View Нулиране на изгледа на завъртане
Rotate View to Selection Завъртане на изгледа спрямо селекцията
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3634,6 +3634,8 @@ theme Appearance Izgled
Real-time Drawing and Editing Crtanje i uređivanje u stvarnom vremenu
Render Threads (-1 = Automatic) Niti iscrtavanja (-1 = automatski)
Reset Preferences Vrati postavke na početne
Search Preferences Pretraga postavki
No settings found Nema pronađenih postavki
Scale Corners Skaliraj uglove
Scale Cursor Proportional to UI Skaliraj kursor proporcionalno interfejsu
Scale Strokes & Effects Skaliraj poteze i efekte
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3401,6 +3401,8 @@ digits Malayalam Malaiàlam
Reset Painting Restableix Pintura
Reset Panel Restableix el panell
Reset Preferences Restableix les preferències
Search Preferences Cerca a les preferències
No settings found No s'ha trobat cap paràmetre
Reset Printing and Proofing Restableix impressió i prova
Reset Profiles Restableix els perfils
Reset Rotate View Restableix la rotació de la vista
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3675,6 +3675,8 @@ theme Appearance Motiv
Real-time Drawing and Editing Kreslení a úpravy v reálném čase
Render Threads (-1 = Automatic) Vlákna vykreslování (-1 = automaticky)
Reset Preferences Obnovit předvolby
Search Preferences Hledat v předvolbách
No settings found Nenalezena žádná nastavení
Scale Corners Měnit velikost rohů
Scale Cursor Proportional to UI Měnit velikost kurzoru úměrně rozhraní
Scale Strokes & Effects Měnit velikost tahů a efektů
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3403,6 +3403,8 @@ theme Appearance Udseende
Real-time Drawing and Editing Tegning og redigering i realtid
Render Threads (-1 = Automatic) Renderingstråde (-1 = automatisk)
Reset Preferences Nulstil indstillinger
Search Preferences Søg i præferencer
No settings found Ingen indstillinger fundet
Scale Corners Skalér hjørner
Scale Cursor Proportional to UI Skalér markør proportionalt med brugergrænsefladen
Scale Strokes & Effects Skalér konturer & effekter
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2714,6 +2714,8 @@ digits Malayalam Malayalam
Reset OpenType Features OpenType-Funktionen zurücksetzen
Reset Panel Bedienfeld zurücksetzen
Reset Preferences Voreinstellungen zurücksetzen
Search Preferences Voreinstellungen durchsuchen
No settings found Keine Einstellungen gefunden
Reset Profiles Profile zurücksetzen
Rotate View Ansicht drehen
Reset Rotate View Ansichtsdrehung zurücksetzen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1872,6 +1872,8 @@ font-technology Variable Μεταβλητές
Reselect Επαναφορά προηγούμενης επιλογής
Reset Επαναφορά
Reset Preferences Επαναφορά προτιμήσεων
Search Preferences Αναζήτηση στις προτιμήσεις
No settings found Δεν βρέθηκαν ρυθμίσεις
Reset to Default Επαναφορά προεπιλογής
Resolution: Ανάλυση:
Restore Επαναφορά
Can't render this file because it contains an unexpected character in line 163 and column 5.
+2
View File
@@ -2757,6 +2757,8 @@ digits Malayalam Malayálam
Reset OpenType Features Restablecer funciones OpenType
Reset Panel Restablecer panel
Reset Preferences Restablecer preferencias
Search Preferences Buscar en preferencias
No settings found No se encontraron ajustes
Reset Profiles Restablecer perfiles
Reset Rotate View Restablecer rotación de vista
Rotate View to Selection Rotar vista según la selección
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3599,6 +3599,8 @@ theme Appearance Ulkoasu
Real-time Drawing and Editing Reaaliaikainen piirto ja muokkaus
Render Threads (-1 = Automatic) Renderöintisäikeet (-1 = automaattinen)
Reset Preferences Palauta asetukset
Search Preferences Hae asetuksista
No settings found Asetuksia ei löytynyt
Scale Corners Skaalaa kulmat
Scale Cursor Proportional to UI Skaalaa kohdistin suhteessa käyttöliittymään
Scale Strokes & Effects Skaalaa viivat ja tehosteet
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2749,6 +2749,8 @@ digits Malayalam Malayalam
Reset OpenType Features Réinitialiser les fonctions OpenType
Reset Panel Réinitialiser le panneau
Reset Preferences Réinitialiser les préférences
Search Preferences Rechercher dans les préférences
No settings found Aucun réglage trouvé
Reset Profiles Réinitialiser les profils
Reset Rotate View Réinitialiser la rotation de l’affichage
Rotate View to Selection Faire pivoter l’affichage selon la sélection
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3717,6 +3717,8 @@
Reset Painting पेंटिंग रीसेट करें
Reset Panel पैनल रीसेट करें
Reset Preferences प्रेफ़रेंसेज़ रीसेट करें
Search Preferences प्रेफ़रेंसेज़ में खोजें
No settings found कोई सेटिंग नहीं मिली
Reset Printing and Proofing मुद्रण और प्रूफिंग रीसेट करें
Reset Profiles प्रोफ़ाइल रीसेट करें
Reset Rotate View व्यू घुमाएँ रीसेट करें
Can't render this file because it is too large.
+2
View File
@@ -3295,6 +3295,8 @@ mark-style Japanese Japán
Reset Painting Painting visszaállítása
Reset Panel Panel visszaállítása
Reset Preferences Beállítások visszaállítása
Search Preferences Keresés a beállításokban
No settings found Nincs ilyen beállítás
Reset Printing and Proofing Printing and Proofing visszaállítása
Reset Profiles Profilok visszaállítása
Reset Rotate View Nézetforgatás visszaállítása
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3403,6 +3403,8 @@ theme Appearance Tampilan
Real-time Drawing and Editing Penggambaran dan pengeditan waktu nyata
Render Threads (-1 = Automatic) Utas render (-1 = Otomatis)
Reset Preferences Atur ulang preferensi
Search Preferences Cari preferensi
No settings found Tidak ada pengaturan yang ditemukan
Scale Corners Skalakan sudut
Scale Cursor Proportional to UI Skalakan kursor proporsional dengan UI
Scale Strokes & Effects Skalakan guratan & efek
Can't render this file because it contains an unexpected character in line 2243 and column 5.
+2
View File
@@ -2790,6 +2790,8 @@ digits Malayalam Malayalam
Reset OpenType Features Reimposta funzioni OpenType
Reset Panel Reimposta pannello
Reset Preferences Reimposta preferenze
Search Preferences Cerca nelle preferenze
No settings found Nessuna impostazione trovata
Reset Profiles Reimposta profili
Rotate View Ruota vista
Reset Rotate View Reimposta rotazione vista
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2768,6 +2768,8 @@ mark-style Japanese 日本式
Reset OpenType Features OpenType 機能をリセット
Reset Panel パネルをリセット
Reset Preferences 環境設定をリセット
Search Preferences 環境設定を検索
No settings found 設定が見つかりません
Reset Profiles プロファイルをリセット
Reset the rows to the artwork's colors 行をアートワークのカラーにリセット
Reset to Default 初期設定に戻す
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3220,6 +3220,8 @@ theme Appearance იერსახე
Real-time Drawing and Editing რეალურ დროში ხატვა და რედაქტირება
Render Threads (-1 = Automatic) ნაკადები (-1 = ავტომატური)
Reset Preferences პარამეტრების ჩამოყრა
Search Preferences პარამეტრებში ძიება
No settings found პარამეტრები ვერ მოიძებნა
Scale Corners კუთხეების მასშტაბირება
Scale Cursor Proportional to UI კურსორის მასშტაბირება ინტერფეისის პროპორციულად
Scale Strokes & Effects კონტურებისა და ეფექტების მასშტაბირება
Can't render this file because it is too large.
+2
View File
@@ -2757,6 +2757,8 @@ mark-style Japanese 일본식
Reset OpenType Features OpenType 기능 재설정
Reset Panel 패널 재설정
Reset Preferences 환경 설정 재설정
Search Preferences 환경 설정 검색
No settings found 설정을 찾을 수 없음
Reset Profiles 프로필 재설정
Reset the rows to the artwork's colors 행을 아트웍 색상으로 재설정
Reset to Default 기본값으로 재설정
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3417,6 +3417,8 @@ theme Appearance Vormgeving
Real-time Drawing and Editing Tekenen en bewerken in realtime
Render Threads (-1 = Automatic) Renderthreads (-1 = automatisch)
Reset Preferences Voorkeuren herstellen
Search Preferences Voorkeuren doorzoeken
No settings found Geen instellingen gevonden
Scale Corners Hoeken schalen
Scale Cursor Proportional to UI Cursor proportioneel schalen met UI
Scale Strokes & Effects Streken & effecten schalen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3538,6 +3538,8 @@ theme Appearance Wygląd
Real-time Drawing and Editing Rysowanie i edycja w czasie rzeczywistym
Render Threads (-1 = Automatic) Wątki renderowania (-1 = automatycznie)
Reset Preferences Resetuj preferencje
Search Preferences Szukaj w preferencjach
No settings found Nie znaleziono ustawień
Scale Corners Skaluj narożniki
Scale Cursor Proportional to UI Skaluj kursor proporcjonalnie do interfejsu
Scale Strokes & Effects Skaluj obrysy i efekty
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2300,6 +2300,8 @@ digits Malayalam Malaiala
Reset OpenType Features Redefinir Recursos OpenType
Reset Panel Redefinir Painel
Reset Preferences Redefinir Preferências
Search Preferences Pesquisar nas Preferências
No settings found Nenhuma configuração encontrada
Reset Profiles Redefinir Perfis
Rotate View Girar Visualização
Reset Rotate View Redefinir Visualização Girada
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2785,6 +2785,8 @@ digits Malayalam Малаялам
Reset OpenType Features Сбросить функции OpenType
Reset Panel Сбросить панель
Reset Preferences Сбросить настройки
Search Preferences Поиск в настройках
No settings found Настройки не найдены
Reset Profiles Сбросить профили
Rotate View Повернуть вид
Reset Rotate View Сбросить поворот вида
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2693,6 +2693,8 @@ digits Malayalam Malayalam
Reset OpenType Features Återställ OpenType-funktioner
Reset Panel Återställ panel
Reset Preferences Återställ inställningar
Search Preferences Sök i inställningar
No settings found Inga inställningar hittades
Reset Profiles Återställ profiler
Reset Rotate View Återställ Rotera vy
Rotate View to Selection Rotera vyn efter markeringen
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -2562,6 +2562,8 @@ digits Malayalam Malayalam
Reset OpenType Features OpenType Özelliklerini Sıfırla
Reset Panel Paneli Sıfırla
Reset Preferences Tercihleri Sıfırla
Search Preferences Tercihlerde Ara
No settings found Ayar bulunamadı
Reset Profiles Profilleri Sıfırla
Reset Rotate View Döndürme Görünümünü Sıfırla
Rotate View to Selection Görünümü Seçime Döndür
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -2782,6 +2782,8 @@ digits Malayalam Малаялам
Reset OpenType Features Скинути функції OpenType
Reset Panel Скинути панель
Reset Preferences Скинути налаштування
Search Preferences Пошук у налаштуваннях
No settings found Налаштування не знайдено
Reset Profiles Скинути профілі
Rotate View Повернути вид
Reset Rotate View Скинути поворот виду
Can't render this file because it contains an unexpected character in line 177 and column 5.
+2
View File
@@ -3059,6 +3059,8 @@ mark-style Japanese Kiểu Nhật
Reset OpenType Features Đặt lại tính năng OpenType
Reset Panel Đặt lại bảng
Reset Preferences Đặt lại tùy chọn
Search Preferences Tìm trong tùy chọn
No settings found Không tìm thấy cài đặt
Reset Profiles Đặt lại hồ sơ màu
Rotate View Xoay chế độ xem
Reset Rotate View Đặt lại xoay chế độ xem
Can't render this file because it contains an unexpected character in line 199 and column 5.
+2
View File
@@ -2661,6 +2661,8 @@ digits Malayalam 马拉雅拉姆数字
Reset OpenType Features 重置 OpenType 特性
Reset Panel 重置面板
Reset Preferences 重置首选项
Search Preferences 搜索首选项
No settings found 未找到设置
Reset Profiles 重置配置文件
Reset Rotate View 重置旋转视图
Rotate View to Selection 按所选对象旋转视图
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3654,6 +3654,8 @@ digits Malayalam 馬來亞拉姆數字
Reset OpenType Features 重設 OpenType 功能
Reset Panel 重設面板
Reset Preferences 重設偏好設定
Search Preferences 搜尋偏好設定
No settings found 找不到設定
Reset Profiles 重設描述檔
Rotate View 旋轉檢視
Reset Rotate View 重設旋轉檢視
Can't render this file because it contains an unexpected character in line 6 and column 47.
+186 -13
View File
@@ -32,9 +32,158 @@ pub fn open(app: &mut VectorcraftApp, category: Option<&str>) {
fields.insert("__smartGuides".into(), json!(app.ui.view.smart_guides));
fields.insert("__snapToGrid".into(), json!(app.ui.view.snap_to_grid));
fields.insert("__taskBar".into(), json!(app.ui.task_bar));
fields.insert("__search".into(), json!(""));
fields.insert("__focusSearch".into(), json!(true));
app.ui.dialog = Some(Dialog { kind: "preferences".into(), fields });
}
/// A setting the dialog shows, by its key, category, section ("" for none) and label, in English.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Setting {
pub key: &'static str,
pub category: &'static str,
pub section: &'static str,
pub label: &'static str,
}
/// Every setting the dialog shows on this platform: the UI-only fields, then the engine's
/// preferences, each category's in the order the dialog lists them.
fn settings() -> impl Iterator<Item = Setting> {
let ui_fields = UI_FIELDS.iter().map(|&(key, category, label)| Setting { key, category, section: "", label });
let shown = |key: &str| match key {
"systemTitleBar" => cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))),
"linuxDisplayServer" => cfg!(target_os = "linux"),
_ => true,
};
let prefs =
PREF_SPECS.iter().filter(move |s| shown(s.key)).map(|s| Setting { key: s.key, category: s.category, section: s.section, label: s.label });
ui_fields.chain(prefs)
}
/// The settings whose label, section or category contains `query`, ignoring case, in the language
/// the dialog is drawn in or in English, grouped by category in the dialog's order. An empty
/// `query` finds none.
pub(crate) fn search(query: &str) -> Vec<Setting> {
let q = query.trim().to_lowercase();
if q.is_empty() {
return vec![];
}
let hit = |s: &str| !s.is_empty() && (tl!(s).to_lowercase().contains(&q) || s.to_lowercase().contains(&q));
let mut found: Vec<Setting> = settings().filter(|s| hit(s.label) || hit(s.section) || hit(s.category)).collect();
found.sort_by_key(|s| PREF_CATEGORIES.iter().position(|c| *c == s.category).unwrap_or(usize::MAX));
found
}
/// Goes to the setting `s` a search found: its category is shown, the list scrolls to it and its
/// row is highlighted. The search field is cleared.
fn go_to(d: &mut Dialog, s: &Setting) {
d.fields.insert("__category".into(), json!(s.category));
d.fields.insert("__found".into(), json!(s.key));
d.fields.insert("__scrollTo".into(), json!(true));
d.fields.insert("__search".into(), json!(""));
}
/// The search field's memory key ([`widgets::search_field`]).
fn search_id() -> egui::Id {
egui::Id::unique("prefs-search")
}
/// The search field's text edit, which takes the keyboard focus.
pub(crate) fn search_edit_id() -> egui::Id {
search_id().with("edit")
}
/// Whether the search field had the keyboard focus when the dialog was last drawn.
fn search_focused_id() -> egui::Id {
egui::Id::unique("prefs-search-focused")
}
/// Escape while the Preferences dialog's search field has text and the keyboard focus: the field is
/// cleared and keeps the focus, and the Escape goes no further. → Whether it did; otherwise Escape
/// does what it does in any dialog.
pub(crate) fn clear_search_on_escape(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == "preferences") else { return false };
// egui takes the focus from the field when the frame with Escape begins: the field's focus is
// the one recorded when the dialog was last drawn.
if d.str("__search").is_empty() || !ctx.data(|m| m.get_temp::<bool>(search_focused_id()).unwrap_or(false)) {
return false;
}
d.fields.insert("__search".into(), json!(""));
d.fields.insert("__focusSearch".into(), json!(true));
ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key: egui::Key::Escape, pressed: true, .. })));
true
}
/// The search field above the category list. It takes the keyboard focus when the dialog opens and
/// on Cmd/Ctrl+F. → Its text.
fn search_box(ui: &mut egui::Ui, d: &mut Dialog) -> String {
let refocus = ui.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::F));
if refocus {
d.fields.insert("__focusSearch".into(), json!(true));
}
ui.data_mut(|m| m.insert_temp(search_id(), d.str("__search")));
let query = widgets::search_field(ui, search_id(), tl!("Search Preferences"));
if d.bool("__focusSearch") {
// Not on the window's measuring frame, nor in the frame of the click that opened the
// dialog: egui takes the focus from a field such a click missed.
if ui.is_sizing_pass() || ui.input(|i| i.pointer.any_click()) {
ui.ctx().request_repaint();
} else {
widgets::focus_field(ui, search_edit_id(), &query);
d.fields.remove("__focusSearch");
}
}
let focused = ui.memory(|m| m.has_focus(search_edit_id()));
ui.data_mut(|m| m.insert_temp(search_focused_id(), focused));
d.fields.insert("__search".into(), json!(query));
query
}
/// The settings `query` finds, in place of the category list: choosing one goes to it.
fn search_results(ui: &mut egui::Ui, d: &mut Dialog, query: &str) {
let t = Tokens::get(ui.ctx());
let found = search(query);
if found.is_empty() {
ui.label(egui::RichText::new(tl!("No settings found")).color(t.text_dim).size(12.0));
return;
}
egui::ScrollArea::vertical().id_salt("prefs-search-results").max_height(380.0).auto_shrink([false, true]).show(ui, |ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
for s in &found {
let text = egui::RichText::new(tl!(s.label)).size(12.5).color(t.text);
let place = if s.section.is_empty() { tl!(s.category).to_string() } else { format!("{} › {}", tl!(s.category), tl!(s.section)) };
let b = egui::Button::selectable(false, text).min_size(egui::vec2(180.0, 22.0));
if ui.add(b).on_hover_text(&place).clicked() {
go_to(d, s);
}
ui.label(egui::RichText::new(place).color(t.text_dim).size(11.0));
ui.add_space(3.0);
}
});
}
/// Before a setting's row: where to paint the highlight behind it, when it is the setting a search
/// went to ([`go_to`]).
fn found_mark(ui: &mut egui::Ui, d: &Dialog, key: &str) -> Option<(egui::layers::ShapeIdx, f32)> {
(d.str("__found") == key).then(|| (ui.painter().add(egui::Shape::Noop), ui.cursor().top()))
}
/// After the row [`found_mark`] marked: highlights it, and scrolls the list to it once.
fn found_paint(ui: &mut egui::Ui, d: &mut Dialog, mark: Option<(egui::layers::ShapeIdx, f32)>) {
let Some((idx, top)) = mark else { return };
let bottom = ui.min_rect().bottom();
if bottom <= top {
return;
}
let rect = egui::Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=bottom).expand2(egui::vec2(0.0, 2.0));
let t = Tokens::get(ui.ctx());
ui.painter().set(idx, egui::epaint::RectShape::filled(rect, 3.0, t.accent.gamma_multiply(0.22)));
if d.bool("__scrollTo") && !ui.is_sizing_pass() {
ui.scroll_to_rect(rect, Some(egui::Align::Center));
d.fields.remove("__scrollTo");
}
}
/// OK: validate and apply every preference in the dialog (nothing changes on error).
pub fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
@@ -189,20 +338,32 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
crate::dialogs::modal::heading(ui, tl!("Preferences"));
ui.add_space(12.0);
ui.horizontal_top(|ui| {
// Category list.
egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
ui.set_width(196.0);
ui.set_min_height(430.0);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 1.0;
for c in PREF_CATEGORIES {
let sel = *c == cat;
let text = egui::RichText::new(tl!(*c)).size(12.5).color(if sel { t.text_strong } else { t.text });
let b = egui::Button::selectable(sel, text).min_size(egui::vec2(184.0, 24.0));
if ui.add(b).clicked() {
d.fields.insert("__category".into(), json!(c));
ui.vertical(|ui| {
ui.set_width(208.0);
// Search, above the category list; while it has text, the settings it finds show
// in place of the list.
let query = search_box(ui, &mut d);
ui.add_space(6.0);
// Category list.
let list = egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6));
list.show(ui, |ui| {
ui.set_width(196.0);
ui.set_min_height(396.0);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 1.0;
if !query.trim().is_empty() {
search_results(ui, &mut d, &query);
return;
}
}
for c in PREF_CATEGORIES {
let sel = *c == cat;
let text = egui::RichText::new(tl!(*c)).size(12.5).color(if sel { t.text_strong } else { t.text });
let b = egui::Button::selectable(sel, text).min_size(egui::vec2(184.0, 24.0));
if ui.add(b).clicked() {
d.fields.insert("__category".into(), json!(c));
}
}
});
});
});
ui.add_space(14.0);
@@ -248,6 +409,12 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
d.fields.insert(k, v);
}
}
// The setting a search went to stays highlighted until another category is shown.
let found = d.str("__found");
if !found.is_empty() && settings().find(|s| s.key == found).is_none_or(|s| s.category != d.str("__category")) {
d.fields.remove("__found");
d.fields.remove("__scrollTo");
}
app.ui.dialog = Some(d);
if cancel {
app.ui.dialog = None;
@@ -263,11 +430,15 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
let specs: Vec<_> = PREF_SPECS.iter().filter(|s| s.category == cat).collect();
for (key, c, label) in UI_FIELDS {
if *c == cat && cat != "Smart Guides" {
let mark = found_mark(ui, d, key);
bool_row(ui, d, key, label);
found_paint(ui, d, mark);
}
}
if cat == "Smart Guides" {
let mark = found_mark(ui, d, "__smartGuides");
bool_row(ui, d, "__smartGuides", UI_FIELDS[0].2);
found_paint(ui, d, mark);
ui.add_space(4.0);
}
for sp in specs {
@@ -283,6 +454,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
}
first = false;
let v = d.fields.get(sp.key).cloned().unwrap_or(Value::Null);
let mark = found_mark(ui, d, sp.key);
match sp.kind {
// Units › Numbers Without Units Are Points only tells points from picas: dimmed
// unless a unit is Picas.
@@ -409,6 +581,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
});
}
}
found_paint(ui, d, mark);
}
}
+151
View File
@@ -324,6 +324,157 @@ fn the_category_shown_is_highlighted() {
assert_eq!(filled, vec![3], "{rows:?}");
}
/// One frame of the Preferences dialog with `events`, keys first as the app takes them.
fn search_frame(a: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> egui::FullOutput {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
crate::shortcuts::handle(a, ui.ctx());
show(a, ui.ctx());
});
out.textures_delta.clear();
out
}
/// A key pressed and let go in one frame.
fn key_press(key: egui::Key, modifiers: egui::Modifiers) -> Vec<egui::Event> {
[true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec()
}
fn search_text(a: &VectorcraftApp) -> String {
a.ui.dialog.as_ref().map(|d| d.str("__search")).unwrap_or_default()
}
/// Preferences' search finds a setting by its label, ignoring case; an empty search finds nothing.
#[test]
fn a_search_finds_a_setting_by_its_label() {
let keys = |q: &str| search(q).iter().map(|s| s.key).collect::<Vec<_>>();
assert!(keys("tool tips").contains(&"showToolTips"), "{:?}", keys("tool tips"));
assert!(keys("SHOW TOOL TIPS").contains(&"showToolTips"));
assert!(keys("snap to grid").contains(&"__snapToGrid"));
let found = search("gridline every");
assert_eq!(found.iter().map(|s| (s.key, s.category)).collect::<Vec<_>>(), [("gridlineEvery", "Guides & Grid")]);
assert!(search("").is_empty() && search(" ").is_empty());
assert!(search("no such setting at all").is_empty());
}
/// A translated label matches while the dialog is drawn in that language.
#[test]
fn a_translated_label_matches_in_the_interface_language() {
let de = crate::i18n::Lang::from_code("de").expect("de registered");
let label = crate::i18n::tr(de, "Show Tool Tips");
assert_ne!(label, "Show Tool Tips", "German has a row for the label");
crate::i18n::set_current(de);
let found: Vec<_> = search(&label.to_uppercase()).iter().map(|s| s.key).collect();
crate::i18n::set_current(crate::i18n::Lang::EN);
assert!(found.contains(&"showToolTips"), "{label}: {found:?}");
}
/// An empty search field shows the category list; a search that finds nothing says so.
#[test]
fn an_empty_search_lists_the_categories_and_no_match_says_so() {
let mut a = app();
open(&mut a, Some("General"));
let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx()));
assert!(PREF_CATEGORIES.iter().all(|c| text.contains(c)), "{text}");
assert!(!text.contains("No settings found"), "{text}");
a.ui.dialog.as_mut().unwrap().fields.insert("__search".into(), json!("no such setting at all"));
let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx()));
assert!(text.contains("No settings found"), "{text}");
assert!(!text.contains("Plug-ins & Scratch Disks"), "the list is hidden: {text}");
a.ui.dialog.as_mut().unwrap().fields.insert("__search".into(), json!(" "));
let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx()));
assert!(PREF_CATEGORIES.iter().all(|c| text.contains(c)), "{text}");
}
/// Choosing a setting the search found shows its category, clears the search and highlights the
/// setting's row.
#[test]
fn choosing_a_found_setting_goes_to_its_category() {
let mut a = app();
open(&mut a, Some("General"));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
a.ui.dialog.as_mut().unwrap().fields.insert("__search".into(), json!("gridline"));
search_frame(&mut a, &ctx, vec![]);
let out = search_frame(&mut a, &ctx, vec![]);
let mut at = None;
fn find(s: &egui::Shape, label: &str, at: &mut Option<egui::Pos2>) {
match s {
egui::Shape::Text(t) if t.galley.text() == label => *at = Some(t.pos + t.galley.rect.center().to_vec2()),
egui::Shape::Vec(v) => v.iter().for_each(|s| find(s, label, at)),
_ => {}
}
}
out.shapes.iter().for_each(|c| find(&c.shape, "Gridline every", &mut at));
let at = at.expect("the found setting is listed");
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
search_frame(&mut a, &ctx, vec![egui::Event::PointerMoved(at), button(true)]);
search_frame(&mut a, &ctx, vec![button(false)]);
let d = a.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("__category").as_str(), d.str("__found").as_str(), d.str("__search").as_str()), ("Guides & Grid", "gridlineEvery", ""));
search_frame(&mut a, &ctx, vec![]);
let out = search_frame(&mut a, &ctx, vec![]);
fn rects(s: &egui::Shape, out: &mut Vec<(egui::Rect, egui::Color32)>) {
match s {
egui::Shape::Rect(r) => out.push((r.rect, r.fill)),
egui::Shape::Vec(v) => v.iter().for_each(|s| rects(s, out)),
_ => {}
}
}
let mut shown = vec![];
out.shapes.iter().for_each(|c| rects(&c.shape, &mut shown));
let mut label = None;
out.shapes.iter().for_each(|c| find(&c.shape, "Gridline every:", &mut label));
let label = label.expect("the setting's row is shown");
// A tinted band across the fields column, behind the setting's label.
let band = shown.iter().any(|(r, fill)| r.width() >= 500.0 && r.height() < 40.0 && fill.a() > 0 && r.contains(label));
assert!(band, "the setting's row is highlighted: {shown:?}");
assert!(!a.ui.dialog.as_ref().unwrap().bool("__scrollTo"), "the list scrolled to it");
// Another category: the highlight goes.
a.ui.dialog.as_mut().unwrap().fields.insert("__category".into(), json!("Units"));
search_frame(&mut a, &ctx, vec![]);
assert_eq!(a.ui.dialog.as_ref().unwrap().str("__found"), "");
}
/// The search field has the keyboard focus when the dialog opens, and Cmd/Ctrl+F gives it back.
#[test]
fn the_search_field_takes_the_focus_on_open_and_on_cmd_f() {
let mut a = app();
open(&mut a, None);
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let focused = |ctx: &egui::Context| ctx.memory(|m| m.has_focus(search_edit_id()));
search_frame(&mut a, &ctx, vec![]);
search_frame(&mut a, &ctx, vec![]);
assert!(focused(&ctx), "focused on open");
ctx.memory_mut(|m| m.surrender_focus(search_edit_id()));
search_frame(&mut a, &ctx, vec![]);
assert!(!focused(&ctx));
search_frame(&mut a, &ctx, key_press(egui::Key::F, egui::Modifiers::COMMAND));
assert!(focused(&ctx), "focused on Cmd/Ctrl+F");
assert!(a.ui.dialog.is_some());
}
/// Escape in the search field with text clears it and keeps the dialog open with the field
/// focused; Escape in the empty field closes the dialog.
#[test]
fn escape_clears_the_search_then_closes_the_dialog() {
let mut a = app();
open(&mut a, None);
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
search_frame(&mut a, &ctx, vec![]);
search_frame(&mut a, &ctx, vec![]);
search_frame(&mut a, &ctx, vec![egui::Event::Text("grid".into())]);
assert_eq!(search_text(&a), "grid");
search_frame(&mut a, &ctx, key_press(egui::Key::Escape, egui::Modifiers::NONE));
assert!(a.ui.dialog.is_some(), "the first Escape leaves the dialog open");
assert_eq!(search_text(&a), "");
search_frame(&mut a, &ctx, vec![]);
assert!(ctx.memory(|m| m.has_focus(search_edit_id())), "the field keeps the focus");
search_frame(&mut a, &ctx, key_press(egui::Key::Escape, egui::Modifiers::NONE));
assert!(a.ui.dialog.is_none(), "Escape in the empty field closes the dialog");
}
/// Units › Numbers Without Units Are Points (#394): the preference reaches the fields when it
/// changes, and its checkbox is dimmed unless a unit is Picas.
#[test]
+2 -1
View File
@@ -211,7 +211,8 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
// Escape closes a dropdown list or popover open in the dialog (each closes itself on it) and
// leaves the dialog open.
let popup = app.ui.dialog.as_ref().is_some_and(|d| egui::Popup::is_any_open(ctx) || crate::dialogs::brush_options::tips_open(d));
if !popup && pressed_anew(ctx, Key::Escape) {
// Escape in Preferences' search field with text clears the field first.
if !popup && pressed_anew(ctx, Key::Escape) && !crate::prefs_dialog::clear_search_on_escape(app, ctx) {
crate::dialogs::cancel(app);
app.ui.palette_open = false;
}
+6
View File
@@ -3872,6 +3872,12 @@ first; no spaces), stored back as the object name.
`typeSizeIncrement`, `baselineShiftIncrement`) with `measure: "general"|"type"`; they are kept in points and also take
a string with a unit. Unit names: `Points`, `Picas`, `Inches`, `Millimeters`, `Centimeters`, `Pixels`, `Feet & Inches`,
`Meters`, `Yards`, `Feet` (the preferences use `points`, `picas`, … `feetInches`, `meters`, `yards`, `feet`).
`edit.preferences {category?}` opens the Preferences dialog (`preferences`) with its search field focused. Its
`__search` field (`ui.dialog.set`) holds the search text: while it has text, the category list is replaced by the
settings whose label, section or category contains it, ignoring case, in the interface language or in English, or by
"No settings found". Choosing a setting sets `__category` to its category, sets `__found` to its key (the row is
highlighted until another category is shown) and clears `__search`. Cmd/Ctrl+F focuses the field again; Escape in the
field with text clears it, and Escape in the empty field closes the dialog.
```json
{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"unitsGeneral","value":"millimeters"}}}
+1
View File
@@ -599,6 +599,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Dragging a document tab out and a document window back (G7):** a document tab dragged more than 24 pt above or below the tab strip floats its document in a window under the pointer, and a document window dragged onto the tab strip goes back under the tabs ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)). A window inside the app's window is dragged by its title bar; an OS window by the document's tab at its top, and the window follows the pointer. The 24 pt distance and the tab at the top of an OS window are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`.
- **Reordering document tabs (G7, #5663):** a document tab dragged along the tab strip moves its document among the others ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)); a line marks the gap it goes in. The current document stays current, each document keeps its own zoom, centre and artboard, the views Window › New Window opened move with their document, and Window › Next/Previous Document follow the new order. `document.move` (`{from?, to}`) does the same from MCP, the control channel and recordings. `crates/ui-egui/src/tests/tab_reorder.rs`, `crates/engine/src/tests/document_move.rs`.
- **Preferences › User Interface › Open Documents As Tabs (G7, #394):** checked by default, documents open under tabs in one document window ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/workspace-overview.html)). With the box cleared, each document opened or created afterwards opens in a floating window of its own, at the next cascade place, as Float in Window floats it; documents already open stay where they are, and checking the box again leaves floating documents in their windows. Tests: `crates/ui-egui/src/doc_windows/tests.rs`.
- **Preferences search:** a Search Preferences field above the category list has the keyboard focus when the dialog opens, and Cmd/Ctrl+F gives it back; typing lists the settings it finds, and choosing one shows its category, as the [help page](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/set-app-preferences.html) describes. VectorCraft's own rules: the field matches a setting's label, section or category, ignoring case, in the interface language or in English; the list replaces the categories while the field has text and shows every match, or "No settings found"; the chosen setting's row is highlighted and scrolled into view; Escape in the field with text clears it, and Escape in the empty field closes the dialog. The dialog's `__search` field (`ui.dialog.set`) holds the text. Tests: `crates/ui-egui/src/prefs_dialog/tests.rs`.
- **Window › New Window (G7, #4116):** New Window (`window.newWindow`) opens another view of the current document ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html) describes showing one document in several windows at once), with its own zoom, center, rotation, artboard and Outline or Preview, as a tab of its own; an edit in one view shows in every view. Tile, Cascade, Float in Window, Float All in Windows and Consolidate All Windows arrange each view as they arrange a document, and Next and Previous Document step through the views. Closing a view leaves the document open; closing its last view closes the document, asking to save it first. The view's number after the name in its tab ("Name:2", while the document has more than one view) is VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`.
- **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`.