From f90becadab1085c60e1d0dcd17d176597759889e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 11:52:32 +0700 Subject: [PATCH] #765: search results use Home's own Open files rows and Recent files section (the full list stays known, so pictures and the selection survive a search); search_grid, SearchHit, prepare_search and search_file_card go; the field is widgets::text_input and Escape clears it --- crates/ui-egui/src/home.rs | 158 +++++++---------------------- crates/ui-egui/src/home_recents.rs | 72 ++++--------- crates/ui-egui/src/home_tests.rs | 56 +++++++++- 3 files changed, 107 insertions(+), 179 deletions(-) diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 77d1a1a3..236e2786 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -4,7 +4,6 @@ //! the documents themselves, searched and filtered by kind). use std::collections::BTreeMap; -use std::path::Path; use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_engine::templates::{Kind, TEMPLATES, Template}; @@ -12,7 +11,6 @@ use serde_json::json; use crate::FigApp; use crate::a11y; -use crate::home_recents::SearchHit; use crate::i18n::{tr, tr_noop}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular, semibold}; @@ -207,35 +205,16 @@ 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, 32.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| { - ui.painter().rect_filled(search, CornerRadius::same(5), t.field); - icons::paint( - ui.painter(), - Rect::from_center_size(pos2(search.min.x + 18.0, search.center().y), vec2(16.0, 16.0)), - Icon::Search, - t.icon_secondary, - ); - let text_rect = Rect::from_min_max(pos2(search.min.x + 36.0, search.center().y - 8.0), pos2(search.max.x - 8.0, search.center().y + 8.0)); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_rect)); - let response = child.add( - egui::TextEdit::singleline(&mut app.ui.home_view.file_query) - .id(Id::new("home_file_search")) - .frame(egui::Frame::NONE) - .font(regular(12.0)) - .hint_text(tr("Search files and templates")) - .vertical_align(egui::Align::Center) - .desired_width(text_rect.width()), - ); - widgets::select_all_on_focus(ui, &response, &app.ui.home_view.file_query); + 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() { - ui.painter().rect_stroke(search, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); - } else if ui.rect_contains_pointer(search) { - ui.painter().rect_stroke(search, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); + if (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.home_view.file_query.clear(); } }); - y += 32.0 + 4.0; + 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()); @@ -259,78 +238,13 @@ fn file_matches(name: &str, location: &str, query: &str) -> bool { name.to_lowercase().contains(query) || location.to_lowercase().contains(query) } -/// An online search result with a name and optional location on separate lines. Local files use -/// the Recent files cards below so their stored preview and file-type icon are also shown here. -fn search_file_card(ui: &mut Ui, t: &Tokens, id: Id, row: &FileRow) -> bool { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width().min(640.0), 64.0), Sense::hover()); - let resp = ui.interact(r, id, Sense::click()); - a11y::button(&resp, row.name); - ui.painter().rect_filled(r, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.field }); - ui.painter().rect_stroke(r, CornerRadius::same(8), Stroke::new(1.0, t.border), StrokeKind::Inside); - focus_ring(ui, t, r, &resp, 8); - if let Some((icon, tint)) = row.mark { - let mark = Rect::from_center_size(pos2(r.min.x + 29.0, r.center().y), vec2(20.0, 20.0)); - ui.painter().rect_filled(mark, CornerRadius::same(4), t.field); - icons::paint(ui.painter(), mark.shrink(2.0), icon, tint); - } - let width = (r.width() - 78.0).max(1.0); - let name = widgets::elide_font(ui, row.name, width, semibold(13.0)); - let name_y = if row.detail.is_empty() { r.center().y } else { r.min.y + 23.0 }; - ui.painter().text(pos2(r.min.x + 58.0, name_y), Align2::LEFT_CENTER, name, semibold(13.0), row.ink); - if !row.detail.is_empty() { - let detail = widgets::elide_start_font(ui, row.detail, width, regular(row.detail_size)); - ui.painter().text(pos2(r.min.x + 58.0, r.min.y + 44.0), Align2::LEFT_CENTER, detail, regular(row.detail_size), row.detail_ink); - } - if !row.detail.is_empty() { resp.on_hover_text(row.detail).clicked() } else { resp.clicked() } -} - /// 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 = 0; - let mut switch = None; - let open_paths: Vec<&str> = app.session.docs.iter().filter_map(|d| d.path.as_deref()).collect(); - - let open: Vec<_> = - app.session.docs.iter().enumerate().filter(|(_, d)| file_matches(&d.doc.name, d.path.as_deref().unwrap_or_default(), &query)).collect(); - let open_hits: Vec = - open.iter().map(|(_, d)| SearchHit { key: format!("open:{}", d.uid), path: d.path.clone(), name: d.doc.name.clone() }).collect(); - - let recent: Vec<(String, String)> = app - .session - .prefs - .recent_files - .iter() - .filter(|p| figcraft_engine::file_access::allowed(p, figcraft_engine::file_access::Access::Read)) - .filter(|p| !open_paths.contains(&p.as_str())) - .filter_map(|path| { - let p = Path::new(path); - let name = p.file_stem().or_else(|| p.file_name()).map_or_else(|| path.clone(), |n| n.to_string_lossy().into_owned()); - file_matches(&name, path, &query).then(|| (path.clone(), name)) - }) - .collect(); - let recent_hits: Vec = - recent.iter().map(|(path, name)| SearchHit { key: format!("recent:{path}"), path: Some(path.clone()), name: name.clone() }).collect(); - let preview_paths: Vec = open_hits.iter().chain(&recent_hits).filter_map(|hit| hit.path.clone()).collect(); - let reduce_motion = app.reduce_motion(); - app.ui.home_view.recents.prepare_search(ui.ctx(), &preview_paths, ui.input(|i| i.time)); - if !open_hits.is_empty() { - subheading(ui, t, tr("Open files")); - count += open_hits.len(); - if let Some(hit) = crate::home_recents::search_grid(ui, t, &mut app.ui.home_view.recents, &open_hits, reduce_motion) { - switch = open.get(hit).map(|(index, _)| *index); - } - } - let mut open_recent = None; - if !recent_hits.is_empty() { - subheading(ui, t, tr("Recent files")); - count += recent_hits.len(); - if let Some(hit) = crate::home_recents::search_grid(ui, t, &mut app.ui.home_view.recents, &recent_hits, reduce_motion) { - open_recent = recent_hits.get(hit).and_then(|hit| hit.path.clone()); - } - } + 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; @@ -348,10 +262,9 @@ fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { detail_size: 11.0, detail_ink: t.text_secondary, }; - if search_file_card(ui, t, ui.id().with(("search_online", &f.server, &f.file)), &row) { + if listed(ui, t, ui.id().with(("search_online", &f.server, &f.file)), 36.0, &row) { open_online = Some(f.clone()); } - ui.add_space(8.0); } } let templates: Vec<&Template> = TEMPLATES.iter().filter(|template| matches(template, &query, None)).collect(); @@ -369,14 +282,7 @@ fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { app.ui.home_view.section = Section::Templates; } } - if let Some(index) = switch { - app.run("doc.switch", json!({"index": index})); - app.ui.home = false; - } else if let Some(path) = open_recent { - if app.run_user("file.open", json!({"path": path})).is_some() { - app.ui.home = false; - } - } else if let Some(file) = open_online { + if let Some(file) = open_online { crate::online::open(app, &file); } } @@ -455,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")); @@ -482,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 8ccc3cec..aeeb0f27 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -504,12 +504,6 @@ impl Recents { paths.iter().map(|p| Shown::new(p, self.files.get(p).and_then(|k| k.stat))).collect() } - /// Share the Recent files thumbnail cache with Home search. The existing loader keeps file - /// reads off the UI thread and only requests pictures for cards that are on screen. - pub(crate) fn prepare_search(&mut self, ctx: &egui::Context, paths: &[String], time: f64) { - self.refresh(ctx, paths, time); - } - /// For `ui.inspect`: each file listed, whether it is there and whether its picture is in. pub fn inspect(&self, prefs: &Prefs) -> Value { let paths: Vec = listed(&prefs.recent_files).take(SHOWN).cloned().collect(); @@ -560,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); @@ -599,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. @@ -730,8 +736,7 @@ fn arrows(ui: &Ui, recents: &mut Recents, files: &[Shown], across: usize, down: /// The square file-type mark before a name. fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) { let r = Rect::from_center_size(center, vec2(16.0, 16.0)); - let kind = if path.is_empty() { FileKind::FigCraft } else { FileKind::of(Path::new(path)) }; - match kind { + match FileKind::of(Path::new(path)) { FileKind::FigCraft | FileKind::Fig => { ui.painter().rect_filled(r, CornerRadius::same(4), ACCENT); icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, Color32::WHITE); @@ -748,49 +753,6 @@ fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) { } } -/// A file found by the Home sidebar search. A tab without a disk path has no stored preview yet. -pub(crate) struct SearchHit { - pub key: String, - pub path: Option, - pub name: String, -} - -/// Search results use the same preview, file-type icon and footer as Recent files. A single click -/// still opens the result, as it did before the visual change. -pub(crate) fn search_grid(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[SearchHit], reduce_motion: bool) -> Option { - let (columns, card_w) = grid_columns(ui.available_width()); - let (picture_h, card_h) = card_heights(card_w); - let (time, now) = (ui.input(|i| i.time), figcraft_engine::cmd::comment::now_secs()); - let mut chosen = None; - let mut fading = false; - for (row_index, row) in files.chunks(columns).enumerate() { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), card_h), Sense::hover()); - for (column, file) in row.iter().enumerate() { - let rect = Rect::from_min_size(pos2(r.min.x + column as f32 * (card_w + GAP), r.min.y), vec2(card_w, card_h)); - let resp = ui.interact(rect, ui.id().with(("search_file_card", &file.key)), Sense::click()); - let stat = file.path.as_deref().and_then(|path| recents.files.get(path).and_then(|k| k.stat)); - let shown = Shown { path: file.path.clone().unwrap_or_default(), name: file.name.clone(), stat }; - a11y::named_button(&resp, &spoken(&shown, now)); - let resp = if let Some(path) = file.path.as_deref() { resp.on_hover_text(path) } else { resp }; - let visible = ui.is_rect_visible(rect); - let picture = file.path.as_deref().and_then(|path| recents.picture(path, visible, time)); - if visible { - let alpha = picture.as_ref().map_or(1.0, |(_, _, a)| if reduce_motion { 1.0 } else { *a }); - fading |= alpha < 1.0; - card(ui, t, rect, picture_h, &resp, false, &shown, stat.unwrap_or_default(), picture, alpha, now); - } - if resp.clicked() { - chosen = Some(row_index * columns + column); - } - } - ui.add_space(GAP); - } - if fading { - ui.ctx().request_repaint(); - } - chosen -} - /// The name a card or row has for screen readers: "name, Edited 3 hours ago". fn spoken(file: &Shown, now: u64) -> String { let detail = file.detail(now); diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index cf34e5b3..c493a0a1 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -202,7 +202,7 @@ fn home_search_finds_known_files_and_opens_an_existing_tab() { let mut u = Ui::new(session); u.app.ui.home = true; u.app.ui.home_view.file_query = " TRAIL ".into(); - let s = shapes(&mut u); + 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")); @@ -258,12 +258,62 @@ fn home_search_field_filters_as_typed_and_a_section_click_clears_it() { let s = shapes(&mut u); click(&mut u, text_center(&s, "Search files and templates")); u.frame(vec![Event::Text("glacier".into())]); - let s = shapes(&mut u); + 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()); - assert!(has_text(&shapes(&mut u), "Forest")); + 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]