Skip to content

Commit 7e41fbc

Browse files
authored
ui/flamegraph: improve searchbar (#72)
Signed-off-by: Florian Lehner <florian.lehner@elastic.co>
1 parent 4aa826b commit 7e41fbc

2 files changed

Lines changed: 221 additions & 6 deletions

File tree

src/ui/tabs/flamegraph.rs

Lines changed: 182 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
use super::*;
1919
use crate::storage::*;
2020
use crate::ui::cached::Cached;
21-
use crate::ui::util::{clearable_line_edit, frame_kind_color, humanize_count};
21+
use crate::ui::util::{clearable_line_edit_with_status, frame_kind_color, humanize_count};
2222
use base64::Engine;
2323
use egui::emath::RectTransform;
2424
use egui::Stroke;
@@ -92,7 +92,34 @@ impl TabWidget for FlameGraphTab {
9292
});
9393
ui[1].with_layout(Layout::right_to_left(Align::Min), |ui| {
9494
let hint = format!("{} Filter ...", icons::FUNNEL);
95-
clearable_line_edit(ui, &hint, &mut self.widget.filter);
95+
let prev_filter = self.widget.filter.clone();
96+
97+
let status_text = if self.widget.filter.len() >= 3 {
98+
if self.widget.match_count > 0 {
99+
let current = self.widget.current_match_index + 1;
100+
Some((
101+
format!("{}/{}", current, self.widget.match_count),
102+
Color32::from_rgb(100, 200, 100),
103+
))
104+
} else {
105+
Some(("No matches".to_string(), Color32::from_rgb(200, 100, 100)))
106+
}
107+
} else {
108+
None
109+
};
110+
111+
clearable_line_edit_with_status(
112+
ui,
113+
&hint,
114+
&mut self.widget.filter,
115+
status_text
116+
.as_ref()
117+
.map(|(text, color)| (text.as_str(), *color)),
118+
);
119+
120+
if prev_filter != self.widget.filter {
121+
self.widget.current_match_index = 0;
122+
}
96123
});
97124
});
98125
ui.add_space(5.0);
@@ -101,6 +128,13 @@ impl TabWidget for FlameGraphTab {
101128
}
102129
}
103130

131+
/// MatchingFrame is a helper struct to navigate filtered frames.
132+
struct MatchingFrame {
133+
id: FrameId,
134+
pos: Pos2,
135+
width_ratio: f32,
136+
}
137+
104138
/// Widget drawing a flame-graph.
105139
///
106140
/// Separate from [`FlameGraphTab`] to allow reusing it later (e.g. for
@@ -110,6 +144,12 @@ struct FlameGraphWidget {
110144
x_zoom: f32,
111145
filter: String,
112146
sandwich_view: Option<SandwichView>,
147+
148+
matching_frames: Vec<MatchingFrame>,
149+
current_match_index: usize,
150+
match_count: usize,
151+
cached_filter: String,
152+
rebuild_matches: bool,
113153
}
114154

115155
/// Sandwich view showing callers above and callees below a selected frame
@@ -128,6 +168,11 @@ impl Default for FlameGraphWidget {
128168
x_zoom: 1.0,
129169
filter: "".to_string(),
130170
sandwich_view: None,
171+
matching_frames: Vec::new(),
172+
current_match_index: 0,
173+
match_count: 0,
174+
cached_filter: "".to_string(),
175+
rebuild_matches: true,
131176
}
132177
}
133178
}
@@ -140,6 +185,13 @@ impl FlameGraphWidget {
140185

141186
self.process_inputs(ui, size, &response, root);
142187

188+
if self.filter != self.cached_filter {
189+
self.matching_frames.clear();
190+
self.match_count = 0;
191+
self.cached_filter = self.filter.clone();
192+
self.rebuild_matches = true;
193+
}
194+
143195
let to_screen = RectTransform::from_to(
144196
Rect::from_min_size(self.origin, response.rect.size()),
145197
response.rect,
@@ -180,6 +232,8 @@ impl FlameGraphWidget {
180232
root,
181233
);
182234
}
235+
236+
self.rebuild_matches = false;
183237
});
184238
}
185239

