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:
2026-06-12 02:42:17 +08:00
parent 9cdbd5a892
commit afb8ca509a
6 changed files with 684 additions and 122 deletions

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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),
}
}

View File

@@ -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),
}
}

View File

@@ -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");
}