diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 8143e3d4..c015e1b5 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -834,6 +834,7 @@ name = "English" "No contributor data was built into this copy." = "No contributor data was built into this copy." "No device" = "No device" "No fonts match" = "No fonts match" +"No files or templates match your search" = "No files or templates match your search" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." "No layer changes to show." = "No layer changes to show." "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." @@ -1179,6 +1180,8 @@ name = "English" "Search actions, layers and tools" = "Search actions, layers and tools" "Search assets" = "Search assets" "Search comments" = "Search comments" +"Search files and templates" = "Search files and templates" +"Search results" = "Search results" "Search fonts" = "Search fonts" "Search layers" = "Search layers" "Search shortcuts" = "Search shortcuts" @@ -1408,6 +1411,7 @@ name = "English" "Truncate with an ellipsis" = "Truncate with an ellipsis" "Try again" = "Try again" "Try another search or filter." = "Try another search or filter." +"Try another search, or browse templates." = "Try another search, or browse templates." "Try another word, or look at every kind." = "Try another word, or look at every kind." "Turn a layer into a component, reuse it, and change every copy at once" = "Turn a layer into a component, reuse it, and change every copy at once" "Two digits" = "Two digits" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index cd1fabd0..47917267 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -835,6 +835,7 @@ status = "draft — needs native review" "No contributor data was built into this copy." = "Esta copia no incluye datos de colaboradores." "No device" = "Sin dispositivo" "No fonts match" = "Ninguna fuente coincide" +"No files or templates match your search" = "Ningún archivo ni plantilla coincide con tu búsqueda" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Aquí no hay código para unirse (el servidor es anterior a los códigos, o esta dirección no cabe en uno): comparte el enlace." "No layer changes to show." = "No hay cambios de capas que mostrar." "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Aún no hay bibliotecas. Añade un archivo .figcraft publicado como biblioteca o pega el enlace de una biblioteca de equipo." @@ -1180,6 +1181,8 @@ status = "draft — needs native review" "Search actions, layers and tools" = "Buscar acciones, capas y herramientas" "Search assets" = "Buscar recursos" "Search comments" = "Buscar comentarios" +"Search files and templates" = "Buscar archivos y plantillas" +"Search results" = "Resultados de búsqueda" "Search fonts" = "Buscar fuentes" "Search layers" = "Buscar capas" "Search shortcuts" = "Buscar atajos" @@ -1409,6 +1412,7 @@ status = "draft — needs native review" "Truncate with an ellipsis" = "Truncar con puntos suspensivos" "Try again" = "Reintentar" "Try another search or filter." = "Prueba otra búsqueda u otro filtro." +"Try another search, or browse templates." = "Prueba otra búsqueda o explora las plantillas." "Try another word, or look at every kind." = "Prueba con otra palabra o mira todos los tipos." "Turn a layer into a component, reuse it, and change every copy at once" = "Convierte una capa en componente, reutilízalo y cambia todas las copias a la vez" "Two digits" = "Dos dígitos" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index dc850023..8d371cbe 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -819,6 +819,7 @@ name = "Русский" "No contributor data was built into this copy." = "В этой сборке нет данных об участниках." "No device" = "Без устройства" "No fonts match" = "Подходящих шрифтов нет" +"No files or templates match your search" = "Файлы и шаблоны по запросу не найдены" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Код подключения недоступен (сервер не поддерживает коды или адрес нельзя включить в код): поделитесь ссылкой." "No layer changes to show." = "Нет изменений слоёв для показа." "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Библиотек пока нет. Добавьте файл .figcraft, опубликованный как библиотека, или вставьте ссылку на командную библиотеку." @@ -1157,6 +1158,8 @@ name = "Русский" "Search actions, layers and tools" = "Поиск действий, слоёв и инструментов" "Search assets" = "Поиск ресурсов" "Search comments" = "Поиск комментариев" +"Search files and templates" = "Поиск файлов и шаблонов" +"Search results" = "Результаты поиска" "Search fonts" = "Поиск шрифтов" "Search layers" = "Поиск слоёв" "Search shortcuts" = "Поиск сочетаний клавиш" @@ -1382,6 +1385,7 @@ name = "Русский" "Truncate with an ellipsis" = "Обрезать с многоточием" "Try again" = "Повторить" "Try another search or filter." = "Попробуйте другой запрос или фильтр." +"Try another search, or browse templates." = "Попробуйте другой запрос или просмотрите шаблоны." "Try another word, or look at every kind." = "Попробуйте другое слово или посмотрите все категории." "Turn a layer into a component, reuse it, and change every copy at once" = "Превратите слой в компонент, используйте повторно и изменяйте все копии сразу" "Two digits" = "Две цифры" diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a66e1ed9..a4a8dd15 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -15,7 +15,7 @@ //! for the frames that follow (all false: released); `gamepad` presses a controller button in the //! prototype playing (presenter or preview pane) //! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog) -//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}` +//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}` //! - `ui.screenshot {path?}` — PNG of the window (needs a visible window), written to `path` or //! returned as base64 `data` //! - `ui.render {path?, scale?, ids?}` — PNG of the document (no UI chrome; works without a @@ -429,6 +429,9 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if let Some(section) = p.get("homeSection").and_then(Value::as_str).and_then(crate::home::Section::parse) { app.ui.home_view.section = section; } + if let Some(query) = p.get("homeSearch").and_then(Value::as_str) { + app.ui.home_view.file_query = query.chars().take(256).collect(); + } if let Some(view) = p.get("homeRecentsView").and_then(Value::as_str).and_then(crate::home_recents::View::parse) { view.store(&mut app.session.prefs); } diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 856b018e..236e2786 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -51,6 +51,8 @@ impl Section { #[derive(Default)] pub struct Home { pub section: Section, + /// Searches known files and starter templates together from the home sidebar. + pub file_query: String, /// The Templates search text and kind filter. pub query: String, pub kind: Option, @@ -143,7 +145,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { let left = Rect::from_min_size(rect.min, vec2(SIDEBAR, rect.height())); ui.painter().line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.border)); sidebar(app, ui, &t, left); - if app.ui.home_view.section == Section::Online { + if app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == Section::Online { // Your online files lay themselves out in the area (and scroll their own list). let area = Rect::from_min_max(pos2(left.max.x + 40.0, rect.min.y + 24.0), rect.max - vec2(40.0, 24.0)); crate::online::section(app, ui, area, &t); @@ -155,9 +157,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { egui::ScrollArea::vertical().id_salt("home").auto_shrink([false, false]).show(ui, |ui| { egui::Frame::NONE.inner_margin(egui::Margin::symmetric(40, 24)).show(ui, |ui| { ui.spacing_mut().item_spacing = vec2(GAP, 0.0); - match app.ui.home_view.section { - Section::Templates => templates(app, ui, &t), - _ => recents(app, ui, &t), + if app.ui.home_view.file_query.trim().is_empty() { + match app.ui.home_view.section { + Section::Templates => templates(app, ui, &t), + _ => recents(app, ui, &t), + } + } else { + search_results(app, ui, &t); + opened_join(app); } }); }); @@ -198,10 +205,20 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT); ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text); y += 48.0; + let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, widgets::FIELD_H)); + ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| { + let response = + widgets::text_input(ui, Id::new("home_file_search"), &mut app.ui.home_view.file_query, search.width(), tr("Search files and templates")); + a11y::text(&response, tr("Search files and templates"), &app.ui.home_view.file_query); + if (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.home_view.file_query.clear(); + } + }); + y += widgets::FIELD_H + 12.0; for (section, icon, label) in Section::ALL { let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0)); let resp = ui.interact(r, ui.id().with(("home_section", label)), Sense::click()); - let current = app.ui.home_view.section == section; + let current = app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == section; a11y::tab(&resp, tr(label), current); if current || resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), if current { t.selected_row } else { t.hover }); @@ -211,11 +228,65 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { ui.painter().text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, tr(label), regular(12.0), t.text); if resp.clicked() { app.ui.home_view.section = section; + app.ui.home_view.file_query.clear(); } y += 34.0; } } +fn file_matches(name: &str, location: &str, query: &str) -> bool { + name.to_lowercase().contains(query) || location.to_lowercase().contains(query) +} + +/// One search over known files and starter templates. It never sends a query or owner token to a +/// server; online files are opened through their existing owner-token command only on click. +fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { + heading(ui, t, tr("Search results")); + let query = app.ui.home_view.file_query.trim().to_lowercase(); + let mut count = open_files(app, ui, t, &query); + count += crate::home_recents::section(app, ui, t, &query); + + let online: Vec<_> = app.session.prefs.online_files.iter().filter(|f| file_matches(&f.name, &f.server, &query)).cloned().collect(); + let mut open_online = None; + if !online.is_empty() { + subheading(ui, t, tr("Online files")); + for f in &online { + count += 1; + let host = figcraft_engine::online::server_host(&f.server); + let icon = crate::online::mark(f.shared.is_some()); + let row = FileRow { + mark: Some((icon, if f.shared.is_some() { t.success } else { t.icon_secondary })), + name: &f.name, + ink: t.text, + detail: host, + detail_size: 11.0, + detail_ink: t.text_secondary, + }; + if listed(ui, t, ui.id().with(("search_online", &f.server, &f.file)), 36.0, &row) { + open_online = Some(f.clone()); + } + } + } + let templates: Vec<&Template> = TEMPLATES.iter().filter(|template| matches(template, &query, None)).collect(); + if !templates.is_empty() { + subheading(ui, t, tr("Templates")); + template_grid(app, ui, t, &templates); + } + if count == 0 && templates.is_empty() { + ui.label(egui::RichText::new(tr("No files or templates match your search")).font(semibold(13.0)).color(t.text)); + ui.add_space(6.0); + widgets::label(ui, tr("Try another search, or browse templates."), t.text_secondary); + ui.add_space(16.0); + if widgets::button(ui, tr("Show all templates"), false, true).clicked() { + app.ui.home_view.file_query.clear(); + app.ui.home_view.section = Section::Templates; + } + } + if let Some(file) = open_online { + crate::online::open(app, &file); + } +} + /// A ring around a card or row that has the keyboard. fn focus_ring(ui: &Ui, t: &Tokens, rect: Rect, resp: &Response, radius: u8) { if resp.has_focus() { @@ -290,22 +361,7 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { ui.add_space(28.0); getting_started(app, ui, t); } - if !app.session.docs.is_empty() { - subheading(ui, t, tr("Open files")); - let mut switch = None; - for (i, d) in app.session.docs.iter().enumerate() { - let detail = d.path.as_deref().unwrap_or_default(); - let row = - FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary }; - if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) { - switch = Some(i); - } - } - if let Some(i) = switch { - app.run("doc.switch", json!({"index": i})); - app.ui.home = false; - } - } + open_files(app, ui, t, ""); if crate::home_recents::listed(&app.session.prefs.recent_files).next().is_none() { // A first run: nothing to reopen yet, so offer something to start from. subheading(ui, t, tr("Start from a template")); @@ -317,7 +373,32 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { } return; } - crate::home_recents::section(app, ui, t); + crate::home_recents::section(app, ui, t, ""); +} + +/// The Open files list: the documents whose name or path has `query` (lower case; all for ""), a +/// row each; choosing one shows its tab. How many are listed. +fn open_files(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize { + let hit = |d: &&figcraft_engine::DocState| file_matches(&d.doc.name, d.path.as_deref().unwrap_or_default(), query); + let count = app.session.docs.iter().filter(hit).count(); + if count == 0 { + return 0; + } + subheading(ui, t, tr("Open files")); + let mut switch = None; + for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| hit(d)) { + let detail = d.path.as_deref().unwrap_or_default(); + let row = + FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary }; + if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) { + switch = Some(i); + } + } + if let Some(i) = switch { + app.run("doc.switch", json!({"index": i})); + app.ui.home = false; + } + count } /// The file joined from the home screen (a code, or one of your online files) opened in a tab of diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index 1796eb02..aeeb0f27 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -554,16 +554,27 @@ enum Action { View(View), } -/// The Recent files heading, its toolbar and the files as cards or a table. -pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { - let paths: Vec = listed(&app.session.prefs.recent_files).take(SHOWN).cloned().collect(); +/// The Recent files heading, its toolbar and the files as cards or a table. With a `query` (lower +/// case, from the Home search) only the files not open whose path has it, from every recent file; +/// nothing at all when none has it. How many files are shown. +pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize { + let searching = !query.is_empty(); + // A search reaches every recent file; the files shown without one stay known through it. + let paths: Vec = listed(&app.session.prefs.recent_files).take(if searching { usize::MAX } else { SHOWN }).cloned().collect(); let time = ui.input(|i| i.time); let now = figcraft_engine::cmd::comment::now_secs(); let reduce_motion = app.reduce_motion(); let (view, sort) = (View::of(&app.session.prefs), Sort::of(&app.session.prefs)); let state = &mut app.ui.home_view.recents; state.refresh(ui.ctx(), &paths, time); - let files = sorted(state.shown(&paths), sort); + let mut files = sorted(state.shown(&paths), sort); + if searching { + let docs = &app.session.docs; + files.retain(|f| f.path.to_lowercase().contains(query) && !docs.iter().any(|d| d.path.as_deref() == Some(f.path.as_str()))); + if files.is_empty() { + return 0; + } + } let with_created = files.iter().any(|f| f.stat.is_some_and(|s| s.created.is_some())); let mut action = toolbar(ui, t, view, sort, with_created); ui.add_space(16.0); @@ -593,6 +604,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { } None => {} } + files.len() } /// "Recent files", and at the right the sort menu and the grid and list buttons. diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index a975bec2..c493a0a1 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -6,6 +6,7 @@ use egui::accesskit::Role; use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, Shape}; use figcraft_engine::Session; +use figcraft_engine::online::OnlineFile; use figcraft_engine::templates::TEMPLATES; use serde_json::json; @@ -184,6 +185,161 @@ fn typing_in_the_search_field_filters() { assert!(u.app.session.docs.is_empty(), "typing opens nothing"); } +#[test] +fn home_search_finds_known_files_and_opens_an_existing_tab() { + let mut session = Session::new(); + session.execute("file.rename", &json!({"name": "North Trail"})).unwrap(); + session.docs[0].path = Some("C:/designs/North Trail.figcraft".into()); + session.prefs.recent_files = + vec!["C:/designs/North Trail.figcraft".into(), "C:/designs/South Trail.figcraft".into(), "C:/designs/Other.figcraft".into()]; + session.prefs.online_files.push(OnlineFile { + server: "wss://share.example/".into(), + file: "trail".into(), + name: "Trail Live".into(), + owner: "test-only-secret".into(), + ..OnlineFile::default() + }); + let mut u = Ui::new(session); + u.app.ui.home = true; + u.app.ui.home_view.file_query = " TRAIL ".into(); + let s = settle(&mut u); + assert!(has_text(&s, "Search results")); + assert!(has_text(&s, "North Trail") && has_text(&s, "South Trail") && has_text(&s, "Trail Live")); + assert!(!has_text(&s, "Other") && !has_text(&s, "test-only-secret")); + assert_eq!(texts(&s).iter().filter(|(name, pos, _)| name == "North Trail" && pos.y > 90.0).count(), 1, "an open file is not repeated in recents"); + assert_eq!(u.app.inspect()["homeSearch"], " TRAIL "); + + u.app.ui.home_view.file_query = "nothing matches".into(); + let s = shapes(&mut u); + assert!(has_text(&s, "No files or templates match your search")); + assert!(!has_text(&s, "Trail Live") && !has_text(&s, "South Trail")); + assert!(!texts(&s).iter().any(|(name, pos, _)| name == "North Trail" && pos.y > 90.0)); + + u.app.ui.home_view.file_query = "north".into(); + let s = shapes(&mut u); + click(&mut u, text_center(&s, "North Trail")); + assert!(!u.app.ui.home, "choosing an open result shows its editor tab"); + assert_eq!(u.app.session.docs.len(), 1); +} + +#[test] +fn home_search_shows_a_saved_files_preview() { + let dir = std::env::temp_dir().join(format!("figcraft-home-search-preview-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Search preview.figcraft"); + let image = image::RgbaImage::from_pixel(64, 32, image::Rgba([30, 140, 210, 255])); + let mut png = Vec::new(); + image.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let doc = figcraft_doc::Document::new("Search preview"); + std::fs::write(&path, figcraft_format::save_bytes(&doc, Some(&png)).unwrap()).unwrap(); + + let mut session = Session::empty(); + session.prefs.recent_files.push(path.to_string_lossy().into_owned()); + let mut u = Ui::new(session); + u.app.ui.home_view.file_query = "Search preview".into(); + for _ in 0..400 { + u.frame(vec![]); + if u.app.inspect()["homeRecents"]["files"][0]["picture"] == "picture" { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let s = shapes(&mut u); + assert!(has_text(&s, "Search preview")); + assert!(s.iter().any(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default())), "the saved preview is drawn"); + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir(dir); +} + +#[test] +fn home_search_field_filters_as_typed_and_a_section_click_clears_it() { + let mut u = first_run(); + u.app.session.prefs.recent_files = vec!["C:/designs/Glacier.figcraft".into(), "C:/designs/Forest.figcraft".into()]; + let s = shapes(&mut u); + click(&mut u, text_center(&s, "Search files and templates")); + u.frame(vec![Event::Text("glacier".into())]); + let s = settle(&mut u); + assert_eq!(u.app.ui.home_view.file_query, "glacier"); + assert!(has_text(&s, "Glacier") && !has_text(&s, "Forest")); + click(&mut u, text_center(&s, "Recents")); + assert!(u.app.ui.home_view.file_query.is_empty()); + let s = shapes(&mut u); + assert!(has_text(&s, "Forest")); + // Escape in the field clears the search too. + click(&mut u, text_center(&s, "Search files and templates")); + u.frame(vec![Event::Text("forest".into())]); + assert_eq!(u.app.ui.home_view.file_query, "forest"); + u.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert!(u.app.ui.home_view.file_query.is_empty(), "Escape clears the search"); +} + +/// A search keeps what the recent files it doesn't match know: back from it, their pictures and +/// the selection are still there (nothing is read again). +#[test] +fn a_search_keeps_the_other_recent_files_pictures_and_the_selection() { + let dir = std::env::temp_dir().join(format!("figcraft-home-search-cache-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut png = Vec::new(); + image::RgbaImage::from_pixel(8, 8, image::Rgba([30, 140, 210, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let paths: Vec = ["Alpine", "Basin"] + .into_iter() + .map(|name| { + let path = dir.join(format!("{name}.figcraft")); + std::fs::write(&path, figcraft_format::save_bytes(&figcraft_doc::Document::new(name), Some(&png)).unwrap()).unwrap(); + path.to_string_lossy().into_owned() + }) + .collect(); + let mut session = Session::empty(); + session.prefs.recent_files = paths.clone(); + let mut u = Ui::new(session); + let pictures = |u: &mut Ui| -> Vec { + u.app.inspect()["homeRecents"]["files"].as_array().unwrap().iter().map(|f| f["picture"].clone()).collect() + }; + for _ in 0..400 { + u.frame(vec![]); + if pictures(&mut u).iter().all(|p| p == "picture") { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(pictures(&mut u).iter().all(|p| p == "picture"), "both pictures are in"); + u.app.ui.home_view.recents.selected = Some(paths[1].clone()); + u.app.ui.home_view.file_query = "alpine".into(); + let s = shapes(&mut u); + assert!(has_text(&s, "Alpine") && !has_text(&s, "Basin")); + u.app.ui.home_view.file_query.clear(); + u.frame(vec![]); + assert!(pictures(&mut u).iter().all(|p| p == "picture"), "no picture is read again"); + assert_eq!(u.app.ui.home_view.recents.selected.as_deref(), Some(paths[1].as_str()), "the selection stays"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn home_search_includes_templates_and_opens_a_matching_card() { + let mut u = first_run(); + u.app.ui.home_view.file_query = "MOBILE".into(); + let s = settle(&mut u); + assert!(has_text(&s, "Templates") && has_text(&s, "Mobile app")); + assert!(!has_text(&s, "Dashboard")); + click(&mut u, text_center(&s, "Mobile app")); + assert_eq!(u.app.session.doc().unwrap().name, "Mobile app"); + assert!(!u.app.ui.home); +} + +#[test] +fn empty_home_search_can_browse_all_templates() { + let mut u = first_run(); + u.app.ui.home_view.file_query = "no such design".into(); + let s = shapes(&mut u); + assert!(has_text(&s, "No files or templates match your search")); + click(&mut u, text_center(&s, "Show all templates")); + assert!(u.app.ui.home_view.file_query.is_empty()); + assert_eq!(u.app.ui.home_view.section, Section::Templates); + assert!(has_text(&settle(&mut u), "Dashboard")); +} + #[test] fn thumbnails_are_cached_per_theme() { let mut u = first_run(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 55c5438d..450317b4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1678,6 +1678,7 @@ impl FigApp { "lastError": self.ui.last_error, "importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect), }); + state["homeSearch"] = json!(self.ui.home_view.file_query); state["pendingOpens"] = json!(self.ui.pending_opens.len()); state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); state diff --git a/crates/ui-egui/src/online.rs b/crates/ui-egui/src/online.rs index 261424cb..2f054986 100644 --- a/crates/ui-egui/src/online.rs +++ b/crates/ui-egui/src/online.rs @@ -72,7 +72,7 @@ fn manage(app: &mut FigApp, ctx: &egui::Context, f: (&str, &str), action: &str, } /// Open recorded file `f` as its owner; its tab shows once the file arrives. -fn open(app: &mut FigApp, f: &OnlineFile) { +pub(crate) fn open(app: &mut FigApp, f: &OnlineFile) { let Some(r) = app.run("collab.openFile", json!({"server": f.server, "file": f.file})) else { return }; if r["alreadyOpen"] == true && !r["document"].is_null() { app.ui.home = false; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0dc52a1e..3ae9d287 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -248,12 +248,13 @@ separate calls. Returns `{queued}`. Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`. -### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}` +### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}` Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`, `home` (bool), `homeSection` (`recents`, `online` or `templates`: which section the home screen shows, as `ui.inspect` reports it; opening `online` asks each server once what became of your online files), `homeRecentsView` (`grid|list`), `homeRecentsSort` (`modified|name|created`) and `homeRecentsOrder` (`desc|asc`): how the home screen shows its recent files (kept in the preferences; `ui.inspect` reports them under `homeRecents` with each file and whether its picture is in), `palette` (bool: the Actions palette), `mainMenu` (bool, or a top-level menu such -as `"Plugins"` to open the main menu with it open), `pluginsWindow` (bool: Plugins ▸ Manage +as `"Plugins"` to open the main menu with it open), `homeSearch` (text, at most 256 characters: filters locally known open, recent and online files plus starter templates in Home; an empty string returns to the selected section), +`pluginsWindow` (bool: Plugins ▸ Manage plugins…), `pluginsTab` (`installed|browse`: that window's tab), `pluginConfirm` (a plugin id of the directory listed last: its install confirmation shows, as its Install button would; `null` closes it), `pluginForm` (`{id, command?}`: start that plugin command like its menu item, opening