@@ -353,10 +407,35 @@ impl FlameGraphWidget {
353407
_root: &FlameGraphNode,
354408
) {
355409
let Some(cursor) = response.hover_pos() else {
356-
// Ignore inputs when not hovered.
410+
// Check for Enter key even when not hovered (for filter navigation)
411+
if ui.input(|i| i.key_pressed(Key::Enter))
412+
&& self.filter.len() >= 3
413+
&& !self.matching_frames.is_empty()
414+
{
415+
let shift_held = ui.input(|i| i.modifiers.shift);
416+
if shift_held {
417+
self.navigate_to_prev_match(size);
418+
} else {
419+
self.navigate_to_next_match(size);
420+
}
421+
}
357422
return;
358423
};
359424

425+
// Handle Enter key for navigating through matches
426+
if ui.input(|i| i.key_pressed(Key::Enter))
427+
&& self.filter.len() >= 3
428+
&& !self.matching_frames.is_empty()
429+
{
430+
let shift_held = ui.input(|i| i.modifiers.shift);
431+
if shift_held {
432+
self.navigate_to_prev_match(size);
433+
} else {
434+
self.navigate_to_next_match(size);
435+
}
436+
return;
437+
}
438+
360439
// Double-click -> reset the view.
361440
if response.double_clicked() {
362441
self.origin = Pos2::ZERO;
@@ -430,12 +509,49 @@ impl FlameGraphWidget {
430509
return flame_width;
431510
}
432511

433-
let bg_color = if flame.text.contains(&self.filter) {
512+
let bg_color = if self.filter.len() >= 3 {
513+
if flame.text.contains(&self.filter) {
514+
flame.bg_color
515+
} else {
516+
flame.bg_color.gamma_multiply(0.5)
517+
}
518+
} else {
434519
flame.bg_color
520+
};
521+
522+
// Track matching frames for navigation (only if filter changed)
523+
let is_match = self.filter.len() >= 3 && flame.text.contains(&self.filter);
524+
let is_focused = if is_match {
525+
if self.rebuild_matches {
526+
let unscaled_pos = pos2(draw_pos.x / self.x_zoom, draw_pos.y);
527+
let width_ratio = flame.weight as f32 / root.weight.max(1) as f32;
528+
529+
self.matching_frames.push(MatchingFrame {
530+
id: flame.id,
531+
pos: unscaled_pos,
532+
width_ratio,
533+
});
534+
self.match_count += 1;
535+
}
536+
537+
// Check if this frame is the focused one by comparing IDs
538+
self.matching_frames
539+
.get(self.current_match_index)
540+
.map(|m| m.id == flame.id)
541+
.unwrap_or(false)
435542
} else {
436-
flame.bg_color.gamma_multiply(0.5)
543+
false
437544
};
438545

546+
// Highlight the currently focused match
547+
if is_focused {
548+
painter.add(Shape::rect_stroke(
549+
screen_rect,
550+
Rounding::ZERO,
551+
Stroke::new(3.0, Color32::from_rgb(255, 215, 0)), // Gold color
552+
));
553+
}
554+
439555
painter.add(Shape::rect_filled(screen_rect, Rounding::ZERO, bg_color));
440556

441557
painter.add(Shape::rect_stroke(
@@ -501,6 +617,67 @@ impl FlameGraphWidget {
501617
flame_width
502618
}
503619

620+
/// Navigate to the next matching frame
621+
fn navigate_to_next_match(&mut self, size: Vec2) {
622+
if self.matching_frames.is_empty() {
623+
return;
624+
}
625+
626+
// Cycle to next match
627+
self.current_match_index = (self.current_match_index + 1) % self.match_count;
628+
self.center_on_current_match(size);
629+
}
630+
631+
/// Navigate to the previous matching frame
632+
fn navigate_to_prev_match(&mut self, size: Vec2) {
633+
if self.matching_frames.is_empty() {
634+
return;
635+
}
636+
637+
// Cycle to previous match (wrap around)
638+
if self.current_match_index == 0 {
639+
self.current_match_index = self.match_count - 1;
640+
} else {
641+
self.current_match_index -= 1;
642+
}
643+
self.center_on_current_match(size);
644+
}
645+
646+
/// Center the view on the currently selected match
647+
fn center_on_current_match(&mut self, size: Vec2) {
648+
if let Some(frame_info) = self.matching_frames.get(self.current_match_index) {
649+
let base_width = size.x * frame_info.width_ratio;
650+
651+
// Make sure text in frame is readable
652+
let min_visible_width = size.x * 0.3;
653+
let desired_zoom = if base_width < min_visible_width {
654+
(min_visible_width / base_width).min(20.0)
655+
} else {
656+
1.0
657+
};
658+
659+
// Update zoom
660+
self.x_zoom = desired_zoom;
661+
662+
// Recalculate frame position and center with new zoom
663+
let frame_x = frame_info.pos.x * self.x_zoom;
664+
let frame_width_zoomed = base_width * self.x_zoom;
665+
let frame_center_x = frame_x + frame_width_zoomed / 2.0;
666+
let frame_center_y = frame_info.pos.y + FLAME_HEIGHT / 2.0;
667+
668+
let target_x = frame_center_x - size.x / 2.0;
669+
let target_y = frame_center_y - size.y / 2.0;
670+
671+
self.origin.x = target_x.max(0.0);
672+
self.origin.y = target_y.max(0.0);
673+
674+
// Clamp to visible region
675+
let virt_width = size.x * self.x_zoom;
676+
self.origin.x = self.origin.x.clamp(0.0, (virt_width - size.x).max(0.0));
677+
self.origin.y = self.origin.y.clamp(0.0, MAX_FRAMES * FLAME_HEIGHT);
678+
}
679+
}
680+
504681
/// Populates the on-hover tooltip UI.
505682
fn draw_tooltip(
506683
&self,

src/ui/util.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
use crate::storage::{FrameKind, InterpKind};
1919
use eframe::emath::{Pos2, Rect, Vec2};
2020
use eframe::epaint::{Color32, Stroke};
21-
use egui::{Button, TextEdit, Ui};
21+
use egui::{Align2, Button, FontId, TextEdit, Ui};
2222
use egui_phosphor::regular as icons;
2323
use std::fmt;
2424

@@ -42,6 +42,44 @@ pub fn clearable_line_edit(ui: &mut Ui, hint: &str, input: &mut String) {
4242
}
4343
}
4444

45+
/// Draw a line edit with a button for clearing it and optional right-aligned text.
46+
pub fn clearable_line_edit_with_status(
47+
ui: &mut Ui,
48+
hint: &str,
49+
input: &mut String,
50+
status_text: Option<(&str, Color32)>,
51+
) {
52+
let elem = TextEdit::singleline(input).hint_text(hint);
53+
let edit_rect = ui.add(elem).rect;
54+
55+
if let Some((text, color)) = status_text {
56+
let mut status_pos = edit_rect.right_center();
57+
status_pos.x -= if input.is_empty() { 10.0 } else { 30.0 };
58+
59+
ui.painter().text(
60+
status_pos,
61+
Align2::RIGHT_CENTER,
62+
text,
63+
FontId::proportional(12.0),
64+
color,
65+
);
66+
}
67+
68+
if !input.is_empty() {
69+
let mut clear_origin = edit_rect.right_center();
70+
clear_origin.x -= 10.0;
71+
72+
let clear_rect = Rect::from_center_size(clear_origin, Vec2::splat(15.0));
73+
let clear_widget = Button::new(icons::X).small().frame(false);
74+
75+
let clear_resp = ui.put(clear_rect, clear_widget);
76+
77+
if clear_resp.clicked() {
78+
input.clear();
79+
}
80+
}
81+
}
82+
4583
/// Suggest a color for the given frame kind.
4684
pub fn frame_kind_color(kind: FrameKind) -> Color32 {
4785
let interp = match kind {

0 commit comments

Comments
 (0)