Merge pull request #861 from AJMakesStuff/#855--The-Search-button-for-the-pages-doesn't-work-as-expected

#855: The Search button for the pages doesn't work as expected
This commit is contained in:
BFlat
2026-10-20 02:58:18 +07:00
committed by GitHub
5 changed files with 118 additions and 17 deletions
+1
View File
@@ -1186,6 +1186,7 @@ name = "English"
"Search results" = "Search results"
"Search fonts" = "Search fonts"
"Search layers" = "Search layers"
"Search pages" = "Search pages"
"Search shortcuts" = "Search shortcuts"
"Section" = "Section"
"Section color" = "Section color"
+1
View File
@@ -1187,6 +1187,7 @@ status = "draft — needs native review"
"Search results" = "Resultados de búsqueda"
"Search fonts" = "Buscar fuentes"
"Search layers" = "Buscar capas"
"Search pages" = "Buscar páginas"
"Search shortcuts" = "Buscar atajos"
"Section" = "Sección"
"Section color" = "Color de sección"
+36 -17
View File
@@ -184,13 +184,18 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
app.ui.pages_open = !app.ui.pages_open;
}
let sr = Rect::from_center_size(pos2(hr.max.x - 50.0, hr.center().y), vec2(24.0, 24.0));
let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(crate::shortcuts::tip(tr("Find"), "edit.findReplace"));
if sresp.hovered() {
let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(tr("Search pages"));
if sresp.hovered() || app.ui.page_search {
ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon);
let search_id = Id::new("page_search_field");
if sresp.clicked() {
app.ui.find = Some((String::new(), String::new()));
app.ui.page_search = !app.ui.page_search;
app.ui.page_filter.clear();
if app.ui.page_search {
app.ui.pages_open = true;
}
}
let ar = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0));
let aresp = ui.interact(ar, ui.id().with("addpage"), Sense::click()).tip(tr("Add new page"));
@@ -205,9 +210,14 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
if !app.ui.pages_open {
return;
}
if app.ui.page_search {
search_field(ui, &t, w, search_id, tr("Search pages"), &mut app.ui.page_filter, &mut app.ui.page_search, sresp.clicked());
}
let Ok(ds) = app.session.ds() else { return };
let pages: Vec<(NodeId, String, bool)> =
ds.doc.pages.iter().filter_map(|p| ds.doc.node(*p).map(|n| (*p, n.name.clone(), ds.doc.is_divider(*p)))).collect();
let filter = app.ui.page_filter.trim().to_lowercase();
let filtering = !filter.is_empty();
let cur = ds.page;
let uid = ds.uid;
let sel = ds.selection.clone();
@@ -223,8 +233,14 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
let max_h = pages_list_height(app.ui.pages_height, ui.available_height());
let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| {
for (k, (id, name, divider)) in pages.iter().cloned().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag());
reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() });
if filtering && (divider || !name.to_lowercase().contains(&filter)) {
continue;
}
let sense = if filtering { Sense::click() } else { Sense::click_and_drag() };
let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense);
if !filtering {
reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() });
}
a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur);
let r = r.shrink2(vec2(8.0, 0.0));
a11y::ring_around(ui, r, &resp);
@@ -641,8 +657,10 @@ fn set_expanded_deep(doc: &Document, id: NodeId, open: bool, expanded: &mut BTre
}
}
/// The search row under the Layers header (live filter; Escape closes it).
fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) {
/// The search row under the Pages or Layers header: a live filter `text`, taking the keyboard when
/// `focus` (just opened). Escape in it clears it and closes the row (`open`), and goes no further
/// (the canvas keeps its selection).
fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut String, open: &mut bool, focus: bool) {
let (fr, _) = ui.allocate_exact_size(vec2(w, 32.0), Sense::hover());
let field = Rect::from_min_max(pos2(fr.min.x + 12.0, fr.min.y + 2.0), pos2(fr.max.x - 12.0, fr.max.y - 6.0));
ui.painter().rect_filled(field, CornerRadius::same(5), t.field);
@@ -650,21 +668,25 @@ fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) {
let te_rect = Rect::from_min_max(pos2(field.min.x + 24.0, field.center().y - 7.0), pos2(field.max.x - 6.0, field.center().y + 7.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(te_rect));
let r = child.add(
egui::TextEdit::singleline(&mut app.ui.layer_filter)
egui::TextEdit::singleline(text)
.id(id)
.frame(egui::Frame::NONE)
.font(regular(11.0))
.hint_text(tr("Find layers"))
.hint_text(hint)
.vertical_align(egui::Align::Center)
.desired_width(te_rect.width()),
);
crate::widgets::select_all_on_focus(ui, &r, &app.ui.layer_filter);
// After the field: the click that opened it ends elsewhere, which would take the focus back.
if focus {
r.request_focus();
}
crate::widgets::select_all_on_focus(ui, &r, text);
if r.has_focus() {
ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, ACCENT), egui::StrokeKind::Inside);
}
if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.layer_search = false;
app.ui.layer_filter.clear();
if (r.has_focus() || r.lost_focus()) && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
*open = false;
text.clear();
}
}
@@ -683,12 +705,9 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
if sresp.clicked() {
app.ui.layer_search = !app.ui.layer_search;
app.ui.layer_filter.clear();
if app.ui.layer_search {
ui.memory_mut(|m| m.request_focus(search_id));
}
}
if app.ui.layer_search {
search_field(app, ui, &t, w, search_id);
search_field(ui, &t, w, search_id, tr("Find layers"), &mut app.ui.layer_filter, &mut app.ui.layer_search, sresp.clicked());
}
let Ok(ds) = app.session.ds() else { return };
let doc = ds.doc.clone();
@@ -199,6 +199,81 @@ fn page_row(u: &mut Ui, page: NodeId, back: NodeId) -> Pos2 {
panic!("no row for page {page:?}");
}
#[test]
fn pages_search_button_opens_the_filter_and_escape_clears_it() {
let s = Session::new();
let _ = s.page().unwrap();
let mut u = Ui::new(s);
// The search icon in the Pages header opens the page-name field, not Find and Replace.
click(&mut u, Pos2::new(246.0, 124.0), Modifiers::NONE);
assert!(u.app.ui.page_search, "the Pages search is open");
assert!(u.app.ui.find.is_none(), "page search does not open Find and Replace");
u.frame(vec![Event::Text("COMPON".into())]);
assert_eq!(u.app.ui.page_filter, "COMPON", "typing can start immediately");
u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
assert!(!u.app.ui.page_search, "Escape closes page search");
assert!(u.app.ui.page_filter.is_empty(), "Escape clears the filter");
}
#[test]
fn pages_search_leaves_escape_to_the_canvas_when_the_field_is_unfocused() {
let mut s = Session::new();
let r = rect(&mut s, "r", 0.0, None);
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = Ui::new(s);
u.app.ui.page_search = true;
u.app.ui.page_filter = "COMPON".into();
u.frame(vec![]);
u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
assert!(u.app.session.selection().is_empty(), "Escape deselects on the canvas");
assert!(u.app.ui.page_search, "the page search stays open");
assert_eq!(u.app.ui.page_filter, "COMPON", "and keeps its filter");
}
#[test]
fn escape_in_the_pages_search_does_not_reach_the_canvas() {
let mut s = Session::new();
let r = rect(&mut s, "r", 0.0, None);
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = Ui::new(s);
click(&mut u, Pos2::new(246.0, 124.0), Modifiers::NONE);
assert!(u.app.ui.page_search);
u.frame(vec![Event::Text("x".into())]);
u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
assert!(!u.app.ui.page_search, "Escape closes the page search");
assert_eq!(u.app.session.selection(), vec![r], "and leaves the selection alone");
}
#[test]
fn pages_search_filters_page_names_case_insensitively() {
let mut s = Session::new();
let _overview = s.page().unwrap();
let components = id(s.execute("page.add", &json!({"name": "Components"})).unwrap());
let library = id(s.execute("page.add", &json!({"name": "Library"})).unwrap());
s.execute("page.select", &json!({"id": library})).unwrap();
let mut u = Ui::new(s);
u.app.ui.page_search = true;
u.app.ui.page_filter = "COMPON".into();
let shapes = u.frame_output(vec![]).shapes;
let text = crate::live_tests::texts(&crate::live_tests::flat(&shapes));
assert!(text.iter().any(|(label, _, _)| label == "Components"), "the matching page remains visible");
assert!(!text.iter().any(|(label, _, _)| label == "Library"), "nonmatching pages are filtered out");
// Search leaves only Components; the active page starts as Library.
for y in (150..400).step_by(4) {
let p = Pos2::new(150.0, y as f32);
u.button(p, true);
u.button(p, false);
if u.app.session.page().unwrap() == components {
break;
}
}
assert_eq!(u.app.session.page().unwrap(), components, "matching is case-insensitive and opens the named page");
}
#[test]
fn pages_drag_to_reorder_and_dividers_open_nothing() {
let mut s = Session::new();
+5
View File
@@ -483,6 +483,9 @@ pub struct UiState {
pub last_right_tab: RightTab,
pub left_tab: LeftTab,
pub pages_open: bool,
/// Pages panel search: open, and the case-insensitive name filter.
pub page_search: bool,
pub page_filter: String,
/// The height asked for the pages list (`None`: a share of the panel).
pub pages_height: Option<f32>,
pub expanded: BTreeSet<NodeId>,
@@ -651,6 +654,8 @@ impl Default for UiState {
last_right_tab: RightTab::Design,
left_tab: LeftTab::File,
pages_open: true,
page_search: false,
page_filter: String::new(),
pages_height: None,
expanded: BTreeSet::new(),
rename: None,