Lesson 0009: Searchable Netlist, Virtualised BOM & Iterator Adapters
Filtering components and nets with TextEdit::singleline, .filter(), and virtualised scrolling via ScrollArea::vertical().show_rows(...)
A row of three buttons in a toolbar works fine for a tiny demo board. Try doing that on a synth voice card with 400 passives and ICs, though, and your toolbar will run right off the side of your monitor.
In Lesson 0008, we wired schematic-to-PCB cross-probing across split viewports. Now, let's build a proper Bill of Materials (BOM) & Netlist Inspector where typing "u1", "10k", or "spi_clk" filters the component list in real time and clicking any matching row cross-probes that footprint on the canvas.
flowchart LR
Edit["ui.text_edit_singleline(&mut self.view.bom_search_query)"] --> Filter["board.filter_components(&query)<br/>.iter().filter(|c| c.matches_query(q))"]
Filter --> Virt["ScrollArea::vertical().show_rows(ui, 22.0, matching.len(), |ui, row_range| ...)"]
Virt --> Slice["&matching[row_range]<br/>(Only visible rows laid out!)"]
Slice --> Probe["Click BOM Row -> view.focus_component(&board, &comp.ref_des)"]
Figure 9.1: TextEdit::singleline mutates the query string in place, .filter() selects matching components, and show_rows virtualises layout to only the visible slice &matching[row_range].
1. Just-in-Time Rust: Iterator Adapters (.filter()) & Range Slices (&[T])
To filter components case-insensitively across their reference designator (U1), value (LDO_3V3), package (SOT-23), or connected net name (VCC_3V3), we normalise the query with .trim().to_ascii_lowercase() and chain Rust's zero-cost iterator adapters (Rust Book §13.2).Why slice via &matching[row_range]? A Rust slice &matching[row_range] (Range<usize>) is a bounds-checked window into the contiguous vector that lets us iterate over just the visible viewport rows without allocating a second vector.
2. Anatomy of Lesson 0009's Additions: Block by Block
Let's inspect the new and modified code in apps/0009-searchable-netlist-and-bom-inspector/src/main.rs block by block, then put the full BOM & Netlist Inspector together in context.
Block 1: Case-Insensitive Matching (matches_query) & filter_components
impl ComponentSymbol {
pub fn inspector_label(&self) -> String {
format!("{}: {} ({})", self.ref_des, self.value, self.package_name)
}
/// Case-insensitively matches `query` against `ref_des`, `value`, `package_name`,
/// or `primary_net.signal_name()`. An empty or whitespace-only query matches all components.
pub fn matches_query(&self, query: &str) -> bool {
let needle = query.trim().to_ascii_lowercase();
if needle.is_empty() {
return true;
}
self.ref_des.to_ascii_lowercase().contains(&needle)
|| self.value.to_ascii_lowercase().contains(&needle)
|| self.package_name.to_ascii_lowercase().contains(&needle)
|| self
.primary_net
.signal_name()
.to_ascii_lowercase()
.contains(&needle)
}
}
impl BoardModel {
pub fn find_component(&self, ref_des: &str) -> Option<&ComponentSymbol> {
self.components.iter().find(|c| c.ref_des == ref_des)
}
pub fn filter_components(&self, query: &str) -> Vec<ComponentSymbol> {
self.components
.iter()
.filter(|c| c.matches_query(query))
.cloned()
.collect()
}
}
query.trim().to_ascii_lowercase():.trim()returns a borrowed&strstripped of leading/trailing whitespace, and.to_ascii_lowercase()allocates an owned lowercaseString(needle). Ifneedle.is_empty(), we short-circuit and returntrueso an empty search box shows the full BOM.- Four-Way Disjunction (
||): Checks whetherneedleis a substring (.contains(&needle)) of the lowercasedref_des,value,package_name, orprimary_net.signal_name(). Because||short-circuits in Rust, later branches only run if earlier ones returnfalse. .iter().filter(|c| c.matches_query(query)).cloned().collect(): Creates an iterator of&ComponentSymbol, keeps only elements satisfyingmatches_query, clones the matching symbols via.cloned(), and collects them into aVec<ComponentSymbol>.
Block 2: Storing bom_search_query & clear_bom_query on ViewState
pub struct ViewState {
pub show_top_copper: bool,
pub show_bottom_copper: bool,
pub show_silkscreen: bool,
pub show_ratsnest: bool,
pub viewport: BoardViewport,
pub pcb_scene_rect: Rect,
pub selected_component: Option<String>,
pub hovered_net: Option<NetId>,
pub selected_net: Option<NetId>,
pub bom_search_query: String,
}
impl Default for ViewState {
fn default() -> Self {
let mut view = Self {
// ...
bom_search_query: String::new(),
};
view.reset_layers();
view
}
}
impl ViewState {
// ...
pub fn clear_bom_query(&mut self) {
self.bom_search_query.clear();
}
}
pub bom_search_query: String: Becauseegui::Ui::text_edit_singlelinemutates a buffer in place on each keystroke, the search string must be an owned, growableStringliving insideViewState(initialised viaString::new()).pub fn clear_bom_query(&mut self): Callsself.bom_search_query.clear(), which truncates the string's length to0in-place while reusing its existing heap capacity.
Block 3: Virtualised BOM Table (text_edit_singleline, ScrollArea::show_rows & Grid)
Now, even in an immediate-mode GUI, laying out 10,000 offscreen table rows on every 60 Hz frame is a great way to warm up your laptop fan for no reason.
Turns out, ScrollArea::show_rows makes virtualised scrolling almost ridiculously simple: it calculates the exact row_range: Range<usize> currently visible inside the scroll viewport based on row_height_sans_spacing and calls your closure for only those visible rows:Notice how we chain .labelled_by(bom_label.id) onto ui.text_edit_singleline(...). In egui_kittest 0.37, Harness::run() automatically audits AccessKit nodes and panics if any interactive TextInput lacks an accessible name!
ui.separator();
ui.heading("BOM & Netlist Inspector");
ui.horizontal(|ui| {
let bom_label = ui.label("Filter BOM:");
ui.text_edit_singleline(&mut self.view.bom_search_query)
.labelled_by(bom_label.id);
if ui.button("Clear Filter").clicked() {
self.view.clear_bom_query();
}
});
if self.view.show_silkscreen {
let matching = self.board.filter_components(&self.view.bom_search_query);
ui.label(format!("Matching Components: {}", matching.len()));
egui::ScrollArea::vertical()
.max_height(180.0)
.show_rows(ui, 22.0, matching.len(), |ui, row_range| {
egui::Grid::new("bom_inspector_grid")
.striped(true)
.show(ui, |ui| {
for comp in &matching[row_range] {
ui.label(comp.primary_net.signal_name());
if ui.button(comp.inspector_label()).clicked() {
self.view.focus_component(&self.board, &comp.ref_des);
}
ui.end_row();
}
});
});
} else {
ui.label("Enable Silkscreen to inspect component RefDes.");
}
ui.text_edit_singleline(&mut self.view.bom_search_query).labelled_by(bom_label.id): Binds the single-line text box directly to&mut self.view.bom_search_queryand links its AccessKit accessibility node tobom_label.id("Filter BOM:").ScrollArea::vertical().max_height(180.0).show_rows(ui, 22.0, matching.len(), |ui, row_range| { ... }): Caps the scroll container height at180.0logical points and passesrow_height_sans_spacing = 22.0andtotal_rows = matching.len().eguicomputesrow_range: std::ops::Range<usize>for the currently visible rows.egui::Grid::new("bom_inspector_grid").striped(true): Renders a two-column striped table (signal_name()in column 1, a clickableinspector_label()cross-probe button in column 2), advancing to the next row viaui.end_row().for comp in &matching[row_range]: Indexesmatchingwithrow_range(Range<usize>) to borrow a slice&[ComponentSymbol]of only the visible rows, then cross-probes intoself.view.focus_component(&self.board, &comp.ref_des)when clicked.
Block 4: Mutation-Proof Filter & Headless BOM Tests (#[cfg(test)])
#[test]
fn matches_query_checks_ref_des_value_package_and_net_case_insensitively() {
let board = BoardModel::default();
assert_eq!(board.filter_components(" ").len(), 3);
let by_ref = board.filter_components("r12");
assert_eq!(by_ref.len(), 1);
assert_eq!(by_ref[0].ref_des, "R12");
let by_val = board.filter_components("ldo_3v3");
assert_eq!(by_val.len(), 1);
assert_eq!(by_val[0].ref_des, "U1");
let by_pkg = board.filter_components("header");
assert_eq!(by_pkg.len(), 1);
assert_eq!(by_pkg[0].ref_des, "J1");
let by_net = board.filter_components("spi_clk");
assert_eq!(by_net.len(), 1);
assert_eq!(by_net[0].ref_des, "R12");
assert!(board.filter_components("zz_missing").is_empty());
}
#[test]
fn clicking_schematic_probe_and_bom_filter_buttons_synchronizes_view() {
let mut harness = Harness::new_eframe(|_cc| PcbReviewerApp::default());
harness.get_by_label("Probe R12 (10k)").click();
harness.run();
assert_eq!(
harness.state().view.selected_component.as_deref(),
Some("R12")
);
harness.state_mut().view.bom_search_query = "j1".to_owned();
harness.run();
assert!(harness.query_by_label("Matching Components: 1").is_some());
assert!(harness.query_by_label("U1: LDO_3V3 (SOT-23)").is_none());
harness.get_by_label("J1: CONN_2P (Header)").click();
harness.run();
assert_eq!(
harness.state().view.selected_component.as_deref(),
Some("J1")
);
harness.get_by_label("Clear Filter").click();
harness.run();
assert_eq!(harness.state().view.bom_search_query, "");
assert!(harness.query_by_label("Matching Components: 3").is_some());
harness.get_by_label("Clear Selection").click();
harness.run();
assert_eq!(harness.state().view.selected_net, None);
assert_eq!(harness.state().view.selected_component, None);
}
- Isolating Every
||Branch inmatches_query: Testing"r12"(matches onlyref_des),"ldo_3v3"(matches onlyvalue),"header"(matches onlypackage_name), and"spi_clk"(matches onlyprimary_net.signal_name()) guarantees that ifcargo-mutantsflips any||into&&or negatesneedle.is_empty(), at least one assertion fails. - Driving BOM Filtering & Row Cross-Probing in
egui_kittest: Settingharness.state_mut().view.bom_search_query = "j1".to_owned()and callingharness.run()verifies that"Matching Components: 1"appears,"U1: LDO_3V3 (SOT-23)"is filtered out of the virtualisedScrollArea, clicking the remaining"J1: CONN_2P (Header)"row focuses"J1", and clicking"Clear Filter"restores all 3 rows.
Complete Lesson 0009 Listing (In Context)
Here is how matches_query, filter_components, ViewState, and the virtualised ScrollArea::show_rows BOM inspector fit together in apps/0009-searchable-netlist-and-bom-inspector/src/main.rs:
// ... [Imports, MIN_ZOOM, MAX_ZOOM, main(), NetId, PcbLayer, TraceSegment, SmdPad unchanged] ...
#[derive(Clone, Debug, PartialEq)]
pub struct ComponentSymbol {
pub ref_des: String,
pub value: String,
pub package_name: String,
pub center_mm: Pos2,
pub body_size_mm: Vec2,
pub primary_net: NetId,
}
impl ComponentSymbol {
pub fn inspector_label(&self) -> String {
format!("{}: {} ({})", self.ref_des, self.value, self.package_name)
}
/// Case-insensitively matches `query` against `ref_des`, `value`, `package_name`,
/// or `primary_net.signal_name()`. An empty or whitespace-only query matches all components.
pub fn matches_query(&self, query: &str) -> bool {
let needle = query.trim().to_ascii_lowercase();
if needle.is_empty() {
return true;
}
self.ref_des.to_ascii_lowercase().contains(&needle)
|| self.value.to_ascii_lowercase().contains(&needle)
|| self.package_name.to_ascii_lowercase().contains(&needle)
|| self
.primary_net
.signal_name()
.to_ascii_lowercase()
.contains(&needle)
}
}
// ... [BoardViewport, sample_traces(), sample_pads(), sample_components()] ...
pub struct BoardModel {
pub traces: Vec<TraceSegment>,
pub pads: Vec<SmdPad>,
pub components: Vec<ComponentSymbol>,
}
impl Default for BoardModel {
fn default() -> Self {
Self {
traces: sample_traces(),
pads: sample_pads(),
components: sample_components(),
}
}
}
impl BoardModel {
pub fn find_component(&self, ref_des: &str) -> Option<&ComponentSymbol> {
self.components.iter().find(|c| c.ref_des == ref_des)
}
pub fn filter_components(&self, query: &str) -> Vec<ComponentSymbol> {
self.components
.iter()
.filter(|c| c.matches_query(query))
.cloned()
.collect()
}
}
pub struct ViewState {
pub show_top_copper: bool,
pub show_bottom_copper: bool,
pub show_silkscreen: bool,
pub show_ratsnest: bool,
pub viewport: BoardViewport,
pub pcb_scene_rect: Rect,
pub selected_component: Option<String>,
pub hovered_net: Option<NetId>,
pub selected_net: Option<NetId>,
pub bom_search_query: String,
}
impl Default for ViewState {
fn default() -> Self {
let mut view = Self {
show_top_copper: false,
show_bottom_copper: false,
show_silkscreen: false,
show_ratsnest: false,
viewport: BoardViewport::default(),
pcb_scene_rect: Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 70.0)),
selected_component: None,
hovered_net: None,
selected_net: None,
bom_search_query: String::new(),
};
view.reset_layers();
view
}
}
impl ViewState {
// ... [reset_layers, isolate_top_copper, show_all_layers, reset_viewport,
// is_layer_visible, active_net, focus_component] ...
pub fn clear_bom_query(&mut self) {
self.bom_search_query.clear();
}
}
#[derive(Default)]
pub struct PcbReviewerApp {
pub board: BoardModel,
pub view: ViewState,
}
impl PcbReviewerApp {
// ... [visible_traces_in_z_order, hit_test_net_at_mm, render_schematic_bar, render_canvas, paint_board] ...
pub fn render_side_panel(&mut self, ui: &mut egui::Ui) {
ui.heading("PCB Layers");
ui.checkbox(&mut self.view.show_top_copper, "Top Copper (F.Cu)");
ui.checkbox(&mut self.view.show_bottom_copper, "Bottom Copper (B.Cu)");
ui.checkbox(&mut self.view.show_silkscreen, "Silkscreen (F.SilkS)");
ui.checkbox(&mut self.view.show_ratsnest, "Ratsnest (Unrouted)");
ui.separator();
if ui.button("Isolate Top Copper").clicked() {
self.view.isolate_top_copper();
}
if ui.button("Show All Layers").clicked() {
self.view.show_all_layers();
}
ui.separator();
ui.heading("BOM & Netlist Inspector");
ui.horizontal(|ui| {
let bom_label = ui.label("Filter BOM:");
ui.text_edit_singleline(&mut self.view.bom_search_query)
.labelled_by(bom_label.id);
if ui.button("Clear Filter").clicked() {
self.view.clear_bom_query();
}
});
if self.view.show_silkscreen {
let matching = self.board.filter_components(&self.view.bom_search_query);
ui.label(format!("Matching Components: {}", matching.len()));
egui::ScrollArea::vertical()
.max_height(180.0)
.show_rows(ui, 22.0, matching.len(), |ui, row_range| {
egui::Grid::new("bom_inspector_grid")
.striped(true)
.show(ui, |ui| {
for comp in &matching[row_range] {
ui.label(comp.primary_net.signal_name());
if ui.button(comp.inspector_label()).clicked() {
self.view.focus_component(&self.board, &comp.ref_des);
}
ui.end_row();
}
});
});
} else {
ui.label("Enable Silkscreen to inspect component RefDes.");
}
ui.separator();
ui.heading("Net Inspector");
if let Some(net) = self.view.active_net() {
ui.label(format!("Active Net: {} ({})", net.code(), net.signal_name()));
if ui.button("Clear Selection").clicked() {
self.view.selected_net = None;
self.view.selected_component = None;
}
} else {
ui.label("Hover or click a pad to cross-probe its net.");
}
}
}
show_rows Inspector
TextEdit::singleline • .filter() • ScrollArea::show_rows
Type a query like u1, 0603, vcc, or spi into the simulated TextEdit::singleline box below, and click any row in the virtualised Grid to cross-probe that component:
1. TextEdit::singleline Filter
2. Virtualised egui::Grid (&matching[row_range])
| Idx | RefDes | Value | Primary Net |
|---|
3. Iterator & Slice State
Fill in the three blanks below to (1) filter components matching the query, (2) bind the search input to &mut self.view.bom_search_query, and (3) virtualise row rendering with show_rows:
pub fn filter_components(&self, query: &str) -> Vec<ComponentSymbol> {
self.components
.iter()
.(|c| c.matches_query(query))
.cloned()
.collect()
}
// Inside render_side_panel():
let bom_label = ui.label("Filter BOM:");
ui.(&mut self.view.bom_search_query)
.labelled_by(bom_label.id);
let matching = self.board.filter_components(&self.view.bom_search_query);
egui::ScrollArea::vertical()
.max_height(180.0)
.(ui, 22.0, matching.len(), |ui, row_range| {
for comp in &matching[row_range] {
if ui.button(comp.inspector_label()).clicked() {
self.view.focus_component(&self.board, &comp.ref_des);
}
}
});
! and || → && Filter Mutants
cargo test + cargo mutants
In ComponentSymbol::matches_query, four || branches test ref_des, value, package_name, and primary_net.signal_name(). When cargo mutants flips any single || into &&, a test that only searches for "U1" will miss the mutations on the other three branches! By asserting queries that match exclusively each field ("r12", "ldo_3v3", "header", "spi_clk", plus an empty query and a non-matching query "zz_missing"), our test suite catches every single boolean mutant.
#[test]
fn matches_query_checks_ref_des_value_package_and_net_case_insensitively() {
let board = BoardModel::default();
// 1. Empty or whitespace query matches all 3 components
assert_eq!(board.filter_components(" ").len(), 3);
// 2. Match exclusively by ref_des ("r12"), value ("ldo"), package ("header"), or net ("spi_clk")
assert_eq!(board.filter_components("r12").len(), 1);
assert_eq!(board.filter_components("ldo_3v3").len(), 1);
assert_eq!(board.filter_components("header").len(), 1);
assert_eq!(board.filter_components("spi_clk").len(), 1);
// 3. Non-matching query returns empty Vec
assert!(board.filter_components("zz_missing").is_empty());
}
Recommended Primary Source
egui::ScrollArea::show_rows Official API Docs and The Rust Programming Language — §13.2 "Processing a Series of Items with Iterators".
F, M, Esc) and an interactive point-to-point distance ruler? Continue to Lesson 0010: Keyboard Shortcuts, Data-Carrying Enums & Point-to-Point CAD Ruler!