#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

This commit is contained in:
bflatastic
2026-10-18 11:52:32 +07:00
parent 6a334fa354
commit f90becadab
3 changed files with 107 additions and 179 deletions
+37 -121
View File
@@ -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<SearchHit> =
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<SearchHit> =
recent.iter().map(|(path, name)| SearchHit { key: format!("recent:{path}"), path: Some(path.clone()), name: name.clone() }).collect();
let preview_paths: Vec<String> = 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
+17 -55
View File
@@ -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<String> = 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<String> = 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<String> = 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<String>,
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<usize> {
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);
+53 -3
View File
@@ -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<String> = ["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<serde_json::Value> {
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]