Skip to content

Commit a4e6ec1

Browse files
0xrinegadeclaude
andcommitted
feat(graph): Add filter modal for selective node/token display
Press ` (backtick) in Graph tab to open a filter modal that lets you: - View and select/deselect wallets, programs, and tokens - Use Tab to switch between Wallets/Programs/Tokens tabs - Space to toggle individual items - 'a' to select all, 'x' to deselect all - Enter/Esc/` to apply and close Implementation details: - Added FilterModal struct with selection state for each category - Added GraphInput variants for filter modal navigation - Graph tracks user_filter_active flag to enable filtered rendering - node_passes_filter() and edge_passes_filter() methods check visibility - Modal renders as centered overlay with checkbox list UI This enables investigators to focus on specific wallets, tokens, or programs while hiding noise from the network graph visualization. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 92a30dd commit a4e6ec1

File tree

2 files changed

+526
-0
lines changed

2 files changed

+526
-0
lines changed

src/utils/tui/app.rs

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2700,6 +2700,20 @@ impl OsvmApp {
27002700
/// Handle a key event (from either local terminal or web)
27012701
/// Mirrors the main event_loop key handling for consistent behavior
27022702
fn handle_key_event(&mut self, key: KeyEvent) {
2703+
// Check if filter modal is active first
2704+
let filter_modal_active = if self.active_tab == TabIndex::Graph {
2705+
self.wallet_graph.lock()
2706+
.map(|g| g.filter_modal.active)
2707+
.unwrap_or(false)
2708+
} else {
2709+
false
2710+
};
2711+
2712+
if filter_modal_active {
2713+
self.handle_filter_modal_key(key);
2714+
return;
2715+
}
2716+
27032717
match key.code {
27042718
// Quit/Escape - handle different contexts
27052719
KeyCode::Char('q') | KeyCode::Esc => {
@@ -2898,6 +2912,12 @@ impl OsvmApp {
28982912
graph.handle_input(GraphInput::ScrollDetailDown);
28992913
}
29002914
}
2915+
// Filter modal (backtick key)
2916+
KeyCode::Char('`') if self.active_tab == TabIndex::Graph => {
2917+
if let Ok(mut graph) = self.wallet_graph.lock() {
2918+
graph.handle_input(GraphInput::OpenFilterModal);
2919+
}
2920+
}
29012921
// Number keys 1-5 to select tabs
29022922
KeyCode::Char('1') if !self.chat_input_active => {
29032923
self.active_tab = TabIndex::Chat;
@@ -2952,6 +2972,78 @@ impl OsvmApp {
29522972
}
29532973
}
29542974

2975+
/// Handle key events when filter modal is active
2976+
fn handle_filter_modal_key(&mut self, key: KeyEvent) {
2977+
match key.code {
2978+
// Close modal
2979+
KeyCode::Esc | KeyCode::Char('`') | KeyCode::Char('q') => {
2980+
if let Ok(mut graph) = self.wallet_graph.lock() {
2981+
graph.handle_input(GraphInput::CloseFilterModal);
2982+
}
2983+
}
2984+
// Navigate items
2985+
KeyCode::Up | KeyCode::Char('k') => {
2986+
if let Ok(mut graph) = self.wallet_graph.lock() {
2987+
if graph.filter_modal.selected_index > 0 {
2988+
graph.filter_modal.selected_index -= 1;
2989+
// Adjust scroll offset if needed
2990+
if graph.filter_modal.selected_index < graph.filter_modal.scroll_offset {
2991+
graph.filter_modal.scroll_offset = graph.filter_modal.selected_index;
2992+
}
2993+
}
2994+
}
2995+
}
2996+
KeyCode::Down | KeyCode::Char('j') => {
2997+
if let Ok(mut graph) = self.wallet_graph.lock() {
2998+
let max_idx = graph.filter_modal.current_items().len().saturating_sub(1);
2999+
if graph.filter_modal.selected_index < max_idx {
3000+
graph.filter_modal.selected_index += 1;
3001+
// Adjust scroll offset if needed (assume 10 visible items)
3002+
if graph.filter_modal.selected_index >= graph.filter_modal.scroll_offset + 15 {
3003+
graph.filter_modal.scroll_offset = graph.filter_modal.selected_index.saturating_sub(14);
3004+
}
3005+
}
3006+
}
3007+
}
3008+
// Toggle selection with Space
3009+
KeyCode::Char(' ') => {
3010+
if let Ok(mut graph) = self.wallet_graph.lock() {
3011+
graph.handle_input(GraphInput::FilterToggleItem);
3012+
}
3013+
}
3014+
// Tab to switch between Wallets/Programs/Tokens tabs
3015+
KeyCode::Tab => {
3016+
if let Ok(mut graph) = self.wallet_graph.lock() {
3017+
graph.handle_input(GraphInput::FilterNextTab);
3018+
}
3019+
}
3020+
KeyCode::BackTab => {
3021+
if let Ok(mut graph) = self.wallet_graph.lock() {
3022+
graph.handle_input(GraphInput::FilterPrevTab);
3023+
}
3024+
}
3025+
// Select all with 'a'
3026+
KeyCode::Char('a') => {
3027+
if let Ok(mut graph) = self.wallet_graph.lock() {
3028+
graph.handle_input(GraphInput::FilterSelectAll);
3029+
}
3030+
}
3031+
// Deselect all with 'x'
3032+
KeyCode::Char('x') => {
3033+
if let Ok(mut graph) = self.wallet_graph.lock() {
3034+
graph.handle_input(GraphInput::FilterDeselectAll);
3035+
}
3036+
}
3037+
// Enter to apply and close
3038+
KeyCode::Enter => {
3039+
if let Ok(mut graph) = self.wallet_graph.lock() {
3040+
graph.handle_input(GraphInput::CloseFilterModal);
3041+
}
3042+
}
3043+
_ => {}
3044+
}
3045+
}
3046+
29553047
fn event_loop<B, F>(&mut self, terminal: &mut Terminal<B>, on_tick: F) -> Result<()>
29563048
where
29573049
B: ratatui::backend::Backend + std::io::Write,
@@ -3715,6 +3807,19 @@ impl OsvmApp {
37153807
}
37163808
}
37173809

3810+
// Render filter modal if active (only on Graph tab)
3811+
let show_filter_modal = if self.active_tab == TabIndex::Graph {
3812+
self.wallet_graph.lock()
3813+
.map(|g| g.filter_modal.active)
3814+
.unwrap_or(false)
3815+
} else {
3816+
false
3817+
};
3818+
3819+
if show_filter_modal {
3820+
self.render_filter_modal(f, size);
3821+
}
3822+
37183823
// Render help overlay if active
37193824
if self.show_help {
37203825
self.render_help_overlay(f, size);
@@ -4831,6 +4936,147 @@ impl OsvmApp {
48314936
f.render_widget(search_widget, modal_area);
48324937
}
48334938

4939+
/// Render the filter modal for graph node/token filtering
4940+
fn render_filter_modal(&mut self, f: &mut Frame, area: Rect) {
4941+
use super::graph::FilterTab;
4942+
4943+
// Get filter modal state
4944+
let (current_tab, selected_index, scroll_offset, items, counts) = {
4945+
if let Ok(graph) = self.wallet_graph.lock() {
4946+
let items = graph.filter_modal.current_items();
4947+
let counts = (
4948+
graph.filter_modal.all_wallets.len(),
4949+
graph.filter_modal.all_programs.len(),
4950+
graph.filter_modal.all_tokens.len(),
4951+
graph.filter_modal.selected_wallets.len(),
4952+
graph.filter_modal.selected_programs.len(),
4953+
graph.filter_modal.selected_tokens.len(),
4954+
);
4955+
(
4956+
graph.filter_modal.current_tab,
4957+
graph.filter_modal.selected_index,
4958+
graph.filter_modal.scroll_offset,
4959+
items,
4960+
counts,
4961+
)
4962+
} else {
4963+
return;
4964+
}
4965+
};
4966+
4967+
// Center the modal (50% width, 60% height)
4968+
let width = (area.width * 60 / 100).max(50).min(area.width.saturating_sub(4));
4969+
let height = (area.height * 70 / 100).max(20).min(area.height.saturating_sub(4));
4970+
let x = (area.width.saturating_sub(width)) / 2;
4971+
let y = (area.height.saturating_sub(height)) / 2;
4972+
let modal_area = Rect::new(x, y, width, height);
4973+
4974+
// Clear background
4975+
f.render_widget(Clear, modal_area);
4976+
4977+
// Create main block
4978+
let block = Block::default()
4979+
.title(" 🔍 Filter Graph ")
4980+
.title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
4981+
.borders(Borders::ALL)
4982+
.border_style(Style::default().fg(Color::Cyan))
4983+
.style(Style::default().bg(Color::Black));
4984+
4985+
let inner = block.inner(modal_area);
4986+
f.render_widget(block, modal_area);
4987+
4988+
// Split into tabs bar and content
4989+
let chunks = Layout::default()
4990+
.direction(Direction::Vertical)
4991+
.constraints([
4992+
Constraint::Length(3), // Tabs
4993+
Constraint::Min(0), // Content
4994+
Constraint::Length(3), // Footer with instructions
4995+
])
4996+
.split(inner);
4997+
4998+
// Render tabs
4999+
let (wallet_count, program_count, token_count, sel_wallet, sel_program, sel_token) = counts;
5000+
let tabs = vec![
5001+
format!(" Wallets ({}/{}) ", sel_wallet, wallet_count),
5002+
format!(" Programs ({}/{}) ", sel_program, program_count),
5003+
format!(" Tokens ({}/{}) ", sel_token, token_count),
5004+
];
5005+
5006+
let tab_idx = match current_tab {
5007+
FilterTab::Wallets => 0,
5008+
FilterTab::Programs => 1,
5009+
FilterTab::Tokens => 2,
5010+
};
5011+
5012+
let tabs_widget = Tabs::new(tabs.iter().map(|s| s.as_str()).collect::<Vec<_>>())
5013+
.block(Block::default().borders(Borders::BOTTOM))
5014+
.select(tab_idx)
5015+
.style(Style::default().fg(Color::White))
5016+
.highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
5017+
5018+
f.render_widget(tabs_widget, chunks[0]);
5019+
5020+
// Render content list
5021+
let visible_height = chunks[1].height as usize;
5022+
let display_items: Vec<ListItem> = items
5023+
.iter()
5024+
.enumerate()
5025+
.skip(scroll_offset)
5026+
.take(visible_height)
5027+
.map(|(idx, (name, selected))| {
5028+
let checkbox = if *selected { "☑" } else { "☐" };
5029+
let style = if idx == selected_index {
5030+
Style::default().fg(Color::Yellow).bg(Color::DarkGray).add_modifier(Modifier::BOLD)
5031+
} else if *selected {
5032+
Style::default().fg(Color::Green)
5033+
} else {
5034+
Style::default().fg(Color::DarkGray)
5035+
};
5036+
5037+
// Truncate long addresses
5038+
let display_name = if name.len() > 50 {
5039+
format!("{}...{}", &name[..20], &name[name.len()-20..])
5040+
} else {
5041+
name.clone()
5042+
};
5043+
5044+
ListItem::new(Line::from(vec![
5045+
Span::styled(format!(" {} ", checkbox), style),
5046+
Span::styled(display_name, style),
5047+
]))
5048+
})
5049+
.collect();
5050+
5051+
let list = List::new(display_items)
5052+
.block(Block::default());
5053+
5054+
f.render_widget(list, chunks[1]);
5055+
5056+
// Render footer with instructions
5057+
let footer_text = Line::from(vec![
5058+
Span::styled(" ↑↓ ", Style::default().fg(Color::Yellow)),
5059+
Span::raw("navigate "),
5060+
Span::styled("Space", Style::default().fg(Color::Yellow)),
5061+
Span::raw(" toggle "),
5062+
Span::styled("Tab", Style::default().fg(Color::Yellow)),
5063+
Span::raw(" switch "),
5064+
Span::styled("a", Style::default().fg(Color::Yellow)),
5065+
Span::raw(" all "),
5066+
Span::styled("x", Style::default().fg(Color::Yellow)),
5067+
Span::raw(" none "),
5068+
Span::styled("Enter/`/Esc", Style::default().fg(Color::Yellow)),
5069+
Span::raw(" apply"),
5070+
]);
5071+
5072+
let footer = Paragraph::new(footer_text)
5073+
.style(Style::default().fg(Color::DarkGray))
5074+
.alignment(Alignment::Center)
5075+
.block(Block::default().borders(Borders::TOP));
5076+
5077+
f.render_widget(footer, chunks[2]);
5078+
}
5079+
48345080
fn render_help_overlay(&mut self, f: &mut Frame, area: Rect) {
48355081
// Center the help box
48365082
let width = 70.min(area.width.saturating_sub(4));

0 commit comments

Comments
 (0)