feat: add search for received data in Text and Hex views
- Add search() method to TextBuffer and HexBuffer - Add SearchState with match tracking, prev/next navigation - Highlight matched text with yellow background via LayoutJob - Search bar UI with case-sensitive toggle and match counter - Keyboard shortcuts: Ctrl+F (open), F3 (next), Shift+F3 (prev) - Auto-focus input on Ctrl+F - Update README with comprehensive project documentation
This commit is contained in:
@@ -55,6 +55,71 @@ pub struct DisplayOptions {
|
||||
pub show_pipeline: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SearchState {
|
||||
pub query: String,
|
||||
pub matches: Vec<usize>,
|
||||
pub current_match: usize,
|
||||
pub case_sensitive: bool,
|
||||
pub active: bool,
|
||||
pub just_opened: bool,
|
||||
}
|
||||
|
||||
impl Default for SearchState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
query: String::new(),
|
||||
matches: Vec::new(),
|
||||
current_match: 0,
|
||||
case_sensitive: false,
|
||||
active: false,
|
||||
just_opened: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchState {
|
||||
pub fn clear(&mut self) {
|
||||
self.query.clear();
|
||||
self.matches.clear();
|
||||
self.current_match = 0;
|
||||
self.active = false;
|
||||
self.just_opened = false;
|
||||
}
|
||||
|
||||
pub fn next(&mut self) {
|
||||
if !self.matches.is_empty() {
|
||||
self.current_match = (self.current_match + 1) % self.matches.len();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev(&mut self) {
|
||||
if !self.matches.is_empty() {
|
||||
self.current_match = if self.current_match == 0 {
|
||||
self.matches.len() - 1
|
||||
} else {
|
||||
self.current_match - 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// pub fn current_line_index(&self) -> Option<usize> {
|
||||
// self.matches.get(self.current_match).copied()
|
||||
// }
|
||||
|
||||
pub fn match_count(&self) -> usize {
|
||||
self.matches.len()
|
||||
}
|
||||
|
||||
pub fn current_display(&self) -> usize {
|
||||
if self.matches.is_empty() {
|
||||
0
|
||||
} else {
|
||||
self.current_match + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SessionTab {
|
||||
pub id: u64,
|
||||
pub session_config: SessionConfig,
|
||||
@@ -71,6 +136,7 @@ pub struct SessionTab {
|
||||
pub send_mode: SendMode,
|
||||
pub append_newline: bool,
|
||||
pub send_status: Option<String>,
|
||||
pub search: SearchState,
|
||||
}
|
||||
|
||||
pub struct XserialApp {
|
||||
@@ -260,6 +326,26 @@ impl XserialApp {
|
||||
self.config_open = false;
|
||||
self.font_settings_open = false;
|
||||
}
|
||||
Search => {
|
||||
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||
tab.search.active = true;
|
||||
tab.search.just_opened = true;
|
||||
}
|
||||
}
|
||||
SearchNext => {
|
||||
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||
if tab.search.active {
|
||||
tab.search.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchPrev => {
|
||||
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||
if tab.search.active {
|
||||
tab.search.prev();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +388,7 @@ impl XserialApp {
|
||||
send_mode: SendMode::Text,
|
||||
append_newline: true,
|
||||
send_status: None,
|
||||
search: SearchState::default(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -596,6 +683,8 @@ impl XserialApp {
|
||||
});
|
||||
persist_state |= display_changed;
|
||||
|
||||
render_search_bar(ui, tab);
|
||||
|
||||
if let ConnectionStatus::Error(message) = &tab.status {
|
||||
ui.label(egui::RichText::new(message).color(Color32::RED));
|
||||
}
|
||||
@@ -619,11 +708,11 @@ impl XserialApp {
|
||||
let started = Instant::now();
|
||||
match tab.view {
|
||||
View::Text => {
|
||||
let line_count = console::render(ui, &tab.console, *display);
|
||||
let line_count = console::render(ui, &tab.console, *display, tab.search.active.then_some(&tab.search));
|
||||
text_render = Some((started.elapsed(), line_count));
|
||||
}
|
||||
View::Hex => {
|
||||
let line_count = hex_view::render(ui, &tab.hex, *display);
|
||||
let line_count = hex_view::render(ui, &tab.hex, *display, tab.search.active.then_some(&tab.search));
|
||||
hex_render = Some((started.elapsed(), line_count));
|
||||
}
|
||||
View::Plot => {
|
||||
@@ -972,6 +1061,62 @@ impl eframe::App for XserialApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_search_bar(ui: &mut egui::Ui, tab: &mut SessionTab) {
|
||||
if !tab.search.active {
|
||||
return;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let response = ui.add(
|
||||
TextEdit::singleline(&mut tab.search.query)
|
||||
.hint_text("Search...")
|
||||
.desired_width(200.0),
|
||||
);
|
||||
if tab.search.just_opened {
|
||||
response.request_focus();
|
||||
tab.search.just_opened = false;
|
||||
}
|
||||
if response.changed() {
|
||||
let matches = match tab.view {
|
||||
View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive),
|
||||
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
|
||||
View::Plot => Vec::new(),
|
||||
};
|
||||
tab.search.matches = matches;
|
||||
tab.search.current_match = 0;
|
||||
}
|
||||
if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
tab.search.next();
|
||||
}
|
||||
|
||||
ui.label(format!(
|
||||
"{}/{}",
|
||||
tab.search.current_display(),
|
||||
tab.search.match_count()
|
||||
));
|
||||
|
||||
if ui.button("▲").clicked() {
|
||||
tab.search.prev();
|
||||
}
|
||||
if ui.button("▼").clicked() {
|
||||
tab.search.next();
|
||||
}
|
||||
|
||||
if ui.checkbox(&mut tab.search.case_sensitive, "Aa").changed() {
|
||||
let matches = match tab.view {
|
||||
View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive),
|
||||
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
|
||||
View::Plot => Vec::new(),
|
||||
};
|
||||
tab.search.matches = matches;
|
||||
tab.search.current_match = 0;
|
||||
}
|
||||
|
||||
if ui.button("✕").clicked() {
|
||||
tab.search.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn render_session_controls(
|
||||
ui: &mut egui::Ui,
|
||||
manager: &SessionManager,
|
||||
|
||||
@@ -67,6 +67,32 @@ impl TextBuffer {
|
||||
pub fn set_limit(&mut self, limit: usize) {
|
||||
self.lines.set_limit(limit);
|
||||
}
|
||||
|
||||
// pub fn iter(&self) -> impl Iterator<Item = &ConsoleLine> {
|
||||
// (0..self.lines.len()).filter_map(|i| self.lines.get(i))
|
||||
// }
|
||||
|
||||
pub fn search(&self, query: &str, case_sensitive: bool) -> Vec<usize> {
|
||||
if query.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let query_lower = if case_sensitive {
|
||||
String::new()
|
||||
} else {
|
||||
query.to_lowercase()
|
||||
};
|
||||
(0..self.lines.len())
|
||||
.filter(|&i| {
|
||||
self.lines.get(i).map_or(false, |line| {
|
||||
if case_sensitive {
|
||||
line.text.contains(query)
|
||||
} else {
|
||||
line.text.to_lowercase().contains(&query_lower)
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -155,6 +181,33 @@ impl HexBuffer {
|
||||
pub fn set_limit(&mut self, limit: usize) {
|
||||
self.lines.set_limit(limit);
|
||||
}
|
||||
|
||||
// pub fn iter(&self) -> impl Iterator<Item = &HexLine> {
|
||||
// (0..self.lines.len()).filter_map(|i| self.lines.get(i))
|
||||
// }
|
||||
|
||||
pub fn search(&self, query: &str, case_sensitive: bool) -> Vec<usize> {
|
||||
if query.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let query_lower = if case_sensitive {
|
||||
String::new()
|
||||
} else {
|
||||
query.to_lowercase()
|
||||
};
|
||||
(0..self.lines.len())
|
||||
.filter(|&i| {
|
||||
self.lines.get(i).map_or(false, |line| {
|
||||
if case_sensitive {
|
||||
line.hex.contains(query) || line.ascii.contains(query)
|
||||
} else {
|
||||
line.hex.to_lowercase().contains(&query_lower)
|
||||
|| line.ascii.to_lowercase().contains(&query_lower)
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PlotSeries {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
use crate::app::DisplayOptions;
|
||||
use crate::app::{DisplayOptions, SearchState};
|
||||
use crate::buffers::{ConsoleLine, LineDirection, TextBuffer};
|
||||
use egui::{Label, RichText, ScrollArea, TextStyle, TextWrapMode, Ui};
|
||||
use egui::{
|
||||
Color32, Label, ScrollArea, TextStyle, TextWrapMode, Ui,
|
||||
text::{LayoutJob, TextFormat},
|
||||
};
|
||||
|
||||
pub fn render(ui: &mut Ui, buf: &TextBuffer, display: DisplayOptions) -> usize {
|
||||
pub fn render(
|
||||
ui: &mut Ui,
|
||||
buf: &TextBuffer,
|
||||
display: DisplayOptions,
|
||||
search: Option<&SearchState>,
|
||||
) -> usize {
|
||||
let line_count = buf.len();
|
||||
let row_height = ui.text_style_height(&TextStyle::Monospace);
|
||||
ScrollArea::vertical().stick_to_bottom(true).show_rows(
|
||||
@@ -13,7 +21,7 @@ pub fn render(ui: &mut Ui, buf: &TextBuffer, display: DisplayOptions) -> usize {
|
||||
for row in row_range {
|
||||
if let Some(line) = buf.get(row) {
|
||||
ui.add(
|
||||
Label::new(RichText::new(format_console_line(line, display)).monospace())
|
||||
Label::new(format_console_line(line, display, search, ui.style()))
|
||||
.wrap_mode(TextWrapMode::Extend),
|
||||
);
|
||||
}
|
||||
@@ -23,7 +31,59 @@ pub fn render(ui: &mut Ui, buf: &TextBuffer, display: DisplayOptions) -> usize {
|
||||
line_count
|
||||
}
|
||||
|
||||
fn format_console_line(line: &ConsoleLine, display: DisplayOptions) -> String {
|
||||
fn highlight_matches(
|
||||
text: &str,
|
||||
query: &str,
|
||||
case_sensitive: bool,
|
||||
default_format: TextFormat,
|
||||
highlight_format: TextFormat,
|
||||
) -> LayoutJob {
|
||||
let search_text = if case_sensitive {
|
||||
text.to_string()
|
||||
} else {
|
||||
text.to_lowercase()
|
||||
};
|
||||
let query = if case_sensitive {
|
||||
query.to_string()
|
||||
} else {
|
||||
query.to_lowercase()
|
||||
};
|
||||
let mut job = LayoutJob::default();
|
||||
let mut last_end = 0;
|
||||
let mut idx = 0;
|
||||
while let Some(pos) = search_text[idx..].find(&query) {
|
||||
let start = idx + pos;
|
||||
let end = start + query.len();
|
||||
if last_end < start {
|
||||
job.append(&text[last_end..start], 0.0, default_format.clone());
|
||||
}
|
||||
job.append(&text[start..end], 0.0, highlight_format.clone());
|
||||
last_end = end;
|
||||
idx = end;
|
||||
}
|
||||
if last_end < text.len() {
|
||||
job.append(&text[last_end..], 0.0, default_format);
|
||||
}
|
||||
job
|
||||
}
|
||||
|
||||
fn monospace_format(style: &egui::Style) -> TextFormat {
|
||||
TextFormat {
|
||||
font_id: style
|
||||
.text_styles
|
||||
.get(&TextStyle::Monospace)
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_console_line(
|
||||
line: &ConsoleLine,
|
||||
display: DisplayOptions,
|
||||
search: Option<&SearchState>,
|
||||
style: &egui::Style,
|
||||
) -> LayoutJob {
|
||||
let mut prefix = Vec::new();
|
||||
if display.show_timestamp {
|
||||
let ts = line.elapsed.as_secs_f64();
|
||||
@@ -49,5 +109,20 @@ fn format_console_line(line: &ConsoleLine, display: DisplayOptions) -> String {
|
||||
} else {
|
||||
format!("{} ", prefix.join(" "))
|
||||
};
|
||||
format!("{prefix}{}", line.text)
|
||||
let full_text = format!("{prefix}{}", line.text);
|
||||
|
||||
let default = monospace_format(style);
|
||||
let highlight = TextFormat {
|
||||
font_id: default.font_id.clone(),
|
||||
color: Color32::BLACK,
|
||||
background: Color32::from_rgb(255, 255, 0),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match search {
|
||||
Some(state) if state.active && !state.query.is_empty() => {
|
||||
highlight_matches(&full_text, &state.query, state.case_sensitive, default, highlight)
|
||||
}
|
||||
_ => LayoutJob::single_section(full_text, default),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
use crate::app::DisplayOptions;
|
||||
use crate::app::{DisplayOptions, SearchState};
|
||||
use crate::buffers::{HexBuffer, HexLine, LineDirection};
|
||||
use egui::{Label, RichText, ScrollArea, TextStyle, TextWrapMode, Ui};
|
||||
use egui::{
|
||||
Color32, Label, ScrollArea, TextStyle, TextWrapMode, Ui,
|
||||
text::{LayoutJob, TextFormat},
|
||||
};
|
||||
|
||||
pub fn render(ui: &mut Ui, buf: &HexBuffer, display: DisplayOptions) -> usize {
|
||||
pub fn render(
|
||||
ui: &mut Ui,
|
||||
buf: &HexBuffer,
|
||||
display: DisplayOptions,
|
||||
search: Option<&SearchState>,
|
||||
) -> usize {
|
||||
let line_count = buf.len();
|
||||
if line_count == 0 {
|
||||
ui.label("no hex data");
|
||||
@@ -17,7 +25,7 @@ pub fn render(ui: &mut Ui, buf: &HexBuffer, display: DisplayOptions) -> usize {
|
||||
for row in row_range {
|
||||
if let Some(line) = buf.get(row) {
|
||||
ui.add(
|
||||
Label::new(RichText::new(format_hex_line(line, display)).monospace())
|
||||
Label::new(format_hex_line(line, display, search, ui.style()))
|
||||
.wrap_mode(TextWrapMode::Extend),
|
||||
);
|
||||
}
|
||||
@@ -27,7 +35,59 @@ pub fn render(ui: &mut Ui, buf: &HexBuffer, display: DisplayOptions) -> usize {
|
||||
line_count
|
||||
}
|
||||
|
||||
fn format_hex_line(line: &HexLine, display: DisplayOptions) -> String {
|
||||
fn highlight_matches(
|
||||
text: &str,
|
||||
query: &str,
|
||||
case_sensitive: bool,
|
||||
default_format: TextFormat,
|
||||
highlight_format: TextFormat,
|
||||
) -> LayoutJob {
|
||||
let search_text = if case_sensitive {
|
||||
text.to_string()
|
||||
} else {
|
||||
text.to_lowercase()
|
||||
};
|
||||
let query = if case_sensitive {
|
||||
query.to_string()
|
||||
} else {
|
||||
query.to_lowercase()
|
||||
};
|
||||
let mut job = LayoutJob::default();
|
||||
let mut last_end = 0;
|
||||
let mut idx = 0;
|
||||
while let Some(pos) = search_text[idx..].find(&query) {
|
||||
let start = idx + pos;
|
||||
let end = start + query.len();
|
||||
if last_end < start {
|
||||
job.append(&text[last_end..start], 0.0, default_format.clone());
|
||||
}
|
||||
job.append(&text[start..end], 0.0, highlight_format.clone());
|
||||
last_end = end;
|
||||
idx = end;
|
||||
}
|
||||
if last_end < text.len() {
|
||||
job.append(&text[last_end..], 0.0, default_format);
|
||||
}
|
||||
job
|
||||
}
|
||||
|
||||
fn monospace_format(style: &egui::Style) -> TextFormat {
|
||||
TextFormat {
|
||||
font_id: style
|
||||
.text_styles
|
||||
.get(&TextStyle::Monospace)
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_hex_line(
|
||||
line: &HexLine,
|
||||
display: DisplayOptions,
|
||||
search: Option<&SearchState>,
|
||||
style: &egui::Style,
|
||||
) -> LayoutJob {
|
||||
let mut prefix = Vec::new();
|
||||
if display.show_timestamp {
|
||||
let ts = line.elapsed.as_secs_f64();
|
||||
@@ -53,5 +113,21 @@ fn format_hex_line(line: &HexLine, display: DisplayOptions) -> String {
|
||||
} else {
|
||||
format!("{} ", prefix.join(" "))
|
||||
};
|
||||
format!("{prefix}{} |{}|", line.hex, line.ascii)
|
||||
let full_text = format!("{prefix}{} |{}|", line.hex, line.ascii);
|
||||
|
||||
let default = monospace_format(style);
|
||||
let highlight = TextFormat {
|
||||
font_id: default.font_id.clone(),
|
||||
color: Color32::BLACK,
|
||||
background: Color32::from_rgb(255, 255, 0),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match search {
|
||||
Some(state) if state.active && !state.query.is_empty() => {
|
||||
highlight_matches(&full_text, &state.query, state.case_sensitive, default, highlight)
|
||||
}
|
||||
_ => LayoutJob::single_section(full_text, default),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ pub enum Action {
|
||||
|
||||
UiSettings,
|
||||
CloseOverlay,
|
||||
|
||||
Search,
|
||||
SearchNext,
|
||||
SearchPrev,
|
||||
}
|
||||
|
||||
pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
|
||||
@@ -50,6 +54,12 @@ pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
|
||||
CloseOverlay,
|
||||
KeyboardShortcut::new(Modifiers::NONE, Key::Escape),
|
||||
),
|
||||
(Search, KeyboardShortcut::new(Modifiers::CTRL, Key::F)),
|
||||
(SearchNext, KeyboardShortcut::new(Modifiers::NONE, Key::F3)),
|
||||
(
|
||||
SearchPrev,
|
||||
KeyboardShortcut::new(Modifiers::SHIFT, Key::F3),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -122,6 +132,9 @@ mod tests {
|
||||
Action::ToggleConnect,
|
||||
Action::Clear,
|
||||
Action::UiSettings,
|
||||
Action::Search,
|
||||
Action::SearchNext,
|
||||
Action::SearchPrev,
|
||||
] {
|
||||
assert!(is_char_based(action), "{action:?} should be char-based");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user